CPS STM32 Door Node (PN532 RFID + solenoid)
This runbook brings up the STM32F401 door node: a WeAct Blackpill wired to a
real PN532 RFID reader and a 12V solenoid lock driven through an XY-MOS N-channel
MOSFET module. Unlike the bench node (cps-stm32-bench, which simulates badges
with a button and only blinks an LED), the door node reads real cards and
actuates a real lock, with the backend as the access authority. See ADR 051.
It keeps the same USB CDC-ACM transport and aurora-firmware-contracts wire
format as the bench node, so the existing serial bridge and MQTTEdgeConsumer
ingest it unchanged. The bench node project is left in place; the door node is a
separate firmware project (stm32f401_door_node).
Hardware
- WeAct STM32F401CCU6 Blackpill (256 KB flash), connected by USB-C.
- ST-Link V2 on the SWD header (SWDIO, SWCLK, GND, 3V3) for flashing and logs.
- PN532 RFID reader on I2C1, set to I2C mode with its onboard switches.
- XY-MOS N-channel MOSFET module driving a 12V solenoid lock from a 12V supply.
Wiring
| STM32F401 | Connects to | Notes |
|---|---|---|
| PA11 / PA12 | USB-C D- / D+ | CDC-ACM serial to host |
| PA13 / PA14 | ST-Link SWDIO / SWCLK | flash and RTT logs |
| PB6 (SCL), PB7 (SDA) | PN532 SCL / SDA | PN532 in I2C mode; also wire its 3V3 and GND |
| PB0 | XY-MOS TRIG / PWM | logic-level gate drive |
| GND | XY-MOS GND | common ground between the board and the module |
| PA0 | request-to-exit button to GND | optional local egress |
| (12V) | XY-MOS VIN+ / VIN- | solenoid supply |
| (solenoid) | XY-MOS OUT+ / OUT- | add a flyback diode (e.g. SS54 or 1N5408) across the solenoid |
The solenoid is fail-secure, energize-to-unlock: the gate is driven high to open for the unlock window, then released (locked). Loss of power leaves the door locked. The flyback diode is required to absorb the inductive kickback when the solenoid de-energizes.
What the firmware does
- Presents a USB serial port and emits one line per event as
<topic>\t<payload>using the sharedaurora-firmware-contractsformat. - Polls the PN532 for ISO14443A cards and publishes the real UID (uppercase hex)
on
aurora/access/<device_id>/badge_event. A short cooldown de-duplicates a card left on the reader. - Does not self-unlock on a badge read. The backend decides (see below).
- Drives the solenoid and the PC13 LED from the lock state: locked (gate low, LED off), unlocked (gate high, LED on for the unlock window, then auto-relock), lockdown (gate low, LED blinking).
- PA0 is a local request-to-exit (egress) button that unlocks locally.
- Publishes periodic status (every 30 s) and real MCU die-temperature telemetry (every 10 s).
- Accepts commands written back over serial:
unlock,lock,lockdown,lockdown_clear,keepalive. A fail-secure watchdog relocks the door if the host link goes silent for 70 s.
Flash and observe
From firmware/embassy-stm32/projects/stm32f401_door_node:
export DEVICE_ID="stm32f401-door-01"
cargo run --release # probe-rs flashes via ST-Link and streams defmt RTT
or just fw flash-stm32f401-door (set DEVICE_ID first). The RTT log shows boot,
the PN532 firmware version, status, telemetry, and a line per card read. A
/dev/ttyACM* serial port also appears for the USB CDC interface, enumerated as
"AuroraSOC Door Node". To watch the raw framed lines directly:
cat /dev/ttyACM0 # topic<TAB>payload lines on the status/telemetry interval
Run the host bridge
With the stack up (Mosquitto, API, console), run the bridge so the device lines
reach the broker. The broker exposes a loopback plaintext listener on
127.0.0.1:1883 for bench use, so the bridge needs no client certificate:
export SERIAL_BRIDGE_ENABLED=true
export SERIAL_BRIDGE_PORT=/dev/ttyACM0
export SERIAL_BRIDGE_DEVICE_ID=stm32f401-door-01
export MQTT_HOST=127.0.0.1 MQTT_PORT=1883 MQTT_USE_TLS=false
python -m aurorasoc.tools.cps.serial_bridge
The backend MQTTEdgeConsumer is subscribed on the mTLS listener of the same
broker, so device lines published on 1883 reach it and commands it publishes
reach the bridge. When the API itself runs with SERIAL_BRIDGE_ENABLED=true and
the device mapped in, the bridge starts inside the API lifespan instead.
Backend-authorized unlock
The door node never opens on its own. The full loop is:
- A card is tapped. The node publishes a
badge_eventwith the real UID. MQTTEdgeConsumerauthorizes the UID against the access policy and writes an access event (granted or denied) that appears in the console.- On a grant, the backend dispatches an
unlockcommand toaurora/access/<device_id>/command. The bridge forwards it to the board and the solenoid opens for the unlock window, then auto-relocks. - On a denial, an
access_deniedCPS alert is raised; the lock stays shut.
Operator commands from the console (Access Control, Commands tab) use the same command topic, so Unlock, Lock, Lockdown, and Clear Lockdown reach the board too.
End-to-end check
-
With the bridge running, the device appears in
GET /api/v1/cps/devicesand on the CPS page;temperature_ctelemetry lands on the sensors topic. -
In the console, open Access Control, read the UID of a tapped card from the Live Event Feed, then create a Badge with that UID and assign it a zone.
-
Tap the card again. The feed shows GRANTED, the backend sends
unlock, and the solenoid opens (LED on) for the unlock window before relocking. An unknown card shows DENIED and raises anaccess_deniedalert. -
Drive the lock manually to confirm the command path:
mosquitto_pub -h 127.0.0.1 -p 1883 \-t aurora/access/stm32f401-door-01/command -m unlock
Notes
- The PN532 is wired for reading only; no secure element is fitted, so attestation requests are not answered and the device stays PENDING by design.
- Pulling the USB cable or stopping the bridge trips the fail-secure watchdog after
70 s, which relocks the door and raises a
failsecure_lockalert.