Opening the Garage With a Number Plate
A Raspberry Pi, a cheap ultrasonic sensor, and a UniFi camera that already knows my car
I am a cybersecurity engineer by trade. The garage door opener is a five-year-old steel box bolted to the ceiling of my garage and, typically, the only way to talk to it is through a pair of remotes that were, between them, almost never in the right car. The manufacturer sells a “smart” companion app that wants a subscription, a dedicated Wi-Fi bridge, and — I’m not making this up — an account on their cloud. It opens the door. It does only that, and does it roughly twice as slowly as the original remote.
I wanted three things instead:
- Hands-free entry when my car rolled up — and absolutely nobody else’s.
- A web UI I could open from any phone or laptop on the home network without installing a single thing.
- A safety net, because sooner or later I am bound to get distracted unloading the boot, and the door stays open for an hour.
The hardware budget was basically zero. A Raspberry Pi 3A+ had been living in the parts bin since the pandemic. I had an HC-SR04 ultrasonic sensor and a cheap relay module left over from another project. Add to it a 1N4001 diode a transistor (can’t remember what right now) and some basic soldering on a protoboard and - it is alive!!! The real unlock was the new UniFi G6 Bullet that I recently bought to monitor our driveway, which does licence-plate recognition server-side. If Protect is going to recognise my plate anyway, it might as well do something useful with the information.
Architecture at a glance
Driveway camera (UniFi Protect) ─────webhook─────┐
▼
┌────────────────────────────────────────────┐
Browser ──► │ Nginx :80/:443 ──► uvicorn :8080 (FastAPI) │
│ │ │
│ ├── GPIO (relay + HC-SR04)
│ ├── SQLite (garage.db)
│ └── MQTT (optional, Home Assistant)
└────────────────────────────────────────────┘
Nothing fancy. Nginx terminates TLS (self-signed, re-encrypted by a Kemp LoadMaster
upstream) and proxies both HTTP and WebSocket traffic. FastAPI + uvicorn does the
real work: GPIO, auth, websockets, LPR logic. SQLite holds three tables
(garage_events, lpr_events, authorized_plates) plus an admin user row. MQTT
is strictly optional — if Mosquitto isn’t running the app shrugs and carries on.
How the number-plate flow actually works
UniFi Protect fires an Alarm Manager rule when it sees a known vehicle. The action is a Custom Webhook — a plain GET with the plate in the query string:
GET http://<raspberry pi's IP>/api/lpr/unifi-webhook?plate=ABC123
The handler calls into handle_lpr_detection, which is where all the interesting
decisions happen:
1async def handle_lpr_detection(self, plate_number):
2 plate_number = normalize_plate(plate_number)
3
4 if not self.is_plate_authorized(plate_number):
5 self.record_lpr_event(plate_number, "rejected",
6 self.current_status, None, False)
7 return {"status": "unauthorized"}
8
9 # Cancel any pending auto-close — a new detection supersedes the old one
10 if self.pending_close_task and not self.pending_close_task.done():
11 self.pending_close_task.cancel()
12
13 if self.current_status == "Closed":
14 self.toggle_garage() # open immediately
15 self.record_lpr_event(plate_number, "open_immediate",
16 "Closed", "Open", True)
17 return {"status": "opened"}
18
19 if self.current_status == "Open":
20 # 60-second countdown, cancellable
21 self.pending_close_task = asyncio.create_task(
22 self.auto_close_with_countdown(plate_number,
23 self.LPR_CLOSE_SECONDS))
24 return {"status": "close_scheduled"}
Three states, three outcomes:
| Door state | Plate authorised? | What happens |
|---|---|---|
| Closed | ✅ | Opens immediately |
| Open | ✅ | 60 s auto-close countdown (cancellable) |
| Any | ❌ | Event logged, door does nothing |
That’s the whole trick. The rest of the code is just making sure each of those three outcomes is actually reliable.
Design considerations worth writing down
1. Normalise plates before you store or compare them
Plate OCR can be noisy. Protect may report ABC-123 one day, ABC 123 the next, and
abc123 the time it was raining. Any comparison against the authorised list has
to be post-normalisation, otherwise the first rejection event you get will be
your own car:
1def normalize_plate(plate):
2 return plate.replace("-", "").replace(" ", "").upper()
Applied at three points: the web UI form, the API insert, and the incoming webhook.
If normalisation only lives in one of those paths you will end up with ABC 123
and ABC123 as two separate rows and spend an afternoon working out why your
partner’s car works and yours doesn’t.
2. Ultrasonic sensors lie
The HC-SR04 returns distance in centimetres. The garage door is either ~5 cm from
the sensor (closed, because the sensor is bolted to the ceiling pointing at the
rail) or ~3 m away (open). Easy — except when the pulse times out (returns None),
or when one of the cats walks underneath at exactly the wrong moment, or when the
neighbourhood possums start an argument on top of the carport.
The compromise is a dedicated monitor thread polling once per second, with a crude threshold at 10 cm:
1def get_garage_status(self):
2 distance = self.get_distance()
3 if distance is None:
4 return "Error: Sensor timeout"
5 return "Closed" if distance > 10 else "Open"
6
7def monitor_status(self):
8 while True:
9 new_status = self.get_garage_status()
10 if new_status != self.current_status:
11 # persist, broadcast, start/cancel safety timer
12 asyncio.run(self.notify_clients(new_status))
13 time.sleep(1)
Only transitions get persisted and broadcast. Without that, every WebSocket client gets hammered with 60 identical messages per minute, which — as I learnt — is enough to make mobile Safari sulk and silently drop the connection.
3. Crossing the thread/asyncio boundary carefully
The sensor poller runs in a plain threading.Thread because it’s blocking
(time.sleep(1) forever). But the WebSocket broadcast and the safety-timer
coroutine both need to be on the main event loop. This caught me out twice, both
times spectacularly:
1@app.on_event("startup")
2async def startup_event():
3 controller.main_loop = asyncio.get_event_loop()
4
5def start_safety_timer(self):
6 ...
7 future = asyncio.run_coroutine_threadsafe(
8 self.safety_auto_close_timer(), self.main_loop)
Grab a reference to the main loop at startup. Use run_coroutine_threadsafe to
schedule work on it from the sensor thread. The wrong way — spinning up a new
loop inside the thread — silently stops working the first time a WebSocket client
disconnects, and the door fails to auto-close, and you find out three hours later
when your phone buzzes with a “door has been open for 180 minutes” notification.
Ask me how I know.
4. Two layers of auto-close, and one kill switch
There are two timers, and they do different jobs:
- LPR auto-close (60 s): fires when an authorised plate is seen while the door is already open. The intent is “you just pulled out of the driveway, please close up behind me”. Cancellable from the web UI if you change your mind.
- Safety auto-close (10 min): fires unconditionally whenever the door has been open for N minutes, unless Hold Open is on. This is the “I got distracted unloading groceries” net.
Hold Open is the kill switch for both, and it exists specifically because anything that auto-closes a garage door will, at some point, try to close it while you’re holding the last of the Bunnings trolley underneath it. Hold Open disables every timer until you explicitly turn it off, and the UI makes it very obvious that it’s active.
5. JWT + bcrypt + rate-limiting, because this faces the internet
The app is theoretically world-reachable. Even behind Cloudflare and a LoadMaster, the toggle endpoint opens my garage. So:
- Passwords are stored as bcrypt hashes. Never plaintext.
- Login returns a 2-hour JWT, signed with a secret from
GARAGE_JWT_SECRET. POST /api/toggle,POST /api/lpr/plates,DELETE /api/lpr/plates/{n}, andPOST /api/settingsall require a bearer token.- Login is rate-limited — 5 attempts per 60 s per username — to blunt credential stuffing.
- The plate list is masked when unauthenticated:
BDO-123comes back asB*****3. The endpoint still works for health checks, but you can’t enumerate the allowlist from the open web.
The webhook endpoint itself is not behind auth, because UniFi can’t carry a bearer token on a webhook. That’s mitigated by network position: the endpoint is only reachable from inside the LAN, and the only thing it does with a plate is look it up in the allowlist. An attacker who finds it still can’t open the door unless they can already guess a plate that’s authorised, and if they can guess my plate number they probably don’t need the webhook either.
6. WebSockets make the countdown feel right
When the 60-second LPR countdown is ticking, the UI shows “closing in 47 s — cancel?” and updates live. You could poll, but polling feels laggy in a way that matters for something with a physical consequence at the end of it. Every ten seconds the countdown coroutine pushes an update:
1while self.close_countdown > 0:
2 await self.broadcast_lpr_status({
3 "action": "countdown",
4 "plate": plate_number,
5 "seconds_remaining": self.close_countdown
6 })
7 wait_time = min(10, self.close_countdown)
8 await asyncio.sleep(wait_time)
9 self.close_countdown -= wait_time
The React frontend picks this up, renders a ring, and shows a big Cancel button.
Tap Cancel → asyncio.CancelledError fires inside auto_close_with_countdown,
the except branch broadcasts "action": "cancelled", and every connected
browser updates at once. The first time that worked across two phones and a
laptop simultaneously, without any of them reloading, was one of the nicer
moments of the build.
7. One process, one SQLite file
The first deployment had both the legacy uWSGI service and the new uvicorn
process running, both writing to garage.db. SQLite locked up under load and
the logs filled with OperationalError: database is locked. The fix was to
stop and disable the old service and run exactly one uvicorn. Obvious in
hindsight, not obvious at 11pm on a Sunday. The README now calls this out in
bold, which is as close to a lesson as I’ve managed to bake into the repo.
8. UTC in, local out
Timestamps go into SQLite as UTC (DATETIME DEFAULT CURRENT_TIMESTAMP), and the
browser formats them in en-AU. No ambiguity over daylight savings, and anyone
who clones this for another timezone only has to touch the frontend. This is a
small detail and I still manage to get it wrong in roughly half the apps I
write. Not this one. This time I wrote it down.
What didn’t make the cut
- Per-plate cooldowns. I wrote the scaffolding —
is_plate_in_cooldownis still there, returningFalse— and then realised Hold Open plus the 60-second LPR auto-close already covered every real scenario I cared about. Left the stub in place so the hook point is obvious if I ever need it. - Face recognition / NFC / Bluetooth proximity. All fun, none of them add anything LPR doesn’t already do, and each of them adds a new failure mode.
- Home Assistant as the source of truth. The MQTT topics are published because it’s cheap to do, but the Pi stays authoritative. If Hass falls over during one of my endless homelab reshuffles, the garage still opens. This is a policy I’ve come to apply to every piece of the homelab that my family interacts with: degrade gracefully, never take a dependency on the experimental stack.
The feel of it
The best moment of this whole build was the first time the wife pulled up into driveway at dusk, after a busy day at work and the garage door just magically opened! Protect saw the plate, the webhook fired, the relay clicked, the door started moving, and by the time she’d parked and grabbed her bag it was closing itself with the 60-second countdown ticking politely on my phone. No app, no remote, no thinking.
A thing I did not expect about building little pieces of home infrastructure like this: after a while, you stop noticing them. Which is, honestly, the highest compliment I can pay my own work. The point of a good bit of household automation isn’t that it wows visitors; it’s that you forget it exists until something newer in the rack catches fire and reminds you the garage is still, quietly, doing its job - now with LPR.
The code is at github.com/rdapaz/garageController if you want to steal any of it. The UniFi Protect side is the only genuinely fiddly part and there’s a setup guide in the repo — for the Alarm Manager rule. If you’re new to homelab projects and want somewhere to start, something like this is a decent pick: small, self-contained, physically satisfying, and if it breaks you can still open the door with the twenty-year-old plastic remote.