ADR-009: Optional Door Hardware and Door Modes¶
Status: Accepted Date: 2026-03-12
Context¶
The in-berlin hackerspace door has additional hardware beyond the NUKI Smart Lock:
- Bolt sensor — momentary switch detecting the main lock bolt position (key-operated)
- Reed relay — detects whether the door is physically closed
- Bistable solenoid bolt — secondary lock, 12V DC, polarity-switched via H-bridge, 0.5s pulse to lock/unlock
- Door crack sensor — short pulse when door opens a few degrees (repurposed former doorbell trigger)
- Doorbell system — momentary button outside, bell (GPIO-driven) inside, duo-color LED outside
This hardware is specific to our installation. Other hackerspaces using OpenLatch may not have any of these components. The firmware must maintain a consistent door state model regardless of whether the hardware is present.
Decision¶
Two-layer architecture¶
Layer 1: Door Hardware (door_hardware.h) — optional, compiled only with -DUSE_DOOR_HARDWARE. Thin GPIO abstraction: init pins, read sensors, drive solenoid/bell/LED. No logic.
Layer 2: Door Controller (door_controller.h) — always compiled. Manages door modes and state machine. Consumes sensor events (from hardware layer or software commands). Drives outputs through callbacks. Works without door hardware by accepting software-triggered state transitions.
This separation means: - With hardware: sensors feed real data into the controller, outputs drive real GPIOs - Without hardware: the controller still tracks modes and state, driven by NFC access events and API commands. Door is assumed closed after unlock timeout. No solenoid, bell, or LED.
Door Modes¶
When the space is open (not in lockup), occupants select one of four door modes via 1-2 momentary buttons inside the space (active only with USE_DOOR_HARDWARE) or via the admin API:
| Mode | Solenoid | Doorbell press | Door crack | External LED |
|---|---|---|---|---|
OPEN_SILENT |
Unlocked | Ignored | Ignored | Green |
OPEN_NOTICE |
Unlocked | Ring | Ring | Green |
GUARDED_AUTO |
Locked | Ring → delay → unlock → relock on close | Ring | Red; blink green during unlock window |
GUARDED_MANUAL |
Locked | Ring (member goes to door) | Ring | Red |
When the space is closed (lockup mode): NUKI locked, solenoid locked, doorbell ignored, external LED red. NFC/TOTP access still works for authorized members.
In all modes: a member presenting a valid NFC key or TOTP code always gains access, regardless of door mode.
Solenoid control rules¶
- Unlock: set H-bridge A=HIGH, B=LOW for 500ms, then both LOW
- Lock: set H-bridge A=LOW, B=HIGH for 500ms, then both LOW
- Auto-relock (GUARDED_AUTO): after unlock, wait for door open (reed) then door close (reed), then lock. If door never opens within 15 seconds, relock anyway.
- Reed debounce: 100ms debounce before confirming door closed/open state changes
Bolt sensor as lockup hint¶
If the bolt sensor detects "locked" (someone turned the key from outside) and the space is currently open, the firmware logs an event and can optionally trigger lockup mode. This handles the case where someone locks up physically without pressing the lockup button. The exact behavior (auto-lockup vs. log-only) is configurable.
Inside control panel (I2C bus)¶
The inside user interface (mode buttons, status LEDs, future LCD) is connected via an SX1509 I2C GPIO expander rather than using ESP32 GPIOs directly. This:
- Frees ESP32 GPIOs for door-mounted, time-critical hardware
- Puts all inside-UI on a single I2C bus (2 wires: SDA/SCL)
- Makes the control panel physically separate and easy to relocate
- Leaves room for a future I2C LCD display that could replace the status LEDs with richer feedback (mode name, member count, last event, etc.) — same bus, no rewiring
The I2C bus uses GPIO 21 (SDA) / GPIO 22 (SCL), shared with the PN532 in dev mode. The SX1509 INT pin connects to an ESP32 GPIO for interrupt-driven button reads (no polling needed).
SX1509 pin allocation (16 pins, active-low inputs with internal pull-ups):
| SX1509 Pin | Function | Notes |
|---|---|---|
| 0 | Mode button A | Tactile momentary switch, cycles forward through door modes |
| 1 | Mode button B | Tactile momentary switch, cycles backward (or independent toggle) |
| 4 | LED: OPEN_SILENT | Green, indicates current mode |
| 5 | LED: OPEN_NOTICE | Green |
| 6 | LED: GUARDED_AUTO | Yellow/amber |
| 7 | LED: GUARDED_MANUAL | Yellow/amber |
| 8-15 | Reserved | Future LCD buttons, additional indicators |
The SX1509 supports hardware PWM on all pins, so LEDs can pulse/breathe to indicate the active mode rather than just on/off.
Mode cycling: button A short press cycles SILENT → NOTICE → GUARDED_AUTO → GUARDED_MANUAL → SILENT. The corresponding LED lights up. Button B cycles backward. The exact UX (cycle vs. independent toggles) can be refined once the panel is in use.
Without USE_DOOR_HARDWARE¶
The door controller still manages all four modes and state transitions. Sensor inputs that are unavailable use safe defaults:
| Sensor | Default without hardware | Effect |
|---|---|---|
| Bolt sensor | Assumed unlocked | No auto-lockup hint; lockup requires button or API |
| Reed relay | Door assumed closed after auto-relock timeout | GUARDED_AUTO still works with timeout-based relock |
| Door crack sensor | No events | No entry notifications |
| Doorbell button | No events | Guests must be let in via other means |
This means:
- Space open (default: OPEN_SILENT) — NFC/TOTP access unlocks NUKI
- Space closed (lockup) — NFC/TOTP access unlocks NUKI for authorized members
- GUARDED modes — still functional via NUKI-only locking; solenoid commands become no-ops
- Door modes can be set via API; modes that rely on missing hardware degrade gracefully
The lockup button (GPIO 33, always present) still triggers lockup mode.
Other hackerspaces can also choose a middle ground: use jumpers or fixed wiring to simulate sensors (e.g., tie reed relay input to GND = "always closed") without needing the full hardware. The compile flag simply controls whether GPIO init and ISR registration happen.
GPIO Assignments¶
All behind USE_DOOR_HARDWARE. Chosen to avoid conflicts with existing pins (16/17 Wiegand, 21/22 PN532 I2C shared, 25/26/27 LED/buzzer, 33 lockup button).
ESP32 GPIOs — door-mounted hardware (time-critical)¶
| Component | Direction | GPIO | Notes |
|---|---|---|---|
| Bolt sensor | Input | 34 | Input-only, needs external pull-up |
| Reed relay | Input | 35 | Input-only, needs external pull-up |
| Door crack sensor | Input | 36 | Input-only, edge-triggered, ext. pull-up |
| Doorbell button | Input | 39 | Input-only, needs external pull-up |
| Solenoid A | Output | 4 | H-bridge pin 1 |
| Solenoid B | Output | 5 | H-bridge pin 2 |
| Bell | Output | 18 | Simple on/off |
| External LED grn | Output | 19 | Duo-color LED outside |
| External LED red | Output | 23 | Duo-color LED outside |
| SX1509 INT | Input | 15 | Interrupt from I2C expander |
I2C bus — inside control panel¶
| Bus | GPIO | Notes |
|---|---|---|
| I2C SDA | 21 | Shared with PN532 in dev mode |
| I2C SCL | 22 | Shared with PN532 in dev mode |
SX1509 at default address 0x3E. Pin allocation see "Inside control panel" section above.
Note: GPIO 34-39 are input-only on ESP32 and lack internal pull-ups. External 10k pull-up resistors to 3.3V are required for dev breadboard setups.
PlatformIO Environment¶
New environment esp32dev-inberlin extends the production esp32dev with the door hardware flag:
[env:esp32dev-inberlin]
extends = env:esp32dev
build_flags =
${env:esp32dev.build_flags}
-DUSE_DOOR_HARDWARE
lib_deps =
${env:esp32dev.lib_deps}
sparkfun/SparkFun SX1509 IO Expander@^3.0.4
Consequences¶
- The door controller state machine is testable on native (no hardware dependency)
- Other hackerspaces get a working system with just NUKI — no door hardware needed
- The in-berlin installation gets full sensor feedback, secondary locking, and guest doorbell
- Inside control panel is on I2C (SX1509) — physically separate, easy to relocate
- Mode cycling provides physical UX without requiring the admin UI
- The two-layer split keeps controller logic hardware-agnostic
- I2C bus is ready for a future LCD display (same bus, no rewiring)
- PN532 (dev) and SX1509 (in-berlin) share I2C without conflict (different addresses)