[{"content":"\u003cp\u003eI am a cybersecurity engineer by trade. The garage door opener is a five-year-old\nsteel box bolted to the ceiling of my garage and, typically, the only way to talk\nto it is through a pair of remotes that were, between them, almost never in the right\ncar. The manufacturer sells a \u0026ldquo;smart\u0026rdquo; companion app that wants a subscription, a\ndedicated Wi-Fi bridge, and — I\u0026rsquo;m not making this up — an account on their cloud. It\nopens the door. It does \u003cem\u003eonly\u003c/em\u003e that, and does it roughly twice as slowly as the\noriginal remote.\u003c/p\u003e\n\u003cp\u003eI wanted three things instead:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eHands-free entry\u003c/strong\u003e when \u003cem\u003emy\u003c/em\u003e car rolled up — and absolutely nobody else\u0026rsquo;s.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA web UI\u003c/strong\u003e I could open from any phone or laptop on the home network without\ninstalling a single thing.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA safety net\u003c/strong\u003e, because sooner or later I am bound to get distracted\nunloading the boot, and the door stays open for an hour.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eThe hardware budget was basically zero. A Raspberry Pi 3A+ had been living in the\nparts bin since the pandemic. I had an HC-SR04 ultrasonic sensor and a cheap relay\nmodule left over from another project. Add to it a 1N4001 diode a transistor (can\u0026rsquo;t remember what right now) and some\nbasic soldering on a protoboard and - it is alive!!! The real unlock was the new \u003cstrong\u003eUniFi G6 Bullet\u003c/strong\u003e\nthat I recently bought to monitor our driveway, which does licence-plate recognition server-side. If\nProtect is going to recognise my plate anyway, it might as well do something useful\nwith the information.\u003c/p\u003e\n\u003ch2 id=\"architecture-at-a-glance\"\u003eArchitecture at a glance\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eDriveway camera (UniFi Protect) ─────webhook─────┐\n                                                  ▼\n            ┌────────────────────────────────────────────┐\nBrowser ──► │ Nginx :80/:443 ──► uvicorn :8080 (FastAPI) │\n            │                        │                   │\n            │                        ├── GPIO (relay + HC-SR04)\n            │                        ├── SQLite (garage.db)\n            │                        └── MQTT (optional, Home Assistant)\n            └────────────────────────────────────────────┘\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eNothing fancy. Nginx terminates TLS (self-signed, re-encrypted by a Kemp LoadMaster\nupstream) and proxies both HTTP and WebSocket traffic. FastAPI + uvicorn does the\nreal work: GPIO, auth, websockets, LPR logic. SQLite holds three tables\n(\u003ccode\u003egarage_events\u003c/code\u003e, \u003ccode\u003elpr_events\u003c/code\u003e, \u003ccode\u003eauthorized_plates\u003c/code\u003e) plus an admin user row. MQTT\nis strictly optional — if Mosquitto isn\u0026rsquo;t running the app shrugs and carries on.\u003c/p\u003e\n\u003ch2 id=\"how-the-number-plate-flow-actually-works\"\u003eHow the number-plate flow actually works\u003c/h2\u003e\n\u003cp\u003eUniFi Protect fires an Alarm Manager rule when it sees a known vehicle. The action\nis a \u003cstrong\u003eCustom Webhook\u003c/strong\u003e — a plain GET with the plate in the query string:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eGET http://\u0026lt;raspberry pi\u0026#39;s IP\u0026gt;/api/lpr/unifi-webhook?plate=ABC123\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eThe handler calls into \u003ccode\u003ehandle_lpr_detection\u003c/code\u003e, which is where all the interesting\ndecisions happen:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"nf\"\u003ehandle_lpr_detection\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eplate_number\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 2\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eplate_number\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003enormalize_plate\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eplate_number\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 3\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 4\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"ow\"\u003enot\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eis_plate_authorized\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eplate_number\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 5\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erecord_lpr_event\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eplate_number\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;rejected\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 6\u003c/span\u003e\u003cspan class=\"cl\"\u003e                              \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrent_status\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kc\"\u003eNone\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kc\"\u003eFalse\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 7\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;status\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;unauthorized\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 8\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 9\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e# Cancel any pending auto-close — a new detection supersedes the old one\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e10\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epending_close_task\u003c/span\u003e \u003cspan class=\"ow\"\u003eand\u003c/span\u003e \u003cspan class=\"ow\"\u003enot\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epending_close_task\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003edone\u003c/span\u003e\u003cspan class=\"p\"\u003e():\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e11\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epending_close_task\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecancel\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e12\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e13\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrent_status\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Closed\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e14\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etoggle_garage\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e                        \u003cspan class=\"c1\"\u003e# open immediately\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e15\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erecord_lpr_event\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eplate_number\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;open_immediate\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e16\u003c/span\u003e\u003cspan class=\"cl\"\u003e                              \u003cspan class=\"s2\"\u003e\u0026#34;Closed\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Open\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kc\"\u003eTrue\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e17\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;status\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;opened\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e18\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e19\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrent_status\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Open\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e20\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"c1\"\u003e# 60-second countdown, cancellable\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e21\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epending_close_task\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003easyncio\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecreate_task\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e22\u003c/span\u003e\u003cspan class=\"cl\"\u003e            \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eauto_close_with_countdown\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eplate_number\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e23\u003c/span\u003e\u003cspan class=\"cl\"\u003e                                           \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eLPR_CLOSE_SECONDS\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e24\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;status\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;close_scheduled\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThree states, three outcomes:\u003c/p\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eDoor state\u003c/th\u003e\n\u003cth\u003ePlate authorised?\u003c/th\u003e\n\u003cth\u003eWhat happens\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eClosed\u003c/td\u003e\n\u003ctd\u003e✅\u003c/td\u003e\n\u003ctd\u003eOpens immediately\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eOpen\u003c/td\u003e\n\u003ctd\u003e✅\u003c/td\u003e\n\u003ctd\u003e60 s auto-close countdown (cancellable)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eAny\u003c/td\u003e\n\u003ctd\u003e❌\u003c/td\u003e\n\u003ctd\u003eEvent logged, door does nothing\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eThat\u0026rsquo;s the whole trick. The rest of the code is just making sure each of those\nthree outcomes is actually reliable.\u003c/p\u003e\n\u003ch2 id=\"design-considerations-worth-writing-down\"\u003eDesign considerations worth writing down\u003c/h2\u003e\n\u003ch3 id=\"1-normalise-plates-before-you-store-or-compare-them\"\u003e1. Normalise plates before you store or compare them\u003c/h3\u003e\n\u003cp\u003ePlate OCR can be noisy. Protect may report \u003ccode\u003eABC-123\u003c/code\u003e one day, \u003ccode\u003eABC 123\u003c/code\u003e the next, and\n\u003ccode\u003eabc123\u003c/code\u003e the time it was raining. Any comparison against the authorised list has\nto be post-normalisation, otherwise the first rejection event you get will be\n\u003cem\u003eyour own car\u003c/em\u003e:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"nf\"\u003enormalize_plate\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eplate\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003eplate\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ereplace\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;-\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ereplace\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34; \u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eupper\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eApplied at three points: the web UI form, the API insert, and the incoming webhook.\nIf normalisation only lives in one of those paths you \u003cem\u003ewill\u003c/em\u003e end up with \u003ccode\u003eABC 123\u003c/code\u003e\nand \u003ccode\u003eABC123\u003c/code\u003e as two separate rows and spend an afternoon working out why your\npartner\u0026rsquo;s car works and yours doesn\u0026rsquo;t.\u003c/p\u003e\n\u003ch3 id=\"2-ultrasonic-sensors-lie\"\u003e2. Ultrasonic sensors lie\u003c/h3\u003e\n\u003cp\u003eThe HC-SR04 returns distance in centimetres. The garage door is either ~5 cm from\nthe sensor (closed, because the sensor is bolted to the ceiling pointing at the\nrail) or ~3 m away (open). Easy — except when the pulse times out (returns \u003ccode\u003eNone\u003c/code\u003e),\nor when one of the cats walks underneath at exactly the wrong moment, or when the\nneighbourhood possums start an argument on top of the carport.\u003c/p\u003e\n\u003cp\u003eThe compromise is a dedicated monitor thread polling once per second, with a crude\nthreshold at 10 cm:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"nf\"\u003eget_garage_status\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 2\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003edistance\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eget_distance\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003edistance\u003c/span\u003e \u003cspan class=\"ow\"\u003eis\u003c/span\u003e \u003cspan class=\"kc\"\u003eNone\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 4\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Error: Sensor timeout\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 5\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Closed\u0026#34;\u003c/span\u003e \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003edistance\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"mi\"\u003e10\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Open\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 6\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 7\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"nf\"\u003emonitor_status\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 8\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ewhile\u003c/span\u003e \u003cspan class=\"kc\"\u003eTrue\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 9\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003enew_status\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eget_garage_status\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e10\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003enew_status\u003c/span\u003e \u003cspan class=\"o\"\u003e!=\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrent_status\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e11\u003c/span\u003e\u003cspan class=\"cl\"\u003e            \u003cspan class=\"c1\"\u003e# persist, broadcast, start/cancel safety timer\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e12\u003c/span\u003e\u003cspan class=\"cl\"\u003e            \u003cspan class=\"n\"\u003easyncio\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erun\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003enotify_clients\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003enew_status\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e13\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003etime\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esleep\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eOnly \u003cem\u003etransitions\u003c/em\u003e get persisted and broadcast. Without that, every WebSocket client\ngets hammered with 60 identical messages per minute, which — as I learnt — is enough\nto make mobile Safari sulk and silently drop the connection.\u003c/p\u003e\n\u003ch3 id=\"3-crossing-the-threadasyncio-boundary-carefully\"\u003e3. Crossing the thread/asyncio boundary carefully\u003c/h3\u003e\n\u003cp\u003eThe sensor poller runs in a plain \u003ccode\u003ethreading.Thread\u003c/code\u003e because it\u0026rsquo;s blocking\n(\u003ccode\u003etime.sleep(1)\u003c/code\u003e forever). But the WebSocket broadcast and the safety-timer\ncoroutine both need to be on the main event loop. This caught me out twice, both\ntimes spectacularly:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nd\"\u003e@app.on_event\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;startup\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"nf\"\u003estartup_event\u003c/span\u003e\u003cspan class=\"p\"\u003e():\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003econtroller\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emain_loop\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003easyncio\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eget_event_loop\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e5\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"nf\"\u003estart_safety_timer\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e6\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"o\"\u003e...\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e7\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003efuture\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003easyncio\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erun_coroutine_threadsafe\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e8\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esafety_auto_close_timer\u003c/span\u003e\u003cspan class=\"p\"\u003e(),\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emain_loop\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eGrab a reference to the main loop at startup. Use \u003ccode\u003erun_coroutine_threadsafe\u003c/code\u003e to\nschedule work on it from the sensor thread. The \u003cem\u003ewrong\u003c/em\u003e way — spinning up a new\nloop inside the thread — silently stops working the first time a WebSocket client\ndisconnects, and the door fails to auto-close, and you find out three hours later\nwhen your phone buzzes with a \u0026ldquo;door has been open for 180 minutes\u0026rdquo; notification.\u003c/p\u003e\n\u003cp\u003eAsk me how I know.\u003c/p\u003e\n\u003ch3 id=\"4-two-layers-of-auto-close-and-one-kill-switch\"\u003e4. Two layers of auto-close, and one kill switch\u003c/h3\u003e\n\u003cp\u003eThere are two timers, and they do different jobs:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eLPR auto-close (60 s)\u003c/strong\u003e: fires when an authorised plate is seen while the door\nis already open. The intent is \u0026ldquo;you just pulled out of the driveway, please close\nup behind me\u0026rdquo;. Cancellable from the web UI if you change your mind.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSafety auto-close (10 min)\u003c/strong\u003e: fires unconditionally whenever the door has been\nopen for N minutes, unless Hold Open is on. This is the \u0026ldquo;I got distracted\nunloading groceries\u0026rdquo; net.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eHold Open\u003c/strong\u003e is the kill switch for both, and it exists specifically because\nanything that auto-closes a garage door will, at some point, try to close it while\nyou\u0026rsquo;re holding the last of the Bunnings trolley underneath it. Hold Open disables\nevery timer until you explicitly turn it off, and the UI makes it very obvious\nthat it\u0026rsquo;s active.\u003c/p\u003e\n\u003ch3 id=\"5-jwt--bcrypt--rate-limiting-because-this-faces-the-internet\"\u003e5. JWT + bcrypt + rate-limiting, because this faces the internet\u003c/h3\u003e\n\u003cp\u003eThe app is theoretically world-reachable. Even behind Cloudflare and a LoadMaster,\nthe toggle endpoint \u003cem\u003eopens my garage\u003c/em\u003e. So:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003ePasswords are stored as bcrypt hashes. Never plaintext.\u003c/li\u003e\n\u003cli\u003eLogin returns a 2-hour JWT, signed with a secret from \u003ccode\u003eGARAGE_JWT_SECRET\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ePOST /api/toggle\u003c/code\u003e, \u003ccode\u003ePOST /api/lpr/plates\u003c/code\u003e, \u003ccode\u003eDELETE /api/lpr/plates/{n}\u003c/code\u003e, and\n\u003ccode\u003ePOST /api/settings\u003c/code\u003e all require a bearer token.\u003c/li\u003e\n\u003cli\u003eLogin is rate-limited — 5 attempts per 60 s per username — to blunt credential\nstuffing.\u003c/li\u003e\n\u003cli\u003eThe plate list is \u003cstrong\u003emasked when unauthenticated\u003c/strong\u003e: \u003ccode\u003eBDO-123\u003c/code\u003e comes back as\n\u003ccode\u003eB*****3\u003c/code\u003e. The endpoint still works for health checks, but you can\u0026rsquo;t enumerate\nthe allowlist from the open web.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe webhook endpoint itself is \u003cem\u003enot\u003c/em\u003e behind auth, because UniFi can\u0026rsquo;t carry a\nbearer token on a webhook. That\u0026rsquo;s mitigated by network position: the endpoint is\nonly reachable from inside the LAN, and the only thing it does with a plate is\nlook it up in the allowlist. An attacker who finds it still can\u0026rsquo;t open the door\nunless they can already guess a plate that\u0026rsquo;s authorised, and if they can guess my\nplate number they probably don\u0026rsquo;t need the webhook either.\u003c/p\u003e\n\u003ch3 id=\"6-websockets-make-the-countdown-feel-right\"\u003e6. WebSockets make the countdown feel right\u003c/h3\u003e\n\u003cp\u003eWhen the 60-second LPR countdown is ticking, the UI shows \u0026ldquo;closing in 47 s —\ncancel?\u0026rdquo; and updates live. You could poll, but polling feels \u003cem\u003elaggy\u003c/em\u003e in a way\nthat matters for something with a physical consequence at the end of it. Every\nten seconds the countdown coroutine pushes an update:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003ewhile\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eclose_countdown\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebroadcast_lpr_status\u003c/span\u003e\u003cspan class=\"p\"\u003e({\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"s2\"\u003e\u0026#34;action\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;countdown\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"s2\"\u003e\u0026#34;plate\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eplate_number\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e5\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"s2\"\u003e\u0026#34;seconds_remaining\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eclose_countdown\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e6\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e})\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e7\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003ewait_time\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nb\"\u003emin\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e10\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eclose_countdown\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e8\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003easyncio\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esleep\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ewait_time\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e9\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eclose_countdown\u003c/span\u003e \u003cspan class=\"o\"\u003e-=\u003c/span\u003e \u003cspan class=\"n\"\u003ewait_time\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe React frontend picks this up, renders a ring, and shows a big Cancel button.\nTap Cancel → \u003ccode\u003easyncio.CancelledError\u003c/code\u003e fires inside \u003ccode\u003eauto_close_with_countdown\u003c/code\u003e,\nthe \u003ccode\u003eexcept\u003c/code\u003e branch broadcasts \u003ccode\u003e\u0026quot;action\u0026quot;: \u0026quot;cancelled\u0026quot;\u003c/code\u003e, and every connected\nbrowser updates at once. The first time that worked across two phones and a\nlaptop simultaneously, without any of them reloading, was one of the nicer\nmoments of the build.\u003c/p\u003e\n\u003ch3 id=\"7-one-process-one-sqlite-file\"\u003e7. One process, one SQLite file\u003c/h3\u003e\n\u003cp\u003eThe first deployment had both the legacy uWSGI service \u003cem\u003eand\u003c/em\u003e the new uvicorn\nprocess running, both writing to \u003ccode\u003egarage.db\u003c/code\u003e. SQLite locked up under load and\nthe logs filled with \u003ccode\u003eOperationalError: database is locked\u003c/code\u003e. The fix was to\nstop and disable the old service and run exactly one uvicorn. Obvious in\nhindsight, not obvious at 11pm on a Sunday. The README now calls this out in\nbold, which is as close to a lesson as I\u0026rsquo;ve managed to bake into the repo.\u003c/p\u003e\n\u003ch3 id=\"8-utc-in-local-out\"\u003e8. UTC in, local out\u003c/h3\u003e\n\u003cp\u003eTimestamps go into SQLite as UTC (\u003ccode\u003eDATETIME DEFAULT CURRENT_TIMESTAMP\u003c/code\u003e), and the\nbrowser formats them in \u003ccode\u003een-AU\u003c/code\u003e. No ambiguity over daylight savings, and anyone\nwho clones this for another timezone only has to touch the frontend. This is a\nsmall detail and I still manage to get it wrong in roughly half the apps I\nwrite. Not this one. This time I wrote it down.\u003c/p\u003e\n\u003ch2 id=\"what-didnt-make-the-cut\"\u003eWhat didn\u0026rsquo;t make the cut\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003ePer-plate cooldowns.\u003c/strong\u003e I wrote the scaffolding — \u003ccode\u003eis_plate_in_cooldown\u003c/code\u003e is\nstill there, returning \u003ccode\u003eFalse\u003c/code\u003e — and then realised Hold Open plus the 60-second\nLPR auto-close already covered every real scenario I cared about. Left the\nstub in place so the hook point is obvious if I ever need it.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFace recognition / NFC / Bluetooth proximity.\u003c/strong\u003e All fun, none of them add\nanything LPR doesn\u0026rsquo;t already do, and each of them adds a new failure mode.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eHome Assistant as the source of truth.\u003c/strong\u003e The MQTT topics are published\nbecause it\u0026rsquo;s cheap to do, but the Pi stays authoritative. If Hass falls over\nduring one of my endless homelab reshuffles, the garage still opens. This is\na policy I\u0026rsquo;ve come to apply to every piece of the homelab that my family\ninteracts with: degrade gracefully, never take a dependency on the experimental\nstack.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"the-feel-of-it\"\u003eThe feel of it\u003c/h2\u003e\n\u003cp\u003eThe best moment of this whole build was the first time the wife pulled up into driveway at dusk,\nafter a busy day at work and the garage door just magically opened!\nProtect saw the plate, the webhook fired, the relay clicked, the door started\nmoving, and by the time she\u0026rsquo;d parked and grabbed her bag it was closing itself with\nthe 60-second countdown ticking politely on my phone. No app, no remote, no\nthinking.\u003c/p\u003e\n\u003cp\u003eA thing I did not expect about building little pieces of home infrastructure like\nthis: after a while, you stop noticing them. Which is, honestly, the highest\ncompliment I can pay my own work. The point of a good bit of household automation\nisn\u0026rsquo;t that it wows visitors; it\u0026rsquo;s that you forget it exists until something newer\nin the rack catches fire and reminds you the garage is still, quietly, doing its\njob - now with LPR.\u003c/p\u003e\n\u003cp\u003eThe code is at \u003cstrong\u003e\u003ca href=\"https://github.com/rdapaz/garageController\"\u003egithub.com/rdapaz/garageController\u003c/a\u003e\u003c/strong\u003e\nif you want to steal any of it. The UniFi Protect side is the only genuinely\nfiddly part and there\u0026rsquo;s a setup guide in the repo —\nfor the Alarm Manager rule. If you\u0026rsquo;re new to homelab projects and want somewhere\nto start, something like this is a decent pick: small, self-contained, physically\nsatisfying, and if it breaks you can still open the door with the twenty-year-old\nplastic remote.\u003c/p\u003e\n","date":"2026-04-18","dateFormatted":"2026.04.18","excerpt":"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 …","featured":true,"mood":"🚗 rolling home slowly","permalink":"https://blog.ricdeez.com/posts/opening-the-garage-with-a-number-plate/","readingTime":10,"slug":"opening-the-garage-with-a-number-plate","subtitle":"A Raspberry Pi, a cheap ultrasonic sensor, and a UniFi camera that already knows my car","summary":"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 \u0026ldquo;smart\u0026rdquo; companion app that wants a subscription, a dedicated Wi-Fi bridge, and — I\u0026rsquo;m not making this up — an account on their cloud.","tags":["raspberry-pi","home-automation","fastapi","unifi","lpr","homelab"],"title":"Opening the Garage With a Number Plate","url":"https://blog.ricdeez.com/posts/opening-the-garage-with-a-number-plate/","wordCount":2005},{"content":"\u003cp\u003eI am a cybersecurity engineer by trade. I spend my days worrying about PLCs, network\nsegmentation, pondering when SOCI will require us to meet MIL2 or SP2 and worrying about\nhow many firewalls we need to have between our corporate network and the DCS (hint: lots!).\nThe closest my day job has come to language design lately is helping a fellow engineer\nwork out a regex for a cribl route filter.\u003c/p\u003e\n\u003cp\u003eAnd yet, for the last several months I\u0026rsquo;ve been steadily working my way through a short\nbut formidable reading list:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003cem\u003eWriting a C Compiler\u003c/em\u003e\u003c/strong\u003e — Nora Sandler\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003cem\u003eCrafting Interpreters\u003c/em\u003e\u003c/strong\u003e — Robert Nystrom\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003cem\u003eWriting an Interpreter in Go\u003c/em\u003e\u003c/strong\u003e — Thorsten Ball\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003cem\u003eWriting a Compiler in Go\u003c/em\u003e\u003c/strong\u003e — Thorsten Ball\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThese are the books. If you\u0026rsquo;ve poked around this corner of the internet, you already know\nthem. Sandler takes you from \u003ccode\u003ereturn 2;\u003c/code\u003e to a genuine x86-64 compiler in C, one\nexcruciatingly well-argued chapter at a time. Nystrom builds two complete implementations\nof the same small language (\u0026ldquo;Lox\u0026rdquo;): a tree-walking interpreter and a bytecode VM, and\nsomehow makes both feel like the most natural thing in the world. Ball\u0026rsquo;s two \u0026ldquo;Writing an\nInterpreter/Compiler in Go\u0026rdquo; books are the Go-flavoured siblings — shorter, tighter, great\nfor a weekend each.\u003c/p\u003e\n\u003cp\u003eI don\u0026rsquo;t think there\u0026rsquo;s a better curriculum for self-teaching compilers out there. Each\nbook scratches a different itch, and together they give you the full-stack tour:\nlexical analysis, parsing, ASTs, tree-walking evaluation, bytecode, stack machines,\nregister allocation, the lot.\u003c/p\u003e\n\u003cp\u003eReading those books put me in that dangerous headspace where everything you look at\nstarts to resemble a tokenizer.\u003c/p\u003e\n\u003ch2 id=\"so-i-built-a-thing\"\u003eSo I built a thing\u003c/h2\u003e\n\u003cp\u003eLet me say the important part up front: \u003cstrong\u003ewhat I\u0026rsquo;m about to describe is a learning\nexercise, not a language I\u0026rsquo;m promoting.\u003c/strong\u003e The world does not need another language.\nThe world, arguably, needs fewer languages. I built this to find out, concretely and\nwith my own hands, which of the ideas in those books I actually understood versus just\nthought I understood. Nothing sharpens the distinction like trying to generate working\ncode from an AST at one in the morning.\u003c/p\u003e\n\u003cp\u003eThe project is called \u003cstrong\u003e\u003ca href=\"https://github.com/rdapaz/breeze\"\u003ebreeze\u003c/a\u003e\u003c/strong\u003e. It\u0026rsquo;s a\nCoffeeScript-flavoured language that compiles to Lua 5.1. It lives in a single\nroughly-1,200-line Lua file. There are no dependencies. You run it with \u003ccode\u003elua breeze.lua file.bz\u003c/code\u003e and it spits out Lua. That is the whole trick.\u003c/p\u003e\n\u003ch2 id=\"why-coffeescript-flavour-why-lua-target\"\u003eWhy CoffeeScript flavour? Why Lua target?\u003c/h2\u003e\n\u003cp\u003eThe CoffeeScript-ness was a deliberate self-imposed constraint. CoffeeScript has, for\nbetter and worse, a \u003cstrong\u003every opinionated\u003c/strong\u003e syntax: significant whitespace, arrow functions,\nstring interpolation, implicit returns, classes, list comprehensions, postfix\nconditionals. That\u0026rsquo;s a dense cluster of features that each have to be handled by the\nlexer, the parser, or the code generator — sometimes all three. If I picked something\nsimpler, I\u0026rsquo;d skip the hard parts. If I picked something wildly different, I\u0026rsquo;d spend\nthree weekends on parser engineering before writing a single interesting line of\nbreeze code.\u003c/p\u003e\n\u003cp\u003eThe Lua target was partly pragmatism and partly homage. Lua 5.1 has a beautifully\nsmall surface area — no classes, no exceptions, no pattern matching, no list\ncomprehensions — so every CoffeeScript feature I wanted to support translated into a\ngenuine code-generation problem rather than a one-line pass-through. Want classes?\nYou have to implement them with tables and metatables. Want \u003ccode\u003etry/catch\u003c/code\u003e? You wrap it\nin \u003ccode\u003epcall\u003c/code\u003e. Want safe navigation (\u003ccode\u003e?.\u003c/code\u003e)? You emit a careful little IIFE that\nshort-circuits on nil. Each feature is a tiny puzzle, and that\u0026rsquo;s exactly what I wanted\nto practise. On top of all that, Lua was made in Brazil, just like me!!!\u003c/p\u003e\n\u003cp\u003ePlus — and this matters for the day job — Lua is \u003cem\u003eeverywhere\u003c/em\u003e in the security world:\nWireshark dissectors, Nmap scripting engine, nDPI, Suricata, Redis, World of Warcraft,\nmost game engines worth modding, and half the microcontrollers on your bench. Any tool\nthat lets you write cleaner Lua has a real place in that ecosystem, even if \u0026ldquo;that tool\u0026rdquo;\nis a weekend project for one guy in Perth.\u003c/p\u003e\n\u003ch2 id=\"a-quick-tour\"\u003eA quick tour\u003c/h2\u003e\n\u003cp\u003eHere\u0026rsquo;s roughly what the language looks like. All of these compile to plain Lua 5.1;\nnothing below requires a runtime.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eArrow functions with implicit returns and string interpolation.\u003c/strong\u003e The two CoffeeScript\nstaples I reach for most:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-coffee\" data-lang=\"coffee\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003egreet = \u003c/span\u003e\u003cspan class=\"nf\"\u003e(name, greeting = \u0026#34;G\u0026#39;day\u0026#34;) -\u0026gt;\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"si\"\u003e#{\u003c/span\u003e\u003cspan class=\"nx\"\u003egreeting\u003c/span\u003e\u003cspan class=\"si\"\u003e}\u003c/span\u003e\u003cspan class=\"s\"\u003e, \u003c/span\u003e\u003cspan class=\"si\"\u003e#{\u003c/span\u003e\u003cspan class=\"nx\"\u003ename\u003c/span\u003e\u003cspan class=\"si\"\u003e}\u003c/span\u003e\u003cspan class=\"s\"\u003e!\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003eprint\u003c/span\u003e \u003cspan class=\"nx\"\u003egreet\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;Mabel\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eClasses with inheritance and fat-arrow \u003ccode\u003eself\u003c/code\u003e binding.\u003c/strong\u003e The fat arrow (\u003ccode\u003e=\u0026gt;\u003c/code\u003e) was the\nfeature I most enjoyed getting right — it desugars into \u003ccode\u003e(function(self, ...) return function(...) ... end end)(self)\u003c/code\u003e style shenanigans to preserve \u003ccode\u003eself\u003c/code\u003e inside callbacks:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-coffee\" data-lang=\"coffee\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nx\"\u003eTimer\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nv\"\u003econstructor: \u003c/span\u003e\u003cspan class=\"nf\"\u003e(@label) -\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"vi\"\u003e@elapsed = \u003c/span\u003e\u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e5\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nv\"\u003etick: \u003c/span\u003e\u003cspan class=\"nf\"\u003e=\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e6\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003e@elapsed\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e7\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003eprint\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"si\"\u003e#{\u003c/span\u003e\u003cspan class=\"nx\"\u003e@label\u003c/span\u003e\u003cspan class=\"si\"\u003e}\u003c/span\u003e\u003cspan class=\"s\"\u003e: \u003c/span\u003e\u003cspan class=\"si\"\u003e#{\u003c/span\u003e\u003cspan class=\"nx\"\u003e@elapsed\u003c/span\u003e\u003cspan class=\"si\"\u003e}\u003c/span\u003e\u003cspan class=\"s\"\u003es\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eSafe navigation and null coalescing.\u003c/strong\u003e Two features Lua programmers have been\nfaking with \u003ccode\u003eand\u003c/code\u003e/\u003ccode\u003eor\u003c/code\u003e chains for decades:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-coffee\" data-lang=\"coffee\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003ecity = \u003c/span\u003e\u003cspan class=\"nx\"\u003euser\u003c/span\u003e\u003cspan class=\"o\"\u003e?\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eprofile\u003c/span\u003e\u003cspan class=\"o\"\u003e?\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eaddress\u003c/span\u003e\u003cspan class=\"o\"\u003e?\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ecity\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003elabel = \u003c/span\u003e\u003cspan class=\"nx\"\u003edevice\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ename\u003c/span\u003e \u003cspan class=\"o\"\u003e??\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;unnamed\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003ePipeline operator.\u003c/strong\u003e Steal good ideas shamelessly:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-coffee\" data-lang=\"coffee\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003eresult = \u003c/span\u003e\u003cspan class=\"nx\"\u003eraw_logs\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"o\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003efilter\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nf\"\u003e(row) -\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003erow\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eseverity\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;alert\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"o\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003emap\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nf\"\u003e(row) -\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003erow\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esrc_ip\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"o\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003eunique\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eList comprehensions with ranges.\u003c/strong\u003e Oh, how I missed these in plain Lua:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-coffee\" data-lang=\"coffee\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003eevens = \u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"nx\"\u003ex\u003c/span\u003e \u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"nx\"\u003ex\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e..\u003c/span\u003e\u003cspan class=\"mi\"\u003e100\u003c/span\u003e \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"nx\"\u003ex\u003c/span\u003e \u003cspan class=\"o\"\u003e%\u003c/span\u003e \u003cspan class=\"mi\"\u003e2\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003egrid  = \u003c/span\u003e\u003cspan class=\"p\"\u003e[{\u003c/span\u003e\u003cspan class=\"nv\"\u003ex: \u003c/span\u003e\u003cspan class=\"nx\"\u003ei\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003ey: \u003c/span\u003e\u003cspan class=\"nx\"\u003ej\u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"nx\"\u003ei\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e..\u003c/span\u003e\u003cspan class=\"mi\"\u003e5\u003c/span\u003e \u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"nx\"\u003ej\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e..\u003c/span\u003e\u003cspan class=\"mi\"\u003e5\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003ePostfix conditionals.\u003c/strong\u003e Reads like English. Compiles to a boring \u003ccode\u003eif\u003c/code\u003e block:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-coffee\" data-lang=\"coffee\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;warning: disk nearly full\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"nx\"\u003edisk\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003efree_pct\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e \u003cspan class=\"mi\"\u003e10\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThat\u0026rsquo;s not a complete tour — the full reference is in the repo — but it\u0026rsquo;s a fair sample\nof the syntactic space. Each of those lines compiles to Lua that would be perfectly\nlegible to anyone who already writes Lua; the breeze version is just shorter and (to my\neye at least) easier to read six months later.\u003c/p\u003e\n\u003ch2 id=\"a-look-under-the-hood\"\u003eA look under the hood\u003c/h2\u003e\n\u003cp\u003eThe compiler is structured the way the textbooks tell you to structure compilers:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eLexer\u003c/strong\u003e (\u003ccode\u003eBreeze.lex\u003c/code\u003e) — turns source text into tokens. The interesting parts are\nsignificant-indentation tracking (emitting synthetic \u003ccode\u003eINDENT\u003c/code\u003e and \u003ccode\u003eDEDENT\u003c/code\u003e tokens\nwhen the indent level changes) and handling \u003ccode\u003e#{expr}\u003c/code\u003e interpolation inside strings,\nwhich is genuinely fiddly because the interpolation can itself contain strings.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eParser\u003c/strong\u003e (\u003ccode\u003eBreeze.parse\u003c/code\u003e) — recursive-descent over the token stream, building an\nAST of plain Lua tables tagged with a \u003ccode\u003ekind\u003c/code\u003e field.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCode generator\u003c/strong\u003e (\u003ccode\u003eBreeze.compile\u003c/code\u003e) — walks the AST and emits Lua source. Tracks\nlocal-variable scope so the right things get \u003ccode\u003elocal\u003c/code\u003e-declared. Emits IIFEs for\nconstructs that don\u0026rsquo;t have a direct Lua analogue.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eHere\u0026rsquo;s a representative slice of \u003ccode\u003ebreeze.lua\u003c/code\u003e — the bit that handles the fat arrow,\ncompressed a little for readability. This is the kind of thing you find yourself writing\nat 11pm when you swore you were going to bed at 10:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-lua\" data-lang=\"lua\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e-- compile a function literal; `bind_self` is true for `=\u0026gt;`, false for `-\u0026gt;`\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003elocal\u003c/span\u003e \u003cspan class=\"kr\"\u003efunction\u003c/span\u003e \u003cspan class=\"nf\"\u003eemit_function\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003enode\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003estate\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 3\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kd\"\u003elocal\u003c/span\u003e \u003cspan class=\"n\"\u003eparams\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003enode.params\u003c/span\u003e \u003cspan class=\"ow\"\u003eor\u003c/span\u003e \u003cspan class=\"p\"\u003e{}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 4\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kr\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003enode.bind_self\u003c/span\u003e \u003cspan class=\"kr\"\u003ethen\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 5\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003etable.insert\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eparams\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;self\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 6\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kr\"\u003eend\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 7\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kd\"\u003elocal\u003c/span\u003e \u003cspan class=\"n\"\u003ebody\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eemit_block\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003enode.body\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003estate\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 8\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"c1\"\u003e-- last expression of an arrow function becomes an implicit return\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 9\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"n\"\u003ebody\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eadd_implicit_return\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e10\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kr\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003estring.format\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e11\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"s2\"\u003e\u0026#34;function(%s)\u003c/span\u003e\u003cspan class=\"se\"\u003e\\n\u003c/span\u003e\u003cspan class=\"s2\"\u003e%s\u003c/span\u003e\u003cspan class=\"se\"\u003e\\n\u003c/span\u003e\u003cspan class=\"s2\"\u003eend\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e12\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003etable.concat\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eparams\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;, \u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e13\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003ebody\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e14\u003c/span\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e15\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003eend\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe pattern that recurs all through the compiler is: \u003cstrong\u003epick a desugaring, emit Lua that\nbehaves identically, document the transform.\u003c/strong\u003e I wrote a separate\n\u003ca href=\"https://github.com/rdapaz/breeze/blob/main/REFERENCE.md\"\u003e\u003ccode\u003eREFERENCE.md\u003c/code\u003e\u003c/a\u003e for the\nlanguage partly to have real documentation and partly because writing reference docs\nforces you to notice your own inconsistencies.\u003c/p\u003e\n\u003ch2 id=\"where-id-actually-use-it\"\u003eWhere I\u0026rsquo;d actually use it\u003c/h2\u003e\n\u003cp\u003eThe use cases I keep circling back to aren\u0026rsquo;t web apps — they\u0026rsquo;re the places Lua already\nlives and where cleaner syntax would genuinely earn its keep:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eWireshark dissectors\u003c/strong\u003e for industrial protocols (Modbus, DNP3, OPC UA over some\ntransports). The standard Lua dissector template is verbose in a way that really\nisn\u0026rsquo;t helping anyone understand what the dissector is doing. Significant whitespace\nplus fat-arrow callbacks makes the \u0026ldquo;read a field, build a tree item, advance the\noffset\u0026rdquo; loop read the way it feels in your head.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNmap NSE scripts.\u003c/strong\u003e Same argument. The lift isn\u0026rsquo;t large but the readability win\nover time adds up.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eESP32 / NodeMCU firmware.\u003c/strong\u003e I have a pile of half-finished microcontroller projects\nwhere the Lua side is mostly \u0026ldquo;set up a pin, wait for an event, send an MQTT message\u0026rdquo;.\nThe boilerplate-to-logic ratio drops nicely under breeze.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eI don\u0026rsquo;t realistically expect anyone else to pick this up — and I\u0026rsquo;d be a bit suspicious\nif they did, because it\u0026rsquo;s a one-person project I mainly built to learn. But I do\ngenuinely use it on my own bench, and it has earned its keep there.\u003c/p\u003e\n\u003ch2 id=\"on-doing-this-with-an-ai-pair-programmer\"\u003eOn doing this with an AI pair-programmer\u003c/h2\u003e\n\u003cp\u003eA short meta-note, because it\u0026rsquo;s relevant to how this got built. I wrote breeze with\n\u003cstrong\u003eClaude Code\u003c/strong\u003e as a constant pair-programming partner. The book reading was mine; the\ncompiler design decisions were mine; the language-feature choices were mine. But the\nact of typing out a recursive-descent parser and catching the five hundredth\noff-by-one error in the indent tracker is a much better experience when there\u0026rsquo;s a\npatient agent next to you willing to read your tokenizer state-machine out loud and\npoint out the case you missed.\u003c/p\u003e\n\u003cp\u003eThe books taught me the concepts. Claude helped me ship an artefact that let me prove\nI actually held the concepts in my head. Those are different skills, and I\u0026rsquo;m grateful\nto have both.\u003c/p\u003e\n\u003ch2 id=\"what-i-actually-learned-as-opposed-to-what-i-thought-id-learn\"\u003eWhat I actually learned (as opposed to what I thought I\u0026rsquo;d learn)\u003c/h2\u003e\n\u003cp\u003eA few things surprised me, in rough order of how surprised I was:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eLexers are mostly boring until they aren\u0026rsquo;t.\u003c/strong\u003e Ninety percent of a lexer is a\nboring state machine. The last ten percent — string interpolation, significant\nwhitespace, telling \u003ccode\u003e#\u003c/code\u003e as comment from \u003ccode\u003e#\u003c/code\u003e as length operator from \u003ccode\u003e#\u003c/code\u003e in\ninterpolation — is all the interesting thinking, and it arrives late.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eParsers feel powerful, code generators feel humbling.\u003c/strong\u003e Writing the parser felt\nlike wielding a very sharp tool. Writing the code generator felt like being handed\na toddler and told \u0026ldquo;now translate everything this toddler wants into a formal\nlanguage the toddler has never heard of.\u0026rdquo; That\u0026rsquo;s the real craft.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCoffeeScript\u0026rsquo;s design was more principled than I remembered.\u003c/strong\u003e When you actually\ntry to implement its corners — the implicit return rules, the distinction between\n\u003ccode\u003e-\u0026gt;\u003c/code\u003e and \u003ccode\u003e=\u0026gt;\u003c/code\u003e, the way \u003ccode\u003e@foo\u003c/code\u003e binds in constructor parameters — you notice how much\nthought went into each of them. My own additions (the pipeline operator, safe\nnavigation, null coalescing) feel grafted on by comparison, which is fair: they\n\u003cem\u003eare\u003c/em\u003e grafted on.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eError messages are the real product.\u003c/strong\u003e I haven\u0026rsquo;t got nearly as far into this as\nI\u0026rsquo;d like. A teaching language with bad error messages is a worse teaching language\nthan a production language with good ones. Next iteration.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"closing\"\u003eClosing\u003c/h2\u003e\n\u003cp\u003eIf you\u0026rsquo;re in the same headspace — professional day job, curious evenings, a handful of\ncompiler books on your desk — I can\u0026rsquo;t recommend the project highly enough. Not \u003cem\u003emy\u003c/em\u003e\nproject; the project of picking any small language and trying to make it run. Breeze\nis just the one I happened to pick because I\u0026rsquo;d spent too many hours squinting at\nverbose Lua dissectors.\u003c/p\u003e\n\u003cp\u003eIf you want to poke at it, the code is at\n\u003cstrong\u003e\u003ca href=\"https://github.com/rdapaz/breeze\"\u003egithub.com/rdapaz/breeze\u003c/a\u003e\u003c/strong\u003e. It\u0026rsquo;s MIT-licensed.\nIt\u0026rsquo;s full of comments written at 1am. It has no stars and that\u0026rsquo;s genuinely fine.\u003c/p\u003e\n\u003cp\u003eYou probably know a better CoffeeScript-to-Lua compiler (Moonscript I am looking right at you!) No matter, though\u0026hellip; I wanted to write mine anyway, for the learning.\u003c/p\u003e\n","date":"2026-04-17","dateFormatted":"2026.04.17","excerpt":"I am a cybersecurity engineer by trade. I spend my days worrying about PLCs, network segmentation, pondering when SOCI will require us to meet MIL2 or SP2 and worrying about how many firewalls we need …","featured":true,"mood":"🦆 head down, reading","permalink":"https://blog.ricdeez.com/posts/breeze-a-compilers-rabbit-hole/","readingTime":9,"slug":"breeze-a-compilers-rabbit-hole","subtitle":"What happens when an OT-cyber engineer reads too many books about parsers","summary":"I am a cybersecurity engineer by trade. I spend my days worrying about PLCs, network segmentation, pondering when SOCI will require us to meet MIL2 or SP2 and worrying about how many firewalls we need to have between our corporate network and the DCS (hint: lots!). The closest my day job has come to language design lately is helping a fellow engineer work out a regex for a cribl route filter.","tags":["compilers","interpreters","lua","coffeescript","learning-in-public","claude-code"],"title":"Breeze: A Compilers-and-Interpreters Rabbit Hole","url":"https://blog.ricdeez.com/posts/breeze-a-compilers-rabbit-hole/","wordCount":1871}]