# Sol T-Dongle Universal Node

`sol-tdongle` turns a LILYGO T-Dongle-S3 into a local-first, authenticated Sol
display and physical-I/O node. The device joins known Wi-Fi automatically,
initiates its own authenticated connection to `sol-node-daemon`, advertises
capabilities, survives DHCP and host restarts, accepts bounded display/media
commands, and emits authenticated physical events.

The repository contains the ESP32-S3 firmware, host daemon, command-line tools,
Unified Clock manual-control integration, broadcast-display adapter,
provisioning and OTA helpers, systemd service, protocol documentation, tests,
and physical camera evidence.

## Current live state

The remotely deployed unit was verified on 2026-08-30 with:

| Item | Live value |
| --- | --- |
| Device ID | `sol-dongle-f2339c` |
| Aliases | `pocket-display`, `tdongle` |
| Firmware | `0.7.3` |
| Hardware | LILYGO T-Dongle-S3, ESP32-S3, no PSRAM |
| Display | ST7735, 160x80 landscape color |
| Network | Six-profile Wi-Fi/DHCP, verified local mDNS WebSocket; Cloudflare WSS fallback configured |
| Address at verification | `10.0.1.143` (not an API contract) |
| OTA partition | `app0`, valid, rollback verification complete |
| External port | GPIO43/44 active-low button inputs with internal pull-ups, 200 ms debounce and 3 s arming; active control permission is false |
| Power source report | `usb_host` while attached to the workstation; non-enumerating sources remain `power_only_or_unknown` |
| Configured bank | Samsung 20,000 mAh, provisional estimate profile |
| Time | NTP-synchronized Central time with DST and displayed seconds |

The DHCP address is observational. Clients use the registry, mDNS, node ID, or
stable alias rather than assuming that address will remain assigned.

## What the node does

- Boots autonomously from any suitable USB power source.
- Reconnects to up to 12 remembered Wi-Fi networks and the Sol host without USB data access.
- Authenticates the server and node with a per-device HMAC secret.
- Displays online state, IP address, estimated power-bank runtime, Central
  network time to the second, RSSI, and device suffix on the default screen.
- Accepts text, status, progress, notifications, RGB565 images, and silent
  video at up to 4 FPS.
- Runs bounded declarative animation timelines across the LCD and APA102 LED.
- Controls the onboard APA102 RGB LED directly or with built-in blink, pulse,
  alternate, and rainbow patterns.
- Reports telemetry, OTA health, reset cause, free heap, Wi-Fi state, and
  external-port state.
- Emits sequenced button and GPIO events with durations and replay metadata.
- Supports guarded GPIO read/write/watch/pulse/PWM/counter operations.
- Supports explicit UART and I2C modes on GPIO43/GPIO44.
- Returns both external pins to safe input after the trusted host disappears,
  unless an explicitly authorized policy says otherwise.
- Registers as a castable Sol broadcast display for other skills.
- Shows an Internet-capable Unified Clock control QR after a three-second
  onboard-button hold.

It intentionally does not expose arbitrary shell execution, unrestricted job
execution, a scripting runtime, or anonymous LAN control.

## System architecture

```text
Unified Clock / Sol skills / CLI
              |
              | local bearer-protected HTTP
              v
       sol-node-daemon :8766
              ^
              | authenticated WebSocket (LAN)
              | or configured TLS fallback via Cloudflare
              | node initiates connection
              |
     LILYGO T-Dongle-S3
       | display + LED
       | button GPIO0
       | GPIO43 / GPIO44
       | Wi-Fi + USB power
```

The browser never receives the node secret or Wi-Fi credentials. Unified Clock
talks to the protected host API. The daemon owns authenticated node sessions,
command acknowledgments, telemetry, the node registry, physical-event policy,
and replay/deduplication. The ESP32 only implements bounded capabilities.

## Phone QR and Internet path

Hold GPIO0, the onboard back/BOOT button, for three seconds during normal
operation. The LCD shows a 25-module QR code at 2x scale for:

```text
https://sol.system42.one/e
```

The short route passes through the existing Cloudflare tunnel, requires the
existing Unified Clock login, and redirects to
`/unified-clock/app/#esp`. It does not contain a session token, node secret, or
Wi-Fi password. The QR remains visible for 60 seconds or until dismissed.

At home, the node gives its primary saved profile a 15-second exclusive join
window, then permits the other remembered profiles. It prefers
`_solserver._tcp` mDNS and a direct authenticated WebSocket. After two failed
local routes it can promote the configured public route
`wss://sol.system42.one/sol-node/api/nodes/ws`. The public reverse proxy exposes
only the node WebSocket route and the normal HMAC challenge remains mandatory.
The local route and public web control page are live-verified; an authenticated
firmware session through the public WSS fallback remains a separate acceptance
test before relying on hotspot-only operation.

The `control_qr` physical event is logged and published to the event bus but is
excluded from human notifications and sound. Holds from one to three seconds
retain held-duration reporting on release.

## Saved Wi-Fi profiles

Firmware stores up to 12 unique WPA-PSK SSIDs in a dedicated NVS namespace.
The authenticated `wifi.sync` command writes the new profile count last, so a
power loss during the update falls back to the original USB-provisioned network
instead of accepting a partial list. After a changed set is acknowledged, the
node reboots once. Firmware first gives profile zero a 15-second exclusive join
window, then uses `WiFiMulti` if the primary network is unavailable.

Synchronize this workstation's current NetworkManager profiles without printing
passwords:

```bash
python3 scripts/sync_wifi_from_networkmanager.py --dry-run
python3 scripts/sync_wifi_from_networkmanager.py
```

Duplicate connection profiles for one SSID are collapsed. The current set
includes the saved iPhone Personal Hotspot. Root access is used only to read
NetworkManager's system-connection secrets; the helper sends them directly to
the local bearer-protected daemon and does not place them in Unified Clock or
project evidence.

## Default device screen

Firmware `0.7.3` uses the default online screen as a compact dashboard:

```text
SOL ONLINE
IP 10.0.1.143
PWR ~100% / 52h MAX
03:56:00 AM CDT
-70 dBm  f2339c
```

The clock uses SNTP servers and the POSIX Central-time rule
`CST6CDT,M3.2.0/2,M11.1.0/2`, so the displayed zone follows CST/CDT instead of
using a fixed UTC offset. The screen is refreshed once per second only while
default content is active. Transient notifications, physical-input feedback,
images, and video retain their existing display priority and timeout behavior.

The power row is an estimate and includes `MAX` deliberately. It must not be
read as a measurement from the battery bank.

## Power visibility and estimate

### What the hardware can observe

The board receives USB VBUS through its regulator. It has no battery fuel
gauge, USB-PD telemetry path, current shunt, or ADC-readable VBUS divider.
Therefore the node can report:

- whether USB start-of-frame traffic proves an enumerating USB host exists
- that VBUS is powering the running node
- boot-session duration
- reset reason
- whether network time is synchronized

The node cannot directly report:

- actual power-bank percentage
- actual remaining mAh or Wh
- whether a non-enumerating source is a wall adapter or battery bank
- battery voltage, output current, temperature, or charge-controller state

For that reason, telemetry explicitly carries:

```json
{
  "source": "power_only_or_unknown",
  "usb_host_detected": false,
  "battery_percent_available": false,
  "capacity_available": false,
  "measurement": "inferred_no_power_sensor"
}
```

### Host-configured estimate

Unified Clock stores a separate estimate profile and synchronizes it to the
node with authenticated command `power.estimate.configure`. The profile is
persisted in ESP32 NVS, so the main screen continues to show the estimate after
a host or node restart. Editing and saving the form updates both the host and
device.

The current default profile is:

| Field | Current assumption |
| --- | ---: |
| Label | Samsung 20,000 mAh (provisional) |
| Rated capacity | 20,000 mAh at cell voltage |
| Cell voltage | 3.7 V |
| Conversion efficiency | 85% |
| Average dongle load | 1.2 W |
| Starting charge at reference time | 100% |

The calculation is:

```text
usable Wh = rated Ah x cell voltage x conversion efficiency
consumed Wh = elapsed hours x assumed average watts
remaining Wh = start Wh - consumed Wh
estimated hours = remaining Wh / assumed average watts
```

With the provisional values, usable energy is about 62.9 Wh and the full-bank
upper-bound runtime is about 52 hours. Actual runtime can differ substantially
because the load and conversion losses are not measured and the bank may not
have started full.

After one uninterrupted discharge from a known-full bank, refine the assumed
load using:

```text
calibrated average watts = usable Wh / observed runtime hours
```

An inline USB power meter or queryable smart battery is required for direct
electrical measurement.

## Unified Clock control surface

Open the `ESP Device` pane in Sol Unified Clock:

```text
/unified-clock/app/#esp
```

The control surface provides:

- keyboard-accessible control panels collapsed by default, while connection
  metrics and target selection remain visible
- live connection, firmware, IP, RSSI, uptime, and capabilities
- observed USB-source classification and power-session details
- editable battery-bank estimate profile synchronized to the main screen
- text, status, progress, clear, wake, and sleep controls
- timed notifications with severity and acknowledgment
- APA102 color, brightness, and off controls
- built-in LED patterns with adjustable rate, colors, brightness, and finite or
  continuous looping
- a bounded JSON animation-timeline editor spanning display and LED output
- direct rendering of the authenticated Internet control QR
- image upload and silent-video casting
- telemetry and physical-state inspection
- safe GPIO reads and guarded external-port controls
- explicit `port.safe`
- confirmed node reboot

See [`docs/MANUAL_CONTROL.md`](docs/MANUAL_CONTROL.md) for the control and
safety contract.

## Display and LED animation timelines

`animation.play` accepts a declarative timeline with 1-24 steps. Each step lasts
50-60,000 ms and can independently leave the display/LED unchanged or update
them. `repeat: 0` loops until stopped; finite repeats are limited to 1-1000.
The runtime is non-blocking and RAM-only, so button handling, telemetry, and
network reconnection continue while a sequence plays.

```json
{
  "name": "SOL systems tour",
  "repeat": 0,
  "steps": [
    {
      "duration_ms": 3300,
      "display": {"type":"status","title":"SOL NETWORK","status":"AUTHENTICATED","detail":"pocket-display ready","accent":"green"},
      "led": {"type":"set","r":0,"g":220,"b":80,"brightness":72}
    },
    {
      "duration_ms": 1050,
      "display": {"type":"keep"},
      "led": {"type":"set","r":0,"g":120,"b":35,"brightness":24}
    },
    {
      "duration_ms": 1350,
      "display": {"type":"progress","title":"SYSTEM SYNC","percent":20,"accent":"blue"},
      "led": {"type":"set","r":0,"g":80,"b":255,"brightness":35}
    },
    {
      "duration_ms": 1350,
      "display": {"type":"progress","title":"SYSTEM SYNC","percent":50,"accent":"cyan"},
      "led": {"type":"set","r":0,"g":180,"b":255,"brightness":55}
    },
    {
      "duration_ms": 1350,
      "display": {"type":"progress","title":"SYSTEM SYNC","percent":80,"accent":"yellow"},
      "led": {"type":"set","r":255,"g":150,"b":0,"brightness":68}
    },
    {
      "duration_ms": 1950,
      "display": {"type":"progress","title":"SYSTEM SYNC","percent":100,"accent":"green"},
      "led": {"type":"set","r":0,"g":255,"b":90,"brightness":82}
    },
    {
      "duration_ms": 4200,
      "display": {"type":"lines","title":"POWER ESTIMATE","lines":["NOW: {{battery_percent}}","RUNTIME: {{battery_hours}}"],"accent":"green"},
      "led": {"type":"set","r":30,"g":220,"b":70,"brightness":58}
    },
    {
      "duration_ms": 4500,
      "display": {"type":"lines","title":"PROFILE INPUT","lines":["20,000 mAh @ 3.7 V","NOT USB TELEMETRY"],"accent":"orange"},
      "led": {"type":"set","r":255,"g":90,"b":0,"brightness":54}
    },
    {
      "duration_ms": 4500,
      "display": {"type":"lines","title":"USABLE ENERGY","lines":["mAh / 1000 x VOLTS","x EFFICIENCY = Wh"],"accent":"yellow"},
      "led": {"type":"set","r":255,"g":180,"b":0,"brightness":58}
    },
    {
      "duration_ms": 4500,
      "display": {"type":"lines","title":"TIME REMAINING","lines":["START % - ELAPSED LOAD","MODELED, NOT MEASURED"],"accent":"cyan"},
      "led": {"type":"set","r":0,"g":180,"b":255,"brightness":48}
    },
    {
      "duration_ms": 3600,
      "display": {"type":"lines","title":"BROADCAST NODE","lines":["DISPLAY + MEDIA READY","CAST TARGET ONLINE"],"accent":"blue"},
      "led": {"type":"set","r":0,"g":120,"b":255,"brightness":62}
    },
    {
      "duration_ms": 960,
      "display": {"type":"keep"},
      "led": {"type":"off"}
    },
    {
      "duration_ms": 3150,
      "display": {"type":"lines","title":"PHYSICAL INPUT","lines":["BUTTON + GPIO EVENTS","HOST BUS ARMED"],"accent":"yellow"},
      "led": {"type":"set","r":255,"g":90,"b":0,"brightness":64}
    },
    {
      "duration_ms": 3150,
      "display": {"type":"status","title":"REMOTE CONTROL","status":"HOLD BUTTON 3 SEC","detail":"local + Internet QR","accent":"white"},
      "led": {"type":"set","r":190,"g":40,"b":255,"brightness":58}
    },
    {
      "duration_ms": 540,
      "display": {"type":"clear"},
      "led": {"type":"off"}
    },
    {
      "duration_ms": 3750,
      "display": {"type":"status","title":"SOL","status":"ONLINE","detail":"systems nominal","accent":"green"},
      "led": {"type":"set","r":0,"g":200,"b":70,"brightness":46}
    }
  ]
}
```

This 16-step, approximately 43-second systems-tour preset is loaded by default
in Unified Clock. It
demonstrates independent display and LED holds, staged progress, broadcast and
physical-input states, a remote-control hint, and a brief output blackout
before returning to the online state. Use **Load systems tour** to restore the
preset after editing the JSON. Immediately before playback, Unified Clock
refreshes device telemetry and replaces `{{battery_percent}}` and
`{{battery_hours}}` with the current host/device estimate. These are the only
template tokens; they cannot invoke code or arbitrary data access.

Display step types are `keep`, `clear`, `lines`, `status`, `progress`, and
`control_qr`. LED step types are `keep`, `off`, and `set`. Manual display/LED
commands and notifications stop the active timeline and restore ordinary node
ownership. `animation.stop` restores the default dashboard/status LED;
`animation.status` reports the current bounded cursor. This is intentionally not
a general scripting runtime: it cannot execute shell commands, access files,
open network connections, or manipulate GPIO.

## Display and media

Host FFmpeg decodes image/video containers and scales frames before delivery.
The ESP32 receives only exact 160x80 RGB565LE frames; it does not parse
untrusted media containers.

```bash
.venv/bin/sol-node display sol-dongle-f2339c "System nominal" --title SOL
.venv/bin/sol-node notify sol-dongle-f2339c "Front server offline" \
  --title SOL --severity warning --timeout 10
.venv/bin/sol-node image sol-dongle-f2339c photo.jpg --fit contain
.venv/bin/sol-node video sol-dongle-f2339c clip.mp4 --fps 4 --fit cover
.venv/bin/sol-node video sol-dongle-f2339c animation.gif --fps 4 --loop
```

Fit modes are `contain`, `cover`, and `stretch`. The verified practical limit
is approximately 4 FPS. The dongle has no audio output; video casting is silent.

## Broadcast-display integration

Other Sol skills use stable aliases through `sol-broadcast-display` rather
than calling the node transport or reading credentials:

```bash
sol-broadcast-display list
sol-broadcast-display status pocket-display
sol-broadcast-display message pocket-display "Registry online" --title CODEX
sol-broadcast-display notify pocket-display "Front server offline" \
  --severity warning
sol-broadcast-display cast pocket-display image.png --fit contain
sol-broadcast-display cast pocket-display clip.mp4 --fit cover --fps 4
```

The registry file is `~/.config/sol/broadcast-displays.json` and contains
metadata, not node secrets. See
[`docs/BROADCAST_DISPLAY.md`](docs/BROADCAST_DISPLAY.md).

## Physical interaction events

Host daemon `0.5.1` introduced passive child-device discovery and logical GPIO event
annotations without changing firmware `0.5.4`. List built-in children or staged
peripheral declarations using `.venv/bin/sol-node devices pocket-display`.
Unified Clock's ESP Device pane shows the same catalog, including unverified
state and per-input policies. Listing/reloading metadata sends no hardware
commands. See [`docs/EXTERNAL_DEVICES.md`](docs/EXTERNAL_DEVICES.md) for the
configuration schema, host `i2c.device.list`, and exact implementation limits.

The onboard GPIO0 button and explicitly watched GPIO43/GPIO44 inputs emit
authenticated events with node identity, boot ID, sequence number, monotonic
timestamp, duration where available, server receipt time, and replay state.

```bash
.venv/bin/sol-node physical-events --limit 20
.venv/bin/sol-node physical-state
.venv/bin/sol-node physical-config
.venv/bin/sol-node physical-reload
```

Default-screen button presses enter the generic Sol physical-event pipeline.
Transient-screen presses dismiss the content and restore the dashboard without
also firing the normal button action. The host can log, chime, open Custom
Notifier, send a private Unified Clock push, and publish a generic
`sol.physical.*` event according to per-input policy.

The node keeps 16 recent unacknowledged events in RAM and replays them after a
short disconnect. It does not wear flash by persisting every edge. See
[`docs/PHYSICAL_EVENTS.md`](docs/PHYSICAL_EVENTS.md).

## External-port safety

The side connector exposes 3.3 V logic, ground, GPIO43, and GPIO44. The signal
pins are not 5 V tolerant.

Every cold boot starts with:

```text
GPIO43 = INPUT, no pull, high impedance
GPIO44 = INPUT, no pull, high impedance
```

After stored configuration validates, firmware `0.7.3` enables an active-low
button profile on both pins using internal pull-ups, 200 ms debounce, and a
three-second released-state arming window. It never drives either pin. The live
human notification policy remains `never` for these unconnected inputs. Output
configuration requires an atomic initial value.
Writes to input pins are rejected. PWM stop returns the pin to safe input by
default. Mode transitions stop active peripherals before changing the pins.
Loss of the trusted host applies the configured failsafe, which defaults to
input after five seconds for GPIO outputs/PWM. Firmware `0.6.0` stops UART/I2C on detected host loss and returns both pins
to SAFE_GPIO; reconnect does not re-enable buses.

Use the emergency-safe command at any time:

```bash
.venv/bin/sol-node command sol-dongle-f2339c port.safe '{}'
```

I2C requires suitable external 3.3 V pull-ups unless the attached module
already supplies them. ADC capability is not advertised for GPIO43/44.

## Security model

- Initial enrollment is physical and USB-based.
- Each node receives an independent random secret stored locally.
- The node authenticates the server before returning its proof.
- Commands are accepted only on the authenticated node session.
- The daemon and Unified Clock enforce command allowlists and payload bounds.
- The browser never receives node credentials.
- Wi-Fi passwords are never returned by status APIs.
- OTA uses the existing authenticated Arduino OTA path.
- A new OTA image remains rollback-pending until display initialization,
  authenticated reconnect, telemetry, and a health interval succeed.
- The complete 16 MB factory image remains a separate physical recovery path.

The current unit did not ship with ESP32 secure boot or flash encryption
enabled. HMAC session authentication and OTA password protection reduce LAN
risk but are not equivalent to signed secure boot. See
[`docs/PROTOCOL.md`](docs/PROTOCOL.md) and
[`docs/PROVISIONING.md`](docs/PROVISIONING.md).

## Repository layout

```text
sol-tdongle/
  firmware/                  ESP32-S3 PlatformIO firmware
  daemon/sol_node_daemon/    host daemon, CLI, and broadcast adapter
  daemon/tests/              daemon tests
  docs/                      hardware, protocol, operation, and recovery docs
  evidence/                  acceptance records and C615/UI captures
  protocol/examples/         protocol examples
  scripts/                   provisioning, OTA, and camera helpers
  systemd/                   user-service definition
  vendor/                    pinned official LILYGO reference snapshot
```

## Development setup

The checked-in project uses a local virtual environment:

```bash
cd /home/david/projects/sol-tdongle
python3 -m venv .venv
.venv/bin/pip install -e . pytest platformio
```

Build firmware:

```bash
cd firmware
../.venv/bin/pio run
```

Run daemon tests:

```bash
cd /home/david/projects/sol-tdongle/daemon
../.venv/bin/python -m pytest -q
```

The `0.7.3` build uses 17.0% of ESP32 RAM and 36.0% of the 3 MiB application
partition.

## Host service

The user service runs from this project and restarts on failure:

```bash
systemctl --user status sol-node-daemon.service
journalctl --user -u sol-node-daemon.service -n 100 --no-pager
curl -fsS http://127.0.0.1:8766/health | jq
.venv/bin/sol-node list
```

The service advertises `_solserver._tcp`, listens on port 8766, and stores
private registry/event state under `~/.local/share/sol-tdongle/` with
configuration under `~/.config/sol-tdongle/`.

## Provisioning

Initial provisioning requires physical USB because it installs Wi-Fi
credentials and the per-node secret. Normal operation and later OTA updates do
not require a USB data connection.

1. Enroll the hardware MAC with `sol-node enroll`.
2. Build and flash the verified firmware.
3. Run `scripts/provision_from_networkmanager.py` over the stable serial path.
4. Confirm the node appears connected with `sol-node list`.

Secrets are transferred locally and are not printed. USB provisioning is
preferred to the fallback AP. Full procedure:
[`docs/PROVISIONING.md`](docs/PROVISIONING.md).

## Safe OTA workflow

Before OTA:

1. Confirm the exact device ID, firmware, boot ID, IP, RSSI, uptime, OTA state,
   and external-port state.
2. Back up every host and firmware source file that will change.
3. Verify the complete recovery image checksum without modifying that image.
4. Build and test the firmware.
5. Confirm the image fits the inactive 3 MiB OTA slot.
6. Run the OTA helper in dry-run mode.

```bash
python3 scripts/ota_update.py --dry-run \
  firmware/.pio/build/tdongle-s3/firmware.bin
python3 scripts/ota_update.py \
  firmware/.pio/build/tdongle-s3/firmware.bin
```

After OTA, require a new boot ID, automatic authenticated reconnect, the new
firmware version, valid OTA state, initialized display, fresh telemetry,
unchanged safe GPIO state, and physical camera evidence before declaring the
update complete.

Do not improvise a remote flashing path if OTA capacity, rollback, target
identity, or recovery semantics are unclear.

## Verification and evidence

The `0.7.3` animation and network-priority update was verified with:

- successful PlatformIO firmware build
- 99 passing project tests
- 10 passing Unified Clock ESP-control tests
- successful Unified Clock production renderer build
- successful Electron main-process syntax check
- direct USB application flash through the stable by-id path and automatic
  authenticated reconnect
- new boot ID `df8a7002`
- `time_synchronized=true`
- persisted power estimate in fresh telemetry
- six saved Wi-Fi profiles including the iPhone hotspot; primary `system`
  profile selected at `10.0.1.143`
- GPIO43/44 still inputs, internally pulled up, watched, notification-disabled,
  and never output-driven
- active daemon, Unified Clock backend, and Unified Clock tray services
- C615 frames showing looped LCD status/progress frames, visible red/green LED
  states, the control QR, and the restored status dashboard

Primary evidence:

- [`evidence/animation-20260830/`](evidence/animation-20260830/): live `0.7.3`
  display/LED timeline, QR decode, reconnect, and restored-dashboard evidence.

- [`evidence/external-devices-20260830T043155/acceptance.md`](evidence/external-devices-20260830T043155/acceptance.md): host `0.5.1` child-device foundation, 68 daemon tests, unchanged firmware `0.5.4`, live reconnect, mobile UI and C615 verification; includes reconstructed firmware safety limitations.

- [`evidence/power-inference-ota-acceptance-20260830.md`](evidence/power-inference-ota-acceptance-20260830.md)
- [`evidence/c615/main-screen-power-time-054-a.png`](evidence/c615/main-screen-power-time-054-a.png)
- [`evidence/c615/main-screen-power-time-054-b.png`](evidence/c615/main-screen-power-time-054-b.png)
- [`evidence/ui/power-inference-samsung-20000-053.png`](evidence/ui/power-inference-samsung-20000-053.png)
- [`evidence/gpio-ota-acceptance-20260830.md`](evidence/gpio-ota-acceptance-20260830.md)

Use `scripts/capture_c615_bench.py` for physical display evidence. It consumes
the existing Camera Bench feed without reopening `/dev/video1` or interrupting
the live camera process.

## Recovery

The original complete flash image is stored outside the repository at:

```text
/home/david/backups/sol-tdongle/original-20260829T1620-CDT/
```

Expected SHA-256 for the 16 MB recovery image:

```text
66134ed7a6ea2d41e958db5930e5fc7540f94ef772be1ea91a761563c11de4ab
```

Physical recovery is destructive and requires an explicit decision. Never
overwrite or repurpose the historical recovery image. See
[`docs/PROVISIONING.md`](docs/PROVISIONING.md) for the exact esptool command and
BOOT-button procedure.

## Known limitations

- Firmware `0.7.3` implements provisioning permission enforcement and bounded
  I2C/UART operations. Actual electrical transactions, UART-derived physical
  events, and active bus failsafe remain bench-unverified.
- Configured peripheral names do not prove attachment. Sensor/expander drivers
  remain future work. See `docs/EXTERNAL_DEVICES.md` for exact limits.

- Battery percentage and runtime are estimates, not electrical measurements.
- A wall adapter and ordinary battery bank are indistinguishable over
  power-only USB.
- A firmware reboot preserves the profile/reference timestamp but cannot know
  whether the power bank was swapped or recharged while the node was off.
- Video is silent and limited to roughly 4 FPS at 160x80.
- The portable node has no fixed room or privacy context.
- GPIO43/44 are 3.3 V only and expose no advertised ADC channel.
- I2C may require external pull-ups.
- ESP32 secure boot and flash encryption are not enabled on the current unit.
- The firmware public-WSS path is configured but still needs a successful live
  authenticated hotspot-only acceptance run.
- The main screen reports Central time only while network time is available;
  before first synchronization it displays `TIME SYNC...`.

## Additional documentation

- [`docs/PROJECT_SPEC.md`](docs/PROJECT_SPEC.md): complete project objectives
- [`docs/HARDWARE.md`](docs/HARDWARE.md): exact unit and pin record
- [`docs/PROTOCOL.md`](docs/PROTOCOL.md): authenticated wire protocol
- [`docs/PROVISIONING.md`](docs/PROVISIONING.md): enrollment and recovery
- [`docs/MANUAL_CONTROL.md`](docs/MANUAL_CONTROL.md): Unified Clock controls
- [`docs/PHYSICAL_EVENTS.md`](docs/PHYSICAL_EVENTS.md): event and push pipeline
- [`docs/BROADCAST_DISPLAY.md`](docs/BROADCAST_DISPLAY.md): skill-facing display contract
- [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md): failure diagnosis

## Bounded external buses (0.6.0)

This increment adds I2C allowlisted scan/read/write, bounded UART buffering,
framing and line/raw events, firmware provisioning enforcement, proof-before-auth
sequencing, and bus host-loss shutdown. It preserves the passive device catalog,
node identity, display/media, inferred power labels, and physical-event policy.
See [external devices](docs/EXTERNAL_DEVICES.md) for safe commands and bounds;
timestamped acceptance under `evidence/` distinguishes tests from physical proof.

Acceptance: [0.6.0 bounded bus and safety update](evidence/bounded-bus-20260830T045139/acceptance.md).
Live boot `9b0a5c77` replaced `75372a7f` through authenticated OTA; the display,
safe reads, negative authorization checks, time sync, rollback clearance, and
same-boot daemon reconnect were verified. Active I2C/UART electrical behavior
remains unverified, and the existing false provisioning permission was preserved.

## Default GPIO buttons (0.6.1)

GPIO43 and GPIO44 listen as `gpio43-button` / `gpio44-button` by default,
including without the host. Firmware 0.6.2 superseded the original floating
no-pull profile with internal pull-ups, 200 ms debounce, and three-second
released-state arming. GPIO events are logged, but human notifications remain
disabled for the unconnected pins. GPIO0 held notification remains enabled. No
drive permission was enabled. See [default buttons](docs/DEFAULT_BUTTONS.md).

## QR, Internet fallback, and multi-Wi-Fi (0.7.0)

Firmware 0.7.x adds the three-second control QR gesture, a protected short URL,
a configured TLS Cloudflare WebSocket fallback, up to 12 remembered WPA
networks, primary-profile grace, and Wi-Fi route/count telemetry. Unified Clock
displays the same short link, saved-network count, and active Sol route. The
public control page is verified; firmware WSS transport still requires a live
hotspot-only acceptance run. The general GPIO permission remains disabled and
the complete recovery image is unchanged.
