|
Ersa Wearable Platform
Embedded smartwatch firmware and application framework
|
Provide a small, scriptable USB control surface for inspecting and operating an Ersa watch during development. The host tool should feel familiar to someone who has used adb, while remaining a narrow Ersa protocol rather than implementing the ADB protocol. It must coexist with normal watch operation: USB commands cannot block the UI loop, BLE callbacks, display work, or power management.
The first transport is the ESP32-C3 USB Serial/JTAG console. Keep the protocol transport-neutral so a later BLE or network transport can reuse command handling with a separate policy.
ewctl ota reports the selected board's name, codename, manufacturer, running app slot, ESP-IDF image state, the other slot's bootability, update target, partition sizes, and whether rollback confirmation is pending. ewctl ota boot-other validates the alternate image, selects it through ESP-IDF OTA metadata, replies over USB, then reboots into it. The equivalent updater-screen shortcut is a B2 double-click. ewctl status includes compiled firmware version, name, codename, manufacturer, running slot, and a human-readable current boot uptime. --json retains the raw version and uptime_seconds fields.
The host client lives at scripts/ewctl.py and uses Python, pyserial, and Rich (python3 -m pip install -r requirements-ewctl.txt). It renders readable Rich tables by default; pass --json for machine-readable output. Examples: python3 scripts/ewctl.py status, python3 scripts/ewctl.py power cpu-freq-set 40, python3 scripts/ewctl.py poll status battery ble power --interval 5, and python3 scripts/ewctl.py logs --follow. Follow mode streams the live USB console without sending logs.read requests; redirect it with python3 scripts/ewctl.py logs --follow > watch.log or use logs --output watch.log. logs --raw also selects direct raw console capture. Stop raw capture with Ctrl-C. Pass --port /dev/ttyACM0 if auto-detection is ambiguous. The firmware endpoint is implemented in src/core/usb_control.cpp; the watch must run a build containing it.
Use ewctl version to print the installed CLI release, six-character Git hash, and source branch without connecting to the watch. Arch package builds pull their CLI source from master and embed the hash of the master commit used for that package; a tag-triggered release does not package the tag's CLI tree.
ewctl flash firmware.bin flashes an app image through the ESP32 ROM bootloader. ewctl flash release 0.4.0 downloads the C3 image by default; pass --platform xiao_esp32c6 for C6. Use --factory for the matching platform's factory image. Release downloads are verified against the platform-specific SHA-256 manifest before caching. The image argument can be omitted to use the local C3 build. This uses the same ROM bootloader process and esptool wrapper as scripts/flash_firmware.sh, not the USB control bridge. The app-image path writes the fixed ota_0 address; after migration to the dual-slot table, use the on-device updater app for routine Wi-Fi updates because it selects the inactive slot and keeps bootloader rollback metadata consistent.
ewctl power get-dvfs-state captures ESP-IDF's current PM lock and CPU residency report on demand. It reports the measured CPU clock, lock counts/timing, and time in each PM mode, including sleep. ewctl power log samples battery voltage, CPU frequency, power state, BLE state, uptime, and heap to a timestamped CSV. Set --interval 30 --duration 7200 --output run.csv; duration 0 records until Ctrl-C. USB monitoring blocks the watch's normal sleep policy, so this helps compare telemetry but does not measure normal battery life.
Provision settings over USB with ewctl config get. The getter reports which passwords are present without returning their values. Set each group independently with config wifi, config caldav, config time, or config hotspot; these commands write the same persisted settings used by the setup portal. The previous flat config set form remains available for scripts that already use it:
Passwords are sent over the USB control bridge and are never returned by config get. Avoid putting secrets directly in shell commands on shared hosts because shells may save command history.
ewctl time status reports the current software wall-time epoch, a direct chip readback epoch, whether the RTC is readable/healthy, whether its oscillator-stop flag is set, and the most recent chip-to-software drift measurement. Drift is marked unavailable until a periodic comparison has run. ewctl time set <epoch> writes the watch wall time directly; the epoch is interpreted as the wall-clock value shown on the watch (the firmware stores the phone's local clock fields without applying a timezone conversion).
ewctl debug bundle creates a ZIP bug report with status snapshots and up to 16 recent logs. It redacts Wi-Fi SSIDs, tokens, and URLs from log lines. Add --include-coredump to decode and include the raw core; that opt-in dump may contain arbitrary task memory and should be reviewed before sharing.
New firmware builds save ESP-IDF panic coredumps in the existing 64 KiB coredump flash partition, using ELF format and a CRC32 integrity check. After a panic and reboot, connect USB and decode the saved dump with the exact ELF used to build the flashed firmware:
The command reads the coredump partition over the ROM bootloader, prints task/register/backtrace details, and uses .pio/build/ErsaWearable/firmware.elf by default. Retain that ELF for each flashed build: symbols from a different build can produce misleading function names. If the ELF is elsewhere, pass --elf /path/to/firmware.elf; if ESP-IDF is installed outside this checkout, pass --idf-path /path/to/esp-idf. The decoder also needs the RISC-V ESP GDB toolchain; the local PlatformIO package is detected automatically, otherwise put riscv32-esp-elf-gdb on PATH or pass --gdb. Use --save-core panic.elf to retain the extracted dump for sharing. A missing or invalid saved dump is reported by ESP-IDF's decoder.
Core dumps are disabled in firmware builds predating this setting. Flash the new firmware before expecting a panic dump. Since a dump captures task memory, it may include transient notification or other user data; review it before sharing the saved core file.
The endpoint is polled from the firmware application loop. It reads at most 64 bytes and dispatches at most one complete request per pass; it does not create a second task or call application code from a USB callback. A fixed 512-byte line buffer bounds memory use. Status and log handlers are queries; CPU frequency commands are narrowly scoped development settings. Long operations and job polling are not implemented.
The USB transport uses the existing Arduino Serial USB Serial/JTAG console. The parser does not depend on a JSON library. Firmware diagnostics are kept in a bounded ring and raw log output is suppressed after the first valid protocol request, preventing log text from corrupting replies.
Use newline-delimited JSON (NDJSON) initially. It is easy to inspect with a terminal while remaining straightforward to parse in a host script. Requests are limited to 512 bytes; replies are limited to 2048 bytes so a bounded batch of diagnostic lines fits. Reject overlong or malformed requests and discard input through the next newline after a framing error. Never allocate from an untrusted length field.
Each request includes a protocol version, request ID, command, and optional arguments:
A completed command returns exactly one response carrying the same ID:
Errors are structured and stable for scripts:
The wire format reserves asynchronous job replies for future commands:
The current endpoint has no asynchronous jobs or dispatch queue. It processes one request at a time and echoes the request ID. job.get returns unsupported. Commands must remain idempotent where practical.
Implemented commands:
| Command | Result |
|---|---|
system.status | Compiled firmware version/build identity, selected-board name/codename/manufacturer, running OTA slot, uptime, reset reason, active app, heap, USB session, power state |
battery.read | Latest voltage and estimated percentage, with sample age and validity |
ble.status | Link and advertising state, active companion source IDs, discovered capabilities |
power.status | CPU frequency/PM mode, sleep eligibility/block reason, active power locks |
power.cpu-freq-get | Measured CPU MHz, active test override, and automatic/forced mode |
power.cpu-freq-set | Queue a 40/80/160 MHz test profile; value 0 restores automatic 40–160 MHz scaling |
power.get-dvfs-state | ESP-IDF power-management lock report and CPU residency per power mode |
ota.status | Running/alternate/update slots, image states, alternate bootability, rollback state |
ota.boot-other | Validate and select the alternate OTA slot, then reboot |
logs.read | Bounded batch of recent diagnostic records, with cursor for pagination |
job.get | State and result for an asynchronous job |
system.status, battery.read, ble.status, power.status, both CPU-frequency commands, power.get-dvfs-state, both OTA commands, and logs.read are implemented. logs.read returns up to four records (constrained by the response size) and a next_cursor, allowing ewctl logs --follow to drain the backlog without one USB round trip per line. job.get is reserved and currently returns unsupported.
Read the active frequency and override:
Queue a CPU profile for a test, then restore normal dynamic PM scaling:
ESP-IDF's global power policy is applied at boot. A set request returns an acknowledgement and then performs a controlled reboot to apply the profile; it does not reconfigure PM while BLE or peripheral locks are live. The request is held in RTC memory across that restart. 0 restores the automatic 40–160 MHz range. The 40 MHz profile uses a 40 MHz floor and an 80 MHz ceiling because ESP-IDF supports 80/160 MHz CPU maxima; BLE's APB lock can raise the live CPU to 80 MHz. Check the measured clock with power cpu-freq-get after the watch restarts.
Follow-up commands can request agenda.refresh, ble.restart-advertising, or system.reboot. Commands that mutate configuration, clear data, or reboot must be explicitly named and return an acknowledgement before execution. Do not provide an arbitrary shell, memory read/write, or unrestricted register command.
Responses should report capability and validity instead of inventing defaults. For example, if battery sampling is unavailable, return available:false; if BLE is disconnected, report connection state while keeping discovered capability data clearly marked stale or unavailable.
power.status.0 returns to automatic scaling. Lower profiles can affect BLE/Wi-Fi responsiveness. power cpu-freq-get verifies the measured clock and requested profile.logs.read. Once a valid request arrives, raw logging is suppressed until USB disconnect so replies remain machine-readable.The endpoint enforces a 512-byte request bound, required version/ID/command fields, numeric ID parsing, and bounded command names. It discards malformed and oversized lines through the next newline and resumes. CPU frequency inputs are restricted to 0, 40, 80, or 160 MHz. Unsupported valid commands receive a structured error. Full JSON schema/depth validation remains a follow-up before broader mutating commands are introduced.
The host client has unit tests for request framing, size limits, malformed replies, and argument parsing. The firmware has been built and flashed; USB command protocol behavior still needs an on-device pass for attach/detach, command bursts, log cursor polling, BLE activity during a USB session, forced-clock response, and sleep after disconnect.