Skip to content

Latest commit

 

History

205 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vehicle Gate Controller

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.

Safety Model

  • 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.

Install And Update

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.env

Edit /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-updates

The 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 Control Plane

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.

Camera Deployment

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.

Verification

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.

About

Licence Plate Recognition and Gate Opening Relay Control for Raspberry Pi

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages