From 34b2717ab2f1c9122ee6f47e1f6603d387409a86 Mon Sep 17 00:00:00 2001 From: reubenmiller Date: Tue, 6 Oct 2026 21:20:46 +0200 Subject: [PATCH] feat(identify): identify a device from the cloud and from its button With several identical boards it was hard to tell which cloud device is which board. Every tedge-* image can now be matched both ways: - Cloud to device: `tedge identify [seconds]` (a shell subcommand, run through c8y_Command) blinks the status LED in the identify pattern for 30 s by default, at most 300 s, and returns at once. It is on the shell-diagnostics allow-list. - Device to cloud: two quick presses of sw0 send a zephyr_Identify event naming the hostname (at most one per 5 s), acknowledged on the LED. The device keeps serving. The new tedge-zephyr-events Smart Function maps te/device/*///e/* to Cumulocity events, which nothing did before. The sw0 watcher moves out of the provisioning hand-off into button.c, so it runs with either feature; the gesture classifier gains the identify count, and each gesture can be switched off. Also fixes tedge-zephyr leaving a failed shell command EXECUTING: the shell wraps shell_error() text in ANSI colour codes, and Cumulocity refuses a status message that carries them. Captured output is now stripped of escape sequences, and SmartREST quoting drops any other control character (line breaks stay inside the quoted field). Tested on an S3-DevKitC, a QT Py S3, an ESP32-C6 and an ESP32-CAM. Image +463 B / dram0 +32 B on the S3 modbus full build against main; release/size-baseline.json updated. Co-Authored-By: Claude Opus 5.5 --- DEVICES.md | 70 +++- README.md | 48 ++- cspell.json | 1 + cumulocity/README.md | 9 + .../tedge-zephyr-events/data-prep.yaml | 6 + .../tedge-zephyr-events/events.js | 37 ++ .../tests/extra-fields.yaml | 16 + .../tedge-zephyr-events/tests/identify.yaml | 15 + .../tests/no-text-no-time.yaml | 16 + .../tedge-zephyr-events/tests/other-type.yaml | 15 + lib/common/CMakeLists.txt | 15 +- lib/common/Kconfig | 37 ++ lib/common/button.c | 157 ++++++++ lib/common/button.h | 16 + lib/common/button_gesture.c | 29 +- lib/common/button_gesture.h | 13 +- lib/common/identify.c | 85 ++++ lib/common/identify.h | 21 + lib/common/net.c | 7 +- lib/common/prov_handoff.c | 106 +---- lib/common/prov_handoff.h | 11 +- .../extras/shell-diagnostics.conf | 6 +- .../2026-10-06-device-identify/.openspec.yaml | 2 + .../2026-10-06-device-identify/design.md | 192 +++++++++ .../2026-10-06-device-identify/proposal.md | 130 ++++++ .../specs/device-identify/spec.md | 114 ++++++ .../specs/tedge-diagnostics/spec.md | 59 +++ .../specs/wifi-provisioning/spec.md | 47 +++ .../2026-10-06-device-identify/tasks.md | 45 +++ openspec/specs/device-identify/spec.md | 118 ++++++ openspec/specs/tedge-diagnostics/spec.md | 12 +- openspec/specs/wifi-provisioning/spec.md | 20 +- release/size-baseline.json | 374 +++++++++--------- tedge-zephyr/src/tedge_internal.h | 10 +- tedge-zephyr/src/tedge_shell_allow_list.c | 45 +++ tedge-zephyr/src/tedge_shell_cmd.c | 1 + tedge-zephyr/src/tedge_smartrest.c | 7 + tedge-zephyr/tests/unit/src/main.c | 33 ++ tests/button_gesture/test_button_gesture.c | 85 +++- 39 files changed, 1684 insertions(+), 346 deletions(-) create mode 100644 cumulocity/smart-functions/tedge-zephyr-events/data-prep.yaml create mode 100644 cumulocity/smart-functions/tedge-zephyr-events/events.js create mode 100644 cumulocity/smart-functions/tedge-zephyr-events/tests/extra-fields.yaml create mode 100644 cumulocity/smart-functions/tedge-zephyr-events/tests/identify.yaml create mode 100644 cumulocity/smart-functions/tedge-zephyr-events/tests/no-text-no-time.yaml create mode 100644 cumulocity/smart-functions/tedge-zephyr-events/tests/other-type.yaml create mode 100644 lib/common/button.c create mode 100644 lib/common/button.h create mode 100644 lib/common/identify.c create mode 100644 lib/common/identify.h create mode 100644 openspec/changes/archive/2026-10-06-device-identify/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-06-device-identify/design.md create mode 100644 openspec/changes/archive/2026-10-06-device-identify/proposal.md create mode 100644 openspec/changes/archive/2026-10-06-device-identify/specs/device-identify/spec.md create mode 100644 openspec/changes/archive/2026-10-06-device-identify/specs/tedge-diagnostics/spec.md create mode 100644 openspec/changes/archive/2026-10-06-device-identify/specs/wifi-provisioning/spec.md create mode 100644 openspec/changes/archive/2026-10-06-device-identify/tasks.md create mode 100644 openspec/specs/device-identify/spec.md diff --git a/DEVICES.md b/DEVICES.md index ad20b68..fbac545 100644 --- a/DEVICES.md +++ b/DEVICES.md @@ -48,6 +48,8 @@ parameters, certificate renewal. | Parameters | ✅ | ✅ | ✅ | ✅ | ✅ | | Certificate renewal | ✅ | ✅ | ✅ | ✅ | ✅ | | Shell command | ✅ | ✅ | ✅ | ❌ | ❌ | +| Identify from the cloud (`tedge identify`, needs the shell command) | ✅ | ✅ | ✅ | ❌ | ❌ | +| Identify from the button (2 presses → `zephyr_Identify` event) | ✅ | ✅ | ✅ | ✅ (IO0 on the CAM-MB base) | ✅ | | Log upload | ✅ | ✅ | ✅ | ✅ | ❌ | | Crash dumps | ✅ | ✅ | ✅ | ❌ | ❌ | | **Remote access** | ✅ | ✅ | ✅ | ❌ | ❌ | @@ -238,8 +240,8 @@ Telemetry was published throughout, but the test tenant had no mapping for `lib/common/tedge-boards/extras/shell-diagnostics.conf` adds the cloud shell command (allow-list: `kernel uptime`, `kernel version`, `net iface`, -`net conn`, `wifi status`, `tedge params list`, `tedge diag`; `help` lists -them, one per line). It costs about 16 KB of internal RAM. +`net conn`, `wifi status`, `tedge params list`, `tedge diag`, and since +2026-10-06 `tedge identify`; `help` lists them, one per line). It costs about 16 KB of internal RAM. | Board | Build | RAM | Result | |---|---|---|---| @@ -261,6 +263,70 @@ Test note: a TCP probe of a local `c8y remoteaccess server` port opens a tunnel of its own, which holds the device's single session for a moment; an SSH connection straight after it can find the slot busy. +### Identify measured (2026-10-06) + +`device-identify` adds `tedge identify [seconds]` (cloud shell) and the sw0 +double press (`zephyr_Identify` event) to every `tedge-*` image, and moves the +sw0 watcher out of the provisioning hand-off. Measured against `main` built +the same way, it costs **+463 B image / +32 B dram0** on the S3-DevKitC +modbus `tedge-full` + shell build and **+336 B / +24 B** on the WROOM agent +`tedge-ota` build. The table below is the new `release/size-baseline.json` +against the one before it, which also takes in #10 (shell results keep their +lines) and the 0.7.x version strings; the standalone images, which only get +the button refactor, moved by under 35 B. + +Runs (eu-latest, all app-only flashes keeping Wi-Fi and identity): + +| Board | Build | `tedge identify` (c8y_Command) | sw0 double press | +|---|---|---|---| +| S3-DevKitC | modbus full + shell | ✅ default 30 s, `10`, `3600` → 300 s; `0`/`soon` fail with the reason | ✅ event in ~2 s, LED ack | +| QT Py S3 | modbus full + shell | ✅ same cases | ✅ event, LED ack | +| ESP32-C6 | modbus full (no shell) | not advertised; sent anyway it fails "no handler (511)" | ✅ event, LED ack | +| ESP32-CAM | snmp ota (no shell) | not advertised | ✅ event (IO0 on the CAM-MB base), GPIO33 LED ack | + +The shell-diagnostics builds above still pass their size gate with the +subcommand on the allow-list (S3/QT Py modbus and agent full: +465 to ++480 B, +176 B dram0, 56–65 KB arena left). + +| Build | Δ image | Δ RAM (fullest region) | Arena after | +|---|---|---|---| +| `modbus-server-standalone-esp32-devkitc` | +32 | +8 (dram0_0_seg) | 99,440 | +| `modbus-server-standalone-esp32c6-devkitc` | +16 | +8 (sram0_0_seg) | 303,648 | +| `modbus-server-standalone-esp32s3-devkitc` | +17 | +8 (dram0_0_seg) | 202,980 | +| `modbus-server-standalone-qtpy-esp32s3` | +15 | +8 (dram0_0_seg) | 205,580 | +| `modbus-server-tedge-full-esp32c6-devkitc` | +225 | +176 (sram0_0_seg) | 76,896 | +| `modbus-server-tedge-full-esp32s3-devkitc` | +480 | +176 (dram0_0_seg) | 56,076 | +| `modbus-server-tedge-full-qtpy-esp32s3` | +465 | +176 (dram0_0_seg) | 59,684 | +| `modbus-server-tedge-ota-esp32-cam` | +401 | +168 (dram0_0_seg) | 51,560 | +| `modbus-server-tedge-ota-esp32-devkitc` | +433 | +24 (dram0_0_seg) | 15,096 | +| `modbus-server-tedge-ota-esp32c6-devkitc` | +209 | +32 (sram0_0_seg) | 110,656 | +| `modbus-server-tedge-ota-esp32s3-devkitc` | +209 | +24 (dram0_0_seg) | 105,260 | +| `modbus-server-tedge-ota-qtpy-esp32s3` | +208 | +24 (dram0_0_seg) | 108,868 | +| `opcua-server-standalone-esp32-devkitc` | +34 | +8 (dram0_0_seg) | 95,104 | +| `opcua-server-standalone-esp32c6-devkitc` | +15 | +8 (sram0_0_seg) | 279,584 | +| `opcua-server-standalone-esp32s3-devkitc` | +16 | +8 (dram0_0_seg) | 179,620 | +| `opcua-server-standalone-qtpy-esp32s3` | +17 | +8 (dram0_0_seg) | 182,220 | +| `opcua-server-tedge-ota-qtpy-esp32s3` | +207 | +24 (dram0_0_seg) | 91,988 | +| `snmp-agent-standalone-esp32-devkitc` | +31 | +8 (dram0_0_seg) | 90,992 | +| `snmp-agent-standalone-esp32c6-devkitc` | +16 | +8 (sram0_0_seg) | 297,088 | +| `snmp-agent-standalone-esp32s3-devkitc` | +16 | +8 (dram0_0_seg) | 196,388 | +| `snmp-agent-standalone-qtpy-esp32s3` | +0 | +8 (dram0_0_seg) | 198,988 | +| `snmp-agent-tedge-full-esp32c6-devkitc` | +225 | +176 (sram0_0_seg) | 64,608 | +| `snmp-agent-tedge-full-esp32s3-devkitc` | +208 | +168 (dram0_0_seg) | 59,148 | +| `snmp-agent-tedge-full-qtpy-esp32s3` | +209 | +168 (dram0_0_seg) | 62,756 | +| `snmp-agent-tedge-ota-esp32-cam` | +416 | +168 (dram0_0_seg) | 43,520 | +| `snmp-agent-tedge-ota-esp32c6-devkitc` | +224 | +32 (sram0_0_seg) | 98,352 | +| `snmp-agent-tedge-ota-esp32s3-devkitc` | +208 | +24 (dram0_0_seg) | 93,092 | +| `snmp-agent-tedge-ota-qtpy-esp32s3` | +208 | +32 (dram0_0_seg) | 96,692 | +| `tedge-agent-tedge-full-esp32c6-devkitc` | +223 | +176 (sram0_0_seg) | 81,568 | +| `tedge-agent-tedge-full-esp32s3-devkitc` | +480 | +176 (dram0_0_seg) | 61,244 | +| `tedge-agent-tedge-full-qtpy-esp32s3` | +480 | +176 (dram0_0_seg) | 64,852 | +| `tedge-agent-tedge-ota-esp32-cam` | +416 | +168 (dram0_0_seg) | 53,064 | +| `tedge-agent-tedge-ota-esp32-devkitc` | +432 | +168 (dram0_0_seg) | 16,600 | +| `tedge-agent-tedge-ota-esp32c6-devkitc` | +208 | +32 (sram0_0_seg) | 115,328 | +| `tedge-agent-tedge-ota-esp32s3-devkitc` | +224 | +24 (dram0_0_seg) | 109,788 | +| `tedge-agent-tedge-ota-qtpy-esp32s3` | +211 | +24 (dram0_0_seg) | 113,396 | + ### Static sizes after the footprint work, link only (2026-09-22) Every `release/devices.yml` build, rebuilt from the `reduce-memory-footprint` diff --git a/README.md b/README.md index c6c4761..35e8cf2 100644 --- a/README.md +++ b/README.md @@ -539,7 +539,8 @@ it through reset enters the ROM serial bootloader instead): |---|---| | **3 quick presses** within 2 s | Reboot into the provisioner. The stored network is kept until new credentials are verified, so an abandoned attempt returns to it when the window expires. | | **Hold ≥ 10 s**, then release | Erase the stored credentials and reboot into the provisioner. The LED flickers once the erase is armed, so you know before letting go. | -| anything else | Nothing (1, 2 or 4+ presses, presses too slow, a shorter hold). | +| **2 quick presses** (`tedge-*` images) | Identify: a `zephyr_Identify` event goes to the cloud and the LED fast-blinks for 1 s. The device keeps serving. See [Identifying a device](#identifying-a-device). | +| anything else | Nothing (1 or 4+ presses, presses too slow, a shorter hold). | | a press in the provisioner | Restarts an expired window; authorizes provisioning when `CONFIG_APP_WIFI_PROV_REQUIRE_AUTH=y`. | **Provisioning window:** the provisioner advertises for @@ -556,19 +557,22 @@ S3-DevKitC-1 and the QT Py S3, which also shows the colour): | steady on | green | connected and serving | | even blink, 250 ms on / 250 ms off | amber | not connected (associating, reconnecting) | | **two short blinks, then a pause** (100 ms on, 150 off, 100 on, ~1.5 s off) | blue | provisioner, waiting for credentials | -| fast 5 Hz blink | white | identify request from a client, or the button pattern was recognized (just before the reboot) | +| fast 5 Hz blink | white | identify request (from the cloud with `tedge identify`, or from an Improv client), or a button pattern was recognized | | very fast flicker | red | erase hold armed — release to erase | | off | — | provisioning window expired; press the button to advertise again | The application also logs every -button press and release with its length (`app_prov: sw0 released after +button press and release with its length (`app_button: sw0 released after 140 ms`), then what it made of the sequence ("not a gesture, ignored", "Button -pattern: …", "Erase armed: …"), so you can check a gesture on the console. +pattern: …", "Erase armed: …", "Identify pattern: …"), so you can check a +gesture on the console. **Options.** Application (`lib/common/Kconfig`): `APP_PROV_HANDOFF` (on by default in an MCUboot build with the provisioner layout), `APP_WIFI_PROV_PRESS_COUNT` (3), `APP_WIFI_PROV_PRESS_WINDOW_MS` (2000), -`APP_WIFI_PROV_ERASE_HOLD_S` (10). Provisioner (`apps/wifi-provisioner/Kconfig`): +`APP_WIFI_PROV_ERASE_HOLD_S` (10), and for identify `APP_IDENTIFY` (on in +`tedge-*` images), `APP_IDENTIFY_PRESS_COUNT` (2, must differ from the +provisioning count), `APP_IDENTIFY_DURATION_S` (30). Provisioner (`apps/wifi-provisioner/Kconfig`): `APP_WIFI_PROV_WINDOW_S` (900), `APP_WIFI_PROV_CONNECT_TIMEOUT_S` (30), `APP_WIFI_PROV_REQUIRE_AUTH` (off); set them for the provisioner image with a sysbuild image prefix, e.g. `-Dwifi-provisioner_CONFIG_APP_WIFI_PROV_REQUIRE_AUTH=y`. @@ -1261,7 +1265,7 @@ health, on thin-edge.io topics over the Cumulocity MQTT Service. Cumulocity only turns them into measurements where a **Smart Function** maps them; a tenant without one shows the device connected, with firmware, restart and remote access working, and no measurements. [`cumulocity/`](cumulocity/README.md) -has the Smart Functions (measurements, twin state, firmware progress), the +has the Smart Functions (measurements, twin state, firmware progress, events), the DTM definitions behind the Parameters tab, and the shell diagnostics — what a tenant needs, ready to deploy. @@ -1339,6 +1343,38 @@ through the app's parameters, and on the Modbus app also the type (`measurement_type`): a mapping keyed on the topic's last segment keeps working when they do. +## Identifying a device + +With several identical boards on a bench, the cloud's device list does not +say which board is which. Every `tedge-*` image (`CONFIG_APP_IDENTIFY`) can be +matched both ways. + +**From the cloud: which board is this device?** Run `tedge identify` as a +shell command (Device Management, the device's **Shell** tab). The status LED +fast-blinks (white on an RGB LED) for 30 s, or for `tedge identify ` +(at most 300), then goes back to its normal pattern. The operation succeeds as +soon as the blinking starts. From the command line: + +```sh +c8y operations create --device \ + --template "{c8y_Command:{text:'tedge identify 60'}}" +``` + +This needs an image with the cloud shell command, which today means the +images built with `lib/common/tedge-boards/extras/shell-diagnostics.conf` +(the S3-DevKitC and QT Py modbus-server and tedge-agent full builds; see +DEVICES.md). That file puts `tedge identify` on the allow-list. On other +images the cloud has no shell, so only the button works. The same command +works on a local console where the image has one. + +**From the board: which device is this board?** Press `sw0` (the BOOT button) +**twice** quickly. The LED fast-blinks for 1 s and the device sends a +`zephyr_Identify` event ("Identify button pressed on "), which shows +up on its own device in Cumulocity. Pressing again within 5 s sends no second +event. The device keeps serving throughout. On the MQTT Service the event +reaches the tenant only through the `tedge-zephyr-events` Smart Function +([`cumulocity/`](cumulocity/README.md)); deploy it once per tenant. + ## Point libraries (for a tedge-dot collector) Each firmware ships the point list a collector needs to read it, as a **point diff --git a/cspell.json b/cspell.json index 5851500..b9080ed 100644 --- a/cspell.json +++ b/cspell.json @@ -54,6 +54,7 @@ "getaddrinfo", "getnameinfo", "glyph", + "gpios", "HASCOMPONENT", "Heisenbug", "HELF", diff --git a/cumulocity/README.md b/cumulocity/README.md index a79e085..2fa23fe 100644 --- a/cumulocity/README.md +++ b/cumulocity/README.md @@ -12,6 +12,7 @@ parameters, shell) need no mapping. | `smart-functions/tedge-zephyr-measurements/` | `te/device/*///m/*` → measurements | any measurement, including `tedge_health` | | `smart-functions/tedge-zephyr-twin/` | `te/device/*///twin/*` → managed-object fragments | `remoteAccess`, `tedge_Agent`, and the current parameter values the Parameters tab shows | | `smart-functions/tedge-zephyr-firmware-progress/` | `te/device/*///progress/firmware` → `c8y_FirmwareDownload` events | download progress while a firmware update runs (optional) | +| `smart-functions/tedge-zephyr-events/` | `te/device/*///e/*` → events | any event, including `zephyr_Identify` from the sw0 identify pattern | | `dtm/*.json` | Digital Twin Manager property definitions | the Parameters tab | ## Smart Functions @@ -59,6 +60,14 @@ under a fragment named ``, except `tedge_RemoteAccess`, which becomes reads them. It replaces a rule that mapped only `tedge_RemoteAccess`; do not deploy both. +**Events.** Every `te/device////e/` message becomes an event of +type `` on that device, with the payload's `text` (the type when there +is none), the device's `time` when it sent one, and any other payload field +as a fragment. Without this function no event from a device on the MQTT +Service reaches the tenant. The one the firmware sends itself is +`zephyr_Identify`: an operator at a board presses `sw0` twice, and the event +shows which cloud device it is. + ## Parameters tab (DTM) The tenant needs the `dtm` and `device-parameter` microservices, and the diff --git a/cumulocity/smart-functions/tedge-zephyr-events/data-prep.yaml b/cumulocity/smart-functions/tedge-zephyr-events/data-prep.yaml new file mode 100644 index 0000000..6ecf4b1 --- /dev/null +++ b/cumulocity/smart-functions/tedge-zephyr-events/data-prep.yaml @@ -0,0 +1,6 @@ +smartFunctionFile: events.js +description: "tedge-zephyr events (zephyr_Identify and any other type) to Cumulocity events" +tags: [tedge-zephyr, event] +input: + transport: mqtt + topicPattern: "te/device/*///e/*" diff --git a/cumulocity/smart-functions/tedge-zephyr-events/events.js b/cumulocity/smart-functions/tedge-zephyr-events/events.js new file mode 100644 index 0000000..924d1f7 --- /dev/null +++ b/cumulocity/smart-functions/tedge-zephyr-events/events.js @@ -0,0 +1,37 @@ +// Cumulocity Smart Function: tedge-zephyr events -> Cumulocity events. +// +// Topic: te/device/*///e/* +// +// The device sends {"text": "...", "time": "..."} (time only once its clock +// is set), e.g. +// te/device////e/zephyr_Identify the sw0 identify pattern +// te/device////e/app_test the `tedge event` test command +// Every type is mapped: the event type is the last topic segment, the text +// falls back to the type, and any further payload field is kept as a +// fragment. +export function onMessage(message, context) { + const type = message.topic.split('/').pop(); + let data; + try { + data = JSON.parse(new TextDecoder().decode(message.payload)); + } catch (e) { + return []; + } + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + return []; + } + // The device's own timestamp when it sent one (the runtime wants a Date). + const time = typeof data.time === 'string' ? new Date(data.time) : message.time; + const text = typeof data.text === 'string' && data.text !== '' ? data.text : type; + const payload = { type: type, text: text, time: time }; + for (const [key, value] of Object.entries(data)) { + if (key !== 'time' && key !== 'text' && key !== 'type') { + payload[key] = value; + } + } + return [{ + cumulocityType: 'event', + payload: payload, + externalSource: [{ externalId: message.clientID, type: 'c8y_Serial' }], + }]; +} diff --git a/cumulocity/smart-functions/tedge-zephyr-events/tests/extra-fields.yaml b/cumulocity/smart-functions/tedge-zephyr-events/tests/extra-fields.yaml new file mode 100644 index 0000000..2f5964d --- /dev/null +++ b/cumulocity/smart-functions/tedge-zephyr-events/tests/extra-fields.yaml @@ -0,0 +1,16 @@ +inputs: + - payload: '{"time":"2026-10-06T08:15:00Z","text":"door opened","door":{"id":3}}' + payloadFormat: json + topic: "te/device/tedge-agent7c0c5f5a6eb8///e/door" + clientID: "tedge-agent7c0c5f5a6eb8" + time: "2026-10-06T08:15:01.000Z" +expectedOutput: + - cumulocityType: event + externalSource: + - externalId: tedge-agent7c0c5f5a6eb8 + type: c8y_Serial + payload: + type: door + text: door opened + time: "2026-10-06T08:15:00.000Z" + door: {id: 3} diff --git a/cumulocity/smart-functions/tedge-zephyr-events/tests/identify.yaml b/cumulocity/smart-functions/tedge-zephyr-events/tests/identify.yaml new file mode 100644 index 0000000..52e6430 --- /dev/null +++ b/cumulocity/smart-functions/tedge-zephyr-events/tests/identify.yaml @@ -0,0 +1,15 @@ +inputs: + - payload: '{"time":"2026-10-06T08:15:00Z","text":"Identify button pressed on tedge-modbus7c0c5f5a6eb8"}' + payloadFormat: json + topic: "te/device/tedge-modbus7c0c5f5a6eb8///e/zephyr_Identify" + clientID: "tedge-modbus7c0c5f5a6eb8" + time: "2026-10-06T08:15:01.000Z" +expectedOutput: + - cumulocityType: event + externalSource: + - externalId: tedge-modbus7c0c5f5a6eb8 + type: c8y_Serial + payload: + type: zephyr_Identify + text: Identify button pressed on tedge-modbus7c0c5f5a6eb8 + time: "2026-10-06T08:15:00.000Z" diff --git a/cumulocity/smart-functions/tedge-zephyr-events/tests/no-text-no-time.yaml b/cumulocity/smart-functions/tedge-zephyr-events/tests/no-text-no-time.yaml new file mode 100644 index 0000000..c3a35a5 --- /dev/null +++ b/cumulocity/smart-functions/tedge-zephyr-events/tests/no-text-no-time.yaml @@ -0,0 +1,16 @@ +# Before the device's clock is set it sends no time: the message time is used. +inputs: + - payload: '{}' + payloadFormat: json + topic: "te/device/tedge-agent7c0c5f5a6eb8///e/zephyr_Identify" + clientID: "tedge-agent7c0c5f5a6eb8" + time: "2026-10-06T08:15:01.000Z" +expectedOutput: + - cumulocityType: event + externalSource: + - externalId: tedge-agent7c0c5f5a6eb8 + type: c8y_Serial + payload: + type: zephyr_Identify + text: zephyr_Identify + time: "2026-10-06T08:15:01.000Z" diff --git a/cumulocity/smart-functions/tedge-zephyr-events/tests/other-type.yaml b/cumulocity/smart-functions/tedge-zephyr-events/tests/other-type.yaml new file mode 100644 index 0000000..ccd6e41 --- /dev/null +++ b/cumulocity/smart-functions/tedge-zephyr-events/tests/other-type.yaml @@ -0,0 +1,15 @@ +inputs: + - payload: '{"time":"2026-10-06T08:15:00Z","text":"test event"}' + payloadFormat: json + topic: "te/device/tedge-agent7c0c5f5a6eb8///e/app_test" + clientID: "tedge-agent7c0c5f5a6eb8" + time: "2026-10-06T08:15:01.000Z" +expectedOutput: + - cumulocityType: event + externalSource: + - externalId: tedge-agent7c0c5f5a6eb8 + type: c8y_Serial + payload: + type: app_test + text: test event + time: "2026-10-06T08:15:00.000Z" diff --git a/lib/common/CMakeLists.txt b/lib/common/CMakeLists.txt index 2c3f8e8..1fb2b40 100644 --- a/lib/common/CMakeLists.txt +++ b/lib/common/CMakeLists.txt @@ -24,12 +24,19 @@ zephyr_library_sources(status_led.c) # Wi-Fi provisioning support: the boot request that selects the provisioner # image, the identity record it names itself after, and the application-side -# hand-off with its sw0 gesture classifier. The provisioner itself (Improv over -# BLE) is its own application: apps/wifi-provisioner. +# hand-off. The provisioner itself (Improv over BLE) is its own application: +# apps/wifi-provisioner. zephyr_library_sources_ifdef(CONFIG_APP_BOOT_REQUEST boot_request.c) zephyr_library_sources_ifdef(CONFIG_APP_WIFI_CRED_STORE prov_identity.c prov_c8y.c) -zephyr_library_sources_ifdef(CONFIG_APP_PROV_HANDOFF - prov_handoff.c +zephyr_library_sources_ifdef(CONFIG_APP_PROV_HANDOFF prov_handoff.c) + +# Identify: `tedge identify` blinks the status LED, the sw0 identify pattern +# sends an event. +zephyr_library_sources_ifdef(CONFIG_APP_IDENTIFY identify.c) + +# The sw0 watcher and its gesture classifier, shared by the two above. +zephyr_library_sources_ifdef(CONFIG_APP_BUTTON + button.c button_gesture.c ) diff --git a/lib/common/Kconfig b/lib/common/Kconfig index 04615d3..b3e9a73 100644 --- a/lib/common/Kconfig +++ b/lib/common/Kconfig @@ -334,6 +334,43 @@ config APP_WIFI_PROV_ERASE_HOLD_S endif # APP_PROV_HANDOFF +config APP_IDENTIFY + bool "Identify the device from the cloud and from its button" + default y + depends on TEDGE + help + Two ways to match a physical board with its cloud device. From the + cloud: the shell command `tedge identify [seconds]` (needs the shell; + the cloud runs it through c8y_Command once it is on the + TEDGE_SHELL_COMMAND_ALLOW_LIST) blinks the status LED in the identify + pattern. From the device: the sw0 identify pattern sends a + zephyr_Identify event. Boards without a status LED or sw0 build with + that half absent. + +if APP_IDENTIFY + +config APP_IDENTIFY_DURATION_S + int "Seconds `tedge identify` blinks when given no duration" + default 30 + range 1 300 + +config APP_IDENTIFY_PRESS_COUNT + int "Short sw0 presses in the identify pattern" + default 2 + range 2 6 + help + Must differ from APP_WIFI_PROV_PRESS_COUNT; the build fails if not. + The pattern uses the provisioning pattern's window when that is + built in, 2 s otherwise. + +endif # APP_IDENTIFY + +# The sw0 watcher (button.c), shared by the provisioning hand-off and +# identify. +config APP_BUTTON + def_bool APP_PROV_HANDOFF || APP_IDENTIFY + select GPIO if $(dt_alias_enabled,sw0) + config APP_WIFI_PROVISIONER bool "This image is the Wi-Fi provisioner" depends on APP_WIFI_CRED_STORE && APP_BOOT_REQUEST diff --git a/lib/common/button.c b/lib/common/button.c new file mode 100644 index 0000000..f4368fd --- /dev/null +++ b/lib/common/button.c @@ -0,0 +1,157 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "button.h" +#include "button_gesture.h" +#include "net.h" +#if defined(CONFIG_APP_PROV_HANDOFF) +#include "prov_handoff.h" +#endif +#if defined(CONFIG_APP_IDENTIFY) +#include "identify.h" +#endif + +#include +#include +#include + +LOG_MODULE_REGISTER(app_button, CONFIG_LOG_DEFAULT_LEVEL); + +#if DT_NODE_HAS_STATUS(DT_ALIAS(sw0), okay) + +#define BTN_DEBOUNCE_MS 30 +#define BTN_TICK_MS 50 + +#if defined(CONFIG_APP_PROV_HANDOFF) +#define PROV_PRESS_COUNT CONFIG_APP_WIFI_PROV_PRESS_COUNT +#define PRESS_WINDOW_MS CONFIG_APP_WIFI_PROV_PRESS_WINDOW_MS +#define ERASE_HOLD_MS (CONFIG_APP_WIFI_PROV_ERASE_HOLD_S * 1000) +#else +#define PROV_PRESS_COUNT 0 /* off */ +#define PRESS_WINDOW_MS 2000 +#define ERASE_HOLD_MS 0 /* off */ +#endif + +#if defined(CONFIG_APP_IDENTIFY) +#define IDENTIFY_PRESS_COUNT CONFIG_APP_IDENTIFY_PRESS_COUNT +#else +#define IDENTIFY_PRESS_COUNT 0 /* off */ +#endif + +BUILD_ASSERT(PROV_PRESS_COUNT == 0 || PROV_PRESS_COUNT != IDENTIFY_PRESS_COUNT, + "APP_IDENTIFY_PRESS_COUNT must differ from " + "APP_WIFI_PROV_PRESS_COUNT"); + +static const struct gpio_dt_spec btn = GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios); +static struct gpio_callback btn_cb; +static struct k_work_delayable btn_work; +static struct gesture_state gst; +static bool btn_level; +static bool btn_started; + +static void btn_isr(const struct device *dev, struct gpio_callback *cb, + uint32_t pins) +{ + ARG_UNUSED(dev); + ARG_UNUSED(cb); + ARG_UNUSED(pins); + /* Debounce: act once the line has been quiet for BTN_DEBOUNCE_MS. */ + (void)k_work_reschedule_for_queue(app_net_workq(), &btn_work, + K_MSEC(BTN_DEBOUNCE_MS)); +} + +static void handle_gesture(enum gesture g) +{ + switch (g) { + case GESTURE_NONE: + break; + case GESTURE_IDENTIFY: +#if defined(CONFIG_APP_IDENTIFY) + app_identify_gesture(); +#endif + break; + default: +#if defined(CONFIG_APP_PROV_HANDOFF) + app_prov_handle_gesture(g); +#endif + break; + } +} + +static void btn_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + int64_t now = k_uptime_get(); + bool level = gpio_pin_get_dt(&btn) > 0; + enum gesture g = GESTURE_NONE; + + /* Per-press log lines: on boards without an LED the console is the only + * feedback an operator has on what the device saw. */ + static int64_t pressed_at; + static bool seq_active; + static bool seq_matched; + + if (level != btn_level) { + btn_level = level; + if (level) { + pressed_at = now; + seq_active = true; + LOG_INF("sw0 pressed"); + } else { + LOG_INF("sw0 released after %u ms", + (unsigned int)(now - pressed_at)); + } + g = level ? gesture_press(&gst, now) : gesture_release(&gst, now); + } + if (g == GESTURE_NONE) { + g = gesture_tick(&gst, now); + } + if (g != GESTURE_NONE) { + seq_matched = true; + } + handle_gesture(g); + if (gesture_busy(&gst)) { + (void)k_work_reschedule_for_queue(app_net_workq(), &btn_work, + K_MSEC(BTN_TICK_MS)); + } else if (seq_active) { + if (!seq_matched) { + LOG_INF("sw0: not a gesture, ignored"); + } + seq_active = false; + seq_matched = false; + } +} + +void app_button_start(void) +{ + static const struct gesture_cfg cfg = { + .press_count = PROV_PRESS_COUNT, + .identify_count = IDENTIFY_PRESS_COUNT, + .window_ms = PRESS_WINDOW_MS, + .short_max_ms = 1000, + .quiet_ms = 700, + .erase_ms = ERASE_HOLD_MS, + }; + + if (btn_started || !gpio_is_ready_dt(&btn)) { + return; + } + gesture_init(&gst, &cfg); + k_work_init_delayable(&btn_work, btn_work_fn); + if (gpio_pin_configure_dt(&btn, GPIO_INPUT) != 0 || + gpio_pin_interrupt_configure_dt(&btn, GPIO_INT_EDGE_BOTH) != 0) { + LOG_ERR("sw0 configuration failed"); + return; + } + btn_level = gpio_pin_get_dt(&btn) > 0; + gpio_init_callback(&btn_cb, btn_isr, BIT(btn.pin)); + (void)gpio_add_callback(btn.port, &btn_cb); + btn_started = true; +} + +#else /* no sw0 on this board */ + +void app_button_start(void) +{ +} + +#endif diff --git a/lib/common/button.h b/lib/common/button.h new file mode 100644 index 0000000..3f87e5f --- /dev/null +++ b/lib/common/button.h @@ -0,0 +1,16 @@ +/* SPDX-License-Identifier: Apache-2.0 + * + * The sw0 watcher (CONFIG_APP_BUTTON): debounces the board's sw0 button, + * feeds the gesture classifier (button_gesture.h) and hands each recognized + * gesture to the feature that owns it: provisioning and erase to the + * provisioning hand-off, the identify pattern to identify.c. Gestures whose + * feature is not built in are switched off in the classifier. + */ +#ifndef APP_BUTTON_H_ +#define APP_BUTTON_H_ + +/** Start watching sw0. A no-op on boards without an sw0 alias, and when + * called again. */ +void app_button_start(void); + +#endif /* APP_BUTTON_H_ */ diff --git a/lib/common/button_gesture.c b/lib/common/button_gesture.c index 0fef37a..bef79f4 100644 --- a/lib/common/button_gesture.c +++ b/lib/common/button_gesture.c @@ -4,6 +4,10 @@ #include +#ifndef MAX +#define MAX(a, b) (((a) > (b)) ? (a) : (b)) +#endif + static void reset_sequence(struct gesture_state *g) { g->presses = 0; @@ -11,6 +15,11 @@ static void reset_sequence(struct gesture_state *g) g->erase_armed = false; } +static bool erase_on(const struct gesture_state *g) +{ + return g->cfg.erase_ms > 0; +} + void gesture_init(struct gesture_state *g, const struct gesture_cfg *cfg) { memset(g, 0, sizeof(*g)); @@ -39,7 +48,8 @@ enum gesture gesture_release(struct gesture_state *g, int64_t now_ms) int64_t held = now_ms - g->press_at; - if (g->erase_armed || held >= (int64_t)g->cfg.erase_ms) { + if (g->erase_armed || + (erase_on(g) && held >= (int64_t)g->cfg.erase_ms)) { reset_sequence(g); return GESTURE_ERASE; } @@ -49,7 +59,7 @@ enum gesture gesture_release(struct gesture_state *g, int64_t now_ms) } g->presses++; g->last_release = now_ms; - if (g->presses > g->cfg.press_count || + if (g->presses > MAX(g->cfg.press_count, g->cfg.identify_count) || now_ms - g->first_press > (int64_t)g->cfg.window_ms) { g->spoiled = true; } @@ -59,7 +69,7 @@ enum gesture gesture_release(struct gesture_state *g, int64_t now_ms) enum gesture gesture_tick(struct gesture_state *g, int64_t now_ms) { if (g->pressed) { - if (!g->erase_armed && + if (erase_on(g) && !g->erase_armed && now_ms - g->press_at >= (int64_t)g->cfg.erase_ms) { g->erase_armed = true; return GESTURE_ERASE_ARMED; @@ -68,10 +78,19 @@ enum gesture gesture_tick(struct gesture_state *g, int64_t now_ms) } if (g->presses > 0 && now_ms - g->last_release >= (int64_t)g->cfg.quiet_ms) { - bool match = !g->spoiled && g->presses == g->cfg.press_count; + uint32_t n = g->spoiled ? 0 : g->presses; reset_sequence(g); - return match ? GESTURE_PROVISION : GESTURE_NONE; + if (n == 0) { + return GESTURE_NONE; + } + if (n == g->cfg.press_count) { + return GESTURE_PROVISION; + } + if (n == g->cfg.identify_count) { + return GESTURE_IDENTIFY; + } + return GESTURE_NONE; } return GESTURE_NONE; } diff --git a/lib/common/button_gesture.h b/lib/common/button_gesture.h index 4856fb0..0405c9b 100644 --- a/lib/common/button_gesture.h +++ b/lib/common/button_gesture.h @@ -1,8 +1,9 @@ /* SPDX-License-Identifier: Apache-2.0 * - * sw0 gesture classifier for BLE provisioning: recognizes the provisioning - * pattern (exactly N short presses within a window, then a quiet gap) and the - * erase hold (one long continuous press). Pure logic with no Zephyr + * sw0 gesture classifier: recognizes the provisioning pattern (exactly N + * short presses within a window, then a quiet gap), the identify pattern (the + * same with a different N) and the erase hold (one long continuous press). + * Any of the three can be switched off by setting its count or time to 0. Pure logic with no Zephyr * dependency, fed with debounced press/release edges and periodic ticks, so it * can be tested on the host (see tests/button_gesture/). */ @@ -17,14 +18,16 @@ enum gesture { GESTURE_PROVISION, /**< the N-press pattern completed */ GESTURE_ERASE_ARMED, /**< a hold has passed the erase threshold */ GESTURE_ERASE, /**< an armed erase hold was released */ + GESTURE_IDENTIFY, /**< the identify press pattern completed */ }; struct gesture_cfg { - uint32_t press_count; /**< presses in the pattern */ + uint32_t press_count; /**< presses in the provisioning pattern; 0 = off */ + uint32_t identify_count; /**< presses in the identify pattern; 0 = off */ uint32_t window_ms; /**< first press to last release */ uint32_t short_max_ms; /**< a press at least this long is not "short" */ uint32_t quiet_ms; /**< no press for this long ends a sequence */ - uint32_t erase_ms; /**< hold this long to arm the erase */ + uint32_t erase_ms; /**< hold this long to arm the erase; 0 = off */ }; struct gesture_state { diff --git a/lib/common/identify.c b/lib/common/identify.c new file mode 100644 index 0000000..039c243 --- /dev/null +++ b/lib/common/identify.c @@ -0,0 +1,85 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "identify.h" +#include "net.h" +#include "status_led.h" + +#include +#include + +#include +#include +#include + +#include + +LOG_MODULE_REGISTER(app_identify, CONFIG_LOG_DEFAULT_LEVEL); + +#define IDENTIFY_MAX_S 300 +#define GESTURE_ACK_MS 1000 +#define EVENT_MIN_GAP_MS 5000 /* repeated gestures send one event */ + +void app_identify_gesture(void) +{ + static int64_t last_event; + static bool sent; + int64_t now = k_uptime_get(); + char text[96]; + int rc; + + status_led_flash(STATUS_LED_IDENTIFY, GESTURE_ACK_MS); + if (sent && now - last_event < EVENT_MIN_GAP_MS) { + LOG_INF("Identify pattern again within %d s: no new event", + EVENT_MIN_GAP_MS / 1000); + return; + } + snprintf(text, sizeof(text), "Identify button pressed on %s", + app_net_hostname()); + /* Only queues the event: the client's thread sends it, after a + * reconnect if need be. */ + rc = tedge_publish_event("zephyr_Identify", text, 0); + if (rc != 0) { + LOG_WRN("Identify pattern: event not queued (%d)", rc); + return; + } + LOG_INF("Identify pattern: zephyr_Identify event queued"); + sent = true; + last_event = now; +} + +#if defined(CONFIG_SHELL) +#include + +/* `tedge identify [seconds]`. Returns as soon as the blinking starts: the + * cloud runs one command at a time under a timeout that is no longer than + * the default blink. */ +static int cmd_identify(const struct shell *sh, size_t argc, char **argv) +{ + long secs = CONFIG_APP_IDENTIFY_DURATION_S; + + if (argc > 1) { + char *end; + + secs = strtol(argv[1], &end, 10); + if (end == argv[1] || *end != '\0' || secs <= 0) { + shell_error(sh, "the duration must be a whole number of " + "seconds above 0, not \"%s\"", argv[1]); + return -EINVAL; + } + secs = MIN(secs, IDENTIFY_MAX_S); + } + if (!status_led_present()) { + shell_error(sh, "this board has no status LED"); + return -ENODEV; + } + status_led_flash(STATUS_LED_IDENTIFY, (uint32_t)secs * 1000U); + LOG_INF("Identify: blinking the status LED for %ld s", secs); + shell_print(sh, "identifying for %ld s", secs); + return 0; +} + +/* Hangs off the client's own "tedge" root, which tedge-zephyr defines. */ +SHELL_SUBCMD_ADD((tedge), identify, NULL, + "blink the status LED to find this board: identify [seconds]", + cmd_identify, 1, 1); +#endif /* CONFIG_SHELL */ diff --git a/lib/common/identify.h b/lib/common/identify.h new file mode 100644 index 0000000..44c9d86 --- /dev/null +++ b/lib/common/identify.h @@ -0,0 +1,21 @@ +/* SPDX-License-Identifier: Apache-2.0 + * + * Identify (CONFIG_APP_IDENTIFY): matching a physical board with its cloud + * device, both ways. + * + * - Cloud to device: the shell command `tedge identify [seconds]`, which the + * cloud runs through c8y_Command when it is on the shell command's + * allow-list, blinks the status LED in the identify pattern. Registered by + * identify.c itself; nothing to call. + * - Device to cloud: the sw0 identify pattern (button.c) calls + * app_identify_gesture(), which sends a zephyr_Identify event. + */ +#ifndef APP_IDENTIFY_H_ +#define APP_IDENTIFY_H_ + +/** The sw0 identify pattern was recognized: acknowledge it on the LED and + * send a zephyr_Identify event (at most one per 5 s). Never blocks on the + * network. */ +void app_identify_gesture(void); + +#endif /* APP_IDENTIFY_H_ */ diff --git a/lib/common/net.c b/lib/common/net.c index 204ceb2..8a657d1 100644 --- a/lib/common/net.c +++ b/lib/common/net.c @@ -7,6 +7,9 @@ #if defined(CONFIG_APP_PROV_HANDOFF) #include "prov_handoff.h" #endif +#if defined(CONFIG_APP_BUTTON) +#include "button.h" +#endif #if defined(CONFIG_APP_WIFI_CRED_STORE) #include #endif @@ -797,7 +800,9 @@ int app_net_init(void) app_prov_identity_update(); app_prov_handoff_no_credentials(); /* reboots when it can */ } - app_prov_button_start(); +#endif +#if defined(CONFIG_APP_BUTTON) + app_button_start(); #endif if (!have_creds) { diff --git a/lib/common/prov_handoff.c b/lib/common/prov_handoff.c index 8825a59..18f04fe 100644 --- a/lib/common/prov_handoff.c +++ b/lib/common/prov_handoff.c @@ -2,7 +2,6 @@ #include "prov_handoff.h" #include "boot_request.h" -#include "button_gesture.h" #include "net.h" #include "prov_identity.h" #include "status_led.h" @@ -11,15 +10,12 @@ #include #include #include -#include #include #include LOG_MODULE_REGISTER(app_prov, CONFIG_LOG_DEFAULT_LEVEL); -#define ACK_MS 1000 /* LED acknowledgement before a reboot */ -#define BTN_DEBOUNCE_MS 30 -#define BTN_TICK_MS 50 +#define ACK_MS 1000 /* LED acknowledgement before a reboot */ static FUNC_NORETURN void reboot_now(void) { @@ -68,26 +64,6 @@ void app_prov_identity_update(void) } } -#if DT_NODE_HAS_STATUS(DT_ALIAS(sw0), okay) - -static const struct gpio_dt_spec btn = GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios); -static struct gpio_callback btn_cb; -static struct k_work_delayable btn_work; -static struct gesture_state gst; -static bool btn_level; -static bool btn_started; - -static void btn_isr(const struct device *dev, struct gpio_callback *cb, - uint32_t pins) -{ - ARG_UNUSED(dev); - ARG_UNUSED(cb); - ARG_UNUSED(pins); - /* Debounce: act once the line has been quiet for BTN_DEBOUNCE_MS. */ - (void)k_work_reschedule_for_queue(app_net_workq(), &btn_work, - K_MSEC(BTN_DEBOUNCE_MS)); -} - static void ack_and_reboot(void) { status_led_flash(STATUS_LED_IDENTIFY, ACK_MS); @@ -95,7 +71,7 @@ static void ack_and_reboot(void) reboot_now(); } -static void handle_gesture(enum gesture g) +void app_prov_handle_gesture(enum gesture g) { switch (g) { case GESTURE_PROVISION: @@ -128,81 +104,3 @@ static void handle_gesture(enum gesture g) break; } } - -static void btn_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - int64_t now = k_uptime_get(); - bool level = gpio_pin_get_dt(&btn) > 0; - enum gesture g = GESTURE_NONE; - - /* Per-press log lines: on boards without an LED the console is the only - * feedback an operator has on what the device saw. */ - static int64_t pressed_at; - static bool seq_active; - static bool seq_matched; - - if (level != btn_level) { - btn_level = level; - if (level) { - pressed_at = now; - seq_active = true; - LOG_INF("sw0 pressed"); - } else { - LOG_INF("sw0 released after %u ms", - (unsigned int)(now - pressed_at)); - } - g = level ? gesture_press(&gst, now) : gesture_release(&gst, now); - } - if (g == GESTURE_NONE) { - g = gesture_tick(&gst, now); - } - if (g != GESTURE_NONE) { - seq_matched = true; - } - handle_gesture(g); - if (gesture_busy(&gst)) { - (void)k_work_reschedule_for_queue(app_net_workq(), &btn_work, - K_MSEC(BTN_TICK_MS)); - } else if (seq_active) { - if (!seq_matched) { - LOG_INF("sw0: not a gesture, ignored"); - } - seq_active = false; - seq_matched = false; - } -} - -void app_prov_button_start(void) -{ - static const struct gesture_cfg cfg = { - .press_count = CONFIG_APP_WIFI_PROV_PRESS_COUNT, - .window_ms = CONFIG_APP_WIFI_PROV_PRESS_WINDOW_MS, - .short_max_ms = 1000, - .quiet_ms = 700, - .erase_ms = CONFIG_APP_WIFI_PROV_ERASE_HOLD_S * 1000, - }; - - if (btn_started || !gpio_is_ready_dt(&btn)) { - return; - } - gesture_init(&gst, &cfg); - k_work_init_delayable(&btn_work, btn_work_fn); - if (gpio_pin_configure_dt(&btn, GPIO_INPUT) != 0 || - gpio_pin_interrupt_configure_dt(&btn, GPIO_INT_EDGE_BOTH) != 0) { - LOG_ERR("sw0 configuration failed"); - return; - } - btn_level = gpio_pin_get_dt(&btn) > 0; - gpio_init_callback(&btn_cb, btn_isr, BIT(btn.pin)); - (void)gpio_add_callback(btn.port, &btn_cb); - btn_started = true; -} - -#else /* no sw0 on this board */ - -void app_prov_button_start(void) -{ -} - -#endif diff --git a/lib/common/prov_handoff.h b/lib/common/prov_handoff.h index fd526c7..733382b 100644 --- a/lib/common/prov_handoff.h +++ b/lib/common/prov_handoff.h @@ -9,6 +9,8 @@ #ifndef APP_PROV_HANDOFF_H_ #define APP_PROV_HANDOFF_H_ +#include "button_gesture.h" + /** * No credentials resolve: request the provisioner and reboot into it. Returns * only when no provisioner is flashed (the device then stays offline, as a @@ -17,11 +19,12 @@ void app_prov_handoff_no_credentials(void); /** - * Watch sw0 for the provisioning pattern (reboot into the provisioner, keep - * the credentials) and the erase hold (erase them, then reboot into the - * provisioner). A no-op on boards without an sw0 alias. + * Act on an sw0 gesture (called by button.c): the provisioning pattern + * reboots into the provisioner and keeps the credentials, the erase hold + * erases them and then reboots into the provisioner. Other gestures are + * ignored. */ -void app_prov_button_start(void); +void app_prov_handle_gesture(enum gesture g); /** Record this application's hostname, service type and port for the * provisioner (written only when they changed). Call once connected. */ diff --git a/lib/common/tedge-boards/extras/shell-diagnostics.conf b/lib/common/tedge-boards/extras/shell-diagnostics.conf index b79a797..d8063a4 100644 --- a/lib/common/tedge-boards/extras/shell-diagnostics.conf +++ b/lib/common/tedge-boards/extras/shell-diagnostics.conf @@ -7,7 +7,8 @@ # A command runs in-process against a capture backend; there is no shell on # the console or the network. Nothing outside this list runs, and a request # that could chain a second command is refused whatever the list says. Every -# entry is read-only and returns within the command timeout. +# entry is read-only (identify only blinks the status LED) and returns within +# the command timeout. CONFIG_SHELL=y CONFIG_SHELL_BACKEND_SERIAL=n CONFIG_SHELL_STACK_SIZE=3072 @@ -19,6 +20,7 @@ CONFIG_TEDGE_SHELL_COMMAND=y # net iface / net conn addresses, gateway, open sockets # wifi status SSID, RSSI, channel, security # tedge params list / tedge diag the client's parameter sets and state +# tedge identify [seconds] blink the status LED to find the board # Not `net ping`: Zephyr prints the replies after the command has returned, # so all the cloud would see is the "PING " line. -CONFIG_TEDGE_SHELL_COMMAND_ALLOW_LIST="kernel uptime,kernel version,net iface,net conn,wifi status,tedge params list,tedge diag" +CONFIG_TEDGE_SHELL_COMMAND_ALLOW_LIST="kernel uptime,kernel version,net iface,net conn,wifi status,tedge params list,tedge diag,tedge identify" diff --git a/openspec/changes/archive/2026-10-06-device-identify/.openspec.yaml b/openspec/changes/archive/2026-10-06-device-identify/.openspec.yaml new file mode 100644 index 0000000..aa32566 --- /dev/null +++ b/openspec/changes/archive/2026-10-06-device-identify/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-06 diff --git a/openspec/changes/archive/2026-10-06-device-identify/design.md b/openspec/changes/archive/2026-10-06-device-identify/design.md new file mode 100644 index 0000000..19056de --- /dev/null +++ b/openspec/changes/archive/2026-10-06-device-identify/design.md @@ -0,0 +1,192 @@ +## Context + +The pieces mostly exist already: + +- `lib/common/status_led.{c,h}` has `STATUS_LED_IDENTIFY` (5 Hz, white on an + RGB LED) and `status_led_flash(mode, ms)`, which shows a pattern for a while + and returns to the base pattern. The Improv provisioner already uses it for + its identify RPC. +- `lib/common/button_gesture.{c,h}` is a host-tested classifier for `sw0` + (N short presses, long hold). `prov_handoff.c` owns the GPIO, debounce and + work item, and is only built and started under `CONFIG_APP_PROV_HANDOFF` + (`net.c`). +- tedge-zephyr runs `c8y_Command` (`CONFIG_TEDGE_SHELL_COMMAND`, + `tedge_shell_cmd.c`): an allow-listed Zephyr shell command executed + in-process against the dummy backend on a thread of its own, its output + returned as the operation result. The module defines one `tedge` shell root; + applications hang subcommands off it with `SHELL_SUBCMD_ADD((tedge), …)` + (`tedge diag`, `tedge event`, …). The allow-list is empty by default; + `lib/common/tedge-boards/extras/shell-diagnostics.conf` fills it, and + `release/devices.yml` adds that file to the S3-DevKitC and QT Py + modbus-server and tedge-agent full images only (DEVICES.md, "Shell + diagnostics measured"). +- `tedge_publish_event()` goes to `te/device////e/` on the MQTT + Service transport or SmartREST `400` otherwise. + +One gap surfaced while reading the code: **no event reaches the tenant on the +MQTT Service.** Only measurements, twin and firmware progress have Smart +Functions; `te/…/e/*` is unmapped. + +## Goals / Non-Goals + +**Goals:** + +- `tedge identify` sent as a `c8y_Command` blinks the LED on any image that + carries the shell command and has a status LED. +- A two-press gesture on `sw0` produces a `zephyr_Identify` event on the right + cloud device, on any tedge-* image with `sw0`, with or without the + provisioning hand-off. +- Keep `tedge-zephyr/` free of `lib/` knowledge (LED and button stay in the + application side). + +**Non-Goals:** + +- A new LED pattern, a dedicated operation type, a UI plugin, runtime + configuration, or bringing the shell to more images (see proposal). + +## Decisions + +### D1. The cloud trigger is a shell subcommand run through `c8y_Command` + +`lib/common/identify.c` adds `SHELL_SUBCMD_ADD((tedge), identify, NULL, +"blink the status LED to find this board: identify [seconds]", cmd_identify, +1, 1)`. The operator uses the Shell tab (or `c8y operations create --template +"{c8y_Command:{text:'tedge identify'}}"`); nothing new reaches the module, the +supported-operations list or the tenant. + +*Why:* the cloud already has a UI and an API for `c8y_Command`, the module +already handles its delivery, status and output, and the same command works on +a local console during bench work. A dedicated operation would need a UI +plugin to be usable from Device Management. + +*Consequence:* cloud identify exists only where the shell command does. On the +WROOM, ESP32-CAM, C6 and snmp-agent images the shell does not fit or is not +carried, and adding it for identify alone is out of proportion (DEVICES.md +measures the shell at several percent of flash, and on the C6 it starves the +Wi-Fi driver's RAM). On those images the button half still works. Revisit if +the shell gets cheaper or a smaller image grows room for it. + +### D2. Return immediately, don't wait for the blink to end + +`cmd_identify` calls `status_led_flash(STATUS_LED_IDENTIFY, s * 1000)`, prints +`identifying for s` and returns 0. The shell command thread is single and +`TEDGE_SHELL_COMMAND_TIMEOUT_S` defaults to 30 s, so a command that waited for +the blink would time out at the default duration and block the next command. +A repeated command restarts the flash (that is `status_led_flash()`'s +behaviour already). Bad argument → `shell_error()` and `-EINVAL`; no status +LED (`!status_led_present()`) → `shell_error("this board has no status LED")` +and `-ENODEV`; both become the failure reason. + +### D3. Allow-list: `tedge identify` in `extras/shell-diagnostics.conf` + +The allow-list is one string Kconfig the module owns, and the module's +profile keeps it empty by design, so the entry goes where the other entries +are: `shell-diagnostics.conf`. Matching is by prefix, so the entry also allows +`tedge identify `; the module already refuses anything that could chain a +second command. Identify is harmless (it changes nothing but the LED for at +most 5 minutes), so it fits the file's "read-only and returns within the +timeout" rule; the comment block lists it with the others. + +### D4. `sw0` wiring moves to `lib/common/button.c` + +`prov_handoff.c` keeps the provisioning actions; GPIO setup, debounce and the +work item move to `button.c`, built when `APP_PROV_HANDOFF || APP_IDENTIFY`, +started from `net.c` outside the `APP_PROV_HANDOFF` block. `button.c` calls +`gesture_*()` and dispatches each result: provisioning/erase to `prov_handoff` +(when built), identify to `identify.c` (when built). With `APP_PROV_HANDOFF` +off, the provisioning and erase gestures are disabled in the config (count 0 / +hold 0 meaning "off"), so a triple press or long hold does nothing. + +### D5. Gesture classifier gains a second press count + +`struct gesture_cfg` gets `identify_count` (0 = disabled) and the classifier +returns a new `GESTURE_IDENTIFY` when a sequence ends quietly with exactly that +many short presses, alongside the existing `GESTURE_PROVISION`. Both use the +same window, short-press limit and quiet gap, so the classifier already waits +for the quiet gap before deciding between 2 and 3 — a triple press never fires +identify first. `BUILD_ASSERT(APP_IDENTIFY_PRESS_COUNT != +APP_WIFI_PROV_PRESS_COUNT)` when both are built. Host tests in +`tests/button_gesture/` cover: 2 → identify, 3 → provision, 2 with identify off +→ none, 2 slow → none, hold → erase unaffected. + +Default 2 because it is the quickest deliberate gesture and a fumbled triple +press that comes out as two is harmless (an event, no mode change). + +### D6. Event: `zephyr_Identify`, rate-limited, from the work queue + +`identify.c` handles `GESTURE_IDENTIFY` on the button work queue: flash +`STATUS_LED_IDENTIFY` for 1 s, then `tedge_publish_event("zephyr_Identify", +"Identify button pressed on ", 0)` unless the previous one was under +5 s ago. `tedge_publish_event()` only enqueues, so the work queue never waits on +the network. Text carries the hostname (`app_net_hostname()`), which is also +the mDNS name, so the operator sees both names side by side. The `zephyr_` +prefix matches the repo's other device-defined types. + +### D7. `identify.c` lives in `lib/common` + +The shell subcommand registers itself at link time (`SHELL_SUBCMD_ADD`, under +`CONFIG_SHELL`), and the gesture handler is called by `button.c`, so the apps +need no call of their own. Keeping it in `lib/common` rather than the module +respects the module's independence rule (the module knows nothing about the +LED) and avoids four copies in the apps' glue. + +### D8. Events Smart Function maps all types generically + +`tedge-zephyr-events/events.js`, topic `te/device/*///e/*`: type from the last +topic segment, `text` (fallback: the type), `time` from the payload or the +message, every other payload key copied as a fragment, `externalSource` +`c8y_Serial` = client ID (same as the firmware-progress function). Tests: +`zephyr_Identify`, an arbitrary type, a payload without text. Generic rather +than identify-only because no event reaches the tenant today and a per-type +function would have to be repeated for every app event. + +### D9. Failed shell commands: strip terminal escapes before reporting + +Found on hardware: `shell_error()` output reaches the capture backend as +`\r\n ESC[1;31m \r\n ESC[m`. Cumulocity accepts line breaks inside a +quoted SmartREST field but refuses the escape character, so the `505` was +rejected and the operation stayed EXECUTING. `tedge_shell_clean_output()` +(pure, unit-tested) removes CSI and two-character escape sequences and other +C0 controls except `\n`, `\r`, `\t` from the captured output in +`tedge_shell_cmd.c`; `sr_quote()` also drops C0 controls (except `\n` when +keeping lines, `\r` handled as before, and tab) as a backstop for every +other text the client quotes. Line breaks stay in both results and failure +reasons. + +*Alternative:* `CONFIG_SHELL_VT100_COLORS=n`. Rejected: it is an image-wide +setting the integrator owns, and it would leave the client one shell option +away from the same stuck operations. + +## Risks / Trade-offs + +- **Event lost while offline** → acceptable; it uses the existing telemetry + queue with QoS 1 once connected. The operator sees the LED ack regardless. +- **Accidental double press** → produces one harmless event; rate limit stops + floods. +- **Older images** → `tedge identify` is refused as "not on the device's + allow-list" (or not a known command) — correct and self-explaining. +- **Cloud identify missing on most boards** → see D1; documented per image in + DEVICES.md so nobody expects it on a WROOM. +- **Smart Function not deployed** → the event silently never appears on the + MQTT Service transport. README and the operator docs say to deploy it; the + identify docs name it explicitly. +- **RGB boards** → they rely on the RGB backend from `rgb-status-led`, which + is already in `status_led.c` (its archive is pending); without it + `status_led_present()` would be false on exactly the boards with the shell. + +## Migration Plan + +Firmware: none; shipped through the normal release and OTA. Tenant: deploy +`tedge-zephyr-events` once. Rollback: older firmware refuses the command; the +Smart Function can stay deployed. + +## Open Questions + +- Should cloud identify reach the boards without the shell (WROOM, CAM, C6, + snmp-agent)? Options would be a minimal shell (no kernel/net/wifi shells, + only the `tedge` root) or a fallback dedicated operation; both are a + separate change with their own footprint measurement. + +- Should the identify event also set a managed-object fragment (e.g. + `zephyr_LastIdentified`) so the device list can be sorted by it? Left out; + the event list is enough for the first version. diff --git a/openspec/changes/archive/2026-10-06-device-identify/proposal.md b/openspec/changes/archive/2026-10-06-device-identify/proposal.md new file mode 100644 index 0000000..f4ec661 --- /dev/null +++ b/openspec/changes/archive/2026-10-06-device-identify/proposal.md @@ -0,0 +1,130 @@ +## Why + +Once an operator has several of these boards on a bench or in a cabinet, the +cloud's device list no longer says which physical board is which: they look the +same, and the external ID is printed nowhere on them. Matching the two today +means reading a serial console or unplugging boards one at a time. The device +should be identifiable from both ends: the cloud asks a device to show itself, +and an operator at the device makes it announce itself in the cloud. + +## What Changes + +- **Cloud → device: `tedge identify` over the shell command.** A new shell + subcommand, `tedge identify [seconds]`, hung off the client's `tedge` root, + blinks the status LED in the existing identify pattern (fast 5 Hz blink; + white on an RGB LED) for 30 s by default, or for the given number of seconds + (capped). The cloud triggers it with the existing `c8y_Command` operation + (the Shell tab in Device Management), so no new operation type is added. The + command returns as soon as the blinking starts, so the operation never holds + up the next one. `tedge identify` is added to the shell-command allow-list of + the images that carry the shell command. It works from a local console too. +- **Device → cloud: an identify button gesture.** A new `sw0` gesture, exactly + two short presses by default (`APP_IDENTIFY_PRESS_COUNT`), sends a + `zephyr_Identify` event to the cloud and acknowledges the press on the LED. + The device keeps serving; the gesture never changes the device's mode. The + provisioning pattern (three presses) and the erase hold are unchanged, and a + build refuses the same press count for both patterns. +- **The `sw0` button no longer depends on the provisioning hand-off.** Today + the button is only watched when `APP_PROV_HANDOFF` is built in; the identify + gesture needs it in every tedge-* image with a button. +- **tedge-zephyr: a failed shell command is reported as failed.** Testing + `tedge identify 0` on an S3-DevKitC left the operation EXECUTING: the + Zephyr shell wraps `shell_error()` text in ANSI colour codes, the client + passed them through into the `505` status, and Cumulocity refused the + message ("contains invalid characters"). Any command that fails through + `shell_error()` was affected. The client now strips escape sequences from + captured output, and SmartREST quoting drops any remaining control + character (line breaks stay, inside the quoted field). Fixed here because + identify's own failure cases depend on it. +- **Cumulocity: an events Smart Function.** On the MQTT Service transport + Cumulocity stores only what a Smart Function maps, and nothing maps + `te/device/*///e/*` yet, so no tedge-zephyr event reaches the tenant today. A + new `cumulocity/smart-functions/tedge-zephyr-events/` maps every event type + to a Cumulocity event on the device that sent it; the identify event is the + first user. + +Phase: 3 (tedge-zephyr device management). + +Targets: the tedge-* images of all four applications (modbus-server, +opcua-server, snmp-agent, tedge-agent) on the tedge boards: ESP32-DevKitC +(WROOM-32), ESP32-CAM, ESP32-C6-DevKitC, ESP32-S3-DevKitC-1 and QT Py ESP32-S3. +No industrial-protocol behaviour changes. The cloud half needs a status LED and +an image with the shell command (`CONFIG_TEDGE_SHELL_COMMAND`, today the +S3-DevKitC and QT Py modbus-server and tedge-agent full images, which add +`extras/shell-diagnostics.conf`); the button half needs `sw0` (the BOOT button +on these boards) and works on every tedge-* image. A board or image missing +either builds and runs with that half absent. + +## Non-goals + +- A new LED pattern or colour for identify: the existing identify pattern from + the Improv provisioner is reused. +- A dedicated identify operation (`c8y_Identify` or similar) or a Cumulocity UI + plugin: the shell command is the cloud entry point. +- Bringing the shell command to more images. Where it does not fit today + (WROOM, ESP32-CAM, the C6, the snmp-agent images) cloud identify is absent + until the shell does fit; that is a separate footprint question. +- Identifying a device that is not connected to the cloud (the event is sent + through the normal telemetry queue and is lost if the queue overflows before + the device reconnects, like any other event). +- Configuring the identify duration or gesture at runtime (Kconfig only). +- The thin-edge.io gateway transport. + +## Kconfig options added + +| Option | Where | Default | Purpose | +|---|---|---|---| +| `APP_IDENTIFY` | `lib/common` | `y` when `TEDGE` | the `tedge identify` subcommand (with `SHELL`) and the button gesture | +| `APP_IDENTIFY_DURATION_S` | `lib/common` | 30 (range 1–300) | blink time when the command names none | +| `APP_IDENTIFY_PRESS_COUNT` | `lib/common` | 2 (range 2–6) | short presses in the identify gesture | + +No new `CONFIG_TEDGE_*` option and no public `tedge_*` API change; the +tedge-zephyr fix is internal. The existing +`CONFIG_TEDGE_SHELL_COMMAND_ALLOW_LIST` gains `tedge identify` in +`extras/shell-diagnostics.conf`. + +## Resource constraints + +The cost must stay small enough not to matter on the tightest image (the +ESP32-CAM and WROOM ota profiles): a gesture branch, an event call and a few +bytes of state, plus one shell subcommand on the images that already have the +shell. The shell itself is not added anywhere. Expected well under 1 KiB flash and under 64 B RAM per +image; the measured flash/RAM delta per target board goes into the footprint +table (`device-management-features`, "Per-feature footprint is measured and +documented") and must pass the size gate against the baseline. + +## Capabilities + +### New Capabilities + +- `device-identify`: identifying a physical device from the cloud (the + `tedge identify` command run through `c8y_Command`, and its LED pattern) and the cloud device from the + physical one (the button gesture, the `zephyr_Identify` event and the events + Smart Function that delivers it). + +### Modified Capabilities + +- `tedge-diagnostics`: "A commanded shell is allow-listed and off by + default" also forbids escape sequences and control characters in reported + output, so a failed command always ends FAILED. +- `wifi-provisioning`: "Button-triggered re-provisioning and erase" gains a + third gesture that does not change the device's mode, and its "other presses + have no effect" scenario no longer lists two presses. + +## Impact + +- `lib/common/`: `button_gesture.{c,h}` (new gesture, host tests in + `tests/button_gesture/`), the `sw0` wiring split out of `prov_handoff.c`, a + new `identify.{c,h}`, `Kconfig`, `CMakeLists.txt`. +- `lib/common/tedge-boards/extras/shell-diagnostics.conf`: allow-list entry, + and the shell-diagnostics measurements in DEVICES.md re-checked. +- `tedge-zephyr/src/tedge_shell_cmd.c`, `tedge_shell_allow_list.c` + (`tedge_shell_clean_output()`), `tedge_smartrest.c`, `tedge_internal.h`, + and unit tests: the failed-command fix. +- `cumulocity/smart-functions/tedge-zephyr-events/` (new) and + `cumulocity/README.md`. +- README / DEVICES.md: how to trigger identify from the cloud (Shell tab or + `c8y_Command`) and from the button, and which images support each. +- RGB LED boards (C6, S3-DevKitC-1, QT Py) use the RGB backend of the + `rgb-status-led` change, which is already implemented (only its archive is + pending). diff --git a/openspec/changes/archive/2026-10-06-device-identify/specs/device-identify/spec.md b/openspec/changes/archive/2026-10-06-device-identify/specs/device-identify/spec.md new file mode 100644 index 0000000..e66acc5 --- /dev/null +++ b/openspec/changes/archive/2026-10-06-device-identify/specs/device-identify/spec.md @@ -0,0 +1,114 @@ +## ADDED Requirements + +### Requirement: The cloud can make a device show itself on its LED + +A tedge-* image built with `APP_IDENTIFY` and the Zephyr shell SHALL provide a +`tedge identify [seconds]` shell subcommand under the client's `tedge` root. +It SHALL show the identify pattern on the status LED (the fast blink, white on +an RGB LED) for the given number of seconds, or `APP_IDENTIFY_DURATION_S` +(default 30) when none is given, and then return to the pattern shown before. +A value above 300 SHALL be capped at 300; a value that is not a positive +integer, or a board with no status LED, SHALL make the command fail with a +message saying why, and leave the LED unchanged. The command SHALL return as +soon as the blinking starts, printing the duration. Running it again while the +LED is blinking SHALL restart the blinking with the new duration. + +The cloud SHALL trigger it through the existing `c8y_Command` operation: every +shipped image that carries the shell command (`CONFIG_TEDGE_SHELL_COMMAND`) +SHALL list `tedge identify` on its allow-list. No other operation type SHALL be +added for identify. + +#### Scenario: Identify from the Shell tab + +- **WHEN** an operator sends `tedge identify` as a `c8y_Command` to a + connected device whose image carries the shell command +- **THEN** the status LED fast-blinks for about 30 s and then returns to its + connected pattern +- **AND** the operation succeeds within a few seconds, before the blinking + ends, with the command's output (the duration) as its result + +#### Scenario: Identify with a duration + +- **WHEN** the command is `tedge identify 10` +- **THEN** the status LED fast-blinks for about 10 s + +#### Scenario: Duration out of range + +- **WHEN** the command is `tedge identify 3600` +- **THEN** the LED blinks for 300 s and the operation succeeds +- **WHEN** the command is `tedge identify 0` or `tedge identify soon` +- **THEN** the LED does not change and the operation fails with the reason + +#### Scenario: Board without a status LED + +- **WHEN** `tedge identify` runs on a board with neither `led0` nor + `led-strip` +- **THEN** the operation fails with "this board has no status LED" + +#### Scenario: Image without the shell command + +- **WHEN** the image is built without `CONFIG_TEDGE_SHELL_COMMAND` +- **THEN** it builds and runs, the cloud cannot trigger identify, and the + button half still works + +### Requirement: An operator at the device can make it announce itself in the cloud + +A tedge-* image built with `APP_IDENTIFY` on a board with `sw0` SHALL recognize +an **identify gesture**: exactly `APP_IDENTIFY_PRESS_COUNT` (default 2) short +presses within `APP_WIFI_PROV_PRESS_WINDOW_MS`, followed by the same quiet gap +that ends the provisioning pattern. On the gesture the device SHALL publish an +event of type `zephyr_Identify` whose text names the device's hostname and +how it was triggered, and SHALL acknowledge the gesture on the status LED (the +identify pattern for about 1 s). The gesture SHALL NOT change the device's mode +or reboot it. The build SHALL fail when `APP_IDENTIFY_PRESS_COUNT` equals +`APP_WIFI_PROV_PRESS_COUNT`. Gestures recognized less than 5 s apart SHALL +send one event, not one each. + +#### Scenario: Double press sends an event + +- **WHEN** the operator presses `sw0` twice in quick succession on a connected + device +- **THEN** the LED acknowledges the press +- **AND** within a few seconds a `zephyr_Identify` event appears on that + device in the cloud, so the operator can tell which cloud device this board + is +- **AND** the device keeps serving its industrial protocol throughout + +#### Scenario: Gesture while offline + +- **WHEN** the gesture is made while the device is not connected +- **THEN** the event is queued like any other event and sent after the + device reconnects, if the queue has not overflowed + +#### Scenario: Repeated presses do not flood the cloud + +- **WHEN** the operator makes the identify gesture three times within 5 s +- **THEN** one `zephyr_Identify` event is sent + +#### Scenario: Board without sw0 + +- **WHEN** the image runs on a board without `sw0` +- **THEN** it builds and runs, and only the operation half of identify is + present + +### Requirement: Device events reach Cumulocity on the MQTT Service transport + +The repository SHALL ship a Smart Function, `tedge-zephyr-events`, that maps +every `te/device////e/` message to a Cumulocity event of type +`` on the device whose external ID (`c8y_Serial`) is the MQTT client ID, +with the payload's `text`, its `time` when present (the message time +otherwise), and any further payload fields kept as fragments. Its tests SHALL +run under `cumulocity/smart-functions/test.py`, and `cumulocity/README.md` +SHALL list it with the other functions. + +#### Scenario: Identify event is mapped + +- **WHEN** the device publishes `{"text":"Identify button pressed on + tedge-abc123"}` on `te/device/abc123///e/zephyr_Identify` +- **THEN** the tenant has an event of type `zephyr_Identify` with that text on + the device with external ID `abc123` + +#### Scenario: Any other event type is mapped too + +- **WHEN** the device publishes on `te/device////e/app_test` +- **THEN** an `app_test` event is created on that device, not dropped diff --git a/openspec/changes/archive/2026-10-06-device-identify/specs/tedge-diagnostics/spec.md b/openspec/changes/archive/2026-10-06-device-identify/specs/tedge-diagnostics/spec.md new file mode 100644 index 0000000..9889817 --- /dev/null +++ b/openspec/changes/archive/2026-10-06-device-identify/specs/tedge-diagnostics/spec.md @@ -0,0 +1,59 @@ +## MODIFIED Requirements + +### Requirement: A commanded shell is allow-listed and off by default + +When the shell command feature is built in, the client SHALL run only +commands permitted by its configured allow-list, and SHALL refuse every +command when no allow-list is configured, with a reason that names what to +configure. The client SHALL refuse a command that tries to chain or redirect +into another command, whatever the allow-list says. A command SHALL NOT run +on the client's own thread, and only one command SHALL run at a time. +The reported output of a command, whether it succeeded or failed, SHALL keep +its line breaks, and SHALL NOT contain carriage returns, terminal escape +sequences (such as the colour codes the Zephyr shell puts around an error) +or any other control character except tab. A failed command SHALL always +leave its operation FAILED with that output as the reason, never EXECUTING. A `help` (or `?`) +command SHALL be answered without running anything, with a header line +followed by each allow-listed command on a line of its own. + +#### Scenario: No allow-list configured + +- **WHEN** the cloud sends a command to a device whose allow-list is empty +- **THEN** the operation fails with a reason naming the configuration + needed, and nothing runs + +#### Scenario: An allowed command + +- **WHEN** the cloud sends a command that the allow-list permits +- **THEN** the device runs it and reports what it printed + +#### Scenario: Smuggling a second command + +- **WHEN** a command begins with an allowed prefix but chains another + command onto it +- **THEN** the operation fails and neither command runs + +#### Scenario: The connection during a slow command + +- **WHEN** a permitted command takes several seconds +- **THEN** the client stays connected and keeps handling other messages + +#### Scenario: Asking for help + +- **WHEN** the cloud sends `help` to a device whose allow-list is + `kernel uptime,net iface,tedge diag` +- **THEN** the operation succeeds with a result whose lines are a header, + then `kernel uptime`, `net iface` and `tedge diag`, one per line + +#### Scenario: Multi-line output keeps its lines + +- **WHEN** a permitted command prints several lines ending in `\r\n` +- **THEN** the reported result has the same lines separated by `\n`, with + no carriage returns and no lines joined by spaces + +#### Scenario: A command that fails with a shell error + +- **WHEN** a permitted command fails and prints its error with + `shell_error()`, which colours it with escape sequences +- **THEN** the operation fails with the error text as its reason, without the + escape sequences, and Cumulocity accepts the status diff --git a/openspec/changes/archive/2026-10-06-device-identify/specs/wifi-provisioning/spec.md b/openspec/changes/archive/2026-10-06-device-identify/specs/wifi-provisioning/spec.md new file mode 100644 index 0000000..bce03fe --- /dev/null +++ b/openspec/changes/archive/2026-10-06-device-identify/specs/wifi-provisioning/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Button-triggered re-provisioning and erase + +In station mode the firmware SHALL monitor the board's `sw0` button for two +distinct gestures that change the device's mode, plus the identify gesture +(`device-identify`), which does not. The **provisioning pattern** is exactly +`APP_WIFI_PROV_PRESS_COUNT` (default 3) short presses, completed within +`APP_WIFI_PROV_PRESS_WINDOW_MS` (default 2000 ms). When it is recognized, the +application SHALL acknowledge it on the status LED, set the boot request, and +reboot into the provisioner, keeping its stored credentials. The **erase +gesture** is one continuous hold of at least `APP_WIFI_PROV_ERASE_HOLD_S` +(default 10 s). On release the application SHALL delete all stored +credentials, set the boot request, and reboot, and the status LED SHALL show that +the erase is armed before the button is released. No other press sequence, +the identify gesture included, SHALL change the device's mode. The `sw0` +button SHALL be watched whenever the image has the provisioning hand-off or +the identify feature, independent of each other. A board without `sw0` SHALL build and run with +this trigger absent. + +#### Scenario: Button pattern re-provisions and keeps the old network + +- **WHEN** the operator presses `sw0` three times in quick succession, then + abandons provisioning +- **THEN** the device reboots into the provisioner +- **AND** once the provisioning window expires it reboots into the + application and rejoins the previously stored network + +#### Scenario: Very long hold erases credentials + +- **WHEN** the operator holds `sw0` for at least the erase hold time and + releases it +- **THEN** the stored credentials are deleted and the device reboots into the + provisioner + +#### Scenario: Other presses have no effect in station mode + +- **WHEN** `sw0` is pressed once, more times than the pattern, too slowly to + fit the window, or held for less than the erase hold time +- **THEN** the device keeps serving and no request is recorded + +#### Scenario: Identify gesture does not change the mode + +- **WHEN** `sw0` is pressed exactly `APP_IDENTIFY_PRESS_COUNT` times (default + 2) within the window +- **THEN** the device keeps serving, no boot request is recorded, and only the + identify event is sent diff --git a/openspec/changes/archive/2026-10-06-device-identify/tasks.md b/openspec/changes/archive/2026-10-06-device-identify/tasks.md new file mode 100644 index 0000000..5b68939 --- /dev/null +++ b/openspec/changes/archive/2026-10-06-device-identify/tasks.md @@ -0,0 +1,45 @@ +## 1. Button gesture classifier (firmware, host-testable) + +- [x] 1.1 `button_gesture.{c,h}`: `identify_count` in `gesture_cfg` (0 = off), `GESTURE_IDENTIFY`; provisioning count and erase hold also accept 0 = off (design D5) +- [x] 1.2 Host tests in `tests/button_gesture/`: 2 → identify, 3 → provision, 2 with identify off → none, 2 too slow → none, triple press never fires identify, hold → erase unchanged, provisioning off + 3 presses → none + +## 2. `sw0` wiring and identify feature in lib/common (firmware) + +- [x] 2.1 Kconfig: `APP_IDENTIFY` (default y if `TEDGE`), `APP_IDENTIFY_DURATION_S` (30, 1–300), `APP_IDENTIFY_PRESS_COUNT` (2, 2–6); `BUILD_ASSERT` that the press counts differ when both features are built +- [x] 2.2 Split GPIO/debounce/work item out of `prov_handoff.c` into `button.c`, built for `APP_PROV_HANDOFF || APP_IDENTIFY`, started from `net.c` outside the hand-off block; provisioning/erase dispatch unchanged (design D4) +- [x] 2.3 `identify.{c,h}`: `tedge identify [seconds]` via `SHELL_SUBCMD_ADD((tedge), …)` under `CONFIG_SHELL`; default/cap 300/reject non-positive or non-numeric; fail with "no status LED" when `!status_led_present()`; `status_led_flash(STATUS_LED_IDENTIFY, …)`, print the duration and return at once (design D1, D2) +- [x] 2.4 `identify.c` gesture handler: 1 s LED ack, `zephyr_Identify` event with the hostname, 5 s rate limit (design D6) +- [x] 2.5 Add `tedge identify` to `CONFIG_TEDGE_SHELL_COMMAND_ALLOW_LIST` and its comment block in `lib/common/tedge-boards/extras/shell-diagnostics.conf` (design D3) + +## 3. Cumulocity side (cloud / tenant, not firmware) + +- [x] 3.1 `cumulocity/smart-functions/tedge-zephyr-events/`: `data-prep.yaml` (`te/device/*///e/*`), `events.js` (design D8) +- [x] 3.2 Tests: `zephyr_Identify`, an arbitrary type, payload without text, payload with extra fields; pass `cumulocity/smart-functions/test.py` against the tenant +- [x] 3.3 `cumulocity/README.md`: add the function to the table and deployment notes + +## 3b. tedge-zephyr: failed shell commands end FAILED (module) + +- [x] 3b.1 `tedge_shell_clean_output()` strips escape sequences and control characters from captured output; called in `tedge_shell_cmd.c` (design D9) +- [x] 3b.2 `sr_quote()` drops control characters except `\n` (line mode), `\r` handling as before, and tab +- [x] 3b.3 Unit tests (native_sim): the captured `shell_error()` text from the S3, layout kept, `sr_quote` control characters; all 93 pass; `check-independence.py` passes +- [x] 3b.4 On the S3: `tedge identify 0` and `tedge identify soon` end FAILED with a readable reason (2026-10-06: both FAILED in seconds, multi-line reason accepted; also `kernel uptime too many args` and `tedge identify 1 2` FAILED with the shell's usage text; no "cloud error" in the console) + +## 4. Build, size and footprint + +- [x] 4.1 Build every tedge-* app × board (ota and full profiles) plus `native_sim`; queue builds sequentially (2026-10-06: all 36 `release/devices.yml` builds and native_sim modbus-server pass) +- [x] 4.2 Measure the flash/RAM delta per board against the baseline, record it in the footprint table, and pass the size gate (measured vs main, 2026-10-06: S3-DevKitC modbus full+shell +463 B image / +32 B dram0; WROOM agent ota +336 B / +24 B; `release/size-baseline.json` rewritten, per-build table in DEVICES.md "Identify measured") +- [x] 4.3 Build with `APP_IDENTIFY=n`, with `APP_PROV_HANDOFF=n`, and without `CONFIG_SHELL` to check each half builds alone (2026-10-06: S3 modbus ota with `APP_PROV_HANDOFF=n` links with `app_button_start` and `app_identify_gesture`, no `app_prov_handle_gesture`; WROOM agent ota links without the shell; the standalone release images build without identify) + +## 5. Verification on hardware + +- [x] 5.1 Deploy the events Smart Function to the dev tenant (eu-latest t493319102, 2026-10-06 18:20 UTC; no other rule on `te/device/*///e/*`) +- [x] 5.2 S3-DevKitC-1 modbus-server full + shell (RGB LED): `tedge identify` from the Shell tab blinks white for 30 s and the operation succeeds early with the duration; `tedge identify 10` blinks ~10 s; `tedge identify 0` fails with the reason; a command off the list is still refused (2026-10-06, tedge-modbusd405927b33d0: all of these pass on the operations and the device log — 3600→300 s, 10 s, 30 s default, 5 s succeed in 1–3 s; 0/soon/1 2 fail; `kernel bogus` refused; `help` lists `tedge identify`. LED behaviour confirmed by the user at the board) +- [x] 5.3 QT Py ESP32-S3 tedge-agent full + shell: same `tedge identify` checks on the NeoPixel (2026-10-06, run as modbus-server full + shell to keep tedge-dot polling it: tedge-modbusf412fa5a9424 on the Pi — help lists it; 0/soon FAILED with reason; 3600→300, 10, 30 s SUCCESSFUL; double press → zephyr_Identify event 18:45:12 UTC; LEDs confirmed by the user) +- [x] 5.4 S3-DevKitC-1 or QT Py: double press → LED ack + `zephyr_Identify` event on the right device; triple press still enters the provisioner; device keeps serving through the double press (2026-10-06, S3: double press → event queued ~0.7 s after release, `zephyr_Identify` "Identify button pressed on tedge-modbusd405927b33d0" on device 1659934142, 2 s from device time to creation; two gestures 12 s apart each sent one; measurements kept flowing. user confirmed the board behaves as expected at the board) +- [x] 5.5 WROOM-32 ota image (no shell, GPIO LED): double press sends the event; `tedge identify` from the cloud is refused as unsupported, not left pending (2026-10-06, no WROOM enrolled, so run on the other two shell-less boards: ESP32-C6 modbus full (no c8y_Command advertised; `tedge identify` sent anyway FAILED "no handler (511)"; double press → event 18:45:28 UTC) and ESP32-CAM snmp tedge-ota (GPIO33 LED, IO0 on the CAM-MB base; double press → event 18:45:42 UTC); LED acknowledgements confirmed by the user on both. The WROOM on the Pi runs a standalone OPC-UA image with no identify, so it was not part of the test) + +## 6. Documentation + +- [x] 6.1 README / DEVICES.md: identify from the cloud (Shell tab: `tedge identify [seconds]`, or `c8y operations create … --template "{c8y_Command:{text:'tedge identify'}}"`) and from the button; gesture table updated with the double press; per-image support (cloud identify only with the shell) in the features table +- [x] 6.2 Update `SCOPE.md` Phase 3 roadmap if identify is listed or should be (checked: the roadmap covers the module only; identify is application-side, so no entry) +- [x] 6.3 Re-check the "Shell diagnostics measured" table in DEVICES.md still holds with the subcommand added (size gate) (the shell builds pass with the new baseline; allow-list text updated; S3 and QT Py run the subcommand) diff --git a/openspec/specs/device-identify/spec.md b/openspec/specs/device-identify/spec.md new file mode 100644 index 0000000..f6d4e3d --- /dev/null +++ b/openspec/specs/device-identify/spec.md @@ -0,0 +1,118 @@ +# device-identify Specification + +## Purpose +Matching a physical board with its cloud device, both ways: the cloud runs `tedge identify` through the shell command and the board blinks its status LED; an operator presses `sw0` twice and the board sends a `zephyr_Identify` event, which the `tedge-zephyr-events` Smart Function stores on its device. +## Requirements +### Requirement: The cloud can make a device show itself on its LED + +A tedge-* image built with `APP_IDENTIFY` and the Zephyr shell SHALL provide a +`tedge identify [seconds]` shell subcommand under the client's `tedge` root. +It SHALL show the identify pattern on the status LED (the fast blink, white on +an RGB LED) for the given number of seconds, or `APP_IDENTIFY_DURATION_S` +(default 30) when none is given, and then return to the pattern shown before. +A value above 300 SHALL be capped at 300; a value that is not a positive +integer, or a board with no status LED, SHALL make the command fail with a +message saying why, and leave the LED unchanged. The command SHALL return as +soon as the blinking starts, printing the duration. Running it again while the +LED is blinking SHALL restart the blinking with the new duration. + +The cloud SHALL trigger it through the existing `c8y_Command` operation: every +shipped image that carries the shell command (`CONFIG_TEDGE_SHELL_COMMAND`) +SHALL list `tedge identify` on its allow-list. No other operation type SHALL be +added for identify. + +#### Scenario: Identify from the Shell tab + +- **WHEN** an operator sends `tedge identify` as a `c8y_Command` to a + connected device whose image carries the shell command +- **THEN** the status LED fast-blinks for about 30 s and then returns to its + connected pattern +- **AND** the operation succeeds within a few seconds, before the blinking + ends, with the command's output (the duration) as its result + +#### Scenario: Identify with a duration + +- **WHEN** the command is `tedge identify 10` +- **THEN** the status LED fast-blinks for about 10 s + +#### Scenario: Duration out of range + +- **WHEN** the command is `tedge identify 3600` +- **THEN** the LED blinks for 300 s and the operation succeeds +- **WHEN** the command is `tedge identify 0` or `tedge identify soon` +- **THEN** the LED does not change and the operation fails with the reason + +#### Scenario: Board without a status LED + +- **WHEN** `tedge identify` runs on a board with neither `led0` nor + `led-strip` +- **THEN** the operation fails with "this board has no status LED" + +#### Scenario: Image without the shell command + +- **WHEN** the image is built without `CONFIG_TEDGE_SHELL_COMMAND` +- **THEN** it builds and runs, the cloud cannot trigger identify, and the + button half still works + +### Requirement: An operator at the device can make it announce itself in the cloud + +A tedge-* image built with `APP_IDENTIFY` on a board with `sw0` SHALL recognize +an **identify gesture**: exactly `APP_IDENTIFY_PRESS_COUNT` (default 2) short +presses within `APP_WIFI_PROV_PRESS_WINDOW_MS`, followed by the same quiet gap +that ends the provisioning pattern. On the gesture the device SHALL publish an +event of type `zephyr_Identify` whose text names the device's hostname and +how it was triggered, and SHALL acknowledge the gesture on the status LED (the +identify pattern for about 1 s). The gesture SHALL NOT change the device's mode +or reboot it. The build SHALL fail when `APP_IDENTIFY_PRESS_COUNT` equals +`APP_WIFI_PROV_PRESS_COUNT`. Gestures recognized less than 5 s apart SHALL +send one event, not one each. + +#### Scenario: Double press sends an event + +- **WHEN** the operator presses `sw0` twice in quick succession on a connected + device +- **THEN** the LED acknowledges the press +- **AND** within a few seconds a `zephyr_Identify` event appears on that + device in the cloud, so the operator can tell which cloud device this board + is +- **AND** the device keeps serving its industrial protocol throughout + +#### Scenario: Gesture while offline + +- **WHEN** the gesture is made while the device is not connected +- **THEN** the event is queued like any other event and sent after the + device reconnects, if the queue has not overflowed + +#### Scenario: Repeated presses do not flood the cloud + +- **WHEN** the operator makes the identify gesture three times within 5 s +- **THEN** one `zephyr_Identify` event is sent + +#### Scenario: Board without sw0 + +- **WHEN** the image runs on a board without `sw0` +- **THEN** it builds and runs, and only the operation half of identify is + present + +### Requirement: Device events reach Cumulocity on the MQTT Service transport + +The repository SHALL ship a Smart Function, `tedge-zephyr-events`, that maps +every `te/device////e/` message to a Cumulocity event of type +`` on the device whose external ID (`c8y_Serial`) is the MQTT client ID, +with the payload's `text`, its `time` when present (the message time +otherwise), and any further payload fields kept as fragments. Its tests SHALL +run under `cumulocity/smart-functions/test.py`, and `cumulocity/README.md` +SHALL list it with the other functions. + +#### Scenario: Identify event is mapped + +- **WHEN** the device publishes `{"text":"Identify button pressed on + tedge-abc123"}` on `te/device/abc123///e/zephyr_Identify` +- **THEN** the tenant has an event of type `zephyr_Identify` with that text on + the device with external ID `abc123` + +#### Scenario: Any other event type is mapped too + +- **WHEN** the device publishes on `te/device////e/app_test` +- **THEN** an `app_test` event is created on that device, not dropped + diff --git a/openspec/specs/tedge-diagnostics/spec.md b/openspec/specs/tedge-diagnostics/spec.md index acc8b1b..19b5a7d 100644 --- a/openspec/specs/tedge-diagnostics/spec.md +++ b/openspec/specs/tedge-diagnostics/spec.md @@ -67,7 +67,10 @@ configure. The client SHALL refuse a command that tries to chain or redirect into another command, whatever the allow-list says. A command SHALL NOT run on the client's own thread, and only one command SHALL run at a time. The reported output of a command, whether it succeeded or failed, SHALL keep -its line breaks, and SHALL NOT contain carriage returns. A `help` (or `?`) +its line breaks, and SHALL NOT contain carriage returns, terminal escape +sequences (such as the colour codes the Zephyr shell puts around an error) +or any other control character except tab. A failed command SHALL always +leave its operation FAILED with that output as the reason, never EXECUTING. A `help` (or `?`) command SHALL be answered without running anything, with a header line followed by each allow-listed command on a line of its own. @@ -106,6 +109,13 @@ followed by each allow-listed command on a line of its own. - **THEN** the reported result has the same lines separated by `\n`, with no carriage returns and no lines joined by spaces +#### Scenario: A command that fails with a shell error + +- **WHEN** a permitted command fails and prints its error with + `shell_error()`, which colours it with escape sequences +- **THEN** the operation fails with the error text as its reason, without the + escape sequences, and Cumulocity accepts the status + ### Requirement: Diagnostics never block the connection Producing or uploading a log SHALL NOT stop the client from serving its diff --git a/openspec/specs/wifi-provisioning/spec.md b/openspec/specs/wifi-provisioning/spec.md index 3fa592f..9233ad6 100644 --- a/openspec/specs/wifi-provisioning/spec.md +++ b/openspec/specs/wifi-provisioning/spec.md @@ -60,7 +60,8 @@ boot-strapping pin. ### Requirement: Button-triggered re-provisioning and erase In station mode the firmware SHALL monitor the board's `sw0` button for two -distinct gestures. The **provisioning pattern** is exactly +distinct gestures that change the device's mode, plus the identify gesture +(`device-identify`), which does not. The **provisioning pattern** is exactly `APP_WIFI_PROV_PRESS_COUNT` (default 3) short presses, completed within `APP_WIFI_PROV_PRESS_WINDOW_MS` (default 2000 ms). When it is recognized, the application SHALL acknowledge it on the status LED, set the boot request, and @@ -68,8 +69,10 @@ reboot into the provisioner, keeping its stored credentials. The **erase gesture** is one continuous hold of at least `APP_WIFI_PROV_ERASE_HOLD_S` (default 10 s). On release the application SHALL delete all stored credentials, set the boot request, and reboot, and the status LED SHALL show that -the erase is armed before the button is released. No other press sequence -SHALL change the device's mode. A board without `sw0` SHALL build and run with +the erase is armed before the button is released. No other press sequence, +the identify gesture included, SHALL change the device's mode. The `sw0` +button SHALL be watched whenever the image has the provisioning hand-off or +the identify feature, independent of each other. A board without `sw0` SHALL build and run with this trigger absent. #### Scenario: Button pattern re-provisions and keeps the old network @@ -89,10 +92,17 @@ this trigger absent. #### Scenario: Other presses have no effect in station mode -- **WHEN** `sw0` is pressed once, twice, more times than the pattern, too - slowly to fit the window, or held for less than the erase hold time +- **WHEN** `sw0` is pressed once, more times than the pattern, too slowly to + fit the window, or held for less than the erase hold time - **THEN** the device keeps serving and no request is recorded +#### Scenario: Identify gesture does not change the mode + +- **WHEN** `sw0` is pressed exactly `APP_IDENTIFY_PRESS_COUNT` times (default + 2) within the window +- **THEN** the device keeps serving, no boot request is recorded, and only the + identify event is sent + ### Requirement: No automatic fallback to provisioning mode A device with credentials (stored or compile-time) SHALL NOT enter provisioning diff --git a/release/size-baseline.json b/release/size-baseline.json index d669a93..953964d 100644 --- a/release/size-baseline.json +++ b/release/size-baseline.json @@ -1,16 +1,16 @@ { "modbus-server-standalone-esp32-devkitc": { - "image": 590311, - "content": 590311, + "image": 590343, + "content": 590343, "ram": { "iram0_0_seg": 59648, - "dram0_0_seg": 97152, + "dram0_0_seg": 97160, "dram1_0_seg": 46904 }, "arena": 99440, "provisioner": { - "image": 740407, - "content": 740407, + "image": 740408, + "content": 740408, "ram": { "iram0_0_seg": 92928, "dram0_0_seg": 125664, @@ -20,16 +20,16 @@ } }, "modbus-server-standalone-esp32c6-devkitc": { - "image": 733882, - "content": 632986, + "image": 733898, + "content": 633058, "ram": { - "sram0_0_seg": 205780, + "sram0_0_seg": 205788, "lp_ram_seg": 36 }, - "arena": 303664, + "arena": 303648, "provisioner": { - "image": 943754, - "content": 863650, + "image": 943755, + "content": 863651, "ram": { "sram0_0_seg": 282472, "lp_ram_seg": 36 @@ -38,16 +38,16 @@ } }, "modbus-server-standalone-esp32s3-devkitc": { - "image": 583866, - "content": 583866, + "image": 583883, + "content": 583883, "ram": { "iram0_0_seg": 56672, - "dram0_0_seg": 196104 + "dram0_0_seg": 196112 }, - "arena": 202996, + "arena": 202980, "provisioner": { - "image": 662058, - "content": 662058, + "image": 662059, + "content": 662059, "ram": { "iram0_0_seg": 57688, "dram0_0_seg": 230760 @@ -56,13 +56,13 @@ } }, "modbus-server-standalone-qtpy-esp32s3": { - "image": 584011, - "content": 584011, + "image": 584026, + "content": 584026, "ram": { "iram0_0_seg": 56200, - "dram0_0_seg": 193500 + "dram0_0_seg": 193508 }, - "arena": 205596, + "arena": 205580, "provisioner": { "image": 662220, "content": 662220, @@ -74,16 +74,16 @@ } }, "modbus-server-tedge-full-esp32c6-devkitc": { - "image": 962586, - "content": 852386, + "image": 962811, + "content": 853123, "ram": { - "sram0_0_seg": 432364, + "sram0_0_seg": 432540, "lp_ram_seg": 36 }, - "arena": 77072, + "arena": 76896, "provisioner": { - "image": 1013963, - "content": 909467, + "image": 1013964, + "content": 909468, "ram": { "sram0_0_seg": 302652, "lp_ram_seg": 36 @@ -95,13 +95,13 @@ } }, "modbus-server-tedge-full-esp32s3-devkitc": { - "image": 934779, - "content": 934779, + "image": 935259, + "content": 935259, "ram": { "iram0_0_seg": 61332, - "dram0_0_seg": 342852 + "dram0_0_seg": 343028 }, - "arena": 56244, + "arena": 56076, "provisioner": { "image": 732123, "content": 732123, @@ -113,16 +113,16 @@ } }, "modbus-server-tedge-full-qtpy-esp32s3": { - "image": 934970, - "content": 934970, + "image": 935435, + "content": 935435, "ram": { "iram0_0_seg": 60380, - "dram0_0_seg": 339244 + "dram0_0_seg": 339420 }, - "arena": 59852, + "arena": 59684, "provisioner": { - "image": 732283, - "content": 732283, + "image": 732284, + "content": 732284, "ram": { "iram0_0_seg": 57216, "dram0_0_seg": 248204 @@ -131,17 +131,17 @@ } }, "modbus-server-tedge-ota-esp32-cam": { - "image": 807206, - "content": 807206, + "image": 807607, + "content": 807607, "ram": { "iram0_0_seg": 65792, - "dram0_0_seg": 144864, + "dram0_0_seg": 145032, "dram1_0_seg": 79128 }, - "arena": 51728, + "arena": 51560, "provisioner": { - "image": 776471, - "content": 776471, + "image": 776472, + "content": 776472, "ram": { "iram0_0_seg": 92928, "dram0_0_seg": 126996, @@ -151,14 +151,14 @@ } }, "modbus-server-tedge-ota-esp32-devkitc": { - "image": 802182, - "content": 802182, + "image": 802615, + "content": 802615, "ram": { "iram0_0_seg": 60416, - "dram0_0_seg": 181472, + "dram0_0_seg": 181496, "dram1_0_seg": 81440 }, - "arena": 15120, + "arena": 15096, "provisioner": { "image": 776471, "content": 776471, @@ -171,16 +171,16 @@ } }, "modbus-server-tedge-ota-esp32c6-devkitc": { - "image": 891115, - "content": 819415, + "image": 891324, + "content": 820152, "ram": { - "sram0_0_seg": 398740, + "sram0_0_seg": 398772, "lp_ram_seg": 36 }, - "arena": 110688, + "arena": 110656, "provisioner": { - "image": 1013963, - "content": 909467, + "image": 1013962, + "content": 909466, "ram": { "sram0_0_seg": 302652, "lp_ram_seg": 36 @@ -192,16 +192,16 @@ } }, "modbus-server-tedge-ota-esp32s3-devkitc": { - "image": 805339, - "content": 805339, + "image": 805548, + "content": 805548, "ram": { "iram0_0_seg": 59280, - "dram0_0_seg": 293804 + "dram0_0_seg": 293828 }, - "arena": 105284, + "arena": 105260, "provisioner": { - "image": 732124, - "content": 732124, + "image": 732123, + "content": 732123, "ram": { "iram0_0_seg": 57688, "dram0_0_seg": 250820 @@ -210,16 +210,16 @@ } }, "modbus-server-tedge-ota-qtpy-esp32s3": { - "image": 739947, - "content": 739947, + "image": 740155, + "content": 740155, "ram": { "iram0_0_seg": 58328, - "dram0_0_seg": 290196 + "dram0_0_seg": 290220 }, - "arena": 108892, + "arena": 108868, "provisioner": { - "image": 732282, - "content": 732282, + "image": 732283, + "content": 732283, "ram": { "iram0_0_seg": 57216, "dram0_0_seg": 248204 @@ -228,17 +228,17 @@ } }, "opcua-server-standalone-esp32-devkitc": { - "image": 750006, - "content": 750006, + "image": 750040, + "content": 750040, "ram": { "iram0_0_seg": 59904, - "dram0_0_seg": 101492, + "dram0_0_seg": 101500, "dram1_0_seg": 65656 }, "arena": 95104, "provisioner": { - "image": 740408, - "content": 740408, + "image": 740407, + "content": 740407, "ram": { "iram0_0_seg": 92928, "dram0_0_seg": 125664, @@ -248,16 +248,16 @@ } }, "opcua-server-standalone-esp32c6-devkitc": { - "image": 854139, - "content": 794059, + "image": 854154, + "content": 794130, "ram": { - "sram0_0_seg": 229836, + "sram0_0_seg": 229844, "lp_ram_seg": 36 }, - "arena": 279600, + "arena": 279584, "provisioner": { - "image": 943755, - "content": 863651, + "image": 943756, + "content": 863652, "ram": { "sram0_0_seg": 282472, "lp_ram_seg": 36 @@ -266,11 +266,11 @@ } }, "opcua-server-standalone-esp32s3-devkitc": { - "image": 768652, - "content": 768652, + "image": 768668, + "content": 768668, "ram": { "iram0_0_seg": 56924, - "dram0_0_seg": 219464 + "dram0_0_seg": 219472 }, "arena": 179620, "provisioner": { @@ -284,16 +284,16 @@ } }, "opcua-server-standalone-qtpy-esp32s3": { - "image": 768794, - "content": 768794, + "image": 768811, + "content": 768811, "ram": { "iram0_0_seg": 56452, - "dram0_0_seg": 216856 + "dram0_0_seg": 216864 }, - "arena": 182236, + "arena": 182220, "provisioner": { - "image": 662220, - "content": 662220, + "image": 662219, + "content": 662219, "ram": { "iram0_0_seg": 57216, "dram0_0_seg": 228144 @@ -302,16 +302,16 @@ } }, "opcua-server-tedge-ota-qtpy-esp32s3": { - "image": 923899, - "content": 923899, + "image": 924106, + "content": 924106, "ram": { "iram0_0_seg": 58580, - "dram0_0_seg": 307088 + "dram0_0_seg": 307112 }, - "arena": 92012, + "arena": 91988, "provisioner": { - "image": 732282, - "content": 732282, + "image": 732284, + "content": 732284, "ram": { "iram0_0_seg": 57216, "dram0_0_seg": 248204 @@ -320,17 +320,17 @@ } }, "snmp-agent-standalone-esp32-devkitc": { - "image": 578760, - "content": 578760, + "image": 578791, + "content": 578791, "ram": { "iram0_0_seg": 59648, - "dram0_0_seg": 105600, + "dram0_0_seg": 105608, "dram1_0_seg": 45144 }, "arena": 90992, "provisioner": { - "image": 740406, - "content": 740406, + "image": 740408, + "content": 740408, "ram": { "iram0_0_seg": 92928, "dram0_0_seg": 125664, @@ -340,16 +340,16 @@ } }, "snmp-agent-standalone-esp32c6-devkitc": { - "image": 731996, - "content": 616668, + "image": 732012, + "content": 616740, "ram": { - "sram0_0_seg": 212344, + "sram0_0_seg": 212352, "lp_ram_seg": 36 }, "arena": 297088, "provisioner": { - "image": 943754, - "content": 863650, + "image": 943755, + "content": 863651, "ram": { "sram0_0_seg": 282472, "lp_ram_seg": 36 @@ -358,13 +358,13 @@ } }, "snmp-agent-standalone-esp32s3-devkitc": { - "image": 582412, - "content": 582412, + "image": 582428, + "content": 582428, "ram": { "iram0_0_seg": 56560, - "dram0_0_seg": 202688 + "dram0_0_seg": 202696 }, - "arena": 196404, + "arena": 196388, "provisioner": { "image": 662058, "content": 662058, @@ -380,12 +380,12 @@ "content": 582556, "ram": { "iram0_0_seg": 56088, - "dram0_0_seg": 200084 + "dram0_0_seg": 200092 }, - "arena": 199004, + "arena": 198988, "provisioner": { - "image": 662219, - "content": 662219, + "image": 662220, + "content": 662220, "ram": { "iram0_0_seg": 57216, "dram0_0_seg": 228144 @@ -394,16 +394,16 @@ } }, "snmp-agent-tedge-full-esp32c6-devkitc": { - "image": 960522, - "content": 842658, + "image": 960747, + "content": 843395, "ram": { - "sram0_0_seg": 444644, + "sram0_0_seg": 444820, "lp_ram_seg": 36 }, - "arena": 64784, + "arena": 64608, "provisioner": { - "image": 1013964, - "content": 909468, + "image": 1013962, + "content": 909466, "ram": { "sram0_0_seg": 302652, "lp_ram_seg": 36 @@ -415,16 +415,16 @@ } }, "snmp-agent-tedge-full-esp32s3-devkitc": { - "image": 809307, - "content": 809307, + "image": 809515, + "content": 809515, "ram": { "iram0_0_seg": 60032, - "dram0_0_seg": 339768 + "dram0_0_seg": 339936 }, - "arena": 59316, + "arena": 59148, "provisioner": { - "image": 732124, - "content": 732124, + "image": 732122, + "content": 732122, "ram": { "iram0_0_seg": 57688, "dram0_0_seg": 250820 @@ -433,13 +433,13 @@ } }, "snmp-agent-tedge-full-qtpy-esp32s3": { - "image": 809450, - "content": 809450, + "image": 809659, + "content": 809659, "ram": { "iram0_0_seg": 59080, - "dram0_0_seg": 336156 + "dram0_0_seg": 336324 }, - "arena": 62924, + "arena": 62756, "provisioner": { "image": 732283, "content": 732283, @@ -451,17 +451,17 @@ } }, "snmp-agent-tedge-ota-esp32-cam": { - "image": 801560, - "content": 801560, + "image": 801976, + "content": 801976, "ram": { "iram0_0_seg": 65792, - "dram0_0_seg": 152904, + "dram0_0_seg": 153072, "dram1_0_seg": 83224 }, - "arena": 43688, + "arena": 43520, "provisioner": { - "image": 776470, - "content": 776470, + "image": 776471, + "content": 776471, "ram": { "iram0_0_seg": 92928, "dram0_0_seg": 126996, @@ -471,13 +471,13 @@ } }, "snmp-agent-tedge-ota-esp32c6-devkitc": { - "image": 889755, - "content": 811679, + "image": 889979, + "content": 812415, "ram": { - "sram0_0_seg": 411044, + "sram0_0_seg": 411076, "lp_ram_seg": 36 }, - "arena": 98384, + "arena": 98352, "provisioner": { "image": 1013963, "content": 909467, @@ -492,16 +492,16 @@ } }, "snmp-agent-tedge-ota-esp32s3-devkitc": { - "image": 738875, - "content": 738875, + "image": 739083, + "content": 739083, "ram": { "iram0_0_seg": 59280, - "dram0_0_seg": 305972 + "dram0_0_seg": 305996 }, - "arena": 93116, + "arena": 93092, "provisioner": { - "image": 732124, - "content": 732124, + "image": 732123, + "content": 732123, "ram": { "iram0_0_seg": 57688, "dram0_0_seg": 250820 @@ -510,16 +510,16 @@ } }, "snmp-agent-tedge-ota-qtpy-esp32s3": { - "image": 739003, - "content": 739003, + "image": 739211, + "content": 739211, "ram": { "iram0_0_seg": 58328, - "dram0_0_seg": 302360 + "dram0_0_seg": 302392 }, - "arena": 96724, + "arena": 96692, "provisioner": { - "image": 732284, - "content": 732284, + "image": 732282, + "content": 732282, "ram": { "iram0_0_seg": 57216, "dram0_0_seg": 248204 @@ -528,13 +528,13 @@ } }, "tedge-agent-tedge-full-esp32c6-devkitc": { - "image": 893179, - "content": 828771, + "image": 893402, + "content": 829506, "ram": { - "sram0_0_seg": 427684, + "sram0_0_seg": 427860, "lp_ram_seg": 36 }, - "arena": 81744, + "arena": 81568, "provisioner": { "image": 1013963, "content": 909467, @@ -549,16 +549,16 @@ } }, "tedge-agent-tedge-full-esp32s3-devkitc": { - "image": 931147, - "content": 931147, + "image": 931627, + "content": 931627, "ram": { "iram0_0_seg": 61332, - "dram0_0_seg": 337672 + "dram0_0_seg": 337848 }, - "arena": 61428, + "arena": 61244, "provisioner": { - "image": 732123, - "content": 732123, + "image": 732122, + "content": 732122, "ram": { "iram0_0_seg": 57688, "dram0_0_seg": 250820 @@ -567,13 +567,13 @@ } }, "tedge-agent-tedge-full-qtpy-esp32s3": { - "image": 931339, - "content": 931339, + "image": 931819, + "content": 931819, "ram": { "iram0_0_seg": 60380, - "dram0_0_seg": 334060 + "dram0_0_seg": 334236 }, - "arena": 65036, + "arena": 64852, "provisioner": { "image": 732282, "content": 732282, @@ -585,14 +585,14 @@ } }, "tedge-agent-tedge-ota-esp32-cam": { - "image": 791671, - "content": 791671, + "image": 792087, + "content": 792087, "ram": { "iram0_0_seg": 65792, - "dram0_0_seg": 143372, + "dram0_0_seg": 143540, "dram1_0_seg": 76056 }, - "arena": 53232, + "arena": 53064, "provisioner": { "image": 776471, "content": 776471, @@ -605,17 +605,17 @@ } }, "tedge-agent-tedge-ota-esp32-devkitc": { - "image": 786903, - "content": 786903, + "image": 787335, + "content": 787335, "ram": { "iram0_0_seg": 60416, - "dram0_0_seg": 179832, + "dram0_0_seg": 180000, "dram1_0_seg": 78368 }, - "arena": 16768, + "arena": 16600, "provisioner": { - "image": 776471, - "content": 776471, + "image": 776472, + "content": 776472, "ram": { "iram0_0_seg": 92928, "dram0_0_seg": 126996, @@ -625,16 +625,16 @@ } }, "tedge-agent-tedge-ota-esp32c6-devkitc": { - "image": 888171, - "content": 798167, + "image": 888379, + "content": 798903, "ram": { - "sram0_0_seg": 394076, + "sram0_0_seg": 394108, "lp_ram_seg": 36 }, - "arena": 115360, + "arena": 115328, "provisioner": { - "image": 1013962, - "content": 909466, + "image": 1013963, + "content": 909467, "ram": { "sram0_0_seg": 302652, "lp_ram_seg": 36 @@ -646,16 +646,16 @@ } }, "tedge-agent-tedge-ota-esp32s3-devkitc": { - "image": 737387, - "content": 737387, + "image": 737611, + "content": 737611, "ram": { "iram0_0_seg": 59280, - "dram0_0_seg": 289272 + "dram0_0_seg": 289296 }, - "arena": 109812, + "arena": 109788, "provisioner": { - "image": 732123, - "content": 732123, + "image": 732122, + "content": 732122, "ram": { "iram0_0_seg": 57688, "dram0_0_seg": 250820 @@ -664,13 +664,13 @@ } }, "tedge-agent-tedge-ota-qtpy-esp32s3": { - "image": 737529, - "content": 737529, + "image": 737740, + "content": 737740, "ram": { "iram0_0_seg": 58328, - "dram0_0_seg": 285660 + "dram0_0_seg": 285684 }, - "arena": 113420, + "arena": 113396, "provisioner": { "image": 732283, "content": 732283, diff --git a/tedge-zephyr/src/tedge_internal.h b/tedge-zephyr/src/tedge_internal.h index ea7cf42..d88c8d9 100644 --- a/tedge-zephyr/src/tedge_internal.h +++ b/tedge-zephyr/src/tedge_internal.h @@ -85,7 +85,9 @@ int tedge_sr_field(const char *line, int index, char *out, size_t len); int tedge_sr_template(const char *line); /** Quote @p in for use as a SmartREST field (adds the surrounding quotes). */ int tedge_sr_quote(const char *in, char *out, size_t len); -/** As tedge_sr_quote(), but keeps line breaks ("\n") and drops "\r". */ +/** As tedge_sr_quote(), but keeps line breaks ("\n") and drops "\r". Both + * drop every other control character except tab: Cumulocity refuses a + * message that carries one. */ int tedge_sr_quote_lines(const char *in, char *out, size_t len); /* --- Helpers (unit-tested on native_sim) --------------------------------- */ @@ -286,6 +288,12 @@ bool tedge_shell_is_help(const char *cmd); * @return the number of commands, or -ENOSPC when it had to be cut short. */ int tedge_shell_help_text(const char *list, char *buf, size_t len); +/** + * Strip what the shell adds for a terminal (colour escape sequences, other + * control characters) from captured output, in place; line breaks and tabs + * stay. @return the new length. + */ +size_t tedge_shell_clean_output(char *s); /* --- Firmware update (tedge_firmware.c) ---------------------------------- */ diff --git a/tedge-zephyr/src/tedge_shell_allow_list.c b/tedge-zephyr/src/tedge_shell_allow_list.c index 880d324..4590b52 100644 --- a/tedge-zephyr/src/tedge_shell_allow_list.c +++ b/tedge-zephyr/src/tedge_shell_allow_list.c @@ -146,3 +146,48 @@ int tedge_shell_help_text(const char *list, char *buf, size_t len) } return (n >= len) ? -ENOSPC : count; } + +/* Zephyr's shell colours what it prints: shell_error() wraps its message in + * "ESC[1;31m" ... "ESC[m". In a terminal that is red text; in an operation + * result it is a control character, and Cumulocity refuses the whole status + * message ("contains invalid characters"), so the operation is never + * finished. Escape sequences go, and with them any other control character + * except the line breaks and tabs the output is laid out with. */ +size_t tedge_shell_clean_output(char *s) +{ + char *out = s; + const char *p = s; + + if (s == NULL) { + return 0; + } + while (*p != '\0') { + unsigned char c = (unsigned char)*p; + + if (c == 0x1b) { /* ESC */ + p++; + if (*p == '[') { + /* CSI: parameters and intermediates, then one + * final byte in 0x40..0x7e. */ + p++; + while (*p >= 0x20 && *p <= 0x3f) { + p++; + } + if (*p >= 0x40 && *p <= 0x7e) { + p++; + } + } else if (*p != '\0') { + p++; /* a two-character escape */ + } + continue; + } + if ((c < 0x20 && c != '\n' && c != '\r' && c != '\t') || + c == 0x7f) { + p++; + continue; + } + *out++ = *p++; + } + *out = '\0'; + return (size_t)(out - s); +} diff --git a/tedge-zephyr/src/tedge_shell_cmd.c b/tedge-zephyr/src/tedge_shell_cmd.c index 36b5841..74bdded 100644 --- a/tedge-zephyr/src/tedge_shell_cmd.c +++ b/tedge-zephyr/src/tedge_shell_cmd.c @@ -65,6 +65,7 @@ static void run_thread(void *a, void *b, void *c) memcpy(ev.output, out, keep); ev.output[keep] = '\0'; + (void)tedge_shell_clean_output(ev.output); } if (ret != 0 && ev.output[0] == '\0') { snprintf(ev.output, sizeof(ev.output), "the command failed (%d)", diff --git a/tedge-zephyr/src/tedge_smartrest.c b/tedge-zephyr/src/tedge_smartrest.c index cef68c7..073c92d 100644 --- a/tedge-zephyr/src/tedge_smartrest.c +++ b/tedge-zephyr/src/tedge_smartrest.c @@ -93,6 +93,13 @@ static int sr_quote(const char *in, char *out, size_t len, bool lines) if (lines && *p == '\r') { continue; } + /* Cumulocity refuses a message with any other control + * character in it, so one never gets that far. */ + if (((unsigned char)*p < 0x20 && *p != '\n' && *p != '\r' && + *p != '\t') || + *p == 0x7f) { + continue; + } if (n + need + 2 > len) { /* closing quote + NUL */ break; } diff --git a/tedge-zephyr/tests/unit/src/main.c b/tedge-zephyr/tests/unit/src/main.c index a059d20..355fe9a 100644 --- a/tedge-zephyr/tests/unit/src/main.c +++ b/tedge-zephyr/tests/unit/src/main.c @@ -106,6 +106,18 @@ ZTEST(tedge_smartrest, test_quote_lines_keeps_line_breaks) zassert_str_equal(out, "\"say \"\"hi\"\"\n\""); } +ZTEST(tedge_smartrest, test_quote_drops_control_characters) +{ + char out[64]; + + /* An escape or a bell would make Cumulocity refuse the message. */ + zassert_true(tedge_sr_quote_lines("a\x1b\x07" "b\n\tc\x7f", out, + sizeof(out)) > 0); + zassert_str_equal(out, "\"ab\n\tc\""); + zassert_true(tedge_sr_quote("a\x1b" "b", out, sizeof(out)) > 0); + zassert_str_equal(out, "\"ab\""); +} + ZTEST(tedge_smartrest, test_quote_lines_truncates_safely) { char out[8]; @@ -638,6 +650,27 @@ ZTEST(tedge_shell_allow, test_help_lists_the_allow_list) "\n kernel uptime\n net iface\n tedge diag"); } +ZTEST(tedge_shell_allow, test_clean_output_strips_colours) +{ + /* What shell_error() printed into the capture backend, as seen on an + * ESP32-S3 (tedge identify 0). */ + char out[] = "\r\n\x1b[1;31mthe duration must be above 0, not \"0\"\r\n" + "\x1b[m"; + + zassert_equal(tedge_shell_clean_output(out), strlen(out)); + zassert_str_equal(out, + "\r\nthe duration must be above 0, not \"0\"\r\n"); +} + +ZTEST(tedge_shell_allow, test_clean_output_keeps_layout) +{ + char out[] = "a\tb\nc\x1b[0m\x1b" "7d\x07\x1b"; + + tedge_shell_clean_output(out); + zassert_str_equal(out, "a\tb\ncd"); + zassert_equal(tedge_shell_clean_output(NULL), 0); +} + ZTEST(tedge_shell_allow, test_help_with_an_empty_list) { char out[160]; diff --git a/tests/button_gesture/test_button_gesture.c b/tests/button_gesture/test_button_gesture.c index b84dc5d..b971797 100644 --- a/tests/button_gesture/test_button_gesture.c +++ b/tests/button_gesture/test_button_gesture.c @@ -11,7 +11,26 @@ #include +/* The firmware's defaults: provisioning 3 presses, identify 2. */ static const struct gesture_cfg cfg = { + .press_count = 3, + .identify_count = 2, + .window_ms = 2000, + .short_max_ms = 1000, + .quiet_ms = 700, + .erase_ms = 10000, +}; + +/* An image without the provisioning hand-off: identify only. */ +static const struct gesture_cfg identify_only = { + .identify_count = 2, + .window_ms = 2000, + .short_max_ms = 1000, + .quiet_ms = 700, +}; + +/* An image without identify: the original classifier. */ +static const struct gesture_cfg provision_only = { .press_count = 3, .window_ms = 2000, .short_max_ms = 1000, @@ -28,16 +47,18 @@ struct result { int provision; int erase; int armed; + int identify; }; -static struct result run(const struct press *p, int n) +static struct result run(const struct gesture_cfg *c, const struct press *p, + int n) { struct gesture_state g; struct result r = { 0 }; int i = 0; int64_t end = p[n - 1].up + 5000; - gesture_init(&g, &cfg); + gesture_init(&g, c); for (int64_t t = 0; t <= end; t += 10) { enum gesture e = GESTURE_NONE; @@ -52,26 +73,32 @@ static struct result run(const struct press *p, int n) r.provision += e == GESTURE_PROVISION; r.erase += e == GESTURE_ERASE; r.armed += e == GESTURE_ERASE_ARMED; + r.identify += e == GESTURE_IDENTIFY; } return r; } static int failures; -static void expect(const char *name, const struct press *p, int n, - int provision, int erase) +static void expect(const char *name, const struct gesture_cfg *c, + const struct press *p, int n, int provision, int erase, + int identify) { - struct result r = run(p, n); + struct result r = run(c, p, n); bool ok = r.provision == provision && r.erase == erase && - r.armed == erase; + r.armed == erase && r.identify == identify; - printf("%-40s %s (provision=%d erase=%d armed=%d)\n", name, - ok ? "ok" : "FAIL", r.provision, r.erase, r.armed); + printf("%-44s %s (provision=%d erase=%d armed=%d identify=%d)\n", + name, ok ? "ok" : "FAIL", r.provision, r.erase, r.armed, + r.identify); failures += !ok; } -#define EXPECT(name, arr, prov, erase) \ - expect(name, arr, (int)(sizeof(arr) / sizeof(arr[0])), prov, erase) +#define N(arr) ((int)(sizeof(arr) / sizeof(arr[0]))) +#define EXPECT(name, arr, prov, erase, ident) \ + expect(name, &cfg, arr, N(arr), prov, erase, ident) +#define EXPECT_CFG(name, c, arr, prov, erase, ident) \ + expect(name, c, arr, N(arr), prov, erase, ident) int main(void) { @@ -92,15 +119,35 @@ int main(void) { 3000, 3150 }, { 3400, 3550 }, { 3800, 3950 }, }; - EXPECT("1 press", one, 0, 0); - EXPECT("2 presses", two, 0, 0); - EXPECT("3 presses (the pattern)", three, 1, 0); - EXPECT("4 presses", four, 0, 0); - EXPECT("3 presses, too slow", slow, 0, 0); - EXPECT("3 presses, last one held 1.6 s", long_third, 0, 0); - EXPECT("3 s hold", short_hold, 0, 0); - EXPECT("10.4 s hold (erase)", erase, 0, 1); - EXPECT("pattern twice, apart", three_then_three, 2, 0); + static const struct press two_slow[] = { { 100, 250 }, { 2000, 2150 } }; + static const struct press two_then_two[] = { + { 100, 250 }, { 500, 650 }, { 3000, 3150 }, { 3400, 3550 }, + }; + + EXPECT("1 press", one, 0, 0, 0); + EXPECT("2 presses (identify)", two, 0, 0, 1); + EXPECT("2 presses, too slow", two_slow, 0, 0, 0); + EXPECT("identify twice, apart", two_then_two, 0, 0, 2); + EXPECT("3 presses (provisioning, never identify)", three, 1, 0, 0); + EXPECT("4 presses", four, 0, 0, 0); + /* The second gap (750 ms) ends the sequence after two presses, so a + * slow triple is two quick presses and a lone one: identify, which + * changes nothing on the device. */ + EXPECT("3 presses, too slow", slow, 0, 0, 1); + EXPECT_CFG("identify off: 3 presses, too slow", &provision_only, slow, + 0, 0, 0); + EXPECT("3 presses, last one held 1.6 s", long_third, 0, 0, 0); + EXPECT("3 s hold", short_hold, 0, 0, 0); + EXPECT("10.4 s hold (erase)", erase, 0, 1, 0); + EXPECT("pattern twice, apart", three_then_three, 2, 0, 0); + + EXPECT_CFG("identify off: 2 presses", &provision_only, two, 0, 0, 0); + EXPECT_CFG("identify off: 3 presses", &provision_only, three, 1, 0, 0); + EXPECT_CFG("provisioning off: 2 presses", &identify_only, two, 0, 0, 1); + EXPECT_CFG("provisioning off: 3 presses", &identify_only, three, 0, 0, + 0); + EXPECT_CFG("provisioning off: 10.4 s hold", &identify_only, erase, 0, + 0, 0); printf("%s\n", failures ? "FAILED" : "all passed"); return failures ? 1 : 0;