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;