The controller watches completed JPEG uploads from a local camera, recognises plates, applies a fail-closed local policy, and activates a Pi relay. Recognition never waits for optional Cloudflare delivery, event delivery, or audio services. A cloud-managed plate snapshot remains usable only for its configured bounded staleness window, after which authorization fails closed.
- Exact normalised authorised plates may open the gate. How far a read may stray beyond that is a cloud-managed level on a schedule of local-time bands, so a site can run one rule by day and exact-only overnight. With no schedule configured the controller keeps its shipped behaviour: a fuzzy match needs two high-confidence frames, one recognised OCR-confusion substitution, and a single authorised candidate. See docs/plate-matching.md.
- An unreadable schedule, an unknown level, or an unloadable timezone fails closed to stricter matching, never to a wider one.
- OCR, parsing, network, cloud, and audio failures leave the gate closed.
- The relay drives a step-by-step input on a two-leaf swing gate: a pulse into an open or moving gate closes, stops or reverses it, and out-of-sequence leaves jam. Nothing may pulse a gate that is not provably shut, and nothing sends "recovery" pulses. See docs/gate-operator.md.
- A durable global activation marker is committed before GPIO is energized; optional event delivery remains off the recognition path.
- Remote commands reach a loopback-only command server through Cloudflare Tunnel and Access. They are short-lived, controller-bound, and use a persisted idempotency key. Expiry is checked again under the relay lock immediately before GPIO activation; an expired command never pulses the relay.
- The image processor and every configured background worker are supervised. Unexpected exit or fatal failure terminates the process so systemd restarts it.
- The browser never reaches the Pi, GPIO relay, camera, RTSP stream, or FTP service directly. Cloudflare Access protects the tunnelled command endpoint.
Python 3.10 or newer is required. The launcher and systemd unit reject older interpreters before the controller starts.
Production releases use an atomic managed layout under
/opt/gate-controller-deploy. Each immutable commit has its own virtual
environment and /opt/gate-controller-deploy/current points to the active
release. Persistent state and configuration remain outside that tree. Prepare
the root-readable environment file and configure the dedicated ftp-user
account first. The installer creates the controller accounts, persistent state
directory, and shared upload directory when they are absent:
sudo install -m 0600 -o root -g root .env.example /etc/gate-controller.envEdit /etc/gate-controller.env with real credentials and paths before running
the installer. It contains the Plate Recognizer token and the Cloudflare Access
service-token credentials when the Cloudflare control plane is enabled. Never
commit that file or put its values in a systemd unit.
Configure branch protection on master to require the three stable checks listed
in the deployment guide. Then bootstrap from a clean
checkout with the explicit update-enablement flag:
git clone https://github.com/ciaran-finnegan/gate-controller.git /tmp/gate-controller-bootstrap
cd /tmp/gate-controller-bootstrap
git checkout master
sudo deployment/install.sh --source "$PWD" --enable-updatesThe installer does not overwrite /etc/gate-controller.env, existing files
beneath /var/lib/gate-controller, or the legacy /opt/gate-controller
checkout. It configures state/upload directory ownership and copies the legacy
SQLite database only when the persistent destination is absent. The controller
service requires systemd-time-wait-sync.service to complete before image or
command handling starts, restarts failed processes after five seconds, limits
restart bursts, and can write only beneath /var/lib/gate-controller. Its
local camera upload directory must match GATE_WATCH_DIRECTORY.
Automatic deployment is outbound and pull-based. Every five minutes the Pi
checks the exact commit at the configured release branch (master by default) and
adopts it only after that SHA's complete
Gate Controller CI push workflow succeeds. Because that workflow has already
run the complete unit suite against the exact candidate commit, on-device
verification does not repeat it. It runs only what CI cannot: building the
release virtual environment, installing dependencies for this architecture,
importing the modules the service loads at startup, compiling sources, and
checking the
candidate's shell scripts. Set GATE_UPDATE_RUN_TESTS=1 to run the full suite on
the Pi as well; the suite refuses to run any command against a live stream, so
doing that can no longer starve the board the way it did on 2026-09-07.
Network, GitHub, rate-limit,
dependency, staging, or verification failures leave the running release
untouched; activation failures restore the previous managed symlink. After a
release is active the updater also brings the services published outside the
release tree up to it: gate-camera-control is re-published through its own
installer, and the media library, its MediaMTX configuration and its units are
copied and the media services restarted, each only when the files it is built
from actually changed (a digest marker beside each installed copy). A component
that fails to refresh leaves the controller release active, marks the run
failed, and is retried every cycle. Because the updater runs under
ProtectSystem=strict, a Pi whose updater unit predates this needs one
deployment/install.sh re-run to grant those paths; the updater names the
blocked path and that command until it happens. The root updater helper itself is refreshed
from each verified release; only the updater's own systemd unit and timer, the
MediaMTX binary, the rendered WHEP proxy configuration and the TURN credentials
remain bootstrap-owned, because they depend on arguments only the operator's
install command carries.
Tailscale is optional break-glass administration only and is not required for
gate operation or updates. See Raspberry Pi deployment
for migration, logs, retention, manual rollback, and optional private-token
configuration.
The bundled PiRelay.py adapter additionally needs Raspberry Pi GPIO support;
the conditional rpi-lgpio package provides its RPi.GPIO-compatible API on
supported Pi architectures, including Raspberry Pi 5. Do not install it in the
same virtual environment as the original RPi.GPIO package.
Install the relay board vendor library if a different relay adapter is used.
Cloudflare is the active remote-control path. Set
GATE_CLOUDFLARE_API_URL, GATE_CLOUDFLARE_ACCESS_CLIENT_ID,
GATE_CLOUDFLARE_ACCESS_CLIENT_SECRET, and GATE_CONTROLLER_ID together to
enable Cloudflare-backed plate refresh, status reporting, and event delivery.
The API URL is the HTTPS Worker origin; the client ID and secret are the
Cloudflare Access service token sent only from the controller. The controller ID
defaults to primary. Plate snapshots are atomically applied to the local cache;
recognition only reads that in-memory snapshot and never waits for a network
request. Plate lists change rarely, so a cloud outage does not stop the gate:
the last good snapshot stays in use for 14 days by default before recognition
fails closed. Set GATE_AUTHORISATION_MAX_STALENESS_SECONDS to shorten or
lengthen that window, or to 0 to keep the last snapshot indefinitely.
When the Worker is unreachable or failing, the controller logs each cloud path
as a transition rather than once per attempt: gate_cloud stage=heartbeat_failed
and stage=plates_refresh_failed on the first failure and then at most every
ten minutes, with a matching _recovered line carrying the failure count.
Queued event deliveries back off per item from 5 s doubling to a 5 minute cap,
and each cloud_send_failed line names the HTTP status the Worker returned.
GATE_PLATE_REGION=x,y,w,h (frame fractions) restricts capture and OCR to the
band of the picture where plates appear; gate_ocr plate_box= journal lines
record where each plate was read so the region can be tuned from data. See the
RLC-810A document for the decoder settings that keep a fanless Pi 5 cool.
A vehicle at the gate gets more than one chance: after the webhook capture
series, the controller keeps offering fresh keyframes for OCR while nothing
has decided the passage (GATE_PRESENCE_WINDOW_SECONDS,
GATE_PRESENCE_SPACING_SECONDS, GATE_PRESENCE_MAX_FRAMES), stops the moment
the gate opens — or when a plate is read confidently enough that nothing
authorised is anywhere near it, which is a different car rather than a
doubtful read — waits for a busy OCR slot instead of discarding a frame, and
retries a connect timeout once. A queued frame is dropped unread when a newer
frame of the same alarm arrives, when its own passage has already opened, or
when its remaining decision budget could not cover the lookup it would be
billed for (GATE_OCR_MIN_REQUEST_SECONDS). The on-device read runs on a
fast lane that never waits for a cloud answer, and a moving frame it found no
plate in is decided without a lookup at all
(GATE_OCR_CLOUD_SKIP_MOVING_STILLNESS). The RLC-810A document describes
the session and its journal lines.
GATE_TRAINING_CORPUS_DIR keeps every uploaded frame and OCR answer on the Pi
(bounded by GATE_TRAINING_CORPUS_MAX_BYTES) as training data for a local
recogniser.
The clear stream is held compressed while idle and decoded only for events
(GATE_CLEAR_STREAM_MODE, GATE_SESSION_FPS, GATE_SESSION_SECONDS); each
capture takes the stillest frame of the last second of the live session.
GATE_CLEAR_STREAM_SOURCE_FPS must equal the camera's main-stream frame rate,
currently 6 - the session decoder reads a pipe with no timestamps, so nothing
detects a mismatch.
Frames that show an empty drive are never sent to OCR (GATE_EMPTY_SCENE_THRESHOLD),
frames the decoder could not finish - mostly one flat colour - are skipped as
outcome=skipped_corrupt (GATE_TRIGGER_CAPTURE_MAX_FLAT_FRACTION),
blazed frames can be skipped once a limit is chosen (GATE_MAX_HIGHLIGHT_CLIPPING),
and a vehicle that was present without the gate opening is journaled as
gate_presence stage=unresolved.
Detailed local pipeline telemetry is retained for 30 days by default. Set
GATE_TELEMETRY_RETENTION_DAYS to an integer from 1 through 3650 to change the
window. python -m gate_controller telemetry-export produces a bounded JSON or
CSV diagnostic export from a database snapshot and never initializes the relay.
Remote commands are accepted only by the loopback command server hosted inside
the main controller process at
POST /commands, exposed through the authenticated Cloudflare Tunnel. Supported
commands are open_gate and play_prompt; fixed GATE_PROMPT_* settings map
prompt keys to local files, and request payloads cannot select arbitrary shell
commands or file paths.
Set GATE_OUTBOX_URL only for an authenticated server-side event endpoint and
set GATE_OUTBOX_BEARER_TOKEN to the secret it validates. Do not point this at
a browser route or expose the token to the web app. The versioned JSON contains
the local event ID, timestamps, source, decision, open state, plate match, and
confidence. Schema version 2 also persists the controller ID and, when evidence
is available, image_sha256. The same digest appears in the embedded JPEG
object. Each request sends an Idempotency-Key equal to the SHA-256 digest of
<controller_id>:<local_event_id>.
Events are first stored in SQLite, then retried in a background worker. A
failed delivery remains queued and does not delay image processing or relay
activation.
The Worker ingests events at /api/controller/events using the event
idempotency key. It must store any accepted evidence in private R2 under its
digest and apply the site-approved R2 lifecycle retention policy. The Pi only
removes its local evidence after the Worker returns a 2xx response; R2 retention
is owned by the Cloudflare deployment, not by the controller process.
Before an event is queued, its best-ranked JPEG is EXIF-normalised, converted
to RGB, bounded to 1280 pixels and 512KB, and atomically written as
event-evidence/<sha256>.jpg beside the SQLite database. The private spool never
stores a mutable camera path in the network payload. Retries verify and reuse
the exact queued bytes. Missing or corrupt expected evidence leaves the event
pending instead of substituting another image. After the receiver confirms a
2xx response, SQLite retains the digest and completion timestamp while the
spool file is unlinked once no other pending event references it. Startup also
removes completed leftovers from an interrupted cleanup. During a prolonged
receiver outage, pending evidence can grow by at most 512KB per distinct image;
monitor the state filesystem together with outbox depth.
The relay is pulsed at most once per gate cycle. After any relay pulse, plate
reads are recorded as grants but do not pulse again for
GATE_ACTUATION_COOLDOWN_SECONDS (default 90, longer than the measured
open-hold-close cycle), because the operator's input is step-by-step and a
second pulse closes or stops the gate. A person's open command from the app
uses the shorter GATE_COMMAND_COOLDOWN_SECONDS (default 20). See
docs/deployment.md, "Relay Cooldown".
Image work is bounded and freshness checked. Startup JPEGs older than
GATE_MAX_IMAGE_AGE_SECONDS, coalesced queue entries, OCR failures, no-plate
results, authorization errors, and worker exceptions are stored as denied/error
events where SQLite remains available. GATE_DECISION_TIMEOUT_SECONDS is the
overall event-decision budget, including frame ranking and content hashing; each
Plate Recognizer request also uses a shorter bounded timeout and exact matches
still stop the burst immediately. GATE_MAX_BURST_CANDIDATES defaults to 8 and
GATE_MAX_CANDIDATE_IMAGE_BYTES defaults to 8 MiB. Startup rejects values above
the hard safety ceilings of 16 candidates or 16 MiB per candidate. Within that
bounded set, the high-resolution FTP trigger remains the first OCR attempt. Up
to two continuously buffered fluent-stream fallbacks follow in quality order,
keeping all three inside the existing OCR attempt ceiling.
When the Reolink webhook listener is enabled, an eligible accepted camera
event may also schedule a delayed capture: the controller waits for the
vehicle to stop at the gate, then grabs a short series of frames from the
loopback clear stream and recognises each (GATE_TRIGGER_CAPTURE_*). Capture
is skipped when disabled, for manual_test events, inside the rate-limit
interval, or while a series is already queued. The webhook never authorises or
actuates; the frame takes the same path as an upload, and the FTP path stays
as the fallback. GATE_OCR_MAX_UPLOAD_WIDTH can downscale frames before the
OCR upload once the plate is large enough in the 4K image. With the local
reader active, GATE_LOCAL_SWEEP_ENABLED=true replaces that spaced series
with a sweep that reads every live session frame on the Pi for a bounded
window and hands the pipeline only a frame the reader already authorises, so
no sweep frame reaches the cloud; see
Local Sweep.
The controller can also read plates on the Pi itself, from the very bytes it
uploads, using two small MIT-licensed ONNX models. GATE_LOCAL_OCR_MODE=shadow
journals the local read beside the cloud read and can never reach the relay;
active lets a confident local read answer through the controller's own
matching - on the strength of that frame's own plate, under the same policy
band the processor will apply - with the cloud as the fallback. Its confidence
gate is the weakest character of the read, not the mean. The local read runs
off the single serial cloud OCR slot, so a 170 ms inference never queues
behind a 2.5 s cloud call for an older frame; and when both readers
independently read the same plate, the shared matching opens at a lower bar
than either would clear alone (GATE_MATCH_AGREEMENT_*, see
plate matching). It is off unless the
variable is set, and with it unset nothing is imported, loaded or started.
Enabling it on a controller that delivers to Cloudflare needs two app-side
changes first: local_ocr on the telemetry allowlist, and "local" accepted
as an event source. The models, the measured accuracy and latency, the
thermal limits of the fanless Pi, what the local guard may spend of the
decision budget, the journal lines and how to read a week of agreement are in
on-device plate recognition.
The Python entry point and production systemd unit both use a 200 ms completed-upload quiet window. This is calibrated from the latest ten production camera recognition events: each contained one 3840x2160 frame, with no second frame arriving inside the previous 500 ms window. The collector still coalesces and ranks completed frames inside 200 ms. Future camera settings that emit frames farther apart require fresh telemetry and calibration. CLI overrides must be finite and between 100 ms and 2 seconds.
Every frame becomes one event in the app's log, and the decision it took is not the same question as whether it worked the relay: a plate matched while the gate was already open is a grant whose actuation was skipped, not a denial. The wire fields, the reason table, and the one app-side change still needed (frames no reader saw cannot yet report a null confidence) are in gate event ingest.
The installed camera is an RLC-811A, fitted on 2026-09-11 in place of the RLC-810A on the same mount; the RLC-810A is the rollback unit. Base setup and night calibration are in RLC-810A deployment and night calibration; what differs for the fitted camera is in RLC-811A gate camera swap. The swap was done without re-registering the webhook, re-aiming, or redrawing the detection zone, and recognition stopped as a result; the evidence and the order of commissioning work are in reviews/2026-09-16-rlc-811a-first-week.md. Remote diagnosis from the developer Mac is described in deployment.md. The Pi performance harness is documented in Pi Cloudflare performance validation. It is intentionally deferred until reliable on-site network access is available.
Camera settings are changed by a separate isolated service,
gate-camera-control, which owns the only camera API credentials on the Pi and
exposes a bounded loopback HTTP surface for the IR illuminator and an on-demand
4K still. The controller gains no camera credentials from it; it only reads the
nonsecret state the service publishes. Its HTTP contract, environment file,
journal lines, security model, and rollback are in
Gate camera control. Every IR change is a bounded lease
that reverts to the configured default, because IR on at night degrades plate
recognition rather than helping it.
Make and colour returned by OCR are reserved for telemetry and operator review; they are intentionally not gate authorization factors. This service does not implement live video or two-way audio. It only exposes camera/prompt capability status and can play the two configured local WAV prompts. Camera status reports whether the upload directory is configured and ready separately from last-upload activity. It reports the camera connection as unprobed instead of inferring a connection failure from a quiet vehicle trigger. Relay status is based on measured initialization and actuation outcomes.
Run the unit suite with python3 -m unittest discover -s tests -v, compile with
python3 -m compileall gate_controller deployment tests, and validate shell
entry points with bash -n deployment/install.sh and sh -n file_monitor.sh.
The suite may not run a command against a live video stream. Every class on the
capture path takes a process factory (popen=) whose default is the real
subprocess.Popen, and every default source URL is the camera's 4K main stream,
so a test that forgets to inject a fake gets the live camera: nothing happens on
a laptop, and on the Pi it is an out-of-memory kill that stops the gate
answering webhooks. tests/stream_guard.py therefore fails any test whose
command mentions rtsp:// or the loopback media server, and is installed for
the whole suite under every way of invoking it. An integration test that
genuinely needs a real stream marks itself with
tests.stream_guard.allow_live_stream("why") and bounds its children in time,
frames and address space, as tests/test_clear_stream_ffmpeg.py does.