● refactoringbuildingsamridhlimbu.com/projects/lock-in · v0.1
Lock-In
● embedded · arduino + piSIT210A desk-side focus tracker that watches the physical world instead of the laptop. Arduino reads presence, distance, temperature, and light at 1 Hz. Raspberry Pi runs a five-state FSM driven by sensor frames, button events, and a Gemini vision call every 75 s. Built over a weekend. Boot-on-power via systemd. Total parts cost under AU$70.
Context
Solo project, Deakin SIT210/730 Embedded Systems Development (Task 10.1D: Project Teaching Case). The constraint was deliberate: build something you'd actually use, then write a Hackster.io-style how-to article that forces you to explain every decision to a stranger. The article became the best debugging session I had on the project. Re-reading every module from a stranger's perspective surfaced dead imports, a near-duplicate JSON serialisation call, and a sensor threshold that had been wrong since day two.
Finite state machine
AWAY
No one at the desk for 5 min. LEDs off.
IDLE
Person detected at desk. Waiting for button press to start.
FOCUS
Session active. Gemini vision polling every 75 s.
DEGRADING
Distraction detected. Buzzer nagging.
BREAK
Break timer running. Yellow blink.
Hardware
Arduino UNOSensor hub + LED/buzzer driver
Raspberry Pi 5FSM orchestrator + Flask dashboard + MQTT broker
PIR HC-SR501Presence detection (INT0)
HC-SR04Distance to screen
DHT22Room temperature + humidity
LDR + 10 kΩAmbient light (voltage divider, A0)
3× LED + 220 ΩRed / yellow / green state output
Passive piezoBuzzer patterns via tone()
Push buttonSession control (INT1, active-low)
Key decisions
01pure-logic FSM › FSM with I/O
No time.time(), no globals, no threads inside the FSM. The orchestrator pushes events in, gets a list of Actions back. Result: 13 FSM unit tests that run in microseconds with a FakeClock. No serial port, no network required.
02return None on any error › raise exceptions
camera_client.capture() returns None on timeout, HTTP error, or connection drop. The orchestrator treats None as a normal code path. No exception flows, no "is it alive?" branching, no special cases. The dashboard reads the online flag instead.
03laptop Flask server over ESP32-CAM › dedicated camera hardware
The ESP32-CAM kept dropping frames and returning blurry JPEGs under desk lamps. A spare laptop serving GET /capture is more reliable and costs nothing extra. The Pi pulls frames on its own schedule.
04systemd services › manual startup
Two units (lock-in-orchestrator and lock-in-dashboard) start on boot and restart on failure. The Pi becomes a fully autonomous appliance: power it on and it just works.
Code structure
config.pyenv vars → dataclass
database.pySQLite schema + queries
serial_reader.pyasync Arduino bridge, auto-reconnect
camera_client.pyasync HTTP /capture client
vision_judge.pyGemini call + strict JSON parser
fsm.pyfinite state machine: pure logic, no I/O
mqtt_bus.pyasync MQTT pub/sub, survives broker outages
sd_notify.pysystemd watchdog/ready notifier, zero deps
orchestrator.py8 asyncio tasks: serial, tick, vision, retention, mqtt, publish, watchdog
dashboard/app.pyFlask + SSE fan-out over MQTT snapshot topic
One rule kept the project from sprawling: each file does exactly one thing. The FSM is the most important: pure logic means testable in microseconds with a FakeClock, no mocking of serial ports or HTTP clients. MQTT is the primary IPC channel between the orchestrator and dashboard; both sides fall back to on-disk snapshot.json if the broker is offline.
What I'd do differently
The cleanest lesson: handling absence is cheaper than handling exceptions. As soon as every client module returned None on any error and the orchestrator treated that as a normal code path, the rest of the system became simpler. No special cases, no "is this still alive?" branching. I'd apply that convention from the very first file on the next embedded project, rather than discovering it halfway through.