Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

UniFi Access Lockdown Controller — M5Stack M5UnitPoEP4

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.


Files

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.


Before the first flash — things to verify

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_HIGH to 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.


Board and library setup

Option A — arduino-cli (scripted)

./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.

Option B — Arduino IDE

  1. Boards Manager — install esp32 by Espressif Systems, v3.1.0 or newer (ESP32-P4 support and the ETH.h / Network.h APIs used here).
  2. Board — select ESP32P4 Dev Module (see note above on why there's no M5-specific entry to pick instead).
  3. Library Manager — install ArduinoJson v7.x (Benoit Blanchon). No other third-party libraries are needed; LittleFS, Preferences, ETH, HTTPClient and NetworkClientSecure all ship with the core.
  4. 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.sh uses; the board's own default only gives ~1.5 MB.
  5. 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, Serial produces 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.


Wiring the button

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).


Configuration

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.


Console commands

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.


LED patterns

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.


What happens on a press

  1. GET /access_policies, filtered down to id, name, schedule_id and resources so a controller with many policies does not exhaust RAM.
  2. Safety check — every ID in lockdown_exempt_policy_ids must 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.
  3. 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).
  4. Each policy except the exempt ones is PUT back with an empty resources array.

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.


Logging

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.


Hardening notes

  • 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 with client.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.json and /logs as sensitive.

Initial Prompt for Claude

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:

Functionality:

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.

Button Behavior

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

Serial Port Interaction

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

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages