A wired button on G44 puts UniFi Access into lockdown. While the button is held, every access policy except the configured exempt policies has its door / door-group resources cleared. When the button is released the originals are written back from a snapshot taken at the moment of engagement.
Everything is driven from the USB serial console. Configuration and logs live in flash and survive power cycles.
| File | Purpose |
|---|---|
UniFiLockdownFirmware.ino |
setup() / loop() wiring |
hw_config.h |
Pin map and build constants — verify this first |
Logger.{h,cpp} |
Serial mirror + rotating log files in LittleFS |
Config.{h,cpp} |
NVS-backed parameters, validation and metadata |
ButtonInput.{h,cpp} |
Debounced G44 input, NO/NC handling, test override |
StatusLed.{h,cpp} |
Non-blocking blink-pattern player |
Net.{h,cpp} |
PoE Ethernet bring-up, link events, SNTP |
UnifiAccess.{h,cpp} |
UniFi Access developer API client |
Lockdown.{h,cpp} |
Snapshot / clear / restore state machine |
Console.{h,cpp} |
Serial command parser and help output |
../scripts/setup-arduino-cli.sh |
Installs arduino-cli, the esp32 core and ArduinoJson |
../scripts/build.sh |
Compiles and/or uploads from the command line |
Arduino requires a sketch's .ino file to be named after its containing
folder, so the sketch file is UniFiLockdownFirmware.ino, not
UniFiLockdown.ino.
These are the parts I could not confirm against your exact hardware and controller. Each is isolated so it is a small edit.
1. Pin map (hw_config.h, VERIFY block). Set from M5Stack's published Unit
PoE-P4 pin map, but not yet confirmed on hardware. Check against the schematic
for your board revision. If the unit fronts the PHY over SPI instead of RMII,
set ETH_IFACE_MODE to ETH_IFACE_SPI and fill in the SPI pins. The button pin
(G44) is per your requirement and needs no change.
- Status LED is driven as a plain GPIO on G15 (the green channel of the
onboard RGB LED, which is three discrete GPIOs — G15 green, G16 blue, G17 red
— not an addressable part). G45 is the onboard user button, so nothing
drives it. If the LED reads inverted, set
STATUS_LED_ACTIVE_HIGHto 0. - PHY address is
ETH_PHY_ADDR_AUTO, so the strap address is discovered by scanning the MDIO bus rather than assumed.
ETH_CLOCK_MODE is already fixed to EMAC_CLK_EXT_IN for this reason: on
ESP32-P4, eth_clock_mode_t is emac_rmii_clock_mode_t from the IDF, a
different enum from classic ESP32's GPIO-numbered one — ETH_CLOCK_GPIO0_IN
doesn't exist on P4 and fails to compile. EMAC_CLK_EXT_IN (PHY drives the
RMII clock, P4 reads it in) is the P4 equivalent of the original intent.
2. UniFi Access data model. In UniFi Access an access policy carries a
resources array — the doors and door groups it applies to — and policies are
assigned to users and user groups. So "remove all policies from all doors" is
implemented as "clear each policy's resources array", and restore writes those
arrays back. That preserves the policies themselves, their names and their
schedules; only the door bindings move. If your controller models this
differently, UnifiAccess.cpp is the only file that needs to change.
3. API endpoints. The client uses
GET /api/v1/developer/access_policies and
PUT /api/v1/developer/access_policies/{id} on port 12445. Confirm these
against the API docs for your controller version before trusting the device with
a live door. Run test_lockdown_set on a lab controller first.
4. Why the log is rotating files, not a ring buffer (fixed; do not undo).
Logger.cpp originally implemented the log as random-access seek() +
write() into a pre-allocated 1 MiB file. That bricked the boot. LittleFS is
copy-on-write and stores files as CTZ skip-lists, so an in-place write in the
middle of a large file rewrites a large part of the block chain. Measured on
real M5UnitPoEP4 hardware (esp32 core 3.3.11, 3 MB filesystem partition):
| Operation on a 1 MiB LittleFS file | Measured cost |
|---|---|
| Sequential append, 256-byte writes | ~3 s for the whole 1 MiB (fine) |
seek() + write() of 200 bytes mid-file |
4.7 s |
| Same, second time | 5.2 s |
| Same, third time | did not complete |
The device pre-allocated fine, then each of the first log flushes stalled for
seconds and churned flash until the partition was exhausted
(esp_littlefs: ... No more free space), wedging about 12 seconds into boot —
deterministically, from a fully erased chip, at every partition size tried.
This was a design bug here, not a platform or LittleFS defect: sequential writes of the same size on the same hardware are fine, and per-file overhead is a constant 12 KB. LittleFS is simply not a backing store for a random-access ring buffer. See littlefs #1054 and DESIGN.md.
It is now a rotating set of append-only files (LOG_DIR, LOG_FILE_BYTES,
LOG_MAX_FILES), which only ever appends to the newest file and deletes whole
old ones — both operations LittleFS handles well. File size was picked by
measurement, appending 2 KiB at a time (the real flush size) at steady state:
| File size | Files | Append avg | Append worst | Rotate avg | Rotate worst |
|---|---|---|---|---|---|
| 32 KiB | 32 | 33.1 ms | 358 ms | 69.1 ms | 478 ms |
| 64 KiB | 16 | 18.4 ms | 208 ms | 37.8 ms | 227 ms |
| 128 KiB | 8 | 17.8 ms | 147 ms | 28.2 ms | 162 ms |
| 256 KiB | 4 | 26.1 ms | 125 ms | — | — |
It is a U-curve: below ~64 KiB the cost of scanning directory entries dominates, above ~128 KiB the per-file append cost takes over. 128 KiB x 8 sits at the bottom. Note this means logs roll off in whole 128 KiB files, so the retained log sits between ~896 KiB and 1 MiB rather than at exactly 1 MiB.
./scripts/setup-arduino-cli.sh # one-time: installs arduino-cli, the esp32 core, ArduinoJson
./scripts/build.sh compile # compile only
./scripts/build.sh upload /dev/cu.usbmodem2101 # compile + flash (use your board's port)See scripts/build.sh for the exact FQBN and board options used, and why.
There is no dedicated M5UnitPoEP4 entry in the esp32 core's board list, but
this firmware has no M5Stack SDK dependency (no M5Unified.h / M5.begin())
— it's built entirely on ETH.h / Network.h / LittleFS from the esp32
core — so the generic ESP32P4 Dev Module (esp32:esp32:esp32p4) FQBN is
the correct target.
- Boards Manager — install esp32 by Espressif Systems, v3.1.0 or
newer (ESP32-P4 support and the
ETH.h/Network.hAPIs used here). - Board — select ESP32P4 Dev Module (see note above on why there's no M5-specific entry to pick instead).
- Library Manager — install ArduinoJson v7.x (Benoit Blanchon). No
other third-party libraries are needed;
LittleFS,Preferences,ETH,HTTPClientandNetworkClientSecureall ship with the core. - Partition scheme — the retained log measures ~1.22 MB on flash for
1 MiB of log text (LittleFS overhead is ~17%), so the filesystem partition
needs to be comfortably above that. "No OTA (1MB APP/3MB SPIFFS)" is what
scripts/build.shuses; the board's own default only gives ~1.5 MB. - USB CDC On Boot — enable it, so the console appears on the native USB
port and the device can tell when a terminal opens the port. Confirmed via
hardware test: without this,
Serialproduces no output on the native USB port at all.
Logs are written to /logs as rotating files; nothing is pre-allocated, so
first boot is not slow.
Reading the serial console on macOS. Opening the port resets the chip
(rst:0x17 CHIP_USB_UART_RESET), and plain cat/screen against
/dev/cu.usbmodem* proved unreliable here. If the console gives you nothing,
that is likely the host side rather than the firmware. Reading device state
out of NVS with esptool read-flash 0x9000 0x5000 and decoding it, or dumping
the filesystem partition directly, was far more dependable for debugging.
G44 is configured INPUT_PULLUP, so wire the contact between G44 and GND.
config set button no— normally open. Idle reads HIGH, pressing shorts to GND. This is the default.config set button nc— normally closed. Idle reads LOW, pressing opens the circuit. Use this if you want a cut wire to trigger a lockdown rather than go unnoticed.
Debounce is 50 ms (BUTTON_DEBOUNCE_MS).
Over the serial console:
config set unifi_ip 192.0.2.10
config set unifi_api_key <token from the UniFi Access console>
config set lockdown_exempt_policy_ids 6b1f2c40-0e4a-4a11-9d0e-2f7a9c5b1234,9c0d11a2-3e55-4f00-8a21-77b1c2d3e4f5
| Parameter | Meaning |
|---|---|
button |
no (normally open, default) or nc (normally closed) |
unifi_ip |
Controller IPv4 address |
unifi_hostname |
Controller DNS name |
unifi_use_hostname |
true to address by hostname, false (default) to use the IP |
unifi_api_key |
UniFi Access API token |
lockdown_exempt_policy_ids |
Comma-separated policies left in force during a lockdown |
Every policy that is not on the exempt list is cleared, so the list must name each policy that has to keep working during a lockdown — typically the security team's policy and an admin policy. Two things to check before go-live:
- Schedules ride along. An exempt policy still grants only what its own schedule allows. If the security team's policy is not on an Always schedule, a 2 AM lockdown locks them out too.
- Built-in policies. If the controller refuses API edits to a built-in
policy that is not exempt, engage fails partway and sits in
PARTIAL. The fix is to add that policy's ID to the exempt list.
Required set: unifi_api_key, lockdown_exempt_policy_ids, and either
unifi_ip or unifi_hostname depending on unifi_use_hostname. button
and unifi_use_hostname have defaults and are never "missing".
Until the required set is populated the device stays idle — it will not touch the controller even if the button is pressed — and the LED runs the unconfigured pattern.
unifi_api_key is marked secret. config get unifi_api_key reports
<set, N characters, hidden> and never the value; the token is likewise never
written to the log or echoed in an error. It is stored in NVS, which is not
encrypted by default — enable NVS encryption / flash encryption if the device
sits somewhere physically accessible.
| Command | Behaviour |
|---|---|
help, ? |
Status + command list + parameter summary + whether config is complete |
status |
Status block only |
policies |
Lists every controller access policy: ID, name, door-binding count, and whether it is exempt |
getlogs |
Streams every retained log file, oldest line first |
clearlogs |
Deletes every retained log file |
config get |
Every parameter: description, sample, current value, required-ness |
config get <parameter> |
One parameter |
config set <parameter> <value> |
Validates, stores, then prints the same block a get would |
test_lockdown_set |
Simulates the button being pressed |
test_lockdown_lift |
Simulates the button being released |
test_lockdown_clear |
Drops the simulation, follows G44 again |
config set with no parameter, or a parameter with no value, returns an error
followed by the help dialog. Unknown parameters and unknown commands do the
same.
The help dialog is also sent automatically when a terminal opens the serial port. On native USB CDC this fires each time a client connects; on a plain UART there is no connect signal, so it is sent once at boot.
test_lockdown_clear is not in your original list. Without it the device
stays stuck on the simulated state after a test, ignoring the real button, so I
added it as the way back. Drop it from Console.cpp if you would rather a
reboot be the only exit.
| Situation | Pattern |
|---|---|
| Configured and active | 0.5 s on, 3 s off |
| Configuration incomplete | 0.25 s on, 0.25 s off, 0.25 s on, 1 s off |
The pattern player in StatusLed.cpp takes an arbitrary list of on/off steps,
so adding a third pattern (a fast error blink, say) is a two-line change. I left
only the two you specified wired up.
GET /access_policies, filtered down toid,name,schedule_idandresourcesso a controller with many policies does not exhaust RAM.- Safety check — every ID in
lockdown_exempt_policy_idsmust be among the returned policies, and the list must name at least one. Otherwise nothing is cleared: a typo there would strip a policy meant to survive and leave nobody with door access. The failure is logged and retried. - The full policy list is written to
/policies.json(via a temp file and rename, so a power cut cannot leave a half-written snapshot). - Each policy except the exempt ones is
PUTback with an emptyresourcesarray.
On release, the snapshot is read back, each policy is PUT with its original
resources, and the snapshot file is deleted.
The snapshot file is the source of truth for "this device has modified the controller". It persists across reboots, so:
- Power loss mid-lockdown, then reboot with the button still held → the snapshot is reused, not overwritten, and the clearing pass is re-run.
- Power loss mid-lockdown, then reboot with the button released → the policies are restored automatically once the link is up.
- A failed API call part-way through a transition leaves the snapshot in place and retries with backoff (5 s, 10 s, 30 s, then 60 s), so a controller reboot or a flapping link does not strand the doors.
- Releasing the button restores whenever any snapshot exists, even if the clearing pass never finished. A half-engaged lockdown is always rolled back rather than left with some policies cleared.
status reports PARTIAL while a transition is incomplete. If the snapshot
itself ever becomes unreadable the device refuses to delete it and says so — the
policies then have to be restored by hand from /policies.json.
Every line goes to the serial port and into the newest file under /logs.
Lines carry a boot counter plus uptime until SNTP sets the clock, then a UTC
timestamp — so a dumped log tells you which power cycle a line came from.
Rotation happens on a flush boundary, so a line is never split across files.
Logged events: button edges (hardware and simulated), link up/down and DHCP lease, every configuration change (parameter name only, for secrets), every lockdown transition with policy counts, and every API failure with the HTTP status.
Writes are staged in a 4 KB RAM buffer and flushed every 5 seconds or when the
buffer is half full, which keeps flash wear down; getlogs flushes first, so it
always shows everything. At most ~5 seconds of log is lost in a hard crash.
- TLS is not verified. UniFi controllers present a self-signed certificate,
so
NetworkClientSecure::setInsecure()is used. On a flat VLAN shared with the controller that is a reasonable trade; if this crosses any untrusted segment, pin the controller CA withclient.setCACert()instead and add a parameter to carry it. - Least privilege. Scope the API token to access-policy read/write if your controller version supports scoped tokens.
- Blocking HTTP. API calls block
loop()for up to 15 s each. The LED pattern and the console pause during a transition. With a handful of policies this is not noticeable; with hundreds it will be, and the transition would want to move to a task on the second core. - No PHI or PII is stored on the device. The snapshot holds policy IDs, policy
names and door/door-group IDs only — no user records. If your policy names
embed personal information, treat
/policies.jsonand/logsas sensitive.
I'm programming an M5Stack M5UnitPoEP4 using the Arduino IDE. I want it to manage a UniFi Access policy at the press of a button. Here's a more specific requirement list:
The button status, ethernet connection changes, and configuration changes should be logged on the serial port (as well as memory).
Logs and configuration should be in persistant memory, so they last between power cycles.
A test_lockdown_set message should simulate the button being pressed
A test_lockdown_lift message should simulate the button being released
The button will be connected via the G44 input.
When the device is active (and configured), a light should blink in a pattern: .5s on, 3s off
If not all required configuration parameters have been set, a light should blink in a pattern: .25s on, .25s off, .25s on, 1s off.
If not all required configuration parameters have been set, the device will wait until they have been set to activate.
When the button is pressed:
The current access policies of all doors/door groups are fetched and stored in memory
All current access policies except the configured lockdown_access_policy_id are removed from all doors/door groups
When the button is released: The stored access policies are restored from memory and written to the appropriate doors/door groups
A getlogs message should return a 1mb log stream. (logs should roll off when the size limit is reached)
A help message returns a list of commands and subcommands with descriptions. It should also inform the user whether the device has been appropriately configured
When a client connects to the serial port, the help dialog should automatically be sent
Configuration commands should be allowed to be sent over the serial port with this structure: config set <parameter> <value> or config get <parameter>
Calling config get <parameter> should return the value of the parameter, along with a description of what it does and a sample value.
Calling config get without a parameter should return the information for all parameters.
Calling config set <parameter> without a value, or calling config set without a parameter should return an error and the help dialog.
Calling config set <parameter> <value> should set the value then return the same information as a get on that parameter.
Available configuration parameters:
button: can be set to nc to assume the button is normally closed, or no to assume it's normally open
unifi_ip: the IP address to use for the unifi API calls
unifi_hostname: the hostname to use for the unifi API calls
unifi_use_hostname: set to true to use the hostname for the API calls. Otherwise the device will use the IP address.
unifi_api_key: The API key to authenticate on the UniFi Access API
lockdown_access_policy_id: the access policy ID to be set when the button is pressed