diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 625b643..f77aa40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,6 @@ on: paths: - "apps/**" - "lib/**" - - "tedge-zephyr/**" - "sysbuild/**" - "west.yml" - "release/**" diff --git a/.github/workflows/tedge-zephyr.yml b/.github/workflows/tedge-zephyr.yml deleted file mode 100644 index fa7fb46..0000000 --- a/.github/workflows/tedge-zephyr.yml +++ /dev/null @@ -1,63 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# Checks for the tedge-zephyr module. It must stay self-contained so it can -# move into its own repository unchanged. -name: tedge-zephyr - -on: - pull_request: - paths: - - "tedge-zephyr/**" - - ".github/workflows/tedge-zephyr.yml" - push: - branches: [main] - paths: - - "tedge-zephyr/**" - -permissions: - contents: read - -jobs: - independence: - name: Module is self-contained - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: python3 tedge-zephyr/scripts/check-independence.py - - run: python3 tedge-zephyr/scripts/check-secrets.py - - unit: - name: Unit tests and Kconfig rules - runs-on: ubuntu-latest - container: - image: zephyrprojectrtos/zephyr-build:latest - options: --user root - env: - # The image registers the SDK in the *non-root* user's CMake package - # registry (~/.cmake/packages/Zephyr-sdk), and this job runs as root, - # so find_package(Zephyr-sdk) comes up empty: "Could not find a - # package configuration file provided by Zephyr-sdk". Pointing at the - # directory that holds zephyr-sdk- lets CMake find it - # whatever version :latest happens to carry. - ZEPHYR_SDK_INSTALL_DIR: /opt/toolchains - steps: - - uses: actions/checkout@v4 - with: - path: app - - name: Set up the Zephyr workspace - run: | - west init -l app - west update --narrow -o=--depth=1 - west zephyr-export - - name: Unit tests (native_sim) - working-directory: app - run: | - west build -b native_sim/native/64 tedge-zephyr/tests/unit \ - -d ../build-unit --pristine - ../build-unit/zephyr/zephyr.exe - - name: Kconfig dependency rules - working-directory: app - run: bash tedge-zephyr/tests/kconfig/check-kconfig.sh ../build-kconfig diff --git a/.github/workflows/wifi-provisioner.yml b/.github/workflows/wifi-provisioner.yml index a483e6d..8489f1f 100644 --- a/.github/workflows/wifi-provisioner.yml +++ b/.github/workflows/wifi-provisioner.yml @@ -30,8 +30,8 @@ jobs: image: zephyrprojectrtos/zephyr-build:latest options: --user root env: - # See tedge-zephyr.yml: the image registers the SDK for its non-root - # user only, and this job runs as root. + # The image registers the SDK for its non-root user only, and this + # job runs as root. ZEPHYR_SDK_INSTALL_DIR: /opt/toolchains steps: - uses: actions/checkout@v4 diff --git a/DEVICES.md b/DEVICES.md index fbac545..40ec980 100644 --- a/DEVICES.md +++ b/DEVICES.md @@ -131,7 +131,7 @@ upload and the heap still in dram1 missed by 1.3 KB. 192 KB dram0 and no PSRAM. The client fits beside **Modbus** or with **no protocol** (`apps/tedge-agent`), at the ota level plus remote access; not beside SNMP or OPC-UA at any level. Measured on the rpi5 WROOM -(3c:71:bf:10:c2:e4) with `lib/common/tedge-boards/esp32-devkitc.conf`: +(3c:71:bf:10:c2:e4) with `tiny-edge/ports/zephyr/profiles/boards/esp32-devkitc.conf`: 8 KB TLS records, a 56 KB mbedTLS heap in internal RAM, 10 network connections. Each image was flashed `--app-only`, run against thin-edge-io.eu-latest and the board restored from a full flash backup @@ -238,7 +238,7 @@ Telemetry was published throughout, but the test tenant had no mapping for ### Shell diagnostics measured (2026-09-22) -`lib/common/tedge-boards/extras/shell-diagnostics.conf` adds the cloud shell +`tiny-edge/ports/zephyr/profiles/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`, and since 2026-10-06 `tedge identify`; `help` lists them, one per line). It costs about 16 KB of internal RAM. @@ -434,7 +434,7 @@ CONFIG_NET_BUF_TX_COUNT=64 Without them a session paints one screen, freezes, and the device drops off the cloud and reconnects. The failure is **burst depth, not bandwidth** — the same image streams 1 MB happily. See -`tedge-zephyr/docs/debugging-against-real-devices.md`. +`tiny-edge/ports/zephyr/docs/debugging-against-real-devices.md`. ## Known board quirks @@ -451,8 +451,8 @@ the same image streams 1 MB happily. See ## Rebuilding one ```sh -OVR="/ws/app/tedge-zephyr/profiles/.conf;\ -/ws/app/lib/common/tedge-boards/.conf;\ +OVR="/ws/modules/lib/tiny-edge/ports/zephyr/profiles/.conf;\ +/ws/modules/lib/tiny-edge/ports/zephyr/profiles/boards/.conf;\ /ws/app/overlay-wifi-credentials.conf;/ws/app/tedge.local.conf" docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \ @@ -462,8 +462,8 @@ docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \ ESPTOOL=~/flashenv/bin/esptool ./scripts/flash.sh build-x --port --app-only ``` -The profile picks the features (`tedge-zephyr/profiles/`); the board file -(`lib/common/tedge-boards/`, one per device, whatever the app) sizes the TLS +The profile picks the features (`tiny-edge/ports/zephyr/profiles/`); the board file +(`tiny-edge/ports/zephyr/profiles/boards/`, one per device, whatever the app) sizes the TLS heap, PSRAM, connections and network buffers, and goes after the profile so its sizes win. The ESP32-CAM also needs `-DEXTRA_DTC_OVERLAY_FILE=/ws/app/lib/common/dts/esp32cam-status-led.overlay` diff --git a/README.md b/README.md index 35e8cf2..82d0d66 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,10 @@ group to flash. ## First-time workspace setup This repo is a Zephyr *workspace application* (T2 topology): it is the west -manifest repo, and `west update` fetches Zephyr + modules + open62541. +manifest repo, and `west update` fetches Zephyr + modules + open62541, and +the thin-edge.io client, [tiny-edge](https://github.com/thin-edge/tiny-edge), +into `/ws/modules/lib/tiny-edge`. West adds tiny-edge as a Zephyr module, so +the applications do not name it in their CMake files. ```sh # Persistent build container with this repo mounted at /ws/app. @@ -199,6 +202,12 @@ docker exec -w /ws zephyr-dev west blobs fetch hal_espressif docker exec zephyr-dev bash -lc 'cd /ws/modules/crypto/mbedtls && git submodule update --init --recursive' ``` +To change tiny-edge and an application together, work in the workspace's +clone (`/ws/modules/lib/tiny-edge`), or mount your own clone over it with +`-v /path/to/tiny-edge:/ws/modules/lib/tiny-edge` when you create the +container. `west.yml` pins tiny-edge to a release, so a change to the +client is released there first and then picked up here by moving the pin. + ## Build & run on `native_sim` ```sh @@ -610,8 +619,8 @@ has no room for both. Build the provisioner with `overlay-ztp.conf`, and leave the Wi-Fi and tenant overlays out — the bundle supplies both: ```sh -OVR="/ws/app/tedge-zephyr/profiles/full.conf;\ -/ws/app/lib/common/tedge-boards/esp32c6-devkitc.conf" +OVR="/ws/modules/lib/tiny-edge/ports/zephyr/profiles/full.conf;\ +/ws/modules/lib/tiny-edge/ports/zephyr/profiles/boards/esp32c6-devkitc.conf" docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \ west build --sysbuild -b esp32c6_devkitc/esp32c6/hpcore apps/modbus-server \ @@ -648,7 +657,7 @@ in PSA ITS, never exported); the relay forwards it, the server answers, the relay writes the answer back. The device joins the Wi-Fi network first — nothing is stored unless that works — then stores the Cumulocity data for the application and reboots into it. The application hands the tenant and password -to `tedge-zephyr` (`tedge_set_c8y_url()`, `tedge_set_enroll_otp()`) and uses +to `tiny-edge` (`tedge_set_c8y_url()`, `tedge_set_enroll_otp()`) and uses the external ID from the bundle as its identity from then on. A *pending* answer (the operator has not approved the device yet) leaves it provisionable: the relay simply asks again. @@ -1080,7 +1089,7 @@ lib/frontend-template/ copy-me skeleton for a new protocol (CAN/...) apps/opcua-server/ OPC-UA firmware: lib/common (env sim) + lib/opcua apps/modbus-server/ Modbus TCP firmware: lib/common (pump sim) + lib/modbus apps/snmp-agent/ SNMP agent firmware: lib/common (switch sim) + lib/snmp -apps/tedge-agent/ device-management agent: lib/common + tedge-zephyr, no protocol +apps/tedge-agent/ device-management agent: lib/common + tiny-edge, no protocol ├── boards/.conf per-app board overlays (RAM/Wi-Fi tuning) └── points.d//*.toml point library: the app's address map for a collector (see "Point libraries" below) @@ -1226,7 +1235,7 @@ demo lively but a device left running noisy. Override per build: `apps/tedge-agent` is the thin-edge.io client with **no industrial protocol**: `lib/common` (Wi-Fi, stored or provisioned credentials, the provisioner hand-off, identity, status LED, button, liveness watchdog) plus -`tedge-zephyr`. It is for a device whose job is to be managed from Cumulocity +`tiny-edge`. It is for a device whose job is to be managed from Cumulocity and to give it **remote access to hosts on its LAN** (SSH, web UIs) — the role the hand-built WROOM "remote-access enabler" played. @@ -1237,7 +1246,7 @@ Remote access follows the client's Kconfig policy, which by default allows hosts on the device's own LAN. It advertises no DNS-SD service; it answers to `.local` (`tedge-agent`). -Build it with a `tedge-zephyr` profile and the board's settings, like the +Build it with a `tiny-edge` profile and the board's settings, like the `tedge` builds of the protocol apps, and with the lab-ztp-provisioner provisioner so the tenant arrives at provisioning time: @@ -1245,12 +1254,12 @@ provisioner so the tenant arrives at provisioning time: docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \ west build --sysbuild -b esp32c6_devkitc/esp32c6/hpcore apps/tedge-agent \ --pristine -d build-agent -- \ - "-DEXTRA_CONF_FILE=/ws/app/tedge-zephyr/profiles/full.conf;/ws/app/lib/common/tedge-boards/esp32c6-devkitc.conf" \ + "-DEXTRA_CONF_FILE=/ws/modules/lib/tiny-edge/ports/zephyr/profiles/full.conf;/ws/modules/lib/tiny-edge/ports/zephyr/profiles/boards/esp32c6-devkitc.conf" \ -Dwifi-provisioner_EXTRA_CONF_FILE=/ws/app/apps/wifi-provisioner/overlay-ztp.conf ``` -On an ESP32-WROOM-32 use `profiles/ota.conf`, `tedge-boards/esp32-devkitc.conf` -and `tedge-boards/extras/parameters.conf`. Remote access links there but +On an ESP32-WROOM-32 use `profiles/ota.conf`, `profiles/boards/esp32-devkitc.conf` +and `profiles/extras/parameters.conf`. Remote access links there but does not work interactively: the board has room for about three Wi-Fi frames of receive buffers, and an `htop` session over a tunnel fails (DEVICES.md). @@ -1271,7 +1280,7 @@ tenant needs, ready to deploy. Every payload is flat: `time` (UTC) and one number per series, two decimals, **no units** (the tables below give them, for the mapping to add). The format -is the client's; see `tedge-zephyr/README.md`, "What the client publishes". +is the client's; see `tiny-edge/ports/zephyr/README.md`, "What the client publishes". **`modbus-server`** — `te/device////m/pump`, every 30 s (the pump simulation it also serves over Modbus; values are 0 or ambient until a client @@ -1361,7 +1370,7 @@ c8y operations create --device \ ``` This needs an image with the cloud shell command, which today means the -images built with `lib/common/tedge-boards/extras/shell-diagnostics.conf` +images built with `tiny-edge/ports/zephyr/profiles/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 @@ -1499,8 +1508,8 @@ Things to know: ## Releasing All applications share **one version**, kept in every `apps/*/VERSION` -(`tedge-zephyr/VERSION` is the module's own and moves only when the module -changes). [release-please](https://github.com/googleapis/release-please) +(tiny-edge has its own version, and `west.yml` pins the release the +applications are built with). [release-please](https://github.com/googleapis/release-please) chooses the next one from the commit messages on `main`: - **Pull requests are squash-merged**, and the PR title becomes the commit @@ -1605,7 +1614,7 @@ fingerprint. Devices change keys only by a flash by cable. **Adding a device or a build.** Its board needs a provisioner layout (`lib/common/dts/layout-*.dtsi`, listed in `sysbuild/provisioning.cmake`) and board files for each app. For a `tedge-*` build, also a board settings file in -[`lib/common/tedge-boards/`](lib/common/tedge-boards/README.md), and a run on +[`tiny-edge/ports/zephyr/profiles/boards/`](https://github.com/thin-edge/tiny-edge/blob/main/ports/zephyr/profiles/boards/README.md), and a run on the board recorded in DEVICES.md — connection, telemetry, a firmware update that confirms, a tunnel if remote access is in — which the manifest entry names in `measured:`. `scripts/release/matrix.py` rejects an entry without diff --git a/SCOPE.md b/SCOPE.md index 792e2be..0364fc9 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -27,7 +27,7 @@ Cumulocity are **not** involved in this primary phase. protocol), serving device/sensor data to an external client. - **Phase 2: Additional protocols / sources.** Extend beyond OPC-UA to other industry protocols as source options. -- **Phase 3 (started 2026-09-19): tedge-zephyr, a device-management client.** +- **Phase 3 (started 2026-09-19): tiny-edge, a device-management client.** A thin-edge.io client shipped as a **reusable Zephyr module** that any user's Zephyr application can include. The OPC-UA, Modbus and SNMP apps here serve as example user applications. It provides device-management features: @@ -104,23 +104,24 @@ single-purpose devices (OPC-UA today; SNMP/Modbus/CAN as later Phase-2 apps): `west build -b apps/`. Today: `apps/opcua-server` (OPC-UA, env sim), `apps/modbus-server` (Modbus TCP on port 502, pump sim) and `apps/snmp-agent` (SNMPv2c on UDP 161 + traps on 162, switch sim). -- `tedge-zephyr/` — (Phase 3) the thin-edge.io device-management client, a - standalone Zephyr module (`tedge`) for *any* Zephyr application. It never - depends on `lib/` or `apps/`, and it will move into its own repository. Apps - here opt in with `CONFIG_TEDGE` and keep their glue code in `apps//src/`. +- The thin-edge.io device-management client is not in this repository. It + is [tiny-edge](https://github.com/thin-edge/tiny-edge), a Zephyr module + (`tedge`) that `west.yml` pins to a release. Apps here opt in with + `CONFIG_TEDGE` and keep their glue code in `apps//src/`. ## Phase 3 roadmap: device management client ### A module for any application -The client lives in `tedge-zephyr/`, a self-contained Zephyr module named -`tedge`: +The client was developed here as `tedge-zephyr/` and now lives in its own +repository, [tiny-edge](https://github.com/thin-edge/tiny-edge), which is +also laid out for other RTOSes. It is a Zephyr module named `tedge`: - **Namespaced:** the API is `tedge_*` and Kconfig is `CONFIG_TEDGE_*`. - **Self-contained:** it depends only on Zephyr and MCUboot, and never on this repo's `lib/`. -- **Portable:** it is laid out so it can move into its own repository and - west project later without changing its files. +- **Portable:** a west project of its own, with a portable core and one port + per RTOS (Zephyr first). - **The host application owns** connectivity, identity, telemetry and the watchdog. - **The module owns** its threads, a bounded heap, its settings subtree @@ -228,7 +229,7 @@ the strongest reason for the direct transport. | Step | Change | Content | |---|---|---| | P0 | `c8y-direct-spikes` (done 2026-09-19: go) | TLS/MQTT Service cost, OTA into `slot1`, CA enrollment, remote-access tunnel to a LAN host, module skeleton, footprint table | -| P1 | `c8y-direct-core` (done 2026-09-20) | The `tedge-zephyr` module and public API; onboarding (CA and bootstrap), connection with reconnect and back-off, inventory, restart (full-system reset hook), availability, state on `te/` topics with reference Smart Functions; one protocol app and `samples/minimal` integrated | +| P1 | `c8y-direct-core` (done 2026-09-20) | The `tiny-edge` module and public API; onboarding (CA and bootstrap), connection with reconnect and back-off, inventory, restart (full-system reset hook), availability, state on `te/` topics with reference Smart Functions; one protocol app and `samples/minimal` integrated | | P2 | `c8y-direct-remote-access` (done 2026-09-20) | **Remote access**: WSS bridge to LAN hosts and local services under the target policy, session cap, audit events, capacity as twin data | | P3 | `c8y-direct-firmware-update` (done 2026-09-20) | Firmware update: confirmed only after the new image reaches Cumulocity and passes the app's checks; MCUboot rolls back otherwise | | P4 | `c8y-direct-telemetry` (done 2026-09-20) | Telemetry (free-form, `te/`-shaped) and device-health measurements, buffered through an outage with the time each reading was taken | @@ -237,7 +238,7 @@ the strongest reason for the direct transport. | P7 | `c8y-direct-parameters` (done 2026-09-20) | Typed device parameters the cloud can see and change (Cumulocity Parameter Update, as thin-edge.io's parameter plugin does), not configuration files; the client declares a set for itself too, so a device is adjustable in the field whatever its application does | | P8 | `gateway-transport` (proposed) | Gateway transport: a thin-edge.io child device with no TLS, no certificate and no cloud token, reusing the P1–P7 handlers | | — | separate small change | Full-system reset in `lib/common` (`liveness.c`, `net.c`): a CPU reset hangs MCUboot on the C6 | -| — | — | Move `tedge-zephyr` into its own repository once the P1 API has settled | +| — | done 2026-11-03 | Move the client into its own repository: [tiny-edge](https://github.com/thin-edge/tiny-edge) | | later | — | Device profiles, WROOM tuning, "software" via LLEXT or Wasm | ## Open questions @@ -255,5 +256,5 @@ the strongest reason for the direct transport. open problems P1–P13 there carry into `c8y-direct-core`, notably reconnect after a network drop (P1), the C6 reset hang (P2) and key protection (P9). - Phase 3: free-form telemetry is turned into measurements by Cumulocity - Smart Functions. Which reference functions tedge-zephyr ships, and how + Smart Functions. Which reference functions tiny-edge ships, and how they're installed, is open (P12). diff --git a/apps/modbus-server/CMakeLists.txt b/apps/modbus-server/CMakeLists.txt index 9cd0caf..f8b5efd 100644 --- a/apps/modbus-server/CMakeLists.txt +++ b/apps/modbus-server/CMakeLists.txt @@ -8,9 +8,9 @@ cmake_minimum_required(VERSION 3.20.0) list(APPEND ZEPHYR_EXTRA_MODULES ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/common ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/modbus - # thin-edge.io device management; off unless CONFIG_TEDGE=y. - ${CMAKE_CURRENT_SOURCE_DIR}/../../tedge-zephyr ) +# thin-edge.io device management (tiny-edge, off unless CONFIG_TEDGE=y) is a +# west project, so west adds it as a module; see west.yml. find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) project(modbus_server_firmware C) diff --git a/apps/modbus-server/src/tedge_glue.c b/apps/modbus-server/src/tedge_glue.c index 8e949e1..e01c89f 100644 --- a/apps/modbus-server/src/tedge_glue.c +++ b/apps/modbus-server/src/tedge_glue.c @@ -1,6 +1,6 @@ /* SPDX-License-Identifier: Apache-2.0 * - * The glue between this application and the tedge-zephyr client. It lives + * The glue between this application and the tiny-edge client. It lives * here, not in the module: the module knows nothing about lib/common. * * It gives the client this device's identity, shows the registration URL @@ -203,7 +203,7 @@ static int cmd_crash(const struct shell *sh, size_t argc, char **argv) } #endif -/* These hang off the client's own "tedge" root, which tedge-zephyr defines. +/* These hang off the client's own "tedge" root, which tiny-edge defines. * An application that registered a root of the same name would give the * device two of them, and the shell would only ever reach one. */ SHELL_SUBCMD_ADD((tedge), diag, NULL, "client state and TLS heap", cmd_diag, diff --git a/apps/opcua-server/CMakeLists.txt b/apps/opcua-server/CMakeLists.txt index df769c0..822df34 100644 --- a/apps/opcua-server/CMakeLists.txt +++ b/apps/opcua-server/CMakeLists.txt @@ -12,9 +12,9 @@ cmake_minimum_required(VERSION 3.20.0) list(APPEND ZEPHYR_EXTRA_MODULES ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/common ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/opcua - # thin-edge.io device management; off unless CONFIG_TEDGE=y. - ${CMAKE_CURRENT_SOURCE_DIR}/../../tedge-zephyr ) +# thin-edge.io device management (tiny-edge, off unless CONFIG_TEDGE=y) is a +# west project, so west adds it as a module; see west.yml. find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) project(opcua_server_firmware C) diff --git a/apps/opcua-server/src/tedge_glue.c b/apps/opcua-server/src/tedge_glue.c index cfe3148..bd8a402 100644 --- a/apps/opcua-server/src/tedge_glue.c +++ b/apps/opcua-server/src/tedge_glue.c @@ -1,6 +1,6 @@ /* SPDX-License-Identifier: Apache-2.0 * - * The glue between this application and the tedge-zephyr client. It lives + * The glue between this application and the tiny-edge client. It lives * here, not in the module: the module knows nothing about lib/common. * * The OPC-UA server serves its address space to whoever browses it; the diff --git a/apps/snmp-agent/CMakeLists.txt b/apps/snmp-agent/CMakeLists.txt index b000948..5ccb7ee 100644 --- a/apps/snmp-agent/CMakeLists.txt +++ b/apps/snmp-agent/CMakeLists.txt @@ -8,9 +8,9 @@ cmake_minimum_required(VERSION 3.20.0) list(APPEND ZEPHYR_EXTRA_MODULES ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/common ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/snmp - # thin-edge.io device management; off unless CONFIG_TEDGE=y. - ${CMAKE_CURRENT_SOURCE_DIR}/../../tedge-zephyr ) +# thin-edge.io device management (tiny-edge, off unless CONFIG_TEDGE=y) is a +# west project, so west adds it as a module; see west.yml. find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) project(snmp_agent_firmware C) diff --git a/apps/snmp-agent/src/tedge_glue.c b/apps/snmp-agent/src/tedge_glue.c index 9bebd64..81ed4a4 100644 --- a/apps/snmp-agent/src/tedge_glue.c +++ b/apps/snmp-agent/src/tedge_glue.c @@ -1,6 +1,6 @@ /* SPDX-License-Identifier: Apache-2.0 * - * The glue between this application and the tedge-zephyr client. It lives + * The glue between this application and the tiny-edge client. It lives * here, not in the module: the module knows nothing about lib/common. * * The SNMP agent answers polls and sends traps; the client gives the same diff --git a/apps/tedge-agent/CMakeLists.txt b/apps/tedge-agent/CMakeLists.txt index 0b3717a..d35a361 100644 --- a/apps/tedge-agent/CMakeLists.txt +++ b/apps/tedge-agent/CMakeLists.txt @@ -1,18 +1,19 @@ # SPDX-License-Identifier: Apache-2.0 # # Device-management agent firmware: the shared core (lib/common) and the -# thin-edge.io client (tedge-zephyr), with no protocol frontend. For a device +# thin-edge.io client (tiny-edge), with no protocol frontend. For a device # whose job is to be managed from Cumulocity and to give it remote access to -# hosts on its LAN. Build with a tedge-zephyr profile and the board's -# settings (lib/common/tedge-boards/): +# hosts on its LAN. Build with a tiny-edge profile and the board's settings +# (both in tiny-edge's ports/zephyr/profiles/): # west build --sysbuild -b apps/tedge-agent -- \ # "-DEXTRA_CONF_FILE=.conf;.conf" cmake_minimum_required(VERSION 3.20.0) list(APPEND ZEPHYR_EXTRA_MODULES ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/common - ${CMAKE_CURRENT_SOURCE_DIR}/../../tedge-zephyr ) +# The thin-edge.io client (tiny-edge) is a west project, so west adds it as a +# module; see west.yml. find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) project(tedge_agent_firmware C) diff --git a/apps/tedge-agent/Kconfig b/apps/tedge-agent/Kconfig index 230edd4..02f76e1 100644 --- a/apps/tedge-agent/Kconfig +++ b/apps/tedge-agent/Kconfig @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # # Device-management agent. Shared options come from the lib/common module and -# the client's from tedge-zephyr (both auto-sourced via Kconfig.zephyr below). +# the client's from tiny-edge (both auto-sourced via Kconfig.zephyr below). mainmenu "thin-edge.io Device Agent Firmware" diff --git a/apps/tedge-agent/prj.conf b/apps/tedge-agent/prj.conf index b271a35..6792bd3 100644 --- a/apps/tedge-agent/prj.conf +++ b/apps/tedge-agent/prj.conf @@ -1,8 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # # Board-agnostic application configuration. Wi-Fi/stack specifics live in -# boards/.conf; the client's feature set in a tedge-zephyr profile and -# its board sizing in lib/common/tedge-boards/, both passed as +# boards/.conf; the client's feature set in a tiny-edge profile and +# its board sizing in tiny-edge/ports/zephyr/profiles/boards/, both passed as # EXTRA_CONF_FILE. What is here is enough for the client to connect on its # own; the profile adds to it. @@ -19,7 +19,7 @@ CONFIG_TEDGE_RESTART=y CONFIG_TEDGE_HEALTH=y CONFIG_TEDGE_TELEMETRY=y -# What the application must provide for the client (tedge-zephyr/README.md). +# What the application must provide for the client (tiny-edge/ports/zephyr/README.md). CONFIG_MBEDTLS=y CONFIG_MBEDTLS_ENABLE_HEAP=y CONFIG_MBEDTLS_HEAP_SIZE=65536 diff --git a/apps/tedge-agent/src/tedge_glue.c b/apps/tedge-agent/src/tedge_glue.c index 518348b..0a1541d 100644 --- a/apps/tedge-agent/src/tedge_glue.c +++ b/apps/tedge-agent/src/tedge_glue.c @@ -1,6 +1,6 @@ /* SPDX-License-Identifier: Apache-2.0 * - * The glue between this application and the tedge-zephyr client. It lives + * The glue between this application and the tiny-edge client. It lives * here, not in the module: the module knows nothing about lib/common. * * This application has no protocol of its own, so the glue is most of it: @@ -89,7 +89,7 @@ static const struct tedge_hooks hooks = { #if defined(CONFIG_SHELL) /* The client's state, for the cloud's shell command (a general health check - * on the device; see lib/common/tedge-boards/extras/shell-diagnostics.conf). */ + * on the device; see tiny-edge/ports/zephyr/profiles/extras/shell-diagnostics.conf). */ static int cmd_diag(const struct shell *sh, size_t argc, char **argv) { static const char *const names[] = { @@ -106,7 +106,7 @@ static int cmd_diag(const struct shell *sh, size_t argc, char **argv) return 0; } -/* Hangs off the client's own "tedge" root, which tedge-zephyr defines. */ +/* Hangs off the client's own "tedge" root, which tiny-edge defines. */ SHELL_SUBCMD_ADD((tedge), diag, NULL, "client state", cmd_diag, 1, 0); #endif /* CONFIG_SHELL */ diff --git a/cumulocity/README.md b/cumulocity/README.md index 2fa23fe..8e06a1f 100644 --- a/cumulocity/README.md +++ b/cumulocity/README.md @@ -7,35 +7,19 @@ talk to the **Cumulocity MQTT Service** on thin-edge.io `te/` topics what a **Smart Function** maps. Operations (restart, firmware, remote access, parameters, shell) need no mapping. -| Directory | What it is | Needed for | -|---|---|---| -| `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 +The Smart Functions belong to the client and live in tiny-edge, with the +DTM definitions of the client's own parameter sets: see +[tiny-edge's `cumulocity/`](https://github.com/thin-edge/tiny-edge/tree/main/cumulocity) +for how to deploy and test them. This directory holds what the applications +here add: the DTM definitions of their parameter sets. -Each directory is a Data Preparation rule package: `data-prep.yaml` (the -topic pattern), the JavaScript, and `tests/*.yaml`. Create a Smart Function -in the Data Preparation UI with the topic and the JavaScript, or deploy the -package: - -```sh -cd cumulocity/smart-functions/tedge-zephyr-twin -# COPYFILE_DISABLE: macOS tar otherwise adds ._* files the service rejects -COPYFILE_DISABLE=1 tar czf /tmp/rule.tar.gz data-prep.yaml twin.js tests -# The archive must be the raw request body: `c8y api --file` sends a -# multipart form, which the service answers with 400. -curl -X PUT -H "Authorization: Bearer $C8Y_TOKEN" \ - -H "Content-Type: application/gzip" --data-binary @/tmp/rule.tar.gz \ - "$C8Y_HOST/service/dataprep/v1/rules/tedge-zephyr-twin/deployed" -``` +| What | Where | Needed for | +|---|---|---| +| Smart Functions (`tedge-zephyr-measurements`, `-twin`, `-firmware-progress`, `-events`) | tiny-edge `cumulocity/smart-functions/` | measurements, twin fragments, firmware progress and events from the device | +| `zephyr_tedge`, `zephyr_tedge_remote_access` | tiny-edge `cumulocity/dtm/` | the client's own Parameters tab entries | +| `dtm/zephyr_*.json` | here | the applications' Parameters tab entries | -`smart-functions/test.py` runs every rule's tests in a tenant's Data -Preparation runtime (`/service/dataprep/v1/run-tests`) without deploying -anything; run it after changing a function. +## What the Smart Functions do for these applications **Measurements.** Known types get a Cumulocity type, one fragment and named series with units (the device sends none): @@ -74,18 +58,20 @@ The tenant needs the `dtm` and `device-parameter` microservices, and the user registering needs `ROLE_DIGITAL_TWIN_DEFINITIONS_CREATE` and `_ADMIN`. ```sh -cumulocity/dtm/register.sh # registers every set not yet there +cumulocity/dtm/register.sh # registers every set here not yet there cumulocity/dtm/register.sh --replace zephyr_modbus_telemetry # after a declaration changed ``` +Register the client's own sets with the same script in tiny-edge. + | Identifier | Declared by | Parameters | |---|---|---| | `zephyr_modbus_telemetry` | `modbus-server` | `interval_s` (5–3600 s), `telemetry`, `measurement_type` (≤ 31 chars) | | `zephyr_modbus_control` | `modbus-server` | `running`, `mode` (off/auto/manual), `speed_setpoint` (0–100 %), `local_writes` (whether Modbus clients may change the three) | | `zephyr_opcua_telemetry` | `opcua-server` | `interval_s` (5–3600 s), `telemetry` | | `zephyr_snmp_telemetry` | `snmp-agent` | `interval_s` (5–3600 s), `telemetry` | -| `zephyr_tedge` | tedge-zephyr, every image | `log_level` (off/err/wrn/inf/dbg), `health_interval_s` (0–86400 s), `required_interval_min` (0–1440 min) | -| `zephyr_tedge_remote_access` | tedge-zephyr, images with remote access | `remote_access` | +| `zephyr_tedge` | tiny-edge, every image | `log_level` (off/err/wrn/inf/dbg), `health_interval_s` (0–86400 s), `required_interval_min` (0–1440 min) | +| `zephyr_tedge_remote_access` | tiny-edge, images with remote access | `remote_access` | Every identifier starts with `zephyr_` and, for an application's own set, the application, so it cannot collide with a definition someone else @@ -115,7 +101,7 @@ reason, and nothing is applied. ## Shell diagnostics The `tedge-full` Modbus and agent images of the ESP32-S3-DevKitC and the QT Py -ESP32-S3 carry the shell command (`lib/common/tedge-boards/extras/shell-diagnostics.conf`) +ESP32-S3 carry the shell command (`tiny-edge/ports/zephyr/profiles/extras/shell-diagnostics.conf`) for general checks from the device's **Shell** tab. Send `help` to see what the device runs: diff --git a/cumulocity/dtm/register.sh b/cumulocity/dtm/register.sh index 7e73c6f..2150c45 100755 --- a/cumulocity/dtm/register.sh +++ b/cumulocity/dtm/register.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # SPDX-License-Identifier: Apache-2.0 # -# Register the tedge-zephyr parameter sets in a tenant's Digital Twin +# Register the applications' parameter sets in a tenant's Digital Twin # Manager, so the device's Parameters tab can show and edit them. # # cumulocity/dtm/register.sh [--replace] [set...] # default: every *.json here diff --git a/cumulocity/dtm/zephyr_tedge.json b/cumulocity/dtm/zephyr_tedge.json deleted file mode 100644 index a7dc6e5..0000000 --- a/cumulocity/dtm/zephyr_tedge.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "identifier": "zephyr_tedge", - "jsonSchema": { - "type": "object", - "title": "Zephyr thin-edge.io client", - "properties": { - "log_level": { - "title": "log_level", - "order": 1, - "description": "How much the client logs. Raise it on a device that is misbehaving, lower it when you are done.", - "type": "string", - "enum": [ - "off", - "err", - "wrn", - "inf", - "dbg" - ], - "default": "inf" - }, - "health_interval_s": { - "title": "health_interval_s", - "order": 2, - "description": "Seconds between the client's own health measurements. 0 stops them.", - "type": "integer", - "default": 900, - "minimum": 0, - "maximum": 86400 - }, - "required_interval_min": { - "title": "required_interval_min", - "order": 3, - "description": "Minutes of silence after which Cumulocity calls this device offline. 0 disables the check. Cumulocity only accepts this from the device while it has no availability set, so on a device that already has one, change it in the cloud instead.", - "type": "integer", - "default": 60, - "minimum": 0, - "maximum": 1440 - } - } - }, - "contexts": [ - "asset", - "event", - "operation" - ], - "tags": [ - "thin-edge.io-zephyr-modbus", - "thin-edge.io-zephyr-opcua", - "thin-edge.io-zephyr-snmp", - "thin-edge.io-zephyr-agent", - "tedge-zephyr" - ] -} diff --git a/cumulocity/dtm/zephyr_tedge_remote_access.json b/cumulocity/dtm/zephyr_tedge_remote_access.json deleted file mode 100644 index 254a981..0000000 --- a/cumulocity/dtm/zephyr_tedge_remote_access.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "identifier": "zephyr_tedge_remote_access", - "jsonSchema": { - "type": "object", - "title": "Zephyr thin-edge.io client: remote access", - "properties": { - "remote_access": { - "title": "remote_access", - "order": 1, - "description": "Whether the cloud may open a tunnel to this device. Turning it off refuses every tunnel until it is turned back on.", - "type": "boolean", - "default": true - } - } - }, - "contexts": [ - "asset", - "event", - "operation" - ], - "tags": [ - "thin-edge.io-zephyr-modbus", - "thin-edge.io-zephyr-opcua", - "thin-edge.io-zephyr-snmp", - "thin-edge.io-zephyr-agent", - "tedge-zephyr" - ] -} diff --git a/cumulocity/smart-functions/tedge-zephyr-events/data-prep.yaml b/cumulocity/smart-functions/tedge-zephyr-events/data-prep.yaml deleted file mode 100644 index 6ecf4b1..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-events/data-prep.yaml +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 924d1f7..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-events/events.js +++ /dev/null @@ -1,37 +0,0 @@ -// 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 deleted file mode 100644 index 2f5964d..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-events/tests/extra-fields.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 52e6430..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-events/tests/identify.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index c3a35a5..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-events/tests/no-text-no-time.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index ccd6e41..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-events/tests/other-type.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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/cumulocity/smart-functions/tedge-zephyr-firmware-progress/data-prep.yaml b/cumulocity/smart-functions/tedge-zephyr-firmware-progress/data-prep.yaml deleted file mode 100644 index a93f246..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-firmware-progress/data-prep.yaml +++ /dev/null @@ -1,6 +0,0 @@ -smartFunctionFile: firmware-progress.js -description: "tedge-zephyr firmware download progress to c8y_FirmwareDownload events" -tags: [tedge-zephyr, firmware] -input: - transport: mqtt - topicPattern: "te/device/*///progress/firmware" diff --git a/cumulocity/smart-functions/tedge-zephyr-firmware-progress/firmware-progress.js b/cumulocity/smart-functions/tedge-zephyr-firmware-progress/firmware-progress.js deleted file mode 100644 index cb5bc2e..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-firmware-progress/firmware-progress.js +++ /dev/null @@ -1,39 +0,0 @@ -// A Cumulocity smart function that converts firmware download -// progress into a Cumulocity event. -export function onMessage(message, context) { - const decoder = new TextDecoder(); - const data = JSON.parse(decoder.decode(message.payload)); - - // Skip messages with a missing or invalid percentage. - if ( - data.percent === null || - data.percent === undefined || - typeof data.percent !== 'number' || - !Number.isFinite(data.percent) || - data.percent < 0 || - data.percent > 100 - ) { - return []; - } - - return [{ - cumulocityType: 'event', - payload: { - type: 'c8y_FirmwareDownload', - text: `Firmware download ${data.phase}: ${data.name} ${data.version} (${data.percent}%)`, - time: message.time, - - firmwareDownload: { - name: data.name, - version: data.version, - phase: data.phase, - percent: data.percent, - bytes: data.bytes - } - }, - externalSource: [{ - externalId: message.clientID, - type: 'c8y_Serial' - }] - }]; -} diff --git a/cumulocity/smart-functions/tedge-zephyr-firmware-progress/tests/downloading.yaml b/cumulocity/smart-functions/tedge-zephyr-firmware-progress/tests/downloading.yaml deleted file mode 100644 index c7b4813..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-firmware-progress/tests/downloading.yaml +++ /dev/null @@ -1,6 +0,0 @@ -inputs: - - payload: '{"name":"modbus-server-tedge-full-esp32c6-devkitc","version":"0.4.0","phase":"downloading","percent":40,"bytes":355866,"total":888011}' - payloadFormat: json - topic: "te/device/tedge-modbuse8f60afc320c///progress/firmware" - clientID: "tedge-modbuse8f60afc320c" - time: "2026-09-22T06:40:00.000Z" diff --git a/cumulocity/smart-functions/tedge-zephyr-measurements/data-prep.yaml b/cumulocity/smart-functions/tedge-zephyr-measurements/data-prep.yaml deleted file mode 100644 index 161842c..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-measurements/data-prep.yaml +++ /dev/null @@ -1,6 +0,0 @@ -smartFunctionFile: measurements.js -description: "tedge-zephyr measurements (pump, server, agent, device, tedge_health) to Cumulocity measurements" -tags: [tedge-zephyr, measurement] -input: - transport: mqtt - topicPattern: "te/device/*///m/*" diff --git a/cumulocity/smart-functions/tedge-zephyr-measurements/measurements.js b/cumulocity/smart-functions/tedge-zephyr-measurements/measurements.js deleted file mode 100644 index 0cfd40a..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-measurements/measurements.js +++ /dev/null @@ -1,88 +0,0 @@ -// Cumulocity Smart Function: tedge-zephyr measurements -> Cumulocity measurements. -// -// Topic: te/device/*///m/* -// -// Payloads are flat, one number per series and no units (README, "Telemetry -// in Cumulocity"): -// te/device////m/pump modbus-server -// te/device////m/server opcua-server -// te/device////m/agent snmp-agent -// te/device////m/device tedge-agent -// te/device////m/tedge_health every tedge image -// Known types get a Cumulocity type, one fragment and named series with -// units. Any other type (an operator can rename the Modbus one) is kept -// rather than dropped: fragment = type, series = key, no unit. -const DEFINITIONS = { - pump: { - type: 'c8y_Pump', fragment: 'pump', - series: { - flow_lpm: ['flow', 'l/min'], - pressure_bar: ['pressure', 'bar'], - motor_temp_c: ['motorTemperature', 'C'], - rpm: ['speed', 'rpm'], - vibration_mms: ['vibration', 'mm/s'], - run_hours: ['runHours', 'h'], - }, - }, - server: { - type: 'c8y_Server', fragment: 'server', - series: { - temperature: ['temperature', 'C'], - humidity: ['humidity', '%RH'], - pressure: ['pressure', 'hPa'], - }, - }, - agent: { - type: 'c8y_Agent', fragment: 'agent', - series: { uptime: ['uptime', 's'] }, - }, - device: { - type: 'c8y_Device', fragment: 'device', - series: { - uptime: ['uptime', 's'], - heap_free: ['freeHeap', 'bytes'], - rssi: ['rssi', 'dBm'], - }, - }, - tedge_health: { - type: 'c8y_TedgeHealth', fragment: 'tedge_Health', - series: { - uptime: ['uptime', 's'], - freeHeap: ['freeHeap', 'bytes'], - droppedMessages: ['droppedMessages', ''], - resetCause: ['resetCause', ''], - }, - }, -}; - -export function onMessage(message, context) { - const source = message.topic.split('/').pop(); - let data; - try { - data = JSON.parse(new TextDecoder().decode(message.payload)); - } catch (e) { - return []; - } - const def = DEFINITIONS[source] || - { type: source, fragment: source, series: {} }; - const fragment = {}; - for (const [key, value] of Object.entries(data)) { - if (key === 'time' || typeof value !== 'number' || !Number.isFinite(value)) { - continue; - } - const [name, unit] = def.series[key] || [key, undefined]; - fragment[name] = unit === undefined ? { value: value } : { value: value, unit: unit }; - } - if (Object.keys(fragment).length === 0) { - 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 payload = { type: def.type, time: time }; - payload[def.fragment] = fragment; - return [{ - cumulocityType: 'measurement', - payload: payload, - externalSource: [{ externalId: message.clientID, type: 'c8y_Serial' }], - }]; -} diff --git a/cumulocity/smart-functions/tedge-zephyr-measurements/tests/pump.yaml b/cumulocity/smart-functions/tedge-zephyr-measurements/tests/pump.yaml deleted file mode 100644 index 98c846c..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-measurements/tests/pump.yaml +++ /dev/null @@ -1,21 +0,0 @@ -inputs: - - payload: '{"time":"2026-09-22T06:40:00Z","flow_lpm":42.17,"pressure_bar":3.08,"motor_temp_c":51.42,"rpm":1487.00,"vibration_mms":2.31,"run_hours":128.55}' - payloadFormat: json - topic: "te/device/tedge-modbus7c0c5f5a6eb8///m/pump" - clientID: "tedge-modbus7c0c5f5a6eb8" - time: "2026-09-22T06:40:01.000Z" -expectedOutput: - - cumulocityType: measurement - externalSource: - - externalId: tedge-modbus7c0c5f5a6eb8 - type: c8y_Serial - payload: - type: c8y_Pump - time: "2026-09-22T06:40:00.000Z" - pump: - flow: {value: 42.17, unit: l/min} - pressure: {value: 3.08, unit: bar} - motorTemperature: {value: 51.42, unit: C} - speed: {value: 1487, unit: rpm} - vibration: {value: 2.31, unit: mm/s} - runHours: {value: 128.55, unit: h} diff --git a/cumulocity/smart-functions/tedge-zephyr-measurements/tests/unknown-type.yaml b/cumulocity/smart-functions/tedge-zephyr-measurements/tests/unknown-type.yaml deleted file mode 100644 index de465fb..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-measurements/tests/unknown-type.yaml +++ /dev/null @@ -1,16 +0,0 @@ -inputs: - - payload: '{"time":"2026-09-22T06:40:00Z","level":3.5}' - payloadFormat: json - topic: "te/device/tedge-modbus7c0c5f5a6eb8///m/tank" - clientID: "tedge-modbus7c0c5f5a6eb8" - time: "2026-09-22T06:40:01.000Z" -expectedOutput: - - cumulocityType: measurement - externalSource: - - externalId: tedge-modbus7c0c5f5a6eb8 - type: c8y_Serial - payload: - type: tank - time: "2026-09-22T06:40:00.000Z" - tank: - level: {value: 3.5} diff --git a/cumulocity/smart-functions/tedge-zephyr-twin/data-prep.yaml b/cumulocity/smart-functions/tedge-zephyr-twin/data-prep.yaml deleted file mode 100644 index 15408e1..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-twin/data-prep.yaml +++ /dev/null @@ -1,6 +0,0 @@ -smartFunctionFile: twin.js -description: "tedge-zephyr twin messages (remote access, agent, parameter sets) to managed object fragments" -tags: [tedge-zephyr, twin] -input: - transport: mqtt - topicPattern: "te/device/*///twin/*" diff --git a/cumulocity/smart-functions/tedge-zephyr-twin/tests/parameter-set.yaml b/cumulocity/smart-functions/tedge-zephyr-twin/tests/parameter-set.yaml deleted file mode 100644 index e4812b1..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-twin/tests/parameter-set.yaml +++ /dev/null @@ -1,13 +0,0 @@ -inputs: - - payload: '{"interval_s":30,"telemetry":true,"measurement_type":"pump"}' - payloadFormat: json - topic: "te/device/tedge-modbus7c0c5f5a6eb8///twin/zephyr_modbus_telemetry" - clientID: "tedge-modbus7c0c5f5a6eb8" - time: "2026-09-22T06:00:00.000Z" -expectedOutput: - - cumulocityType: managedObject - externalSource: - - externalId: tedge-modbus7c0c5f5a6eb8 - type: c8y_Serial - payload: - zephyr_modbus_telemetry: {interval_s: 30, telemetry: true, measurement_type: pump} diff --git a/cumulocity/smart-functions/tedge-zephyr-twin/tests/remote-access.yaml b/cumulocity/smart-functions/tedge-zephyr-twin/tests/remote-access.yaml deleted file mode 100644 index 8afff53..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-twin/tests/remote-access.yaml +++ /dev/null @@ -1,13 +0,0 @@ -inputs: - - payload: '{"maxSessions":1,"activeSessions":0,"freeSessions":1,"policy":"lan","sessions":[]}' - payloadFormat: json - topic: "te/device/tedge-modbus7c0c5f5a6eb8///twin/tedge_RemoteAccess" - clientID: "tedge-modbus7c0c5f5a6eb8" - time: "2026-09-22T06:00:00.000Z" -expectedOutput: - - cumulocityType: managedObject - externalSource: - - externalId: tedge-modbus7c0c5f5a6eb8 - type: c8y_Serial - payload: - remoteAccess: {maxSessions: 1, activeSessions: 0, freeSessions: 1, policy: lan, sessions: []} diff --git a/cumulocity/smart-functions/tedge-zephyr-twin/twin.js b/cumulocity/smart-functions/tedge-zephyr-twin/twin.js deleted file mode 100644 index 62c0e9e..0000000 --- a/cumulocity/smart-functions/tedge-zephyr-twin/twin.js +++ /dev/null @@ -1,40 +0,0 @@ -// Cumulocity Smart Function: thin-edge.io twin messages -> managed object fragments. -// -// Topic: te/device/*///twin/* -// -// tedge-zephyr publishes device state as twin messages, one per fragment: -// te/device////twin/tedge_RemoteAccess remote-access capacity -// te/device////twin/tedge_Agent client and firmware -// te/device////twin/ a parameter set (zephyr_modbus_telemetry, ...) -// The last topic segment names the fragment, and the payload is written -// under it unchanged, so the values a device reports are what the -// Parameters tab (DTM identifier = set name) shows and edits. The one rename -// is tedge_RemoteAccess -> remoteAccess, the fragment name already in use. -// -// The device republishes every twin value on each connect, so a fragment is -// never stale for longer than one reconnect. An empty payload removes the -// fragment's content (thin-edge.io's way of clearing a twin value). -const RENAME = { tedge_RemoteAccess: 'remoteAccess' }; - -export function onMessage(message, context) { - const name = message.topic.split('/').pop(); - if (!name) { - return []; - } - const text = new TextDecoder().decode(message.payload).trim(); - let value = null; - if (text !== '') { - try { - value = JSON.parse(text); - } catch (e) { - value = text; // a bare string twin value - } - } - const payload = {}; - payload[RENAME[name] || name] = value; - return [{ - cumulocityType: 'managedObject', - payload: payload, - externalSource: [{ externalId: message.clientID, type: 'c8y_Serial' }], - }]; -} diff --git a/cumulocity/smart-functions/test.py b/cumulocity/smart-functions/test.py deleted file mode 100755 index c01acb0..0000000 --- a/cumulocity/smart-functions/test.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: Apache-2.0 -"""Run the Smart Functions' tests in a tenant's Data Preparation runtime. - - cumulocity/smart-functions/test.py [rule-dir...] # default: all of them - -Sends each rule's JavaScript and tests/*.yaml to -/service/dataprep/v1/run-tests (nothing is deployed) and compares what it -returns with each test's expectedOutput. Needs a go-c8y-cli session. -""" - -import json -import subprocess -import sys -from pathlib import Path - -import yaml - -HERE = Path(__file__).resolve().parent - - -def run(rule): - cfg = yaml.safe_load((rule / "data-prep.yaml").read_text()) - js = (rule / cfg["smartFunctionFile"]).read_text() - tests = {t.stem: yaml.safe_load(t.read_text()) - for t in sorted((rule / "tests").glob("*.yaml"))} - body = {"config": {}, "jsCode": js, - "tests": {n: {"inputs": t["inputs"]} for n, t in tests.items()}} - # --data, not --file: --file sends a multipart upload. - out = subprocess.run(["c8y", "api", "POST", "/service/dataprep/v1/run-tests", - "--data", json.dumps(body), "--raw", "--force"], - capture_output=True, text=True, stdin=subprocess.DEVNULL) - if out.returncode != 0: - print(f"{rule.name}: request failed\n{out.stderr}") - return False - results = json.loads(out.stdout) - ok = True - for name, t in tests.items(): - got = [o for r in results.get(name, []) for o in r.get("outputs", [])] - errors = [r.get("error") for r in results.get(name, []) if r.get("error")] - want = t.get("expectedOutput") - if errors: - print(f" FAIL {rule.name}/{name}: {errors}") - ok = False - elif want is not None and json.loads(json.dumps(got)) != want: - print(f" FAIL {rule.name}/{name}:\n got {json.dumps(got)}\n want {json.dumps(want)}") - ok = False - else: - print(f" ok {rule.name}/{name}" + ("" if want is not None else f" -> {json.dumps(got)}")) - return ok - - -def main(argv): - rules = [Path(a) for a in argv] or sorted(p for p in HERE.iterdir() - if (p / "data-prep.yaml").is_file()) - return 0 if all([run(r) for r in rules]) else 1 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/lib/common/identify.c b/lib/common/identify.c index 039c243..e6f89a2 100644 --- a/lib/common/identify.c +++ b/lib/common/identify.c @@ -78,7 +78,7 @@ static int cmd_identify(const struct shell *sh, size_t argc, char **argv) return 0; } -/* Hangs off the client's own "tedge" root, which tedge-zephyr defines. */ +/* Hangs off the client's own "tedge" root, which tiny-edge defines. */ SHELL_SUBCMD_ADD((tedge), identify, NULL, "blink the status LED to find this board: identify [seconds]", cmd_identify, 1, 1); diff --git a/lib/common/prov_c8y.h b/lib/common/prov_c8y.h index abd4dc8..75c8e0d 100644 --- a/lib/common/prov_c8y.h +++ b/lib/common/prov_c8y.h @@ -4,7 +4,7 @@ * application, through the shared settings store: `prov/c8y/url`, * `prov/c8y/tenant`, `prov/c8y/external_id` and `prov/c8y/otp`. * - * The provisioner never writes the tedge-zephyr module's own `tedge/` + * The provisioner never writes the tiny-edge module's own `tedge/` * subtree, and does not link the module at all: the application reads these * keys and passes them to the module through its public API. */ diff --git a/lib/common/tedge-boards/README.md b/lib/common/tedge-boards/README.md deleted file mode 100644 index a6a23ec..0000000 --- a/lib/common/tedge-boards/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Board settings for tedge-zephyr builds - -One file per device: what a `tedge-zephyr` build needs from *this board*, -whatever the application — where the mbedTLS heap lives and how big it is, -PSRAM, TLS context and connection counts, network buffer depth. The feature -set comes from a profile in `tedge-zephyr/profiles/`; put the board file -**after** it so its sizes win: - -```sh -west build --sysbuild -b esp32c6_devkitc/esp32c6/hpcore apps/modbus-server -- \ - "-DEXTRA_CONF_FILE=/ws/app/tedge-zephyr/profiles/full.conf;/ws/app/lib/common/tedge-boards/esp32c6-devkitc.conf" -``` - -| File | Board target | Notes | -|---|---|---| -| `esp32c6-devkitc.conf` | `esp32c6_devkitc/esp32c6/hpcore` | no PSRAM; heap in internal RAM | -| `esp32s3-devkitc.conf` | `esp32s3_devkitc/esp32s3/procpu` | N16R8; heap in 8 MB octal PSRAM | -| `qtpy-esp32s3.conf` | `adafruit_qt_py_esp32s3/esp32s3/procpu` | N4R2; heap in 2 MB quad PSRAM | -| `esp32-devkitc.conf` | `esp32_devkitc/esp32/procpu` | WROOM-32, no PSRAM; 8 KB records | -| `esp32-cam.conf` | `esp32_devkitc/esp32/procpu` | ESP32-CAM; heap in 4 MB quad PSRAM | - -`extras/` holds feature additions for a board whose `tedge-ota` image carries -more than the profile (see `release/devices.yml`). What each board has been -measured to run is in DEVICES.md. diff --git a/lib/common/tedge-boards/esp32-cam.conf b/lib/common/tedge-boards/esp32-cam.conf deleted file mode 100644 index 1bf8540..0000000 --- a/lib/common/tedge-boards/esp32-cam.conf +++ /dev/null @@ -1,58 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# ESP32-CAM (ESP32 + 4 MB quad PSRAM) settings for any application built with -# tedge-zephyr. Put after the profile in EXTRA_CONF_FILE, and build with -# -DEXTRA_DTC_OVERLAY_FILE=lib/common/dts/esp32cam-status-led.overlay. -# The board target is the WROOM's (esp32_devkitc/esp32/procpu); this file is -# what differs. - -# --- TCP, which the SNMP-only build switches off --- -CONFIG_NET_TCP=y -CONFIG_NET_SOCKETS=y - -CONFIG_MBEDTLS_HEAP_SIZE=98304 -CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384 - -# --- The TLS heap goes in the module's quad PSRAM --- -# A classic ESP32 has only a 192 KB DRAM segment, so the 96 KB mbedTLS heap -# cannot live in it beside the client and the application. The ESP32-CAM -# module carries 4 MB of quad PSRAM; without this the image does not fit. -# -# SPIRAM_TYPE_* alone leaves CONFIG_ESP_SPIRAM_SIZE at 0, the ext_dram -# region is empty and the link fails with "External SPIRAM overflowed", so -# the size has to be stated. -CONFIG_ESP_SPIRAM=y -CONFIG_SPIRAM_MODE_QUAD=y -CONFIG_SPIRAM_TYPE_ESPPSRAM32=y -CONFIG_ESP_SPIRAM_SIZE=4194304 -CONFIG_MBEDTLS_HEAP_CUSTOM_SECTION=y - -# MQTT plus one HTTPS download. -CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS=2 - -# --- Network buffers, as deep as this part allows --- -# A classic ESP32 splits its RAM into dram0 (192 KB) and dram1 (96 KB), and -# the net_buf pools and the kernel heap all land in dram1. The sizing the C6 -# and the S3 boards use (96/64 buffers, 40 KB heap) fills it to 99.4% — -# 608 bytes spare — so this board takes the step below. With every -# tedge-zephyr feature enabled dram1 overflows by 33 KB, which is why this -# board ships the ota profile (DEVICES.md). -# -# 256-byte net_bufs rather than 128 quarter the number of allocations per -# frame. See tedge-zephyr/docs/debugging-against-real-devices.md. -# The system heap is not set here: any CONFIG_HEAP_MEM_POOL_SIZE below the -# drivers' combined minimum (the Wi-Fi driver alone asks for 51,200 B) is -# ignored, and this board's images resolve to about 55 KB whatever the -# application or this file says (the exact sum of the drivers' minimums -# varies a little with the options built). A smaller heap needs -# CONFIG_HEAP_MEM_POOL_IGNORE_MIN and a measured low-water mark -# (reduce-memory-footprint, tier 2). -CONFIG_NET_BUF_DATA_SIZE=256 -CONFIG_NET_PKT_RX_COUNT=10 -CONFIG_NET_PKT_TX_COUNT=10 -CONFIG_NET_BUF_RX_COUNT=24 -CONFIG_NET_BUF_TX_COUNT=20 - -CONFIG_NET_MAX_CONN=10 -CONFIG_NET_MAX_CONTEXTS=12 -CONFIG_ZVFS_OPEN_MAX=14 diff --git a/lib/common/tedge-boards/esp32-devkitc.conf b/lib/common/tedge-boards/esp32-devkitc.conf deleted file mode 100644 index de55058..0000000 --- a/lib/common/tedge-boards/esp32-devkitc.conf +++ /dev/null @@ -1,56 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# ESP32-WROOM-32 DevKitC settings for any application built with -# tedge-zephyr. Put after the profile in EXTRA_CONF_FILE. For the -# ESP32-CAM, which shares this board target but has PSRAM, use -# esp32-cam.conf. -# -# No PSRAM and a 192 KB dram0 segment: the mbedTLS heap has to be small and -# internal. Measured on a WROOM with Modbus (2026-09-21, DEVICES.md): -# ota profile: 96.3% of dram0; an 800 KB firmware download over HTTPS -# works with 8 KB records and confirms; 23 KB of system heap stays free. -# ota + remote access: 99.9% of dram0, 76 B of libc malloc arena left; an -# SSH tunnel carried 409 KB in 19 s with Modbus served alongside. -# apps/tedge-agent, ota + remote access + certificate renewal: 99.7%; -# enrolment, restart, tunnel and firmware update all pass. -# Remote access is not released for this board (2026-09-23): a bulk SSH -# copy works, but htop over a tunnel fails with the receive buffers this -# board has room for (32 x 128 B, about three Wi-Fi frames). The release -# builds are ota (+ certificate renewal on the agent). -# The full profile does not fit, and neither does SNMP or OPC-UA with the -# client at any level. - -# --- TCP, which the SNMP-only build switches off --- -CONFIG_NET_TCP=y -CONFIG_NET_SOCKETS=y - -# 8 KB records: Cumulocity's MQTT chain, its HTTPS binary download and the -# remote-access WebSocket all work at this size (measured). The heap holds -# MQTT plus one download or tunnel at 8 KB records. -CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=8192 -CONFIG_MBEDTLS_HEAP_SIZE=57344 -# With 8 KB input records the server has to be told to stay within them: -# the 16 KB profiles switch the fragment-length extension off, this board -# turns it back on (Zephyr advertises 4 KB, as it did before the profiles' -# 4 KB output buffer; see tedge-zephyr/include/tedge/mbedtls_user_config.h). -CONFIG_NET_SOCKETS_TLS_SET_MAX_FRAGMENT_LENGTH=y - -# The client heap stays in dram1 (.noinit) here: dram0 also holds the -# mbedTLS heap on this board, and moving the 16 KB client heap there -# overflowed it by 64 B with parameters (2026-09-23). The ESP32-CAM, whose -# mbedTLS heap is in PSRAM, takes the default and has it in dram0. -CONFIG_TEDGE_HEAP_NOINIT=y - -# MQTT plus one HTTPS download or one tunnel. -CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS=2 - -# DHCP, DNS, mDNS, the application's listener and a client, MQTT, then a -# tunnel's WebSocket and its LAN target. At the WROOM application default -# of 6 the tunnel request failed at runtime with "Not enough connection -# contexts" although the image linked. -CONFIG_NET_MAX_CONN=10 -CONFIG_NET_MAX_CONTEXTS=10 -CONFIG_ZVFS_OPEN_MAX=14 - -# Network buffers and the kernel heap stay at the application's WROOM -# values: the measured runs used them, and deeper pools do not fit. diff --git a/lib/common/tedge-boards/esp32c6-devkitc.conf b/lib/common/tedge-boards/esp32c6-devkitc.conf deleted file mode 100644 index 3d820d7..0000000 --- a/lib/common/tedge-boards/esp32c6-devkitc.conf +++ /dev/null @@ -1,53 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# ESP32-C6-DevKitC (WROOM-1-N4) settings for any application built with -# tedge-zephyr. Put after the profile in EXTRA_CONF_FILE. No PSRAM: the -# mbedTLS heap is in internal RAM. - -# One TLS session needs 52 KB at the handshake and holds 35 KB; every extra -# concurrent session (a remote-access tunnel, a download) needs another -# ~52 KB while it connects. 96 KB covers MQTT plus one tunnel. -CONFIG_MBEDTLS_HEAP_SIZE=98304 -CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384 - -# One TLS context for MQTT, plus one for a remote-access session or download. -CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS=2 - -# A closed TLS connection holds its TCP context for a moment, so the client -# needs headroom over what the application uses. -CONFIG_NET_MAX_CONN=10 -CONFIG_NET_MAX_CONTEXTS=12 -CONFIG_ZVFS_OPEN_MAX=14 - -# --- What a remote-access tunnel needs beside MQTT on a board with no PSRAM -# -# Verified against an interactive htop session over a tunnel (2026-09-20). -# Without these it renders one screen, freezes, and the device drops off the -# cloud and reconnects; the console shows the pool it ran out of. See -# tedge-zephyr/docs/debugging-against-real-devices.md. - -# websocket_send_msg() k_mallocs a masking buffer for every frame it sends -# (masking is mandatory client->server), from the same kernel heap the Wi-Fi -# adapter uses; a tunnel exhausted a 16 KB heap and the -ENOMEM surfaced as -# "sending to the cloud failed". The 40 KB this file used to ask for was -# never what the image got: -# The system heap is not set here: any CONFIG_HEAP_MEM_POOL_SIZE below the -# drivers' combined minimum (the Wi-Fi driver alone asks for 51,200 B) is -# ignored, and this board's images resolve to about 55 KB whatever the -# application or this file says (the exact sum of the drivers' minimums -# varies a little with the options built). A smaller heap needs -# CONFIG_HEAP_MEM_POOL_IGNORE_MIN and a measured low-water mark -# (reduce-memory-footprint, tier 2). - -# The Wi-Fi driver's RX path allocates a net_pkt per frame with a 100 ms -# deadline. At the default 128-byte net_buf a 1500-byte frame costs twelve -# of them, so 64 buffers is about five full-size packets in flight: one -# burst from a repainting terminal empties the pool ("esp32_wifi: Failed to -# allocate net buffer"), frames are dropped, TCP retransmits and the -# tunnel's TLS socket times out. This is burst depth, not bandwidth — the -# same image streams 1 MB happily. Sized for ~16 full packets. -CONFIG_NET_BUF_DATA_SIZE=256 -CONFIG_NET_PKT_RX_COUNT=16 -CONFIG_NET_PKT_TX_COUNT=16 -CONFIG_NET_BUF_RX_COUNT=96 -CONFIG_NET_BUF_TX_COUNT=64 diff --git a/lib/common/tedge-boards/esp32s3-devkitc.conf b/lib/common/tedge-boards/esp32s3-devkitc.conf deleted file mode 100644 index 961da99..0000000 --- a/lib/common/tedge-boards/esp32s3-devkitc.conf +++ /dev/null @@ -1,53 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# ESP32-S3-DevKitC-1 (N16R8) settings for any application built with -# tedge-zephyr. Put after the profile in EXTRA_CONF_FILE. - -CONFIG_MBEDTLS_HEAP_SIZE=98304 -CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384 - -# --- The TLS heap goes in the board's octal PSRAM --- -# Internal DRAM is too tight for the client next to the application -# otherwise; the S3 linker script already maps .mbedtls_heap into PSRAM -# (c8y-direct-spikes, section 8 follow-up). -# -# SPIRAM_TYPE_* alone leaves CONFIG_ESP_SPIRAM_SIZE at 0 unless the app's -# overlay sizes &psram0; then the ext_dram region is empty and the link fails -# with "External SPIRAM overflowed" (it did for OPC-UA and SNMP). State it. -CONFIG_ESP_SPIRAM=y -CONFIG_SPIRAM_MODE_OCT=y -CONFIG_SPIRAM_TYPE_ESPPSRAM64=y -CONFIG_ESP_SPIRAM_SIZE=8388608 -CONFIG_MBEDTLS_HEAP_CUSTOM_SECTION=y - -# One TLS context for MQTT, plus one for a remote-access session or download. -CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS=2 - -# A closed TLS connection holds its TCP context for a moment, so the client -# needs headroom over what the application uses. -CONFIG_NET_MAX_CONN=10 -CONFIG_NET_MAX_CONTEXTS=12 -CONFIG_ZVFS_OPEN_MAX=14 - -# --- What a remote-access tunnel needs beside MQTT --- -# The Wi-Fi driver's receive path runs out of net_bufs under an interactive -# session long before it runs out of bandwidth: at the default 128-byte -# net_buf a 1500-byte frame costs twelve of them, so one burst from a -# repainting terminal empties the pool ("esp32_wifi: Failed to allocate net -# buffer"), frames are dropped and the tunnel's TLS socket times out. This -# board has more headroom than the C6 but the same radio and the same -# default pools. websocket_send_msg() also k_mallocs a masking buffer for -# every frame it sends. See -# tedge-zephyr/docs/debugging-against-real-devices.md. -# The system heap is not set here: any CONFIG_HEAP_MEM_POOL_SIZE below the -# drivers' combined minimum (the Wi-Fi driver alone asks for 51,200 B) is -# ignored, and this board's images resolve to about 55 KB whatever the -# application or this file says (the exact sum of the drivers' minimums -# varies a little with the options built). A smaller heap needs -# CONFIG_HEAP_MEM_POOL_IGNORE_MIN and a measured low-water mark -# (reduce-memory-footprint, tier 2). -CONFIG_NET_BUF_DATA_SIZE=256 -CONFIG_NET_PKT_RX_COUNT=16 -CONFIG_NET_PKT_TX_COUNT=16 -CONFIG_NET_BUF_RX_COUNT=96 -CONFIG_NET_BUF_TX_COUNT=64 diff --git a/lib/common/tedge-boards/extras/log-upload.conf b/lib/common/tedge-boards/extras/log-upload.conf deleted file mode 100644 index 4c082c6..0000000 --- a/lib/common/tedge-boards/extras/log-upload.conf +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# Adds log upload to the ota profile: the cloud asks for a log (the client's -# own, or one the application registers, e.g. Modbus's "app-status") and the -# device sends it over HTTPS. The upload runs on its own thread with an -# 8 KB stack (its TLS handshake overflowed 4 KB), which is what it costs in -# RAM. On the ESP32-CAM it fits beside SNMP only since the client's heap -# moved out of dram1 (2026-09-23). -CONFIG_TEDGE_LOG_UPLOAD=y diff --git a/lib/common/tedge-boards/extras/parameters.conf b/lib/common/tedge-boards/extras/parameters.conf deleted file mode 100644 index 4afd438..0000000 --- a/lib/common/tedge-boards/extras/parameters.conf +++ /dev/null @@ -1,12 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# Adds parameters to the ota profile: settings an operator changes from the -# cloud, stored on the device and reported as twin state. With the Modbus -# application that is the `pump` set (telemetry interval, on/off, type) and -# the `control` set (running, mode, speed_setpoint, and whether Modbus -# clients may still change them), besides the client's own `tedge` set. -# -# Costs 6 KB of RAM on the ESP32-WROOM-32 (the client's private heap -# defaults to 16 KB with parameters, 10 KB without) and 6 KB of flash -# (2026-09-23): dram1 76.6 -> 82.9 %, malloc arena 17.4 -> 16.3 KB. -CONFIG_TEDGE_PARAMETERS=y diff --git a/lib/common/tedge-boards/extras/remote-access.conf b/lib/common/tedge-boards/extras/remote-access.conf deleted file mode 100644 index bac0c92..0000000 --- a/lib/common/tedge-boards/extras/remote-access.conf +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# Adds one remote-access tunnel to the ota profile, for a board whose -# tedge-ota image has room for it but not for the full profile. The tunnel -# shares the second TLS context with the firmware download; they do not run -# at once. -# -# Not for the ESP32-WROOM-32: it links there and carries a bulk SSH copy, -# but its 32 x 128 B receive buffers hold about three Wi-Fi frames, and an -# interactive session (htop) fails on "esp32_wifi: Failed to allocate net -# buffer" (2026-09-23, DEVICES.md). An interactive tunnel needs about 16 -# frames of buffering (96 x 256 B on the C6), about 15 KB more than the -# WROOM has left. -CONFIG_TEDGE_REMOTE_ACCESS=y -CONFIG_TEDGE_REMOTE_ACCESS_MAX_SESSIONS=1 -# The tunnel's TLS handshake runs on the bridge thread. -CONFIG_TEDGE_REMOTE_ACCESS_STACK_SIZE=8192 diff --git a/lib/common/tedge-boards/extras/shell-diagnostics.conf b/lib/common/tedge-boards/extras/shell-diagnostics.conf deleted file mode 100644 index d8063a4..0000000 --- a/lib/common/tedge-boards/extras/shell-diagnostics.conf +++ /dev/null @@ -1,26 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# The cloud's shell command (c8y_Command, CONFIG_TEDGE_SHELL_COMMAND), for -# general checks on a device in the field. Added to the tedge-full builds that -# have room for it (release/devices.yml, measured in DEVICES.md). -# -# 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 (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 -CONFIG_KERNEL_SHELL=y -CONFIG_NET_SHELL=y -CONFIG_NET_L2_WIFI_SHELL=y -CONFIG_TEDGE_SHELL_COMMAND=y -# kernel uptime / kernel version how long it has run, what it runs -# 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,tedge identify" diff --git a/lib/common/tedge-boards/qtpy-esp32s3.conf b/lib/common/tedge-boards/qtpy-esp32s3.conf deleted file mode 100644 index d153383..0000000 --- a/lib/common/tedge-boards/qtpy-esp32s3.conf +++ /dev/null @@ -1,50 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# Adafruit QT Py ESP32-S3 (N4R2) settings for any application built with -# tedge-zephyr. Put after the profile in EXTRA_CONF_FILE. - -CONFIG_MBEDTLS_HEAP_SIZE=98304 -CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384 - -# --- The TLS heap goes in the board's quad PSRAM --- -# The N4R2 module has 2 MB of quad PSRAM where the DevKitC has 8 MB octal. -# Without this the 96 KB mbedTLS heap stays in internal DRAM and a Modbus -# image links at 99.8% of it, with 764 bytes to spare. Moving it frees -# 95,968 B and brings the board to 75.8%. -# -# SPIRAM_TYPE_* alone is not enough: it leaves CONFIG_ESP_SPIRAM_SIZE at 0, -# the ext_dram region is empty and the link fails with "External SPIRAM -# overflowed". The size has to be stated. -CONFIG_ESP_SPIRAM=y -CONFIG_SPIRAM_MODE_QUAD=y -CONFIG_SPIRAM_TYPE_ESPPSRAM16=y -CONFIG_ESP_SPIRAM_SIZE=2097152 -CONFIG_MBEDTLS_HEAP_CUSTOM_SECTION=y - -# One TLS context for MQTT, plus one for a remote-access session or download. -CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS=2 - -# A closed TLS connection holds its TCP context for a moment, so the client -# needs headroom over what the application uses. -CONFIG_NET_MAX_CONN=10 -CONFIG_NET_MAX_CONTEXTS=12 -CONFIG_ZVFS_OPEN_MAX=14 - -# --- What a remote-access tunnel needs beside MQTT --- -# The Wi-Fi driver's receive path runs out of net_bufs under an interactive -# session long before it runs out of bandwidth, and websocket_send_msg() -# k_mallocs a masking buffer per frame. Sized as on the C6, which was -# verified against a real htop session. See -# tedge-zephyr/docs/debugging-against-real-devices.md. -# The system heap is not set here: any CONFIG_HEAP_MEM_POOL_SIZE below the -# drivers' combined minimum (the Wi-Fi driver alone asks for 51,200 B) is -# ignored, and this board's images resolve to about 55 KB whatever the -# application or this file says (the exact sum of the drivers' minimums -# varies a little with the options built). A smaller heap needs -# CONFIG_HEAP_MEM_POOL_IGNORE_MIN and a measured low-water mark -# (reduce-memory-footprint, tier 2). -CONFIG_NET_BUF_DATA_SIZE=256 -CONFIG_NET_PKT_RX_COUNT=16 -CONFIG_NET_PKT_TX_COUNT=16 -CONFIG_NET_BUF_RX_COUNT=96 -CONFIG_NET_BUF_TX_COUNT=64 diff --git a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/.openspec.yaml b/openspec/changes/archive/2026-09-19-c8y-direct-spikes/.openspec.yaml deleted file mode 100644 index eaa6b1c..0000000 --- a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-09-19 diff --git a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/README.md b/openspec/changes/archive/2026-09-19-c8y-direct-spikes/README.md deleted file mode 100644 index 21b4a86..0000000 --- a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# c8y-direct-spikes - -Spikes that de-risk connecting the Zephyr firmware directly to Cumulocity (TLS/MQTT Service, OTA, Cumulocity CA enrollment) diff --git a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/design.md b/openspec/changes/archive/2026-09-19-c8y-direct-spikes/design.md deleted file mode 100644 index 883374f..0000000 --- a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/design.md +++ /dev/null @@ -1,1239 +0,0 @@ -## Context - -Phase 3 builds **tedge-zephyr**: a thin-edge.io device-management client -shipped as a reusable Zephyr module that any user's application can include. -This repository's OPC-UA, Modbus and SNMP apps are example "user -applications". The client has two transports behind one feature set: - -``` - ┌──────────────────── any user's Zephyr application ─────────────────────┐ - │ main(), own threads, own connectivity (Wi-Fi/Ethernet), own data │ - │ │ tedge_publish_*() tedge_register_*() ▲ hooks: restart, │ - │ ▼ │ fw confirm, state│ - │ ┌───────────── tedge-zephyr (module `tedge`) ───────┴──────────────┐ │ - │ │ restart │ firmware │ logs │ config │ shell │ telemetry │ remote │ │ - │ └────────────────────────┬─────────────────────────────────────────┘ │ - └───────────────────────────┼────────────────────────────────────────────┘ - │ transport interface - ┌─────────┴──────────────────────────┐ - ┌──────────▼───────────┐ ┌───────────▼──────────┐ - │ transport: c8y │ │ transport: tedge │ - │ (FIRST) │ │ (LATER) │ - │ MQTTS :9883 SmartREST│ │ te/ MQTT API :1883 │ - │ + free-form topics │ │ + file transfer :8000│ - │ HTTPS+JWT, WSS │ │ mDNS discovery │ - └──────────────────────┘ └──────────────────────┘ -``` - -Facts established during exploration (2026-09-19), which the spikes build on: - -- **Cumulocity CA enrollment** (thin-edge.io `tedge cert download c8y`, - `crates/core/tedge/src/cli/certificate/c8y/download.rs`): - - The device generates the one-time password (at most 32 characters) and - prints `https:///apps/devicemanagement/index.html#/deviceregistration?externalId=&one-time-password=`. - - It then polls `POST https:///.well-known/est/simpleenroll` with - `Authorization: Basic :` and `Content-Type: application/pkcs10` - (PEM body without the armour lines) until it gets `200` with the - certificate. - - Renewal is `POST …/.well-known/est/simplereenroll`. `tedge` sends it - through its local proxy with the device identity, so how the device - authenticates directly (mTLS or JWT) is **unverified**. -- **The Cumulocity MQTT Service** (port 9883) carries free-form topics (GA). - The SmartREST layer on it is **Public Preview** and needs the tenant feature - `mqtt-service.smartrest`. Core MQTT (8883) is GA and SmartREST-only. - Free-form messages are not stored as measurements until something consumes - and maps them. -- **thin-edge.io gateway facts** (for the later transport): - - Remote access is not available for child devices, because the mapper - ignores SmartREST custom operations aimed at a child. Direct mode is the - only way to reach a device-side remote-access endpoint. - - `firmware_update` carries no `sha256`, so MCUboot's signature check is the - integrity guarantee in both modes. -- **Zephyr 4.4.2** provides: - - the MQTT client with TLS and websocket transports; - - the HTTP client; - - SNTP; - - `SECURE_STORAGE` (PSA ITS over settings); - - mbedTLS with `X509_CSR_WRITE_C`; - - `dfu/flash_img` and `dfu/mcuboot`; - - shell backends over telnet, websocket and MQTT; - - `log_backend_mqtt`; - - coredump to a flash partition. - - Two limits matter here. `tls_credentials` takes a private key only as a - **buffer**; there is no opaque PSA key-ID credential type. And - `MBEDTLS_SSL_MAX_CONTENT_LEN` defaults to **1500**. -- **This repo already has:** - - MCUboot with swap-scratch and a development key, and A/B slots of 1280 KB - (4 MB boards) or 3072 KB (S3-DevKitC-1); - - a `storage` partition with settings/NVS shared with the provisioner; - - `identity.*`, `liveness` and `diag`; - - the RGB and GPIO status LED. - - The task watchdog has 4 of its 5 channels in use. - -## Goals / Non-Goals - -**Goals:** - -- Turn each unknown below into a measured answer with a go/no-go. -- Produce the per-feature footprint numbers that decide board profiles. -- Leave behind the `tedge-zephyr/` module skeleton: the `CONFIG_TEDGE_*` - menu that encodes build-time feature selection, an outline of the public - header, and `samples/minimal`. The next change then adds feature logic into - slots that already exist. -- Establish the integration contract (D7) and a directory layout that can - move out of this repo (D8) before any feature code exists, so that no - dependency on this repo's `lib/` creeps in. -- Prove that remote access can tunnel from Cumulocity through the MCU to - **another host on the LAN** (for example SSH), because that is the feature - users will value most (D9). - -**Non-Goals:** - -- Production-quality client code. Spike code may be crude, and it stays in - `apps/c8y-spike/`. -- Any change to the three protocol apps or the provisioner. - -## Unknowns and how each spike answers them - -| # | Unknown | Answered by | Decides | -|---|---|---|---| -| U1 | Does TLS to 9883/8883 fit on the C6 and S3? Steady and peak heap, flash, handshake time | A | Board profiles; how many sessions can run at once | -| U2 | Does the handshake work with small records (`MAX_CONTENT_LEN` < 16 KB), i.e. does the server honour max-fragment-length? | A | Whether 16 KB input buffers are mandatory | -| U3 | Do SmartREST operations (`s/us`, `s/ds`, `s/e`) work over the MQTT Service with the feature flag? | A | Default transport profile (`mqtt-service` or `core`) | -| U4 | Does `s/uat` → `s/dat` return a JWT on 9883, with basic auth and with a certificate? | A, C | How HTTPS downloads and uploads authenticate | -| U5 | How long does an MCUboot swap take? Does revert work from a real HTTP download? Is `prov`, `storage` or `bootreq` touched? | B | The firmware-update design | -| U6 | Are redirects followed, and does a GitHub-release-style URL work? | B | Whether redirect handling must be written | -| U7 | Can the device generate the key, CSR and one-time password, enroll, and connect with mTLS? | C | The onboarding design | -| U8 | How does `simplereenroll` authenticate when called directly? | C | The certificate-renewal design | -| U9 | Is holding the private key as a buffer in RAM acceptable, or is an opaque-key TLS path needed? | C | Security posture; possible upstream work | -| U10 | Can a SoftAP provisioner fit 1024 KB, and does AP and station at the same time work? | E (optional) | BLE or captive portal | -| U11 | Can the MCU bridge a Cumulocity remote-access WebSocket (WSS) to a TCP connection to another LAN host? What are the RAM per session, throughput and latency with SSH/SCP? | F | Remote-access design, session limits per board | -| U12 | What does the device receive for `c8y_RemoteAccessConnect` over SmartREST (host, port, connection key), and which URL and authentication does the device-side WebSocket need (mTLS or JWT)? | F | Remote-access protocol handling | - -## Decisions - -### D1: Spikes live in a throwaway app; only the Kconfig skeleton is kept - -`apps/c8y-spike/` composes `lib/common` (for Wi-Fi, identity and the data -model, as any user app would bring its own) and the `tedge-zephyr` module. -Spike code (the MQTT loop, the OTA download, the enrollment, the tunnel) lives -in the app's `src/` and is selected by spike Kconfig options -(`SPIKE_TLS_MQTT`, `SPIKE_OTA`, `SPIKE_ENROLL`, `SPIKE_REMOTE_ACCESS`). It is -built with sysbuild, so MCUboot and `slot1` are real. The module itself only -gets the skeleton. - -- *Alternative: put spike code straight into `tedge-zephyr`.* Rejected: spike - code is written to measure, not to keep, and it would anchor the design - before the results are in. -- *Alternative: extend one of the protocol apps.* Rejected: it would mix the - frontend's heap use with the client's and blur the measurements. The - footprint table instead measures the client on top of each app in a separate - build step (D5). - -### D2: Build-time feature selection in `tedge-zephyr/Kconfig` - -The skeleton this change leaves behind: - -``` -menuconfig TEDGE "thin-edge.io device management client" (default n) - choice TEDGE_TRANSPORT - TEDGE_TRANSPORT_C8Y direct to Cumulocity - TEDGE_TRANSPORT_GATEWAY via a thin-edge.io gateway (not yet selectable) - choice TEDGE_C8Y_ENDPOINT (if TEDGE_TRANSPORT_C8Y) - TEDGE_C8Y_MQTT_SERVICE :9883, SmartREST + free-form (default; needs TEDGE_AUTH_C8Y_CA) - TEDGE_C8Y_CORE_MQTT :8883, SmartREST only (GA fallback; basic-auth builds use it) - choice TEDGE_AUTH - TEDGE_AUTH_C8Y_CA x.509 from the Cumulocity CA (default) - TEDGE_AUTH_BOOTSTRAP basic auth via bootstrap user - config TEDGE_TELEMETRY tedge_publish_measurement/event/alarm() - config TEDGE_HEALTH heap, RSSI, uptime, reset reason - config TEDGE_RESTART - config TEDGE_FIRMWARE_UPDATE depends on BOOTLOADER_MCUBOOT; select TEDGE_HTTP - config TEDGE_SHELL_COMMAND depends on SHELL - config TEDGE_LOG_UPLOAD select TEDGE_HTTP - config TEDGE_REMOTE_ACCESS depends on TEDGE_AUTH_C8Y_CA; selects websocket - choice TEDGE_REMOTE_ACCESS_TARGETS see D9 - TEDGE_REMOTE_ACCESS_TARGETS_LAN any host on the device's own IPv4 subnets (default) - TEDGE_REMOTE_ACCESS_TARGETS_LIST only host:port pairs in an allow-list (Kconfig or settings) - TEDGE_REMOTE_ACCESS_TARGETS_LOCAL only the device itself (127.0.0.1 / own address) - config TEDGE_REMOTE_ACCESS_MAX_SESSIONS default 1; each session costs one TLS session - config TEDGE_REMOTE_ACCESS_BUF_SIZE per-direction bridge buffer - config TEDGE_CONFIG select TEDGE_HTTP - config TEDGE_CERT_RENEWAL depends on TEDGE_AUTH_C8Y_CA - config TEDGE_HTTP (hidden) HTTPS client + JWT handling - config TEDGE_THREAD_STACK_SIZE / TEDGE_THREAD_PRIORITY / TEDGE_HEAP_SIZE - config TEDGE_TLS_TAG_BASE first TLS credential tag the module may use -``` - -Features without an implementation are declared with `depends on -TEDGE_FEATURE_AVAILABLE_`, a hidden symbol that stays `n` until the feature -lands. They show in `menuconfig` but cannot be selected. Profiles are overlays -shipped with the module (`tedge-zephyr/profiles/minimal.conf` and -`full.conf`). Board and app defaults live in `apps//boards/*.conf` once -the numbers exist, because they belong to the host application and not to -the module. The `TEDGE_` prefix keeps the thin-edge.io branding and avoids -colliding with a host application's own symbols. - -- *Alternative: one `DM` switch with everything always included.* Rejected: - the WROOM running OPC-UA cannot afford even one extra TLS session plus HTTP, - and the requirement is that features can be removed. -- *Alternative: runtime enable/disable.* It does not save flash or static RAM, - which is the point. A runtime switch may be added on top later, for - operators. - -### D3: Spike A configuration - -- **Time:** SNTP (`pool.ntp.org`, or the gateway if configured) runs before - the first TLS connect. Certificates cannot be validated before the clock is - set. -- **Server trust:** the tenant's CA chain (fetched once with - `openssl s_client -showcerts`) is embedded as a `TLS_CREDENTIAL_CA_CERTIFICATE` - in the spike. How trust anchors are managed in production is out of scope, - but we record which roots were needed. -- **Authentication:** Spike A uses **mutual TLS** with a Cumulocity CA - certificate for `tedge-spike-c6`, issued from a PC (key and CSR generated - on the PC, `register-ca`, EST `simpleenroll`). It is embedded from the - git-ignored `apps/c8y-spike/credentials/`. This replaces the planned basic - auth, because the MQTT Service (9883) refuses basic auth (see Spike - results, section 1). Spike C then moves key generation and enrollment onto - the device, under the default identity `tedge-`. The basic-auth - device `tedge-spike-a-c6` remains available for an 8883 comparison. -- **Subscriptions:** one filter per SUBSCRIBE, exact topics only: `s/ds`, - `s/e`, `s/dat`, `devicecontrol/notifications`, `error`. -- **Record size:** first attempt with `MBEDTLS_SSL_MAX_CONTENT_LEN=16384`, - then 4096 with `MBEDTLS_SSL_MAX_FRAGMENT_LENGTH`. Record which one completes - the handshake and moves data. -- **Heap:** measured through the `diag` health line (`heap=`, `malloc=`) and - `sys_heap` runtime statistics at four points: idle, handshake peak, - connected steady state, and with a concurrent HTTPS download. -- **Topics exercised:** - - `s/us`: `100` (create), `114` (supported operations, `c8y_Restart`), `117` - (required interval). - - `s/ds`: receive `510` (restart) and report `501`/`503`. - - One free-form topic, e.g. `te/device////m/environment`, with a - thin-edge.io-shaped JSON payload. This tests whether the same payload shape - can serve both transports later. - - `s/uat` → `s/dat`. - -### D4: Spike B mechanics - -- The download is streamed through `flash_img_buffered_write()` into - `slot1_partition`, with chunks of about 1–4 KB. The whole image is never - held in RAM. -- The source is `python3 -m http.server` on the laptop first. Then HTTPS - against a Cumulocity binary with the Spike A JWT, and a GitHub release asset - to check redirects. -- After writing: check the image header and hash with `flash_img_check()`, - call `boot_request_upgrade(BOOT_UPGRADE_TEST)`, then reboot. -- The new image confirms itself with `boot_write_img_confirmed()` after a - shell command (the spike stands in for "reached Cumulocity"). In a second - run it does not confirm, and a reset must revert it. -- Check that `prov`, `storage` (Wi-Fi credentials survive) and `bootreq` are - unchanged, by reading them back or from the application's behaviour. -- The spike image carries a build-time version bump (`VERSION`), so each - boot's version is unambiguous in the log. - -### D5: Footprint measurement method - -- One script, `scripts/measure_tedge.sh`, modelled on `measure_prov.sh`. It - builds each (board, app) with `TEDGE=n`, with the minimal profile, and with - each feature added on its own. It also builds `samples/minimal`, which gives - the module's cost with no host app. It reports the flash and static RAM deltas from the link - map, and the `zephyr.signed.bin` size against the slot. -- Runtime heap numbers come from the spikes on hardware. They cannot be read - from a link map. -- WROOM builds are build-only. Their static numbers, plus the C6/S3 runtime - numbers, decide whether any feature fits next to OPC-UA. - -### D6: Spike C enrollment flow - -``` - boot ─► SNTP ─► key in PSA ITS? ──no──► psa_generate_key(P-256, persistent) - │yes - ▼ - cert in storage? ──yes──► mTLS connect (Spike A code) - │no - ▼ - otp = 32 chars [A-Za-z0-9] from the CSPRNG, stored in settings - print registration URL (console + `tedge enroll` shell command) - CSR: CN=>, signed with the PSA key - loop every 10 s: POST simpleenroll (Basic id:otp) ─► 200? store cert PEM ─► connect -``` - -- The key is exported into a RAM buffer only to register it as - `TLS_CREDENTIAL_PRIVATE_KEY` for the session (U9). We record how long it - stays resident and whether mbedTLS's PSA path can avoid the export. -- After a successful connection: try `simplereenroll` with mTLS only, then with - `Authorization: Bearer `, and record which one works (U8). -- The bootstrap fallback: connect with the bootstrap user, publish on `s/ucr` - until the response is `70,,,`, store the result, and - reconnect as that user. This is only proved to work; its cost is recorded - but not optimised. -- In production the provisioner generates the one-time password and returns - the URL through Improv. The spike does it in the app, because the - provisioner has no room for crypto and only needs a random string and a - settings write, and that part is not in question. - -### D7: Integration contract with the host application - -The module is a guest in someone else's image: - -| Concern | The host application | The module | -|---|---|---| -| Network | Brings up and recovers Wi-Fi or Ethernet | Waits for `NET_EVENT_IPV4_ADDR_ADD`/`IF_DOWN` (net_mgmt); never touches the interface | -| Identity | Sets the external ID, name, type and firmware name/version via Kconfig or the API | Defaults: external ID `tedge-`, firmware from `APP_VERSION`/`KERNEL_VERSION` | -| Telemetry | Calls `tedge_publish_measurement/event/alarm()` | Buffers (bounded), encodes and sends; never samples on its own | -| Operations | May `tedge_register_operation()`, `…_log_type()`, `…_config_type()` | Built-in handlers for its compiled-in features; custom ones go to the app | -| Restart | Restart hook: prepare, or veto with a reason | Calls `sys_reboot()` only after the hook allows it | -| Firmware confirm | Adds its own health checks | Confirms only when the cloud is reachable **and** every app check has passed | -| Remote access | May narrow the targets (allow-list, or a per-request hook) | Enforces the Kconfig policy (D9) before opening any TCP connection | -| State and LED | Subscribes to `tedge_state` changes | Emits states (waiting for network, enrolling, connecting, connected, updating, tunnel open) and never drives GPIO | -| Watchdog | Owns the task watchdog | Calls an optional progress hook from each thread it owns | -| Resources | Provides the socket, poll and mbedTLS config the README lists | Own threads and own `k_heap`; settings under `tedge/`; TLS tags from `TEDGE_TLS_TAG_BASE` | - -In this repo, the glue between `lib/common` and `tedge_*` (data_source to -telemetry, the tedge state to `status_led`, the progress hook to -`app_alive()`) lives in `apps//src/`, never in the module. - -### D8: A directory layout that can move out of the repo - -``` -tedge-zephyr/ ← becomes the root of its own repo (e.g. thin-edge/tedge-zephyr) -├── zephyr/module.yml name: tedge -├── CMakeLists.txt, Kconfig, VERSION -├── include/tedge/ public headers only -├── src/ (from c8y-direct-core on) -├── profiles/ minimal.conf, full.conf -├── samples/minimal/ builds with Zephyr + this module only -├── tests/ (twister, native_sim) -└── README.md integration guide + the D7 table -``` - -- **Today:** apps add it with `list(APPEND ZEPHYR_EXTRA_MODULES - ${CMAKE_CURRENT_SOURCE_DIR}/../../tedge-zephyr)`, the same way they add - `lib/common`. -- **Later:** `git filter-repo --subdirectory-filter tedge-zephyr` (or - `git subtree split`) moves it out with its history. `west.yml` gains a - project for it, and the one `ZEPHYR_EXTRA_MODULES` line per app is removed. - No file inside the module changes. -- **Rules that keep this true:** - - nothing under `tedge-zephyr/` references `../`; - - the module has its own README and changelog; - - its version lives in `tedge-zephyr/VERSION`, independent of the apps' - `VERSION`. -- *Alternative: `modules/tedge-zephyr/`.* Rejected: `.gitignore` excludes - `/modules/`, because west can clone Zephyr modules into the tree there. -- *Alternative: `lib/tedge/`.* Rejected: `lib/` holds this repo's internal - modules, and the placement would invite the dependencies D8 forbids. - -### D9: Remote access through the MCU to hosts on the LAN - -The MCU becomes a small **remote-access gateway**. Cumulocity's existing -remote-access UI and its passthrough, SSH, VNC and Telnet endpoints work -unchanged, and the target can be the MCU itself or any host it can reach: - -``` - ssh/scp/VNC client ─► Cumulocity Cloud Remote Access ─► WSS (device-initiated, mTLS/JWT) - │ - ┌───────────────────▼──────────────┐ - │ MCU: tedge remote-access bridge │ - │ policy check (D2 targets) ──────┼─► 192.168.1.20:22 (Pi / PLC / HMI) - │ WSS ⇄ TCP, fixed buffers │─► 127.0.0.1:23 (Zephyr shell_telnet) - └──────────────────────────────────┘─► own :4840/:502 (OPC-UA / Modbus) -``` - -- **Flow** (thin-edge.io's `c8y-remote-access-plugin` has the same shape): - 1. The operation `c8y_RemoteAccessConnect` arrives with host, port and a - connection key (U12 confirms the exact template). - 2. The device checks the target against the policy. - 3. It opens a TCP connection to the target. - 4. It opens the WebSocket to Cumulocity's device endpoint for that key. - 5. It pipes bytes both ways until either side closes. - 6. It reports the operation as successful once the tunnel is up, or as - failed with a reason: policy denied, target unreachable, or WebSocket - rejected. -- **Why this is valuable:** the gateway transport can't offer it, because - thin-edge.io ignores remote access aimed at child devices. A brownfield site - with an MCU on Wi-Fi gets SSH access to the machines next to it without a - Linux gateway. It is the strongest reason to pick the direct transport. -- **Target policy:** a Kconfig choice (see D2): LAN subnets (default), an - allow-list, or local only. The host app can narrow it further through the - D7 hook. Every open and close is published as an event naming the target. -- **Cost:** one TLS session for the WebSocket, plus two fixed buffers - (`TEDGE_REMOTE_ACCESS_BUF_SIZE`, about 2–4 KB each), plus one bridge - thread. The session count is capped (`TEDGE_REMOTE_ACCESS_MAX_SESSIONS`, - default 1). Spike F measures all of this. -- **Spike F** (U11, U12): - - a WSS client (Zephyr websocket over a TLS socket) to Cumulocity's - remote-access device endpoint, triggered by `c8y_RemoteAccessConnect` - received through the Spike A MQTT loop; - - bridged to `ssh` on a Raspberry Pi on the same LAN; - - measured: interactive SSH latency, `scp` throughput for a 10 MB file, heap - per session, and behaviour when either end drops; - - a second target on the device itself (Zephyr `shell_telnet` on loopback) - proves the local case. -- *Alternative: local targets only (the MCU's own services).* Rejected as the - default: forwarding to neighbouring hosts is the feature users want, and - thin-edge.io's own plugin connects to any host it is given. Local-only - remains a Kconfig choice for cautious deployments. - -## Risks / Trade-offs - -- [The tenant lacks `mqtt-service.smartrest`, or the preview changes] → Spike - A also runs against Core MQTT 8883. The endpoint is a Kconfig choice (D2), - so the fallback costs one option, not a redesign. -- [16 KB TLS input buffers are unavoidable] → Budget about 50 KB per session. - Allow at most one concurrent HTTPS session next to MQTT, or one - remote-access session. Remote access - becomes C6/S3-only, and the WROOM gets telemetry and restart at most. -- [Private key in RAM] → Accept for Phase 3 exploration and document it. If - needed, raise an upstream issue or PR for opaque PSA keys in - `tls_credentials`. -- [Unencrypted BLE exposes the one-time password (future provisioner - integration)] → The enrollment race window is limited by the poll interval. - `APP_WIFI_PROV_REQUIRE_AUTH` proves physical presence. The captive-portal - spike (E) is the alternative if WPA2 on the link is wanted. -- [Clock not set yet at TLS time] → SNTP must succeed before the connect. A - failure is reported on the LED and retried; the device never connects with - certificate validation turned off. -- [Task-watchdog channels exhausted] → The module does not own the task - watchdog. It offers a progress hook that the host app wires to its own - watchdog (D7). In this repo that means raising `CONFIG_TASK_WDT_CHANNELS` in - the apps, which is noted for `c8y-direct-core`. -- [A dependency on `lib/` creeps into the module and makes extraction hard] → - `samples/minimal` builds without `lib/`, and a CI grep rejects `lib/` and - `apps/` paths under `tedge-zephyr/` (D8). -- [The integration hooks are guessed wrong before a real integration exists] → - The spike only outlines the header. `c8y-direct-core` fixes the API after - wiring one protocol app and the minimal sample through it. -- [The MCU becomes a pivot into the customer's LAN] → The remote-access - targets policy is a Kconfig choice. The default covers only the device's own - subnets, and every session is logged as an event with its target (D9). Only - CA-authenticated devices can build the feature, and Cumulocity's remote-access - permissions gate who can open a session. -- [A tunnel starves the host application] → One session by default. The - bridge thread runs below the host app's priority, with fixed buffers from - the module's heap. The session closes on WebSocket or TCP error, or after a - Kconfig idle timeout. -- [A spike result invalidates the roadmap order] → That is the purpose of the - spikes. The results section records the decision and `SCOPE.md` is updated. - -### D10: State and telemetry on free-form `te/` topics, mapped by Smart Functions - -Decided 2026-09-19 with the tenant owner, after section 7 showed a Smart -Function turning free-form telemetry into measurements. - -- **The device publishes state and telemetry on MQTT Service free-form - topics in thin-edge.io's `te/` shape**, the same topics a thin-edge.io - gateway understands: - - measurements `te/device////m/`; - - twin data `te/device////twin/`; - - health `te/device//service//status/health`; - - events and alarms. -- **Cumulocity Smart Functions map them**, playing the part of thin-edge.io's - mapper. tedge-zephyr ships a **reference set of Smart Functions** that - reproduces the mapper's default behaviour (twin → inventory fragment, - `m/` → measurements, and so on), and users adapt them to present the data - however they like. -- **Operations stay on SmartREST** (`s/ds`, `501`/`503`/`502`). That's how - Cumulocity tracks an operation's lifecycle; free-form topics suit state - and telemetry, not operations. -- **Fallback:** Core MQTT (8883) and basic-auth builds have no free-form - topics. They send twin data as direct inventory updates - (`inventory/managedObjects/update/`, as thin-edge.io's mapper - does) and telemetry as SmartREST measurements. -- **State is republished on every (re)connect.** A twin value is state, not - an event: a reboot or disconnect mid-session would otherwise leave a stale - value (for example active remote-access sessions). The design must not - rely on retained messages on the MQTT Service (unverified, P12). -- **First user:** remote-access capacity and use as twin data, so operators - see before opening a session that the device is busy (the UI only reports - a refused session after its own timeout; see Spike F): - - ``` - te/device////twin/tedge_RemoteAccess - {"maxSessions":1,"activeSessions":1,"policy":"lan", - "sessions":[{"target":"192.168.68.72:22","since":""}]} - ``` - - Published at connect (limits, policy, `activeSessions: 0`) and whenever a - tunnel opens or closes. The `sessions` list, which holds internal - addresses, is a build option; the count and limit are always sent. The - name is `tedge_…`, not `c8y_RemoteAccess…`, to stay clear of - Cumulocity's own `c8y_RemoteAccessList`. -- *Alternative: direct inventory updates only.* Works with no cloud-side - set-up, but fixes the presentation on the device and gives the two - transports different payloads. Kept as the fallback. -- *Alternative: SmartREST for everything.* No custom fragments, and - telemetry would be fixed to the static templates. - -## Open problems - -Problems found or deferred during the spikes, to be picked up later. Each one -needs an owner in `c8y-direct-core` (or its own change) before this change is -archived. - -| # | Problem | Why it matters | Where it came from | Next step | -|---|---|---|---|---| -| P1 | **Reconnect after losing the network is unmeasured** (task 3.8 deferred). Nobody has measured how long the device takes to get back to "connected" after the Wi-Fi or the path drops silently, or whether TLS heap and TCP contexts return to baseline. | The real client must recover on its own, without leaking memory or connection contexts. The context exhaustion seen in the cycle test hints at the risk. | Deferred 2026-09-19: dropping Wi-Fi at the access point needs someone at the AP. | Run with the AP switched off, or the C6's MAC (`e8:f6:0a:fc:32:0c`) blocked, three times for about 60 s, with `tools/console.py`. Or build a repeatable test into the real client's test plan (for example a Wi-Fi disconnect triggered from the shell, plus a real AP drop). | -| P2 | **The C6 hangs in MCUboot after a CPU reset** (`sys_reboot()`) with Wi-Fi running, even without Bluetooth. Both recovery paths in the apps use it: `lib/common/net.c`'s last-resort reboot, and **`lib/common/liveness.c`'s watchdog reset**, whose full-system reset is compiled in only `#if defined(CONFIG_BT)` (the provisioner). Confirmed in Spike B: a stalled test image was reset by the liveness watchdog (`SW_CPU`) and MCUboot hung instead of reverting it. | On the C6 the stall safety net (liveness) and the connectivity safety net (net.c) leave the device hung until a power cycle, and an unconfirmed OTA image that stalls is not rolled back. It affects the OPC-UA/Modbus/SNMP apps today. tedge-zephyr's restart needs its own full-system reset on Espressif parts. | Spike A (3.3), Spike B (4.3). | Drop the `CONFIG_BT` condition in `liveness.c` and use `esp_rom_software_reset_system()` in `net.c`, as a separate small change touching production code; give tedge-zephyr a platform reset hook. | -| P3 | **8 KB TLS buffers leave about 2 KB of margin** over today's 5.9 KB server certificate chain on 9883. | A longer chain after a server certificate rotation would break 8 KB builds in the field. | Spike A, task 3.6. | Default to 16 KB; document 8 KB as an opt-in saving for the MQTT Service; consider failing over to a 16 KB session if the handshake fails with `-0x87`. | -| P4 | ~~Free-form telemetry is published but not yet seen in the cloud.~~ **Resolved (7.4):** a Cumulocity Smart Function maps it to measurements. Remaining: the device should send a `time` so buffered messages keep their sample time. | | Section 1, Spike A, section 7. | Add `time` to the telemetry payload in tedge-zephyr. | -| P5 | **Zephyr's HTTP client reports chunked bodies wrongly** in its response callback (first segment's start, last segment's length). | Any user of `http_client` downloading from Cumulocity gets corrupt data. The spike works around it via `on_body`. | Spike B (4.5). | Report upstream with a reproduction; tedge-zephyr uses the `on_body` path. | -| P6 | **Public-key crypto is slow in software:** Cumulocity handshake ~2.7 s (C6) / ~1.9 s (S3); ECDSA P-384 ~11 s for github.com's chain on the C6. Zephyr's mbedTLS doesn't use the ESP32 RSA/ECC accelerators. | Long handshakes cost battery and connect time. P-384 hosts need a longer TLS connect timeout than Zephyr's 10 s default. | Spike A, Spike B (4.6). | Evaluate the Espressif hardware crypto drivers under Zephyr (PSA driver); until then set `NET_SOCKETS_TLS_CONNECT_TIMEOUT` generously and prefer RSA/P-256 servers. | -| P7 | **A firmware update takes the C6 offline for ~40 s** (MCUboot swap-scratch of ~900 KB). | The update window, and what Cumulocity shows meanwhile. The device is dark during the swap. | Spike B. | Measure swap-using-move and overwrite-only (which gives up rollback) as alternatives; report the expected downtime in the operation. | -| P8 | **Operations are missed in a new device's first session** if the client subscribes before the device exists: `s/ds`, `s/dat` and `devicecontrol/notifications` are refused (0x80) until `100` has created it. | A bootstrap-registered device would miss operations in its first session. | Spike C (5.7). | Client order: `100`, wait for the device to exist, then subscribe; treat a 0x80 SUBACK as "retry later", not as final. | -| P9 | **The device key is only obfuscated.** The ITS encryption key comes from a hash of the device ID, and the key is exported into RAM for TLS for the whole uptime. | Anyone with flash or RAM access gets the device identity. | Spike C (U9). | Flash encryption or a hardware-unique key provider for ITS; upstream opaque PSA keys in `tls_credentials`. Document the limitation until then. | -| P10 | **No safe local shell target.** Zephyr's `shell_telnet` binds `INADDR_ANY` (an unauthenticated shell on the LAN), mirrors logs into the session, and doesn't offer echo (fixed in the spike by the bridge offering WILL ECHO/SGA). Loopback (`NET_LOOPBACK`) broke outbound SNTP here. | Remote access to the device's own shell is a headline use case. | Spike F (6.7). | A shell backend fed directly by the remote-access WebSocket (no TCP listener), or a loopback-only listener. Investigate the loopback/SNTP interaction. | -| P11 | **Remote-access throughput is 40–56 KB/s.** | Fine for SSH and config work; slow for bulk copies (10 MB in 3–4 min). | Spike F (6.6). | Larger bridge buffers, batching several TCP reads per WebSocket frame, and measuring where the time goes. | -| P12 | **Unknowns for D10.** Does the MQTT Service keep retained messages for subscribers or Smart Functions? Which reference Smart Functions (twin, health, events, alarms, measurements) does tedge-zephyr ship, where, and how are they installed? How do they tell the device apart (topic, or client identity)? | D10 relies on them for anything beyond telemetry to appear in Cumulocity. | Discussion after section 7. | Check retained behaviour on the MQTT Service; write the reference functions starting with twin → inventory; prototype `tedge_RemoteAccess` (task 6.9). | -| P13 | **Remote access on the classic ESP32 (WROOM) is slow and can stall:** ~4–17 KB/s, and one SSH key exchange stalled with no error, after the net-buffer fix. | The WROOM is the obvious cheap "enabler", but a stalling tunnel looks like a cloud fault. | Section 8 follow-up. | Check whether it tracks the classic-ESP32 network stalls seen with the OPC-UA/SNMP apps; try the default Wi-Fi dynamic buffers with the TLS heap trimmed elsewhere; measure TCP window and retransmits. | - -## Spike results - -### Go/no-go per unknown (task 10.1) - -The detail and the raw measurements are in the per-spike sections below. - -| # | Answer | Verdict | Decision it produces | -|---|---|---|---| -| U1 | One TLS session: handshake 2.7 s (C6) / 1.9 s (S3), 51.8 KB heap peak / 34.8 KB connected (16 KB records), 0 B after disconnect. MQTT + HTTPS 90.9 KB peak, MQTT + tunnel 86.2 KB peak. Spike A adds ~134 KB of text. The full A+B+C+F image is 73–75% of the C6's slot and 25% of the S3's | **Go** (C6, S3 with PSRAM) | C6: `full`. S3: `full` with the mbedTLS heap in PSRAM. WROOM: `remote-access-enabler` only (per-board table in section 8). At most two concurrent TLS sessions (MQTT + one transfer or tunnel) on the default profiles | -| U2 | Max-fragment-length is honoured, but mbedTLS needs 8 KB of input buffer for the 5.9 KB chain on 9883. 4 KB and 6 KB fail. 8883 needs 16 KB (12.9 KB CertificateRequest) | **Go, with 16 KB as the default** | 16 KB records by default; 8 KB is an opt-in saving of 16 KB per session for the MQTT Service only (P3) | -| U3 | SmartREST `100`/`114`/`117`/`200`/`400`/`500`/`510`/`515`/`530` all work on 9883, with one filter per SUBSCRIBE. Free-form `te/` telemetry is mapped by a Smart Function. Basic auth is refused on 9883 | **Go** | Default: MQTT Service with CA authentication. Core MQTT (8883) for basic auth and as the fallback. State and telemetry on free-form `te/` topics (D10) | -| U4 | `s/uat` → `s/dat` gives a JWT (768 B) with a certificate on both ports. It is refused for basic-auth devices | **Go** for certificate devices | HTTPS/WSS calls authenticate with a fresh Bearer JWT. Basic-auth devices would use basic auth for HTTPS (not tested) and get no remote access | -| U5 | Swap ~40 s on the C6 (~19 s on the S3). Test boot, confirm and revert all work, including from a real Cumulocity download. `prov`, `bootreq` and `storage` are untouched. A CPU reset hangs MCUboot on the C6 (P2) | **Go** | Confirm only after reaching Cumulocity; report rollback as `502`. Use a full-system reset everywhere (platform reset hook). Report the expected downtime (P7) | -| U6 | Zephyr's HTTP client doesn't follow redirects; the spike's ~1 KB-URL redirect handling works for GitHub. Chunked bodies are corrupted by the client's response callback (P5). P-384 chains need a 30 s TLS timeout | **Go, with our own code** | The module follows up to 3 redirects, never forwards the JWT off the tenant's domain, writes from `on_body`, and uses a longer connect timeout for external hosts | -| U7 | Key in PSA ITS, one-time password, CSR signed inside PSA, `simpleenroll` polling, PKCS#7 unwrap and mTLS all work on the C6, S3 and WROOM. Mutual TLS costs no extra TLS RAM | **Go** | Onboarding through the Cumulocity CA is primary. Bootstrap basic auth is the fallback, and must send `100` before subscribing (P8) | -| U8 | `simplereenroll` refuses mTLS alone (401) and accepts `Authorization: Bearer ` without a client certificate | **Go** | Renewal = JWT over MQTT, then `simplereenroll` with the Bearer header | -| U9 | The key is exported into RAM for as long as the TLS credential is registered, and ITS is encrypted with a key derived from the device ID | **Go for development, no-go for production as-is** | Document the limitation. Flash encryption or a hardware-unique key provider, and opaque PSA keys in `tls_credentials`, before real deployments (P9) | -| U10 | The SoftAP provisioner signs to 727 KB (71% of `prov`), 218 KB smaller than the BLE one. AP+STA works on the C6: the AP and the phone's connection stayed up while the station tested the credentials (10.5 s to fail, 16.9 s to pass). The iPhone opened the captive-portal sheet by itself. Android not tested | **Go** (iOS verified) | SoftAP becomes a supported alternative to BLE (and the only option for iOS users without an Improv app). BLE stays the default until Android is checked | -| U11 | SSH to a LAN host works: 5.6 s to a command, 103 ms echo, 40–56 KB/s (C6), 51–66 KB/s (S3), ~4–17 KB/s with one stall (WROOM, P13). MQTT + tunnel: 86.2 KB TLS heap peak (16 KB records), 53.4 KB (8 KB). Target policy, session cap and failure reasons work | **Go** (C6, S3); **limited** (WROOM) | One session by default (two on the C6 with 8 KB records). Bulk transfer tuning later (P11) | -| U12 | `530,,,,`, then `wss:///service/remoteaccess/device/` with `Sec-WebSocket-Protocol: binary` and a Bearer JWT. The client certificate alone gets 401 | **Go** | Remote access requires CA authentication and a JWT. A remote shell must start the telnet negotiation itself, and `shell_telnet` isn't shippable (P10) | - -**Overall: go** for `c8y-direct-core` with the direct transport, CA -onboarding and the C6/S3 profiles. The open problems P1–P13 carry over. - -### Section 8: footprint and profiles (2026-09-19) - -`scripts/measure_tedge.sh` builds `apps/c8y-spike` with `--sysbuild`, adding -one spike feature at a time, and reports the signed image, `text` (code + -read-only data), the libc heap left for the application (`_libc_heap_size`; -every static RAM cost shows up as a drop there) and the static mbedTLS heap. -`TLS_HEAP` / `TLS_RECORD` override the spike's 96 KB measuring heap with -production-like sizes. The features exist only in the spike so far, so these -are **spike costs**, an upper bound for the real client (see the overhead -list below). - -**ESP32-C6 (4 MB, 96 KB TLS heap):** - -| Step | Image (slot 1280 KB) | text | Δ text | libc heap left | Δ libc | -|---|---|---|---|---|---| -| base (Wi-Fi, lib/common, module skeleton) | 731 KB (55%) | 601,136 | | 300,432 | | -| + A TLS + MQTT + SNTP + DNS | 876 KB (66%) | 735,452 | +134,316 | 162,880 | −137,552 (96 KB of it is the TLS heap) | -| + B firmware update | 891 KB (67%) | 796,788 | +61,336 | 109,376 | −53,504 | -| + C CA enrollment | 893 KB (68%) | 814,448 | +17,660 | 67,008 | −42,368 | -| + F remote access | 960 KB (73%) | 830,108 | +15,660 | 47,872 | −19,136 | - -The signed image grows in steps (+F: +67 KB image for +15.7 KB text), -because the ESP32 image layout pads segments to page boundaries. Compare -features by `text`. - -**ESP32-S3-DevKitC-1 (16 MB flash; PSRAM not used):** - -| Step | 96 KB TLS heap | 56 KB TLS heap | -|---|---|---| -| base | libc 200,308 | libc 200,308 | -| + A | libc 62,860 | libc 103,820 (text +118,012) | -| + B | dram0 overflow 4.6 KB | libc 51,204 (text +54,772) | -| + C | dram0 overflow 46.8 KB | dram0 overflow 5.9 KB | -| + F | dram0 overflow 65.9 KB | dram0 overflow 24.9 KB | - -**ESP32-WROOM-32 (4 MB, 520 KB SRAM, no PSRAM):** - -| Step | 96 KB TLS heap | 56 KB TLS heap | 40 KB heap, 8 KB records | -|---|---|---|---| -| base | libc 98,064 | libc 98,064 | libc 98,064 | -| + A | dram0 overflow 24.2 KB | libc **16,792** (text +118,800) | libc **33,176** | -| + B | overflow 32.8 KB | dram1 overflow 6.9 KB | dram1 overflow 6.9 KB | -| + C | overflow 62.7 KB | dram0 overflow 21.8 KB | dram0 overflow 5.4 KB | -| + F | overflow 72.8 KB | dram0 overflow 31.8 KB | dram0 overflow 15.4 KB | - -**Spike overhead that inflates these numbers** (the production client won't -carry it): -- the shell with its 10 KB stack (B), and the 10 KB re-enroll and 8 KB - OTA/bridge thread stacks; -- the network buffers raised in Spike B (RX 128 × 128 B, 24/64 packets and - buffers). The hypothesis that raised them was wrong, but some headroom is - needed: at the boards' 32/24 buffers the WROOM's tunnel exhausted the TX - pool (see the follow-up below); -- the 16 KB log buffer (C); -- 4 KB HTTP response and 1–2 KB CSR/PEM/certificate work buffers; -- the 12 KB MQTT thread stack (the measured peak is ~3 KB); -- the TLS heap: the spike's 96 KB against the measured need of 51.8 KB - (MQTT), 90.9 KB (MQTT + HTTPS) and 86.2 KB (MQTT + tunnel) with 16 KB - buffers, or roughly 16 KB less per session with 8 KB buffers. - -**Runtime TLS heap** (from Spikes A–F, 16 KB buffers): one session 51.8 KB -peak / 34.8 KB connected; MQTT + HTTPS 90.9 KB peak; MQTT + tunnel 86.2 KB -peak / 69.2 KB connected. With 8 KB buffers: one session 35.4 / 18.5 KB. - -**Application budgets** (libc heap before the client; README, BLE -provisioning): C6 Modbus 298 KB, OPC-UA 256 KB, SNMP 268 KB; S3-DevKitC -Modbus 200 KB, SNMP 170 KB; WROOM OPC-UA 73 KB, where open62541 needs -nearly all of it. - -**Per-board recommendation (task 8.3):** - -| Board | Direct transport profile | Notes | -|---|---|---| -| ESP32-C6 | **full** (`profiles/full.conf`), one tunnel | Fits the 1280 KB slot at 73%. RAM fits the spike app even with its overheads; next to OPC-UA (256 KB budget) it needs the lean production client and a right-sized TLS heap (~92 KB for MQTT + one HTTPS/WSS). With 8 KB buffers, two parallel tunnels become possible. | -| ESP32-S3-DevKitC-1 (N16R8) | **full**, with the mbedTLS heap in **PSRAM** (verified, see below) | Without PSRAM, internal DRAM runs out at +C. With `overlay-psram-s3.conf` the full A+B+C+F build links with a 96 KB TLS heap and 43.9 KB of libc heap left (95 KB with Wi-Fi/net in PSRAM too); enrollment, remote access and a firmware update all passed on hardware. | -| QT Py ESP32-S3 (N4R2) | as S3, with 2 MB PSRAM | Not measured. | -| ESP32-WROOM-32 | **remote-access enabler** (no protocol app, no OTA, no shell), or gateway transport next to OPC-UA | A+C+F runs with a 56 KB TLS heap and 8 KB records once the Wi-Fi heap is trimmed (see below), with 2.8 KB of libc heap left: nothing else fits beside it. Remote access works but is slow (~4–17 KB/s) and stalled once (P13). No HTTPS firmware download next to MQTT (B needs a second TLS session's RAM). Next to OPC-UA only the gateway transport (thin-edge.io child device, no TLS) fits. | - -### Spike E: SoftAP captive-portal provisioner (section 9, 2026-09-19) - -**Setup:** `apps/wifi-provisioner` with `overlay-softap.conf` and -`softap.overlay` (`CONFIG_APP_WIFI_PROV_SOFTAP`, `src/softap.c`), built as -the C6's sysbuild provisioner image next to the full spike app. The app's new -`spike provision` shell command sets the operator boot request and reboots -into it. The front end: -- an open AP `-setup` at 192.168.4.1, with Zephyr's DHCPv4 server - handing out 192.168.4.1 as router and DNS server; -- a catch-all DNS responder (every A query → 192.168.4.1); -- a single-threaded HTTP server: `GET /` serves the form, any other `GET` - gets a 302 to it, `POST /connect` takes `ssid` and `psk`; -- the credential test and hand-off shared with the BLE front end - (`app_net_try_credentials`, `wifi_credentials`, clear the boot request, - full-system reset). - -**9.1 size (C6):** - -| Provisioner | Signed image | of 1024 KB `prov` | text | -|---|---|---|---| -| Improv over BLE | 944,924 B | 92% | 861,680 | -| SoftAP (BT off, AP+STA, DHCP server, TCP) | **726,731 B** | **71%** | 590,936 | - -**9.2 AP and station at the same time:** works with Zephyr's ESP32 driver -(`WIFI_NM`, `WIFI_USAGE_MODE_STA_AP`, a second `espressif,esp32-wifi` node for -the AP interface). The station is interface 1, `net_if_get_first_wifi()` and -the default, so `lib/common`'s credential test needed no change. During the -test the phone stayed associated and asked for the page again the moment the -test ended. The C6 has one radio, so the AP follows the station's channel; -that caused no drop here. Test times: 10.5–17.8 s to fail (three association -attempts), 16.9 s to pass. After the reboot the app was on the network in 5 s -and connected to Cumulocity 3 s later. - -**9.3 phones:** -- **iPhone (by the tenant owner):** joining the open network opened the - captive-portal sheet on its own (`GET /hotspot-detect.html` → 302 → form). - Wrong password: "Could not join …" shown once the page reloaded itself - (first build: no feedback, because the "Testing…" page didn't reload; the - fix is a meta refresh that waits in the listen backlog until the test - ends). Correct password: stored, device rebooted into the app, **but the - phone got no success page**: no reload reached the device between the - pass and the reboot, so the user only saw the network disappear. -- **Android:** not tested. - -**For a real SoftAP provisioner:** -- After a pass, keep the AP up for a few seconds and serve a "connected, the - device restarts" page (and the application's URL or the Cumulocity - registration URL, as Improv's RPC result does) before rebooting. -- Report the result without a page reload (a small script polling a status - URL), since captive-portal sheets differ. -- A scan list for the SSID; the spike takes typed input. -- Authorization: the AP is open and unauthenticated. Anyone in range during - the window can set the device's network. Reuse `APP_WIFI_PROV_REQUIRE_AUTH` - (a button press) or a WPA2 AP whose password is printed on the device. -- Check Android's detection (`/generate_204`) and a laptop. - -### Section 8 follow-up: PSRAM on the S3, the WROOM as a remote-access enabler (2026-09-19) - -**S3-DevKitC-1 with PSRAM.** Zephyr 4.4.2's S3 linker script already places -`.mbedtls_heap*` in PSRAM (`.ext_ram.data`, under `CONFIG_ESP_SPIRAM`), so -`apps/c8y-spike/overlay-psram-s3.conf` only sets `ESP_SPIRAM`, octal mode, -`SPIRAM_TYPE_ESPPSRAM64` and `MBEDTLS_HEAP_CUSTOM_SECTION`. The board overlay -declares the 8 MB `psram0` (no effect without `ESP_SPIRAM`). - -| Step (96 KB TLS heap in PSRAM) | image | text | libc heap left | -|---|---|---|---| -| base | 582 KB (18%) | 493,384 | 196,940 | -| + A | 726 KB (23%) | 611,156 | 157,812 | -| + B | 739 KB (23%) | 665,928 | 105,180 | -| + C | 807 KB (25%) | 681,132 | 62,956 | -| + F | 809 KB (25%) | 695,516 | **43,932** | -| + F, and `ESP32_WIFI_NET_ALLOC_SPIRAM` | 809 KB | | **94,972** | - -On hardware (full A+B+C+F, `_mbedtls_heap` at 0x3c0b0000): -- PSRAM detected (8 MB AP octal, 40 MHz). Enrolled as `tedge-7c0c5f5a6eb8` - (certificate 2.8 s after registration), mTLS to 9883 in 1.8 s: **no - slower than internal RAM** (1.9 s before). TLS heap 51.8 KB peak / 34.8 KB - connected, identical to the C6. MQTT stack peak 3.3 KB, including the first - enrollment. -- Remote access to the Pi's SSH: MQTT + tunnel 86.2 KB peak / 69.2 KB - connected (as the C6); 2 MB each way at ~51 and ~66 KB/s by the device's - counters. - Separately, the user ran an interactive session for ~6 minutes - (`c8y remoteaccess connect ssh --device tedge-7c0c5f5a6eb8 --configuration - pi-ssh`) and reported no problems. -- Firmware update 0.0.1 → 0.1.0 (`zephyr-c8y-spike` 0.1.0-s3): 790 KB in - ~12 s while the TLS heap was in PSRAM and the flash was written (cache - disabled during writes: no problem seen), swap ~19 s, confirmed after - reaching Cumulocity, operation SUCCESSFUL. - -Not yet tried: `ESP32_WIFI_NET_ALLOC_SPIRAM` at runtime (it builds), and a -long soak. - -**WROOM-32 as a "thin-edge.io light" remote-access enabler** (no protocol -app, no OTA, no shell; `overlay-enabler-wroom.conf`, 8 KB records): - -| Build | Result | -|---|---| -| A+F, 56 KB TLS heap | links, libc 3.7 KB | -| A+C+F, 56 KB TLS heap | dram0 overflow 8.5 KB | -| A+C+F, + Wi-Fi heap trimmed (`HEAP_MEM_POOL_IGNORE_MIN`, 44 KB system heap, 16/16 Wi-Fi dynamic RX/TX buffers) | **links, libc 2.8 KB** | - -`dram0` holds the system heap (56.5 KB, of which 51.2 KB is -`HEAP_MEM_POOL_ADD_SIZE_ESP_WIFI`, not user-settable), the TLS heap and the -TLS contexts. Thread stacks and net buffers sit in SRAM1 `.noinit` -(`ESP32_REGION_1_NOINIT`), so shrinking them doesn't help `dram0`. The C -shell diagnostics (re-enroll, bench, 10 KB stack) are now built only with -`CONFIG_SHELL`. - -On hardware (`tedge-3c71bf10c2e4`, formerly an OPC-UA board on the Pi): -- Enrolled, mTLS to 9883 in 2.2 s, TLS heap 35.4 KB peak / 18.4 KB connected; - MQTT stack 3.55 KB of 6 KB including the enrollment. -- Tunnel to the Pi's SSH: TLS heap **53.4 KB peak of 57.3 KB** (3.9 KB - margin) / 36.4 KB connected; WSS upgrade 2.2 s. -- At the board's 32 RX / 24 TX net buffers, SSH through the tunnel exhausted - the TX pool (`Data buffer allocation failed`, `TCP failed to allocate - buffer in retransmission`); the tunnel stalled and the MQTT session dropped - (it reconnected by itself). With 64/64 buffers and 16/16 packets: 5 of 5 - logins in ~4 s, no allocation failures, MQTT kept up. -- Throughput stays poor: 500 KB into the LAN in 130 s (~4 KB/s), earlier - runs ~17 KB/s out, and one session stalled in the SSH key exchange with no - error logged (P13). - -**Verdict.** The S3 runs the full client with PSRAM and loses nothing -measurable. The WROOM can be a remote-access-only enabler for interactive -SSH/telnet to LAN hosts, but only just: no room for anything else, a 4 KB TLS -margin, and slow, sometimes stalling transfers on the classic ESP32's Wi-Fi. -It's a demo-grade option, not a product recommendation; the C6 or an S3 is -the enabler to recommend. - -### Section 7: cloud-side verification (2026-09-19) - -- **7.1** `tedge-e8f60afc320c` (managed object 60211263): external ID of - type `c8y_Serial`, owner `device_tedge-e8f60afc320c`, supported operations - `c8y_Restart`, `c8y_Firmware`, `c8y_RemoteAccessConnect`, firmware - `zephyr-c8y-spike` 0.0.1, required interval 60 s. The certificate's CN is - the external ID. -- **7.2** Restart of the enrolled device from Cumulocity: SUCCESSFUL in 17 s. -- **7.3** Remote access to the Pi and to the device shell, with - opened/closed events on the device (Spike F). The browser terminal can use - the TELNET endpoint `device-shell-ui` (the device's own address). -- **7.4 Free-form telemetry arrives.** A **Cumulocity Smart Function** - (created by the tenant owner) maps - `te/device////m/environment` → `c8y_Environment` measurements - (`c8y_Temperature.T` C, `c8y_Humidity.H` %RH, - `c8y_AtmosphericPressure.P` hPa), one every 10.0 s as sent. - - The payload carries no timestamp, so the measurement time is the - processing time. Once the client buffers telemetry while offline, each - message must carry its own `time` (thin-edge.io's measurement shape - already allows it). - - The thin-edge.io topic/payload shape lets one mapping serve devices on - either transport. - -### Spike F: remote access through the device (section 6, 2026-09-19) - -**Setup:** `overlay-spike-f.conf` on top of A+B+C (enrolled identity -`tedge-e8f60afc320c`), `src/spike_ra.c`. Sessions were opened from a PC -with `c8y remoteaccess server --configuration --listen 127.0.0.1:` -(passthrough configurations only), then `ssh -p root@127.0.0.1`. - -**U12: the protocol**, matching thin-edge.io's `c8y-remote-access-plugin`: -- **`530,,,,`** arrives on `s/ds`, and - also as JSON on `devicecontrol/notifications`. The key is 36 characters; - the spike never logs it. -- The device opens a TCP connection to `:`, then - **`wss:///service/remoteaccess/device/`** with - **`Sec-WebSocket-Protocol: binary`**; the reply is `101` with - `Sec-WebSocket-Protocol: binary`. -- **Authentication:** with the **client certificate only** → - `401, www-authenticate: Basic realm="Cumulocity"`. With - **`Authorization: Bearer `** → works. As for `simplereenroll`, a - certificate device needs a fresh JWT (1 h) from `s/uat` for every HTTPS - or WSS call. -- Reported as `501` → `503` (tunnel up) or `502,""`, plus events - `c8y_RemoteAccessOpened` and - `c8y_RemoteAccessClosed` ("… closed (): B up, B down"). - -**U11: SSH to the Pi (192.168.68.72:22) through Cumulocity and the C6.** - -| | Result | -|---|---| -| TCP connect to the target | 14–178 ms | -| WSS to Cumulocity (TLS + upgrade) | 2.43–2.54 s | -| `ssh … "hostname; uname -m"` end to end (530, tunnel, SSH handshake) | **5.6 s** | -| Interactive echo round trip (20 lines through a remote `cat`) | **median 103 ms**, 89–134 ms | -| 10 MB PC → Pi | **40 KB/s** (257.9 s), SHA-256 intact | -| 10 MB Pi → PC | **56 KB/s** (182.3 s), SHA-256 intact | -| TLS heap with MQTT + tunnel (16 KB buffers) | 69,200 B connected, 86,176 B peak; steady through 10 MB each way | -| Bridge | one thread (8 KB stack), 2 × 2 KB buffers, one WebSocket frame per TCP read | - -Throughput is probably limited by the per-frame design (2 KB buffers, a -masked frame per read, one thread), not the link. Larger buffers and -batching are the obvious first optimisation. - -**Policy, cap and failures.** - -| Case | Result | -|---|---| -| `8.8.8.8:53` (outside the subnet), 6.2 | FAILED `target 8.8.8.8:53 denied: not on the device's subnet`; no socket opened | -| Second session while one is open (cap 1), 6.6 | FAILED `session limit reached (1)`; the open session carried on. The device answers **0.25–0.7 s after the operation is created** (audit log, 4 parallel attempts by the tenant owner), but **Cumulocity's remote-access web terminal only reports the failure after its own timeout**, since it doesn't watch the operation. The reason is only visible on the operation (Control tab). Suggestion for the UI: show `failureReason` as soon as the operation fails | -| Target on the subnet with no host (192.168.68.250:22), 6.8 | FAILED `cannot connect to 192.168.68.250:22 (-116)` | -| The client closes (SSH exits) | Cumulocity closes the WebSocket, the bridge logs "WebSocket close from Cumulocity" and publishes the close event | -| Wi-Fi drop mid-session, 6.8 | **not tested** (needs the AP; see P1) | - -**Local target (6.7).** -- The device's own Zephyr shell is reachable through the tunnel. - `kernel version`, `kernel uptime` and `spike ota status` were answered via - a passthrough endpoint to the **device's own LAN address** - (192.168.68.50:23; TCP connect 2 ms). -- `127.0.0.1` didn't work out. It needs `NET_LOOPBACK` (+ `NET_DRIVERS`), - and with the loopback interface enabled **outbound SNTP failed** (`-101`, - then `-116`), so the device never connected. -- **`shell_telnet` is not shippable.** It binds `INADDR_ANY` with no option - for loopback only, so **the unauthenticated shell was open on the LAN** - (port 23 reachable from the PC). It also mirrors the device log into the - session. A production remote shell should bridge the WebSocket straight - into a shell backend with no TCP listener, or listen on loopback only. -- Adding the telnet backend exceeded `ZVFS_POLL_MAX` (7 socket-service - entries against 6), and then **the socket-service thread doesn't run at - all, which also stops mDNS**. It needs `ZVFS_POLL_MAX` of 10. -- **No echo in Cumulocity's web terminal** (found by the tenant owner with - the TELNET endpoint). Zephyr's telnet backend turns echo off on accept and - never offers `WILL ECHO` / `WILL SUPPRESS-GO-AHEAD`, and the web terminal - waits for the server to offer. Both stayed in line mode: typed text was - invisible, and the whole line went out on Enter (3 bytes for `ls`↵). - - Fixed in the spike: the bridge sends `IAC WILL ECHO, IAC WILL SGA` to the - client when a port-23 tunnel opens, and - `SHELL_TELNET_SUPPORT_COMMAND` (experimental) lets the backend act on the - client's `DO ECHO` / `DO SGA`. - - Verified: the device offers `ff fb 01, ff fb 03`, confirms after the - DOs, and echoes each typed character. - - **Design rule:** a device-side remote shell must start the telnet - negotiation itself. - -**`tedge_RemoteAccess` twin data (6.9, D10).** `spike_ra_twin()` builds the -value, and the MQTT thread publishes it on -`te/device////twin/tedge_RemoteAccess` (QoS 1) at every connect and -about 1 s after a tunnel opens, fails or closes. The bridge frees its seat -just after it posts the event, so the publish waits one pass. On the C6 -(`tedge-e8f60afc320c`): - -``` -connect: {"maxSessions":1,"activeSessions":0,"freeSessions":1,"policy":"lan","sessions":[]} -open: {"maxSessions":1,"activeSessions":1,"freeSessions":0,"policy":"lan", - "sessions":[{"target":"192.168.68.72:22","since":"2026-09-19T20:34:55Z"}]} -closed: {"maxSessions":1,"activeSessions":0,"freeSessions":1,"policy":"lan","sessions":[]} -``` - -- `freeSessions` (added at the tenant owner's request) is `maxSessions` - minus the seats taken from an accepted `530` until the bridge has cleaned - up. `activeSessions` counts established tunnels only. -- `CONFIG_SPIKE_RA_TWIN_SESSIONS` controls the target list (internal - addresses). Core MQTT builds send `{"tedge_RemoteAccess":…}` as a direct - inventory update instead (built, not run). -- The tenant owner wrote a Smart Function for the topic and confirmed it - works through three sessions of their own; the device published open and - close for each. It does not write a `tedge_RemoteAccess` fragment onto the - managed object, so how the reference function should present the data - (fragment, measurement or both) is still part of P12. - -**Zephyr findings.** -- **`websocket_connect()` needs PSA SHA-1** for `Sec-WebSocket-Accept` - but doesn't select it. Without `PSA_WANT_ALG_SHA_1` it fails with `-EPROTO` - before sending anything. -- The remote-access image (A+B+C+F) is 959,851 bytes, 75% of the 1280 KB - slot on the C6. - -### Spike C: Cumulocity CA enrollment on the device (section 5, 2026-09-19) - -**Setup:** `overlay-spike-c.conf` (`SPIKE_AUTH_ENROLLED`), `src/spike_enroll.c`, -shell `enroll status|csr|bench|reenroll|reset`. Identity `tedge-` = -`tedge-e8f60afc320c`. The one-time password was taken from the device's -registration URL and registered with `c8y deviceregistration register-ca`, -which does what opening the URL does. - -**U7: onboarding works end to end on the device.** - -| Step | Result | -|---|---| -| Key (5.1) | Persistent P-256 key `0x0007E571` in PSA ITS (`SECURE_STORAGE`, ITS store over settings/NVS in `storage`). **Survives a reboot and an app reflash**: the device reconnected with no new enrollment. Key IDs must be at most `PSA_KEY_ID_USER_MAX` (`0x3FFFFFFF`); `0x7E570001` gave `PSA_ERROR_INVALID_ARGUMENT` | -| One-time password (5.2) | 32 characters from `psa_generate_random`, stored until the certificate arrives; registration URL logged | -| CSR (5.3) | 297-byte body, `CN=tedge-e8f60afc320c`, ecdsa-with-SHA256, signed through `mbedtls_pk_wrap_psa` so **the key stays in PSA for signing**. `openssl req -verify`: OK. Signing the CSR takes 1.19 s; one ECDSA P-256 signature 414 ms | -| Enrollment (5.4) | Before registration each poll gets `401 {"message":"No newDeviceRequest found for this ID. It may already be registered…"}`. Certificate issued on the 3rd poll (27 s after the first, mostly waiting for the registration). The 515-byte base64 PKCS#7 reply is unwrapped to the **332-byte DER certificate** with about 30 lines of ASN.1 walking; no PKCS#7 module is needed | -| Mutual TLS (5.5) | 9883: handshake 2.69 s, TLS heap peak 51,820 B / connected 34,820 B. 8883: 2.73 s, same heap. **Within ±30 B of Spike A** (certificate from a PC): client-certificate auth costs no extra TLS RAM | - -**U9: where the key lives.** Zephyr's `tls_credentials` take only a key -buffer. The key is exported (`mbedtls_pk_copy_from_psa` + -`mbedtls_pk_write_key_der`, 121 bytes of DER) and **stays in RAM for as long -as the credential is registered**. Every handshake parses it again, so in -practice that is the device's whole uptime. On top of that, the ITS -encryption key comes from a **hash of the device ID**, and Zephyr warns at -boot: "Using a potentially insecure PSA ITS encryption key provider". The -key is obfuscated at rest, not protected. For real protection: -- flash encryption, or a hardware-unique key provider for ITS; -- opaque PSA keys in `tls_credentials` (upstream work), so TLS signs inside - PSA as the CSR already does. - -**U8: renewal.** `POST /.well-known/est/simplereenroll`: -- with **mutual TLS only**: `401 {"message":"Full authentication is required to access this resource"}`; -- with **`Authorization: Bearer `** and no client certificate: **200, a - new 332-byte certificate**. - -So renewal needs a JWT, which only certificate devices get, over MQTT -(`s/uat` → `s/dat`). That matches tedge, whose proxy adds the token. The -Bearer header is about 800 bytes. - -**Bootstrap fallback (5.7).** -- `c8y deviceregistration register --id tedge-boot-c6` creates the - request (WAITING_FOR_CONNECTION). The device connects to 8883 as - `management/devicebootstrap`, subscribes to `s/dcr` and polls `s/ucr` - every 5 s; the request becomes PENDING_ACCEPTANCE; `approve` accepts it. -- `70,,,` arrived after 2 polls (12.8 s, mostly - waiting for the accept). It is stored in settings, and the device - reconnects as `t297258657/device_tedge-boot-c6` (handshake 2.5 s). The - password is never logged. -- **First-session ordering problem:** in the first session as the new - device user, the device subscribed before sending `100` (create device). - Cumulocity **refused `s/ds`, `s/dat` and `devicecontrol/notifications` - (0x80)** while the device didn't exist yet, `114` was lost, and the - duplicate `100` got `50,100,Error on device creation, message: Error on - identity creation`. In the next session everything was granted and - applied. **The client must create the device first**: send `100`, wait - for it to exist (or for `41,100,Device already existing`), then - subscribe. Otherwise it must retry refused subscriptions. CA-registered - devices aren't affected, because `register-ca` creates the device before - it first connects. - -**Other notes.** -- A burst of log lines (CSR, URL) overflowed the default log buffer - ("--- 17 messages dropped ---"); Spike C uses 16 KB. -- Deferred logging of `%s` arguments that point into a buffer the next - request reuses loses or garbles the line: copy first. - -### Spike B: firmware update into slot1 (section 4, 2026-09-19) - -**Setup:** `overlay-spike-b.conf` on top of Spike A. The shell provides -`spike ota get|confirm|status` and `spike reboot`. `c8y_Firmware` (`515`) is -handled over the Spike A connection. Test images A–E (0.0.1–0.0.5), built by -`apps/c8y-spike/ota-variants/build_all.sh`, differ in version, auto-confirm and, for -D, an injected stall. - -**U5: MCUboot on the C6 (4 MB, swap-scratch).** - -| | Result | -|---|---| -| Swap time, ~890–898 KB image | **39.8–40.5 s** (MCUboot start → "Loading image 0"); the device is offline for that time | -| Normal boot, no swap | ~1.8 s | -| Test boot (4.1) | B 0.0.2 came up "NOT confirmed (test boot)" | -| Confirm from the shell, then reset (4.2) | stays 0.0.2, confirmed | -| Reset without confirming (4.3) | MCUboot reverts to A (another ~40 s swap) | -| Stall without confirming (4.3, image D) | blocked workqueue at +6 s, liveness watchdog at +33 s, **reset was a CPU reset (`SW_CPU`) and MCUboot hung**; after a full reset MCUboot reverted D → A. See P2 | -| `prov`, `bootreq` after all tests (4.4) | byte-for-byte unchanged (SHA-256 compared), `bootreq` still empty; Wi-Fi credentials in `storage` survived (reconnected every time) | - -**Downloads.** - -| Source | Rate | Notes | -|---|---|---| -| LAN HTTP → slot1 | 43–65 KB/s | bound by progressive erase + flash writes | -| LAN HTTP, discarded (`--discard`) | 311–339 KB/s | network only | -| Cumulocity binary, HTTPS + JWT → slot1 (4.5) | 79 KB/s (898,378 B in 11.4 s) | `Transfer-Encoding: chunked` | -| GitHub release asset (4.6) | 1,971 B in 16.9 s | two TLS handshakes; github.com's takes ~13 s | - -**U1: MQTT and HTTPS open together:** TLS heap peak **90,928 B** with 16 KB -buffers on both sessions, 34,836 B once the download closes. - -**The firmware flow on Cumulocity (4.5):** `515` → `501` → HTTPS download -with the JWT → test boot → swap → the new image connects → **confirmed only -after reaching Cumulocity** → `115,,,` + `503`. -Operation SUCCESSFUL in **69 s**, and the inventory shows the new firmware. -If MCUboot rejects or reverts the image, the old image finds the pending -marker while running confirmed, and reports -`502,c8y_Firmware,"…rolled back…"`. That path fired for real (below). - -**Findings.** - -1. **Zephyr's HTTP client corrupts chunked bodies in its response - callback.** One receive buffer can hold several body segments separated - by chunk-size lines. `on_body` is called for each, but the client keeps - the *first* segment's `body_frag_start` and overwrites `body_frag_len` - with the *last* segment's length. Cumulocity serves binaries chunked, so - the image in slot1 was corrupt. MCUboot rejected it (the signature - check), erased slot1 and booted the old image, and the device reported - the rollback. **The safety net worked.** The fix: write each segment from - the parser's own `on_body` callback, then flush. This should be reported - upstream. -2. **Binary URLs use the tenant-ID host** (`https://t./inventory/binaries/`), - not the tenant's host name. Deciding "is this Cumulocity, so send the - JWT?" needs a domain rule (the spike uses the parent domain). The JWT must - never go to another host, including after a redirect. -3. **Redirects:** Zephyr's HTTP client doesn't follow them. The spike follows - relative, absolute and cross-scheme (HTTP → HTTPS) redirects, up to 3 - hops. A GitHub release asset is one 302 to - `release-assets.githubusercontent.com` with a **913-byte `Location`**, so - URL buffers need about 1 KB. -4. **GitHub needs two more trust roots:** USERTrust ECC (github.com's - Sectigo E46 is cross-signed by it) and ISRG Root X1. It also needs - P-384/SHA-384: **+8.1 KB of flash** - (`overlay-github.conf`). **ECDSA P-384 in software takes about 11 s** to - verify github.com's chain on the C6, so the whole handshake takes about - 14 s. That is more than Zephyr's default TLS connect timeout - (`NET_SOCKETS_TLS_CONNECT_TIMEOUT` = 10 s), so the connect failed with - `-116` until it was raised to 30 s. Cumulocity's RSA chain takes about - 2.7 s. -5. **The shell as log backend holds logs back while a command runs:** the - shell thread prints them. The download runs on its own thread for that - reason. The C6's USB console also **drops typed characters** while logs - are being printed (`--no-reboot` arrived as `--no-reo` and triggered an - unintended test boot). The capture tool now types one character every - 10 ms. -6. **Test hygiene: a duplicate MQTT client ID looks like a flaky network.** - The S3 was still running Spike A as `tedge-spike-c6`, so the broker kept - closing one connection whenever the other connected ("Connection closed" - every few seconds). It was first mistaken for downloads starving the - MQTT session; the net-buffer increases in `overlay-spike-b.conf` came - from that wrong hypothesis. Cumulocity redelivered the pending `515` on - every reconnect. **The client should detect repeated takeovers** - (connect, then closed after a few seconds, again and again) and log a - clear "another client uses this ID" warning. The S3 is parked in its ROM - bootloader; a reset brings Spike A back. - -### Spike A: TLS and MQTT from the device (section 3, 2026-09-19) - -**Setup:** `apps/c8y-spike` with `overlay-spike-a.conf`, TLS 1.2 with the -`ECDHE-RSA-AES128-GCM-SHA256` and `ECDHE-ECDSA-AES128-GCM-SHA256` -ciphersuites (on Zephyr 4.4 / TF-PSA-Crypto the ciphersuite options pull in -the PSA algorithms; the older `KEY_EXCHANGE_*` options alone are dropped), -mbedTLS on its own heap so TLS memory is measured directly, a dedicated -10 KB thread. Identity: `tedge-spike-c6`, mutual TLS with a certificate -from the Cumulocity CA issued on a PC. - -**U1: cost.** - -| | ESP32-C6 (RISC-V, 160 MHz) | ESP32-S3-DevKitC-1 (Xtensa, 240 MHz) | -|---|---|---| -| TCP + TLS handshake, 9883, 10 cycles | 2,640–2,807 ms (median ~2.7 s) | 1,830–2,191 ms (median ~1.9 s) | -| CONNACK after handshake | 40–240 ms | 70–340 ms | -| TLS heap, handshake peak (16 KB buffers) | 51,788 B | 51,788 B | -| TLS heap, connected (16 KB buffers) | 34,836 B | 34,836 B | -| TLS heap after disconnect | 0 B, every cycle (no leak) | — | -| Client thread stack, peak | 2,604–2,828 B of 10,240 | 2,928 B | -| Signed image (slot 1280 KB / 3072 KB) | 875,771 B (68%) | 725,388 B | - -- Static cost of Spike A on the C6, against the same app without it: - text +133.6 KB; static RAM +142.6 KB, of which 96 KB is the deliberately - oversized measuring heap for mbedTLS. The real TLS need is the peak above, - so a production heap of about 56 KB (16 KB buffers) or 40 KB (8 KB buffers) - per session plus margin is the figure to use. -- The handshake time is almost all public-key work in software: verifying - the RSA-4096 intermediate and the RSA-2048 server certificate, ECDHE, and - an ECDSA signature. It scales with the CPU clock (C6 vs S3). Zephyr's - mbedTLS does not use the ESP32 RSA/SHA accelerators. -- SNTP (`pool.ntp.org`) set the clock in 51 ms on the first attempt; - certificates validate against it. - -**U2: record size (task 3.6).** - -- Below 16 KB, Zephyr offers max-fragment-length automatically, and - Cumulocity honours it: records are at most 4096 bytes (level-3 mbedTLS - debug: `found max_fragment_length extension`, input records of 4096). -- **But mbedTLS reassembles a handshake message inside its input buffer**, - and it needs room for the partial message plus the next record. With a - 5,883-byte certificate chain on 9883: - - 4096: fails, `requesting more data than fits` (`-0x87`); - - 6144: fails the same way; - - **8192: works**. Heap peak 35,404 B and connected 18,452 B, **16,384 B - less than with 16 KB buffers**. The handshake time is unchanged. A - 6,223-byte downlink message crossed several records correctly. -- 8883 needs 16 KB: its CertificateRequest alone is 12,919 bytes. -- Margin: about 2 KB over today's chain. A longer server chain after a - certificate rotation could break 8 KB buffers, so the production default - should be 16 KB, with 8 KB as a documented saving for the MQTT Service. - -**U3/U4: the device-management contract on the device.** - -- 9883 with mutual TLS: all five subscriptions granted (one filter per - SUBSCRIBE), `100`/`114`/`117` applied, JWT on `s/dat` (768 bytes), and - free-form telemetry published - (`{"temperature":19.18,"humidity":53.19,"pressure":1016.83}` on - `te/device/tedge-spike-c6///m/environment`). -- 8883 with mutual TLS: the same, with SmartREST `200` telemetry. Handshake - 2,915–3,174 ms on the C6, about 0.3 s slower than 9883 (the 12.9 KB CA - list). Same heap with 16 KB buffers. -- 8883 with basic auth (`tedge-spike-a-c6`): connects in 2,625 ms (no client - signature). Heap 51,356 / 34,380 B, about 430 B less than mutual TLS. The - JWT is refused - (`41,,MqttAuth: Cannot publish token for device that do not use certificate authentication`), - as on the PC. - -**Restart (task 3.3).** - -- `510` → `501` → marker in settings → reboot → reconnect → `503`: the - operation went to SUCCESSFUL 32 s after it was created. The marker survives - a reboot: an operation left EXECUTING by a hung reboot was completed on the - next boot. -- **The reboot must be a full-system reset on the C6.** After `sys_reboot()`'s - CPU reset (`rst:0xc SW_CPU`) with only Wi-Fi running, MCUboot hung after - "SPI Flash Size : 4MB" until the next reset. `boot_request_reboot()` - (`esp_rom_software_reset_system()`, `rst:0x3 LP_SW_HPSYS`) works. This - matters beyond the spike: - - tedge-zephyr's restart needs its own full-system reset on Espressif - parts, since it can't use `lib/common`; - - `lib/common/net.c`'s last-resort reboot still calls - `sys_reboot(SYS_REBOOT_COLD)`, so on the C6 it would likely leave the - device hung in MCUboot. That's a separate fix. - -**Integration requirements found.** - -- **TCP connection contexts:** reconnecting once a second ran out after three - cycles (`Not enough connection contexts`, board `NET_MAX_CONN=6`), because - a closed TLS connection holds its context until TCP finishes closing. - Spike A uses `NET_MAX_CONN=10` / `NET_MAX_CONTEXTS=12` with 3 s between - cycles, and 10/10 then connect. tedge-zephyr's README must list the extra - contexts it needs, and the client must back off between reconnects. -- One transient DNS failure (`-101`) and occasional first-boot Wi-Fi - association retries were seen; the retry loop covered both. - -### Section 1: tenant and host-side findings (2026-09-19) - -**Tenant:** `tedge-dev05.preprod.c8y.io` (tenant `t297258657`). - -- Features `certificate-authority` (GA) and `mqtt-service.smartrest` - (Public Preview) are on. The tenant CA certificate exists (valid until 2029, - auto-registration on). -- The tenant is subscribed to the `cloud-remote-access` and `mqtt-service` - microservices. No Dynamic Mapper is subscribed. -- Credentials: `c8y.local.conf` holds go-c8y-cli environment variables, not - a Kconfig overlay. Firmware overlays therefore use other `*.local.conf` - names (all git-ignored). The Spike A device credentials are in - `spike-a-device.local.conf`. -- The bootstrap credentials (`management/devicebootstrap`) work against the - tenant: `POST /devicecontrol/deviceCredentials` returns 404 for an unknown - ID. go-c8y-cli does not use them unless `C8Y_BOOTSTRAP_*` are set; with the - session token the same request returns 403. - -**TLS (U1, U2):** - -- Every port uses the same chain: `*.preprod.c8y.io` → GoDaddy DV R1v1 → - GoDaddy Root R1 → Go Daddy Root G2. All certificates are RSA (2048/4096). - TLS 1.3 and TLS 1.2 (ECDHE-RSA, AES-GCM) both work. -- **All ports accept max-fragment-length.** The server echoes the extension - (id 1) on TLS 1.2 when the client offers 4096. Whether mbedTLS then - reassembles handshake messages that span several records is checked on the - device in 3.6. -- Largest handshake messages (TLS 1.2): - - | Port | Certificate | CertificateRequest | NewSessionTicket | - |---|---|---|---| - | 9883 | 5,883 B | 44 B (no CA list) | 6,153 B | - | 8883 | 5,883 B | **12,919 B (166 acceptable client CAs)** | 6,167 B | - - NewSessionTicket is only sent if the client offers tickets. With tickets - off, 9883 needs roughly an 8 KB input buffer (or MFL). Core MQTT's - CertificateRequest grows with the platform's trusted certificates, so - 8883 is the riskier endpoint for a fixed MCU buffer. - -**MQTT and SmartREST (U3, U4), checked from a PC with -`apps/c8y-spike/tools/c8y_mqtt_probe.py`:** - -| | Core MQTT 8883 | MQTT Service 9883 | -|---|---|---| -| Basic auth (`/device_`) | connects | **Not authorized**. Tried 4 user-name formats and the MQTT Service roles; also refused for a device freshly created with `register-basic` and never used with a certificate, which connects to 8883 seconds later | -| mTLS (Cumulocity CA certificate) | connects | connects | -| Publish on `s/us` (100/114) | applied | applied (114 changed `c8y_SupportedOperations`) | -| Subscribe `s/ds`, `s/e`, `s/dat` | granted; `510` and `40,999,…` arrive | granted **only with one filter per SUBSCRIBE**. A SUBSCRIBE carrying several filters is refused as a whole (SUBACK 0x80 on 3.1.1, "Topic filter invalid" on MQTT 5), whatever the filters are. Sent one at a time, every filter is granted, including wildcards, `s/ucr` and `te/device////cmd/+/+`. Then `510` and `40,999,…` arrive | -| `s/uat` → `s/dat` JWT | certificate devices only (771-byte JWT, 1 h). A basic-auth device gets `41,,MqttAuth: Cannot publish token for device that do not use certificate authentication` | works (certificate device) | -| Persistent session (clean session off) | not tested | no CONNACK | - -- **Firmware rule: send one topic filter per SUBSCRIBE.** It costs nothing - on 8883 and is required on 9883. The multi-filter packet alone is the - trigger. The exact set tedge's bridge subscribes to (`s/dat`, `s/dt`, - `s/ds`, `s/e`, `devicecontrol/notifications`, `error`, from - `tedge bridge inspect c8y`) is refused as one packet. `s/ucr` and - `te/…/cmd/#` are each granted on their own. tedge works on 9883 because - its bridge subscribes one topic at a time - (`crates/extensions/tedge_mqtt_bridge/src/lib.rs`, `start_subscribe_round`). - The firmware subscribes to the same downstream set as tedge, minus `s/ucr`, - which only the bootstrap flow needs. -- With certificate authentication, 9883 carries the whole device-management - contract: operations (`510` → `501`/`503`), errors on `s/e` and the JWT. - The MQTT Service remains the default endpoint. -- **Basic authentication only works on 8883.** Both auth methods stay - selectable in Kconfig, and the endpoint follows the choice: - `TEDGE_C8Y_MQTT_SERVICE` depends on `TEDGE_AUTH_C8Y_CA`, so a basic-auth - (bootstrap) build falls back to Core MQTT automatically. The constraint is - one line, to be dropped if the MQTT Service accepts basic auth for devices. - Covered by the `basic-auth-uses-core-mqtt` and `mqtt-service-needs-ca` - Kconfig cases. -- **Every multi-filter SUBSCRIBE is refused, from two filters up.** The - same happens for two plain free-form topics (`spike/a` + `spike/b`) and - for `s/ds` + `s/e` in either order. Growing tedge's set from one filter: - 1 is granted, 2 or more are refused. -- Free-form: publishes on `spike/test` and `te/device////m/environment` - are accepted (PUBACK). **The MQTT Service isolates clients from each - other**, so a free-form message is never delivered to another device's - subscription. A device-to-device test can't show anything, and the - two-device attempt (`tedge-spike-host2` publishing) was void for that - reason. Free-form topics are for traffic between a device and the cloud: - whether device publishes arrive is checked in 7.4 with a cloud-side - consumer, and cloud-to-device free-form traffic comes from the cloud side. -- Wildcard free-form subscriptions aren't supported by the MQTT Service at - present (per the tenant owner), **even though the SUBACK grants them** - (`spike/+`, `spike/#`, `te/…/m/+` were all granted). A granted SUBACK is - therefore no proof that a subscription works. **Firmware rule: subscribe - only to exact topics.** -- A basic-auth (bootstrap) device authenticates HTTPS with its own - credentials, because it can't get a JWT. A certificate device must get a - JWT over MQTT (`s/uat`) for HTTPS. That answers U4 for 8883. - -**Cumulocity CA enrollment (U7, host-side rehearsal):** - -- `c8y deviceregistration register-ca` with a 32-character one-time password, - then `POST /.well-known/est/simpleenroll` (Basic `:`, PKCS#10 body - without the armour lines), returns `200`. -- **The response is base64 PKCS#7** (`application/pkcs7-mime; - smime-type=certs-only`), not PEM. The firmware must unwrap it - (mbedTLS `MBEDTLS_PKCS7_C`, or pull out the single certificate). -- The issued certificate: `CN=`, issuer `O=, - CN=`, P-256, **valid for 1 year**, 330 bytes as DER, and no chain. - It connected with mTLS to both 8883 and 9883. - -**Remote access target (for Spike F):** - -- The Pi `rpi5-d83add9f145a` is at 192.168.68.72/22 on `wlan0`. `sshd` - already listens on `0.0.0.0:22` and is reachable from the LAN. -- The endpoints are on `tedge-spike-a-c6` (managed object 26211202) for now: - `pi-ssh` (PASSTHROUGH to 192.168.68.72:22; the local ssh client - authenticates, and no Pi credentials are stored in Cumulocity) and - `device-shell` (TELNET to 127.0.0.1:23). The CA-enrolled device needs the - same endpoints once it exists (Spike C). - -**Identities created on the tenant:** - -- `tedge-spike-c6`: CA certificate issued from the PC, managed object - 20211235, the C6's identity for Spike A (and F). Remote-access endpoints - `pi-ssh` and `device-shell` are configured on it. - -- `tedge-spike-a-c6`: basic auth, managed object 26211202, for Spike A. -- `tedge-spike-host`: CA certificate, managed object 20211208, a host-only - probe identity. -- `tedge-spike-basic-9883`: basic auth, created only to reproduce the 9883 - basic-auth refusal on a fresh device. -- `tedge-spike-host2`: CA certificate, a second host identity. It was - created for a device-to-device delivery test, which client isolation makes - void. diff --git a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/proposal.md b/openspec/changes/archive/2026-09-19-c8y-direct-spikes/proposal.md deleted file mode 100644 index 6fc3755..0000000 --- a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/proposal.md +++ /dev/null @@ -1,180 +0,0 @@ -## Why - -Phase 3 goal: **tedge-zephyr**, a thin-edge.io device-management client that -ships as a reusable Zephyr module, so that **any user's Zephyr application** can -include it. It should provide firmware update, remote access, telemetry, log -retrieval and configuration management. The OPC-UA, Modbus and SNMP apps in this -repo stand in for such user applications: they run their own workload, and the -client runs alongside it. The client will get two transports: - -- **direct to Cumulocity**, which nothing supports today, so it comes first; -- **via a thin-edge.io gateway** as a child device. `rpi-pico-client` and - `freertos-esp32-client` already prove that contract. - -Before we commit to a design for the direct transport, a few unknowns could -change its shape: - -- Can a 4 MB ESP32-C6 afford TLS to the Cumulocity **MQTT Service** (port - 9883)? Do its SmartREST and JWT paths behave as we expect? -- Does OTA into the already laid-out `slot1` work end to end? -- Can the device enroll itself with the **Cumulocity CA** (device-generated - one-time password, on-device key, EST `simpleenroll`)? -- Can the device act as a **remote-access gateway**, tunnelling a Cumulocity - Cloud Remote Access session to another host on its LAN (for example SSH to - a machine next to it)? Users are expected to value this feature most, and - the gateway transport can't offer it for child devices. - -This change answers those questions with measurements before any production -code is written. It also sets the rule every later phase follows: **each -device-management feature is selected at build time**, so a board with little -RAM or flash carries only the features it can afford. - -## What Changes - -- **Spike A: TLS and MQTT to Cumulocity.** An ESP32-C6 opens MQTTS to the - MQTT Service on 9883 (and, for comparison, to Core MQTT on 8883). It - subscribes to the SmartREST topics, publishes an inventory message, and - publishes one free-form telemetry message. It fetches a JWT via `s/uat`. We - measure heap (steady and peak), flash, handshake time, the TLS record size - the server needs, and reconnect behaviour after a Wi-Fi drop. Time comes - from SNTP before certificates are validated. -- **Spike B: OTA into `slot1`, without the cloud.** The device streams a - signed image over HTTP, then HTTPS, into `slot1`. It requests a test boot, - and MCUboot swaps. The image confirms itself, or it is left unconfirmed and - MCUboot must revert it. We check that `prov`, `storage` and `bootreq` are - untouched, measure swap time, and record how HTTP redirects behave. -- **Spike C: Cumulocity CA enrollment on the device.** - - The device generates a P-256 key in PSA secure storage, a one-time - password and a pre-filled registration URL. - - It builds a PKCS#10 CSR (CN = external ID) and polls - `/.well-known/est/simpleenroll` with Basic `:` until it gets a - certificate. - - It then connects to 9883 with mTLS and tries `simplereenroll`. - - The bootstrap-user basic-auth path (`s/ucr` → `70,…`) is exercised as the - fallback. -- **Spike D: module skeleton, feature selection and footprint.** - - Create `tedge-zephyr/`: a self-contained Zephyr module in its own - top-level directory (`zephyr/module.yml`, `Kconfig`, `CMakeLists.txt`, - `include/tedge/`, `samples/`, `README.md`). It is laid out to move into - its own repository and west project later without changing its contents. - - It holds the `CONFIG_TEDGE_*` Kconfig menu: umbrella, transport choice, - auth choice and one option per feature. It also holds the outline of the - `tedge_*` public API. There is no feature logic yet. - - It depends only on Zephyr (plus MCUboot for firmware update) and never on - `lib/` or `apps/` code. A bare `samples/minimal` build and a CI check - enforce that. - - Spikes A–C fill in a table of flash, static RAM and peak heap for each - feature and board. That table sets the default feature profile for each - board and app. -- **Spike F: remote access through the MCU to a LAN host.** - - `c8y_RemoteAccessConnect` arrives over the Spike A connection. - - The device checks the target against a policy, opens TCP to a Raspberry - Pi's SSH port on the same LAN, and bridges it to Cumulocity's device-side - WebSocket (WSS). - - Measured: interactive latency, `scp` throughput, heap per session, and - clean teardown when either side drops. - - A second run targets the device's own loopback telnet shell. -- **Spike E (optional): SoftAP provisioner feasibility.** Measure a SoftAP, - captive-portal and HTTP-form provisioner against the C6's 1024 KB `prov` - partition, and check whether Zephyr's ESP32 driver can run AP and station - at the same time. This informs the BLE-versus-captive-portal decision for - onboarding. -- Findings are recorded in `design.md` ("Spike results"). Each ends in a - go/no-go that feeds the proposal for the next change (`c8y-direct-core`). - -Spike code lives in a dedicated `apps/c8y-spike/` and is **not** merged into -the protocol apps or the module. The lasting outputs are: - -- the `tedge-zephyr/` skeleton: Kconfig, the public header outline, and a - minimal sample; -- the measurement tooling. - -**Target boards:** ESP32-C6 (`esp32c6_devkitc/esp32c6/hpcore`) is primary. -ESP32-S3-DevKitC-1 (`esp32s3_devkitc/esp32s3/procpu`) repeats Spike A. The -ESP32-WROOM-32 (`esp32_devkitc/esp32/procpu`) is build-only for the footprint -table. Spike F needs a second LAN host with SSH (a Raspberry Pi). **Protocols:** none change. The spike app has no industrial-protocol -frontend, and the footprint table covers the feature cost on top of the OPC-UA, -Modbus and SNMP apps. - -**Phase:** 3. This change explicitly starts the Phase 3 thin-edge.io / -Cumulocity work, now that the Phase 1/2 protocol firmware, provisioning and -MCUboot layout are in place. - -### Resource constraints - -- **RAM:** each TLS session is expected to cost roughly 35–50 KB, plus a - handshake peak. The direct transport needs up to three sessions: MQTT - always, HTTPS during a transfer, and WSS during remote access. Free heap - today is about 250–300 KB on the C6, about 170–200 KB on the S3, and only - about 70 KB on the WROOM running OPC-UA. The spikes must turn these - estimates into measurements. -- **Remote access:** each tunnel holds one TLS session for as long as it is - open, plus two bridge buffers. On the C6 and S3 the target is MQTT plus one - tunnel at the same time. Spike F confirms it. -- **Flash:** application slots are 1280 KB on 4 MB boards. The largest app - today is C6 OPC-UA at 839 KB, which leaves about 440 KB for the client, TLS - and certificate handling. The C6 provisioner is already at 943 of 1024 KB, - so **no TLS or crypto may be added to the provisioner**. At most it can - generate and store a one-time password. -- **mbedTLS defaults:** `MBEDTLS_SSL_MAX_CONTENT_LEN` defaults to 1500 in - Zephyr. Whether Cumulocity honours max-fragment-length decides whether 16 KB - input buffers are unavoidable. - -## Non-goals - -- Production device-management code: operation handling, telemetry mapping, - firmware-update policy, log upload, remote access, configuration. Spike F - proves the tunnel; the production version, with its policy and session - handling, is a follow-up change. Those are - the follow-up changes. -- The thin-edge.io gateway (child-device) transport. The feature skeleton only - reserves its Kconfig choice. -- Changing the provisioner. Spike C prints the one-time password and URL on - the console. Delivering them through Improv is the next change. -- Production signing keys, flash encryption, secure boot. -- Making the WROOM run the direct transport. It is only measured. -- Any change to the OPC-UA, Modbus or SNMP behaviour. -- Moving `tedge-zephyr` into its own repository. It stays here until the - core change has settled its API; this change only keeps it ready to move. -- A stable public API. The spike outlines `include/tedge/` and nothing more. - -## Capabilities - -### New Capabilities - -- `tedge-client-module`: tedge-zephyr as a reusable Zephyr module for any - application. It depends only on Zephyr and MCUboot. It has a namespaced - public API (`tedge_*`, `CONFIG_TEDGE_*`) with telemetry push, operation and - log/config registration, and restart and firmware-confirm hooks. It owns its - resources within set bounds (its own thread and heap, namespaced settings and - TLS credentials). The application owns connectivity. There is a minimal - sample, and the module is kept ready to extract into its own repository. -- `device-management-features`: build-time selection of device-management - features and transports, and the remote-access target policy for - forwarding to LAN hosts. Each feature is an independent Kconfig option. A - disabled feature costs nothing. The capabilities a device advertises match - its build. Invalid combinations fail at configure time. The footprint of - each feature is measured and documented, and boards get default profiles - that fit them. - -### Modified Capabilities - -(none. The spikes observe the existing `boot-layout` and `wifi-connectivity` -behaviour; they do not change it.) - -## Impact - -- **Code:** - - new `tedge-zephyr/`: the module skeleton and `samples/minimal`. Apps - consume it through `ZEPHYR_EXTRA_MODULES` for now; later it becomes a - west project in `west.yml`; - - new `apps/c8y-spike/` (throwaway); - - optionally `apps/prov-softap-spike/` (Spike E). -- **Dependencies:** Zephyr `MQTT_LIB` + `MQTT_LIB_TLS`, `HTTP_CLIENT`, `SNTP`, - `SECURE_STORAGE` (PSA ITS), mbedTLS with `X509_CSR_WRITE_C`, - `dfu/flash_img` and `dfu/mcuboot`. No new west projects. -- **External:** a Cumulocity test tenant with the `mqtt-service.smartrest` - feature enabled, the Cumulocity CA enabled, and a bootstrap user for the - fallback path. -- **Docs:** `SCOPE.md` Phase 3 roadmap. The spike results go in this change's - `design.md`. diff --git a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/specs/device-management-features/spec.md b/openspec/changes/archive/2026-09-19-c8y-direct-spikes/specs/device-management-features/spec.md deleted file mode 100644 index 7ec20ce..0000000 --- a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/specs/device-management-features/spec.md +++ /dev/null @@ -1,206 +0,0 @@ -## ADDED Requirements - -### Requirement: The device-management client is optional and off by default - -The device-management client SHALL be the `tedge-zephyr` Zephyr module, which an -application includes only when the umbrella option `CONFIG_TEDGE` enables it. -When that option is disabled, any application SHALL build exactly as it does -without the module: no extra code, RAM, threads, sockets or TLS sessions. - -#### Scenario: Application built without device management - -- **WHEN** an application is built with `CONFIG_TEDGE` disabled -- **THEN** its flash and static RAM match the same build without `tedge-zephyr` - within a few hundred bytes -- **AND** no device-management thread, socket or TLS session exists at runtime - -#### Scenario: Application built with device management - -- **WHEN** an application is built with `CONFIG_TEDGE` enabled -- **THEN** the selected transport, authentication method and features are - compiled in, and the application's own behaviour is unchanged - -### Requirement: Each feature is selected independently at build time - -Each device-management feature SHALL have its own Kconfig option, so that it can -be included in or excluded from an image independently of the others. The -features are telemetry, device-health telemetry, restart, firmware update, -shell command, log upload, remote access, configuration management and -certificate renewal. The transport (direct to Cumulocity, or via a thin-edge.io -gateway) and the authentication method (Cumulocity CA certificate, or bootstrap -basic-auth credentials) SHALL each be a Kconfig choice. A feature that has not -been implemented yet SHALL be present in the menu but not selectable. - -#### Scenario: Feature removed to save resources - -- **WHEN** an image is built with only remote access disabled -- **THEN** the remote-access code, its WebSocket client, its bridge thread and - its TLS session are absent from the image -- **AND** every other selected feature is still built and works - -#### Scenario: Minimal image - -- **WHEN** an image is built with only the connection, inventory and restart - enabled -- **THEN** it links and runs with a single MQTT/TLS session and no HTTP client - -### Requirement: Advertised capabilities match the build - -The device SHALL advertise to the cloud or gateway only the operations and -capabilities of features compiled into the running image. This covers supported -operations, log types and configuration types. When it receives an operation for -a feature that is not compiled in, the device SHALL mark that operation as -failed with a reason that names the missing feature. It SHALL NOT ignore the -operation or leave it pending. - -#### Scenario: Supported operations reflect the image - -- **WHEN** an image without the firmware-update feature connects -- **THEN** its supported-operations list does not include firmware update - -#### Scenario: Operation for an absent feature - -- **WHEN** the device receives an operation whose feature is not compiled in -- **THEN** it marks the operation as failed with a reason naming the feature - -### Requirement: Feature dependencies are enforced at configure time - -Dependencies between features and on the platform SHALL be expressed in Kconfig -(`depends on`/`select`), so that an invalid combination fails when the build is -configured, not at runtime. Firmware update SHALL require an MCUboot build. -Remote access SHALL require certificate (CA) authentication. Features that -transfer files (firmware update, log upload, configuration management) SHALL -bring in the HTTP client. Both authentication methods SHALL stay selectable. -The Cumulocity MQTT Service endpoint SHALL require certificate -authentication, and a basic-auth build SHALL use Core MQTT without further -configuration. - -#### Scenario: Firmware update without MCUboot - -- **WHEN** a build enables firmware update for a board built without sysbuild - or MCUboot -- **THEN** configuration fails, or the option cannot be selected, with a - message naming the missing MCUboot dependency - -#### Scenario: Basic authentication selects Core MQTT - -- **WHEN** a build selects bootstrap basic authentication and sets no endpoint -- **THEN** the build uses the Core MQTT endpoint, and the MQTT Service - endpoint cannot be selected - -#### Scenario: Remote access with basic authentication - -- **WHEN** a build selects bootstrap basic authentication and remote access -- **THEN** remote access cannot be selected - -### Requirement: Per-feature footprint is measured and documented - -The repository SHALL document, for each board and application, the flash, static -RAM and peak heap that each device-management feature adds. The same script -SHALL regenerate the table, as `scripts/measure_prov.sh` does for provisioning. -The table SHALL be updated whenever a feature is added or its cost changes -materially. - -#### Scenario: Footprint table regenerated - -- **WHEN** a developer runs the measurement script for a board and application -- **THEN** it prints the size of each feature's delta against a build without - that feature, using the same format as the documented table - -### Requirement: Boards ship with a feature profile that fits them - -Each supported board and application combination SHALL have a documented default -device-management profile, provided as a Kconfig overlay, that fits its measured -RAM and flash budget with headroom. A profile SHALL never select more concurrent -TLS sessions than the board's measured heap supports. - -#### Scenario: Constrained board - -- **WHEN** the ESP32-WROOM-32 OPC-UA application is built with its default - profile -- **THEN** the profile excludes the features whose measured cost exceeds the - image's free heap, and the image boots and serves OPC-UA as before - -#### Scenario: Larger board - -- **WHEN** the ESP32-C6 application is built with its default profile -- **THEN** all implemented features are included, and the documented free heap - after connecting stays above the margin set in the footprint table - -### Requirement: Remote access forwards to hosts on the local network under a target policy - -When the remote-access feature is built in, the device SHALL act as a -remote-access endpoint for Cumulocity. On a remote-access connect operation, it -SHALL open a TCP connection to the requested host and port and bridge it to -Cumulocity's device-side WebSocket until either side closes. The target MAY be -the device itself or another host on its network. Which targets are allowed -SHALL be set by a build-time policy: the device's own IPv4 subnets (default), -an explicit allow-list, or the device itself only. The host application SHALL -be able to narrow the policy further. The device SHALL refuse a target outside -the policy, and SHALL mark the operation as failed with a reason. Each tunnel -open and close SHALL be recorded as an event naming the target. The number of -concurrent sessions SHALL be capped by a Kconfig option. - -#### Scenario: SSH to a neighbouring host - -- **WHEN** a user opens a Cumulocity remote-access SSH session to the device - with an endpoint of `192.168.1.20:22`, and that host is on the device's subnet -- **THEN** the device bridges the session to that host, and the user reaches - the host's SSH server -- **AND** an event records the tunnel opening and closing with the target - -#### Scenario: Target outside the policy - -- **WHEN** a remote-access connect names a target outside the configured policy -- **THEN** the device opens no TCP connection, and marks the operation as failed - with a reason naming the policy - -#### Scenario: Session cap reached - -- **WHEN** a remote-access connect arrives while the maximum number of sessions - is open -- **THEN** the device marks the new operation as failed, and the open sessions - continue undisturbed - -### Requirement: State and telemetry use thin-edge.io topics on the MQTT Service - -The device SHALL publish telemetry, twin data, health and events on free-form -topics in thin-edge.io's `te/` topic and payload shape when it uses the direct -transport and the Cumulocity MQTT Service endpoint, so that a thin-edge.io gateway -and Cumulocity Smart Functions can map the same messages. Operations and their -status SHALL stay on SmartREST. Without free-form topics (Core MQTT, basic -authentication), the device SHALL send twin data as direct inventory updates -and telemetry as SmartREST measurements. The device SHALL republish its twin -data after every (re)connect, and SHALL NOT rely on retained messages. - -#### Scenario: Twin data on the MQTT Service - -- **WHEN** the device, connected to the MQTT Service, has twin data to report -- **THEN** it publishes it to `te/device////twin/`, and a - Smart Function can map it to the managed object - -#### Scenario: Twin data on Core MQTT - -- **WHEN** the device is connected to Core MQTT -- **THEN** it sends the same twin data as a direct inventory update of its - managed object - -### Requirement: Remote-access capacity and use are published as twin data - -When the remote-access feature is built in, the device SHALL publish -`tedge_RemoteAccess` twin data with the session limit, the number of active -sessions and the target policy. It SHALL publish it when it connects, with no -active sessions after a (re)connect, and whenever a session opens or closes. -The list of active sessions with their targets SHALL be a build-time option. - -#### Scenario: A session opens - -- **WHEN** a remote-access tunnel is established -- **THEN** the device publishes `tedge_RemoteAccess` with `activeSessions` - increased by one - -#### Scenario: The device reconnects - -- **WHEN** the device reconnects after a reboot or a lost connection -- **THEN** it publishes `tedge_RemoteAccess` with `activeSessions: 0`, - replacing any stale value diff --git a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/specs/tedge-client-module/spec.md b/openspec/changes/archive/2026-09-19-c8y-direct-spikes/specs/tedge-client-module/spec.md deleted file mode 100644 index 3d1427f..0000000 --- a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/specs/tedge-client-module/spec.md +++ /dev/null @@ -1,113 +0,0 @@ -## ADDED Requirements - -### Requirement: The client is a self-contained Zephyr module - -The client SHALL be a Zephyr module named `tedge` in the repository's top-level -`tedge-zephyr/` directory. That directory SHALL contain everything the module -needs (`zephyr/module.yml`, `Kconfig`, `CMakeLists.txt`, `include/tedge/`, -sources, `samples/`, `tests/` and `README.md`), so that it can move into its own -repository and west project without changing its contents. The module SHALL -depend only on Zephyr and its modules (and on MCUboot for firmware update). It -SHALL NOT include, link or reference anything under this repository's `lib/` or -`apps/`. - -#### Scenario: Built without this repository's code - -- **WHEN** `tedge-zephyr/samples/minimal` is built on its own for a supported - board, with the module as the only extra Zephyr module -- **THEN** the build succeeds without `lib/common` or any other `lib/` module - -#### Scenario: Dependency leak is caught - -- **WHEN** a source or CMake file under `tedge-zephyr/` references a path or - header from `lib/` or `apps/` -- **THEN** the repository's check (the minimal-sample build, or a grep in CI) - fails and names the offending file - -### Requirement: Namespaced public API and configuration - -All public symbols of the module SHALL use the `tedge_` prefix (types, functions -and macros), and its headers SHALL live under `include/tedge/`. Its Kconfig -symbols SHALL use the `TEDGE_` prefix. Its persistent settings SHALL live under -a single `tedge/` settings subtree. Its TLS credentials SHALL use a configurable -tag range. None of these SHALL collide with the host application's names. - -#### Scenario: Coexisting with an application's own TLS and settings - -- **WHEN** an application that already uses its own settings keys and TLS - credential tags includes the module -- **THEN** neither the application's nor the module's settings or credentials - are overwritten - -### Requirement: The application owns connectivity - -The module SHALL NOT bring up, configure or reconnect the network interface. -It SHALL start connecting when Zephyr reports that an interface has an IPv4 -address (network-management events). It SHALL stop and back off when the -interface goes down. The application's connectivity code (in this repository, -`lib/common/net.c`) stays responsible for Wi-Fi. - -#### Scenario: Network appears after start - -- **WHEN** the application starts the client before Wi-Fi is connected -- **THEN** the client waits without busy-looping, and it connects to the cloud - once the interface has an address - -### Requirement: Bounded resource ownership - -The module SHALL run its work on its own thread(s), each with a stack size and -priority set in Kconfig. It SHALL NOT block the system work queue. It SHALL -allocate its dynamic memory from its own bounded heap, sized in Kconfig, and not -from the application's heap. The module's documentation SHALL list what it -requires from the application's configuration: socket and poll-slot counts, -mbedTLS options, and task-watchdog channels. - -#### Scenario: Client memory is capped - -- **WHEN** the client's operations need more memory than its configured heap -- **THEN** the affected operation fails with a reason, and the application's - own allocations are unaffected - -### Requirement: Integration hooks for the host application - -The public API SHALL let the host application: - -- provide the device identity: external ID, name, type, and firmware name and - version (with a documented default for each); -- push telemetry: measurements, events and alarms; -- register its own operation handlers, log types and configuration types; -- prepare for or veto a restart requested by the cloud; -- add its own health checks that must pass before a newly updated firmware image - is confirmed; -- observe the client's state (for example to drive a status LED); -- optionally report liveness progress to the application's watchdog. - -The module SHALL NOT call `sys_reboot()` without first giving the application's -restart hook the chance to respond. - -#### Scenario: Application-supplied telemetry - -- **WHEN** the application calls the telemetry API with a measurement -- **THEN** the client publishes it on the configured transport, and no telemetry - is read from anything outside the calls the application makes - -#### Scenario: Application vetoes a restart - -- **WHEN** a restart operation arrives and the application's restart hook - declines it -- **THEN** the client does not reboot and marks the operation as failed with the - reason returned by the hook - -### Requirement: Example integrations - -The repository SHALL show how an application integrates the module, through -`tedge-zephyr/samples/minimal` and at least one of this repository's protocol -applications. Integrations SHALL use only the module's public API. In a protocol -application, the glue that maps `lib/common`'s data model and connectivity -state onto that API SHALL live in the application, not in the module. - -#### Scenario: Protocol application as a user application - -- **WHEN** a protocol application is built with `CONFIG_TEDGE` enabled -- **THEN** it serves its industrial protocol as before and also runs the - client, and the code connecting the two lives under `apps/` diff --git a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/tasks.md b/openspec/changes/archive/2026-09-19-c8y-direct-spikes/tasks.md deleted file mode 100644 index 089857e..0000000 --- a/openspec/changes/archive/2026-09-19-c8y-direct-spikes/tasks.md +++ /dev/null @@ -1,85 +0,0 @@ -## 1. Tenant and host preparation (cloud and host side) - -- [x] 1.1 Pick the Cumulocity test tenant. Confirm the tenant features `mqtt-service.smartrest` and the Cumulocity CA are enabled, and note the tenant URL in a git-ignored overlay (`c8y.local.conf`) -- [x] 1.2 Create a device user (basic auth) for Spike A, and confirm bootstrap-user credentials are available for the Spike C fallback -- [x] 1.3 Capture the tenant's TLS chain for 9883 and 8883 (`openssl s_client -showcerts`). Record the root CAs, key types and whether max-fragment-length is negotiated (`-maxfraglen 4096`) -- [x] 1.4 Prepare a Raspberry Pi with `sshd` on the same LAN as the C6, and create two Cloud Remote Access endpoints on the spike device: SSH to the Pi's `:22`, and Telnet to `127.0.0.1:23` (for Spike F) -- [x] 1.5 Check the host-side paths with `mosquitto_pub`/`mosquitto_sub`, so that a device failure can't be mistaken for a tenant problem: SmartREST `100`/`114` on 9883 and 8883, one free-form publish, and `s/uat` → `s/dat` - -## 2. Module skeleton (firmware) - -- [x] 2.1 Create `tedge-zephyr/` in the D8 layout (`zephyr/module.yml` named `tedge`, `CMakeLists.txt`, `Kconfig`, `VERSION`, `README.md`, `include/tedge/`, `profiles/`, `samples/`, `tests/`) -- [x] 2.2 Write the D2 Kconfig menu: `TEDGE` umbrella, transport, endpoint and auth choices, one option per feature, the remote-access targets choice and session cap, hidden `TEDGE_FEATURE_AVAILABLE_*` gates, `TEDGE_HTTP`, and the thread, heap and TLS-tag options -- [x] 2.3 Encode the dependencies from the spec (firmware update needs MCUboot, remote access needs CA authentication, file features select `TEDGE_HTTP`). Confirm that an invalid combination fails at configure time -- [x] 2.4 Outline `include/tedge/tedge.h` from D7 (identity, `tedge_publish_*`, `tedge_register_*`, restart, firmware-confirm and remote-access hooks, state callback, progress hook). Declarations and doc comments only, marked unstable -- [x] 2.5 Add `samples/minimal` (build-only for now) that builds with Zephyr and `tedge-zephyr` only. Build it for the C6 and `native_sim` -- [x] 2.6 Add the extraction guard: a check script (and a CI step) that fails if anything under `tedge-zephyr/` references `lib/`, `apps/` or `../` -- [x] 2.7 Show that a protocol app built with `TEDGE=n` matches its baseline flash and RAM (`build_sb_c6_modbus` against a build that adds the module through `ZEPHYR_EXTRA_MODULES`) -- [x] 2.8 Create `apps/c8y-spike/` (sysbuild, C6 and S3-DevKitC-1 board files reusing the shared layouts, spike Kconfig options `SPIKE_TLS_MQTT`/`SPIKE_OTA`/`SPIKE_ENROLL`/`SPIKE_REMOTE_ACCESS`), composing `lib/common` and `tedge-zephyr` - -## 3. Spike A: TLS and MQTT to Cumulocity (firmware) - -- [x] 3.1 SNTP before the first TLS connect. Log the time, and refuse to connect until the clock is set -- [x] 3.2 MQTTS to 9883 with mutual TLS (the `tedge-spike-c6` key and certificate from `apps/c8y-spike/credentials/`) and the embedded server CA, `MBEDTLS_SSL_MAX_CONTENT_LEN=16384`. Connect, subscribe one filter per SUBSCRIBE (exact topics `s/ds`, `s/e`, `s/dat`), publish `100`/`114`/`117` -- [x] 3.3 Handle `510` (restart): report `501`, persist a "restart pending" marker in settings, reboot, then report `503` after reconnecting -- [x] 3.4 Publish a thin-edge.io-shaped free-form measurement every 10 s (the spike app feeds it from `data_source_*`, as a user app would feed its own data). Request a JWT on `s/uat` and log that `s/dat` arrives (never the token itself) -- [x] 3.5 Measure on the C6: flash delta, static RAM, heap at idle, handshake peak and steady state, and handshake time over 10 connects. Record them in design.md -- [x] 3.6 Retry with a 4096-byte record size and max-fragment-length (U2). Record whether the handshake and a large downlink message work -- [x] 3.7 Repeat 3.2–3.5 against Core MQTT 8883 (the fallback; note its 12.9 KB CertificateRequest, and also try basic auth as `tedge-spike-a-c6`), then repeat 3.5 on the S3-DevKitC-1 -- [ ] 3.8 **DEFERRED (2026-09-19): see "Open problems" in design.md (P1); carried into `c8y-direct-core` as its reconnect requirement.** Reconnect test: drop Wi-Fi at the AP for 60 s, three times. Record the time back to "connected" and the heap after each cycle (leak check) - -## 4. Spike B: OTA into slot1 (firmware) - -- [x] 4.1 Streamed HTTP download into `slot1` with `flash_img` from a laptop `http.server`. `flash_img_check()`, `boot_request_upgrade(BOOT_UPGRADE_TEST)`, reboot. Record the download rate and the swap time -- [x] 4.2 The new image confirms itself with `boot_write_img_confirmed()` from a shell command. Verify the version persists across a further reset -- [x] 4.3 Revert run: don't confirm, reset, and check that MCUboot restores the previous image. Also check the watchdog-reset case with liveness on (the revert works; the watchdog's CPU reset hangs MCUboot on the C6, see P2) -- [x] 4.4 Verify that the Wi-Fi credentials in `storage`, the provisioner in `prov` and `bootreq` are untouched after 4.1–4.3 (the device reconnects without provisioning, and the triple-press still reaches the provisioner). `prov`/`bootreq` compared by SHA-256; the triple-press is physical and wasn't tested here -- [x] 4.5 HTTPS download of a Cumulocity binary using the JWT from 3.4. Record the peak heap with MQTT and HTTPS open at once (U1 concurrency) -- [x] 4.6 Try a redirecting URL (a GitHub release asset) and record what the HTTP client does (U6) - -## 5. Spike C: Cumulocity CA enrollment (firmware) - -- [x] 5.1 Persistent P-256 key in PSA ITS (`SECURE_STORAGE` over settings). It survives a reboot and a reflash of the app -- [x] 5.2 Generate a 32-character one-time password from the CSPRNG and store it. Print the registration URL on the console and from the `enroll` shell command -- [x] 5.3 Build a PKCS#10 CSR with `CN=` signed by the PSA key (`MBEDTLS_X509_CSR_WRITE_C`). Check it on the host with `openssl req -verify` -- [x] 5.4 Poll `simpleenroll` every 10 s with Basic `:`. On `200`, store the certificate. Log the error bodies seen before the device is registered -- [x] 5.5 mTLS connect to 9883 (and 8883) with the enrolled certificate, reusing the Spike A loop. Record the extra RAM of client-certificate authentication and how long the exported key is resident (U9) -- [x] 5.6 Call `simplereenroll` with mTLS only, then with a Bearer JWT. Record which one works (U8) -- [x] 5.7 Bootstrap fallback: `s/ucr` polling with the bootstrap user until `70,…`, store the credentials, reconnect as the device user - -## 6. Spike F: remote access to a LAN host (firmware) - -- [x] 6.1 Subscribe to and parse `c8y_RemoteAccessConnect` (SmartREST) on the Spike A connection. Log the fields received (never the connection key) and confirm the template (U12) -- [x] 6.2 Target policy check (own IPv4 subnet, allow-list, local only). A denied target fails the operation with a reason and opens no socket -- [x] 6.3 Open TCP to the target, then WSS to Cumulocity's device-side remote-access endpoint for the connection key, using mTLS or JWT (whichever works, recorded for U12) -- [x] 6.4 Bridge thread: `zsock_poll()` on both sockets, fixed per-direction buffers, `app_alive()`-style progress, clean close on EOF or error from either side, and an idle timeout -- [x] 6.5 Report the operation (executing, then successful once the tunnel is up, or failed with a reason) and publish tunnel open and close events naming the target -- [x] 6.6 Measure on the C6: heap per session with MQTT connected, interactive SSH latency, `scp` throughput for 10 MB, and the session cap (a second connect while one is open is refused). Repeat the latency check on the S3-DevKitC-1 (not repeated: the S3 is parked; the latency is dominated by the network path) -- [x] 6.7 Local target: Zephyr `shell_telnet` bound to loopback, reached through the Telnet endpoint, and not reachable from the LAN directly (reached via the device's own IP; loopback broke SNTP and shell_telnet can't bind loopback, so it WAS reachable from the LAN; see P10) -- [x] 6.8 Failure cases: Pi powered off (connect fails, operation failed), Wi-Fi drop mid-session (tunnel closes and heap returns to baseline), user closes the browser tab (bridge exits) (unreachable target and client close done; Wi-Fi drop deferred with P1) -- [x] 6.9 (later, D10) Prototype `tedge_RemoteAccess` twin data on `te/device////twin/tedge_RemoteAccess` (at connect and on tunnel open/close), and write a Smart Function that maps it to the managed object - -## 7. Cloud-side verification (host side) - -- [x] 7.1 Register the device from the printed URL. Confirm the managed object, the external ID, the certificate CN and the supported operations in Device Management (registered with the device's one-time password via `register-ca`, which does what the URL does; tedge-e8f60afc320c, managed object 60211263) -- [x] 7.2 Trigger restart from the UI and confirm the operation goes to SUCCESSFUL after the reboot (operation created through the same REST API; SUCCESSFUL in 17 s) -- [x] 7.3 Open SSH to the Pi and Telnet to the device from the Cumulocity UI; confirm the tunnel events appear on the device (via go-c8y-cli's passthrough, the same remote-access backend; the browser terminal is available via the `device-shell-ui` endpoint) -- [x] 7.4 Confirm how the free-form telemetry appears (or doesn't) in the tenant, and write down which consumer would map it (Dynamic Mapper or other) (a Cumulocity Smart Function maps it to `c8y_Environment` measurements, one per 10 s) - -## 8. Footprint and profiles (firmware and tooling) - -- [x] 8.1 Write `scripts/measure_tedge.sh` (D5): TEDGE off, `samples/minimal`, minimal profile, then each feature added on its own, for each board and app. Report flash and static RAM deltas and `zephyr.signed.bin` against the slot -- [x] 8.2 Run it for the C6, S3-DevKitC-1 and WROOM-32 (build-only) with all three apps. Add the runtime heap numbers from 3.5, 4.5, 5.5 and 6.6 -- [x] 8.3 Draft `tedge-zephyr/profiles/minimal.conf` and `full.conf`, and a per-board profile recommendation (in particular what, if anything, fits the WROOM running OPC-UA) - -## 9. Spike E (optional): SoftAP provisioner feasibility (firmware) - -- [x] 9.1 Build a SoftAP, DHCP-server, catch-all DNS and HTTP-form provisioner variant for the C6. Measure it against the 1024 KB `prov` partition with BLE removed -- [x] 9.2 Check whether AP and station at the same time works with Zephyr's ESP32 Wi-Fi driver (serve the form while a station connect tests the credentials) -- [x] 9.3 Test captive-portal behaviour on one iOS and one Android phone, and record the result (iPhone only; Android not tested) - -## 10. Wrap-up - -- [x] 10.1 Fill in "Spike results" in design.md: one go/no-go per unknown U1–U12, with the measurements -- [x] 10.2 Update the `SCOPE.md` Phase 3 roadmap with anything the results changed (transport default, board profiles, onboarding path) -- [x] 10.3 Draft the `c8y-direct-core` proposal from the results, carrying over the open problems in design.md (including the deferred 3.8): `openspec/changes/c8y-direct-core/proposal.md` diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/.openspec.yaml b/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/.openspec.yaml deleted file mode 100644 index cbd245e..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-09-20 diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/design.md b/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/design.md deleted file mode 100644 index 506add7..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/design.md +++ /dev/null @@ -1,157 +0,0 @@ -## Context - -The client enrolls itself with the Cumulocity CA (`c8y-direct-core`): a -P-256 key in PSA, a CSR signed inside PSA, EST `simpleenroll`, and the -PKCS#7 reply unwrapped to a DER certificate in settings. Renewal is the same -machinery pointed at `simplereenroll`, so this design is mostly about -*when*, and about what happens when it does not work. - -From Spike C (archived): -- `simplereenroll` with **mutual TLS alone gets 401**; with - `Authorization: Bearer ` it returns a new certificate. The token comes - from `s/uat`, which only certificate devices receive, and the client - already keeps a current one. -- The reply is the same base64 PKCS#7 as enrollment (~511 B for a 332 B - certificate), and the same ASN.1 walk unwraps it. -- Signing a CSR takes ~1.2 s on an ESP32-C6. - -## Goals / Non-Goals - -**Goals:** a certificate that renews itself well before it expires; a -failure that is visible from the cloud long before it bites; no outage -caused by the renewal itself; no new code paths for the CSR, the request or -the reply. - -**Non-Goals:** key rotation, recovering an already-expired device, and -renewing the trust anchors. - -## Decisions - -### D1: Renew on remaining lifetime, checked from the certificate itself - -The client parses its stored certificate with `mbedtls_x509_crt_parse_der` -and reads `valid_to`. It renews when the remaining lifetime is less than -`TEDGE_CERT_RENEW_BEFORE_DAYS` (default 30). The check runs once after each -connect and then daily, on the client thread; it costs a parse of a 332-byte -certificate and no network traffic. - -*Alternative: remember the issue date and renew after a fixed interval.* -Rejected: it drifts from what the CA actually issued, and a certificate -replaced by hand would be renewed on the wrong schedule. - -The realtime clock decides this, so the check only runs once the clock is -valid — the client already refuses to connect before that. - -### D2: The token is the credential, so renewal waits for a session - -`simplereenroll` needs a Bearer token, so the client renews only while it has -one: after a successful connect. A device that cannot connect cannot renew, -which is why the margin is 30 days and not 3. - -The request reuses `tedge_enroll.c`'s CSR, HTTPS and PKCS#7 code, moved into -shared internal functions rather than copied. The key is the same one: the -CSR is signed inside PSA, and the certificate that comes back belongs to the -same key pair. - -### D3: The new certificate takes effect on the next connect - -Order: store the new certificate in settings, replace the TLS credential -(delete and add at `TEDGE_TAG_DEVICE`), then disconnect so the next -connection uses it. Zephyr's `tls_credentials` are read when a TLS context is -created, so an open session keeps using the old certificate; a clean -disconnect and reconnect (the core's back-off starts at 3 s) is the simplest -way to pick up the new one, and it happens while the old certificate is still -valid. - -If storing or registering fails, the old certificate stays in place and the -client keeps running on it; the renewal is retried on the next daily check. - -### D4: Failure is loud before it is fatal - -- Each failed attempt logs why and is retried: hourly while the remaining - lifetime is under the renewal margin. -- When the remaining lifetime falls below `TEDGE_CERT_RENEW_ALARM_DAYS` - (default 7) and the certificate has still not been renewed, the client - raises a **critical alarm** naming the expiry date, and clears it once a - renewal succeeds. -- The `tedge_Certificate` twin fragment carries the expiry and the days - remaining on every connect, so a fleet's certificate health is a - query rather than an investigation: - -```json -{"expires":"2027-09-20T10:15:00Z","daysRemaining":365,"renewals":2} -``` - -An alarm needs the alarm API, which belongs to telemetry (P4) and is not -built yet; until then the client raises it as a SmartREST alarm directly -(`301,c8y_CertificateExpiring,…`), which is what the alarm API will use -anyway. - -### D5: Kconfig - -`TEDGE_CERT_RENEWAL` already depends on the direct transport and CA -authentication. It adds `TEDGE_CERT_RENEW_BEFORE_DAYS` (30), -`TEDGE_CERT_RENEW_ALARM_DAYS` (7) and `TEDGE_CERT_RENEW_CHECK_HOURS` (24), -and selects `TEDGE_HTTP` and `MBEDTLS_X509_CRT_PARSE_C`. - -## Risks / Trade-offs - -- [The device is offline for longer than the margin] → the margin is a - Kconfig value; a device that is offline for a month has a bigger problem, - and the alarm fires as soon as it reconnects. -- [The clock is wrong] → the client only checks once the clock is valid, and - a clock far in the future would renew early rather than late, which is - safe. -- [The CA refuses the renewal] → the old certificate keeps working until it - expires; the alarm makes the failure visible with days to spare. -- [Renewal storms across a fleet] → the check is daily with a random offset - within the day, so devices deployed together do not renew together. -- [A reconnect at a bad moment] → the reconnect happens after the renewal, - not during an operation; features that own a session (a tunnel, a - download) keep theirs, because they hold their own TLS contexts. - -## Migration Plan - -- Devices already enrolled need nothing: the check reads the certificate - they have. -- A device whose certificate has already expired is not this feature's - problem; it re-enrolls with a new one-time password. - -## Open Questions - -- Should a renewal also be triggerable from the cloud (an operation), for an - operator who wants to rotate a certificate on demand? - -## Results - -### Hardware (ESP32-C6, Modbus + client, 2026-09-20) - -Forced by building with a margin larger than the certificate's whole -lifetime (`TEDGE_CERT_RENEW_BEFORE_DAYS=3650`), so the client renews at once: - -``` -twin tedge_Certificate: {"expires":"2027-09-20T08:03:11Z","daysRemaining":364,"renewals":0} -certificate: 364 days left; renewing -certificate: renewed in 4111 ms (339 B), now valid for 365 days -reconnecting to pick up a new credential -state: connected -twin tedge_Certificate: {"expires":"2027-09-20T08:04:46Z","daysRemaining":364,"renewals":1} -``` - -| Check | Result | -|---|---| -| Renewal through `simplereenroll` with the Bearer token | a new certificate in **4.1 s**, same key | -| The new certificate is used | the client reconnects 8 s later and the expiry moves forward; Cumulocity shows the device CONNECTED throughout | -| Twin data | expiry, days remaining and a renewal count, on every connect and after each renewal | -| Footprint (C6, every feature) | 812 KB text, 116 KB of libc heap left: renewal adds ~3 KB over firmware update, because the CSR, EST and PKCS#7 code is shared with enrollment | - -**One bug found and fixed:** the first twin message reported -`"daysRemaining":-1`, because the days were computed before the expiry had -been read from the certificate. - -**Not exercised on hardware:** a renewal the server refuses, and the alarm. -Forcing either needs the tenant to reject a valid request, so the decision -(renew / retry / alarm / wait, including an expired certificate and a clock -that is not set) is covered by unit tests instead, and the failure path by -reading it: a failure leaves the stored certificate and the registered -credential untouched. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/proposal.md b/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/proposal.md deleted file mode 100644 index 892a73f..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/proposal.md +++ /dev/null @@ -1,81 +0,0 @@ -## Why - -A device certificate expires. When it does, the device cannot connect, and -cannot be told to do anything about it: no remote access, no firmware update, -no operation of any kind. Recovering a fleet then means visiting each device. -This is the one feature whose absence turns every other feature off, on a -date that is already fixed. - -`c8y-direct-spikes` answered how renewal works (unknown U8): EST -`simplereenroll` refuses a client certificate alone with 401, and accepts a -Bearer JWT, which only certificate devices can get. The client already keeps -such a token, already builds CSRs inside PSA, and already unwraps the PKCS#7 -reply — enrollment does all three. This change is mostly the decision of -*when* to renew, and making sure a renewal that fails is visible long before -it matters. Roadmap step P6, brought forward. - -## What Changes - -- **`CONFIG_TEDGE_CERT_RENEWAL` becomes real.** The client reads the - expiry from its own certificate and renews it through `simplereenroll` - with its Bearer token once the remaining lifetime falls below - `TEDGE_CERT_RENEW_BEFORE_DAYS` (default 30). -- **The new certificate is used without an outage:** it is stored, the TLS - credential is replaced, and the client reconnects on its next cycle. A - renewal that fails leaves the old certificate in place and is retried. -- **Expiry is visible from the cloud:** a `tedge_Certificate` twin fragment - with the expiry date and the days remaining, published on every connect - and after a renewal, so an operator can see a fleet's certificate health - without asking each device. -- **A certificate that is about to expire raises an alarm** rather than - failing quietly: if renewal has not succeeded by - `TEDGE_CERT_RENEW_ALARM_DAYS` (default 7), the client raises a critical - alarm naming the expiry date. -- **The check is cheap and rare:** once per connect and then daily, reading - the stored certificate; no extra network traffic unless a renewal is due. - -## Non-goals - -- Rotating the key. The renewal reuses the device key, as the spike did; - a key rotation is a separate decision with its own storage and rollback - story. -- Renewing anything else (the server trust anchors, a tenant CA change). -- Basic-auth devices: they have no certificate and no token, so the feature - cannot apply, and Kconfig already refuses the combination. -- Recovering a device whose certificate has already expired: that needs - re-enrollment with a new one-time password, which is onboarding's job. - -## Resource constraints - -From the spikes, on top of what onboarding already costs: - -| Item | Cost | -|---|---| -| Text | small: the CSR, EST and PKCS#7 code is already built for enrollment; this adds the expiry check and the schedule | -| RAM | the same transient buffers as an enrollment, from the module heap, only while renewing | -| Network | one HTTPS request per renewal, plus a reconnect | -| Time | a CSR signs in ~1.2 s on an ESP32-C6; the request is one round trip | - -## Capabilities - -### New Capabilities - -- `tedge-cert-renewal`: when the client renews, how it authenticates, what - it does with the result, and how an operator sees that it is working. - -### Modified Capabilities - -- `device-management-features`: certificate renewal stops being an - unimplemented feature; the dependency on certificate authentication - becomes load-bearing. - -## Impact - -- `tedge-zephyr/src/`: a new `tedge_cert_renew.c`, with the CSR, EST request - and PKCS#7 unwrap shared with `tedge_enroll.c` rather than copied. -- Zephyr facilities: the ones enrollment already uses (PSA, mbedTLS X.509 - write and parse, `http_client`, `tls_credentials`, settings). -- Cumulocity: the tenant's `certificate-authority` feature, and the - `/.well-known/est/simplereenroll` endpoint. -- The README gains what an operator has to know: when renewal happens, what - the twin fragment says, and what the alarm means. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/specs/device-management-features/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/specs/device-management-features/spec.md deleted file mode 100644 index 7fa88de..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/specs/device-management-features/spec.md +++ /dev/null @@ -1,40 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Feature dependencies are enforced at configure time - -Dependencies between features and on the platform SHALL be expressed in Kconfig -(`depends on`/`select`), so that an invalid combination fails when the build is -configured, not at runtime. Firmware update SHALL require an MCUboot build. -Remote access and certificate renewal SHALL require certificate (CA) -authentication, because both need a token that only certificate devices -receive. Features that -transfer files (firmware update, log upload, configuration management) SHALL -bring in the HTTP client. Both authentication methods SHALL stay selectable. -The Cumulocity MQTT Service endpoint SHALL require certificate -authentication, and a basic-auth build SHALL use Core MQTT without further -configuration. - -#### Scenario: Firmware update without MCUboot - -- **WHEN** a build enables firmware update for a board built without sysbuild - or MCUboot -- **THEN** configuration fails, or the option cannot be selected, with a - message naming the missing MCUboot dependency - -#### Scenario: Basic authentication selects Core MQTT - -- **WHEN** a build selects bootstrap basic authentication and sets no endpoint -- **THEN** the build uses the Core MQTT endpoint, and the MQTT Service - endpoint cannot be selected - -#### Scenario: Remote access with basic authentication - -- **WHEN** a build selects bootstrap basic authentication and remote access -- **THEN** remote access cannot be selected - -#### Scenario: Certificate renewal with basic authentication - -- **WHEN** a build selects bootstrap basic authentication and certificate - renewal -- **THEN** certificate renewal cannot be selected, since such a device has - no certificate to renew diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/specs/tedge-cert-renewal/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/specs/tedge-cert-renewal/spec.md deleted file mode 100644 index e1dbb5a..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/specs/tedge-cert-renewal/spec.md +++ /dev/null @@ -1,81 +0,0 @@ -## ADDED Requirements - -### Requirement: The device renews its certificate before it expires - -When the certificate-renewal feature is built in, the client SHALL read the -expiry of its own certificate and renew it through the cloud's -re-enrollment endpoint once the remaining lifetime falls below a configured -margin. It SHALL authenticate the request with the token it holds for the -tenant, because the endpoint refuses a client certificate alone. It SHALL -keep the same key. - -#### Scenario: The margin is reached - -- **WHEN** a connected device's certificate has less remaining lifetime than - the configured margin -- **THEN** the client requests a new certificate, stores it, and uses it for - its next connection - -#### Scenario: Well before the margin - -- **WHEN** the certificate has more remaining lifetime than the margin -- **THEN** the client makes no renewal request - -#### Scenario: No token yet - -- **WHEN** the device has not obtained a token for the tenant -- **THEN** the client waits rather than sending a request that would be - refused - -### Requirement: A failed renewal never costs the working certificate - -A renewal that fails at any step SHALL leave the existing certificate and -key in place and in use, and SHALL be retried. The client SHALL remain -connected throughout. - -#### Scenario: The endpoint refuses the request - -- **WHEN** the re-enrollment request fails or returns an error -- **THEN** the device keeps using its current certificate, stays connected, - and tries again later - -#### Scenario: The reply cannot be read - -- **WHEN** the reply arrives but no certificate can be taken from it -- **THEN** nothing is stored, and the current certificate stays in use - -### Requirement: An expiring certificate is visible and raises an alarm - -The client SHALL publish the certificate's expiry and the days remaining as -twin data on every connect and after every renewal. When the remaining -lifetime falls below a second, shorter threshold and the certificate has -still not been renewed, the client SHALL raise a critical alarm naming the -expiry, and SHALL clear it once a renewal succeeds. - -#### Scenario: An operator reviews a fleet - -- **WHEN** devices are connected -- **THEN** each one's managed object carries the expiry date and the days - remaining - -#### Scenario: Renewal keeps failing - -- **WHEN** renewal has not succeeded and the remaining lifetime falls below - the alarm threshold -- **THEN** the device raises a critical alarm naming the expiry date - -#### Scenario: Renewal succeeds after an alarm - -- **WHEN** a renewal succeeds while the alarm is raised -- **THEN** the client clears the alarm and publishes the new expiry - -### Requirement: Renewals are spread out across a fleet - -The periodic check SHALL be spread within its interval, so that devices -deployed and enrolled together do not all renew at the same moment. - -#### Scenario: A fleet enrolled on the same day - -- **WHEN** many devices reach the renewal margin on the same day -- **THEN** their renewal requests are spread across the checking interval - rather than arriving together diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/tasks.md b/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/tasks.md deleted file mode 100644 index 60e6161..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-cert-renewal/tasks.md +++ /dev/null @@ -1,35 +0,0 @@ -## 1. Share the enrollment machinery - -- [x] 1.1 Move the CSR builder, the EST request and the PKCS#7 unwrap in `tedge_enroll.c` behind internal functions both enrollment and renewal call, with the transient buffers still from the module heap -- [x] 1.2 Add an internal call that replaces the device's TLS credential with a new certificate (delete, add, keep the same key) - -## 2. The renewal itself - -- [x] 2.1 `tedge_cert_renew.c`: read the stored certificate's expiry with `mbedtls_x509_crt_parse_der` (D1) -- [x] 2.2 The schedule: check after each connect and then every `TEDGE_CERT_RENEW_CHECK_HOURS`, spread within the interval (D1, fleet spreading) -- [x] 2.3 Renew through `simplereenroll` with the Bearer token; keep the same key (D2) -- [x] 2.4 Store the new certificate, replace the credential, and disconnect so the next connect uses it (D3) -- [x] 2.5 Failure handling: keep the old certificate, stay connected, retry hourly inside the margin (D4) -- [x] 2.6 `tedge_Certificate` twin data (expiry, days remaining, renewal count) on every connect and after a renewal -- [x] 2.7 The critical alarm below `TEDGE_CERT_RENEW_ALARM_DAYS`, cleared after a successful renewal -- [x] 2.8 Kconfig: `TEDGE_CERT_RENEW_BEFORE_DAYS`, `_ALARM_DAYS`, `_CHECK_HOURS`; select what it needs; drop the experimental gate - -## 3. Tests - -- [x] 3.1 Unit tests: the renewal decision (well before, inside the margin, inside the alarm threshold, already expired) against a fixed clock -- [x] 3.2 Unit test: the twin payload, including a certificate that expires today — **not done**: the payload builder reads the stored certificate and the clock, so it is not pure; the decision it feeds is unit-tested instead -- [x] 3.3 Kconfig cases: renewal needs CA authentication; absent when the feature is off - -## 4. Hardware verification - -- [x] 4.1 C6: force a renewal with a large margin, and confirm a new certificate is issued, stored and used on the next connect -- [x] 4.2 The device reconnects with the new certificate and keeps working (operations still arrive) -- [x] 4.3 The twin fragment shows the new expiry, and the renewal count increases -- [x] 4.4 A failed renewal (a wrong endpoint) leaves the device connected on its old certificate and retries — **not run on hardware**: forcing a server-side refusal needs tenant changes; covered by the decision unit tests and by inspection -- [x] 4.5 The alarm is raised when the threshold is crossed without a renewal, and cleared afterwards — **not run on hardware**, same reason; the threshold logic is unit-tested -- [x] 4.6 Footprint row with renewal enabled - -## 5. Wrap-up - -- [x] 5.1 README: when renewal happens, what the twin says, what the alarm means, and what an operator must do if a device misses its window -- [x] 5.2 Profiles: renewal in `full.conf`; record the results in design.md and update the roadmap diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-core/.openspec.yaml b/openspec/changes/archive/2026-09-20-c8y-direct-core/.openspec.yaml deleted file mode 100644 index eaa6b1c..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-core/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-09-19 diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-core/design.md b/openspec/changes/archive/2026-09-20-c8y-direct-core/design.md deleted file mode 100644 index e367b27..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-core/design.md +++ /dev/null @@ -1,381 +0,0 @@ -## Context - -`c8y-direct-spikes` (archived 2026-09-19) proved the direct transport on the -ESP32-C6 and ESP32-S3: Cumulocity CA enrollment on the device, mTLS to the MQTT -Service on 9883, SmartREST operations, restart, firmware update and remote -access. All of it lives in `apps/c8y-spike`, a throwaway app with spike -shortcuts (one file per spike, globals shared between threads, a shell for -driving tests, oversized measuring buffers, `lib/common` calls). - -`tedge-zephyr/` is still a skeleton: the Kconfig menu, `VERSION`, -`tedge_version()`, an unstable outline in `include/tedge/tedge.h`, the -independence check and draft profiles. The baseline specs -`tedge-client-module` and `device-management-features` describe the -contract; the spike results live in the archived change's `design.md` -("Spike results", U1–U12, P1–P13). - -This change turns the parts every later feature needs into module code: -onboarding, the connection, and device state. It is roadmap step P1. - -Constraints: -- **The module depends only on Zephyr** and its modules (hal_espressif, - mbedTLS/TF-PSA-Crypto, MCUboot). No `lib/`, no `apps/`. -- **The application owns the network.** The module reacts to network events. -- **Budgets** (from the spikes): one TLS session is 51.8 KB peak / 34.8 KB - connected with 16 KB records; the text of TLS + MQTT + SNTP + DNS is about - 130 KB and enrollment about 18 KB. Next to OPC-UA on the C6, about 256 KB of - libc heap is left for everything else. - -## Goals / Non-Goals - -**Goals:** -- A public `tedge_*` API that is stable enough for P2–P7 to build on. -- CA onboarding (primary) and bootstrap basic auth (fallback) inside the - module, with the registration URL handed to the application. -- One robust MQTT session to 9883 (CA) or 8883 (basic auth), recovering by - itself from network loss (P1) without leaking TLS heap or TCP contexts. -- Inventory, supported operations, required interval, restart and state on - `te/` topics. -- `samples/minimal` and the Modbus app on the C6 integrate it; `CONFIG_TEDGE=n` - builds stay byte-identical. - -**Non-Goals:** remote access, firmware update, telemetry API, logs, config, -certificate renewal (P2–P7); the gateway transport (P8); a production -SoftAP provisioner; hardware key protection (P9). The `lib/common` reset fix -is a separate change. - -## Decisions - -### D1: Module layout, with an internal transport interface - -``` -tedge-zephyr/ - include/tedge/tedge.h public API (single header) - src/tedge_core.c lifecycle, client thread, state machine, API queue - src/tedge_c8y.c Cumulocity transport: MQTT session, SmartREST - dispatch, JWT, twin/inventory publishing - src/tedge_smartrest.c CSV parsing/quoting (no I/O; unit-tested) - src/tedge_enroll.c CA onboarding: key, OTP, CSR, EST, PKCS#7 - src/tedge_bootstrap.c bootstrap-user onboarding (s/ucr -> 70) - src/tedge_time.c SNTP, unless the clock is already valid - src/tedge_platform.c default reset and device-ID helpers - src/tedge_internal.h the transport ops and shared internals - certs/ default trust anchors (overridable) -``` - -The feature code (restart now; firmware, remote access later) talks to a small -`struct tedge_transport` (`connect`, `poll`, `publish_twin`, `op_executing`, -`op_succeeded`, `op_failed`, `subscribe_ops`). Only `tedge_c8y.c` implements it -in this change. The gateway transport (P8) adds a second implementation. - -*Alternative: port `apps/c8y-spike` files as they are.* Rejected: they share -globals across threads and mix measurement code with behaviour. - -### D2: One client thread owns the MQTT client - -Zephyr's MQTT client isn't thread-safe (the spike needed a message queue for -the remote-access thread). The module's single client thread -(`TEDGE_THREAD_STACK_SIZE`, `TEDGE_THREAD_PRIORITY`) owns the socket, the -MQTT context and all publishing. Public API calls from other threads (later: -telemetry, operation results) go through a bounded `k_msgq`; they never touch -the socket. Hooks run on the client thread, as the header already states. - -The spike measured the MQTT thread at 3.0–3.6 KB peak including enrollment -and the mTLS handshake. The default stack becomes 6144 (2× headroom, as on -the WROOM enabler). - -### D3: Memory: a module heap for transients, mbedTLS's global heap for TLS - -- **Module heap** (`TEDGE_HEAP_SIZE`, a `k_heap`): transient buffers only. - These are the EST response (up to 4 KB), the CSR/PEM work buffers (~2.5 KB) - and queued API messages. Allocations fail with a reason instead of touching - the application's heap. -- **TLS memory** comes from Zephyr's mbedTLS heap (`MBEDTLS_HEAP_SIZE`), which - is global and belongs to the application's configuration; a module can't - have a private mbedTLS heap. The README and profiles state the need: 56 KB - for one session with 16 KB records (40 KB with 8 KB), with +40 KB per - concurrent session in later features. On the S3 the profile puts it in - PSRAM (`MBEDTLS_HEAP_CUSTOM_SECTION`). -- **Static buffers** stay small: MQTT rx/tx 2 KB each, JWT 1 KB, identity - strings. - -This needs a MODIFIED `Bounded resource ownership` requirement: TLS memory is -the application's mbedTLS heap, sized from the documented figures. - -### D4: Onboarding: CA enrollment first, bootstrap as a build choice - -`TEDGE_AUTH_C8Y_CA` (default): -1. A persistent P-256 key in PSA ITS (`SECURE_STORAGE`, ITS over settings) at - `TEDGE_PSA_KEY_ID` (default `0x0007E571`, inside `PSA_KEY_ID_USER_MAX`). -2. A 32-character one-time password from `psa_generate_random`, stored at - `tedge/enroll/otp` until the certificate arrives. -3. The registration URL - `https:///apps/devicemanagement/index.html#/deviceregistration?externalId=&one-time-password=` - is available from `tedge_registration_url()`, and the state becomes - `TEDGE_STATE_AWAITING_REGISTRATION` (the `on_state` hook tells the app to - show it). It is never logged by the module above DEBUG. -4. CSR signed inside PSA (`mbedtls_pk_wrap_psa`), EST `simpleenroll` with - Basic `:`, polled every `TEDGE_ENROLL_POLL_S` (10 s) with back-off - to 60 s, until 200. The PKCS#7 reply is unwrapped with the spike's ASN.1 - walk (no PKCS#7 module). -5. Certificate at `tedge/enroll/cert`; OTP deleted; credentials registered at - `TEDGE_TLS_TAG_BASE + 1` (key exported as DER for `tls_credentials`, P9). - -`TEDGE_AUTH_BOOTSTRAP`: connect to 8883 as the bootstrap user, subscribe -`s/dcr`, poll `s/ucr` every 5 s, store `70,,,` at -`tedge/bootstrap/*`, reconnect as the device. Bootstrap credentials come from -Kconfig (`TEDGE_BOOTSTRAP_USER`/`_PASSWORD`, meant for a fleet overlay) or at -runtime through `tedge_set_bootstrap_credentials()`. - -**Tenant host:** `TEDGE_C8Y_URL` is the default. A settings value at -`tedge/c8y/url`, set through `tedge_set_c8y_url()`, overrides it, so one image -serves several tenants (for example set from the provisioner hand-off later). - -**Trust anchors:** the module embeds `certs/*.pem` listed in -`TEDGE_C8Y_CA_FILES` (default: Go Daddy Root G2, which the spikes' tenant -chain ends in) at `TEDGE_TLS_TAG_BASE + 0`. An application points the option -at its own files for other Cumulocity instances. - -*Alternative: enrollment in the provisioner image.* It has no TLS stack today, -and would need one only for this. Rejected for now; it stays an option for -tight boards. - -### D5: Connection state machine and back-off - -``` -STOPPED -> WAITING_NETWORK --(IPv4 address)--> WAITING_TIME --(clock ok)--> - [AWAITING_REGISTRATION] --> CONNECTING --(CONNACK)--> CONNECTED -CONNECTED --(disconnect / keepalive loss / net down)--> back-off --> CONNECTING -any --(net down)--> WAITING_NETWORK -``` - -- Network: `NET_EVENT_L4_CONNECTED`/`DISCONNECTED` from the connection - manager, falling back to `NET_EVENT_IPV4_ADDR_ADD/DEL`. The module never - touches Wi-Fi. -- Time: skip SNTP when the realtime clock is already after the build date - (the app may have set it); otherwise query `TEDGE_SNTP_SERVER`. -- Back-off: 3 s, doubling to `TEDGE_RECONNECT_BACKOFF_MAX_S` (default 300 s), - ±20% jitter, reset after 60 s connected. The 3 s floor comes from the - spike's TCP-context exhaustion; the README states the extra `NET_MAX_CONN` - (+4) the client needs. -- Keepalive 60 s; `117,`. -- **Client-ID takeover:** three sessions in a row closed by the broker within - 10 s of CONNACK log a warning, "another client may be using this ID", and - the back-off continues (spike B finding 6). -- Every exit path releases the TLS socket; tests check that the TLS heap and - `NET_MAX_CONN` usage return to their pre-connect baseline (P1). - -### D6: Session start: create, then subscribe, then state - -On CONNACK: `100,,` first, then one SUBSCRIBE per topic (`s/ds`, -`s/e`, `s/dat`, `devicecontrol/notifications` when a feature needs JSON -operations), then `114,`, `115` (when firmware update is built -in, later), `117`, `s/uat`, then the twin fragments. A SUBACK with 0x80 -(refused while the device doesn't exist yet, P8) is retried after 5 s up to -five times rather than treated as final. The supported-operations list is -assembled from the features compiled in (`TEDGE_RESTART` → `c8y_Restart`; -later features add theirs) plus `tedge_register_operation()` entries. - -An operation for a feature that isn't built in gets `502,," is -not built into this image"` (baseline requirement). - -### D7: JWT as an internal service - -`s/uat` after every connect and 50 minutes after each token; the latest token -is kept in a 1 KB buffer (`tedge_internal.h`: `tedge_c8y_jwt()`). Only -certificate devices get one; basic-auth builds skip the request (the MQTT -Service refuses it). Nothing uses it in this change except that later -features and `simplereenroll` (P6) need it; it is included now because the -session start owns it. - -### D8: Restart through a hook and a full-system reset - -`510` → `restart_request` hook (veto → `502` with the hook's reason) → -`501` → marker `tedge/restart` → graceful MQTT disconnect → reset. After the -reboot, the next CONNACK sends `503,c8y_Restart` and clears the marker. This -was verified in the spike: SUCCESSFUL after 17–32 s, and it survives a hung -reboot. - -The reset is `tedge_hooks.reset` if the application provides it, else -`tedge_platform_reset()`: on Espressif SoCs `esp_rom_software_reset_system()` -(a full-system reset; the CPU reset from `sys_reboot()` hangs MCUboot on the -C6, P2), else `sys_reboot(SYS_REBOOT_COLD)`. This satisfies the baseline -rule that the module never reboots without the application's hook. - -### D9: State on `te/` topics, with a Core MQTT fallback - -- Twin data: `te/device////twin/` (QoS 1) on the MQTT Service, - or `inventory/managedObjects/update/` with `{"":…}` on Core - MQTT. It is republished on every connect, with no reliance on retained - messages. The first built-in fragment is `tedge_Agent` - (`{"name":"tedge-zephyr","version":"","transport":"c8y-mqtt-service"}`), - so a reference Smart Function has something to map from the start. The - application can add its own through `tedge_publish_twin()`. -- Health: `te/device//service/tedge-zephyr/status/health` - `{"status":"up","time":}` on connect. Availability in Cumulocity - keeps using `117` and the connection itself; a last-will "down" is only - added if the MQTT Service is shown to deliver it (open question). -- The module ships reference Smart Functions under - `tedge-zephyr/smartfunctions/` (twin → inventory fragment, health → - inventory), starting from the tenant owner's working `tedge_RemoteAccess` - function (P12). - -### D10: Public API for this change - -Implemented now, and marked stable for P1: -`tedge_version`, `tedge_init`, `tedge_start`, `tedge_stop`, -`tedge_get_state`, `tedge_registration_url`, `tedge_set_c8y_url`, -`tedge_set_bootstrap_credentials`, `tedge_publish_twin`, -`tedge_register_operation` / `tedge_operation_*` (custom SmartREST -operations: the dispatcher needs them anyway), and hooks `on_state`, -`restart_request`, `reset` (new), `progress`. - -Still declared but unimplemented (they return `-ENOTSUP`, and the header -says which change implements them): telemetry, events and alarms (P4), -log and config types (P5/P7), `firmware_confirm_check` (P3), -`remote_access_allow` (P2). `TEDGE_API_UNSTABLE` stays until P3, since -firmware update will still add to the hooks. - -### D11: Integrations and tests - -- **`samples/minimal`:** Wi-Fi from its own tiny `net_mgmt` connect (no - `lib/common`), `tedge_init(NULL, &hooks)`, logs state changes and the - registration URL. -- **`apps/modbus-server` on the C6:** `apps/modbus-server/src/tedge_glue.c` - maps `lib/common` identity (firmware name/version) and the status LED - onto the API, and uses `boot_request_reboot()` as the reset hook. It is - enabled by a C6 board overlay plus `tedge-zephyr/profiles/full.conf`. -- **Unit tests (`native_sim`, ztest):** SmartREST parsing and quoting, the - PKCS#7 unwrap (fixture from the spike's captured reply), the back-off - sequence, the twin JSON and topic builders, and the settings round-trip. -- **Kconfig tests:** the existing `tests/kconfig` cases plus new ones for the - added options. -- **Hardware checks (C6, then S3):** enrollment from an erased device; restart - from Cumulocity; the P1 reconnect test (Wi-Fi drop 3 × 60 s via the - application's shell `wifi disconnect`, plus one real AP drop), recording - time to CONNECTED and TLS-heap/TCP-context baselines; the footprint rerun - with `scripts/measure_tedge.sh` (C6 Modbus with and without `TEDGE`). - -## Risks / Trade-offs - -- [SmartREST on the MQTT Service is Public Preview] → Core MQTT stays a - one-option switch (`TEDGE_C8Y_CORE_MQTT`), tested on hardware in this - change. -- [The global mbedTLS heap is shared with the application's own TLS] → The - README documents the client's need; the footprint script reports it; the - module fails the connect with a clear log when a handshake runs out. -- [Trust anchors differ per Cumulocity instance] → Configurable - `TEDGE_C8Y_CA_FILES`; a wrong anchor fails with a named - certificate-verification error, not a timeout. -- [The device key is only obfuscated (P9)] → Documented in the README as a - development-grade limitation; no production claim. -- [8 KB records have ~2 KB margin (P3)] → Profiles default to 16 KB; 8 KB is - only in the enabler profile, with the risk written next to it. -- [Reconnect behaviour was never measured (P1)] → The state machine is - designed around it and the hardware check is a task, not an afterthought. -- [A hook blocks the client thread] → Documented: hooks must return promptly; - the `progress` hook lets the app's watchdog notice a stuck client. - -## Migration Plan - -- Apps without `CONFIG_TEDGE` are unaffected: the byte-identical check in - `tests/kconfig` / CI stays. -- `apps/c8y-spike` keeps building against its own sources. It is not ported - to the module in this change. It remains the reference for P2/P3 and is - deleted once those land. -- Devices enrolled by the spike (`spike/enroll/*` settings, key - `0x0007E571`) are not migrated. The module uses `tedge/…` keys, so a - spike-enrolled device enrolls again (a new registration). Test devices only. - -## Open Questions - -- ~~**Smart Function format and delivery (P12)**~~ **Settled 2026-09-20:** - the tenant owner writes and owns the functions; the module documents the - topics and payloads instead of shipping any. The mapping they built and - verified takes `te/device////twin/tedge_RemoteAccess` and writes it - unchanged under a `remoteAccess` fragment on the managed object. Earlier - note: the tenant owner's - `tedge_RemoteAccess` function is meant to write the payload to the device's - managed object under a `remoteAccess` fragment, but it is untested and the - fragment has not appeared (the managed object's `lastUpdated` predates the - device's publishes), so it probably has a bug. The reference set needs a - function that is verified end to end, plus a way to install one (file or - CLI). The fragment name the module documents should also be settled: - `remoteAccess` (the owner's choice) or the published `tedge_RemoteAccess`. -- **Last will on the MQTT Service:** does it deliver an LWT to Smart - Functions? This decides whether health "down" is possible without a - heartbeat check. -- **Trust anchors for other Cumulocity instances** (public cloud regions, - on-prem): which roots should ship by default? -- **Registration URL delivery:** the BLE provisioner's Improv result and the - SoftAP success page could both carry it; that's a provisioner change after - this one. - -## Results - -### Hardware verification (2026-09-19/20) - -**ESP32-C6, Modbus + client (tasks 8.1, 8.2, 8.4).** - -| Step | Result | -|---|---| -| Enrollment from erased storage | key generated, registration URL shown by the application, certificate stored 4 s after `register-ca`, no reboot needed | -| MQTT Service (9883), mutual TLS | connected in 7.6 s (first connect), 3.3 s after a restart | -| Cumulocity | name, `c8y_SupportedOperations: [c8y_Restart]`, `responseInterval: 60` | -| Restart from the cloud | SUCCESSFUL; the reset hook (`boot_request_reboot`) avoided the MCUboot hang (P2); the marker survived the reboot and was reported after reconnecting | -| Core MQTT (8883) with the same certificate | connected in 3.4 s; `tedge_Agent` arrived as a **direct inventory update** on the managed object | -| Bootstrap onboarding (`tedge-boote8f60afc320c`) | registration went to PENDING_ACCEPTANCE, was accepted, credentials stored, reconnected as `t…/device_tedge-boote8f60afc320c` in 11 s total, inventory and twin as above | -| `CONFIG_TEDGE=n` | identical text/data/bss to a build without the module; only 4 bytes differ, inside the application's build-timestamp string | - -**ESP32-S3-DevKitC-1, Modbus + client, TLS heap in PSRAM (task 8.5).** The -96 KB mbedTLS heap sits at 0x3c0a0000 (PSRAM) and 139 KB of internal libc -heap is left. Enrollment, the 9883 connection (4.9 s first, 2.4 s after the -restart) and a cloud restart (SUCCESSFUL) all behaved as on the C6. - -**Two findings:** -- **The bootstrap session's MQTT client ID must be the device's external - ID**, not the bootstrap user. With `management/devicebootstrap` as the - client ID the registration stayed WAITING_FOR_CONNECTION forever, because - Cumulocity matches the registration by client ID. Fixed. -- **The managed object's `type` comes from whoever creates the device.** - `register-ca` creates it as `thin-edge.io`, and the client's later `100` - does not change it; a device the client creates itself (bootstrap) gets - the type the client sends (`thin-edge.io-zephyr-modbus`). If the type - matters, the client has to set it as an inventory update, which is a - decision for the twin work. - -### Reconnect after a network drop (task 8.3, spikes problem P1) - -Three 60-second outages on the C6, the interface admin-down so the -application could not re-associate (`net iface down 1`, then up): - -| Cycle | Back to connected | TLS heap after | TCP contexts | -|---|---|---|---| -| 1 | 69.0 s | 34,828 B (peak 51,820) | unchanged | -| 2 | 67.3 s | identical | unchanged | -| 3 | 70.9 s | identical | unchanged | - -The client is connected again 7–11 s after the network returns, and neither -the TLS heap nor the TCP contexts grow across cycles. **P1 is answered.** - -Two earlier attempts are worth recording, because they measured something -else: `wifi disconnect` alone only flaps the link (`lib/common` re-associates -in about 2 s) and the TLS session survives it untouched, so the client never -notices. A test of reconnect behaviour has to take the interface down. - -### Footprint (task 8.6) - -`MODE=module scripts/measure_tedge.sh` builds each application with and -without the client: - -| Build | Image | text | libc heap left | TLS heap | -|---|---|---|---|---| -| C6 Modbus, no client | 733,947 | 616,328 | 295,936 | — | -| C6 Modbus + client | 882,556 | 775,148 | 180,048 | 65,536 | -| C6 Modbus + client + remote access | 885,499 | 798,480 | 129,920 | 98,304 | -| S3 Modbus, no client | 583,914 | 502,188 | 195,964 | — | -| S3 Modbus + client (TLS heap in PSRAM) | 731,740 | 646,268 | 142,452 | 98,304 | -| C6 `samples/minimal` | 865,888 | 748,124 | 205,680 | 57,344 | - -The client costs about **159 KB of flash**; of the RAM, the TLS heap is the -application's to size. On the S3 it sits in PSRAM and costs no internal RAM. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-core/proposal.md b/openspec/changes/archive/2026-09-20-c8y-direct-core/proposal.md deleted file mode 100644 index a76b0a8..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-core/proposal.md +++ /dev/null @@ -1,125 +0,0 @@ -## Why - -`c8y-direct-spikes` answered go: a Zephyr device can enroll with the -Cumulocity CA, hold an mTLS session to the MQTT Service on 9883, and handle -SmartREST operations, on the ESP32-C6 and on the ESP32-S3 with PSRAM. All of -that lives in a throwaway app (`apps/c8y-spike`). This change builds the first -real part of **tedge-zephyr** (Phase 3, roadmap step P1): the module, its -public API, onboarding and the connection that every later feature (remote -access, firmware update, telemetry) runs on. - -## What Changes - -- **The `tedge` module becomes real.** `tedge-zephyr/` gains a public - `tedge_*` API (**BREAKING** for the unstable outline in - `include/tedge/tedge.h`, which nothing uses yet): init and start, a - connection-state callback, identity (external ID, default `tedge-`), - and the hooks the host application needs (restart veto, platform reset). - New functions: `tedge_set_c8y_url()`, `tedge_set_bootstrap_credentials()` - and `tedge_publish_twin()`; not-yet-implemented calls return `-ENOTSUP` - (design.md D10). - The module owns its thread, a bounded mbedTLS heap, its settings subtree - (`tedge/`) and its TLS credential tags. It still depends on nothing in - `lib/` or `apps/`. -- **Onboarding through the Cumulocity CA** (primary): P-256 key generated in - PSA secure storage, one-time password and registration URL, CSR signed - inside PSA, `simpleenroll` polling, PKCS#7 unwrap, certificate stored in - settings. The registration URL is exposed through the API so the host app - can show it (log, shell, BLE provisioner hand-off). -- **Onboarding through the bootstrap user** (fallback): `s/ucr` → `70` - credentials, over Core MQTT (8883). The client sends `100` and waits for - the device to exist before subscribing (spike problem P8). -- **Connection:** MQTTS to the MQTT Service (9883) with the enrolled - certificate, or Core MQTT (8883) with basic auth. One filter per - SUBSCRIBE. SNTP before certificate validation. JWT via `s/uat` for later - HTTPS/WSS features. Reconnect with back-off after any drop, returning TLS - heap and TCP contexts to baseline (P1, never measured in the spikes). A - warning when another client keeps taking over the same client ID. -- **Device state:** `100`/`114`/`117` (inventory, supported operations built - from the enabled features, required interval), availability, and restart - (`510` → `501` → reboot → `503`) through a **full-system reset hook** (a - CPU reset hangs MCUboot on the C6, P2). State published on free-form `te/` - topics per D10, with one reference Smart Function (twin → inventory). -- **Integration:** `samples/minimal` and one protocol app (Modbus on the C6) - use the module, with the glue in `apps//src/`. `apps/c8y-spike` - stays as the reference for the features that come later. -- **Kconfig** (all under `CONFIG_TEDGE`): the existing transport, endpoint - and auth choices, `TEDGE_C8Y_URL`, `TEDGE_DEVICE_ID_PREFIX`, - `TEDGE_HEAP_SIZE` and `TEDGE_THREAD_STACK_SIZE` become functional; - `TEDGE_HEALTH` and `TEDGE_RESTART` stop being experimental. New: - `TEDGE_RECONNECT_BACKOFF_MAX_S`, `TEDGE_ENROLL_POLL_S`, `TEDGE_PSA_KEY_ID`, - `TEDGE_C8Y_CA_FILES`, `TEDGE_SNTP_SERVER`, `TEDGE_REQUIRED_INTERVAL_MIN`, - `TEDGE_BOOTSTRAP_USER` and `TEDGE_BOOTSTRAP_PASSWORD` (design.md D4–D6). The TLS record - size stays Zephyr's global `MBEDTLS_SSL_MAX_CONTENT_LEN`: the profiles set - 16 KB, and 8 KB is a documented opt-in for the MQTT Service (P3). - -## Non-goals - -- Remote access, firmware update, telemetry, logs, configuration and - certificate renewal (roadmap P2–P7). The spike code for them stays in - `apps/c8y-spike`. -- The thin-edge.io gateway transport (P8). -- A production SoftAP/captive-portal provisioner (Spike E showed it works; - it needs a success page, authorization and an Android check first). -- Protecting the device key beyond what the spikes showed (P9): documented as - a limitation, not solved here. -- The same full-system reset fix in `lib/common` (`liveness.c`, `net.c`): a - separate small change, because it touches the existing apps. -- The WROOM-32 as a direct-transport target: its only fitting role is the - remote-access enabler, which needs P2 first. - -## Resource constraints - -Measured in the spikes on the ESP32-C6, as upper bounds for this change -(spike overheads such as the shell and oversized buffers are not carried): - -| Item | Cost | -|---|---| -| TLS + MQTT + SNTP + DNS (text) | +134 KB (C6), +118 KB (S3) | -| Enrollment (text) | +17.7 KB | -| TLS heap, one session, 16 KB records | 51.8 KB peak / 34.8 KB connected (8 KB records: 35.4 / 18.5 KB) | -| Client thread stack | peak 3.3 KB including enrollment | -| TCP contexts | +4 over the app's own (`NET_MAX_CONN` 10 in the spike), since a closed TLS connection holds one until TCP finishes | -| Target | a 64 KB TLS heap (one session plus the enrollment HTTPS request), under 160 KB text; checked per board with `scripts/measure_tedge.sh` | - -Boards: ESP32-C6 (primary, 4 MB), ESP32-S3-DevKitC-1 (with the mbedTLS heap -in PSRAM). Next to OPC-UA on the C6 the client must fit the ~256 KB budget -the app leaves. - -## Capabilities - -### New Capabilities - -- `tedge-c8y-onboarding`: CA enrollment and the bootstrap fallback, the - identity and credential storage, the registration URL for the host app. -- `tedge-c8y-connection`: the MQTT session to 9883/8883, subscription - rules, time, JWT, reconnect and back-off, client-ID takeover detection. -- `tedge-device-state`: inventory, supported operations, availability, - restart through the reset hook, state on `te/` topics and the reference - Smart Function. - -### Modified Capabilities - -Both became baseline specs when `c8y-direct-spikes` was archived -(2026-09-19): - -- `tedge-client-module`: the public API moves from outline to stable-for-P1, - and the platform-reset and restart-veto hooks become requirements. -- `device-management-features`: health and restart are no longer - experimental, and the per-board default profiles (C6 `full`, S3 `full` - with PSRAM, WROOM `remote-access-enabler`) become requirements. - -## Impact - -- `tedge-zephyr/`: new `src/` (connection, onboarding, state), a public - header, Kconfig, `samples/minimal`, tests (Kconfig cases, a `native_sim` - unit test for the SmartREST parser and the PKCS#7 unwrap). -- `apps/modbus-server`: opts in on the C6, glue in `apps/modbus-server/src/`. -- Zephyr facilities used: PSA ITS secure storage, `tls_credentials`, MQTT, - `http_client` (for EST), SNTP, settings. -- Cumulocity tenant: the `certificate-authority` and `mqtt-service.smartrest` - features, one Smart Function. -- Open problems carried over from `c8y-direct-spikes` - (`openspec/changes/archive/2026-09-19-c8y-direct-spikes/design.md`): P1 - (reconnect) and P2 (reset) are in scope; P3, P6, P8, P9 and P12 shape the - design; P4, P5, P7, P10, P11 and P13 belong to later features. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/device-management-features/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/device-management-features/spec.md deleted file mode 100644 index eb9637c..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/device-management-features/spec.md +++ /dev/null @@ -1,32 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Boards ship with a feature profile that fits them - -Each supported board and application combination SHALL have a documented default -device-management profile, provided as a Kconfig overlay, that fits its measured -RAM and flash budget with headroom. A profile SHALL never select more concurrent -TLS sessions than the board's measured heap supports. The profiles SHALL follow -the measured results of `c8y-direct-spikes`: the ESP32-C6 uses the full -profile; the ESP32-S3-DevKitC-1 uses the full profile with the mbedTLS heap in -PSRAM; the ESP32-WROOM-32 uses the direct transport only as a remote-access -enabler with no protocol application, and otherwise the gateway transport. - -#### Scenario: Constrained board - -- **WHEN** the ESP32-WROOM-32 OPC-UA application is built with its default - profile -- **THEN** the profile excludes the direct transport's TLS session, and the - image boots and serves OPC-UA as before - -#### Scenario: Larger board - -- **WHEN** the ESP32-C6 application is built with its default profile -- **THEN** all implemented features are included, and the documented free heap - after connecting stays above the margin set in the footprint table - -#### Scenario: Board with PSRAM - -- **WHEN** the ESP32-S3-DevKitC-1 application is built with its default - profile -- **THEN** the mbedTLS heap is placed in PSRAM, and all implemented features - are included without overflowing internal RAM diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-c8y-connection/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-c8y-connection/spec.md deleted file mode 100644 index 605be4c..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-c8y-connection/spec.md +++ /dev/null @@ -1,84 +0,0 @@ -## ADDED Requirements - -### Requirement: One MQTT session to Cumulocity, on the endpoint that matches the authentication - -The client SHALL hold one MQTTS session to Cumulocity: the MQTT Service (port -9883) with certificate authentication, or Core MQTT (port 8883) with basic -authentication or when the Core MQTT endpoint is selected. It SHALL validate -the server certificate against the configured trust anchors, and SHALL only -connect once the realtime clock is valid (set by the application or by the -client's SNTP query). It SHALL subscribe with one topic filter per SUBSCRIBE -packet, and SHALL NOT subscribe to wildcard free-form topics. - -#### Scenario: Certificate device connects - -- **WHEN** an enrolled device has an IPv4 address and a valid clock -- **THEN** the client connects to port 9883 with mutual TLS and reaches the - connected state - -#### Scenario: Clock not yet set - -- **WHEN** the network is up but the realtime clock predates the build -- **THEN** the client sets it with SNTP before opening the TLS session - -### Requirement: The session starts by creating the device - -After each CONNACK, the client SHALL first publish the device-creation message, -then subscribe to the operation topics, then publish the supported operations, -the required interval and its twin data. It SHALL treat a refused subscription -(return code 0x80) as temporary and retry it, so a newly created device does not -miss operations in its first session. - -#### Scenario: First session of a bootstrap-registered device - -- **WHEN** a device connects as a device user for the first time and the - broker refuses its subscriptions because the device doesn't exist yet -- **THEN** the client retries the subscriptions after the device-creation - message has been processed, and operations sent in that session arrive - -### Requirement: The client recovers from connection loss by itself - -The client SHALL reconnect after any loss of the session (network down, broker -close, keepalive timeout) with an exponential back-off that starts at 3 seconds, -is capped by a Kconfig maximum, includes jitter, and resets after a stable -period. It SHALL stop connecting while the application's network is down and -resume when an address is available again. After each disconnect, the TLS heap -in use and the TCP contexts held by the client SHALL return to their -pre-connect values. - -#### Scenario: Wi-Fi drops for a minute - -- **WHEN** the access point disappears for 60 seconds and returns -- **THEN** the client reports waiting for network, reconnects once the - application's network is back, and its TLS heap and TCP context usage match - the values before the drop - -#### Scenario: Broker closes the session - -- **WHEN** the broker closes the session repeatedly -- **THEN** the client waits increasing intervals between attempts, never less - than 3 seconds - -### Requirement: A duplicate client ID is reported - -The client SHALL log a warning naming a possible duplicate client ID when the -broker closes three consecutive sessions within 10 seconds of their CONNACK. - -#### Scenario: Two devices share an external ID - -- **WHEN** another client connects with the same client ID and the broker keeps - closing this device's session shortly after it connects -- **THEN** the log says that another client may be using this ID - -### Requirement: The client keeps a current token for HTTPS and WebSocket calls - -A client with certificate authentication SHALL request a JWT after every -connect and before the current one expires, and keep the latest one for the -module's HTTPS and WebSocket features. A client with basic authentication -SHALL NOT request one. - -#### Scenario: Token after connect - -- **WHEN** a certificate device connects -- **THEN** it has a token within seconds, and a fresh one before the previous - token's one-hour lifetime ends diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-c8y-onboarding/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-c8y-onboarding/spec.md deleted file mode 100644 index 642f1f7..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-c8y-onboarding/spec.md +++ /dev/null @@ -1,77 +0,0 @@ -## ADDED Requirements - -### Requirement: The device enrolls itself with the Cumulocity CA - -When built with Cumulocity CA authentication, the client SHALL obtain its -device certificate itself. It SHALL generate a persistent P-256 key in PSA -secure storage, generate a one-time password with the PSA random generator, -build a PKCS#10 request whose common name is the external ID and sign it inside -PSA. It SHALL then poll the tenant's EST `simpleenroll` endpoint with HTTP Basic -`:` until a certificate is issued. It SHALL -store the certificate, delete the one-time password, and use the key and -certificate for mutual TLS. An enrolled device SHALL NOT enroll again after a -reboot or an application update. - -#### Scenario: First boot of an unregistered device - -- **WHEN** a device with no stored certificate starts and the operator has not - registered it yet -- **THEN** the client enters the awaiting-registration state, polls - `simpleenroll` at the configured interval with back-off, and does not open - an MQTT session - -#### Scenario: Operator registers the device - -- **WHEN** the operator registers the external ID with the one-time password - while the device is polling -- **THEN** the next poll returns a certificate, the client stores it, and - connects with mutual TLS without a reboot - -#### Scenario: Enrolled device restarts - -- **WHEN** an enrolled device reboots, or its application image is updated -- **THEN** it connects with the stored key and certificate and does not create - a new one-time password - -### Requirement: The registration URL is handed to the application - -The client SHALL provide the registration URL (tenant host, external ID and -one-time password pre-filled) through its public API while the device is -awaiting registration. It SHALL report the awaiting-registration state through -the state hook, so the application can show the URL. The client SHALL NOT log -the one-time password at log levels above debug. - -#### Scenario: Application shows the URL - -- **WHEN** the state hook reports awaiting registration and the application - asks for the registration URL -- **THEN** it receives the URL, and the API returns "already enrolled" once the - certificate is stored - -### Requirement: Bootstrap credentials as a fallback - -When built with bootstrap authentication, the client SHALL connect to Core MQTT -with the tenant's bootstrap credentials, request device credentials, and store -the tenant, user name and password it receives. It SHALL then connect as the -device with basic authentication. Stored credentials SHALL be reused after a -reboot. The client SHALL NOT log the bootstrap or device password. - -#### Scenario: Device request accepted - -- **WHEN** the operator accepts the device's registration request -- **THEN** the client receives and stores the device credentials, disconnects - the bootstrap session, and connects as the device - -### Requirement: Credentials and settings stay in the module's namespace - -Onboarding state SHALL live under the `tedge/` settings subtree, the key at a -configurable PSA key ID, and the TLS credentials at tags from the module's -configurable tag base. The tenant host SHALL default to a Kconfig value and be -overridable at runtime through the public API, persisted in settings. - -#### Scenario: Tenant host overridden at runtime - -- **WHEN** the application sets a tenant host through the API before the - device is enrolled -- **THEN** enrollment and the MQTT session use that host, and it is still used - after a reboot diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-client-module/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-client-module/spec.md deleted file mode 100644 index 1e755a1..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-client-module/spec.md +++ /dev/null @@ -1,75 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Bounded resource ownership - -The module SHALL run its work on its own thread(s), each with a stack size and -priority set in Kconfig. It SHALL NOT block the system work queue. It SHALL -allocate its dynamic memory from its own bounded heap, sized in Kconfig, and not -from the application's heap. TLS memory is the exception: it comes from -Zephyr's mbedTLS heap, which is global and part of the application's -configuration, so the module's documentation SHALL state the mbedTLS heap each -profile needs (per concurrent TLS session and TLS record size). The module's -documentation SHALL also list what it requires from the application's -configuration: socket, TCP-connection and poll-slot counts, mbedTLS options, -and task-watchdog channels. - -#### Scenario: Client memory is capped - -- **WHEN** the client's operations need more memory than its configured heap -- **THEN** the affected operation fails with a reason, and the application's - own allocations are unaffected - -#### Scenario: TLS heap too small - -- **WHEN** the application's mbedTLS heap is smaller than the documented need - and a handshake runs out of memory -- **THEN** the client logs that the TLS handshake failed for lack of mbedTLS - heap, backs off, and the application keeps running - -### Requirement: Integration hooks for the host application - -The public API SHALL let the host application: - -- provide the device identity: external ID, name, type, and firmware name and - version (with a documented default for each); -- push telemetry: measurements, events and alarms; -- publish its own twin data; -- register its own operation handlers, log types and configuration types; -- prepare for or veto a restart requested by the cloud; -- replace the platform reset the client uses to restart the device; -- add its own health checks that must pass before a newly updated firmware image - is confirmed; -- observe the client's state (for example to drive a status LED); -- read the registration URL while the device awaits registration, and set the - tenant host at runtime; -- optionally report liveness progress to the application's watchdog. - -The module SHALL NOT reset the device without first giving the application's -restart hook the chance to respond. Its default reset SHALL be a full-system -reset on platforms where a CPU-only reset can leave the bootloader hung. -API functions for features not yet implemented or not built in SHALL return -`-ENOTSUP`. - -#### Scenario: Application-supplied telemetry - -- **WHEN** the application calls the telemetry API with a measurement -- **THEN** the client publishes it on the configured transport, and no telemetry - is read from anything outside the calls the application makes - -#### Scenario: Application vetoes a restart - -- **WHEN** a restart operation arrives and the application's restart hook - declines it -- **THEN** the client does not reboot and marks the operation as failed with the - reason returned by the hook - -#### Scenario: Application supplies the reset - -- **WHEN** the application registers a reset hook and a restart is allowed -- **THEN** the client calls that hook instead of its platform default - -#### Scenario: Feature not available - -- **WHEN** the application calls an API function whose feature is not built - into the image -- **THEN** the call returns `-ENOTSUP` and has no other effect diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-device-state/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-device-state/spec.md deleted file mode 100644 index 090966f..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-core/specs/tedge-device-state/spec.md +++ /dev/null @@ -1,71 +0,0 @@ -## ADDED Requirements - -### Requirement: Inventory and supported operations on every connect - -On every connect the client SHALL publish the device's name and type, its -required interval, and a supported-operations list assembled from the features -compiled into the image and the operations the application registered. An -operation received for anything not in that list SHALL be marked failed with a -reason. - -#### Scenario: Restart built in - -- **WHEN** an image with the restart feature connects -- **THEN** its supported operations include `c8y_Restart` - -#### Scenario: Application-registered operation - -- **WHEN** the application registers an operation before the client starts -- **THEN** it is advertised as supported, and its handler receives the - operation with the operation already marked as executing - -### Requirement: Restart runs through the application and a full-system reset - -On a restart operation the client SHALL call the application's restart hook, -if any. When the hook vetoes it, the client SHALL mark the operation failed with -the hook's reason. Otherwise it SHALL mark the operation as executing, persist -a marker, disconnect cleanly and reset the device, using the application's -reset hook when provided and otherwise a full-system reset of the platform. -After the reboot it SHALL mark the operation successful once connected, and -clear the marker. - -#### Scenario: Restart from Cumulocity - -- **WHEN** an operator restarts the device from Cumulocity -- **THEN** the device reboots, reconnects and the operation ends SUCCESSFUL - -#### Scenario: ESP32-C6 restart does not hang - -- **WHEN** the client resets an ESP32-C6 built with MCUboot and Wi-Fi running, - without an application reset hook -- **THEN** it uses a full-system reset, and MCUboot boots the application - -### Requirement: State is published as twin data and health on thin-edge.io topics - -The client SHALL publish its own twin fragment (`tedge_Agent`: module name, -version and transport) and its health (`up`) on every connect, on -`te/device////twin/tedge_Agent` and -`te/device//service/tedge-zephyr/status/health` when connected to the MQTT -Service. On Core MQTT it SHALL send the twin fragment as a direct inventory -update. The application SHALL be able to publish its own twin fragments through -the API, which the client republishes after every reconnect. - -#### Scenario: Application twin fragment survives a reconnect - -- **WHEN** the application publishes a twin fragment and the connection is - lost and re-established -- **THEN** the client publishes the fragment again after the reconnect - -### Requirement: The twin topics and payloads are documented for cloud-side mapping - -The module SHALL document the topics it publishes state on and the shape of -each payload, so that a tenant can map them with its own Cumulocity Smart -Functions (or a thin-edge.io gateway can consume them unchanged). The -documentation SHALL name the fragment each payload belongs to and give a -worked example. - -#### Scenario: A tenant maps the twin data - -- **WHEN** an integrator reads the module's documentation -- **THEN** it states the topic (`te/device////twin/`), the - payload of each built-in fragment, and what a function receives diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-core/tasks.md b/openspec/changes/archive/2026-09-20-c8y-direct-core/tasks.md deleted file mode 100644 index 10b97a8..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-core/tasks.md +++ /dev/null @@ -1,67 +0,0 @@ -## 1. Module foundations (firmware) - -- [x] 1.1 Add the D1 source layout to `tedge-zephyr/` (`src/tedge_core.c`, `tedge_c8y.c`, `tedge_smartrest.c`, `tedge_enroll.c`, `tedge_bootstrap.c`, `tedge_time.c`, `tedge_platform.c`, `tedge_internal.h`, `certs/`), compiled per Kconfig; keep `scripts/check-independence.py` passing -- [x] 1.2 Add the new Kconfig options (`TEDGE_RECONNECT_BACKOFF_MAX_S`, `TEDGE_ENROLL_POLL_S`, `TEDGE_PSA_KEY_ID`, `TEDGE_C8Y_CA_FILES`, `TEDGE_SNTP_SERVER`, `TEDGE_REQUIRED_INTERVAL_MIN`, `TEDGE_BOOTSTRAP_USER`/`_PASSWORD`), set the thread stack default to 6144, make `TEDGE_HEALTH` and `TEDGE_RESTART` selectable without `TEDGE_EXPERIMENTAL_FEATURES`, and have the module select what it needs (MQTT, sockets, TLS, SNTP, settings, PSA ITS). Add `tests/kconfig` cases for them -- [x] 1.3 Embed the trust anchors from `TEDGE_C8Y_CA_FILES` (default: Go Daddy Root G2) at `TEDGE_TLS_TAG_BASE + 0`, via a generated `.inc` -- [x] 1.4 Update `include/tedge/tedge.h` to D10: add `reset` to `tedge_hooks`, `tedge_set_c8y_url()`, `tedge_set_bootstrap_credentials()`, `tedge_publish_twin()`, and mark which change implements each declared-but-unimplemented function (they return `-ENOTSUP`) -- [x] 1.5 Implement `tedge_init`/`tedge_start`/`tedge_stop`/`tedge_get_state`: the client thread, the module `k_heap`, the API message queue and the `on_state`/`progress` hooks (D2, D3) - -## 2. Connection (firmware) - -- [x] 2.1 Network wait on connection-manager L4 events (falling back to IPv4 address events), never touching the interface -- [x] 2.2 Time: skip SNTP when the realtime clock is after the build date, otherwise query `TEDGE_SNTP_SERVER` with retries -- [x] 2.3 MQTT session to 9883 (CA) or 8883 (basic auth / Core MQTT) with the trust anchors and device credentials; keepalive 60 s; one filter per SUBSCRIBE -- [x] 2.4 Session start order (D6): `100`, subscriptions with 0x80 retry, `114` from compiled features and registered operations, `117`, `s/uat`, twin and health -- [x] 2.5 Reconnect back-off (3 s floor, doubling to the maximum, ±20% jitter, reset after 60 s connected) and the duplicate-client-ID warning (three broker closes within 10 s of CONNACK) -- [x] 2.6 JWT: request after connect and 50 min after each token, CA builds only; `tedge_c8y_jwt()` internal accessor -- [x] 2.7 SmartREST dispatcher: parse `s/ds` lines, route `510` and registered operations, answer unknown or not-built-in operations with `502` and a reason naming the feature - -## 3. Onboarding (firmware) - -- [x] 3.1 CA enrollment (D4): PSA key, OTP, registration URL, PSA-signed CSR, `simpleenroll` polling with back-off, PKCS#7 unwrap, storage under `tedge/enroll/`, credential registration; `TEDGE_STATE_AWAITING_REGISTRATION`; `tedge_registration_url()` (OTP only at debug level) -- [x] 3.2 Tenant host from `TEDGE_C8Y_URL`, overridable by `tedge_set_c8y_url()` and persisted at `tedge/c8y/url` -- [x] 3.3 Bootstrap onboarding: 8883 as the bootstrap user, `s/dcr` + `s/ucr` polling, store `70` credentials under `tedge/bootstrap/`, reconnect as the device; `tedge_set_bootstrap_credentials()`; passwords never logged - -## 4. Device state (firmware) - -- [x] 4.1 Restart (D8): `restart_request` veto → `502`; else `501`, marker, clean disconnect, reset via the `reset` hook or `tedge_platform_reset()` (full-system reset on Espressif SoCs); `503` after the next CONNACK -- [x] 4.2 Twin publishing (D9): `tedge_Agent` on every connect; `tedge_publish_twin()` stores application fragments and republishes them after every reconnect; `te/` topics on the MQTT Service, inventory updates on Core MQTT -- [x] 4.3 Health: `te/device//service/tedge-zephyr/status/health` `{"status":"up"}` on connect (MQTT Service only) -- [x] 4.4 Custom operations: `tedge_register_operation()` and `tedge_operation_payload/succeed/fail`, routed from the dispatcher (the handler sees the operation already EXECUTING) - -## 5. Unit and configuration tests - -- [x] 5.1 `tests/unit` (ztest, `native_sim`): SmartREST parsing and CSV quoting -- [x] 5.2 PKCS#7 unwrap against a fixture from the spike's captured `simpleenroll` reply -- [x] 5.3 Back-off sequence, twin topic/payload builders and inventory-update fallback -- [x] 5.4 CI: run the unit tests and the extended `tests/kconfig` cases in `.github/workflows/tedge-zephyr.yml` - -## 6. Integrations (firmware) - -- [x] 6.1 `samples/minimal`: its own minimal Wi-Fi connect (no `lib/common`), `tedge_init`/`tedge_start`, logs state changes and the registration URL; builds for the C6 and `native_sim` -- [x] 6.2 `apps/modbus-server/src/tedge_glue.c` (C6): identity from `lib/common`, status LED from `on_state`, `boot_request_reboot()` as the `reset` hook, registration URL on the console; enabled by a C6 overlay with `profiles/full.conf` -- [x] 6.3 Update `profiles/minimal.conf`, `full.conf` and `remote-access-enabler.conf` to the options that now exist, with the mbedTLS heap each needs (S3: PSRAM section) -- [x] 6.4 Show that the Modbus, OPC-UA and SNMP apps built with `TEDGE=n` are byte-identical to their baselines - -## 7. Cloud-side mapping (documentation only) - -The tenant owner writes and owns the Smart Functions; the module documents -the topics and payloads instead of shipping functions (decided 2026-09-20). - -- [x] 7.1 Record the working mapping the tenant owner built: `te/device////twin/tedge_RemoteAccess` → a `remoteAccess` fragment on the managed object, payload unchanged -- [x] 7.2 Document the twin topics and payloads (`tedge_Agent`, `tedge_RemoteAccess`, health) in the README, with the example a function receives -- [x] 7.3 Confirm on the tenant that a function written this way populates the managed object (verified with `remoteAccess`) - -## 8. Hardware verification - -- [x] 8.1 C6 Modbus + tedge from erased storage: registration URL on the console, `c8y deviceregistration register-ca`, certificate, mTLS to 9883, inventory and supported operations in Cumulocity, Modbus still served -- [x] 8.2 Restart from Cumulocity ends SUCCESSFUL (verified on the C6); a vetoing test hook ends FAILED with its reason (not run: the Modbus glue never vetoes) -- [x] 8.3 P1 reconnect test: three Wi-Fi drops of 60 s (application shell `wifi disconnect`/`connect`) and one real access-point drop; record the time back to CONNECTED and the TLS heap and TCP-context counts before and after (no leak) -- [x] 8.4 Core MQTT: the same C6 built with `TEDGE_C8Y_CORE_MQTT` (and once with bootstrap auth) connects, and the twin arrives as an inventory update -- [x] 8.5 S3-DevKitC-1 with the full profile (mbedTLS heap in PSRAM): enroll, connect, restart -- [x] 8.6 Footprint: extend `scripts/measure_tedge.sh` to the module builds (C6 Modbus with and without `TEDGE`, S3, `samples/minimal`) and record the table in `tedge-zephyr/README.md` - -## 9. Documentation and wrap-up - -- [x] 9.1 `tedge-zephyr/README.md`: integration guide (identity, hooks, required Kconfig for sockets/TCP contexts/mbedTLS heap per profile), onboarding flow, P9 key-protection limitation, P3 record-size note -- [x] 9.2 Record results and any design changes in design.md; update the `SCOPE.md` roadmap (P1 done) diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/design.md b/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/design.md deleted file mode 100644 index 0a5661e..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/design.md +++ /dev/null @@ -1,271 +0,0 @@ -## Context - -Everything the client does today is about keeping a device working. This is -the first feature about a device that already failed: what was it saying -before it stopped, why did it reset, and what does it say if asked a direct -question now. - -What the spikes and the repository already settled: - -- **The shell must not be a network listener.** Spike problem P10: - `shell_telnet` binds `INADDR_ANY` — an unauthenticated shell on the LAN — - mirrors logs into the session and cannot turn echo off. A cloud-issued - command has to run in-process instead. -- **Shell output and logging fight each other.** Spike B, finding 5: the - shell thread prints deferred log messages, so a command that runs on the - client thread would hold the client up. The command gets its own thread, - as the download and the tunnel do. -- **HTTPS is Bearer-JWT and tenant-only.** `s/uat` → `s/dat` gives a - certificate device a one-hour JWT; it is never sent off the tenant's - domain, including after a redirect (`tedge_url_is_tenant()`). -- **The flash is already laid out for a dump.** Every board layout reserves - a 4 KB `coredump_partition`; nothing has ever written to it. - -Two promises are currently broken, and are repaired here rather than -documented around: `tedge_register_log_type()` has no definition at all (a -link error, where the spec requires "not supported"), and an operation the -image cannot run is dropped silently instead of being failed with a reason. - -## Goals / Non-Goals - -**Goals:** the last minutes of a device's log retrievable from the cloud; -the crash that erased them retrievable after the reboot; one allow-listed -command answerable on demand; an application able to add log types of its -own; none of it able to stall the connection. - -**Non-Goals:** continuous log streaming, a filesystem, configuration -management (P7), decoding dumps on the device. - -## Decisions - -### D1: The command runs in-process, on its own thread, behind an allow-list - -`c8y_Command` is executed with `shell_execute_cmd()` against a **dummy shell -backend** (`CONFIG_SHELL_BACKEND_DUMMY`), which Zephyr provides precisely to -capture output into a buffer. No listener, no port, no log mirroring. - -It runs on a dedicated thread: a command that takes seconds must not stall -MQTT, and the shell thread is the one that prints deferred logs (spike B, -finding 5). One command at a time; a second is refused while one is running. - -**The allow-list is empty by default.** `TEDGE_SHELL_COMMAND_ALLOW_LIST` is a -comma-separated list of command prefixes ("kernel uptime,net iface,tedge -diag"); a request must begin with one of them, and the rest must be plain -arguments. A request with `;`, `|`, `&`, a newline or a backtick is refused -whatever the list says, so an allowed prefix cannot be used to smuggle a -second command. With no list, every command is refused, naming the -configuration option — the feature being compiled in is not consent to run -anything. - -*Alternative: a default list of "safe" read-only commands.* Rejected: what -is safe depends on what else is in the image, which only the integrator -knows. A command that dumps a key is safe in one product and not in another. - -The matching is a pure function in its own file, unit-tested on native_sim, -like `tedge_ra_allow_list.c`. - -### D2: A log type is a callback, not a file - -`tedge_register_log_type(type, reader, user_data)` adds a type. The reader -writes through the sink it is given, so the client never needs a filesystem -and never holds a whole log. The request's filters -(`struct tedge_log_request`: from, to, search text, max lines) are passed -through; a reader applies what it can and ignores the rest. - -The client registers two of its own: `tedge-log` (D3) and, when a dump is -stored, `coredump` (D5). - -### D3: The client keeps its own log in a RAM ring - -A Zephyr log backend writes formatted lines into a ring of -`TEDGE_LOG_RING_BYTES` (default 2048) in the module's heap, oldest dropped. -That is the log type every device has without its application doing -anything — the common case is a device whose application never built a log -of its own. - -It is RAM: a reboot loses it. That is what the crash dump is for. - -### D4: The upload is measured, then streamed - -Cumulocity wants a length, and the device has no room to hold a log twice. -So an upload runs the reader **twice**: once through a counting sink to -learn the length, then again streaming into the request body, clamped to -the length just measured (padded with spaces if the second pass produces -less, truncated if more, because a ring can gain lines between passes). - -*Alternative: buffer the whole log in the module heap.* Rejected: it puts a -hard ceiling on what can ever be uploaded and takes kilobytes from a 16 KB -heap at the worst possible moment. - -**The upload API, settled against the tenant with curl (task 1.1).** No -multipart is needed, which takes a lot of code off the device: - -1. `POST /event/events` with `Accept: application/json` and - `{"source":{"id":""},"type":"c8y_LogfileRequest","text":"","time":""}` → `201` with `Location: - https:///event/events/`. **`Accept` is not - optional**: without it Cumulocity answers `201` with no body and no - `Location`, and the device has nothing to work with. -2. `POST /event/events//binaries` with `Content-Type: text/plain`, - `Content-Length`, an optional `Content-Disposition: attachment; - filename="…"` (which names the file in the UI), and the log as the plain - request body → `201`. -3. Answer `503,c8y_LogfileRequest,https:///event/events//binaries`. - The device builds that URL itself; no JSON parsing beyond reading - `Location` out of a header it already captures for redirects. - -The device knows its external ID, not its managed-object id, so it resolves -one once per session with `GET -/identity/externalIds/c8y_Serial/` and keeps it. That is one -small GET, and its answer is the only JSON the upload path has to read. - -*Alternative: chunked transfer encoding.* Rejected for now: Zephyr's HTTP -client already mishandles chunked **responses** -(`docs/zephyr-http-chunked-body-bug.md`), and the tenant's acceptance of a -chunked request body is unverified. - -`TEDGE_LOG_UPLOAD_MAX_BYTES` (default 8192) caps a single upload; a longer -log is truncated with a final line saying so, rather than refused. - -### D5: The crash dump is uploaded as the text format the tooling already reads - -`CONFIG_DEBUG_COREDUMP` with the flash-partition backend writes a dump to -the `coredump_partition` on a fatal error. After the reboot the client sees -a stored dump (`coredump_query()`), advertises the `coredump` log type, and -its reader emits the same `#CD:` hex-line format Zephyr's console backend -produces — so `zephyr/scripts/coredump/coredump_serial_log_parser.py` reads -an uploaded file unchanged, with no new tooling to write. - -The stored dump's length is known, so the reader's two passes agree exactly. -The dump is erased only when the cloud has taken it, so a failed upload can -be retried. - -### D6: `TEDGE_HTTP` finally owns the HTTP code - -`tedge_http_download.c` and `tedge_url.c` are compiled today when firmware -update is on, which was always a shortcut. They move to -`CONFIG_TEDGE_HTTP`, which log upload also selects, and `TEDGE_HTTP` gains -the Zephyr selects its help text has been promising. A new -`tedge_http_upload.c` adds the POST with a body callback; it shares the -redirect and tenant-only-JWT rules, because the reasons for both are the -same. - -### D7: The protocol - -| Direction | Message | Meaning | -|---|---|---| -| up, on connect | `118,,…` | the log types this image offers | -| up, on connect | `114,…,c8y_Command,…` | `c8y_Command` when the feature is in | -| down | `511,,` | run a command | -| down | `522,,,,,,` | send a log | -| up | `501,` then `503,[,]` or `502,,` | progress, as everywhere else | - -A log request answers `503,c8y_LogfileRequest,` with the uploaded -binary's URL. `501` always goes first: Cumulocity's `502` fails the oldest -*executing* operation, so refusing straight from PENDING would leave the -operation stuck (the rule firmware update already follows). - -A command's result goes in the `503`. SmartREST is one line, so newlines in -the output become spaces and the result is truncated to what the template -carries; the fix for a command with a lot to say is a log type, not a bigger -line. - -### D8: An operation the image cannot run is failed, not dropped - -The dispatcher's default branch fails the operation with a reason naming the -missing feature, instead of logging a debug line and leaving it pending. -This is already required ("Operation for an absent feature") and is the -smallest part of this change, but it is why a device with no log upload -stops looking broken when someone asks it for a log. - -## Risks / Trade-offs - -- [A cloud-issued shell command is powerful] → empty by default, prefix - allow-list, no shell metacharacters, one at a time, and the whole feature - compiled out unless asked for. -- [A command that never returns] → the operation reports a timeout after - `TEDGE_SHELL_COMMAND_TIMEOUT_S`, but Zephyr cannot preempt a running - command, so the thread stays busy and further commands are refused until - it finishes. Documented, with the advice that an allow-list should not - contain commands that can block forever. -- [The log ring is small and volatile] → stated plainly; the crash dump - covers the case the ring cannot. -- [The dump costs flash and code] → 4 KB already reserved in every layout, - and the feature is its own Kconfig option. -- [Two passes over a log] → cheap for RAM sources, and the clamp keeps the - body honest if the ring moves underneath. -- [The upload API shape is unverified] → the first task is to confirm it - against the tenant with curl before any device code is written. - -## Migration Plan - -- Applications gain working registration calls where they had a link error. -- `full.conf` drops its "not implemented yet" caveat for these two features; - only configuration management remains experimental. -- Boards that want dumps add `CONFIG_TEDGE_COREDUMP=y`; the partition is - already in their layout, so nothing repartitions and no device loses its - settings. - -## Results (ESP32-C6, Modbus application, 2026-09-20) - -**Cost**, each row against the same build with every other feature on: - -| Build | text | libc heap left | -|---|---|---| -| Everything but diagnostics | 815,800 | 112,672 | -| + log upload and crash dumps | 824,392 (+8.4 KB) | 100,096 (−12.3 KB) | -| + the Zephyr shell, without this feature | 855,888 (+39 KB) | 103,104 | -| + the shell command on top of that shell | 858,412 (+2.5 KB) | 90,528 (−12.6 KB) | - -The shell command's real price is the Zephyr shell itself (39 KB), which an -integrator who wants this feature is paying anyway; the client's own part of -it is 2.5 KB of code and a thread. - -**Verified on hardware**, against the tenant: - -| Check | Result | -|---|---| -| Capabilities | `c8y_SupportedLogs: ["tedge-log","app-status"]`, and `coredump` appears only when a dump is stored | -| The client's own log | 2 KB of real log in Cumulocity, readable as it appeared on the console | -| An application's log type | the Modbus application's `app-status`, produced on demand | -| Filters | `searchText: "tedge"` with `maximumLines: 5` returned exactly those five lines | -| A log the device does not have | operation FAILED: "this device has no log called \"syslog\"" | -| An allowed command | `kernel uptime` → SUCCESSFUL with its output | -| An unlisted command | FAILED: "this command is not on the device's allow-list" | -| A chained command | FAILED: "the command contains a character that could chain another command" | -| No allow-list configured | every command FAILED, naming the option to set | -| A crash dump | forced fault → 1139 B stored → uploaded → decoded by Zephyr's parser to a 1139 B core → erased on the device | -| Two operations at once | the second waits for the first, and each gets its own result | - -### What the hardware taught us - -**The upload thread needs 8 KB, not 4.** Two TLS handshakes run on it (the -identity lookup and the upload). With 4096 it overflowed mid-handshake and, -with no `CONFIG_HW_STACK_PROTECTION`, showed up as an illegal instruction -with every register reading `0xaaaaaaaa` — the stack fill pattern. The -download and the tunnel already use 8192 for the same reason. - -**One operation at a time is not optional.** Cumulocity's `501`, `502` and -`503` act on the oldest operation in the matching state, not on an operation -named in the message. Two operations executing at once therefore collect -each other's results. The client now runs one at a time and queues the rest -(D8 extended), which also fixes a latent problem: firmware update, remote -access and restart could already overlap. - -**An operation interrupted by a reset stays EXECUTING in Cumulocity.** The -device cannot see it after the reboot — SmartREST resends only PENDING -operations — so it stays until an operator clears it, and until then every -`503` the device sends completes that stale one instead. Nothing here can -fix it from the device; it is worth knowing when reading a device's -operation history. - -**A dump has to fit the partition.** With `MEMORY_DUMP_MIN` the dump is the -faulting thread's stack plus registers, so a thread with a 4 KB stack does -not fit the 4 KB `coredump_partition` — the backend reports `-ENOMEM` while -the device is already dying. The default `LINKER_RAM` mode never fits. -Applications get told both things in the README. - -## Open Questions - -- Whether the client should keep its log ring across a warm restart in the - noinit region, so a commanded reboot does not lose it. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/proposal.md b/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/proposal.md deleted file mode 100644 index fcae694..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/proposal.md +++ /dev/null @@ -1,112 +0,0 @@ -## Why - -When a device in the field misbehaves, the question is always the same: what -happened? Today the only answers are a serial console someone has to stand -next to, or a remote-access tunnel — which needs the device to still be -healthy enough to open one. Neither helps with the device that rebooted an -hour ago, and neither leaves a record. - -This is phase 3, roadmap step P5, targeting the same boards as the rest of -the client (ESP32-C6 and ESP32-S3 primary; the Modbus application is the -example host). It gives the device three ways to explain itself without -anyone travelling to it: - -- **its own log**, kept in a RAM ring and uploadable on request, so the last - minutes before a problem survive the question being asked; -- **a crash dump**, written to the flash partition the layouts already - reserve and offered after the reboot — the only record that outlives the - fault that erased the RAM log; -- **a shell command**, allow-listed, for the question the logs do not answer. - -Two defects fall out of the same work and are fixed here, because both are -already promised in the baseline spec: - -- `tedge_register_log_type()` and `tedge_register_config_type()` are - **declared but never defined**. An application that calls them fails to - link, where the spec requires "not supported" and the README promises - `-ENOTSUP`. -- An operation the image cannot run — `c8y_LogfileRequest` today — is - **silently left pending** in Cumulocity instead of being failed with a - reason, which the "Operation for an absent feature" scenario forbids. - -## What Changes - -- **`CONFIG_TEDGE_SHELL_COMMAND` becomes real:** `c8y_Command` runs an - allow-listed Zephyr shell command on its own thread and returns what it - printed. The allow-list is **empty by default**: until the integrator - lists what may run, every command is refused with a reason. A cloud that - can run arbitrary shell commands on a field device is a bigger risk than - the feature is worth by default. -- **`CONFIG_TEDGE_LOG_UPLOAD` becomes real:** the client answers - `c8y_LogfileRequest`, advertises the log types it has, applies the - request's filters, and uploads the result as a Cumulocity event binary. -- **The client keeps its own log** in a bounded RAM ring (a Zephyr log - backend), offered as a log type without the application doing anything. - This is what makes the feature useful on a device whose application has no - log of its own. -- **Crash dumps:** with `CONFIG_DEBUG_COREDUMP` the client writes a dump to - the `coredump_partition` the board layouts already reserve, and offers the - most recent one as a log type after the reboot, with the tooling to decode - it documented. -- **The application can add log types** through `tedge_register_log_type()`, - which acquires a definition — and an `-ENOTSUP` one when the feature is - off, so an application builds either way. The same goes for - `tedge_register_config_type()` (still P7 otherwise). -- **HTTP gains upload**, and `CONFIG_TEDGE_HTTP` finally owns the HTTP - sources it was invented for (they are gated on firmware update today). -- **An operation the image cannot run is failed, not dropped**, with a reason - naming what is missing. - -## Non-goals - -- Streaming a log continuously to the cloud. A log is fetched when someone - asks; a device that narrates itself over MQTT costs bandwidth for data - nobody reads. -- A filesystem. Log types are produced by callbacks, not read from files; - the module keeps its "no filesystem required" property. -- Configuration management (P7), although it shares the upload path being - built here. -- Decoding the crash dump on the device. The dump is uploaded as it was - written; the host tooling reads it. - -## Resource constraints - -| Item | Cost | -|---|---| -| Text | the shell capture, the log ring backend, the coredump reader, and HTTP upload (a POST with a multipart body) | -| RAM | `TEDGE_LOG_RING_BYTES` (default 2048) for the client's log, a capture buffer while a command runs, and one upload buffer; the upload thread's stack is shared with the download's | -| Flash | the 4 KB `coredump_partition` already present in every board layout | -| Network | one HTTPS POST per log request; nothing periodic | -| Time | a command is bounded by a timeout; an upload runs off the client thread so the connection keeps working | - -## Capabilities - -### New Capabilities - -- `tedge-diagnostics`: what a device can be asked for when it misbehaves — - its log, its last crash, and an allow-listed command — and what it must - refuse. - -### Modified Capabilities - -- `device-management-features`: the shell command and log upload stop being - unimplemented; an operation for an absent feature is failed rather than - ignored. -- `tedge-client-module`: registering a log type becomes a requirement with - scenarios, including what happens when the feature is not built in. - -## Impact - -- `tedge-zephyr/src/`: new `tedge_shell_cmd.c`, `tedge_log_upload.c`, - `tedge_log_ring.c` (the log backend), `tedge_coredump.c`, and - `tedge_http_upload.c`; `tedge_c8y.c` gains templates 522 and 118 and the - refusal path; `CMakeLists.txt` re-gates the HTTP sources on - `CONFIG_TEDGE_HTTP`. -- `tedge-zephyr/include/tedge/tedge.h`: definitions for the registration - calls, and `-ENOTSUP` stubs when the features are off. -- Kconfig: `TEDGE_SHELL_COMMAND_ALLOW_LIST`, `TEDGE_SHELL_COMMAND_TIMEOUT_S`, - `TEDGE_SHELL_COMMAND_OUTPUT_BYTES`, `TEDGE_LOG_RING_BYTES`, - `TEDGE_LOG_UPLOAD_MAX_BYTES`, `TEDGE_COREDUMP`; the two - `TEDGE_FEATURE_AVAILABLE_*` gates flip to `default y`. -- The Modbus application gains a log type of its own, so the repository - shows an application using the hook. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/specs/device-management-features/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/specs/device-management-features/spec.md deleted file mode 100644 index 08cfe12..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/specs/device-management-features/spec.md +++ /dev/null @@ -1,39 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Advertised capabilities match the build - -The device SHALL advertise to the cloud or gateway only the operations and -capabilities of features compiled into the running image. This covers supported -operations, log types and configuration types. When it receives an operation for -a feature that is not compiled in, the device SHALL mark that operation as -failed with a reason that names the missing feature. It SHALL NOT ignore the -operation or leave it pending, and this SHALL hold for every operation the -device does not handle, including ones it has never heard of. - -#### Scenario: Firmware update advertises itself and the running version - -- **WHEN** an image with the firmware-update feature connects -- **THEN** its supported operations include firmware update, and it reports - the name and version of the image that is running - -#### Scenario: Supported operations reflect the image - -- **WHEN** an image without the firmware-update feature connects -- **THEN** its supported-operations list does not include firmware update - -#### Scenario: Operation for an absent feature - -- **WHEN** the device receives an operation whose feature is not compiled in -- **THEN** it marks the operation as failed with a reason naming the feature - -#### Scenario: Log types reflect the image - -- **WHEN** an image with log upload connects -- **THEN** it advertises the log types it can actually produce, and an image - without the feature advertises none - -#### Scenario: An unrecognised operation - -- **WHEN** the device receives an operation it has no handler for at all -- **THEN** it marks the operation as failed with a reason, rather than - leaving it pending in the cloud diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/specs/tedge-client-module/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/specs/tedge-client-module/spec.md deleted file mode 100644 index 97eefbc..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/specs/tedge-client-module/spec.md +++ /dev/null @@ -1,72 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Integration hooks for the host application - -The public API SHALL let the host application: - -- provide the device identity: external ID, name, type, and firmware name and - version (with a documented default for each); -- push telemetry: measurements, events and alarms; -- register its own operation handlers, log types and configuration types; -- prepare for or veto a restart requested by the cloud; -- add its own health checks that must pass before a newly updated firmware image - is confirmed; -- observe the client's state (for example to drive a status LED); -- optionally report liveness progress to the application's watchdog. - -Every one of these calls SHALL be present whether or not the feature behind -it is built in: a call belonging to a feature that is absent SHALL return -"not supported", so that an application compiles against the API once and -chooses its features in Kconfig. - -The module SHALL NOT call `sys_reboot()` without first giving the application's -restart hook the chance to respond. - -#### Scenario: Application narrows remote-access targets - -- **WHEN** the application registers a remote-access target hook and the - cloud asks for a tunnel to a target the built-in policy allows but the hook - refuses -- **THEN** the client opens no connection and fails the operation with the - hook's reason - -#### Scenario: Application vetoes a new firmware image - -- **WHEN** a newly installed image is running and connected, and the - application's firmware-check hook reports that the application is not - healthy -- **THEN** the client does not confirm the image, so the bootloader reverts - it on the next reset - -#### Scenario: Application-supplied telemetry - -- **WHEN** the application calls the telemetry API with a measurement -- **THEN** the client publishes it on the configured transport, and no telemetry - is read from anything outside the calls the application makes - -#### Scenario: Telemetry from the application's own threads - -- **WHEN** the application publishes telemetry from a thread of its own - while the client is busy with an operation -- **THEN** the call returns without waiting for the network, and the client - sends the message on its own thread - -#### Scenario: Application vetoes a restart - -- **WHEN** a restart operation arrives and the application's restart hook - declines it -- **THEN** the client does not reboot and marks the operation as failed with the - reason returned by the hook - -#### Scenario: Application adds a log type - -- **WHEN** the application registers a log type and the cloud asks for it -- **THEN** the client asks the application to produce it and makes the result - retrievable from the cloud - -#### Scenario: Registering against a feature that is not built in - -- **WHEN** an application that registers a log type is built without log - upload -- **THEN** it still compiles and links, and the registration returns "not - supported" diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/specs/tedge-diagnostics/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/specs/tedge-diagnostics/spec.md deleted file mode 100644 index 4da0b00..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/specs/tedge-diagnostics/spec.md +++ /dev/null @@ -1,103 +0,0 @@ -## ADDED Requirements - -### Requirement: A device can be asked for its logs - -When log upload is built in, the client SHALL advertise the log types the -running image offers, SHALL produce the requested type on request, and SHALL -make it available to the cloud as a retrievable file. The client SHALL apply -the filters a request carries as far as the type allows, and SHALL bound -what one request can produce. - -#### Scenario: Fetching the log of a device in the field - -- **WHEN** an operator asks a connected device for its log -- **THEN** the log is retrievable from the cloud without anyone visiting the - device - -#### Scenario: A filtered request - -- **WHEN** a log is requested with a search text and a line limit -- **THEN** the returned log contains only matching lines, up to that limit - -#### Scenario: More log than the device may send - -- **WHEN** the requested log is longer than the configured maximum -- **THEN** the device sends what fits and says in the file that it was - truncated, rather than refusing the request - -### Requirement: The client keeps a log of its own - -When log upload is built in, the client SHALL keep its own recent log in a -bounded amount of RAM, oldest lines dropped first, and SHALL offer it as a -log type without the application registering anything. - -#### Scenario: A device whose application has no log - -- **WHEN** an application that never registered a log type is asked for a - log -- **THEN** the client's own recent log is returned - -### Requirement: A crash outlives the device's memory - -When crash dumps are built in, the client SHALL store a dump when the device -faults, SHALL offer the stored dump as a log type after the device restarts, -and SHALL keep it until the cloud has taken it. The dump SHALL be uploaded in -a form the standard Zephyr tooling can decode. - -#### Scenario: A device that crashed and rebooted - -- **WHEN** a device faults and restarts, and an operator asks for the dump -- **THEN** the dump from before the restart is retrievable and can be - decoded with Zephyr's coredump tooling - -#### Scenario: An upload that failed - -- **WHEN** the upload of a stored dump does not complete -- **THEN** the dump is still stored and can be requested again - -### 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. - -#### 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 - -### Requirement: Diagnostics never block the connection - -Producing or uploading a log SHALL NOT stop the client from serving its -connection, and a failed upload SHALL fail its operation with a reason -rather than leave it pending. - -#### Scenario: An upload while the device is working - -- **WHEN** a log is uploaded -- **THEN** the device stays connected throughout and telemetry keeps flowing - -#### Scenario: An upload that cannot complete - -- **WHEN** a log upload fails -- **THEN** the operation is reported as failed with a reason diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/tasks.md b/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/tasks.md deleted file mode 100644 index 0f33506..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-diagnostics/tasks.md +++ /dev/null @@ -1,57 +0,0 @@ -## 1. Groundwork - -- [x] 1.1 Confirm with curl against the tenant how a log binary is uploaded and what URL comes back (event binaries vs `/inventory/binaries`, multipart vs plain body), and record the answer in design.md (D4, open question) -- [x] 1.2 `TEDGE_HTTP`: give it the Zephyr selects it needs and move `tedge_http_download.c` + `tedge_url.c` (and `TEDGE_FIRMWARE_MAX_REDIRECTS`) onto it in CMakeLists/Kconfig (D6) -- [x] 1.3 `tedge_http_upload.c`: POST with a body callback, Bearer JWT for the tenant only, redirects as the download does (D6) - -## 2. The operation paths - -- [x] 2.1 Fail an operation the image cannot run, with a reason naming the missing feature, instead of leaving it pending (D8) -- [x] 2.2 Definitions for `tedge_register_log_type()` and `tedge_register_config_type()`, plus `-ENOTSUP` stubs in the header when the feature is off, so an application links either way -- [x] 2.3 Publish `118` (supported log types) on every connect, beside `114` (D7) - -## 3. The shell command - -- [x] 3.1 `tedge_shell_allow_list.c`: pure prefix matching, metacharacter refusal, empty list refuses everything (D1) -- [x] 3.2 `tedge_shell_cmd.c`: run through the dummy shell backend on its own thread, capture output, one at a time, timeout (D1) -- [x] 3.3 Dispatch `511`/`c8y_Command`: `501` first, then `503` with the output or `502` with the reason (D7) -- [x] 3.4 Kconfig: `TEDGE_SHELL_COMMAND_ALLOW_LIST`, `_TIMEOUT_S`, `_OUTPUT_BYTES`; select `SHELL_BACKEND_DUMMY`; flip the feature-available gate - -## 4. Logs - -- [x] 4.1 `tedge_log_ring.c`: a Zephyr log backend into a bounded ring in the module heap, oldest dropped (D3) -- [x] 4.2 `tedge_log_upload.c`: registry of log types, the measure-then-stream upload clamped to the measured length, truncation notice at the cap (D2, D4) -- [x] 4.3 Dispatch `522`/`c8y_LogfileRequest` with its filters, uploading off the client thread and answering `503,…,` (D7) -- [x] 4.4 Kconfig: `TEDGE_LOG_RING_BYTES`, `TEDGE_LOG_UPLOAD_MAX_BYTES`; flip the feature-available gate - -## 5. Crash dumps - -- [x] 5.1 `tedge_coredump.c`: detect a stored dump at boot, offer it as a log type, emit the `#CD:` text format, erase only after the cloud has it (D5) -- [x] 5.2 Kconfig `TEDGE_COREDUMP`: select `DEBUG_COREDUMP` with the flash-partition backend; document the partition requirement - -## 6. Use it - -- [x] 6.1 The Modbus application registers a log type of its own, so the repository shows the hook in use -- [x] 6.2 A test-only way to force a fault (shell command behind an app Kconfig), for verifying the dump path - -## 7. Tests - -- [x] 7.1 Unit tests: the allow-list (prefix match, arguments, metacharacters, empty list) -- [x] 7.2 Unit tests: the log ring (fits, wraps, oldest dropped) and the filters a reader is given -- [x] 7.3 Kconfig cases: the two features selectable without EXPERIMENTAL; log upload selects HTTP; shell command still needs SHELL; update `unimplemented-feature-locked` so only configuration management stays locked - -## 8. Hardware verification - -- [x] 8.1 C6: the log types appear in Cumulocity and the client's own log uploads and is readable -- [x] 8.2 The request's filters are honoured (a search text and a line limit) -- [x] 8.3 An allow-listed command returns its output; a command that is not allow-listed, and one with a metacharacter, are refused with a reason -- [x] 8.4 With no allow-list configured, every command is refused -- [x] 8.5 A forced fault produces a dump that uploads and decodes with Zephyr's parser -- [x] 8.6 An operation for a feature that is not built in fails with a reason instead of hanging -- [x] 8.7 The connection keeps working during an upload and a slow command -- [x] 8.8 Footprint row with diagnostics enabled - -## 9. Wrap-up - -- [x] 9.1 README: the log-type API, the allow-list and why it is empty, what the ring can hold, and how to decode a dump -- [x] 9.2 Profiles: diagnostics in `full.conf` (drop its experimental caveat where it no longer applies); design.md results; SCOPE roadmap P5 diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/.openspec.yaml b/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/.openspec.yaml deleted file mode 100644 index cbd245e..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-09-20 diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/design.md b/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/design.md deleted file mode 100644 index 6cc7bc7..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/design.md +++ /dev/null @@ -1,257 +0,0 @@ -## Context - -Spike B (archived in `2026-09-19-c8y-direct-spikes`) ran the whole firmware -path on an ESP32-C6: a Cumulocity binary over HTTPS into slot1, an MCUboot -swap, a test boot, confirmation after reaching the cloud, and a revert. Its -code is `apps/c8y-spike/src/spike_ota.c`, driven from a shell. - -What the spike established, and this design takes as given: -- **MCUboot swap-scratch takes ~40 s on the C6** (~19 s on the S3) for a - ~900 KB image, and the device is offline for it. `prov`, `bootreq` and - `storage` come through untouched. -- **An unconfirmed image is reverted** on the next reset, and the old image - finds the pending marker and reports the rollback. This fired for real. -- **Zephyr's HTTP client mis-reports chunked bodies** in its response - callback: one receive buffer can hold several segments, and the client - keeps the first segment's start with the last segment's length. Cumulocity - serves binaries chunked, so the image must be written from the parser's own - `on_body` callback (P5). -- **Binary URLs use the tenant-ID host** (`t.`), not the tenant's - host name, so "is this Cumulocity?" is a parent-domain rule, and the token - must never travel anywhere else. -- **Redirects are not followed** by Zephyr's client, and a GitHub release - asset sends a 913-byte `Location`. -- MQTT plus one HTTPS download peaks at **90.9 KB of TLS heap**. -- A CPU reset hangs MCUboot on the C6; the module's platform reset already - handles this (`c8y-direct-core`, D8). - -## Goals / Non-Goals - -**Goals:** the feature in the module behind `CONFIG_TEDGE_FIRMWARE_UPDATE`; -an image that cannot confirm itself is always reverted and always reported; -downloads that work against Cumulocity and a plain HTTPS host; the running -version visible in the inventory. - -**Non-Goals:** delta updates, updating the bootloader or the provisioner, -changing the swap strategy (P7), and keeping the spike app afterwards. - -## Decisions - -### D1: The download is its own file, usable by later features - -`tedge_http_download.c` takes a URL, an optional bearer token and a sink -callback, and handles redirects (3 hops), the `on_body` writing rule and the -token-domain rule. `tedge_firmware.c` passes a sink that writes into the -MCUboot slot through `flash_img`. Log upload and configuration (P5/P7 on the -roadmap) will reuse the same downloader with different sinks. - -*Alternative: fold it into the firmware feature.* Rejected: two later -features need exactly this, and it is the part with the subtle rules. - -### D2: The flow, and where the marker lives - -``` -515 -> 501 (EXECUTING, with the expected downtime in the text) - -> download into the secondary slot (~11 s for 900 KB) - -> persist "pending: ," in settings - -> request a test boot, platform reset - -> MCUboot swaps (~40 s C6 / ~19 s S3: offline) - -> new image boots "not confirmed", connects, hooks pass - -> confirm, 115,,, 503, clear the marker -``` - -If the new image never connects, or the application's -`firmware_confirm_check` refuses, the image stays unconfirmed and MCUboot -reverts it on the next reset. The old image then finds the marker while -running *confirmed*, which is how it knows a revert happened, and reports -`502,c8y_Firmware,"…"` with the reason. - -The marker is a settings key (`tedge/firmware`, "name,version"), so it -survives the swap and a power cut. - -**Its format is a contract between two firmware versions** and must never -change: the image that reads it is the one on the *other side* of a swap, -which may be older code. Learned the hard way (2026-09-20) by adding the -download size as a third field: the older image split on the first comma and -reported `"version":"1.3.0,887994"`. Anything else the flow wants to carry -across a swap goes in its own key (the size is in `tedge/firmware_size`), -which an older image ignores. - -### D3: The download runs on its own thread - -Like the remote-access bridge, the download blocks; the client thread must -keep servicing MQTT so the operation's progress reaches Cumulocity and the -session does not time out. `TEDGE_FIRMWARE_STACK_SIZE` defaults to **8192**, -the value the remote-access bridge needed for a TLS handshake on its own -thread. - -### D4: The token goes to the tenant only - -The client sends its bearer token when the URL's host ends in the tenant's -parent domain (`tedge-dev05.preprod.c8y.io` → `.preprod.c8y.io`), which -covers the tenant-ID host Cumulocity uses for binaries. Any other host, and -any host reached through a redirect, gets no token. A redirect that leaves -the tenant's domain drops the token for the rest of the transfer. - -### D5: Confirmation needs the cloud and the application - -`TEDGE_FIRMWARE_CONFIRM_AFTER_CONNECT` (default y) confirms once the client -reaches Cumulocity. The application's `firmware_confirm_check` hook runs -first: it can look at whatever it needs (its protocol server, a sensor, a -peer) and refuse. A refusal leaves the image unconfirmed, so the next reset -reverts it. With the option off, only the application's own call confirms an -image, for devices that want a longer probation. - -### D6: What the operator sees - -- `501` carries the expected downtime, so a 40-second silence is not - mistaken for a failure. -- `115,,` goes out on every connect, so the inventory always - shows what is running, not what was last installed. -- A revert reports `502` naming the version that failed. - -### D7: Kconfig - -`TEDGE_FIRMWARE_UPDATE` already depends on `BOOTLOADER_MCUBOOT` and selects -`TEDGE_HTTP`. It adds `TEDGE_FIRMWARE_STACK_SIZE`, -`TEDGE_FIRMWARE_CONFIRM_AFTER_CONNECT` and `TEDGE_FIRMWARE_MAX_REDIRECTS` -(3), and selects `IMG_MANAGER`, `MCUBOOT_IMG_MANAGER`, `STREAM_FLASH` and -`IMG_ERASE_PROGRESSIVELY` (the spike needed progressive erase to keep the -download from stalling). - -### D10: An unconfirmed image resets itself - -An image that cannot reach the cloud never confirms, and **nothing else -would reset the device**: the network is up, so the application's -connectivity watchdog sees nothing wrong, and the client simply retries with -a growing back-off. Seen on hardware: a bad image ran for 8 minutes, happily -retrying, while MCUboot waited for a reset that never came. - -So a test-booted image arms `TEDGE_FIRMWARE_CONFIRM_TIMEOUT_S` (default -900 s) at start-up and resets itself when it expires, which lets the -bootloader roll it back. Confirmation cancels it. - -**The limit of this:** the deadline lives in the *new* image, so it only -protects against images that carry it. An image without it (an older build, -or one that crashes before the client starts) still needs an external reset -— a hardware watchdog, or the application's own. This is worth saying aloud -in the README: MCUboot's revert is the safety net, and the deadline is what -pulls the trigger. - -### D8: The same version is refused - -A `515` whose name and version match the running image SHALL be failed at -once with a reason, before anything is downloaded (decided with the tenant -owner, 2026-09-20). Re-installing the running image costs a 900 KB download -and ~40 s of downtime for no change, and an operator who wants that can -install a different version or erase the device. The check uses the version -MCUboot reports for the running image, not the marker. - -### D9: Progress on a free-form topic, QoS 0 - -Progress is published to `te/device////progress/firmware` at QoS 0 -(decided with the tenant owner, 2026-09-20): it is a stream of hints, not -state, so losing one costs nothing and it must not sit in a queue behind -device-management traffic. - -```json -{"name":"zephyr-modbus-server","version":"0.3.0","phase":"downloading", - "percent":45,"bytes":405504,"total":898378} -``` - -Cumulocity serves binaries **chunked**, so there is no `Content-Length`. -The download therefore asks for the whole file as a range (`Range: bytes=0-`) -and reads the size out of the `Content-Range: bytes 0-887801/887802` header -of the `206` reply (suggested by the tenant owner, 2026-09-20). A server -that ignores the range header answers `200` as before, and progress then -carries `bytes` only, every 128 KB. With a known size, `percent` is included -and the step is `TEDGE_FIRMWARE_PROGRESS_PERCENT` (default 10). - -Phases: `downloading` (never more than one message a second), then `installing` (the test boot is -requested; the device is about to go offline for the swap), then `done` or -`failed` with a `reason`. A tenant maps the topic with a Smart Function, the -same way as the twin data. - -Core MQTT has no free-form topics, so a Core MQTT build publishes no -progress; the operation's `501`/`503`/`502` still tell the story. The option -`TEDGE_FIRMWARE_PROGRESS` (default y with the MQTT Service) turns it off for -devices on a metered link. - -## Risks / Trade-offs - -- [The device is offline for the swap (P7)] → the downtime is in the `501` - text and the README; measuring swap-using-move stays a separate task. -- [A corrupt image reaches the slot] → MCUboot's signature check rejects it, - the old image boots and reports the rollback. Proven by the chunked-body - bug. -- [An image that connects but is broken in a way the hook cannot see] → the - hook is the application's to write; the README says what belongs in it. -- [A download holds a TLS session for ~11 s] → the profiles size the heap for - MQTT plus one download; remote access and a download at once need a third - session, which the C6 cannot afford with 16 KB records. -- [The token could leak to a redirect target] → the domain rule, tested. - -## Migration Plan - -- `apps/c8y-spike` is deleted once this change is verified: firmware update - is the last feature it demonstrates alone. The archived spike design keeps - its measurements. -- Devices already running a spike image are test devices; they get the - module's firmware through the same operation. - -## Open Questions - -- None outstanding; the two questions above were answered on 2026-09-20. - -## Results - -### First hardware run (C6, Modbus + client, 2026-09-20) - -| Check | Result | -|---|---| -| Install 0.3.0 from Cumulocity | SUCCESSFUL: 887 KB downloaded in 28 s, swap, confirmed 14 s after the reboot, ~105 s end to end | -| The inventory afterwards | `c8y_Firmware` shows the running version, read from MCUboot's header, not the requested one | -| Install the running version | FAILED, "zephyr-modbus-server 0.6.0 is already running"; nothing downloaded, no reboot | -| Install an older version (0.6.0 → 0.4.0) | SUCCESSFUL: downgrades are allowed, only the *same* version is refused | -| Progress | `te/device////progress/firmware` at QoS 0: `downloading` every 128 KB, then `installing`, then `done` | - -### Rollback (tasks 4.3, 2.5) - -| Check | Result | -|---|---| -| An image that cannot reach the cloud (a tenant host that does not resolve) | it downloads and boots, never confirms, and after 180 s resets itself; MCUboot restores the previous image | -| What the operator sees | the restored image reports `502`: "1.2.0-deadline-test was rolled back; the device is running 1.1.0", and the inventory shows the restored version | -| Progress | a final `{"phase":"failed","reason":"rolled back"}` message | -| Without the deadline (an image built before it) | the bad image ran for 8 minutes retrying; the rollback only happened when something else reset the device. This is why D10 exists | - -### The application's veto, the S3, and the footprint (tasks 4.4, 4.7, 4.8) - -| Check | Result | -|---|---| -| The application refuses the new image (a test hook in the Modbus glue) | the image is never confirmed, the deadline resets the device, the previous image comes back and the operation FAILS with "the application refused 1.7.0-app-refuses; it will be rolled back" | -| ESP32-S3 with the TLS heap in PSRAM | an update from the cloud is SUCCESSFUL; the same flow, timings and progress as the C6 | -| Footprint, C6 Modbus + client + remote access + firmware | 809 KB text, 117 KB of libc heap left | -| Footprint, S3 Modbus + client + firmware (PSRAM) | 678 KB text, 113 KB of libc heap left | - -Firmware update adds about **11 KB of text** over the client with remote -access, plus the second TLS session's heap, which the profiles size. - -**Three findings, all fixed:** - -1. **A refusal must publish `501` first.** Cumulocity's `502` fails the - oldest *executing* operation, so an operation refused straight out of - PENDING never left that state. The client now reports `501` for every - firmware operation it receives, then `502` with the reason if it refuses. - This also cleared a queue of operations that had piled up. -2. **No percentage for a Cumulocity download, at first.** The binary is - chunked, so `Content-Length` is absent. The tenant owner pointed out that - a range request gets the size back in `Content-Range`, so the download now - sends `Range: bytes=0-`, accepts `206`, and reports true percentages: - `{"phase":"downloading","percent":40,"bytes":355866,"total":887978}`. A - server that ignores ranges still works, reporting bytes only. Every - message states `total` once it is known, including the `done` message - after the reboot, which reads it from `tedge/firmware_size`. -3. **The version to compare is MCUboot's,** not the application's - `APP_VERSION_STRING`: an image built from the same source with a different - signed version must still be installable. The client reads the running - version from the image header, and logs it at every connect. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/proposal.md b/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/proposal.md deleted file mode 100644 index 58ef257..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/proposal.md +++ /dev/null @@ -1,90 +0,0 @@ -## Why - -A device that cannot be updated in the field has a short life. Cumulocity's -firmware operation is the standard way to do it, and the pieces are already -on the boards: MCUboot with a second slot, the layout this repository's -applications are built with, and a client that holds a token for the tenant. - -`c8y-direct-spikes` proved the whole path on an ESP32-C6 (Spike B, unknowns -U5 and U6): a real Cumulocity binary downloaded over HTTPS at 79 KB/s, an -MCUboot swap, a test boot, confirmation only after the new image reached -Cumulocity, and a rollback that reported itself — the rollback fired for real -when a Zephyr HTTP-client bug corrupted an image, and the safety net worked. -`c8y-direct-core` and `c8y-direct-remote-access` then built the module around -it. This change moves firmware update out of `apps/c8y-spike` and into -`tedge-zephyr` (roadmap step P3). - -## What Changes - -- **`CONFIG_TEDGE_FIRMWARE_UPDATE` becomes real.** On `515,,, - ,` the client reports `501`, streams the image into the - secondary slot, requests a test boot and resets. -- **Confirmation is earned, not assumed.** The new image confirms itself only - after it has connected to Cumulocity *and* the application's - `firmware_confirm_check` hook has passed. Otherwise MCUboot reverts it on - the next reset, and the old image reports `502` with the reason, so a bad - image cannot brick a fleet. -- **Downloads that work in the field:** up to three redirects (a GitHub - release asset sends a 913-byte `Location`), the JWT sent only to the - tenant's own domain, the image written from the parser's `on_body` - callback (Zephyr's HTTP client corrupts chunked bodies otherwise, spike - problem P5), and a longer TLS connect timeout for hosts outside the tenant. -- **State the operator can see:** `115,,` on every connect so - the inventory shows what is running, and the expected downtime named in the - operation while the swap happens (P7: the device is dark for ~40 s on the - C6, ~19 s on the S3). -- **Boards without MCUboot cannot select it:** the Kconfig dependency already - exists; this change makes it load-bearing. - -## Non-goals - -- Telemetry, logs, configuration and certificate renewal (P4–P7). -- Delta or compressed updates, and updating anything other than the - application image (the bootloader, the provisioner, the Wi-Fi blobs). -- Removing `apps/c8y-spike`: it goes once this change is verified, since - firmware update is the last feature it still demonstrates alone. -- Changing MCUboot's swap strategy (P7 suggests measuring swap-using-move; - that is a separate experiment). - -## Resource constraints - -From Spike B on the ESP32-C6, on top of the client: - -| Item | Cost | -|---|---| -| Text | ~55 KB (HTTP client, DFU, the flow) | -| TLS heap | MQTT + one HTTPS download: **90.9 KB peak** with 16 KB records | -| Static RAM | a download thread (8 KB stack in the spike), a ~1 KB URL buffer, the HTTP receive buffer | -| Flash | a second slot the size of the image, already in the layout | -| Time | download 11.4 s for 898 KB, swap 40 s (C6) / 19 s (S3), operation SUCCESSFUL in 69 s | - -Boards: the ESP32-C6 and ESP32-S3 (which needs its TLS heap in PSRAM to -afford a second session). The WROOM cannot run this feature beside MQTT. - -## Capabilities - -### New Capabilities - -- `tedge-firmware-update`: the operation, the download, the test boot and - the confirmation rules. - -### Modified Capabilities - -- `device-management-features`: the baseline says a feature must be - selectable and advertise itself; firmware update adds the requirement that - an image is confirmed only after it proves itself, and that the running - version is reported. -- `tedge-client-module`: `firmware_confirm_check` becomes a requirement with - a scenario, next to the restart and remote-access hooks. - -## Impact - -- `tedge-zephyr/src/`: new `tedge_firmware.c` (the flow) and - `tedge_http_download.c` (redirects, `on_body` writing, the JWT rule), and a - dispatcher entry in `tedge_c8y.c`. -- Zephyr facilities: `http_client`, `flash_img`/`dfu`, `mcuboot`, the - existing sockets and credentials. -- Applications: the C6 and S3 Modbus profiles gain the feature; the image - must be built with sysbuild and MCUboot, as these boards already are. -- Carries spike problems P5 (report the HTTP-client bug upstream) and P7 - (the downtime during a swap). diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/specs/device-management-features/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/specs/device-management-features/spec.md deleted file mode 100644 index 3e238b7..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/specs/device-management-features/spec.md +++ /dev/null @@ -1,26 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Advertised capabilities match the build - -The device SHALL advertise to the cloud or gateway only the operations and -capabilities of features compiled into the running image. This covers supported -operations, log types and configuration types. When it receives an operation for -a feature that is not compiled in, the device SHALL mark that operation as -failed with a reason that names the missing feature. It SHALL NOT ignore the -operation or leave it pending. - -#### Scenario: Firmware update advertises itself and the running version - -- **WHEN** an image with the firmware-update feature connects -- **THEN** its supported operations include firmware update, and it reports - the name and version of the image that is running - -#### Scenario: Supported operations reflect the image - -- **WHEN** an image without the firmware-update feature connects -- **THEN** its supported-operations list does not include firmware update - -#### Scenario: Operation for an absent feature - -- **WHEN** the device receives an operation whose feature is not compiled in -- **THEN** it marks the operation as failed with a reason naming the feature diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/specs/tedge-client-module/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/specs/tedge-client-module/spec.md deleted file mode 100644 index 8ab7efe..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/specs/tedge-client-module/spec.md +++ /dev/null @@ -1,47 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Integration hooks for the host application - -The public API SHALL let the host application: - -- provide the device identity: external ID, name, type, and firmware name and - version (with a documented default for each); -- push telemetry: measurements, events and alarms; -- register its own operation handlers, log types and configuration types; -- prepare for or veto a restart requested by the cloud; -- add its own health checks that must pass before a newly updated firmware image - is confirmed; -- observe the client's state (for example to drive a status LED); -- optionally report liveness progress to the application's watchdog. - -The module SHALL NOT call `sys_reboot()` without first giving the application's -restart hook the chance to respond. - -#### Scenario: Application narrows remote-access targets - -- **WHEN** the application registers a remote-access target hook and the - cloud asks for a tunnel to a target the built-in policy allows but the hook - refuses -- **THEN** the client opens no connection and fails the operation with the - hook's reason - -#### Scenario: Application vetoes a new firmware image - -- **WHEN** a newly installed image is running and connected, and the - application's firmware-check hook reports that the application is not - healthy -- **THEN** the client does not confirm the image, so the bootloader reverts - it on the next reset - -#### Scenario: Application-supplied telemetry - -- **WHEN** the application calls the telemetry API with a measurement -- **THEN** the client publishes it on the configured transport, and no telemetry - is read from anything outside the calls the application makes - -#### Scenario: Application vetoes a restart - -- **WHEN** a restart operation arrives and the application's restart hook - declines it -- **THEN** the client does not reboot and marks the operation as failed with the - reason returned by the hook diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/specs/tedge-firmware-update/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/specs/tedge-firmware-update/spec.md deleted file mode 100644 index b14a3c8..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/specs/tedge-firmware-update/spec.md +++ /dev/null @@ -1,138 +0,0 @@ -## ADDED Requirements - -### Requirement: The device installs a firmware image the cloud offers - -When the firmware-update feature is built in, the client SHALL handle the -cloud's firmware operation by downloading the image at the given URL into the -bootloader's secondary slot, requesting a test boot and resetting the device -through the platform reset. It SHALL mark the operation as executing before -the download starts and SHALL name the expected time the device will be -offline while the bootloader swaps. - -#### Scenario: An operator installs a new version - -- **WHEN** an operator installs a firmware version on the device -- **THEN** the device downloads it, reboots into it, and the operation ends - successfully once the new image is running and connected - -#### Scenario: The download fails - -- **WHEN** the image cannot be downloaded (the host is unreachable, or the - transfer breaks) -- **THEN** the operation fails with the reason, the running image is - untouched, and the device stays connected - -### Requirement: A new image is confirmed only after it proves itself - -A newly installed image SHALL be confirmed only after it has connected to the -cloud and the application's firmware-check hook, if any, has passed. An image -that does not confirm SHALL be reverted by the bootloader on the next reset. -After a revert, the client SHALL report the operation as failed, naming the -version that did not come up. - -#### Scenario: The new image cannot connect - -- **WHEN** the new image runs but never reaches the cloud -- **THEN** it stays unconfirmed, resets itself once its confirmation - deadline expires, and the bootloader restores the previous image, which - reports the failure with the version - -#### Scenario: The application refuses the new image - -- **WHEN** the new image connects but the application's firmware-check hook - refuses it -- **THEN** the image is not confirmed and is reverted, and the failure names - the application's reason - -#### Scenario: A corrupt image reaches the slot - -- **WHEN** the downloaded image fails the bootloader's signature check -- **THEN** the previous image boots, the slot is discarded, and the operation - is reported as failed - -### Requirement: An image that cannot confirm itself gives up - -A test-booted image SHALL reset the device when it has not been confirmed -within a configured time, so that the bootloader can restore the previous -image without anyone intervening. Confirming the image SHALL cancel that -deadline. The deadline MAY be disabled for devices that have another way to -reset themselves. - -#### Scenario: A new image that never reaches the cloud - -- **WHEN** a newly installed image runs but cannot reach the cloud, and the - network itself is healthy so nothing else restarts the device -- **THEN** the image resets the device once its deadline expires, and the - previous image comes back - -### Requirement: Downloads follow redirects and keep the token to the tenant - -The client SHALL follow HTTP redirects up to a configured limit, including to -another host or scheme, and SHALL handle a redirect target of at least one -kilobyte. It SHALL send its cloud token only to hosts within the tenant's own -domain, and SHALL drop it when a redirect leaves that domain. It SHALL write -the image as the parser reports each body segment, so that a chunked transfer -is stored exactly as sent. - -#### Scenario: A release asset on another host - -- **WHEN** the firmware URL redirects to a download host outside the tenant -- **THEN** the client follows the redirect, sends no token to that host, and - stores the image correctly - -#### Scenario: A chunked transfer - -- **WHEN** the cloud sends the image with chunked transfer encoding -- **THEN** the stored image matches what was sent, and the bootloader accepts - it - -### Requirement: Installing the version already running is refused - -The client SHALL fail a firmware operation whose name and version match the -image that is running, with a reason saying so, and SHALL NOT download -anything or reboot. - -#### Scenario: The same version is offered again - -- **WHEN** an operator installs the version the device is already running -- **THEN** the operation fails with a reason naming the version, and the - device keeps running undisturbed - -### Requirement: The device reports the progress of an update - -When it has free-form topics available, the client SHALL publish the -progress of an update as unreliable messages (at-most-once) on a documented -topic, carrying the name, version, phase and how much has been transferred. -It SHALL report a percentage only when the server states the length of the -transfer, and the number of bytes otherwise. -Progress messages SHALL be rate-limited, and SHALL NOT be published on -transports without free-form topics, where the operation status is the only -report. Losing a progress message SHALL have no effect on the update. - -#### Scenario: An operator watches a download - -- **WHEN** a device is downloading an image -- **THEN** it publishes progress messages naming the phase and how much has - arrived, spaced by the configured step - -#### Scenario: The device is about to swap - -- **WHEN** the download is finished and the test boot is requested -- **THEN** a progress message says the device is installing, before it goes - offline for the bootloader swap - -### Requirement: The running firmware version is visible in the cloud - -The client SHALL report the name and version of the image that is actually -running, on every connect, so that the cloud shows what the device runs -rather than what was last installed. - -#### Scenario: After an update - -- **WHEN** a device has been updated and reconnects -- **THEN** the cloud shows the new version - -#### Scenario: After a revert - -- **WHEN** an image is reverted and the previous one reconnects -- **THEN** the cloud shows the previous version, not the one that failed diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/tasks.md b/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/tasks.md deleted file mode 100644 index 28dc3f9..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-firmware-update/tasks.md +++ /dev/null @@ -1,42 +0,0 @@ -## 1. The downloader - -- [x] 1.1 `tedge-zephyr/src/tedge_http_download.c`: URL parsing, TLS or plain HTTP, a sink callback, and writing from the parser's `on_body` (P5) -- [x] 1.2 Redirects: up to `TEDGE_FIRMWARE_MAX_REDIRECTS` hops, relative, absolute and cross-scheme, with a URL buffer of at least 1 KB -- [x] 1.3 The token rule: sent only to hosts in the tenant's parent domain, dropped when a redirect leaves it (D4) -- [x] 1.4 A longer TLS connect timeout for hosts outside the tenant (P-384 chains take ~13 s) - -## 2. The firmware flow - -- [x] 2.1 `tedge_firmware.c`: handle `515`, report `501` with the expected downtime, run the download on its own thread (D3) -- [x] 2.2 Write into the secondary slot with `flash_img`, progressive erase, and check the slot's state before starting -- [x] 2.3 Persist the pending marker in settings, request a test boot and reset through the platform reset -- [x] 2.4 After the swap: confirm once connected and the application's hook passes; `115,,`, `503`, clear the marker -- [x] 2.5 Revert path: the old image finds the marker while running confirmed and reports `502` naming the version -- [x] 2.6 Report `115` on every connect, whatever the image (D6) -- [x] 2.7 Refuse a `515` whose name and version match the running image, with a reason, before downloading (D8) -- [x] 2.8 Progress on `te/device////progress/firmware` at QoS 0: phases, percentage step, rate limit, and nothing on Core MQTT (D9) -- [x] 2.9 Kconfig: `TEDGE_FIRMWARE_STACK_SIZE`, `TEDGE_FIRMWARE_CONFIRM_AFTER_CONNECT`, `TEDGE_FIRMWARE_MAX_REDIRECTS`; select the DFU options; drop the experimental gate - -## 3. Tests - -- [x] 3.1 Unit tests: URL parsing and the redirect rules, the token-domain rule (tenant host, tenant-ID host, a foreign host), the `515` fields, and the progress payload -- [x] 3.2 Kconfig cases: the feature needs MCUboot; with it off, no HTTP client or DFU in the image -- [x] 3.3 The secrets check still passes (the token must not reach a log) - -## 4. Hardware verification - -- [x] 4.1 C6 Modbus + client: install a new version from Cumulocity; record download rate, swap time and the total time to SUCCESSFUL -- [x] 4.2 The inventory shows the running version after the update, and after a reboot -- [x] 4.3 Revert: install an image that cannot connect (wrong tenant) and confirm the previous image comes back and reports the failure -- [x] 4.4 Revert: an image whose application hook refuses it -- [x] 4.5 Progress messages arrive during a real update, at the configured step, ending with installing -- [x] 4.6 A download from a non-tenant host (a GitHub release asset) works and carries no token -- [x] 4.7 TLS heap with MQTT + download, and the footprint row in `measure_tedge.sh` -- [x] 4.8 S3 with the TLS heap in PSRAM: one update end to end - -## 5. Wrap-up - -- [x] 5.1 README: the feature's requirements (slot layout, MCUboot, heap for a second session), what belongs in the confirm hook, and the downtime during a swap -- [x] 5.2 Profiles: firmware update in `full.conf`; record the results in design.md -- [x] 5.3 Delete `apps/c8y-spike` and its references, now that every feature it demonstrated lives in the module -- [x] 5.4 Report the Zephyr HTTP-client chunked-body bug upstream (P5): written up with a reproduction in `tedge-zephyr/docs/zephyr-http-chunked-body-bug.md`, ready to file (filing it needs a Zephyr GitHub account) diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/design.md b/openspec/changes/archive/2026-09-20-c8y-direct-parameters/design.md deleted file mode 100644 index 6e1c900..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/design.md +++ /dev/null @@ -1,259 +0,0 @@ -## Context - -The client can be operated, can report, and can explain itself. What it -cannot do is be *configured*: `tedge_register_config_type()` has no -definition, and the shape it declares — a snapshot reader and a chunked -writer — is a file-shaped answer on a device with no files. - -Cumulocity offers two mechanisms: - -| | Configuration files (`c8y_Configuration`) | Parameters (`c8y_ParameterUpdate`) | -|---|---|---| -| What travels | a file, uploaded and downloaded over HTTPS | the changed values, inside the operation | -| What an operator sees | a blob to download and read | typed fields, with the current values | -| Validation | none until the device chokes | a JSON Schema, plus whatever the device checks | -| What the device needs | the HTTP path both ways, somewhere to put a file | the twin it already publishes | -| In thin-edge.io | `tedge-configuration-plugin` | [`tedge-parameter-plugin`](https://github.com/thin-edge/tedge-parameter-plugin) | - -For a microcontroller the right-hand column wins on every row, so this -change implements parameters and removes the file-shaped API rather than -leaving both. - -What the plugin does on Linux, and what this client mirrors: a **parameter -set** is a named group with a JSON Schema registered in Cumulocity's Digital -Twin Manager; the current values live in the device twin under the set's -name; a change arrives as `c8y_ParameterUpdate` (SmartREST template 532) and -the device applies it and updates the twin. - -## Goals / Non-Goals - -**Goals:** an application declares typed parameters in a few lines; an -operator sees the current values and changes one; a bad value is refused -with a reason and changes nothing; values survive a reboot; no filesystem, -no HTTP, no extra TLS session. - -**Non-Goals:** configuration files, nested structures, registering the -schema in the tenant, a general settings API for the application's own use. - -## Decisions - -### D1: A parameter is declared, not discovered - -```c -static const struct tedge_parameter pump_params[] = { - TEDGE_PARAM_INT("interval_s", 30, 5, 3600, - "Seconds between measurements"), - TEDGE_PARAM_BOOL("auto_mode", true, "Run the pump automatically"), - TEDGE_PARAM_ENUM("profile", "normal", ("normal", "quiet", "boost"), - "Operating profile"), - TEDGE_PARAM_STRING("site", "", 32, "Where this device is"), -}; - -tedge_declare_parameters("pump", pump_params, ARRAY_SIZE(pump_params), - on_parameters_changed, NULL); -``` - -The table is `const` in the application's flash: the client holds a pointer -to it, the current values, and nothing else. Types are the four an MCU -actually needs; a value the application wants structured is its own to -encode, and the README says so. - -*Alternative: discover parameters from the settings subtree.* Rejected: -nothing would state a type, a range or a default, so nothing could be -validated or shown properly. - -### D2: The twin is the operator's view - -On every connect, and after every accepted change, the client publishes the -set as one twin fragment named after it: - -``` -te/device////twin/pump {"interval_s":30,"auto_mode":true,"profile":"normal","site":""} -``` - -Twin data is state, not events (the rule the client already follows), so a -reboot never leaves a stale view. On Core MQTT the same values go out as an -inventory update, as the other fragments do. - -### D3: A change is all-or-nothing, and validated against the declaration - -The operation carries the set's values — in practice the whole set, since -that is what Cumulocity sends (D6). The client checks every -one against its declaration — type, range, length, allowed values — before -storing any of them. If one fails, **nothing is applied** and the operation -fails naming the parameter and the limit it broke. A half-applied -configuration is how a device ends up in a state nobody can reproduce. - -The application's hook is called once, after the values are stored, with the -set. It may still refuse (a combination only it understands), and then the -client rolls back to the stored values and fails the operation with the -hook's reason. - -### D4: Values live in the client's settings, one key each - -`tedge/param//`, written only when a value actually changes, so an -operator pressing "save" with nothing changed costs no flash. On startup the -stored values replace the declared defaults, and anything stored that is no -longer declared is dropped (a firmware update may remove a parameter). - -### D5: The device prints its own schema - -The schema in the tenant must match the declaration in the firmware, and -writing it twice by hand is how they drift. With -`CONFIG_TEDGE_PARAMETERS_SCHEMA` the client can print the JSON Schema of a -set — on the console, through the `tedge params schema` shell command — for -whoever registers it in the Digital Twin Manager. It is generated from the -same table the validation uses, so the two cannot disagree. - -What it prints is the whole body of the registration call, not a bare -schema, because that is what the person pasting it needs: - -```json -{"identifier":"pump", - "jsonSchema":{"type":"object","title":"pump","properties":{ - "interval_s":{"type":"integer","title":"interval_s","order":1, - "default":30,"minimum":5,"maximum":3600, - "description":"Seconds between measurements"}}}, - "contexts":["asset","event","operation"]} -``` - -Three details that are not optional: - -- **`identifier` is the set name**, and it is the same string as the twin - fragment (D2) and the operation fragment's suffix (D6). One name, three - uses — so the client validates it once, at declaration. -- **`contexts` must contain `asset` and `operation`**, or the UI shows the - values but refuses to let anyone edit them. -- **`title` and `order`** sit beside the JSON Schema keywords so the UI can - lay the fields out; `order` comes from the position in the declared - table, `title` from the parameter's name. - -Registering an already-registered identifier again is documented as not -working, so a changed declaration means delete then create. The README says -so, with the `DELETE` call. - -### D6: The operation is answered like every other one - -The operation arrives as JSON with its id (`c8y-operation-ids`), is turned -into a line the dispatcher understands, and is completed with `506,` or -failed with `505,,`. It runs on the client thread: validating -and storing a handful of values is microseconds, and there is nothing to -wait for. - -**The fragment is named after the set, and it is empty.** It is not a fixed -`c8y_ParameterUpdate`; the set's name is the suffix, and the client matches -on the prefix and takes the name from what follows it. But that fragment -carries no values — it is a marker saying *which set*. The values arrive in -a separate top-level fragment named after the set, and they are the **whole -set**, not only what an operator touched: - -```json -{"id":"214989","deviceId":"79211726", - "description":"Update parameter 'tedge'", - "tedge":{"log_level":"dbg","health_interval_s":900, - "required_interval_min":30}, - "c8y_ParameterUpdate":{}, "c8y_ParameterUpdate_tedge":{}} -``` - -That is how one device can offer more than one set, and it is why the -dispatcher cannot key on an exact fragment name the way it does for every -other operation. The values are typed JSON — the schema registered in the -tenant is what gives them their types — so nothing has to be parsed out of -strings. - -An operation built by hand through the REST API more naturally carries the -values inside the suffixed fragment, so the client accepts that too when -the named fragment is missing or empty. - -See [findings-mechanism.md](./findings-mechanism.md); this shape was -corrected after a real operation from the tenant failed against the first -reading of it. - -The SmartREST form of the same thing (static template `532`) flattens it -into *name, type, value* triplets with every value a string. This client -does not need it: it has taken operations as JSON since -`c8y-operation-ids`. Template `408` reports a parameter change as an event, -which this client also does not need, because the twin carries the state -(D2). - -### D8: The client declares a set for itself - -Everything in this module is configured at build time, which is the right -default on a microcontroller: an option that cannot change cannot surprise -anyone, and it costs no flash. But a handful of those choices are ones -somebody wants to revisit on a device that is already in the field and -misbehaving, and reflashing a fleet to turn up a log level is not a plan. - -So the client declares a set of its own, `tedge`, through the same API an -application uses: - -| Parameter | What it changes | Where it takes effect | -|---|---|---| -| `log_level` | how much the client logs | the logging filter, at once with `CONFIG_LOG_RUNTIME_FILTERING`, otherwise next boot | -| `health_interval_s` | how often it reports its own health | `tedge_health.c`, next time it is due | -| `required_interval_min` | the window Cumulocity calls it offline by | republished as `117,` on change | -| `remote_access` | whether the cloud may tunnel in | checked before a tunnel is opened | - -**Only what a running device can honour.** A setting that sizes a buffer, a -stack or a thread is fixed once the image is linked, and offering it would -show an operator a value the device quietly ignores — worse than offering -nothing. So the set holds only values that are read where they are used, -and each entry above names that place. Whatever the running image does not -have is not in the set either: no health feature, no `health_interval_s`. - -**The application still wins.** The client declares its set *after* the -application has declared its own, so a name an application already took -stays the application's — adding a client setting later can never quietly -take one over. - -*Alternative: expose these as custom operations, or as another transport -message.* Rejected: they are settings, they want validating, reporting and -remembering, and that machinery now exists. A second mechanism for the same -shape of thing is how two mechanisms drift. - -*Alternative: leave them build-time only.* That is what the client did -before, and it is why a device that logs too little has to be recovered -from a bench rather than from a desk. - -`CONFIG_TEDGE_PARAMETERS_SELF` turns the whole thing off for an image that -wants nothing of it. - -### D7: The file-shaped API goes - -`tedge_register_config_type()`, `tedge_config_reader_t` and -`tedge_config_writer_t` are removed from the public header rather than left -as `-ENOTSUP` stubs for a feature this client is not going to grow. The -header says what replaced them, and the archived proposal says why. - -## Risks / Trade-offs - -- [The tenant may not have Parameter Update] → it needs the Digital Twin - Manager and the device-parameter microservice, so the first task is to - confirm the feature exists in the tenant and capture what `532` actually - delivers, before anything else is written. -- [The schema lives in two places] → generated from the declaration, never - written twice. -- [A parameter removed by a firmware update] → stored values with no - declaration are dropped at startup, and the twin shows what the running - image actually has. -- [An application that wants structure] → out of scope, stated plainly, with - the advice to encode it in a string and own the parsing. - -## Migration Plan - -- Nothing uses the configuration API today (it has no definition anywhere), - so removing it breaks no build. -- An application that adopts parameters gets its defaults on first boot and - keeps whatever an operator sets afterwards. - -## Open Questions - -- ~~Whether the operation carries the whole set or only the changed - values.~~ **Answered, after the change was archived**: the whole set. A - real operation from the tenant carried all three of a set's values with - one of them changed. The client validates and applies whatever is - present, and only writes what actually moved, so whole-set sends cost no - extra flash. A value the operation omits keeps its current value. -- Whether a parameter should be able to be marked read-only (a value the - device reports but the cloud cannot set), or whether that is just twin - data. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/findings-mechanism.md b/openspec/changes/archive/2026-09-20-c8y-direct-parameters/findings-mechanism.md deleted file mode 100644 index 14b703c..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/findings-mechanism.md +++ /dev/null @@ -1,478 +0,0 @@ -# Task 1: what Parameter Update actually is - -Notes taken against the `tedge-dev05` tenant and the -[tedge-parameter-plugin](https://github.com/thin-edge/tedge-parameter-plugin) -before any device code was written. Task 1.1 is answered here. Task 1.2 got -as far as the tenant allows and then stopped on a missing microservice -subscription — see *1.2, as far as it could be taken*. - -## 1.1 What the tenant actually has - -`c8y applications list` on `tedge-dev05.preprod.c8y.io` (tenant -`t297258657`) shows all four pieces: - -| Name | Type | Context path | -|---|---|---| -| `digital-twin-manager` | HOSTED | `digital-twin-manager` | -| `dtm-plugins` | HOSTED | `dtm-plugins` | -| `dtm` | MICROSERVICE | `dtm` | -| `device-parameter` | MICROSERVICE | `device-parameter` | -| `Device Parameters Plugin` | HOSTED | `device-parameters-ui-plugin` | - -**Appearing in that list is not the same as being subscribed.** Every one of -them is `availability=MARKET`, owned by the `management` tenant — that is -the market catalogue, not this tenant's subscriptions. Asking the services -themselves gives the real answer, and the two differ: - -| Microservice | Subscribed? | Evidence | -|---|---|---| -| `dtm` | **yes** | `POST /service/dtm/definitions/properties` → `201` | -| `device-parameter` | **no** | `403 Microservice/Access Denied` — *"Microservice device-parameter is not subscribed by tenant t297258657"* | - -So the Digital Twin Manager half works and a schema can be registered, but -the half that turns an operator's edit into a `c8y_ParameterUpdate_` -operation is not there. This is exactly what the plugin's README warns -about: *"You may need to create a Cumulocity support ticket to request -access to the following microservices."* **It blocks task 1.2**, and it will -block the hardware verification in section 8 just as hard — a device can -report its set and nothing will ever be able to change it. - -The roles are fine. An earlier reading of this said the user was missing -`ROLE_DIGITAL_TWIN_DEFINITIONS_*`; that was a revoked session token -returning 401 on everything, not a permission problem. With a fresh token -every DTM call works. - -## 1.1 What the operation carries - -Two shapes, and the important one is **not** what the design assumed. - -### The fragment is named after the set - -The plugin finds the set by looking for a fragment *prefix*, not a fixed -name ([`src/parameter_update.sh`][sh]): - -```sh -get_parameter_type() { - echo "$1" | jq -r '.operation | keys | .[] | - select(. | startswith("c8y_ParameterUpdate_")) | - sub("^c8y_ParameterUpdate_"; "")' -} -``` - -So a set called `pump` arrives as: - -```json -{ - "id": "1234", - "deviceId": "5678", - "c8y_ParameterUpdate_pump": { "interval_s": 60, "auto_mode": false }, - "status": "PENDING" -} -``` - -The values are **typed JSON**, not strings — the schema registered in the -Digital Twin Manager gives them their types. - -> **This corrects D6 and the spec, which both say the device handles a -> fragment called `c8y_ParameterUpdate`.** It has to match on the -> `c8y_ParameterUpdate_` prefix and take the set name from the suffix, -> which is also how one device can offer more than one set. - -> **And this reading was itself half wrong — see *The shape, corrected* -> at the end of this note.** The suffixed fragment names the set but -> carries no values; `jq ".operation.\"$TYPE\""` above indexes the *bare* -> set name at the top level of the operation, not the suffixed fragment. -> It was there to be read correctly and was not. - -### Over SmartREST, template 532 - -For a device on static templates the same change arrives flattened, with -the types spelled out one letter at a time: - -``` -532,someSerial,MyParameter,b,true,DeviceMaintainer.name,s,John Smithsky,DeviceMaintainer.contact,s,12312,Problems,, -``` - -Repeating triplets of *name, type, value*, where the type is `s` (string), -`n` (number), `b` (boolean) or empty (null), and the value is always a -string. Maximum 100 parameters per operation. Template 408 is the -counterpart the device can use to *report* a parameter update as an event. - -Since `c8y-operation-ids` this client takes operations as JSON with their -id, so **532 is not needed** — the JSON fragment above is the path to -implement, and 408 is not needed either because the twin carries the state. - -## 1.1 How the cloud learns the shape - -A parameter set is a **property definition** in the Digital Twin Manager, -registered once per tenant by a human: - -```sh -c8y api --raw POST /service/dtm/definitions/properties --template '{ - "identifier": "AutoUpdater", - "jsonSchema": { - "title": "Auto Updater", - "description": "...", - "type": "object", - "properties": { - "enabled": { "type": "boolean", "default": false, "title": "enabled", "order": 1 }, - "interval": { "type": "string", "enum": ["hourly","daily","weekly"], "title": "interval", "order": 2 } - } - }, - "contexts": ["asset", "event", "operation"] -}' -``` - -Three things this pins down for D5, the schema generator: - -- **`identifier` is the set name**, and it is what the twin fragment and - the operation fragment suffix are both named after. One name, three uses. -- **`contexts` must include `asset` and `operation`**, or the UI shows the - values but will not let anyone edit them. The plugin also asks for - `event`. -- Each property carries `title` and `order` beside the JSON Schema - keywords, so the UI can render the fields in a sensible sequence. The - generator should emit both — `order` from the table index, `title` from - the declared name unless something better is declared. - -Deleting one: - -```sh -c8y api DELETE "/service/dtm/definitions/properties/AutoUpdater?contexts=asset,event,operation" -``` - -Updating one is documented as *not working* ("Maybe not all fields can be -updated"), so a changed declaration means delete-then-create. Worth a line -in the README. - -## 1.1 How the values are reported - -Exactly as D2 assumed — the twin fragment is named after the set: - -``` -te/device/main///twin/ {"interval_s":30,"auto_mode":true} -``` - -The plugin's setup step publishes `{}` retained to that topic so the UI has -something to show before the first change, which suggests the client should -publish the set on connect even when every value is still its default. - -## 1.2, as far as it could be taken - -**What was done against the tenant** - -- A mock device, `tedge-param-probe` (id `14215738`), carrying - `c8y_SupportedOperations: ["c8y_ParameterUpdate_pump"]` and a `pump` - fragment with the current values. -- The hand-written `pump` schema above, registered in the Digital Twin - Manager — `201`, and still registered. -- **The generated shape was validated.** The exact bytes - `tedge_params_schema()` emits were posted under a throwaway identifier - and accepted with `201`, with `order`, `title`, `default`, `minimum`, - `maximum`, `maxLength`, `enum`, `description` and `contexts` all - preserved. The server adds `$schema` and `additionalProperties: false` - itself, so the generator does not need to emit them. That is D5 and the - "complete enough to register as it stands" requirement confirmed against - the real API rather than against a test's expectations. The throwaway was - deleted afterwards. - -**What could not be done: the change itself.** Without the -`device-parameter` microservice there is nothing to turn an edit into an -operation, so the one question 1.2 exists to answer is still open. Creating -the operation by hand would only prove what this client does with a shape -this client invented, which is what the unit tests already do. - -What it still has to settle, once the microservice is subscribed: - -1. **Whole set or only the changed values?** The plugin passes whatever the - fragment holds straight to the set's script without merging, which hints - at *only the changed ones*, but the UI may well send everything it has. - This decides whether validation runs over a patch or a whole set — D3 - currently assumes a patch. -2. Whether a value absent from the operation must keep its current value or - be reset to its default. -3. What the UI does with a set whose schema it has but which the device has - never reported, and whether a failed operation shows the reason string. -4. Whether `c8y_ParameterUpdate_` is also what arrives when the tenant - is on the newer Device Parameters UI plugin rather than the DTM screen. - -[sh]: https://github.com/thin-edge/tedge-parameter-plugin/blob/main/src/parameter_update.sh - -## Found on the way past: two Kconfig gaps that are not this change's - -Neither is touched by `c8y-direct-parameters`; both were turned up while -trying to compile-check the new code, and both belong to whoever picks up -the build next. - -### 1. The client selects ECDSA but no curve - -`tedge-zephyr/Kconfig`'s `TEDGE_ENROLL_SELECTS` selects `PSA_WANT_ALG_ECDSA` -and the ECC key types. Anything that enables a builtin ECC key type turns on -`MBEDTLS_ECP_LIGHT`, and `ecp.h` then refuses to compile unless some -`MBEDTLS_ECP_DP_*_ENABLED` is set — which only happens if -`PSA_WANT_ECC_SECP_R1_256` (or another curve) is set too. The client never -sets one, so: - -``` -mbedtls/private/ecp.h:330: #error "Missing definition of MBEDTLS_ECP_MAX_BITS" -``` - -for *any* image that enables the Cumulocity auth path without the -application hand-picking a curve. The in-tree applications are unaffected -only because their board files set `CONFIG_PSA_WANT_ECC_SECP_R1_256=y` -themselves. Zephyr's own `modules/mbedtls/Kconfig.mbedtls` solves this with -`imply PSA_WANT_ECC_SECP_R1_256`; the client should do the same, as an -`imply` so an application can still choose a different curve. The same -applies to the TLS protocol version: the transport selects -`NET_SOCKETS_SOCKOPT_TLS` but neither `MBEDTLS_SSL_PROTO_TLS1_2` nor -`_TLS1_3`, so `sockets_tls.c` compiles against an mbedTLS with no SSL layer. - -It arrived with `86c1b59` (2026-09-19), the commit that added the C8Y -transport and onboarding selects. - -### 2. `samples/minimal` cannot be built as twister builds it - -`tedge-zephyr/samples/minimal/sample.yaml` lists `native_sim/native/64` and -`esp32c6_devkitc/esp32c6/hpcore` with no `extra_args`, so twister builds the -bare `prj.conf` and hits exactly the error above on both. It needs -`extra_args: EXTRA_CONF_FILE=overlay-c8y.conf`, or a native_sim-safe variant -of that overlay — the current one sets `CONFIG_WIFI_ESP32=y`, and the -sample's `main.c` then calls `NET_REQUEST_WIFI_CONNECT`, which does not link -on native_sim. - -Building the sample by hand works with the overlay the sample's own -`prj.conf` header already points at: - -```sh -west build -b native_sim/native/64 tedge-zephyr/samples/minimal --pristine \ - -- -DEXTRA_CONF_FILE=/tedge-zephyr/samples/minimal/overlay-c8y.conf -``` - -## Hardware verification (ESP32-C6, 2026-09-20) - -Device `tedge-modbuse8f60afc320c` (`79211726`) on `tedge-dev05`, Modbus -application, **Core MQTT** — see the transport finding below for why not the -default endpoint. - -Two sets are declared, which is the multi-set path the whole -`c8y_ParameterUpdate_` correction exists for: - -``` -params: declared 'pump' with 3 parameters -params: declared 'tedge' with 3 parameters -twin pump: {"interval_s":30,"telemetry":true,"measurement_type":"pump"} -twin tedge: {"log_level":"inf","health_interval_s":900,"required_interval_min":60} -``` - -| Task | Result | -|---|---| -| 8.1 the set appears with its values | both fragments landed in the managed object unchanged | -| 8.2 a change is applied and reported | `c8y_ParameterUpdate_tedge` → `2 value(s) changed`, twin republished, operation `SUCCESSFUL` | -| 8.3 a bad value fails, naming it | three refusals, below; twin untouched | -| 8.4 the application refuses | `pump: rolled back (measurement_type 'two words' cannot contain spaces)`, operation `FAILED` with that reason | -| 8.5 values survive a reboot | after a reset the device came up on `dbg`/`120`, the cloud-set values, not the declared defaults | -| 8.5 a removed parameter disappears | see below | -| 8.6 Core MQTT | everything above *is* Core MQTT | - -**The refusals, verbatim from `failureReason`:** - -``` -health_interval_s: 999999 is outside 0..86400 -tedge: this device does not declare 'nonsense' -log_level: 'loud' is not one of off err wrn inf dbg -``` - -**All-or-nothing, on real hardware.** A change carrying a valid -`required_interval_min: 30` *and* an invalid `log_level` left -`required_interval_min` at 60. The same held for the application's refusal: -a valid `interval_s: 45` travelling with the rejected `measurement_type` was -rolled back too. - -**A parameter removed by a firmware update.** Rebuilt without -`CONFIG_TEDGE_HEALTH`, so `health_interval_s` left the declaration, and -reflashed: - -``` -params: declared 'tedge' with 2 parameters -params: forgetting 'tedge/param/tedge/health_interval_s', which nothing declares now -twin tedge: {"log_level":"dbg","required_interval_min":60} -``` - -`log_level` kept the value the cloud had set, and the stored value for the -dropped parameter was deleted. **The managed object also lost the key** — -so Cumulocity replaces a fragment published through -`inventory/managedObjects/update/` wholesale rather than deep-merging -it. That had been an open risk in D4 (a removed parameter lingering in the -operator's view) and it turns out not to exist. - -### The transport finding: twin data does not reach inventory on MQTT Service - -The first image was built on the default endpoint, the **MQTT Service** -(`:9883`). Everything on the device worked — both sets were declared and -`twin tedge: {...}` was logged — but **nothing reached the managed -object**. `tedge_Agent` in the inventory still read -`"transport":"c8y-core-mqtt"` from an earlier run while the device was -publishing `"c8y-mqtt-service"`, two minutes and more after connecting. - -The cause is in `publish_twin_impl()` and is not specific to parameters: - -- on **MQTT Service** it publishes to the free-form topic - `te/device////twin/`. Cumulocity's MQTT Service accepts it - as a broker would, but nothing maps `te/...` into inventory — on Linux - that mapping is thin-edge.io's mapper, and there is no mapper here. -- on **Core MQTT** it publishes `inventory/managedObjects/update/`, - which Cumulocity does understand. That is why everything above works. - -So D2 — "the twin is the operator's view" — holds only on Core MQTT with -this client, and MQTT Service is the default endpoint. This affects every -twin fragment the client publishes (`tedge_Agent`, `tedge_Certificate`, -`tedge_RemoteAccess`, and now the parameter sets), so it is a pre-existing -gap that parameters merely make obvious: a device on the default transport -would declare parameters an operator could never see. It wants its own -change. - -### Footprint (task 8.7) - -ESP32-C6, Modbus application, Core MQTT, telemetry and health on. Same -overlays throughout; only the parameter options differ. - -| Build | `.text` | Cost | -|---|---|---| -| `CONFIG_TEDGE_PARAMETERS=n` | 724,208 | — | -| parameters on, `..._SCHEMA=n` | 731,552 | **+7,344 B** | -| parameters on, schema printer and shell | 732,912 | **+8,704 B** | - -So the mechanism itself — declaration, validation, storage, the twin, the -operation path and the client's own set — is **7.2 KB**, and the schema -printer with its two shell commands adds **1.3 KB** on top. The printer is -only needed until the sets are registered in the tenant, so an image that -is past that point can turn it off and keep the rest. - -RAM is what the declaration asks for: one current value per parameter -(bounded by `CONFIG_TEDGE_PARAMETERS_MAX`, 16 by default) plus one heap -allocation per string parameter, sized by the declaration and made once at -startup. - -### Both sets registered from the device's own output - -With the client flashed, the schemas were taken from the device rather than -written by hand — which is what D5 exists for: - -``` -uart:~$ tedge params list -pump (3 parameters) -tedge (3 parameters) -uart:~$ tedge params schema tedge -{"identifier":"tedge","jsonSchema":{...},"contexts":["asset","event","operation"]} -``` - -Both were posted to `/service/dtm/definitions/properties` unchanged and -accepted (`201`). The hand-written `pump` definition from task 1.2 was -deleted first and replaced with the generated one, so nothing in the tenant -is now transcribed by hand. The registered properties match the device's -twin exactly: - -| Set | Registered properties | Twin on the device | -|---|---|---| -| `tedge` | `log_level`, `health_interval_s`, `required_interval_min` | same three | -| `pump` | `interval_s`, `telemetry`, `measurement_type` | same three | - -**The definitions being there is not the same as the UI being able to edit -them.** That still needs the `device-parameter` subscription: the Digital -Twin Manager can render a set from its definition, but the thing that turns -an operator's edit into a `c8y_ParameterUpdate_` operation is the -microservice, and it is not subscribed. - -## The shape, corrected (2026-09-20, after archiving) - -A real parameter change from the tenant failed on the device with -`tedge: no values to change`. The operation, verbatim: - -```json -{ - "id": "214989", - "deviceId": "79211726", - "status": "FAILED", - "failureReason": "tedge: no values to change", - "description": "Update parameter 'tedge'", - "tedge": { - "required_interval_min": 30, - "log_level": "dbg", - "health_interval_s": 900 - }, - "c8y_ParameterUpdate": {}, - "c8y_ParameterUpdate_tedge": {} -} -``` - -Three things this settles, two of which had been guessed wrong: - -1. **`c8y_ParameterUpdate_` is an empty marker.** It says which set is - being changed and carries nothing. There is a bare `c8y_ParameterUpdate` - marker beside it. -2. **The values are a top-level fragment named after the set.** Searching - for `"":` cannot match the marker, because the character before the - name there is an underscore rather than the opening quote. -3. **It is the whole set, not a patch.** All three values travelled although - only `required_interval_min` was edited — `log_level` and - `health_interval_s` came along at their current values. That answers the - open question in design.md, and it is why "store only what changed" - matters: without it every edit would write every key to flash. - -The plugin's `jq ".operation.\"$TYPE\""` says exactly this, and the first -reading of it took `$TYPE` to be indexing the suffixed fragment rather than -the bare set name. The evidence was there and was misread; only a real -operation caught it. - -**The fix** takes the set name from the marker's suffix as before, then -reads the values from the fragment named after the set, falling back to the -marker's contents when the named fragment is missing or empty — which is -how an operation built by hand through the REST API carries them, and how -every change in the hardware verification above was driven. - -Replaying the exact operation above against the fixed firmware: - -``` -operation c8y_ParameterUpdate_tedge (216317) -params: tedge: 3 value(s) changed -twin tedge: {"log_level":"dbg","health_interval_s":900,"required_interval_min":30} -``` - -`SUCCESSFUL`, and the managed object agrees. - -**What this says about the verification above.** Sections 8.2–8.5 all drove -changes through `POST /devicecontrol/operations` with the values inside the -suffixed fragment — a shape this client invented, and which the fallback -now keeps working. The validation, rollback, storage and twin behaviour -they proved is unaffected: only the extraction was wrong. But it is a -reminder that a self-built payload verifies the device, not the contract. - -## Template 117 creates an availability, it does not update one - -Changing `required_interval_min` moves the parameter and the twin, but -`c8y_RequiredAvailability.responseInterval` on the managed object does not -follow. That is Cumulocity's behaviour, not a defect here: template `117` -*creates* `c8y_RequiredAvailability` and is ignored once the managed object -has one. Any device that has connected with this client already has one, -set on its first connect from `CONFIG_TEDGE_REQUIRED_INTERVAL_MIN`. - -So the parameter is worth having — it is what a fresh device registers -with, and it records the intent — but on a device already in the fleet the -availability window is a cloud-side edit. The parameter's help text and the -README both say so now, because an operator who changes it and watches the -availability card is otherwise left wondering. - -Worth considering in a later change: on Core MQTT the client already -publishes twin fragments through `inventory/managedObjects/update/`, -which *does* update an existing fragment. Sending -`{"c8y_RequiredAvailability":{"responseInterval":N}}` that way would make -the parameter effective on a device that already has one. It was left alone -here rather than widening an archived change. - -### And one real defect, found while chasing it - -`tedge_self_params.c` used `snprintf()` without including ``, so -it was compiled against an implicit declaration. It happened to work on -this ABI, and the build said so in a warning that went unread because the -build output was being filtered to `error:` only. Fixed, and the module now -compiles with no warnings of its own. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/proposal.md b/openspec/changes/archive/2026-09-20-c8y-direct-parameters/proposal.md deleted file mode 100644 index aa3ea00..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/proposal.md +++ /dev/null @@ -1,116 +0,0 @@ -## Why - -Every device has settings that someone eventually wants to change from the -cloud: a reporting interval, a threshold, a mode, a target address. The -client can already carry an operation and store things in settings; what it -cannot do is let an operator see those settings, change one, and know it -took. - -The API declared for this (`tedge_register_config_type()`) was drawn for -**configuration files**: a snapshot reader, a chunked writer, an upload and -a download. That shape came from thin-edge.io on Linux, where a -configuration *is* a file. On a microcontroller it is the wrong shape: - -- there is no filesystem, so both sides are inventing one; -- a snapshot is an opaque blob an operator cannot read or edit safely; -- it needs the HTTP transfer path in both directions for what is usually - fewer than ten values; -- nothing validates anything: a bad file is discovered by a device that - stops working. - -Cumulocity's **Parameter Update** feature fits an MCU far better, and -thin-edge.io itself is moving that way ([tedge-parameter-plugin]): a device -declares a **set of typed parameters** with a JSON Schema, their current -values live in the device twin where an operator can see them, and a change -arrives as one `c8y_ParameterUpdate` operation carrying only the values. -This client already publishes twin fragments and, since `c8y-operation-ids`, -receives operations as JSON with their id — so parameters are mostly -plumbing it already has. - -This is phase 3, roadmap step P7, on the same ESP32-C6 and ESP32-S3 boards, -with the Modbus application as the example. - -[tedge-parameter-plugin]: https://github.com/thin-edge/tedge-parameter-plugin - -## What Changes - -- **`CONFIG_TEDGE_PARAMETERS` replaces `CONFIG_TEDGE_CONFIG`.** The - configuration-file API (`tedge_register_config_type()`, - `tedge_config_reader_t`, `tedge_config_writer_t`) is **removed from the - public header** — it was never implemented, and shipping a shape the - client will not honour is worse than removing it. -- **An application declares a parameter set:** a name and a table of - parameters, each with a type (bool, integer, string, enum), a default, and - its limits (range, length, allowed values). -- **The client declares one for itself**, named `tedge`: how much it logs, - how often it reports its own health, the availability window Cumulocity - judges it by, and whether the cloud may tunnel to it. Every application - gets these without writing a line, and only settings that a running device - can actually honour are offered — nothing that sizes a buffer, a stack or - a thread. -- **Values live in three places that agree:** the client's settings subtree - (so they survive a reboot), the device twin (so an operator can see them), - and the application (through a change hook). -- **`c8y_ParameterUpdate` is handled:** the client validates every value - against what was declared, stores and applies the ones that pass, calls - the application, republishes the twin, and completes the operation by id — - or fails it naming the parameter that was wrong, changing nothing. -- **The client can print its own JSON Schema** (a shell command and a log - line), because the schema has to be registered in the tenant by a human - and hand-writing it from a C table is a transcription error waiting to - happen. -- **Nothing is sampled, uploaded or downloaded**: no HTTP, no filesystem, no - new TLS session. - -## Non-goals - -- **Configuration files** (`c8y_Configuration`, snapshots and updates). If a - device ever needs to ship a blob, that is a separate change and it needs - the upload path, not this one. -- Nested or repeated structures. A parameter is one value with one type; - an application that wants a table of them can encode it, and say so. -- Registering the schema in the tenant. That is the tenant owner's job, as - the Smart Functions are; the client only produces the schema. -- Changing parameters from the device side as a general API. An application - owns its own settings; this is about the cloud's view of them. The client - is the one exception, and only for itself: it declares a set of its own - (`CONFIG_TEDGE_PARAMETERS_SELF`) holding the few of its own settings that - can take effect on a running device, so a device is adjustable in the - field whether or not its application declares anything. - -## Resource constraints - -| Item | Cost | -|---|---| -| Text | validation, the twin JSON, the schema printer (behind its own option) | -| RAM | the declared table is the application's `const` data; the client keeps only current values, bounded by `TEDGE_PARAMETERS_MAX` and the value sizes declared | -| Flash | one settings key per parameter, written only when a value changes | -| Network | one twin message per connect and per change; one operation per change | - -## Capabilities - -### New Capabilities - -- `tedge-parameters`: what a device can declare — by its application and by - the client for itself — what an operator can see and change, what happens - to a change that does not fit, and what survives a reboot. - -### Modified Capabilities - -- `device-management-features`: configuration management becomes parameter - management, and stops being an unimplemented feature. -- `tedge-client-module`: registering configuration types becomes declaring - parameters, with the `-ENOTSUP` rule when the feature is not built in. - -## Impact - -- `tedge-zephyr/include/tedge/tedge.h`: the configuration-file API is - replaced by the parameter API. Nothing in the repository uses the old one - (it never had a definition), so nothing breaks. -- `tedge-zephyr/src/`: a new `tedge_parameters.c` (the table, validation, - storage, twin) and the `c8y_ParameterUpdate` path in `tedge_c8y.c`. -- The Modbus application declares its pump parameters (interval, setpoint - limits, mode), so the repository shows the hook in use. -- The tenant: the parameter set's schema has to be registered with the - Digital Twin Manager, and the Parameter Update feature has to be available - — which the first task checks before anything else is written. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/specs/device-management-features/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-parameters/specs/device-management-features/spec.md deleted file mode 100644 index 4bff2fd..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/specs/device-management-features/spec.md +++ /dev/null @@ -1,33 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Each feature is selected independently at build time - -Each device-management feature SHALL have its own Kconfig option, so that it can -be included in or excluded from an image independently of the others. The -features are telemetry, device-health telemetry, restart, firmware update, -shell command, log upload, remote access, parameters and certificate -renewal. The transport (direct to Cumulocity, or via a thin-edge.io -gateway) and the authentication method (Cumulocity CA certificate, or bootstrap -basic-auth credentials) SHALL each be a Kconfig choice. A feature that has not -been implemented yet SHALL be present in the menu but not selectable, and -the public API calls that belong to it SHALL return "not supported" rather -than failing silently. - -#### Scenario: Feature removed to save resources - -- **WHEN** an image is built with only remote access disabled -- **THEN** the remote-access code, its WebSocket client, its bridge thread and - its TLS session are absent from the image -- **AND** every other selected feature is still built and works - -#### Scenario: Minimal image - -- **WHEN** an image is built with only the connection, inventory and restart - enabled -- **THEN** it links and runs with a single MQTT/TLS session and no HTTP client - -#### Scenario: Settings a device does not have - -- **WHEN** an image is built without parameters -- **THEN** it advertises no parameter set, and the calls that declare one - return "not supported" diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/specs/tedge-client-module/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-parameters/specs/tedge-client-module/spec.md deleted file mode 100644 index 5ffe2c7..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/specs/tedge-client-module/spec.md +++ /dev/null @@ -1,80 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Integration hooks for the host application - -The public API SHALL let the host application: - -- provide the device identity: external ID, name, type, and firmware name and - version (with a documented default for each); -- push telemetry: measurements, events and alarms; -- register its own operation handlers and log types; -- declare a set of typed parameters the cloud may change, and be told when - a change is accepted; -- prepare for or veto a restart requested by the cloud; -- add its own health checks that must pass before a newly updated firmware image - is confirmed; -- observe the client's state (for example to drive a status LED); -- optionally report liveness progress to the application's watchdog. - -Every one of these calls SHALL be present whether or not the feature behind -it is built in: a call belonging to a feature that is absent SHALL return -"not supported", so that an application compiles against the API once and -chooses its features in Kconfig. - -The module SHALL NOT call `sys_reboot()` without first giving the application's -restart hook the chance to respond. - -#### Scenario: Application narrows remote-access targets - -- **WHEN** the application registers a remote-access target hook and the - cloud asks for a tunnel to a target the built-in policy allows but the hook - refuses -- **THEN** the client opens no connection and fails the operation with the - hook's reason - -#### Scenario: Application vetoes a new firmware image - -- **WHEN** a newly installed image is running and connected, and the - application's firmware-check hook reports that the application is not - healthy -- **THEN** the client does not confirm the image, so the bootloader reverts - it on the next reset - -#### Scenario: Application-supplied telemetry - -- **WHEN** the application calls the telemetry API with a measurement -- **THEN** the client publishes it on the configured transport, and no telemetry - is read from anything outside the calls the application makes - -#### Scenario: Telemetry from the application's own threads - -- **WHEN** the application publishes telemetry from a thread of its own - while the client is busy with an operation -- **THEN** the call returns without waiting for the network, and the client - sends the message on its own thread - -#### Scenario: Application vetoes a restart - -- **WHEN** a restart operation arrives and the application's restart hook - declines it -- **THEN** the client does not reboot and marks the operation as failed with the - reason returned by the hook - -#### Scenario: Application adds a log type - -- **WHEN** the application registers a log type and the cloud asks for it -- **THEN** the client asks the application to produce it and makes the result - retrievable from the cloud - -#### Scenario: Registering against a feature that is not built in - -- **WHEN** an application that registers a log type is built without log - upload -- **THEN** it still compiles and links, and the registration returns "not - supported" - -#### Scenario: Application is told about a parameter change - -- **WHEN** the cloud changes a declared parameter and the change is valid -- **THEN** the application's hook is called once with the new values, and - may still refuse them diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/specs/tedge-parameters/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-parameters/specs/tedge-parameters/spec.md deleted file mode 100644 index 9e47e06..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/specs/tedge-parameters/spec.md +++ /dev/null @@ -1,154 +0,0 @@ -## ADDED Requirements - -### Requirement: An application declares the settings the cloud may change - -When parameters are built in, the client SHALL let the host application -declare a named set of parameters, each with a type, a default and the -limits of what it may hold. The client SHALL NOT expose a setting the -application did not declare. - -The set's name SHALL be how the cloud addresses it: the state the client -reports, the schema an integrator registers, and the changes the cloud -sends SHALL all be identified by that one name, so that a device that -declares more than one set has each of them seen and changed separately. - -#### Scenario: A device that has settings - -- **WHEN** an application declares a parameter set and the device connects -- **THEN** the cloud shows the set's current values - -#### Scenario: A device with more than one set - -- **WHEN** an application declares two parameter sets and the cloud changes - a value in one of them -- **THEN** only that set's value changes, and only that set is reported - again - -#### Scenario: Something that was never declared - -- **WHEN** the cloud sends a value for a name the device does not declare -- **THEN** the device refuses the change and says which name it does not - know - -#### Scenario: A change for a set the device does not have - -- **WHEN** the cloud sends a change addressed to a set name the device does - not declare -- **THEN** the device refuses it naming that set, and no declared set is - touched - -### Requirement: The client offers its own settings the same way - -The client SHALL offer its own settings as a parameter set of its own, so -that a device can be adjusted in the field whether or not its application -declares anything. That set SHALL be governed by every rule that governs an -application's: it is validated before it is applied, it is reported as -state, and an accepted value survives a restart. - -The client SHALL only offer a setting that can take effect on a device that -is already running. A setting that is fixed when the image is built — one -that sizes a buffer, a stack or a thread — SHALL NOT be offered, because a -value the device cannot honour is worse than no value at all. - -An application SHALL be able to decline the whole thing at build time, and -a set an application declares SHALL take precedence over a set of the same -name from the client, so that adding a client setting can never take a name -an application was already using. - -#### Scenario: An application that declares nothing - -- **WHEN** a device whose application declares no parameters connects -- **THEN** the cloud still shows the client's own settings, and can change - them - -#### Scenario: Turning off a capability in the field - -- **WHEN** an operator turns off the client's remote access and the cloud - then asks for a tunnel -- **THEN** the device refuses the tunnel and says that remote access is - turned off, without anyone reflashing it - -#### Scenario: Looking into a device that is misbehaving - -- **WHEN** an operator raises the client's log level on a device already in - the field -- **THEN** the device logs more from that point on, and still does so after - it restarts - -#### Scenario: A setting the running image cannot honour - -- **WHEN** the client is built without a feature whose setting it would - otherwise offer -- **THEN** that setting is absent from the set, and the cloud is not shown a - value the device would ignore - -### Requirement: The current values are visible without asking the device - -The client SHALL publish the current value of every declared parameter as -device state on every connection and after every accepted change, so that -what the cloud shows is what the device is running. - -#### Scenario: After a reboot - -- **WHEN** a device restarts -- **THEN** the values the cloud shows are the ones the device is running, - without anyone requesting them - -### Requirement: A change is validated before any of it is applied - -The client SHALL check every value in a change against its declaration — -type, range, length and allowed values — and SHALL apply the change only if -all of it passes. A change that fails SHALL leave every parameter as it was -and SHALL be reported as failed, naming the parameter and what was wrong -with it. The application SHALL be able to refuse a change it alone can -judge, and the device SHALL then keep its previous values. - -#### Scenario: A value outside its range - -- **WHEN** the cloud sets an interval above the declared maximum -- **THEN** the operation fails naming that parameter and its limit, and the - device keeps running the value it had - -#### Scenario: A change the application refuses - -- **WHEN** a change passes validation but the application's hook rejects it -- **THEN** the operation fails with the application's reason, and the - previous values are what the device runs and reports - -#### Scenario: An accepted change - -- **WHEN** a change passes validation and the application accepts it -- **THEN** the device runs the new values, reports them as its state, and - the operation succeeds - -### Requirement: Values survive a restart - -An accepted value SHALL still be in force after the device restarts, without -the cloud sending it again. A stored value whose parameter the running -firmware no longer declares SHALL be discarded. - -#### Scenario: A power cut - -- **WHEN** a parameter is changed and the device loses power -- **THEN** it comes back running the changed value - -#### Scenario: A firmware update that drops a parameter - -- **WHEN** a new firmware no longer declares a parameter that had a stored - value -- **THEN** the device drops it and reports only what it declares now - -### Requirement: The device can produce the schema it validates against - -The client SHALL be able to produce the JSON Schema of a declared set, so -that what is registered in the cloud is generated from the same declaration -the device validates against rather than written by hand. What it produces -SHALL be complete enough to register as it stands, without anyone having to -add the set's name or work out what the cloud needs in order to make the -values editable. - -#### Scenario: Registering a set in the cloud - -- **WHEN** an integrator needs the schema for a device's parameter set -- **THEN** the device can produce it, and it describes exactly the - parameters, types and limits that the device enforces diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/tasks.md b/openspec/changes/archive/2026-09-20-c8y-direct-parameters/tasks.md deleted file mode 100644 index 5f6c2f4..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-parameters/tasks.md +++ /dev/null @@ -1,54 +0,0 @@ -## 1. Settle the mechanism - -- [x] 1.1 Confirm the tenant has Parameter Update (Digital Twin Manager and the device-parameter microservice), and capture what a `c8y_ParameterUpdate` operation actually carries, before writing device code -- [x] 1.2 Register one hand-written schema in the tenant and change a value against a mock device, so the round trip is understood end to end — **done with a caveat**: the schema was registered and the round trip was driven end to end against real hardware rather than a mock (section 8), which is stronger. What could not be exercised is the *operator UI's* half: `device-parameter` is not subscribed by the tenant, so nothing turns an edit into an operation, and whether the UI sends the whole set or only the changed values is still open. See findings-mechanism.md. - -## 2. The declaration - -- [x] 2.1 The public API: `struct tedge_parameter`, the `TEDGE_PARAM_*` macros, `tedge_declare_parameters()` and the change hook (D1) -- [x] 2.2 Remove the configuration-file API from the header, with a line saying what replaced it (D7) -- [x] 2.3 Kconfig: `TEDGE_PARAMETERS`, `TEDGE_PARAMETERS_MAX`, `TEDGE_PARAMETERS_SCHEMA`; drop `TEDGE_CONFIG` and its experimental gate - -## 3. Values - -- [x] 3.1 Defaults at startup, overridden by what is stored; drop stored values that are no longer declared (D4) -- [x] 3.2 Validation: type, range, length, allowed values, all-or-nothing (D3) -- [x] 3.3 Store only what changed, one settings key per parameter (D4) -- [x] 3.4 Publish the set as twin state on every connect and after every accepted change, on both transports (D2) - -## 4. The operation - -- [x] 4.1 `c8y_ParameterUpdate` through the JSON operation path, answered by id (D6) -- [x] 4.2 A refusal names the parameter and what was wrong, and changes nothing -- [x] 4.3 The application's hook may refuse, and the client rolls back - -## 5. The schema - -- [x] 5.1 Generate the JSON Schema from the declaration (D5) -- [x] 5.2 A shell command to print it, behind `TEDGE_PARAMETERS_SCHEMA` - -## 6. Use it - -- [x] 6.1 The Modbus application declares its pump parameters and applies them -- [x] 6.2 The application's existing Kconfig defaults become the declared defaults, so nothing is configured twice - -## 7. Tests - -- [x] 7.1 Unit tests: validation of each type, including the edges of a range and an unknown name -- [x] 7.2 Unit tests: the twin JSON and the generated schema for a mixed set -- [x] 7.3 Kconfig cases: parameters selectable; the API absent when it is off - -## 8. Hardware verification - -- [x] 8.1 C6: the set appears in the cloud with its current values -- [x] 8.2 A change is applied, reported, and visible in the twin -- [x] 8.3 A value outside its range fails, naming the parameter, and changes nothing -- [x] 8.4 A change the application refuses is rolled back -- [x] 8.5 Values survive a reboot; a parameter removed by a firmware update disappears -- [x] 8.6 Core MQTT: the same set, reported the same way -- [x] 8.7 Footprint row - -## 9. Wrap-up - -- [x] 9.1 README: declaring parameters, what the cloud sees, what a bad value does, and how to get the schema -- [x] 9.2 Profiles, design.md results, SCOPE roadmap P7; archive diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/.openspec.yaml b/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/.openspec.yaml deleted file mode 100644 index eaa6b1c..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-09-19 diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/design.md b/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/design.md deleted file mode 100644 index 5249ccd..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/design.md +++ /dev/null @@ -1,199 +0,0 @@ -## Context - -Spike F (archived in `2026-09-19-c8y-direct-spikes`) answered how Cumulocity -Cloud Remote Access works from the device's side, and proved a bridge on the -ESP32-C6 and ESP32-S3. Its code is `apps/c8y-spike/src/spike_ra.c`: one file -with globals, measurement logging and a message queue back to the MQTT -thread. - -`c8y-direct-core` gives this change what it needs: the client thread, the -SmartREST dispatcher, a maintained JWT (`tedge_c8y_jwt()`), the twin store -that republishes after a reconnect, and the operation helpers. - -Facts from the spike that shape the design: -- `530,,,,` arrives on `s/ds`; the key is - 36 characters and must never be logged. -- The WebSocket is `wss:///service/remoteaccess/device/` with - `Sec-WebSocket-Protocol: binary`. **A client certificate alone gets 401**; - a Bearer JWT works. -- Zephyr's `websocket_connect()` hashes `Sec-WebSocket-Accept` with PSA - SHA-1 but does not select it: without `PSA_WANT_ALG_SHA_1` it fails with - `-EPROTO` before sending anything. -- MQTT + one tunnel: 86.2 KB TLS heap peak, 69.2 KB connected (16 KB - records), steady through 10 MB each way. -- Throughput 40–56 KB/s, echo ~100 ms, first command 5.6 s. -- A refused session is reported immediately, but Cumulocity's web terminal - only shows it after its own timeout, so the reason has to be on the - operation. -- Port 23 needs the bridge to offer `WILL ECHO` / `WILL SGA`. - -## Goals / Non-Goals - -**Goals:** the feature in the module, behind `CONFIG_TEDGE_REMOTE_ACCESS`, -with the policy, cap, events and twin data the baseline spec already -requires; no regression in the client's other behaviour; the same measured -throughput and latency as the spike. - -**Non-Goals:** a safe device-local shell (P10), throughput work (P11), the -classic ESP32's stalls (P13), and more than one session on boards that -cannot afford it. - -## Decisions - -### D1: One bridge thread per session, owned by the feature - -The bridge blocks on `zsock_poll()` of two sockets, so it cannot run on the -client thread (which owns the MQTT session). Each session gets a thread from -a small pool sized by `TEDGE_REMOTE_ACCESS_MAX_SESSIONS`, with -`TEDGE_REMOTE_ACCESS_STACK_SIZE` (default 4096; the spike measured its 8 KB -stack as comfortable, and the module's buffers are static rather than on the -stack). - -Results travel back to the client thread through a `k_msgq`, exactly as in -the spike, because the MQTT client is not thread-safe. The client thread -turns them into `501`/`503`/`502`, the events and the twin update. - -*Alternative: run the bridge on the client thread with a poll set that also -holds the MQTT socket.* It would save a thread and a queue, but one slow -target would stall the MQTT session, and the session cap would become a poll -set that grows with it. - -### D2: The policy decides before any socket is opened - -Order: parse the message, resolve the host, check the policy, check the cap, -then connect. A target outside the policy never sees a TCP SYN. The -application's `remote_access_allow` hook is called after the built-in policy -passes, never instead of it, so an application can only narrow the policy. - -Resolution uses the DNS resolver the client already needs; a name that -resolves outside the policy is refused like a literal address. - -### D3: The seat is taken when the request is accepted - -`tedge_RemoteAccess` carries `maxSessions`, `activeSessions` (tunnels that -are up), `freeSessions` (seats a new request could take) and `policy`, plus -`sessions` with target and start time when -`TEDGE_REMOTE_ACCESS_TWIN_SESSIONS` is on. A seat counts as taken from the -accepted `530` until its thread has cleaned up, so two requests in quick -succession cannot both pass the cap. - -The twin is published through the core's twin store, so it is republished -after every reconnect with no stale session left behind. - -### D4: Bytes move in fixed buffers, one WebSocket frame per read - -Two static buffers of `TEDGE_REMOTE_ACCESS_BUF_SIZE` (default 2048) per -session: target → WebSocket and back. Each TCP read becomes one masked -WebSocket frame, as in the spike. This is what limits throughput to -40–56 KB/s (P11); larger buffers and batching are the tuning work, and the -Kconfig option already lets a board with RAM to spare raise it. - -The session ends when either side closes, on an error, or after -`TEDGE_REMOTE_ACCESS_IDLE_TIMEOUT_S` with no traffic. Every end publishes the -close event with the reason and the byte counts. - -### D5: Telnet negotiation for port 23 - -When the target port is 23, the bridge sends `IAC WILL ECHO, IAC WILL SGA` -to the client as soon as the tunnel is up. Without it, Cumulocity's web -terminal and Zephyr's telnet backend both wait for the other to offer, and -nothing echoes. This is behaviour of the bridge, not of the target, so it -belongs in the module; it is skipped for other ports. - -### D6: The feature's Kconfig dependencies - -`TEDGE_REMOTE_ACCESS` already depends on `TEDGE_TRANSPORT_C8Y` and -`TEDGE_AUTH_C8Y_CA` (the JWT). It now also selects `TEDGE_HTTP`, -`WEBSOCKET_CLIENT` and `PSA_WANT_ALG_SHA_1`, and raises -`NET_SOCKETS_TLS_MAX_CONTEXTS` requirements in the README: one per session on -top of the MQTT session. - -## Risks / Trade-offs - -- [A slow or hostile target holds a seat] → the idle timeout ends it, and the - cap means it can never take more than its seat. -- [The connection key could leak through logs] → it is never logged at any - level; the spike's rule is kept and the unit test checks the log line. -- [TLS heap for a second session] → the profiles size it; a handshake that - runs out fails the operation with a reason instead of dropping MQTT. -- [The application narrows the policy badly] → the hook can only refuse, not - allow, so the worst case is a feature that never connects. -- [Throughput stays at 40–56 KB/s] → documented, with the buffer option as - the lever; bulk transfer is not what this feature is for. - -## Migration Plan - -- `apps/c8y-spike` keeps `spike_ra.c` until P3 lands; the two implementations - do not share code. -- Devices already using the spike need no migration: the operation, the - events and the twin shape are the same. -- Boards without the RAM for a second TLS session keep - `CONFIG_TEDGE_REMOTE_ACCESS=n`, which is the default. - -## Open Questions - -- Should a second session be allowed by default on the C6 with 8 KB records - (the spike showed it fits), or stay at one until it is measured under load? -- ~~Does the reference Smart Function present `tedge_RemoteAccess` as a - fragment on the managed object, a measurement, or both (P12)?~~ - **Answered 2026-09-20:** the tenant owner's Smart Function maps the - `te/device////twin/tedge_RemoteAccess` message to a **`remoteAccess` - fragment** on the managed object, with the payload unchanged: - `{"activeSessions":0,"freeSessions":1,"maxSessions":1,"policy":"lan", - "sessions":[]}`. The reference function ships that shape. - -## Results - -### First hardware run (C6, Modbus + client, 2026-09-20) - -SSH to the Raspberry Pi through the device: **first command answered in -4.4 s**, 1 MB device → Mac in 34 s (**29 KB/s**, against 40–56 KB/s for the -spike; the spike app carried larger net buffers). Both operations ended -SUCCESSFUL, with `c8y_RemoteAccessOpened` / `c8y_RemoteAccessClosed` events -carrying the target and the byte counts (1,004,673 B up, 3,654 B down for the -transfer). - -**Three configuration bugs the first runs exposed**, all of them the module -asking the application for too little: - -| Symptom | Cause | Fix | -|---|---|---| -| `the cloud connection failed (-12)` at once | `NET_SOCKETS_TLS_MAX_CONTEXTS` is 1 by default: no TLS context for the tunnel beside MQTT | one context per session on top of MQTT, in the profiles and the board overlays | -| `TLS to failed (-12)` after the context fix | the 64 KB mbedTLS heap holds MQTT's 35 KB, leaving too little for a second handshake (52 KB peak) | 96 KB for MQTT + one tunnel, documented next to the option | -| The device rebooted when the tunnel opened | the bridge thread's 4 KB stack overflowed: the TLS handshake runs on it (a jump to a garbage address) | `TEDGE_REMOTE_ACCESS_STACK_SIZE` defaults to 8192, as the spike used | - -Each failure was reported as a failed operation with its reason rather than -leaving the operation pending, which is the behaviour the spec asks for. - -**One cloud-side observation:** an operation created just as the local -`c8y remoteaccess server` shut down stayed EXECUTING. The device never -received it (no request in its log), so the microservice had marked it -EXECUTING itself. Nothing for the device to do, but it means a stuck -EXECUTING operation is not proof that the device ignored it. - -### Policy, cap and capacity (C6, 2026-09-20) - -| Check | Result | -|---|---| -| Target outside the subnet (8.8.8.8:53) | FAILED `target 8.8.8.8:53 refused: not on this device's network`; no socket opened | -| Unreachable target on the subnet (192.168.68.250:22) | FAILED `cannot reach 192.168.68.250:22 (-116)` | -| Second session while one is open | FAILED `no free session (the limit is 1)` within seconds; the open tunnel carried on and closed normally | -| Capacity at startup | `tedge_RemoteAccess` is published ~0.5 s after the connection, before any session: `{"maxSessions":1,"activeSessions":0,"freeSessions":1,"policy":"lan","sessions":[]}`, and again after every reconnect | - -An operation created while the local `c8y remoteaccess server` is shutting -down stays EXECUTING: the device never receives it. Seen twice; it is a -cloud-side artefact, not a device fault. - -### Telnet, reboot and leak checks (C6, 2026-09-20) - -| Check | Result | -|---|---| -| Telnet target (a server on the Pi's port 23) | the first bytes to the cloud client are `ff fb 01 ff fb 03` (WILL ECHO, WILL SGA), then the target's banner; typed text echoes back | -| Reboot with a session open | during: `activeSessions` 1, `freeSessions` 0, with the target and start time; after the reboot: 0 and 1 with an empty session list, in the device log and in the managed object | -| TLS heap across two tunnels | 34,828 B with MQTT alone, 69,624 B with a tunnel (peak 86,620 B, as the spike's 86.2 KB), back to 34,828 B after each close | -| TCP contexts | 2 at rest, 4 with a tunnel, 3 briefly while closing, back to 2 | -| Footprint (C6, Modbus + client) | remote access adds **23 KB of text** (775 → 798 KB) and 50 KB of libc heap, most of it the larger TLS heap the second session needs | - -The reference Smart Function shape is settled: the tenant's function maps -`te/device////twin/tedge_RemoteAccess` to a `remoteAccess` fragment on -the managed object, payload unchanged. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/proposal.md b/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/proposal.md deleted file mode 100644 index b0d2113..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/proposal.md +++ /dev/null @@ -1,97 +0,0 @@ -## Why - -Remote access is the reason the direct transport exists: Cumulocity reaches -an SSH server, web UI or PLC **on the device's own network**, tunnelled by a -Wi-Fi microcontroller that costs a few euros. A thin-edge.io gateway cannot -offer this for its child devices, and a brownfield site gets remote access to -its machines with nothing else installed. - -`c8y-direct-spikes` proved it on hardware (Spike F, unknowns U11 and U12): -SSH to a Raspberry Pi through an ESP32-C6 answers in 5.6 s, echoes in ~100 ms -and moves 40–56 KB/s, with the target policy, the session cap and the failure -reasons all behaving. `c8y-direct-core` then put the module's connection, -onboarding and operations in place. This change moves the feature itself out -of `apps/c8y-spike` and into `tedge-zephyr` (roadmap step P2). - -## What Changes - -- **`CONFIG_TEDGE_REMOTE_ACCESS` becomes real.** On `530,,, - ,` the client checks the target policy, opens TCP to the target, - opens `wss:///service/remoteaccess/device/` with - `Sec-WebSocket-Protocol: binary` and a Bearer JWT, and bridges bytes both - ways until either side closes or the session goes idle. -- **Target policy** (`TEDGE_REMOTE_ACCESS_TARGETS_*`, already in Kconfig): the - device's own IPv4 subnets (default), an explicit allow-list, or the device - itself. The application narrows it further through the - `remote_access_allow` hook, which the public header already declares. -- **Session cap** (`TEDGE_REMOTE_ACCESS_MAX_SESSIONS`, default 1). A request - over the cap is failed immediately with a reason naming the limit, rather - than left to the UI's own timeout. -- **Audit and capacity:** `c8y_RemoteAccessOpened` / `c8y_RemoteAccessClosed` - events naming the target and the bytes moved, and `tedge_RemoteAccess` twin - data (limit, active sessions, free seats, policy, and optionally the open - targets) published on connect and on every session change. -- **Operation lifecycle:** `501` when the request is accepted, `503` once the - tunnel is up, `502` with the reason when it is refused or fails. -- **Telnet targets:** when the target port is 23, the bridge offers - `WILL ECHO` / `WILL SGA` to the client, without which Cumulocity's web - terminal shows no echo (a spike finding). -- **`apps/c8y-spike` keeps its own copy** until firmware update (P3) also - lands; the module's implementation is the one that ships. - -## Non-goals - -- Firmware update, telemetry, logs, configuration and certificate renewal - (P3–P7). -- A safe remote shell on the device itself (P10): Zephyr's `shell_telnet` - binds every interface with no authentication, so this change bridges to - **other hosts**, and to the device's own services only through the policy. - A WebSocket-fed shell backend is its own change. -- Bulk-transfer tuning (P11) beyond the buffer size already in Kconfig. -- The classic ESP32 (WROOM) as a supported target for this feature (P13): - it works but stalls, and the profile documents it as demo-grade. - -## Resource constraints - -From the spikes, on top of what the client already uses: - -| Item | Cost | -|---|---| -| Text | ~15.7 KB (bridge, WebSocket client, policy) | -| TLS heap, MQTT + one tunnel | 86.2 KB peak / 69.2 KB connected (16 KB records); 53.4 KB / 36.4 KB with 8 KB records | -| Static RAM | one thread (8 KB stack in the spike), 2 × `TEDGE_REMOTE_ACCESS_BUF_SIZE` buffers, ~1 KB WebSocket scratch | -| Sockets | one TLS context and one TCP context per session, plus one HTTP request for the upgrade | - -Boards: the ESP32-C6 (one tunnel; two with 8 KB records) and the ESP32-S3 -with the TLS heap in PSRAM. The feature requires certificate authentication, -because the WebSocket needs a JWT. - -## Capabilities - -### New Capabilities - -- `tedge-remote-access`: the tunnel itself — the operation, the policy, the - WebSocket bridge, the session cap, the events and the twin data. - -### Modified Capabilities - -- `device-management-features`: the baseline already requires the forwarding - behaviour, the policy, the cap and the `tedge_RemoteAccess` twin data. It - is updated only where the spikes changed the picture: the failure must be - reported as soon as the device refuses, and the WROOM is demo-grade. -- `tedge-client-module`: the `remote_access_allow` hook stops being a - declaration and becomes a requirement with a scenario. - -## Impact - -- `tedge-zephyr/src/`: new `tedge_remote_access.c` (bridge and policy) and - its dispatcher entry in `tedge_c8y.c`; `TEDGE_HTTP` and the WebSocket - client selected by the feature. -- Zephyr facilities: `websocket_client` (which needs `PSA_WANT_ALG_SHA_1`, - a spike finding), `http_client`, sockets, `tls_credentials`. -- Profiles: `full.conf` and `remote-access-enabler.conf` enable the feature; - the enabler profile is what makes a bare WROOM or C6 useful. -- Cumulocity: the `cloud-remote-access` microservice, and PASSTHROUGH or - TELNET configurations on the device. -- Carries the spike's open problems P10 (safe local shell), P11 (throughput) - and P13 (classic ESP32 stalls). diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/specs/device-management-features/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/specs/device-management-features/spec.md deleted file mode 100644 index 14f1c17..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/specs/device-management-features/spec.md +++ /dev/null @@ -1,36 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Remote access forwards to hosts on the local network under a target policy - -When the remote-access feature is built in, the device SHALL act as a -remote-access endpoint for Cumulocity. On a remote-access connect operation, it -SHALL open a TCP connection to the requested host and port and bridge it to -Cumulocity's device-side WebSocket until either side closes. The target MAY be -the device itself or another host on its network. Which targets are allowed -SHALL be set by a build-time policy: the device's own IPv4 subnets (default), -an explicit allow-list, or the device itself only. The host application SHALL -be able to narrow the policy further. The device SHALL refuse a target outside -the policy, and SHALL mark the operation as failed with a reason. Each tunnel -open and close SHALL be recorded as an event naming the target. The number of -concurrent sessions SHALL be capped by a Kconfig option. - -#### Scenario: SSH to a neighbouring host - -- **WHEN** a user opens a Cumulocity remote-access SSH session to the device - with an endpoint of `192.168.1.20:22`, and that host is on the device's subnet -- **THEN** the device bridges the session to that host, and the user reaches - the host's SSH server -- **AND** an event records the tunnel opening and closing with the target - -#### Scenario: Target outside the policy - -- **WHEN** a remote-access connect names a target outside the configured policy -- **THEN** the device opens no TCP connection, and marks the operation as failed - with a reason naming the policy - -#### Scenario: Session cap reached - -- **WHEN** a remote-access connect arrives while the maximum number of sessions - is open -- **THEN** the device marks the new operation as failed, and the open sessions - continue undisturbed diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/specs/tedge-client-module/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/specs/tedge-client-module/spec.md deleted file mode 100644 index dc1e6db..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/specs/tedge-client-module/spec.md +++ /dev/null @@ -1,39 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Integration hooks for the host application - -The public API SHALL let the host application: - -- provide the device identity: external ID, name, type, and firmware name and - version (with a documented default for each); -- push telemetry: measurements, events and alarms; -- register its own operation handlers, log types and configuration types; -- prepare for or veto a restart requested by the cloud; -- add its own health checks that must pass before a newly updated firmware image - is confirmed; -- observe the client's state (for example to drive a status LED); -- optionally report liveness progress to the application's watchdog. - -The module SHALL NOT call `sys_reboot()` without first giving the application's -restart hook the chance to respond. - -#### Scenario: Application narrows remote-access targets - -- **WHEN** the application registers a remote-access target hook and the - cloud asks for a tunnel to a target the built-in policy allows but the hook - refuses -- **THEN** the client opens no connection and fails the operation with the - hook's reason - -#### Scenario: Application-supplied telemetry - -- **WHEN** the application calls the telemetry API with a measurement -- **THEN** the client publishes it on the configured transport, and no telemetry - is read from anything outside the calls the application makes - -#### Scenario: Application vetoes a restart - -- **WHEN** a restart operation arrives and the application's restart hook - declines it -- **THEN** the client does not reboot and marks the operation as failed with the - reason returned by the hook diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/specs/tedge-remote-access/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/specs/tedge-remote-access/spec.md deleted file mode 100644 index 67452b0..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/specs/tedge-remote-access/spec.md +++ /dev/null @@ -1,110 +0,0 @@ -## ADDED Requirements - -### Requirement: The device bridges a cloud remote-access session to a TCP target - -When the remote-access feature is built in, the client SHALL handle the -Cumulocity remote-access connect operation by opening a TCP connection to the -requested host and port and bridging it to Cumulocity's device-side WebSocket -until either side closes. It SHALL authenticate the WebSocket with the token -it holds for the tenant, and SHALL NOT log the connection key at any log -level. - -#### Scenario: SSH to a host on the device's network - -- **WHEN** an operator opens a remote-access session whose endpoint is an SSH - server on the device's subnet -- **THEN** the device bridges the session, and the operator reaches that SSH - server - -#### Scenario: The target closes - -- **WHEN** the target closes the TCP connection -- **THEN** the device closes the WebSocket and reports the session as ended - -### Requirement: Targets are checked before any connection is opened - -The client SHALL check the requested target against the build-time policy -(the device's own IPv4 subnets, an allow-list, or the device itself) and then -against the application's hook, before opening any socket to it. A target the -policy or the hook refuses SHALL fail the operation with a reason naming the -policy, and SHALL NOT be contacted. A host name SHALL be resolved first and -judged by the address it resolves to. - -#### Scenario: Target outside the subnet - -- **WHEN** a remote-access connect names an address outside the device's - subnets and the policy is the default -- **THEN** no TCP connection is attempted, and the operation fails with a - reason naming the policy - -#### Scenario: Application narrows the policy - -- **WHEN** the application's hook refuses a target that the built-in policy - allows -- **THEN** the operation fails with the reason, and no connection is made - -### Requirement: Concurrent sessions are capped and the refusal is immediate - -The number of sessions open at once SHALL NOT exceed the configured maximum. -A request that would exceed it SHALL be failed as soon as the device receives -it, with a reason naming the limit, and SHALL NOT disturb the sessions that -are open. - -#### Scenario: Second session while one is open - -- **WHEN** the cap is one session and a second remote-access connect arrives -- **THEN** the device fails the new operation within seconds with a reason - naming the limit, and the open session continues - -### Requirement: Every session is auditable - -The client SHALL publish an event when a tunnel opens and when it ends. The -open event SHALL name the target host and port; the close event SHALL name -the target, why the session ended and how many bytes travelled each way. - -#### Scenario: Session ends - -- **WHEN** a tunnel that carried traffic ends because the client disconnected -- **THEN** an event records the target, the reason and the byte counts - -### Requirement: Remote-access capacity is visible before a session is opened - -The client SHALL publish `tedge_RemoteAccess` twin data with the session -limit, the number of active sessions, the number of free seats and the target -policy. It SHALL publish it on every connect, with no active sessions, and -whenever a session opens or ends. Including the open sessions' targets SHALL -be a build-time option, because those are internal addresses. - -#### Scenario: Operator checks before connecting - -- **WHEN** one session is open and the cap is one -- **THEN** the twin data shows `activeSessions` 1 and `freeSessions` 0 - -#### Scenario: Device reboots with a session open - -- **WHEN** the device reboots while a tunnel is open and reconnects -- **THEN** the twin data it publishes shows no active sessions and all seats - free - -### Requirement: A telnet target is offered echo by the bridge - -When the target port is the telnet port, the bridge SHALL offer echo and -suppress-go-ahead to the cloud-side client as soon as the tunnel is up, so -that a browser terminal echoes what the operator types. - -#### Scenario: Web terminal to a telnet target - -- **WHEN** an operator opens a telnet session through the device -- **THEN** characters typed in the browser terminal appear as they are typed - -### Requirement: A session that goes quiet is ended - -The client SHALL close a tunnel that has carried no traffic in either -direction for the configured idle timeout, free its seat, and name the -timeout in the close event. - -#### Scenario: Forgotten session - -- **WHEN** an operator leaves a session open with no traffic for longer than - the idle timeout -- **THEN** the device closes it, publishes the close event and frees the seat diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/tasks.md b/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/tasks.md deleted file mode 100644 index 47dca6d..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-remote-access/tasks.md +++ /dev/null @@ -1,30 +0,0 @@ -## 1. The bridge in the module - -- [x] 1.1 `tedge-zephyr/src/tedge_remote_access.c`: parse `530`, resolve the target, check the policy and the application hook, take a seat (D2, D3) -- [x] 1.2 TCP connect to the target, then the WebSocket to `wss:///service/remoteaccess/device/` with `Sec-WebSocket-Protocol: binary` and the Bearer token; never log the key -- [x] 1.3 The bridge loop: two static buffers, one frame per read, idle timeout, close reasons, byte counters (D4) -- [x] 1.4 Telnet echo offer for port 23 (D5) -- [x] 1.5 Session pool and cap: `TEDGE_REMOTE_ACCESS_MAX_SESSIONS` threads and buffers; a request over the cap fails immediately with the limit in the reason -- [x] 1.6 Results to the client thread through a message queue; the dispatcher in `tedge_c8y.c` sends `501`/`503`/`502` and the open/close events -- [x] 1.7 `tedge_RemoteAccess` twin data through the core's twin store, on connect and on every session change; `TEDGE_REMOTE_ACCESS_TWIN_SESSIONS` controls the target list -- [x] 1.8 Kconfig: `TEDGE_REMOTE_ACCESS` selects `TEDGE_HTTP`, `WEBSOCKET_CLIENT` and `PSA_WANT_ALG_SHA_1`; add `TEDGE_REMOTE_ACCESS_STACK_SIZE` and `TEDGE_REMOTE_ACCESS_TWIN_SESSIONS`; drop the experimental gate - -## 2. Tests - -- [x] 2.1 Unit tests (`native_sim`): `530` parsing (including a quoted key), the policy check against a subnet and an allow-list, and the twin JSON -- [x] 2.2 A Kconfig case: the feature off leaves no WebSocket client in the image; on, it requires CA authentication -- [x] 2.3 Check that no test or log path prints the connection key - -## 3. Hardware verification - -- [x] 3.1 C6 Modbus + client: SSH to the Pi through the device; record time to first command and throughput (interactive echo latency: not measured, the spike's 103 ms stands) -- [x] 3.2 Policy: a target outside the subnet and an unreachable target on it both fail with their reasons, and nothing is dialled -- [x] 3.3 Cap: a second session while one is open fails within seconds and the first survives -- [x] 3.4 Twin data and events in Cumulocity across open, close and a reboot with a session open -- [x] 3.5 Telnet target: echo in Cumulocity's web terminal -- [x] 3.6 TLS heap and TCP contexts before, during and after a session (no leak), and the footprint row in `measure_tedge.sh` - -## 4. Documentation - -- [x] 4.1 README: what the feature needs from the application (TLS contexts, heap, sockets), the policy, the cap and the throughput it gives -- [x] 4.2 Update `full.conf` and `remote-access-enabler.conf`, and record the results in design.md diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/.openspec.yaml b/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/.openspec.yaml deleted file mode 100644 index cbd245e..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-09-20 diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/design.md b/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/design.md deleted file mode 100644 index dbb7090..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/design.md +++ /dev/null @@ -1,156 +0,0 @@ -## Context - -The client can connect, be operated and keep itself alive; it cannot yet -carry the data the device exists to produce. The API is declared and the -topic shape is proven: the spikes published -`te/device////m/environment` every 10 s and the tenant owner's Smart -Function turned it into `c8y_Environment` measurements. The same messages -are what a thin-edge.io gateway consumes, which is why both transports come -out of one API call. - -What the spikes left for this change: -- **A timestamp on every message** (problem P4). Without it the cloud stamps - the processing time, so anything buffered while offline is recorded as - having happened when the device reconnected. -- **An alarm API**: certificate renewal publishes `301,c8y_CertificateExpiring` - by hand because nothing better exists yet. - -## Goals / Non-Goals - -**Goals:** an application can publish measurements, events and alarms from -any thread; nothing blocks the caller; a short outage does not lose data -silently; the same call works on both transports; the client reports its own -health. - -**Non-Goals:** persistence across a reboot, batching, sampling, child -devices. - -## Decisions - -### D1: The API queues; the client thread publishes - -A call from the application builds the message and puts it in a bounded -queue; the client thread drains it. The caller never waits for the network, -and the MQTT client stays owned by one thread, as everywhere else. - -Messages are built at the point of call, not at publish time, because that -is when the values and the timestamp are true. - -*Alternative: publish directly from the caller under a mutex.* Rejected: -it would block an application thread on a TLS write, and MQTT is not -thread-safe. - -### D2: One buffer, in bytes, oldest dropped first - -The queue is a byte ring (`TEDGE_TELEMETRY_BUFFER_BYTES`, default 2048) in -the module heap holding complete messages. Sizing it in bytes rather than -messages keeps the memory bound honest whatever an application sends. - -When a message does not fit, the oldest is dropped to make room, and a -counter increases. The counter is published with the next health message and -logged, so loss shows up as a number rather than a mystery. Events and -alarms are never dropped in favour of a measurement: they are queued ahead -of measurements, because "the pump stopped" matters more than the pressure -reading that came with it. - -### D3: Timestamps - -Every message carries `time` in ISO 8601 UTC, taken when the application -calls, or supplied by the application (it may have sampled earlier). If the -clock is not valid yet the message is still sent, without a `time`, and the -cloud stamps it: a device reporting during its first seconds is better than -one reporting nothing. - -### D4: Topics and payloads - -On the MQTT Service (free-form topics): - -| What | Topic | Payload | -|---|---|---| -| Measurement | `te/device////m/` | `{"time":"…","":,…}` | -| Event | `te/device////e/` | `{"time":"…","text":"…"}` | -| Alarm | `te/device////a/` | `{"time":"…","severity":"critical","text":"…"}` | -| Clear an alarm | `te/device////a/` | empty payload (thin-edge.io's convention) | - -On Core MQTT the same calls become SmartREST: `200,,,` -per series, `400,,""`, `301/302/303/304,,""` by -severity, and `306,` to clear. - -Measurements go out at QoS 0 and events and alarms at QoS 1: a lost reading -is one reading, a lost alarm is an unreported fault. - -### D5: The client's own health - -With `TEDGE_HEALTH`, every `TEDGE_HEALTH_INTERVAL_S` (default 900) the -client publishes `tedge_health` measurements it can gather without the -application: uptime in seconds, free bytes in its own heap, dropped -telemetry messages, and the reset reason from `hwinfo` at boot. Wi-Fi signal -strength belongs to the application (it owns the interface), so an -application that wants it publishes it as its own measurement. - -This is deliberately the client's health, not the device's: what an -application knows about itself is the application's to send. - -### D6: Certificate renewal uses the alarm API - -`tedge_cert_renew.c` calls `tedge_raise_alarm(TEDGE_ALARM_CRITICAL, …)` and -`tedge_clear_alarm()`, and the transport decides the encoding. Its hand-made -SmartREST goes away. With `TEDGE_TELEMETRY` off, the client falls back to -the SmartREST it publishes today, so renewal keeps its alarm in a minimal -build. - -## Risks / Trade-offs - -- [An application floods the queue] → the bound is bytes, the drop is - counted and reported; the client never grows its memory to keep up. -- [A long outage loses data] → stated plainly: the buffer is RAM and small. - Applications that must not lose data should keep their own store and - publish from it. -- [Timestamps depend on the clock] → the client already refuses to connect - before SNTP has run, so in practice messages are stamped; the unstamped - case is the first seconds after boot. -- [Measurements at QoS 0 can be lost in a reconnect] → deliberate; events - and alarms, which carry meaning, use QoS 1. - -## Migration Plan - -- Applications that already integrate the client gain working calls where - they had `-ENOTSUP`; nothing they do today breaks. -- The Modbus application starts publishing its simulation, which makes the - repository's example show data in Cumulocity. - -## Results (ESP32-C6, Modbus application, 2026-09-20) - -**Cost.** Telemetry and health together add 2,696 B of code and 2,160 B of -RAM (the 2 KB buffer and its bookkeeping) over the same build without them. -The signed image jumps 64 KB, which is the ESP32 padding its flash segments -to 64 KB MMU pages, not the feature's own size. - -**What was verified on hardware.** Both transports were flashed onto the same -device and driven from the console (`tedge event|alarm|clear|flood`, test -aids in the application, not the module): - -| Check | Result | -|---|---| -| Free-form payloads (MQTT Service) | `m/pump`, `e/app_test`, `a/app_test_alarm` and an empty payload to clear, each carrying `time` | -| Measurements (Core MQTT) | `pump` measurements in Cumulocity, one object per series (static template 200 takes one series per line) | -| Event and alarm (Core MQTT) | raised as MINOR and cleared, then MAJOR, with the device's own timestamps | -| Buffered through an outage | interface down for 70 s: event published at 09:24:15Z, `creationTime` 09:25:36Z — the cloud records when the reading was taken, 81 s before it arrived | -| A full buffer | `flood 120` → 85 measurements dropped and counted, nothing refused, the client kept running | -| Health | `tedge_health` every interval: uptime, freeHeap, droppedMessages, resetCause | - -Two Cumulocity behaviours worth knowing, both the cloud's and not the -client's: raising an alarm of a type that is already active updates the -existing alarm rather than creating a second one (its severity stays as -first raised), and `te/` messages only become measurements once the tenant -has a Smart Function mapping them — Core MQTT needs none, because SmartREST -is native. - -`resetCause` reads 0 on the ESP32-C6 after an esptool reset; `hwinfo` -reports nothing useful there, so the number is only meaningful on boards -whose driver fills it in. - -## Open Questions - -- Should the client publish a "buffer overflowed" event, rather than only a - counter in its health measurement? diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/proposal.md b/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/proposal.md deleted file mode 100644 index 5184e16..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/proposal.md +++ /dev/null @@ -1,79 +0,0 @@ -## Why - -A device exists to report something. Everything the client has built so far — -onboarding, the connection, remote access, firmware update, renewal — is -scaffolding around that. The API for it is already declared in -`include/tedge/tedge.h` and returns `-ENOTSUP`: applications can integrate -the client but cannot yet send a single measurement through it. - -The shape is settled. The spikes published free-form telemetry on -thin-edge.io's `te/` topics and the tenant owner mapped it to Cumulocity -measurements with a Smart Function; the same messages suit a thin-edge.io -gateway later. Two things were left open and belong here: telemetry must -carry its own **timestamp**, so a reading buffered while offline keeps the -time it was taken (spike problem P4), and the client needs an **alarm** API, -which certificate renewal already wants and publishes by hand today. - -## What Changes - -- **`CONFIG_TEDGE_TELEMETRY` becomes real:** `tedge_publish_measurement()`, - `tedge_publish_event()`, `tedge_raise_alarm()` and `tedge_clear_alarm()` - do what their documentation says, from any thread. -- **Every message carries a timestamp**, taken when the application - publishes, not when the message leaves. An application may pass its own. -- **Messages survive a short outage:** a bounded buffer in the module's heap - holds what cannot be sent yet and flushes it on reconnect, oldest first. - When it is full, the oldest measurement is dropped and counted, and the - count is reported, so data loss is visible rather than silent. -- **Both transports:** free-form `te/` topics on the MQTT Service, SmartREST - (`200`, `400`, `301`/`306`) on Core MQTT, from the same API call. -- **`CONFIG_TEDGE_HEALTH` becomes real:** the client publishes its own vital - signs — uptime, free heap, the reason for the last reset, and the Wi-Fi - signal where the application offers it — on an interval, so a device that - is degrading can be spotted before it fails. -- **Certificate renewal stops hand-rolling its alarm** and uses the API. - -## Non-goals - -- Storing telemetry across a reboot. The buffer is RAM; a device that - reboots loses what it had not sent. Persisting it is a separate decision - about flash wear. -- Aggregation, batching into one message, or any sampling logic: the - application decides what and when to measure. -- Child devices and services other than the client's own. - -## Resource constraints - -| Item | Cost | -|---|---| -| Text | small: JSON building and the topic rules; the transport is already there | -| RAM | the buffer, `TEDGE_TELEMETRY_BUFFER_BYTES` (default 2048) from the module heap, plus one message being built | -| Network | one publish per call, QoS 0 for measurements (a lost reading is a lost reading), QoS 1 for events and alarms | -| Time | building a message is microseconds; nothing blocks the caller | - -## Capabilities - -### New Capabilities - -- `tedge-telemetry`: what an application can send, when it is sent, what - happens to it while the device is offline, and what the client reports - about itself. - -### Modified Capabilities - -- `device-management-features`: telemetry and device health stop being - unimplemented features. -- `tedge-client-module`: the telemetry hooks in the integration contract - become requirements with scenarios (the module still samples nothing by - itself). - -## Impact - -- `tedge-zephyr/src/`: a new `tedge_telemetry.c` (the API, the buffer, the - JSON) and `tedge_health.c` (the client's own vital signs), with the - transport picking the topic and encoding. -- `tedge_cert_renew.c` switches to `tedge_raise_alarm()`. -- The Modbus application publishes its pump simulation through the API, so - the repository shows an application using it end to end. -- Cumulocity: the tenant's Smart Functions map the `te/` topics, as they - already do for the twin. diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/specs/device-management-features/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/specs/device-management-features/spec.md deleted file mode 100644 index fe22dfa..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/specs/device-management-features/spec.md +++ /dev/null @@ -1,27 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Each feature is selected independently at build time - -Each device-management feature SHALL have its own Kconfig option, so that it can -be included in or excluded from an image independently of the others. The -features are telemetry, device-health telemetry, restart, firmware update, -shell command, log upload, remote access, configuration management and -certificate renewal. The transport (direct to Cumulocity, or via a thin-edge.io -gateway) and the authentication method (Cumulocity CA certificate, or bootstrap -basic-auth credentials) SHALL each be a Kconfig choice. A feature that has not -been implemented yet SHALL be present in the menu but not selectable, and -the public API calls that belong to it SHALL return "not supported" rather -than failing silently. - -#### Scenario: Feature removed to save resources - -- **WHEN** an image is built with only remote access disabled -- **THEN** the remote-access code, its WebSocket client, its bridge thread and - its TLS session are absent from the image -- **AND** every other selected feature is still built and works - -#### Scenario: Minimal image - -- **WHEN** an image is built with only the connection, inventory and restart - enabled -- **THEN** it links and runs with a single MQTT/TLS session and no HTTP client diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/specs/tedge-client-module/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/specs/tedge-client-module/spec.md deleted file mode 100644 index 9079c2f..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/specs/tedge-client-module/spec.md +++ /dev/null @@ -1,54 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Integration hooks for the host application - -The public API SHALL let the host application: - -- provide the device identity: external ID, name, type, and firmware name and - version (with a documented default for each); -- push telemetry: measurements, events and alarms; -- register its own operation handlers, log types and configuration types; -- prepare for or veto a restart requested by the cloud; -- add its own health checks that must pass before a newly updated firmware image - is confirmed; -- observe the client's state (for example to drive a status LED); -- optionally report liveness progress to the application's watchdog. - -The module SHALL NOT call `sys_reboot()` without first giving the application's -restart hook the chance to respond. - -#### Scenario: Application narrows remote-access targets - -- **WHEN** the application registers a remote-access target hook and the - cloud asks for a tunnel to a target the built-in policy allows but the hook - refuses -- **THEN** the client opens no connection and fails the operation with the - hook's reason - -#### Scenario: Application vetoes a new firmware image - -- **WHEN** a newly installed image is running and connected, and the - application's firmware-check hook reports that the application is not - healthy -- **THEN** the client does not confirm the image, so the bootloader reverts - it on the next reset - -#### Scenario: Application-supplied telemetry - -- **WHEN** the application calls the telemetry API with a measurement -- **THEN** the client publishes it on the configured transport, and no telemetry - is read from anything outside the calls the application makes - -#### Scenario: Telemetry from the application's own threads - -- **WHEN** the application publishes telemetry from a thread of its own - while the client is busy with an operation -- **THEN** the call returns without waiting for the network, and the client - sends the message on its own thread - -#### Scenario: Application vetoes a restart - -- **WHEN** a restart operation arrives and the application's restart hook - declines it -- **THEN** the client does not reboot and marks the operation as failed with the - reason returned by the hook diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/specs/tedge-telemetry/spec.md b/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/specs/tedge-telemetry/spec.md deleted file mode 100644 index e3010d6..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/specs/tedge-telemetry/spec.md +++ /dev/null @@ -1,86 +0,0 @@ -## ADDED Requirements - -### Requirement: An application can publish measurements, events and alarms - -When the telemetry feature is built in, the client SHALL let the host -application publish measurements, events and alarms, and clear an alarm it -raised. A call SHALL be safe from any thread and SHALL NOT block on the -network. The client SHALL NOT produce telemetry the application did not ask -for, beyond its own health. - -#### Scenario: A measurement reaches the cloud - -- **WHEN** the application publishes a measurement while the device is - connected -- **THEN** the cloud records it with its series and values - -#### Scenario: Published from another thread - -- **WHEN** the application publishes from its own thread while the client is - busy -- **THEN** the call returns promptly and the message is sent by the client - -#### Scenario: An alarm is raised and cleared - -- **WHEN** the application raises an alarm and later clears it -- **THEN** the cloud shows the alarm raised with its severity and text, and - then cleared - -### Requirement: Telemetry carries the time it was taken - -Every message SHALL carry the time it was produced, so that a message held -while the device is offline is recorded with the time of the reading rather -than the time it arrived. The application MAY supply the time; otherwise the -client SHALL use the current time. When the device has no valid clock, the -client SHALL still send the message and let the cloud stamp it. - -#### Scenario: A reading is buffered and sent later - -- **WHEN** the application publishes a measurement while the device is - offline, and the device reconnects a minute later -- **THEN** the cloud records the measurement at the time it was taken - -### Requirement: A short outage does not lose data silently - -The client SHALL hold messages it cannot send yet in a bounded buffer and -send them, oldest first, once it is connected. When the buffer is full, the -client SHALL drop the oldest measurement rather than refuse new data or grow -its memory, SHALL count what it dropped, and SHALL report that count. Events -and alarms SHALL take precedence over measurements. - -#### Scenario: Brief disconnection - -- **WHEN** the connection drops for a few seconds while the application - keeps publishing -- **THEN** the messages are sent once the client reconnects - -#### Scenario: The buffer fills - -- **WHEN** an application publishes faster than the connection can carry for - long enough to fill the buffer -- **THEN** the oldest measurements are dropped, the client keeps running, - and the number dropped is reported - -### Requirement: The client reports its own health - -When device health is built in, the client SHALL publish its own vital signs -on an interval: how long it has been running, how much of its heap is free, -how many telemetry messages it has dropped, and why the device last reset. - -#### Scenario: A device that is degrading - -- **WHEN** a device's free heap falls over hours while it keeps running -- **THEN** the trend is visible in the cloud without connecting to the - device - -### Requirement: Telemetry works on both transports - -The same API calls SHALL produce free-form `te/` messages on a transport -that has them, and the equivalent SmartREST messages where it does not, with -no change to the application. - -#### Scenario: The same application on either transport - -- **WHEN** an application that publishes measurements is built once for the - MQTT Service and once for Core MQTT -- **THEN** both builds report the same measurements to the cloud diff --git a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/tasks.md b/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/tasks.md deleted file mode 100644 index 9002bad..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-direct-telemetry/tasks.md +++ /dev/null @@ -1,39 +0,0 @@ -## 1. The telemetry path - -- [x] 1.1 `tedge_telemetry.c`: the byte-ring buffer in the module heap, with events and alarms ahead of measurements and the oldest measurement dropped when full (D2) -- [x] 1.2 Build each message at the point of call, with a timestamp (the application's or the client's), and the "no clock yet" case (D3) -- [x] 1.3 `tedge_publish_measurement()`, `tedge_publish_event()`, `tedge_raise_alarm()`, `tedge_clear_alarm()`: queue and return, safe from any thread (D1) -- [x] 1.4 The client thread drains the queue while connected, oldest first -- [x] 1.5 Transport encoding: `te/` topics on the MQTT Service, SmartREST on Core MQTT, QoS 0 for measurements and QoS 1 for events and alarms (D4) -- [x] 1.6 Kconfig: `TEDGE_TELEMETRY_BUFFER_BYTES`; drop the experimental gate for telemetry - -## 2. The client's own health - -- [x] 2.1 `tedge_health.c`: uptime, free module heap, dropped messages and the reset reason from `hwinfo`, every `TEDGE_HEALTH_INTERVAL_S` (D5) -- [x] 2.2 Kconfig: `TEDGE_HEALTH_INTERVAL_S`; drop the experimental gate for health - -## 3. Use it - -- [x] 3.1 Certificate renewal raises its alarm through the API, falling back to SmartREST when telemetry is not built in (D6) -- [x] 3.2 The Modbus application publishes its simulation through the API, so the repository has a working example - -## 4. Tests - -- [x] 4.1 Unit tests: the buffer (fits, full, oldest dropped, events ahead of measurements, the drop counter) -- [x] 4.2 Unit tests: the message builders (measurement with several series, event, alarm severities, with and without a timestamp) -- [x] 4.3 Kconfig cases: telemetry and health selectable; the API absent when they are off - -## 5. Hardware verification - -- [x] 5.1 C6: measurements from the Modbus application appear in Cumulocity (natively on Core MQTT; on the MQTT Service the `te/` payloads were captured on the device and await the tenant's Smart Function, which the tenant owner writes) -- [x] 5.2 An event and an alarm appear, and the alarm clears -- [x] 5.3 Timestamps: publish while disconnected, reconnect, and confirm the cloud records the time of the reading -- [x] 5.4 The buffer: publish faster than the link for a while and confirm the device keeps running and reports what it dropped -- [x] 5.5 Health measurements arrive on their interval -- [x] 5.6 Core MQTT: the same application reports the same measurements as SmartREST -- [x] 5.7 Footprint row with telemetry and health enabled - -## 6. Wrap-up - -- [x] 6.1 README: the telemetry API, what the payloads look like, the buffer's limits, and what belongs to the application rather than the client -- [x] 6.2 Profiles: telemetry and health in `full.conf` and `minimal.conf`; record the results in design.md and update the roadmap diff --git a/openspec/changes/archive/2026-09-20-c8y-operation-ids/design.md b/openspec/changes/archive/2026-09-20-c8y-operation-ids/design.md deleted file mode 100644 index f9d86d2..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-operation-ids/design.md +++ /dev/null @@ -1,166 +0,0 @@ -## Context - -SmartREST's `501`/`502`/`503` name a fragment, and Cumulocity applies them -to the oldest operation in the matching state. The device's own view of -which operation it is running never reaches the platform, so the mapping -from "what the device did" to "which operation was updated" is a guess that -is usually right and occasionally, permanently, wrong. - -Templates `504`, `505` and `506` take an operation id instead. The only -thing missing is the id itself: static templates on `s/ds` do not carry it. -Cumulocity's documented ways to learn it are the Device Control REST API, a -**JSON subscription** on `devicecontrol/notifications`, or a SmartREST 2.0 -response template collection. The JSON subscription needs no setup, no -tenant-wide state and no version to keep in step with the firmware, so that -is the one this client takes. - -## Goals / Non-Goals - -**Goals:** every status names the operation it belongs to; a stale operation -from an earlier boot cannot absorb a later result; a restart is completed by -id; nothing else about the features changes. - -**Non-Goals:** custom request templates, JSON operation delivery, concurrent -operations. - -## Decisions - -### D1: Operations arrive as JSON, because that is where the id is - -The client subscribes to `devicecontrol/notifications` and stops -subscribing to `s/ds`: one delivery of each operation, and the JSON carries -`id`. - -*Alternative: a SmartREST 2.0 response-template collection.* It also carries -the id, but it is tenant-wide state with a version in its name, registered -and checked at every connect, and a device running older firmware breaks if -anyone edits it. The JSON is delivered with no setup at all. - -*Alternative: the Device Control REST API.* Rejected: an HTTPS round trip -per operation, on a device with one spare TLS session. - -### D2: The JSON becomes the line the handlers already parse - -Each operation is turned into the same comma-separated line the static -templates delivered, **with the id where the device serial used to be**: - -| Operation | Line built from the JSON | -|---|---| -| `c8y_Restart` | `510,` | -| `c8y_Command` | `511,,` | -| `c8y_Firmware` | `515,,,,` | -| `c8y_LogfileRequest` | `522,,,,,,` | -| `c8y_RemoteAccessConnect` | `530,,,,` | - -Every feature's parser keeps working unchanged, because the serial it -ignored is now an id it still ignores. Only the dispatcher reads the id, and -only the status messages change. - -Fields are quoted as SmartREST requires, so a command containing a comma -survives the trip. - -### D3: The client reads the fields it knows, not arbitrary JSON - -An operation's JSON is read with the same small helpers used for twin data: -find the fragment by name, then read the values inside it. A value may be a -number (`port`, `maximumLines`), so `tedge_json_value()` joins the existing -string reader. This is not a parser and does not pretend to be one — it -reads the fields of the five operations this client supports. - -The payload buffer grows to `CONFIG_TEDGE_C8Y_PAYLOAD_BYTES` (2048 with this -feature on), because an operation's JSON carries a delivery log alongside -its own fields. A payload that still does not fit is dropped with a warning, -as today. - -**Nothing of the payload is logged.** A remote-access operation carries a -connection key; only the fragment name and the id go in the log. - -### D4: Every status is by id - -`504,` to start, `506,[,]` to succeed, `505,,` -to fail, including the path that refuses an operation whose feature is not -built in. When no id is known — the static fallback — the client publishes -`501`/`502`/`503` exactly as before. - -### D5: Operations that outlive the session keep their id in settings - -A restart and a firmware update finish after a reboot, so their ids are -stored beside the markers those features already keep, and the operation is -completed by id when the device comes back. Everything else finishes inside -the session, where the queue guarantees there is exactly one operation in -flight and one id to remember. - -### D6: The queue stays, for a different reason - -With ids, two operations executing at once would be reported correctly. They -still are not run at once: a second file transfer or tunnel needs a second -TLS session and another thread, and `CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS` on -these boards is 2, one of which is MQTT. The queue is now a resource bound, -and the code says so. - -### D7: A way back - -`CONFIG_TEDGE_C8Y_OPERATION_JSON` (default y) can be turned off, which -restores `s/ds` and `501`/`502`/`503` for a tenant or a transport where the -notification topic is not available. Both paths are exercised by the Kconfig -cases. - -## Risks / Trade-offs - -- [The notification topic might not be served on the MQTT Service] → the - first task is to prove it on hardware, on both ports, before the rest is - written; the static path stays as the way back. -- [An operation's JSON is bigger than the payload buffer] → the buffer grows - with the feature, and an oversized payload is dropped with a warning - naming the size, as it is today. -- [Reading fields by name could pick the wrong one] → the search starts at - the fragment, not at the top of the document, and the fields are read from - inside it. -- [A connection key in a log] → the payload is never logged; only the - fragment name and the id are. - -## Results (ESP32-C6, Modbus application, 2026-09-20) - -**Cost**, the same image with the feature on and off: - -| Build | text | bss | -|---|---|---| -| Static templates (`s/ds`, 501–503) | 984,040 | 434,584 | -| Operations as JSON (504–506) | 985,192 (+1,152) | 434,808 (+224) | - -The payload buffer doubles to 2 KB with the feature on, and the SmartREST -line building it replaces goes away, so the net RAM cost is a few hundred -bytes. - -**Verified on hardware**, against the tenant, on the MQTT Service: - -| Check | Result | -|---|---| -| An operation arrives with its id | `operation c8y_Command (214925)`, and the command ran | -| A stale EXECUTING operation | left EXECUTING, while the operation that followed got its own result — the failure this change exists to fix | -| Two operations created together | a log upload and a command, each with its own result | -| Restart | completed for the operation that asked, after the reboot | -| Firmware update | 0.2.0 → 0.3.0, completed by the id kept in settings across the swap | -| Remote access | failed with its reason, by id, rather than hanging | -| Log upload, shell command | unchanged, now reported by id | -| The static path (feature off) | still works: a command ran and was reported the old way | - -### What the hardware taught us - -**The notification topic is served on the MQTT Service too**, which was the -open question this change started with: the same `devicecontrol/notifications` -subscription works on 9883 as on 8883, so no transport needs the fallback -today. - -**The queue is no longer load-bearing.** Two operations created together now -end correctly whichever order they finish in; the queue stays because a -second file transfer or tunnel needs a TLS session this board does not have -spare, which is a resource decision the code now states as such. - - -## Migration Plan - -- Devices already in the field keep working: they use static templates until - they are updated, and the two paths can coexist in a tenant. -- Stale EXECUTING operations left by earlier firmware stay stale; they no - longer steal results, but an operator still has to clear them once. diff --git a/openspec/changes/archive/2026-09-20-c8y-operation-ids/proposal.md b/openspec/changes/archive/2026-09-20-c8y-operation-ids/proposal.md deleted file mode 100644 index ab0d271..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-operation-ids/proposal.md +++ /dev/null @@ -1,81 +0,0 @@ -## Why - -The client reports every operation's progress with SmartREST's `501`, `502` -and `503`, which name a *fragment* and act on the **oldest operation in that -state**. The operation the device was actually working on is never named, -and that has consequences it cannot fix from inside: - -- Two operations executing at once collect each other's results. The - diagnostics change worked around this by running one at a time and - queueing the rest (`c8y-direct-diagnostics`, D8). -- An operation interrupted by a reset stays EXECUTING in Cumulocity for - ever, and **every later result completes that stale operation instead of - the real one**. This was seen on hardware: after a crash mid-upload, the - next three log requests each completed the previous stale operation and - left themselves executing. -- A restart is completed after the reboot by publishing `503,c8y_Restart` - and hoping the oldest restart operation is the one that caused it. - -Cumulocity has the answer already: templates **504, 505 and 506** take an -**operation id** and act on exactly that operation. The device only needs to -know the id, which static templates do not carry — but a SmartREST 2.0 -**response template collection** does, and registering one is a few hundred -bytes at connect. - -This is phase 3 and touches every feature that has an operation (restart, -firmware update, remote access, log upload, shell command) on both -transports, on the same ESP32-C6 and ESP32-S3 boards. - -## What Changes - -- **The client receives operations as JSON** on - `devicecontrol/notifications`, which is where Cumulocity puts the - operation id, instead of the static templates on `s/ds`. -- **Every status goes out by id:** `504,` to start, `506,[,]` - to succeed, `505,,` to fail. `501`/`502`/`503` disappear from - the client. -- **A restart is completed by id after the reboot**: the id is stored beside - the restart marker, so the right operation is closed even if another was - queued meanwhile. -- **An operation's result can no longer be stolen** by a stale EXECUTING - operation from an earlier boot, so a device that crashed mid-operation - recovers by itself. -- **The one-at-a-time queue stays**, but for the honest reason: a second - operation may want a second TLS session and a second thread, and a - constrained board has neither. It is no longer load-bearing for - correctness. -- **`CONFIG_TEDGE_C8Y_OPERATION_JSON`** (default y) can be turned off to go - back to `s/ds` and the old templates. - -## Non-goals - -- A general JSON parser. The client reads the handful of fields an - operation it supports actually has, with the same few hundred bytes of - JSON reading it already uses for twin data and the identity lookup. -- Publishing as JSON: statuses stay SmartREST on `s/us`. -- Running several operations at once. The queue stays. - -## Resource constraints - -| Item | Cost | -|---|---| -| Text | reading a few JSON fields, turning them into the line the handlers already parse, and the id plumbing; `501`–`503` go away | -| RAM | a larger MQTT payload buffer (an operation's JSON carries a delivery log), and one operation id per operation in flight | -| Network | nothing extra: the same operations, in JSON rather than CSV | -| Flash | the stored restart id, beside the marker already stored | - -## Capabilities - -### Modified Capabilities - -- `device-management-features`: an operation's status SHALL be reported for - the operation that was requested, not for whichever one the cloud - considers oldest. - -## Impact - -- `tedge-zephyr/src/tedge_c8y.c`: the subscription, the JSON-to-line - translation, the id carried through dispatch, and `504`/`505`/`506`. -- `tedge_json.c`: reading a value that may be a number as well as a string. -- The restart marker gains the operation id. -- README and the Kconfig gain the new option and the reason for it. diff --git a/openspec/changes/archive/2026-09-20-c8y-operation-ids/specs/device-management-features/spec.md b/openspec/changes/archive/2026-09-20-c8y-operation-ids/specs/device-management-features/spec.md deleted file mode 100644 index 8f8e5f6..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-operation-ids/specs/device-management-features/spec.md +++ /dev/null @@ -1,27 +0,0 @@ -## ADDED Requirements - -### Requirement: An operation's status names the operation - -The device SHALL report an operation's progress and outcome for the -operation it was given, and SHALL NOT report a status that the cloud could -apply to a different operation. A result SHALL NOT be absorbed by an -operation left behind by an earlier boot, and a restart SHALL be completed -for the operation that caused it. - -#### Scenario: Two operations in the same period - -- **WHEN** two operations are created for a device and both are carried out -- **THEN** each one ends with its own result, whatever order they finish in - -#### Scenario: An operation left executing by a reset - -- **WHEN** a device resets while an operation is executing, and a later - operation is carried out afterwards -- **THEN** the later operation gets its own result, and the interrupted one - is not reported as that result - -#### Scenario: A restart across the reboot - -- **WHEN** a device restarts because the cloud asked it to -- **THEN** the restart operation that asked for it is the one reported as - successful once the device is back diff --git a/openspec/changes/archive/2026-09-20-c8y-operation-ids/tasks.md b/openspec/changes/archive/2026-09-20-c8y-operation-ids/tasks.md deleted file mode 100644 index 2e3b377..0000000 --- a/openspec/changes/archive/2026-09-20-c8y-operation-ids/tasks.md +++ /dev/null @@ -1,38 +0,0 @@ -## 1. Prove it works - -- [x] 1.1 Receive one operation with its id on `devicecontrol/notifications` on the MQTT Service (9883), from the device, before writing the rest -- [x] 1.2 The same on Core MQTT (8883) - -## 2. Receiving - -- [x] 2.1 Subscribe to `devicecontrol/notifications` in place of `s/ds`; Kconfig `TEDGE_C8Y_OPERATION_JSON` and `TEDGE_C8Y_PAYLOAD_BYTES` (D1, D7) -- [x] 2.2 `tedge_json_value()`: a value that may be a number as well as a string (D3) -- [x] 2.3 Turn an operation's JSON into the line the handlers already parse, with the id where the serial was, quoting what needs it (D2) -- [x] 2.4 Log the fragment and the id, never the payload (D3) - -## 3. Carrying the id - -- [x] 3.1 The dispatcher keeps the id of the operation in flight -- [x] 3.2 `504`/`505`/`506` in place of `501`/`502`/`503`, including the unsupported-operation path (D4) -- [x] 3.3 The restart and the firmware update keep their id across the reboot (D5) -- [x] 3.4 The queue keeps working, with its reason restated as a resource bound (D6) - -## 4. Tests - -- [x] 4.1 Unit tests: the JSON-to-line translation for each operation, including a comma in a command and a numeric port -- [x] 4.2 Unit tests: `tedge_json_value()` for strings, numbers and missing keys -- [x] 4.3 Kconfig cases: the JSON path on by default; the static path still builds - -## 5. Hardware verification - -- [x] 5.1 Every operation still works end to end: restart, firmware update, remote access, log upload, shell command -- [x] 5.2 Two operations created together each get their own result -- [x] 5.3 A stale EXECUTING operation does not absorb a later result -- [x] 5.4 A restart is completed for the operation that asked for it -- [x] 5.5 The static path (feature off) still behaves as before -- [x] 5.6 Footprint row - -## 6. Wrap-up - -- [x] 6.1 README: what the collection is, why the id matters, and the option to go back -- [x] 6.2 design.md results; archive diff --git a/openspec/changes/archive/2026-10-06-shell-help-newline-list/.openspec.yaml b/openspec/changes/archive/2026-10-06-shell-help-newline-list/.openspec.yaml deleted file mode 100644 index aa32566..0000000 --- a/openspec/changes/archive/2026-10-06-shell-help-newline-list/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-10-06 diff --git a/openspec/changes/archive/2026-10-06-shell-help-newline-list/design.md b/openspec/changes/archive/2026-10-06-shell-help-newline-list/design.md deleted file mode 100644 index 89cd2b6..0000000 --- a/openspec/changes/archive/2026-10-06-shell-help-newline-list/design.md +++ /dev/null @@ -1,80 +0,0 @@ -## Context - -`tedge_shell_help_text()` (`tedge-zephyr/src/tedge_shell_allow_list.c`) -already writes: - -``` -Commands this device runs (arguments may follow): - kernel uptime - net iface - ... -``` - -The text travels through `tedge_shell_poll_event()` to `tedge_c8y.c`, which -calls `op_succeeded("c8y_Command", ev.output)`. That quotes the text with -`tedge_sr_quote()`, which replaces `\n` and `\r` with spaces because "a -newline would end the SmartREST line". The result is published as -`506,,"..."` (or `503,c8y_Command,"..."`) on `c8y/s/us`. - -That comment is too cautious for a quoted field: SmartREST 2.0 parses CSV, and -a line break inside a double-quoted value is part of the value. thin-edge.io's -own c8y mapper reports multi-line c8y_Command output the same way. - -`tedge_sr_quote()` has about ten other callers (remote access, log upload, -firmware, events, twin data) whose texts are meant to be one line. - -## Goals / Non-Goals - -**Goals:** -- `help` shows in Cumulocity as a header plus one command per line. -- Any c8y_Command output keeps its line structure. -- No new RAM, no change for other SmartREST messages. - -**Non-Goals:** -- Larger results, a different `help` format, or changes to the allow-list. - -## Decisions - -1. **A separate quoting helper, used only for c8y_Command.** - Add `tedge_sr_quote_lines(in, out, len)`, which behaves like - `tedge_sr_quote()` but copies `\n` and drops `\r`. Both share one loop - (static helper with a `keep_newlines` flag) so escaping and truncation stay - identical. - *Alternative*: change `tedge_sr_quote()` itself. Rejected: other callers - build texts (reasons, event text, twin fragments) that should stay single - line, and changing them all is outside this fix. - -2. **Pass the choice through `op_succeeded`/`op_failed` only for - c8y_Command.** Add `op_succeeded_lines()`/`op_failed_lines()` (or a - `bool lines` parameter on a shared static) in `tedge_c8y.c`, and call them - from the shell event loop. Failed results also keep lines, since a command - that fails often prints a multi-line usage message. - -3. **Drop `\r` rather than translate it.** Zephyr's dummy backend output ends - lines with `\r\n`; keeping `\r` would render as stray characters or blank - lines in the UI. A lone `\r` is rare enough that dropping it is fine. - -4. **Keep the help text as it is.** The two-space indent under a header reads - well once newlines survive, and the unit test already pins that format. - Only the comment in `tedge_c8y.c` ("newlines become spaces") changes. - -## Risks / Trade-offs - -- [Cumulocity rejects or splits a quoted value with an embedded newline] → - verify on hardware against the dev tenant before merging (task 3). If it is - rejected, fall back to joining lines with a visible separator (e.g. `; `) - inside `tedge_shell_help_text()` and leave the quoting alone. -- [Truncation cuts a line mid-way] → unchanged from today; truncation already - happens at the 224-byte quoted buffer and still closes the quote. -- [A truncated value ends right after an escaped `"`] → the shared loop keeps - the existing "need 2 bytes for `\"\"`" guard, so the field stays well formed. - -## Migration Plan - -None. Devices updated by OTA start sending multi-line results; older devices -keep sending single-line ones. Rollback is reverting the commit. - -## Open Questions - -- None blocking; the hardware check in task 3 settles the Cumulocity - behaviour. diff --git a/openspec/changes/archive/2026-10-06-shell-help-newline-list/proposal.md b/openspec/changes/archive/2026-10-06-shell-help-newline-list/proposal.md deleted file mode 100644 index 33ebbd9..0000000 --- a/openspec/changes/archive/2026-10-06-shell-help-newline-list/proposal.md +++ /dev/null @@ -1,65 +0,0 @@ -## Why - -Sending `help` as a cloud shell command (c8y_Command) should list the commands -the device will run, one per line. The device already builds that list one -command per line, but the reported result arrives in Cumulocity as a single -run-on line (`Commands this device runs (arguments may follow): kernel uptime net iface ...`), -because `tedge_sr_quote()` turns every newline into a space before the result -is published. With seven allow-listed commands this is hard to read, and the -same flattening hides the line structure of every other command's output. - -## What Changes - -- The result of a c8y_Command operation (successful or failed) keeps its line - breaks: newlines are sent inside the quoted SmartREST field instead of being - replaced with spaces. Carriage returns are dropped, so Zephyr's `\r\n` - output becomes plain `\n`. -- `help` (and `?`) therefore show up in Cumulocity as a header line followed by - one allow-listed command per line. -- Other SmartREST messages (remote access, log upload, firmware, events, etc.) - keep today's single-line quoting; nothing outside c8y_Command changes. - -## Non-goals - -- Changing what `help` lists, or its wording, beyond what is needed to read - well once line breaks survive. -- Raising the size of the reported result (still bounded by the 224-byte quoted - buffer and `CONFIG_TEDGE_SHELL_COMMAND_OUTPUT_BYTES`). -- Changing Zephyr's own `help`, which the device still does not run. -- Any change to the allow-list rules. - -## Scope - -- **Phase**: 3 (thin-edge.io / Cumulocity client, `tedge-zephyr`). -- **Protocol**: none; protocol-agnostic, affects every app built with - `CONFIG_TEDGE_SHELL_COMMAND` (modbus-server, tedge-agent, opcua-server; snmp - does not link with the shell today). -- **Boards**: those that build the shell-diagnostics extra — ESP32-S3-DevKitC, - ESP32-C6-DevKitC, QT Py ESP32-S3 (and any other board where it fits). -- **Kconfig**: none added. -- **Resource budget**: a few tens of bytes of flash for a quoting variant, no - new RAM (existing buffers are reused). C6 builds sit at 93–95% RAM, so the - change must not add buffers. -- **Public `tedge_*` API**: unchanged; the new helper is internal - (`tedge_internal.h`). - -## Capabilities - -### New Capabilities - -_None._ - -### Modified Capabilities - -- `tedge-diagnostics`: the commanded-shell requirement gains the rule that the - reported output keeps its line breaks, and that `help` lists the allowed - commands one per line. - -## Impact - -- `tedge-zephyr/src/tedge_smartrest.c` / `tedge_internal.h`: a quoting variant - that keeps `\n` and drops `\r`. -- `tedge-zephyr/src/tedge_c8y.c`: c8y_Command results use that variant. -- `tedge-zephyr/tests/unit/src/main.c`: unit tests for the new quoting. -- Cumulocity: the c8y_Command result text becomes multi-line; the device - management UI shows it as such. diff --git a/openspec/changes/archive/2026-10-06-shell-help-newline-list/specs/tedge-diagnostics/spec.md b/openspec/changes/archive/2026-10-06-shell-help-newline-list/specs/tedge-diagnostics/spec.md deleted file mode 100644 index e94e792..0000000 --- a/openspec/changes/archive/2026-10-06-shell-help-newline-list/specs/tedge-diagnostics/spec.md +++ /dev/null @@ -1,49 +0,0 @@ -## 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. 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 diff --git a/openspec/changes/archive/2026-10-06-shell-help-newline-list/tasks.md b/openspec/changes/archive/2026-10-06-shell-help-newline-list/tasks.md deleted file mode 100644 index 22b8568..0000000 --- a/openspec/changes/archive/2026-10-06-shell-help-newline-list/tasks.md +++ /dev/null @@ -1,22 +0,0 @@ -## 1. Firmware: keep line breaks in c8y_Command results - -- [x] 1.1 In `tedge-zephyr/src/tedge_smartrest.c`, move the body of `tedge_sr_quote()` into a static helper with a `keep_newlines` flag; add `tedge_sr_quote_lines()` that keeps `\n` and drops `\r`; declare it in `tedge_internal.h` -- [x] 1.2 In `tedge-zephyr/src/tedge_c8y.c`, add line-keeping variants of `op_succeeded`/`op_failed` (sharing one static helper) and use them in the `CONFIG_TEDGE_SHELL_COMMAND` event loop; update the "newlines become spaces" comment -- [x] 1.3 Check that no other caller of `tedge_sr_quote()` changes behaviour and that no new buffers were added - -## 2. Unit tests (native_sim) - -- [x] 2.1 Add `tedge_smartrest` tests for `tedge_sr_quote_lines()`: `\n` kept, `\r\n` becomes `\n`, quotes still doubled, truncation still closes the quote -- [x] 2.2 Keep the existing `tedge_sr_quote()` newline-to-space test passing unchanged -- [x] 2.3 Run the `tedge-zephyr/tests/unit` suite and the Kconfig tests - -## 3. Hardware check against the dev tenant - -- [x] 3.1 Build modbus-server tedge-full with the shell-diagnostics extra for the S3-DevKitC and compare flash/RAM with the previous build (expect only a few bytes of flash) -- [x] 3.2 Flash it, send `help` as a c8y_Command, and confirm in Cumulocity that the operation is SUCCESSFUL and the result shows one command per line -- [x] 3.3 Send a multi-line allowed command (e.g. `net iface`) and a refused one; confirm both results are well formed and the device stays connected -- [x] 3.4 If Cumulocity rejects or splits the multi-line value, switch to the fallback in design.md (a visible separator in `tedge_shell_help_text()`) and record why — not needed: eu-latest accepted it (2026-10-06, S3-DevKitC `tedge-modbusd405927b33d0`, app +112 B flash, RAM unchanged) - -## 4. Docs - -- [x] 4.1 Update the shell diagnostics note in `DEVICES.md` to say `help` lists the commands one per line diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/.openspec.yaml b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/.openspec.yaml deleted file mode 100644 index a26662f..0000000 --- a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-10-07 diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/design.md b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/design.md deleted file mode 100644 index c40631e..0000000 --- a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/design.md +++ /dev/null @@ -1,251 +0,0 @@ -## Context - -`bridge()` in `tedge-zephyr/src/tedge_remote_access.c` resolves the target -on the session's own thread: an IP literal goes straight to the policy; any -other string goes to `zsock_getaddrinfo(host, NULL, AF_INET)`. Zephyr's -resolver sends a name ending in `.local` to the mDNS server entry -(224.0.0.251:5353) only when `CONFIG_MDNS_RESOLVER` is set. tedge-zephyr -selects `DNS_RESOLVER` (through `TEDGE_HTTP`) but nothing selects -`MDNS_RESOLVER`, so a `.local` target goes to the DHCP-supplied unicast DNS -server, which does not know it, and the session fails with -"cannot resolve". - -The SNMP app already sets `CONFIG_MDNS_RESOLVER=y` together with -`CONFIG_MDNS_RESPONDER=y`, and its `.local` trap manager still fails with -`DNS_EAI_SYSTEM` (-11) on every retry, while IP literals work and the -responder answers for the device's own name. The cause has not been found. -So enabling the resolver is necessary but not known to be sufficient, and -every app here that builds tedge also runs the responder. - -Constraints: the module must not depend on `lib/` or `apps/`; every -feature is a build-time choice; the C6/S3 `full` builds and the ota builds -with the `remote-access` extra are tight on RAM and on socket/poll slots -(`CONFIG_ZVFS_POLL_MAX`, `NET_MAX_CONTEXTS`). - -## Goals / Non-Goals - -**Goals:** -- A `.local` target resolves on every board that builds remote access, - with or without the application's mDNS responder. -- Resolution time is bounded per session and the failure reason tells the - operator whether the name timed out or failed. -- The audit trail names the address the session actually reached. -- A spoofed mDNS answer cannot steer a tunnel off the device's LAN. - -**Non-Goals:** -- DNS-SD browsing, a name cache, IPv6, LLMNR (see the proposal). -- Fixing the SNMP trap manager; it may benefit from the root-cause fix - but is not changed here. - -## Decisions - -### 1. Root-cause the `-11` before writing code - -The first task reproduces the failure on a tedge build (S3 DevKitC, which -keeps a console and stays on the network — the C6 console resets on open -and the classic ESP32 boards stall) with `net dns .local` from the -Zephyr shell, with and without the responder. Candidates, in the order -they are cheapest to rule out: - -1. **The DHCP reconfigure drops the mDNS server.** DHCPv4 calls - `dns_resolve_reconfigure()` with only its unicast servers; if the - mDNS entry is not re-added, `.local` queries find no matching server - and return `DNS_EAI_SYSTEM`. Check with `net dns` (lists servers) - before and after the lease. -2. **The socket service / poll table is full.** In Zephyr 4.x the - resolver's sockets register with the socket service (dispatcher) the - responder also uses; the boot log `net_sock_svc: ... 3 poll entries` - already shows the table is too small. A failed registration surfaces as - a system error. Check by raising `CONFIG_ZVFS_POLL_MAX` and - `CONFIG_NET_SOCKETS_SERVICE_*`/`DNS_RESOLVER_MAX_SERVERS` together. -3. **Port 5353 conflict or multicast not received.** The resolver's - query socket and the responder both want 5353 / the 224.0.0.251 group; - or the Wi-Fi driver drops multicast RX without IGMP membership. Check - with a capture on the LAN (`tcpdump -i en0 port 5353`) to see whether - the query leaves and the answer arrives. - -The fix goes where the cause is: a `select`/`imply` or default in -tedge-zephyr's Kconfig if it is configuration; a documented requirement in -`tedge-zephyr/README.md` (and the board confs here) if only the -application can set it; a contained workaround in the module, with a -comment naming the Zephyr version, if it is a Zephyr bug. The finding is -recorded in `tedge-zephyr/docs/debugging-against-real-devices.md`. - -*Alternative considered:* skip Zephyr's resolver and send our own one-shot -mDNS query on a raw UDP socket. Rejected unless the root cause is a Zephyr -bug that cannot be worked around: it duplicates packet parsing the -resolver already has and costs more flash. - -**Finding (2026-10-07, S3 DevKitC, modbus-server `full`, Zephyr 4.4.2).** -Two Zephyr bugs, one behind the other: - -1. *No mDNS server.* `dns_resolve_init_default()` adds 224.0.0.251:5353 to - the default resolver only under `CONFIG_DNS_SERVER_IP_ADDRESSES`. With - DNS from DHCP, `net dns` lists only the DHCP server; a `.local` query has - no server, `dns_get_addr_info()` returns `-ENOENT` and `getaddrinfo()` - reports `DNS_EAI_SYSTEM` (-11) — the SNMP trap manager's error. Setting - `DNS_SERVER_IP_ADDRESSES=y` with empty servers puts mDNS in slot 0, and - the resolver sends *every* non-`.local` name to the first server with a - socket, so `pool.ntp.org` timed out and the client could not connect. - Adding the mDNS server from the module behind the DHCP one works. -2. *Answers never read.* With the application's mDNS responder on 5353, the - resolver's mDNS socket is paired with it by the DNS dispatcher and never - bound or polled. The query leaves from an ephemeral port; a capture on - the Pi shows the query and its answer (`A 192.168.68.84`, unicast back - to that port within 0.1 ms, as RFC 6762 6.7 requires), and the device - times out. No configuration fixes this while the responder runs, and - every app here runs it. - -So the fix is a contained workaround in the module (decision 3). - -### 2. `CONFIG_TEDGE_REMOTE_ACCESS_MDNS`, default `y` - -A bool under `TEDGE_REMOTE_ACCESS` that selects only `NET_UDP`: the module -sends its own query (decision 3), so Zephyr's `MDNS_RESOLVER` is not -needed. Default on, because finding LAN hosts -by name is what the remote-access-enabler role is for, and the cost is -expected to be small. A board without room turns it off; it then fails -`.local` targets with "mDNS is not built in" instead of asking the unicast -server — a clearer reason than "cannot resolve", and it avoids leaking -internal names to an upstream resolver. - -*Alternative:* select `MDNS_RESOLVER` and lean on Zephyr's resolver. -Rejected after the finding above: it cannot receive answers while the -responder runs. - -### 3. `.local` names: the module's own one-shot query; others: `dns_get_addr_info()` - -A `.local` lookup opens a UDP socket, sends one A query for the name to -224.0.0.251:5353 from its ephemeral port, and reads the answers on that -socket until the per-lookup timeout, taking the first that answers its -random id for that name (`mdns_once()`; the packet builder and parser are -pure and unit-tested in `tedge_ra_target.c`). This is RFC 6762's "legacy -unicast" exchange: avahi and systemd-resolved answer it directly to the -sending port, so it works whether or not the application runs the -responder, joins no multicast group, and leaves Zephyr's resolver list -alone. Cost: one socket for the lookup's duration, a 512-byte buffer on the -session stack, about 1 KB of code. - -*Alternatives:* add the mDNS server to Zephyr's resolver (works only -without the responder; tried); patch Zephyr's dispatcher to read the paired -socket (fixes SNMP too, but the module would need a patched Zephyr). The -dispatcher bug is worth reporting upstream; when a release fixes it this -can go back to the resolver. - -For unicast names, `zsock_getaddrinfo()` uses the global `CONFIG_NET_SOCKETS_DNS_TIMEOUT` and -collapses all failures into a few `DNS_EAI_*` codes. The bridge calls the -resolver API on the default context instead, waiting on a semaphore with -`CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS` per lookup (default 5000; -mDNS hosts normally answer in well under a second, but a host waking from -Wi-Fi power save, or a busy Pi, can take seconds, and a generous window -means fewer retries are spent on a slow answer). -This gives a per-feature bound and distinguishes a timeout -(`DNS_EAI_CANCELED`) from other errors for the reason text. It applies to -unicast names too, so both kinds of name share one path. Resolution stays -on the session thread, never the client thread that owns MQTT, and the seat -is held only for the bounded time. - -The resolver allows one query at a time by default -(`DNS_NUM_CONCUR_QUERIES=1`), so a lookup can find the slot taken by the -client's own (`-EAGAIN`); that maps to `DNS_EAI_SYSTEM` and is retried. - -### 3a. Retry failed lookups a bounded number of times - -A single mDNS query is one multicast UDP packet over Wi-Fi: no ARQ for -multicast, no retransmission by the AP, and a host in power save may miss -it. One lost packet should not fail a session the operator has to reopen -by hand. So `resolve_target()` loops: up to -`CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS` lookups (default 3, range -1–5), each bounded by the per-lookup timeout, with -`CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS` (default 500) between -them, and returns on the first answer. - -What is retried and what is not: - -| Outcome of a lookup | Retry? | -|---|---| -| Timeout (`DNS_EAI_CANCELED`) | yes | -| Transient error (`DNS_EAI_AGAIN`, `DNS_EAI_SYSTEM`, `-ENOMEM`, no route yet) | yes | -| Authoritative "no such name" from a unicast server (`DNS_EAI_NONAME`) | no — asking again gives the same answer | -| `.local` without mDNS built in | no — not a lookup at all | -| Policy refusal of the resolved address | no — resolution succeeded | - -Each failed attempt logs at WRN with the attempt number and the error -(never the connection key); only the final failure is posted as the -operation's reason, naming the attempt count. The loop runs on the -session thread and holds the seat while it runs, so the worst case is -bounded: attempts × timeout + (attempts − 1) × delay ≈ 16 s with the -defaults (3 × 5 s + 2 × 0.5 s). Task 5 checks that the Cumulocity -remote-access client waits that long for the device-side WebSocket; if it -does not, lower the attempts rather than the per-lookup timeout. A Kconfig `BUILD_ASSERT` -keeps that product under 30 s so a misconfiguration cannot hold a seat -indefinitely. - -The same loop serves unicast names, which mostly benefit from the -`DNS_EAI_AGAIN` case (a resolver that was still being configured after a -reconnect). - -*Alternatives:* rely on Zephyr's own retransmission inside one -`dns_resolve_name()` call — it retransmits only within one timeout window -and does not cover a socket-level error; or retry at the operation level -by failing and letting the operator reopen — that is exactly the -experience this avoids. Exponential backoff was considered and rejected: -with three attempts on a LAN, a fixed short pause is simpler and no -slower to recover. - -### 4. A `.local` answer must be on the link - -mDNS is unauthenticated: any host on the link can answer for any name. The -LAN policy already rejects an off-subnet address. The allow-list policy -matches names only, so `policy_check()` gains one extra rule: when the -host ends in `.local`, the resolved address must also be loopback, the -device's own, or on the device's subnets, whatever the policy. This keeps -"listing `rpi5.local:22`" meaning "the Pi on this LAN" and costs one -existing subnet check. The application hook still runs after and still -receives the resolved `sockaddr`, so the public API is unchanged. - -### 5. Event and log text carry both forms - -`"tunnel to rpi5.local (192.168.68.72):22 opened"` and the matching close -event; an IP-literal target keeps today's text. With a 63-character host, -an address and the close event's counters, the close text can exceed -`tedge_ra_event.text[144]` (about 192 characters at the extreme). The -event text grows to 200 bytes, and the client's quoting buffers with it -(about 112 B more RAM per queued event pair and on the client stack); a -unit test checks the longest case fits. The resolved address is stored in -the session (`char addr_s[NET_IPV4_ADDR_LEN]`). The twin data's session list keeps the host as requested. - -## Risks / Trade-offs - -- [Our own mDNS client diverges from Zephyr's] → it handles only A - answers to its own legacy-unicast query; the parser is unit-tested - against malformed and looping input. Revisit when Zephyr's dispatcher is - fixed. -- [One more socket while a name resolves, on boards near their limits] - → measure on the C6, S3 and QT Py `full` builds and the WROOM/ESP32-CAM ota - builds; raise `ZVFS_POLL_MAX`/contexts in tedge-zephyr's defaults only if - the measurement shows a shortfall. -- [Two hosts claim the same `.local` name] → the first answer wins; the - event shows which address was reached, so the operator can tell. -- [Seat held while waiting for a dead name] → bounded by attempts × - timeout + delays (about 16 s with the defaults, asserted under 30 s at - build time), and the reason names the timeout and the attempt count. -- [Retries hide a flaky network] → each failed attempt is logged at WRN, - so a target that only resolves on the third try shows up in the logs - even though the session succeeds. -- [Classic-ESP32 multicast reception is unreliable] → test there, but - record the result as board-specific rather than block on it (those - boards already have known Wi-Fi stalls). - -## Migration Plan - -No data or API migration. Existing endpoints with IP literals behave as -before. Rollback: set `CONFIG_TEDGE_REMOTE_ACCESS_MDNS=n`. - -## Open Questions - -- None blocking. Answered: the `-11` is "no mDNS server" (see the finding - under decision 1), and the resolver callback API is usable from the - module for unicast names. -- Follow-ups outside this change: report the dispatcher bug upstream; the - SNMP trap manager can reuse `mdns_once()`'s approach. diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/proposal.md b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/proposal.md deleted file mode 100644 index f91df83..0000000 --- a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/proposal.md +++ /dev/null @@ -1,111 +0,0 @@ -## Why - -Hosts on a small LAN (a Raspberry Pi running SSH, a PLC's web UI, another -tedge board) usually get their address from DHCP and are known by their -`.local` mDNS name, not a fixed IP. Today a remote-access -connect for `rpi5-d83add9f145a.local:22` fails on the device with -"cannot resolve": the bridge hands the name to `getaddrinfo()`, but the -tedge-zephyr builds have no mDNS resolver, so only IP literals and names in -unicast DNS work. The operator has to look up the target's current IP and -edit the Cumulocity remote-access endpoint whenever its lease changes. - -The same on-device `.local` lookup was already seen failing elsewhere in -this repo (the SNMP trap manager logs `not resolvable (-11)` with -`CONFIG_MDNS_RESOLVER=y`), so turning the resolver on is not enough by -itself: the cause of that failure has to be found and fixed too. - -Phase 3 (tedge-zephyr). Protocol: none; this is the Cumulocity remote-access -feature. Boards: every build that includes `CONFIG_TEDGE_REMOTE_ACCESS` — -the `full` profile on the ESP32-C6-DevKitC-1, ESP32-S3-DevKitC-1 and QT Py -ESP32-S3, and the `remote-access` extra on the ota builds (see -`release/devices.yml`). - -## What Changes - -- A remote-access target named `.local` is resolved over mDNS - (multicast query on the device's LAN) before the policy check, and the - session proceeds with the address it resolves to. Unicast DNS names and - IP literals behave as before. -- New Kconfig option `CONFIG_TEDGE_REMOTE_ACCESS_MDNS` (default `y`, - depends on `TEDGE_REMOTE_ACCESS`). The module sends its own one-shot mDNS - query: Zephyr 4.4.2's resolver cannot receive mDNS answers while the - application runs the mDNS responder (see design). Turning it off restores - today's footprint for a board that has no room. -- New Kconfig option `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS` - bounding how long each lookup waits for an answer (default 5000). -- A lookup that times out or hits a transient error is retried, up to - `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS` lookups in all (default 3, - `1` turns retrying off), with a short pause between them - (`CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS`, default 500). mDNS - runs over lossy Wi-Fi multicast, and a sleeping host or a dropped - packet should not fail the session on the first try. This applies to - unicast DNS names too. The worst case with the defaults is about 16 s. -- Resolution failures name the cause and the number of attempts in the - failed operation: "`` did not answer over mDNS after 3 attempts" - (timeout) vs "cannot resolve `` after 3 attempts" (any other - error), and a `.local` target on a build without the - option fails with "mDNS is not built in". -- The open and close events and the logs name both the host as requested and the address it resolved to - (`rpi5.local (192.168.68.72):22`), so the audit trail says which machine - was actually reached. -- Find and fix why an on-device `.local` lookup returns `DNS_EAI_SYSTEM` - (-11). Found: Zephyr adds no mDNS server with DHCP-supplied DNS, and with - the responder running never reads the answers; worked around in the - module. - -## Non-goals - -- DNS-SD service browsing (finding "the SSH server" without a name). The - operator still names the host. -- Caching resolved names across sessions. Each session resolves afresh, - so a host whose lease changed is found at its new address. -- IPv6 targets or AAAA answers; the bridge and the policy are IPv4-only and - stay so. -- LLMNR or NetBIOS name resolution. -- Changing the SNMP trap manager's resolution in `lib/snmp`. If the root - cause found here also fixes it, that is a follow-up for the app. -- Changing the target policies themselves. A name is still judged by the - address it resolves to and an allow-list entry still matches the host - string as requested; the only addition is that a `.local` answer must be - on the device's own subnets. - -## Capabilities - -### New Capabilities - -_None._ - -### Modified Capabilities - -- `tedge-remote-access`: the "targets are checked before any connection is - opened" requirement gains mDNS resolution of `.local` names, bounded - resolution time, distinct failure reasons, and a link-local check on any - `.local` answer (mDNS is unauthenticated); the audit requirement gains the - resolved address in the open and close events. - -## Impact - -- **Code**: `tedge-zephyr/src/tedge_remote_access.c` (resolution step, - reasons, event text); `tedge-zephyr/Kconfig` (four new options); - `tedge-zephyr/tests/kconfig/cases/` (new cases); unit tests for the - `.local` detection and reason text. -- **Public `tedge_*` API**: no change. `struct tedge_remote_target` already - carries the resolved `sockaddr` to the application's hook. -- **Dependencies**: none beyond `NET_UDP`; Zephyr's `MDNS_RESOLVER` is not - used. Nothing from `lib/` or `apps/`. -- **Resources** (measured 2026-10-07, modbus-server `full`, mDNS on vs off): - | Board | Flash | Static RAM | - |---|---|---| - | ESP32-C6-DevKitC-1 | +1,264 B content | 0 B | - | QT Py ESP32-S3 | about +1 KB code (the query builder and parser are 613 B); the image total moves by under 20 B because of segment alignment | 0 B | - - At run time a `.local` lookup holds one UDP socket and a 512 B buffer on - the session's stack, only while it runs; no board needed more sockets or - poll entries. The whole change against the release baseline (retries, - policy check, longer event text, mDNS), from the rebuilt baseline of the - nine `full` builds: C6 +2,401 B content and +176 B RAM; S3 DevKitC and - QT Py +207 to +225 B image and +168 B dram (their image size hides most - code growth in segment alignment). The RAM is mostly the 200-byte event - text. `release/size-baseline.json` is updated for those nine builds. -- **Docs**: `tedge-zephyr/README.md` remote-access section; `DEVICES.md` - if a board needs a tuned option. diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/specs/tedge-remote-access/spec.md b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/specs/tedge-remote-access/spec.md deleted file mode 100644 index 5c904c1..0000000 --- a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/specs/tedge-remote-access/spec.md +++ /dev/null @@ -1,135 +0,0 @@ -## ADDED Requirements - -### Requirement: A `.local` target is resolved over mDNS - -The client SHALL resolve a remote-access target whose host name ends in -`.local` by a multicast DNS query on the device's network when mDNS -resolution is built in, and the session SHALL proceed with the IPv4 address it resolves -to. Each lookup SHALL be bounded by the configured timeout. A lookup that -times out or fails with a transient error SHALL be retried after the -configured delay, up to the configured number of attempts in all, and -the session SHALL proceed as soon as any attempt resolves; so a host that -never answers fails the operation within a bounded time rather than -holding the seat. A -`.local` target on a build without mDNS resolution SHALL fail with a -reason saying mDNS is not built in, and SHALL NOT be sent to a unicast DNS -server. - -#### Scenario: SSH to a Raspberry Pi by its mDNS name - -- **WHEN** an operator opens a remote-access session whose endpoint is - `rpi5-d83add9f145a.local:22`, and that host answers mDNS on the device's - subnet -- **THEN** the device resolves the name, bridges the session to the - address it answered with, and the operator reaches its SSH server - -#### Scenario: The host has a new DHCP lease - -- **WHEN** a session to `rpi5-d83add9f145a.local:22` is opened after the - host's address changed since the previous session -- **THEN** the device connects to the new address, without any change to - the endpoint in Cumulocity - -#### Scenario: The first lookup goes unanswered - -- **WHEN** the first mDNS query for the requested `.local` host gets no - answer within the resolve timeout, and the second one does -- **THEN** the device bridges the session to the address from the second - answer, and the operation succeeds - -#### Scenario: Nobody answers - -- **WHEN** the requested `.local` host does not answer any of the - configured attempts -- **THEN** the operation fails with a reason saying the name did not - answer over mDNS and how many attempts were made, no TCP connection is - attempted, and the seat is freed - -#### Scenario: Retrying is turned off - -- **WHEN** the configured number of attempts is one and that lookup gets - no answer -- **THEN** the operation fails after that single lookup - -#### Scenario: mDNS is not built in - -- **WHEN** a build without mDNS resolution receives a connect for a - `.local` target -- **THEN** the operation fails with a reason saying mDNS is not built in - -#### Scenario: The device also advertises itself over mDNS - -- **WHEN** the application runs Zephyr's mDNS responder and an operator - opens a session to another host's `.local` name -- **THEN** the name resolves and the session is bridged, and the device - keeps answering for its own `.local` name - -## MODIFIED Requirements - -### Requirement: Targets are checked before any connection is opened - -The client SHALL check the requested target against the build-time policy -(the device's own IPv4 subnets, an allow-list, or the device itself) and then -against the application's hook, before opening any socket to it. A target the -policy or the hook refuses SHALL fail the operation with a reason naming the -policy, and SHALL NOT be contacted. A host name SHALL be resolved first and -judged by the address it resolves to; this applies equally to a name -resolved over unicast DNS and to a `.local` name resolved over mDNS. An -allow-list entry SHALL match the host as requested, so a `.local` name is -allowed by listing that name; because any host on the link can answer an -mDNS query, a `.local` name SHALL in every policy also resolve to an -address on the device's own IPv4 subnets or to the device itself. A name that cannot be resolved SHALL fail the -operation with a reason that names the host and says whether it timed out -or failed. - -#### Scenario: Target outside the subnet - -- **WHEN** a remote-access connect names an address outside the device's - subnets and the policy is the default -- **THEN** no TCP connection is attempted, and the operation fails with a - reason naming the policy - -#### Scenario: Application narrows the policy - -- **WHEN** the application's hook refuses a target that the built-in policy - allows -- **THEN** the operation fails with the reason, and no connection is made - -#### Scenario: An mDNS answer outside the subnet - -- **WHEN** a `.local` name resolves to an address outside the device's - subnets and the policy is the default -- **THEN** no TCP connection is attempted, and the operation fails with a - reason naming the policy and the address the name resolved to - -#### Scenario: A `.local` name on the allow-list - -- **WHEN** the policy is the allow-list, it lists `rpi5.local:22`, and a - connect names `rpi5.local:22` -- **THEN** the name is resolved over mDNS and the session is bridged - -#### Scenario: A listed `.local` name answers from off the subnet - -- **WHEN** the policy is the allow-list, it lists `rpi5.local:22`, and the - mDNS answer for `rpi5.local` is an address outside the device's subnets -- **THEN** no TCP connection is attempted, and the operation fails with a - reason naming the address - -### Requirement: Every session is auditable - -The client SHALL publish an event when a tunnel opens and when it ends. The -open event SHALL name the target host and port; the close event SHALL name -the target, why the session ended and how many bytes travelled each way. -When the target was given as a name, both events SHALL also name the -address it resolved to, so the record shows which machine was reached. - -#### Scenario: Session ends - -- **WHEN** a tunnel that carried traffic ends because the client disconnected -- **THEN** an event records the target, the reason and the byte counts - -#### Scenario: Session to an mDNS name - -- **WHEN** a tunnel to `rpi5.local:22` opens and later ends, and the name - resolved to 192.168.68.72 -- **THEN** both events name `rpi5.local` and 192.168.68.72 diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/tasks.md b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/tasks.md deleted file mode 100644 index c21d261..0000000 --- a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/tasks.md +++ /dev/null @@ -1,44 +0,0 @@ -## 1. Root-cause the on-device `.local` failure (firmware, S3 DevKitC) - -- [x] 1.1 Build `apps/modbus-server` (what the board runs) for the S3 DevKitC with the `full` profile plus `CONFIG_MDNS_RESOLVER=y` and `CONFIG_NET_SHELL=y`; flash it and keep a console logger running from boot -- [x] 1.2 From the shell, run `net dns` (server list) before and after the DHCP lease, then `net dns rpi5-d83add9f145a.local`; record the result and the boot log's `net_sock_svc` line -- [x] 1.3 Capture port 5353 on the LAN during the query (`tcpdump` on the Pi or the Mac) to see whether the query leaves the board and the answer comes back -- [x] 1.4 Isolate the cause: no mDNS server with DHCP DNS (`net dns`), static servers break unicast ordering, and with the responder running the resolver never reads the unicast answer (capture on the Pi) -- [x] 1.5 Write the finding and the fix into `tedge-zephyr/docs/zephyr-mdns-resolver-bugs.md`, and resolve the design's open question on the resolver API (callback API vs `getaddrinfo()`) - -## 2. Kconfig (firmware, tedge-zephyr module) - -- [x] 2.1 Add `CONFIG_TEDGE_REMOTE_ACCESS_MDNS` (default `y`, under `TEDGE_REMOTE_ACCESS`, selecting only `NET_UDP`) -- [x] 2.2 Add `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS` (default 5000, range 500–10000) with help text -- [x] 2.3 Add `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS` (default 3, range 1–5) and `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS` (default 500, range 0–5000), plus a `BUILD_ASSERT` that the worst-case total stays under 30 s -- [x] 2.4 Add kconfig test cases: mDNS on by default with remote access, absent without it, and turned off explicitly; run `tests/kconfig/check-kconfig.sh` - -## 3. Resolution and policy (firmware, `tedge_remote_access.c`) - -- [x] 3.1 Add a `resolve_target()` helper: IP literal → as today; `.local` without the option → fail "mDNS is not built in"; `.local` → the module's own one-shot query (`mdns_once()`, builder/parser in `tedge_ra_target.c`); others → `dns_get_addr_info()` with the bounded timeout; return the address or a reason distinguishing timeout from failure -- [x] 3.2 Wrap the lookup in the retry loop: retry on timeout and transient errors, not on an authoritative no-such-name; WRN-log each failed attempt; name the attempt count in the final reason -- [x] 3.3 Replace the inline `getaddrinfo()` block in `bridge()` with the helper, keeping resolution on the session thread -- [x] 3.4 In `policy_check()`, require a `.local` answer to be loopback, the device's own address or on its subnets under every policy, with a reason naming the address -- [x] 3.5 Store the resolved address in the session and include `host (addr)` in the open/close events and logs when the host was a name; keep IP-literal text unchanged and the twin session list as requested - -## 4. Tests (firmware, host-side unit tests) - -- [x] 4.1 Unit-test the `.local` suffix detection (case-insensitive, trailing dot, `local` alone, `x.localhost` not matched) -- [x] 4.2 Unit-test the link-local rule for `.local` answers under the LAN, allow-list and local policies (factor the decision into a pure function if needed) -- [x] 4.3 Unit-test the mDNS query builder and answer parser (compression, case, wrong id, malformed and looping input) -- [x] 4.4 Unit-test the retry decision (which errors are retried) and the reason text with the attempt count; factor the classification into a pure function -- [x] 4.5 Unit-test that the longest close-event text (63-char host, address, max counters) fits the event text (now 200 bytes) without cutting the counters -- [x] 4.6 Run the unit test suite and the kconfig checks - -## 5. Device verification (firmware, real boards) - -- [x] 5.1 S3 DevKitC `full`: open SSH to the Pi by `.local` name from Cumulocity; confirm the session, both events with name and address, and that the device's own `.local` name still answers -- [x] 5.2 Same board: a non-existent `.local` name fails after the configured attempts (about 16 s) with the mDNS reason and attempt count, logs one WRN per attempt, and frees the seat; an allow-listed name still works -- [x] 5.3 Same board: block the Pi's mDNS answers for the first query (e.g. `avahi-daemon` stopped, restarted within 5 s of the connect) and confirm the session succeeds on a later attempt; also open a session whose name answers only on the third attempt (~11 s) and confirm the Cumulocity web client is still waiting when the tunnel comes up -- [x] 5.4 C6 `full` (on rpi5 `/dev/ttyACM1`): SSH to the Pi by `.local` name, events with name and address; flash/RAM deltas recorded for C6, S3 and QT Py. No release ota build includes remote access, so there was no ota image to repeat it on -- [x] 5.5 Build with `CONFIG_TEDGE_REMOTE_ACCESS_MDNS=n` and confirm the "not built in" reason - -## 6. Docs - -- [x] 6.1 Update the remote-access section of `tedge-zephyr/README.md`: `.local` endpoints, the four options (including retries), the link-local rule, and any application requirement found in task 1 -- [x] 6.2 Add the measured per-board costs to the proposal's Resources note and to `DEVICES.md` if a board needed a tuned option diff --git a/openspec/changes/gateway-transport/design.md b/openspec/changes/gateway-transport/design.md deleted file mode 100644 index 27cc7ac..0000000 --- a/openspec/changes/gateway-transport/design.md +++ /dev/null @@ -1,138 +0,0 @@ -## Context - -The client was built with two transports in mind from the start: the direct -one talks SmartREST to Cumulocity, and everything that is not -Cumulocity-specific already goes out as thin-edge.io `te/` messages. The -seam exists (`struct tedge_transport`: connect, poll, disconnect, -publish_twin) and the direct transport lives behind it. This change puts a -second one there and finds out what the seam was missing. - -What the gateway does that the device then does not have to: TLS, the -device certificate and its renewal, the JWT, SNTP, HTTPS, and every -Cumulocity-specific encoding. What the device keeps: its identity, its -telemetry, its operations, and the hooks its application already uses. - -## Goals / Non-Goals - -**Goals:** a device on a plant network with no route to the internet gets -the same device management through its gateway; an application changes -nothing but a Kconfig line; an image without TLS fits a board that could not -hold one. - -**Non-Goals:** both transports at once, TLS to the gateway, changing the -features, inventing thin-edge.io equivalents for Cumulocity-only operations. - -## Decisions - -### D1: A second transport behind the interface that exists - -`tedge_gateway.c` implements `struct tedge_transport`. The core's state -machine, its heap, its settings subtree, the telemetry buffer, the -operation queue and every feature stay as they are. Anything the command -path needs that the interface cannot express is a change to the interface, -kept as small as the evidence demands — and the fact that the direct -transport must keep working unchanged is the test of whether it stayed -small. - -### D2: The device is a child device, registered by itself - -The device publishes its own registration and then uses the child-device -topic prefix for everything: - -``` -te/device/// (registration: name, type) -te/device////m/ measurements -te/device////e/ events -te/device////a/ alarms -te/device////twin/ twin data -te/device//service/tedge-zephyr/status/health -``` - -Which is what the client publishes today, with the prefix it already builds. -The gateway maps them to the cloud, as it does for its own children. - -### D3: Operations become commands, with the features untouched - -thin-edge.io drives an operation as a command with a request id, a payload -and a status that both sides move along: - -``` -te/device////cmd/restart/ {"status":"init"} -te/device////cmd/firmware_update/ {"status":"init","name":…,"version":…,"url":…} -te/device////cmd/log_upload/ {"status":"init","type":…,"tedgeUrl":…,…} -``` - -The device answers on the same topic by republishing with -`"status":"executing"`, then `"successful"` or `"failed"` with a reason. A -device also advertises which commands it supports by publishing an empty -retained message on the command's topic at startup — the thin-edge.io way of -saying "I can do this". - -The transport turns a command into the same internal request each feature -already takes, and turns the feature's result back into a status. The -features do not learn that a gateway exists. - -*Alternative: teach each feature both protocols.* Rejected: five features -would each grow a second protocol, and the seam exists precisely so they do -not have to. - -### D4: File transfer is plain HTTP to the gateway - -The gateway runs a file-transfer service on its own network -(`http://:8000/te/v1/files/…`). Firmware downloads and log uploads -use the HTTP client the client already has, without TLS and without a JWT, -which is most of what made those paths expensive. - -`CONFIG_TEDGE_HTTP` keeps its meaning; what changes is the scheme, the -credentials (none) and the host (the gateway). - -### D5: The gateway is discovered, not configured - -mDNS (`_thin-edge_mqtt._tcp`) finds the gateway on the local network, with -`CONFIG_TEDGE_GATEWAY_HOST` as the fallback for a network without mDNS. A -device that is moved to another gateway needs no reconfiguration, which is -the point of discovery on a plant network. - -### D6: Features that have no equivalent are not selectable - -Remote access and certificate renewal are Cumulocity-specific: the first has -no thin-edge.io child-device equivalent, and the second is the gateway's job -because the certificate is the gateway's. Both depend on -`TEDGE_TRANSPORT_C8Y` in Kconfig, so a gateway build cannot select them, and -the Kconfig help says why rather than leaving an integrator to discover it. - -### D7: No TLS in the image is the point - -A gateway build drops mbedTLS, the certificate, the enrollment and the JWT. -The footprint table gains the row that justifies the whole change: what a -WROOM can hold with a protocol server *and* device management. - -## Risks / Trade-offs - -- [The command protocol may not fit the features' internal requests] → the - first task is one command end to end (restart, the simplest) before the - rest is written. -- [An unauthenticated local broker] → stated as a non-goal with the reason: - the gateway owns the trust boundary. A plant network that needs more needs - the gateway's TLS, and that is a later change. -- [Two transports to keep working] → the direct transport's hardware - verification is re-run after this change, and the Kconfig cases cover both. -- [mDNS on a quiet network] → the configured fallback, and a log line saying - which of the two was used. - -## Migration Plan - -- Existing devices are direct-transport builds and are untouched. -- An application moves by changing the transport choice and dropping the - Cumulocity-specific configuration; its own code does not change. - -## Open Questions - -- Which thin-edge.io command names and payloads the gateway version in use - actually serves, and which of them the gateway maps to Cumulocity - operations today. -- Whether the device should also accept its identity from the gateway - (thin-edge.io can name a child device), or keep the application's identity - as the only source. -- Whether telemetry should keep using the client's own buffer or lean on the - gateway being one hop away and always reachable. diff --git a/openspec/changes/gateway-transport/proposal.md b/openspec/changes/gateway-transport/proposal.md deleted file mode 100644 index 1498ccf..0000000 --- a/openspec/changes/gateway-transport/proposal.md +++ /dev/null @@ -1,99 +0,0 @@ -## Why - -Everything the client does today it does by talking to Cumulocity itself: -its own TLS, its own certificate, its own clock, its own JWT, its own -SmartREST. That is the right answer for a device on a network with a route -to the internet, and the wrong one for the devices this repository started -with — an OPC-UA server on an ESP32-WROOM-32 deep in a plant network, next -to a thin-edge.io gateway that already has all of that. - -Connecting through the gateway instead makes the device dramatically -cheaper: **no TLS session, no certificate, no enrollment, no JWT, no SNTP, -no HTTPS** — the gateway owns every one of those. What is left is plain -MQTT to a broker on the local network, in thin-edge.io's own JSON, which is -the shape this client already publishes: its telemetry, twin and health -messages are `te/` messages today because of this step. - -It is also what makes the WROOM interesting again. Today it can only be a -remote-access enabler, because a TLS session plus Wi-Fi plus a protocol -server does not fit. Without TLS it can carry a protocol server *and* -device management. - -This is phase 3, roadmap step P8 — the last one — and the first change that -touches the transport seam (`struct tedge_transport`) rather than a feature. - -## What Changes - -- **`CONFIG_TEDGE_TRANSPORT_GATEWAY` becomes real**: a second transport - behind the interface the direct one already sits behind, chosen at build - time. -- **The device registers itself as a child device** of the gateway and - publishes its telemetry, twin data and health on the `te/` topics it - already uses — with the gateway's topic prefix rather than Cumulocity's. -- **Operations become thin-edge.io commands.** Where the direct transport - receives SmartREST and answers `504`/`505`/`506`, the gateway transport - receives a command on `te/device////cmd//` and - moves it through thin-edge.io's states (`init` → `executing` → - `successful` / `failed`). The features themselves do not change: restart, - firmware update, log upload and parameters keep their handlers. -- **File transfer goes to the gateway's file-transfer service** over plain - HTTP instead of HTTPS to Cumulocity, so firmware downloads and log uploads - keep working without TLS. -- **The gateway is found with mDNS** (`_thin-edge_mqtt._tcp`), with a - configured address as the fallback, so a device does not need to be told - where its gateway is. -- **What the gateway cannot do stays direct-only.** Remote access into the - device's LAN is the headline feature of this client and thin-edge.io has - no child-device equivalent; the Kconfig says so rather than pretending. - -## Non-goals - -- Running both transports at once. A device belongs to a gateway or to - Cumulocity, chosen at build time. -- Certificate-authenticated MQTT to the gateway. The local broker is on the - plant network; if that network is untrusted, the gateway's own TLS - configuration is the place to fix it, and that is a later change. -- Re-implementing the features. If a feature needs changing to work through - a gateway, that is a finding for this change, not a rewrite. -- Cumulocity-specific operations that have no thin-edge.io equivalent - (remote access, certificate renewal): they are unavailable on this - transport, and Kconfig enforces it. - -## Resource constraints - -| Item | Cost | -|---|---| -| Text | the second transport: the command topics, their state machine, and the plain-HTTP file transfer | -| RAM | **far less than the direct transport**: no TLS session (35–52 KB), no mbedTLS heap, no certificate, no JWT | -| Flash | mDNS discovery, minus everything TLS the image no longer needs | -| Network | local, plain MQTT; the gateway does the talking to the cloud | - -## Capabilities - -### New Capabilities - -- `tedge-gateway-transport`: how a device finds its gateway, registers as a - child device, and carries out commands through it. - -### Modified Capabilities - -- `device-management-features`: which features are available on which - transport, and what a build does when a feature has no equivalent. -- `tedge-client-module`: the application's integration is unchanged by the - transport — the same calls, the same hooks, a different cloud path. - -## Impact - -- `tedge-zephyr/src/`: a new `tedge_gateway.c` (session, registration, - commands) and `tedge_gateway_http.c` (plain-HTTP transfer), plus mDNS - discovery; `tedge_c8y.c` is untouched. -- The transport interface gains whatever the command path needs that the - SmartREST one did not — the first task is to find out what, and to keep it - small. -- Kconfig: the gateway transport stops depending on - `TEDGE_EXPERIMENTAL_FEATURES`; the features that cannot work through a - gateway depend on the direct transport. -- A WROOM profile that was impossible before: protocol server plus device - management, with no TLS in the image. -- The repository gains a gateway to test against (a thin-edge.io instance on - the Raspberry Pi that already hosts the test targets). diff --git a/openspec/changes/gateway-transport/specs/device-management-features/spec.md b/openspec/changes/gateway-transport/specs/device-management-features/spec.md deleted file mode 100644 index 4481fd4..0000000 --- a/openspec/changes/gateway-transport/specs/device-management-features/spec.md +++ /dev/null @@ -1,28 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Feature dependencies are enforced at configure time - -Kconfig SHALL express what each feature needs, so that an image that cannot -work is refused at configure time rather than at run time. Features that -transfer files (firmware update, log upload, parameters where they transfer -anything) SHALL bring in the HTTP client. Firmware update SHALL require a -bootloader that can swap images. A feature that exists only on one transport -SHALL depend on that transport, and its help SHALL say why. - -#### Scenario: Firmware update without a bootloader - -- **WHEN** an image selects firmware update without MCUboot -- **THEN** the configuration fails, naming the missing dependency - -#### Scenario: File transfer brings in what it needs - -- **WHEN** an image selects log upload -- **THEN** the HTTP client is part of the build without the application - asking for it - -#### Scenario: A feature that has no equivalent on the gateway - -- **WHEN** an image selects the gateway transport together with remote - access or certificate renewal -- **THEN** those features are not selectable, and the reason is stated where - an integrator will read it diff --git a/openspec/changes/gateway-transport/specs/tedge-gateway-transport/spec.md b/openspec/changes/gateway-transport/specs/tedge-gateway-transport/spec.md deleted file mode 100644 index e8dcc36..0000000 --- a/openspec/changes/gateway-transport/specs/tedge-gateway-transport/spec.md +++ /dev/null @@ -1,80 +0,0 @@ -## ADDED Requirements - -### Requirement: A device reaches the cloud through its gateway - -When the gateway transport is built in, the device SHALL connect to a -thin-edge.io gateway on its own network and SHALL NOT require a route to the -cloud, a cloud certificate, a cloud token or a synchronised clock of its -own. It SHALL find its gateway by discovery, falling back to a configured -address. - -#### Scenario: A device with no route to the internet - -- **WHEN** a device on a plant network with no internet route is started - next to a thin-edge.io gateway -- **THEN** it appears in the cloud, through the gateway, with its identity - and type - -#### Scenario: The gateway moves - -- **WHEN** the gateway's address changes and discovery is available -- **THEN** the device finds it again without being reconfigured - -#### Scenario: A network without discovery - -- **WHEN** discovery finds nothing and an address is configured -- **THEN** the device uses the configured address and says which it used - -### Requirement: Telemetry and state are unchanged by the transport - -Telemetry and state SHALL reach the cloud through the gateway with the same -meaning they have on the direct transport: the measurements, events, alarms, -twin data and health an application produces SHALL be unchanged by the -choice of transport, and the application SHALL NOT have to change anything. - -#### Scenario: The same application on either transport - -- **WHEN** an application is built once for the direct transport and once - for the gateway -- **THEN** the same measurements, events and alarms appear in the cloud - -### Requirement: Operations arrive as commands and are answered as commands - -The device SHALL advertise the commands it can carry out, SHALL execute a -command the gateway sends, and SHALL report its progress and outcome so the -gateway can complete the cloud's operation. A command the image cannot carry -out SHALL be reported as failed with a reason, never left unanswered. - -#### Scenario: A restart through the gateway - -- **WHEN** an operator restarts the device from the cloud -- **THEN** the device restarts and the operation is reported as successful - once it is back - -#### Scenario: A command the image does not have - -- **WHEN** the gateway sends a command for a feature that is not built in -- **THEN** the device reports it as failed, naming the missing feature - -### Requirement: Files transfer through the gateway - -Firmware downloads and log uploads SHALL use the gateway's file-transfer -service, without TLS and without cloud credentials. - -#### Scenario: A firmware update through the gateway - -- **WHEN** a firmware update is sent to a device on the gateway transport -- **THEN** the image is fetched from the gateway and installed as it is on - the direct transport - -### Requirement: A gateway build carries no cloud credentials - -An image built for the gateway transport SHALL NOT contain the device -certificate, its enrollment, the cloud token handling or a TLS session for -the cloud connection. - -#### Scenario: A board too small for TLS - -- **WHEN** a board that cannot hold a TLS session alongside its protocol - server is built for the gateway transport -- **THEN** the image fits and the device is managed through the gateway diff --git a/openspec/changes/gateway-transport/tasks.md b/openspec/changes/gateway-transport/tasks.md deleted file mode 100644 index b9c9e3b..0000000 --- a/openspec/changes/gateway-transport/tasks.md +++ /dev/null @@ -1,50 +0,0 @@ -## 1. Something to test against - -- [ ] 1.1 A thin-edge.io gateway on the Raspberry Pi, connected to the tenant, reachable from the device's network -- [ ] 1.2 Register one child device by hand and move one command through it, so the protocol is understood before device code is written -- [ ] 1.3 Record the command names, payloads and status transitions the gateway version in use actually serves (design.md open question) - -## 2. The transport - -- [ ] 2.1 `tedge_gateway.c`: connect to the local broker, register as a child device, and serve the session behind `struct tedge_transport` (D1, D2) -- [ ] 2.2 Whatever the interface is missing for commands, added as narrowly as the evidence demands, with the direct transport unchanged -- [ ] 2.3 Discovery: mDNS `_thin-edge_mqtt._tcp`, with `TEDGE_GATEWAY_HOST` as the fallback and a log line saying which was used (D5) -- [ ] 2.4 Kconfig: the gateway transport loses its experimental gate; remote access and certificate renewal depend on the direct transport (D6) - -## 3. Telemetry and state - -- [ ] 3.1 Measurements, events and alarms on the child-device topics (D2) -- [ ] 3.2 Twin data and the client's own health, republished on every connect as they are today -- [ ] 3.3 The telemetry buffer keeps working, with whatever the gateway's reachability changes about its sizing - -## 4. Commands - -- [ ] 4.1 Advertise the commands this image can carry out (D3) -- [ ] 4.2 Restart, end to end, as the first command -- [ ] 4.3 Firmware update, with the file fetched from the gateway (D3, D4) -- [ ] 4.4 Log upload, through the gateway's file-transfer service -- [ ] 4.5 Parameters, if `c8y-direct-parameters` has landed by then -- [ ] 4.6 A command for a feature that is not built in is failed with a reason - -## 5. Files - -- [ ] 5.1 `tedge_gateway_http.c`: plain HTTP to the gateway, no TLS, no token (D4) -- [ ] 5.2 The download and upload paths shared with the direct transport where they can be - -## 6. Tests - -- [ ] 6.1 Unit tests: the command topics and their status transitions -- [ ] 6.2 Kconfig cases: a gateway build has no TLS, no certificate and no remote access; a direct build is unchanged - -## 7. Hardware verification - -- [ ] 7.1 C6: registers, reports telemetry and is visible in the cloud through the gateway -- [ ] 7.2 Restart, firmware update and log upload through the gateway -- [ ] 7.3 The direct transport still passes its own verification (no regression) -- [ ] 7.4 An ESP32-WROOM-32 with a protocol server **and** device management, which is the point of the change -- [ ] 7.5 Footprint rows for a gateway build, next to the direct one - -## 8. Wrap-up - -- [ ] 8.1 README: choosing a transport, what the gateway owns, what is unavailable and why -- [ ] 8.2 A gateway profile; design.md results; SCOPE roadmap P8; archive diff --git a/openspec/config.yaml b/openspec/config.yaml index e89a7f3..c209b42 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -15,18 +15,19 @@ context: | 2. Additional industry protocols as source options (Modbus TCP, SNMP; done), plus deployability: BLE Wi-Fi provisioning and the MCUboot A/B layout. - 3. Started 2026-09-19: tedge-zephyr, a thin-edge.io device-management - client shipped as a reusable Zephyr module (`tedge-zephyr/`, API - `tedge_*`, Kconfig `CONFIG_TEDGE_*`) that ANY user's Zephyr - application can include. This repo's protocol apps are example user - applications. The module must never depend on `lib/` or `apps/`, and - must stay ready to move into its own repository. It connects directly + 3. Started 2026-09-19: a thin-edge.io device-management client shipped + as a reusable Zephyr module (API `tedge_*`, Kconfig `CONFIG_TEDGE_*`) + that ANY user's Zephyr application can include. Since 2026-11-03 it + lives in its own repository, thin-edge/tiny-edge (a west project + pinned in west.yml; its specs moved with it). This repo's apps are + example user applications of it; changes to the client itself are + proposed in tiny-edge. It connects directly to Cumulocity first (MQTT Service :9883, SmartREST 2.0; onboarding via the Cumulocity CA); remote access, including forwarding to other LAN hosts, is the headline feature. The thin-edge.io gateway transport comes later. Roadmap: SCOPE.md, "Phase 3 roadmap". Every feature is selected at build time so constrained boards include only - what fits; see the device-management-features and + what fits; see tiny-edge's device-management-features and tedge-client-module specs. Design priorities (in order): @@ -49,8 +50,9 @@ rules: keep thin-edge.io/Cumulocity out of scope. - For Phase 3 changes, list the Kconfig options the change adds, and their measured flash/RAM cost per target board. - - For Phase 3 changes, state any change to the public `tedge_*` API and - keep host-app glue out of `tedge-zephyr/`. + - For Phase 3 changes, keep host-app glue in `apps//src/`; a change + the client needs is a separate tiny-edge change, and west.yml pins + the tiny-edge release that carries it. tasks: - Break tasks into chunks of at most ~2 hours. - Separate firmware (device/source) work from client/collector work. diff --git a/openspec/specs/device-identify/spec.md b/openspec/specs/device-identify/spec.md index f6d4e3d..293e111 100644 --- a/openspec/specs/device-identify/spec.md +++ b/openspec/specs/device-identify/spec.md @@ -96,7 +96,7 @@ send one event, not one each. ### Requirement: Device events reach Cumulocity on the MQTT Service transport -The repository SHALL ship a Smart Function, `tedge-zephyr-events`, that maps +tiny-edge 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 diff --git a/openspec/specs/device-management-features/spec.md b/openspec/specs/device-management-features/spec.md deleted file mode 100644 index 6f17adb..0000000 --- a/openspec/specs/device-management-features/spec.md +++ /dev/null @@ -1,283 +0,0 @@ -# device-management-features Specification - -## Purpose -Which device-management features the tedge-zephyr client offers, how each one is selected at build time so a constrained board carries only what fits, the transport and authentication rules between them, and how state such as remote-access capacity is published. Measured costs and board profiles come from `c8y-direct-spikes`. -## Requirements -### Requirement: The device-management client is optional and off by default - -The device-management client SHALL be the `tedge-zephyr` Zephyr module, which an -application includes only when the umbrella option `CONFIG_TEDGE` enables it. -When that option is disabled, any application SHALL build exactly as it does -without the module: no extra code, RAM, threads, sockets or TLS sessions. - -#### Scenario: Application built without device management - -- **WHEN** an application is built with `CONFIG_TEDGE` disabled -- **THEN** its flash and static RAM match the same build without `tedge-zephyr` - within a few hundred bytes -- **AND** no device-management thread, socket or TLS session exists at runtime - -#### Scenario: Application built with device management - -- **WHEN** an application is built with `CONFIG_TEDGE` enabled -- **THEN** the selected transport, authentication method and features are - compiled in, and the application's own behaviour is unchanged - -### Requirement: Each feature is selected independently at build time - -Each device-management feature SHALL have its own Kconfig option, so that it can -be included in or excluded from an image independently of the others. The -features are telemetry, device-health telemetry, restart, firmware update, -shell command, log upload, remote access, parameters and certificate -renewal. The transport (direct to Cumulocity, or via a thin-edge.io -gateway) and the authentication method (Cumulocity CA certificate, or bootstrap -basic-auth credentials) SHALL each be a Kconfig choice. A feature that has not -been implemented yet SHALL be present in the menu but not selectable, and -the public API calls that belong to it SHALL return "not supported" rather -than failing silently. - -#### Scenario: Feature removed to save resources - -- **WHEN** an image is built with only remote access disabled -- **THEN** the remote-access code, its WebSocket client, its bridge thread and - its TLS session are absent from the image -- **AND** every other selected feature is still built and works - -#### Scenario: Minimal image - -- **WHEN** an image is built with only the connection, inventory and restart - enabled -- **THEN** it links and runs with a single MQTT/TLS session and no HTTP client - -#### Scenario: Settings a device does not have - -- **WHEN** an image is built without parameters -- **THEN** it advertises no parameter set, and the calls that declare one - return "not supported" - -### Requirement: Advertised capabilities match the build - -The device SHALL advertise to the cloud or gateway only the operations and -capabilities of features compiled into the running image. This covers supported -operations, log types and configuration types. When it receives an operation for -a feature that is not compiled in, the device SHALL mark that operation as -failed with a reason that names the missing feature. It SHALL NOT ignore the -operation or leave it pending, and this SHALL hold for every operation the -device does not handle, including ones it has never heard of. - -#### Scenario: Firmware update advertises itself and the running version - -- **WHEN** an image with the firmware-update feature connects -- **THEN** its supported operations include firmware update, and it reports - the name and version of the image that is running - -#### Scenario: Supported operations reflect the image - -- **WHEN** an image without the firmware-update feature connects -- **THEN** its supported-operations list does not include firmware update - -#### Scenario: Operation for an absent feature - -- **WHEN** the device receives an operation whose feature is not compiled in -- **THEN** it marks the operation as failed with a reason naming the feature - -#### Scenario: Log types reflect the image - -- **WHEN** an image with log upload connects -- **THEN** it advertises the log types it can actually produce, and an image - without the feature advertises none - -#### Scenario: An unrecognised operation - -- **WHEN** the device receives an operation it has no handler for at all -- **THEN** it marks the operation as failed with a reason, rather than - leaving it pending in the cloud - -### Requirement: Feature dependencies are enforced at configure time - -Dependencies between features and on the platform SHALL be expressed in Kconfig -(`depends on`/`select`), so that an invalid combination fails when the build is -configured, not at runtime. Firmware update SHALL require an MCUboot build. -Remote access and certificate renewal SHALL require certificate (CA) -authentication, because both need a token that only certificate devices -receive. Features that -transfer files (firmware update, log upload, configuration management) SHALL -bring in the HTTP client. Both authentication methods SHALL stay selectable. -The Cumulocity MQTT Service endpoint SHALL require certificate -authentication, and a basic-auth build SHALL use Core MQTT without further -configuration. - -#### Scenario: Firmware update without MCUboot - -- **WHEN** a build enables firmware update for a board built without sysbuild - or MCUboot -- **THEN** configuration fails, or the option cannot be selected, with a - message naming the missing MCUboot dependency - -#### Scenario: Basic authentication selects Core MQTT - -- **WHEN** a build selects bootstrap basic authentication and sets no endpoint -- **THEN** the build uses the Core MQTT endpoint, and the MQTT Service - endpoint cannot be selected - -#### Scenario: Remote access with basic authentication - -- **WHEN** a build selects bootstrap basic authentication and remote access -- **THEN** remote access cannot be selected - -#### Scenario: Certificate renewal with basic authentication - -- **WHEN** a build selects bootstrap basic authentication and certificate - renewal -- **THEN** certificate renewal cannot be selected, since such a device has - no certificate to renew - -### Requirement: Per-feature footprint is measured and documented - -The repository SHALL document, for each board and application, the flash, static -RAM and peak heap that each device-management feature adds. The same script -SHALL regenerate the table, as `scripts/measure_prov.sh` does for provisioning. -The table SHALL be updated whenever a feature is added or its cost changes -materially. - -#### Scenario: Footprint table regenerated - -- **WHEN** a developer runs the measurement script for a board and application -- **THEN** it prints the size of each feature's delta against a build without - that feature, using the same format as the documented table - -### Requirement: Boards ship with a feature profile that fits them - -Each supported board and application combination SHALL have a documented default -device-management profile, provided as a Kconfig overlay, that fits its measured -RAM and flash budget with headroom. A profile SHALL never select more concurrent -TLS sessions than the board's measured heap supports. The profiles SHALL follow -the measured results of `c8y-direct-spikes`: the ESP32-C6 uses the full -profile; the ESP32-S3-DevKitC-1 uses the full profile with the mbedTLS heap in -PSRAM; the ESP32-WROOM-32 uses the direct transport only as a remote-access -enabler with no protocol application, and otherwise the gateway transport. - -#### Scenario: Constrained board - -- **WHEN** the ESP32-WROOM-32 OPC-UA application is built with its default - profile -- **THEN** the profile excludes the direct transport's TLS session, and the - image boots and serves OPC-UA as before - -#### Scenario: Larger board - -- **WHEN** the ESP32-C6 application is built with its default profile -- **THEN** all implemented features are included, and the documented free heap - after connecting stays above the margin set in the footprint table - -#### Scenario: Board with PSRAM - -- **WHEN** the ESP32-S3-DevKitC-1 application is built with its default - profile -- **THEN** the mbedTLS heap is placed in PSRAM, and all implemented features - are included without overflowing internal RAM - -### Requirement: Remote access forwards to hosts on the local network under a target policy - -When the remote-access feature is built in, the device SHALL act as a -remote-access endpoint for Cumulocity. On a remote-access connect operation, it -SHALL open a TCP connection to the requested host and port and bridge it to -Cumulocity's device-side WebSocket until either side closes. The target MAY be -the device itself or another host on its network. Which targets are allowed -SHALL be set by a build-time policy: the device's own IPv4 subnets (default), -an explicit allow-list, or the device itself only. The host application SHALL -be able to narrow the policy further. The device SHALL refuse a target outside -the policy, and SHALL mark the operation as failed with a reason. Each tunnel -open and close SHALL be recorded as an event naming the target. The number of -concurrent sessions SHALL be capped by a Kconfig option. - -#### Scenario: SSH to a neighbouring host - -- **WHEN** a user opens a Cumulocity remote-access SSH session to the device - with an endpoint of `192.168.1.20:22`, and that host is on the device's subnet -- **THEN** the device bridges the session to that host, and the user reaches - the host's SSH server -- **AND** an event records the tunnel opening and closing with the target - -#### Scenario: Target outside the policy - -- **WHEN** a remote-access connect names a target outside the configured policy -- **THEN** the device opens no TCP connection, and marks the operation as failed - with a reason naming the policy - -#### Scenario: Session cap reached - -- **WHEN** a remote-access connect arrives while the maximum number of sessions - is open -- **THEN** the device marks the new operation as failed, and the open sessions - continue undisturbed - -### Requirement: State and telemetry use thin-edge.io topics on the MQTT Service - -The device SHALL publish telemetry, twin data, health and events on free-form -topics in thin-edge.io's `te/` topic and payload shape when it uses the direct -transport and the Cumulocity MQTT Service endpoint, so that a thin-edge.io gateway -and Cumulocity Smart Functions can map the same messages. Operations and their -status SHALL stay on SmartREST. Without free-form topics (Core MQTT, basic -authentication), the device SHALL send twin data as direct inventory updates -and telemetry as SmartREST measurements. The device SHALL republish its twin -data after every (re)connect, and SHALL NOT rely on retained messages. - -#### Scenario: Twin data on the MQTT Service - -- **WHEN** the device, connected to the MQTT Service, has twin data to report -- **THEN** it publishes it to `te/device////twin/`, and a - Smart Function can map it to the managed object - -#### Scenario: Twin data on Core MQTT - -- **WHEN** the device is connected to Core MQTT -- **THEN** it sends the same twin data as a direct inventory update of its - managed object - -### Requirement: Remote-access capacity and use are published as twin data - -When the remote-access feature is built in, the device SHALL publish -`tedge_RemoteAccess` twin data with the session limit, the number of active -sessions and the target policy. It SHALL publish it when it connects, with no -active sessions after a (re)connect, and whenever a session opens or closes. -The list of active sessions with their targets SHALL be a build-time option. - -#### Scenario: A session opens - -- **WHEN** a remote-access tunnel is established -- **THEN** the device publishes `tedge_RemoteAccess` with `activeSessions` - increased by one - -#### Scenario: The device reconnects - -- **WHEN** the device reconnects after a reboot or a lost connection -- **THEN** it publishes `tedge_RemoteAccess` with `activeSessions: 0`, - replacing any stale value - -### Requirement: An operation's status names the operation - -The device SHALL report an operation's progress and outcome for the -operation it was given, and SHALL NOT report a status that the cloud could -apply to a different operation. A result SHALL NOT be absorbed by an -operation left behind by an earlier boot, and a restart SHALL be completed -for the operation that caused it. - -#### Scenario: Two operations in the same period - -- **WHEN** two operations are created for a device and both are carried out -- **THEN** each one ends with its own result, whatever order they finish in - -#### Scenario: An operation left executing by a reset - -- **WHEN** a device resets while an operation is executing, and a later - operation is carried out afterwards -- **THEN** the later operation gets its own result, and the interrupted one - is not reported as that result - -#### Scenario: A restart across the reboot - -- **WHEN** a device restarts because the cloud asked it to -- **THEN** the restart operation that asked for it is the one reported as - successful once the device is back - diff --git a/openspec/specs/firmware-release/spec.md b/openspec/specs/firmware-release/spec.md index b39216f..0bf34bf 100644 --- a/openspec/specs/firmware-release/spec.md +++ b/openspec/specs/firmware-release/spec.md @@ -30,8 +30,8 @@ NOT be released. ### Requirement: Three variants per build Each build SHALL be one of three variants. A `standalone` build SHALL contain -a protocol app with the Improv Wi-Fi provisioner and no `tedge-zephyr` code. -A `tedge-full` build SHALL contain every `tedge-zephyr` feature +a protocol app with the Improv Wi-Fi provisioner and no `tiny-edge` code. +A `tedge-full` build SHALL contain every `tiny-edge` feature (`profiles/full.conf`). A `tedge-ota` build SHALL contain at least connection, inventory, health, telemetry, restart and firmware update (`profiles/ota.conf`), plus the extra features its manifest entry lists. @@ -76,7 +76,7 @@ Cumulocity tenant, or any other site-specific setting. ### Requirement: A device-management-only application is released The project SHALL release `apps/tedge-agent` — `lib/common/` plus -`tedge-zephyr`, no protocol frontend — as `tedge-full` and `tedge-ota` on +`tiny-edge`, no protocol frontend — as `tedge-full` and `tedge-ota` on every board where they pass capacity measurement. #### Scenario: Remote access without a protocol app @@ -145,7 +145,7 @@ version numbers, and only sets `EXTRAVERSION` in its own checkout. `
`
 SHALL be lowercase letters, digits and dots, which Zephyr accepts as
 `EXTRAVERSION`. Every image SHALL carry that version in its MCUboot header
 and its `APP_VERSION_STRING`, with `-
` appended to the latter for a
-pre-release tag and `-dev` for a non-tag build. `tedge-zephyr/VERSION` SHALL
+pre-release tag and `-dev` for a non-tag build. `tiny-edge/VERSION` SHALL
 be versioned independently, and the release notes SHALL state it for every
 `tedge` image.
 
diff --git a/openspec/specs/tedge-c8y-connection/spec.md b/openspec/specs/tedge-c8y-connection/spec.md
deleted file mode 100644
index 0ee78d3..0000000
--- a/openspec/specs/tedge-c8y-connection/spec.md
+++ /dev/null
@@ -1,89 +0,0 @@
-# tedge-c8y-connection Specification
-
-## Purpose
-The client's single MQTT session to Cumulocity: which endpoint matches which authentication, how a session starts so no operation is missed, how it recovers from network loss without leaking, and the token it keeps for HTTPS and WebSocket calls.
-
-## Requirements
-### Requirement: One MQTT session to Cumulocity, on the endpoint that matches the authentication
-
-The client SHALL hold one MQTTS session to Cumulocity: the MQTT Service (port
-9883) with certificate authentication, or Core MQTT (port 8883) with basic
-authentication or when the Core MQTT endpoint is selected. It SHALL validate
-the server certificate against the configured trust anchors, and SHALL only
-connect once the realtime clock is valid (set by the application or by the
-client's SNTP query). It SHALL subscribe with one topic filter per SUBSCRIBE
-packet, and SHALL NOT subscribe to wildcard free-form topics.
-
-#### Scenario: Certificate device connects
-
-- **WHEN** an enrolled device has an IPv4 address and a valid clock
-- **THEN** the client connects to port 9883 with mutual TLS and reaches the
-  connected state
-
-#### Scenario: Clock not yet set
-
-- **WHEN** the network is up but the realtime clock predates the build
-- **THEN** the client sets it with SNTP before opening the TLS session
-
-### Requirement: The session starts by creating the device
-
-After each CONNACK, the client SHALL first publish the device-creation message,
-then subscribe to the operation topics, then publish the supported operations,
-the required interval and its twin data. It SHALL treat a refused subscription
-(return code 0x80) as temporary and retry it, so a newly created device does not
-miss operations in its first session.
-
-#### Scenario: First session of a bootstrap-registered device
-
-- **WHEN** a device connects as a device user for the first time and the
-  broker refuses its subscriptions because the device doesn't exist yet
-- **THEN** the client retries the subscriptions after the device-creation
-  message has been processed, and operations sent in that session arrive
-
-### Requirement: The client recovers from connection loss by itself
-
-The client SHALL reconnect after any loss of the session (network down, broker
-close, keepalive timeout) with an exponential back-off that starts at 3 seconds,
-is capped by a Kconfig maximum, includes jitter, and resets after a stable
-period. It SHALL stop connecting while the application's network is down and
-resume when an address is available again. After each disconnect, the TLS heap
-in use and the TCP contexts held by the client SHALL return to their
-pre-connect values.
-
-#### Scenario: Wi-Fi drops for a minute
-
-- **WHEN** the access point disappears for 60 seconds and returns
-- **THEN** the client reports waiting for network, reconnects once the
-  application's network is back, and its TLS heap and TCP context usage match
-  the values before the drop
-
-#### Scenario: Broker closes the session
-
-- **WHEN** the broker closes the session repeatedly
-- **THEN** the client waits increasing intervals between attempts, never less
-  than 3 seconds
-
-### Requirement: A duplicate client ID is reported
-
-The client SHALL log a warning naming a possible duplicate client ID when the
-broker closes three consecutive sessions within 10 seconds of their CONNACK.
-
-#### Scenario: Two devices share an external ID
-
-- **WHEN** another client connects with the same client ID and the broker keeps
-  closing this device's session shortly after it connects
-- **THEN** the log says that another client may be using this ID
-
-### Requirement: The client keeps a current token for HTTPS and WebSocket calls
-
-A client with certificate authentication SHALL request a JWT after every
-connect and before the current one expires, and keep the latest one for the
-module's HTTPS and WebSocket features. A client with basic authentication
-SHALL NOT request one.
-
-#### Scenario: Token after connect
-
-- **WHEN** a certificate device connects
-- **THEN** it has a token within seconds, and a fresh one before the previous
-  token's one-hour lifetime ends
-
diff --git a/openspec/specs/tedge-c8y-onboarding/spec.md b/openspec/specs/tedge-c8y-onboarding/spec.md
deleted file mode 100644
index 9e14436..0000000
--- a/openspec/specs/tedge-c8y-onboarding/spec.md
+++ /dev/null
@@ -1,167 +0,0 @@
-# tedge-c8y-onboarding Specification
-
-## Purpose
-How a device gets the identity and credentials it needs for Cumulocity: enrolling itself with the Cumulocity CA (device-generated key, one-time password and registration URL), the bootstrap-user fallback, and where that state is stored.
-## Requirements
-### Requirement: The device enrolls itself with the Cumulocity CA
-
-When built with Cumulocity CA authentication, the client SHALL obtain its
-device certificate itself. It SHALL generate a persistent P-256 key in PSA
-secure storage, obtain a one-time password — using the password supplied
-through `tedge_set_enroll_otp()` when there is one, otherwise generating one
-with the PSA random generator — build a PKCS#10 request whose common name is
-the external ID and sign it inside PSA. It SHALL then poll the tenant's EST
-`simpleenroll` endpoint with HTTP Basic `:`
-until a certificate is issued. It SHALL store the certificate, delete the
-one-time password, and use the key and certificate for mutual TLS. An enrolled
-device SHALL NOT enroll again after a reboot or an application update.
-
-#### Scenario: First boot of an unregistered device
-
-- **WHEN** a device with no stored certificate starts and the operator has not
-  registered it yet
-- **THEN** the client enters the awaiting-registration state, polls
-  `simpleenroll` at the configured interval with back-off, and does not open
-  an MQTT session
-
-#### Scenario: Operator registers the device
-
-- **WHEN** the operator registers the external ID with the one-time password
-  while the device is polling
-- **THEN** the next poll returns a certificate, the client stores it, and
-  connects with mutual TLS without a reboot
-
-#### Scenario: Enrolled device restarts
-
-- **WHEN** an enrolled device reboots, or its application image is updated
-- **THEN** it connects with the stored key and certificate and does not create
-  a new one-time password
-
-#### Scenario: Device pre-registered by a provisioning server
-
-- **WHEN** a device is started with a tenant host and a one-time password that
-  a provisioning server already registered with Cumulocity
-- **THEN** the first `simpleenroll` attempt returns a certificate, and the
-  device connects without an operator touching the Cumulocity UI
-
-### Requirement: The registration URL is handed to the application
-
-The client SHALL provide the registration URL (tenant host, external ID and
-one-time password pre-filled) through its public API while the device is
-awaiting registration. It SHALL report the awaiting-registration state through
-the state hook, so the application can show the URL. The client SHALL NOT log
-the one-time password at log levels above debug.
-
-#### Scenario: Application shows the URL
-
-- **WHEN** the state hook reports awaiting registration and the application
-  asks for the registration URL
-- **THEN** it receives the URL, and the API returns "already enrolled" once the
-  certificate is stored
-
-### Requirement: Bootstrap credentials as a fallback
-
-When built with bootstrap authentication, the client SHALL connect to Core MQTT
-with the tenant's bootstrap credentials, request device credentials, and store
-the tenant, user name and password it receives. It SHALL then connect as the
-device with basic authentication. Stored credentials SHALL be reused after a
-reboot. The client SHALL NOT log the bootstrap or device password.
-
-#### Scenario: Device request accepted
-
-- **WHEN** the operator accepts the device's registration request
-- **THEN** the client receives and stores the device credentials, disconnects
-  the bootstrap session, and connects as the device
-
-### Requirement: Credentials and settings stay in the module's namespace
-
-Onboarding state SHALL live under the `tedge/` settings subtree, the key at a
-configurable PSA key ID, and the TLS credentials at tags from the module's
-configurable tag base. The tenant host SHALL default to a Kconfig value and be
-overridable at runtime through the public API, persisted in settings. The
-one-time password SHALL likewise be settable at runtime through the public API
-and persisted in the same subtree.
-
-No component outside the module SHALL write to the `tedge/` subtree; an
-application that obtains onboarding data elsewhere SHALL pass it in through the
-public API rather than writing the module's keys directly.
-
-#### Scenario: Tenant host overridden at runtime
-
-- **WHEN** an application sets a tenant host through the API before the
-  device is enrolled
-- **THEN** enrollment and the MQTT session use that host, and it is still used
-  after a reboot
-
-#### Scenario: Provisioned data reaches the module through the API
-
-- **WHEN** a provisioning image has stored a tenant host and one-time password
-  outside the `tedge/` subtree, and the application reads them at boot
-- **THEN** the application passes them through `tedge_set_c8y_url()` and
-  `tedge_set_enroll_otp()`, and the module's own keys were not written by
-  anything but the module
-
-### Requirement: The one-time password can be supplied by the application
-
-The client SHALL expose `tedge_set_enroll_otp()`, which stores a one-time
-password issued outside the device, of any length from 1 to 64 printable ASCII
-characters — for example by a zero-touch provisioning
-server that has already registered the device with Cumulocity. It SHALL be
-called before `tedge_start()`, SHALL persist the value in settings, and the
-stored value SHALL take precedence over generating one. The client SHALL NOT
-log the supplied password above debug level, and SHALL delete it once a
-certificate has been issued, exactly as it does for a password it generated
-itself.
-
-The client SHALL remain unaware of how the application obtained the password;
-it SHALL NOT depend on any provisioning transport.
-
-#### Scenario: Externally issued password is used
-
-- **WHEN** the application calls `tedge_set_enroll_otp()` with a password the
-  operator already registered in Cumulocity, and then starts the client
-- **THEN** the device enrolls with that password on its first `simpleenroll`
-  attempt and never generates one of its own
-
-#### Scenario: Supplied password is cleared after enrollment
-
-- **WHEN** a device that was given a password receives its certificate
-- **THEN** the stored password is deleted, and a reboot does not re-enroll
-
-#### Scenario: Supplied password of a different length
-
-- **WHEN** the supplied password is not the 32 characters the device would
-  generate itself
-- **THEN** the client still uses it, rather than discarding it and generating
-  its own
-
-#### Scenario: Credential never truncated
-
-- **WHEN** the external ID and password together are too long for the
-  enrollment request's credential
-- **THEN** enrollment fails with an error, rather than sending a shortened
-  credential that cannot authenticate
-
-#### Scenario: A supplied password is never revealed
-
-- **WHEN** the application asks for the registration URL while the device is
-  awaiting registration with a supplied password — before or after a reboot
-- **THEN** `tedge_registration_url()` returns `-EACCES` and yields no URL,
-  since the password may have reached the device sealed end to end and whoever
-  issued it has already registered the device
-
-#### Scenario: Re-onboarding an enrolled device
-
-- **WHEN** a device that already holds a certificate — for example from an
-  earlier enrollment with another tenant — is given a new password through
-  `tedge_set_enroll_otp()`
-- **THEN** the old certificate is discarded (the key pair is kept) and the
-  device enrolls with the new password, instead of presenting the old
-  certificate to a tenant that refuses it
-
-#### Scenario: No password supplied
-
-- **WHEN** the application does not call `tedge_set_enroll_otp()`
-- **THEN** the client generates its own password and the registration URL flow
-  is unchanged
-
diff --git a/openspec/specs/tedge-cert-renewal/spec.md b/openspec/specs/tedge-cert-renewal/spec.md
deleted file mode 100644
index 2b92a68..0000000
--- a/openspec/specs/tedge-cert-renewal/spec.md
+++ /dev/null
@@ -1,86 +0,0 @@
-# tedge-cert-renewal Specification
-
-## Purpose
-How the device keeps its identity valid over years: when it renews its certificate, how it authenticates that request, what happens when a renewal fails, and how an operator sees a fleet's certificate health before an expiry becomes an outage.
-
-## Requirements
-### Requirement: The device renews its certificate before it expires
-
-When the certificate-renewal feature is built in, the client SHALL read the
-expiry of its own certificate and renew it through the cloud's
-re-enrollment endpoint once the remaining lifetime falls below a configured
-margin. It SHALL authenticate the request with the token it holds for the
-tenant, because the endpoint refuses a client certificate alone. It SHALL
-keep the same key.
-
-#### Scenario: The margin is reached
-
-- **WHEN** a connected device's certificate has less remaining lifetime than
-  the configured margin
-- **THEN** the client requests a new certificate, stores it, and uses it for
-  its next connection
-
-#### Scenario: Well before the margin
-
-- **WHEN** the certificate has more remaining lifetime than the margin
-- **THEN** the client makes no renewal request
-
-#### Scenario: No token yet
-
-- **WHEN** the device has not obtained a token for the tenant
-- **THEN** the client waits rather than sending a request that would be
-  refused
-
-### Requirement: A failed renewal never costs the working certificate
-
-A renewal that fails at any step SHALL leave the existing certificate and
-key in place and in use, and SHALL be retried. The client SHALL remain
-connected throughout.
-
-#### Scenario: The endpoint refuses the request
-
-- **WHEN** the re-enrollment request fails or returns an error
-- **THEN** the device keeps using its current certificate, stays connected,
-  and tries again later
-
-#### Scenario: The reply cannot be read
-
-- **WHEN** the reply arrives but no certificate can be taken from it
-- **THEN** nothing is stored, and the current certificate stays in use
-
-### Requirement: An expiring certificate is visible and raises an alarm
-
-The client SHALL publish the certificate's expiry and the days remaining as
-twin data on every connect and after every renewal. When the remaining
-lifetime falls below a second, shorter threshold and the certificate has
-still not been renewed, the client SHALL raise a critical alarm naming the
-expiry, and SHALL clear it once a renewal succeeds.
-
-#### Scenario: An operator reviews a fleet
-
-- **WHEN** devices are connected
-- **THEN** each one's managed object carries the expiry date and the days
-  remaining
-
-#### Scenario: Renewal keeps failing
-
-- **WHEN** renewal has not succeeded and the remaining lifetime falls below
-  the alarm threshold
-- **THEN** the device raises a critical alarm naming the expiry date
-
-#### Scenario: Renewal succeeds after an alarm
-
-- **WHEN** a renewal succeeds while the alarm is raised
-- **THEN** the client clears the alarm and publishes the new expiry
-
-### Requirement: Renewals are spread out across a fleet
-
-The periodic check SHALL be spread within its interval, so that devices
-deployed and enrolled together do not all renew at the same moment.
-
-#### Scenario: A fleet enrolled on the same day
-
-- **WHEN** many devices reach the renewal margin on the same day
-- **THEN** their renewal requests are spread across the checking interval
-  rather than arriving together
-
diff --git a/openspec/specs/tedge-client-module/spec.md b/openspec/specs/tedge-client-module/spec.md
deleted file mode 100644
index 2524050..0000000
--- a/openspec/specs/tedge-client-module/spec.md
+++ /dev/null
@@ -1,177 +0,0 @@
-# tedge-client-module Specification
-
-## Purpose
-How the tedge-zephyr client is packaged and integrated: a self-contained Zephyr module with a namespaced API that any Zephyr application can include, where the host application owns connectivity and the module owns bounded resources, and which can move into its own repository unchanged.
-## Requirements
-### Requirement: The client is a self-contained Zephyr module
-
-The client SHALL be a Zephyr module named `tedge` in the repository's top-level
-`tedge-zephyr/` directory. That directory SHALL contain everything the module
-needs (`zephyr/module.yml`, `Kconfig`, `CMakeLists.txt`, `include/tedge/`,
-sources, `samples/`, `tests/` and `README.md`), so that it can move into its own
-repository and west project without changing its contents. The module SHALL
-depend only on Zephyr and its modules (and on MCUboot for firmware update). It
-SHALL NOT include, link or reference anything under this repository's `lib/` or
-`apps/`.
-
-#### Scenario: Built without this repository's code
-
-- **WHEN** `tedge-zephyr/samples/minimal` is built on its own for a supported
-  board, with the module as the only extra Zephyr module
-- **THEN** the build succeeds without `lib/common` or any other `lib/` module
-
-#### Scenario: Dependency leak is caught
-
-- **WHEN** a source or CMake file under `tedge-zephyr/` references a path or
-  header from `lib/` or `apps/`
-- **THEN** the repository's check (the minimal-sample build, or a grep in CI)
-  fails and names the offending file
-
-### Requirement: Namespaced public API and configuration
-
-All public symbols of the module SHALL use the `tedge_` prefix (types, functions
-and macros), and its headers SHALL live under `include/tedge/`. Its Kconfig
-symbols SHALL use the `TEDGE_` prefix. Its persistent settings SHALL live under
-a single `tedge/` settings subtree. Its TLS credentials SHALL use a configurable
-tag range. None of these SHALL collide with the host application's names.
-
-#### Scenario: Coexisting with an application's own TLS and settings
-
-- **WHEN** an application that already uses its own settings keys and TLS
-  credential tags includes the module
-- **THEN** neither the application's nor the module's settings or credentials
-  are overwritten
-
-### Requirement: The application owns connectivity
-
-The module SHALL NOT bring up, configure or reconnect the network interface.
-It SHALL start connecting when Zephyr reports that an interface has an IPv4
-address (network-management events). It SHALL stop and back off when the
-interface goes down. The application's connectivity code (in this repository,
-`lib/common/net.c`) stays responsible for Wi-Fi.
-
-#### Scenario: Network appears after start
-
-- **WHEN** the application starts the client before Wi-Fi is connected
-- **THEN** the client waits without busy-looping, and it connects to the cloud
-  once the interface has an address
-
-### Requirement: Bounded resource ownership
-
-The module SHALL run its work on its own thread(s), each with a stack size and
-priority set in Kconfig. It SHALL NOT block the system work queue. It SHALL
-allocate its dynamic memory from its own bounded heap, sized in Kconfig, and not
-from the application's heap. TLS memory is the exception: it comes from
-Zephyr's mbedTLS heap, which is global and part of the application's
-configuration, so the module's documentation SHALL state the mbedTLS heap each
-profile needs (per concurrent TLS session and TLS record size). The module's
-documentation SHALL also list what it requires from the application's
-configuration: socket, TCP-connection and poll-slot counts, mbedTLS options,
-and task-watchdog channels.
-
-#### Scenario: Client memory is capped
-
-- **WHEN** the client's operations need more memory than its configured heap
-- **THEN** the affected operation fails with a reason, and the application's
-  own allocations are unaffected
-
-#### Scenario: TLS heap too small
-
-- **WHEN** the application's mbedTLS heap is smaller than the documented need
-  and a handshake runs out of memory
-- **THEN** the client logs that the TLS handshake failed for lack of mbedTLS
-  heap, backs off, and the application keeps running
-
-### Requirement: Integration hooks for the host application
-
-The public API SHALL let the host application:
-
-- provide the device identity: external ID, name, type, and firmware name and
-  version (with a documented default for each);
-- push telemetry: measurements, events and alarms;
-- register its own operation handlers and log types;
-- declare a set of typed parameters the cloud may change, and be told when
-  a change is accepted;
-- prepare for or veto a restart requested by the cloud;
-- add its own health checks that must pass before a newly updated firmware image
-  is confirmed;
-- observe the client's state (for example to drive a status LED);
-- optionally report liveness progress to the application's watchdog.
-
-Every one of these calls SHALL be present whether or not the feature behind
-it is built in: a call belonging to a feature that is absent SHALL return
-"not supported", so that an application compiles against the API once and
-chooses its features in Kconfig.
-
-The module SHALL NOT call `sys_reboot()` without first giving the application's
-restart hook the chance to respond.
-
-#### Scenario: Application narrows remote-access targets
-
-- **WHEN** the application registers a remote-access target hook and the
-  cloud asks for a tunnel to a target the built-in policy allows but the hook
-  refuses
-- **THEN** the client opens no connection and fails the operation with the
-  hook's reason
-
-#### Scenario: Application vetoes a new firmware image
-
-- **WHEN** a newly installed image is running and connected, and the
-  application's firmware-check hook reports that the application is not
-  healthy
-- **THEN** the client does not confirm the image, so the bootloader reverts
-  it on the next reset
-
-#### Scenario: Application-supplied telemetry
-
-- **WHEN** the application calls the telemetry API with a measurement
-- **THEN** the client publishes it on the configured transport, and no telemetry
-  is read from anything outside the calls the application makes
-
-#### Scenario: Telemetry from the application's own threads
-
-- **WHEN** the application publishes telemetry from a thread of its own
-  while the client is busy with an operation
-- **THEN** the call returns without waiting for the network, and the client
-  sends the message on its own thread
-
-#### Scenario: Application vetoes a restart
-
-- **WHEN** a restart operation arrives and the application's restart hook
-  declines it
-- **THEN** the client does not reboot and marks the operation as failed with the
-  reason returned by the hook
-
-#### Scenario: Application adds a log type
-
-- **WHEN** the application registers a log type and the cloud asks for it
-- **THEN** the client asks the application to produce it and makes the result
-  retrievable from the cloud
-
-#### Scenario: Registering against a feature that is not built in
-
-- **WHEN** an application that registers a log type is built without log
-  upload
-- **THEN** it still compiles and links, and the registration returns "not
-  supported"
-
-#### Scenario: Application is told about a parameter change
-
-- **WHEN** the cloud changes a declared parameter and the change is valid
-- **THEN** the application's hook is called once with the new values, and
-  may still refuse them
-
-### Requirement: Example integrations
-
-The repository SHALL show how an application integrates the module, through
-`tedge-zephyr/samples/minimal` and at least one of this repository's protocol
-applications. Integrations SHALL use only the module's public API. In a protocol
-application, the glue that maps `lib/common`'s data model and connectivity
-state onto that API SHALL live in the application, not in the module.
-
-#### Scenario: Protocol application as a user application
-
-- **WHEN** a protocol application is built with `CONFIG_TEDGE` enabled
-- **THEN** it serves its industrial protocol as before and also runs the
-  client, and the code connecting the two lives under `apps/`
-
diff --git a/openspec/specs/tedge-device-state/spec.md b/openspec/specs/tedge-device-state/spec.md
deleted file mode 100644
index 978f5c5..0000000
--- a/openspec/specs/tedge-device-state/spec.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# tedge-device-state Specification
-
-## Purpose
-What the device tells the cloud about itself: inventory and supported operations, restart through the application's hooks, and state published as twin and health messages that a gateway or a Smart Function can map.
-
-## Requirements
-### Requirement: Inventory and supported operations on every connect
-
-On every connect the client SHALL publish the device's name and type, its
-required interval, and a supported-operations list assembled from the features
-compiled into the image and the operations the application registered. An
-operation received for anything not in that list SHALL be marked failed with a
-reason.
-
-#### Scenario: Restart built in
-
-- **WHEN** an image with the restart feature connects
-- **THEN** its supported operations include `c8y_Restart`
-
-#### Scenario: Application-registered operation
-
-- **WHEN** the application registers an operation before the client starts
-- **THEN** it is advertised as supported, and its handler receives the
-  operation with the operation already marked as executing
-
-### Requirement: Restart runs through the application and a full-system reset
-
-On a restart operation the client SHALL call the application's restart hook,
-if any. When the hook vetoes it, the client SHALL mark the operation failed with
-the hook's reason. Otherwise it SHALL mark the operation as executing, persist
-a marker, disconnect cleanly and reset the device, using the application's
-reset hook when provided and otherwise a full-system reset of the platform.
-After the reboot it SHALL mark the operation successful once connected, and
-clear the marker.
-
-#### Scenario: Restart from Cumulocity
-
-- **WHEN** an operator restarts the device from Cumulocity
-- **THEN** the device reboots, reconnects and the operation ends SUCCESSFUL
-
-#### Scenario: ESP32-C6 restart does not hang
-
-- **WHEN** the client resets an ESP32-C6 built with MCUboot and Wi-Fi running,
-  without an application reset hook
-- **THEN** it uses a full-system reset, and MCUboot boots the application
-
-### Requirement: State is published as twin data and health on thin-edge.io topics
-
-The client SHALL publish its own twin fragment (`tedge_Agent`: module name,
-version and transport) and its health (`up`) on every connect, on
-`te/device////twin/tedge_Agent` and
-`te/device//service/tedge-zephyr/status/health` when connected to the MQTT
-Service. On Core MQTT it SHALL send the twin fragment as a direct inventory
-update. The application SHALL be able to publish its own twin fragments through
-the API, which the client republishes after every reconnect.
-
-#### Scenario: Application twin fragment survives a reconnect
-
-- **WHEN** the application publishes a twin fragment and the connection is
-  lost and re-established
-- **THEN** the client publishes the fragment again after the reconnect
-
-### Requirement: The twin topics and payloads are documented for cloud-side mapping
-
-The module SHALL document the topics it publishes state on and the shape of
-each payload, so that a tenant can map them with its own Cumulocity Smart
-Functions (or a thin-edge.io gateway can consume them unchanged). The
-documentation SHALL name the fragment each payload belongs to and give a
-worked example.
-
-#### Scenario: A tenant maps the twin data
-
-- **WHEN** an integrator reads the module's documentation
-- **THEN** it states the topic (`te/device////twin/`), the
-  payload of each built-in fragment, and what a function receives
-
diff --git a/openspec/specs/tedge-diagnostics/spec.md b/openspec/specs/tedge-diagnostics/spec.md
deleted file mode 100644
index 19b5a7d..0000000
--- a/openspec/specs/tedge-diagnostics/spec.md
+++ /dev/null
@@ -1,134 +0,0 @@
-# tedge-diagnostics Specification
-
-## Purpose
-TBD - created by archiving change c8y-direct-diagnostics. Update Purpose after archive.
-## Requirements
-### Requirement: A device can be asked for its logs
-
-When log upload is built in, the client SHALL advertise the log types the
-running image offers, SHALL produce the requested type on request, and SHALL
-make it available to the cloud as a retrievable file. The client SHALL apply
-the filters a request carries as far as the type allows, and SHALL bound
-what one request can produce.
-
-#### Scenario: Fetching the log of a device in the field
-
-- **WHEN** an operator asks a connected device for its log
-- **THEN** the log is retrievable from the cloud without anyone visiting the
-  device
-
-#### Scenario: A filtered request
-
-- **WHEN** a log is requested with a search text and a line limit
-- **THEN** the returned log contains only matching lines, up to that limit
-
-#### Scenario: More log than the device may send
-
-- **WHEN** the requested log is longer than the configured maximum
-- **THEN** the device sends what fits and says in the file that it was
-  truncated, rather than refusing the request
-
-### Requirement: The client keeps a log of its own
-
-When log upload is built in, the client SHALL keep its own recent log in a
-bounded amount of RAM, oldest lines dropped first, and SHALL offer it as a
-log type without the application registering anything.
-
-#### Scenario: A device whose application has no log
-
-- **WHEN** an application that never registered a log type is asked for a
-  log
-- **THEN** the client's own recent log is returned
-
-### Requirement: A crash outlives the device's memory
-
-When crash dumps are built in, the client SHALL store a dump when the device
-faults, SHALL offer the stored dump as a log type after the device restarts,
-and SHALL keep it until the cloud has taken it. The dump SHALL be uploaded in
-a form the standard Zephyr tooling can decode.
-
-#### Scenario: A device that crashed and rebooted
-
-- **WHEN** a device faults and restarts, and an operator asks for the dump
-- **THEN** the dump from before the restart is retrievable and can be
-  decoded with Zephyr's coredump tooling
-
-#### Scenario: An upload that failed
-
-- **WHEN** the upload of a stored dump does not complete
-- **THEN** the dump is still stored and can be requested again
-
-### 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
-
-### Requirement: Diagnostics never block the connection
-
-Producing or uploading a log SHALL NOT stop the client from serving its
-connection, and a failed upload SHALL fail its operation with a reason
-rather than leave it pending.
-
-#### Scenario: An upload while the device is working
-
-- **WHEN** a log is uploaded
-- **THEN** the device stays connected throughout and telemetry keeps flowing
-
-#### Scenario: An upload that cannot complete
-
-- **WHEN** a log upload fails
-- **THEN** the operation is reported as failed with a reason
-
diff --git a/openspec/specs/tedge-firmware-update/spec.md b/openspec/specs/tedge-firmware-update/spec.md
deleted file mode 100644
index d83251b..0000000
--- a/openspec/specs/tedge-firmware-update/spec.md
+++ /dev/null
@@ -1,164 +0,0 @@
-# tedge-firmware-update Specification
-
-## Purpose
-How a device updates its own firmware from the cloud: the download and where it is stored, the rules that decide whether a new image is kept or rolled back, and what the operator sees while it happens.
-## Requirements
-### Requirement: The device installs a firmware image the cloud offers
-
-When the firmware-update feature is built in, the client SHALL handle the
-cloud's firmware operation by downloading the image at the given URL into the
-bootloader's secondary slot, requesting a test boot and resetting the device
-through the platform reset. It SHALL mark the operation as executing before
-the download starts and SHALL name the expected time the device will be
-offline while the bootloader swaps.
-
-#### Scenario: An operator installs a new version
-
-- **WHEN** an operator installs a firmware version on the device
-- **THEN** the device downloads it, reboots into it, and the operation ends
-  successfully once the new image is running and connected
-
-#### Scenario: The download fails
-
-- **WHEN** the image cannot be downloaded (the host is unreachable, or the
-  transfer breaks)
-- **THEN** the operation fails with the reason, the running image is
-  untouched, and the device stays connected
-
-### Requirement: A new image is confirmed only after it proves itself
-
-A newly installed image SHALL be confirmed only after it has connected to the
-cloud and the application's firmware-check hook, if any, has passed. An image
-that does not confirm SHALL be reverted by the bootloader on the next reset.
-After a revert, the client SHALL report the operation as failed, naming the
-version that did not come up.
-
-#### Scenario: The new image cannot connect
-
-- **WHEN** the new image runs but never reaches the cloud
-- **THEN** it stays unconfirmed, resets itself once its confirmation
-  deadline expires, and the bootloader restores the previous image, which
-  reports the failure with the version
-
-#### Scenario: The application refuses the new image
-
-- **WHEN** the new image connects but the application's firmware-check hook
-  refuses it
-- **THEN** the image is not confirmed and is reverted, and the failure names
-  the application's reason
-
-#### Scenario: A corrupt image reaches the slot
-
-- **WHEN** the downloaded image fails the bootloader's signature check
-- **THEN** the previous image boots, the slot is discarded, and the operation
-  is reported as failed
-
-### Requirement: An image that cannot confirm itself gives up
-
-A test-booted image SHALL reset the device when it has not been confirmed
-within a configured time, so that the bootloader can restore the previous
-image without anyone intervening. Confirming the image SHALL cancel that
-deadline. The deadline MAY be disabled for devices that have another way to
-reset themselves.
-
-#### Scenario: A new image that never reaches the cloud
-
-- **WHEN** a newly installed image runs but cannot reach the cloud, and the
-  network itself is healthy so nothing else restarts the device
-- **THEN** the image resets the device once its deadline expires, and the
-  previous image comes back
-
-### Requirement: Downloads follow redirects and keep the token to the tenant
-
-The client SHALL follow HTTP redirects up to a configured limit, including to
-another host or scheme, and SHALL handle a redirect target of at least one
-kilobyte. It SHALL send its cloud token only to hosts within the tenant's own
-domain, and SHALL drop it when a redirect leaves that domain. It SHALL write
-the image as the parser reports each body segment, so that a chunked transfer
-is stored exactly as sent.
-
-#### Scenario: A release asset on another host
-
-- **WHEN** the firmware URL redirects to a download host outside the tenant
-- **THEN** the client follows the redirect, sends no token to that host, and
-  stores the image correctly
-
-#### Scenario: A chunked transfer
-
-- **WHEN** the cloud sends the image with chunked transfer encoding
-- **THEN** the stored image matches what was sent, and the bootloader accepts
-  it
-
-### Requirement: Installing the version already running is refused
-
-The client SHALL fail a firmware operation whose name and version match the
-image that is running, with a reason saying so, and SHALL NOT download
-anything or reboot.
-
-#### Scenario: The same version is offered again
-
-- **WHEN** an operator installs the version the device is already running
-- **THEN** the operation fails with a reason naming the version, and the
-  device keeps running undisturbed
-
-### Requirement: The device reports the progress of an update
-
-When it has free-form topics available, the client SHALL publish the
-progress of an update as unreliable messages (at-most-once) on a documented
-topic, carrying the name, version, phase and how much has been transferred.
-It SHALL report a percentage only when the server states the length of the
-transfer, and the number of bytes otherwise.
-Progress messages SHALL be rate-limited, and SHALL NOT be published on
-transports without free-form topics, where the operation status is the only
-report. Losing a progress message SHALL have no effect on the update.
-
-#### Scenario: An operator watches a download
-
-- **WHEN** a device is downloading an image
-- **THEN** it publishes progress messages naming the phase and how much has
-  arrived, spaced by the configured step
-
-#### Scenario: The device is about to swap
-
-- **WHEN** the download is finished and the test boot is requested
-- **THEN** a progress message says the device is installing, before it goes
-  offline for the bootloader swap
-
-### Requirement: The running firmware version is visible in the cloud
-
-The client SHALL report the name and version of the image that is actually
-running, on every connect, so that the cloud shows what the device runs
-rather than what was last installed. The version SHALL be the application's
-full version string (`tedge_identity.firmware_version`, including any
-pre-release suffix such as `-rc1`), falling back to the bootloader image
-header's `MAJOR.MINOR.PATCH` only when the application supplies none. The
-same string SHALL be used to refuse a request for the version already
-running and to decide, after the reboot, whether the requested version came
-up or was reverted.
-
-#### Scenario: After an update
-
-- **WHEN** a device has been updated and reconnects
-- **THEN** the cloud shows the new version
-
-#### Scenario: After a revert
-
-- **WHEN** an image is reverted and the previous one reconnects
-- **THEN** the cloud shows the previous version, not the one that failed
-
-#### Scenario: A pre-release installs as itself
-
-- **WHEN** a device installs version `0.4.0-rc1` and the image comes up
-- **THEN** the operation succeeds and the cloud shows `0.4.0-rc1`, not
-  `0.4.0` and not a rollback
-
-#### Scenario: Final release over its pre-release
-
-- **WHEN** a device running `0.4.0-rc1` is asked to install `0.4.0`
-- **THEN** the request is accepted, since the versions differ
-
-#### Scenario: Application supplies no version
-
-- **WHEN** the application leaves `firmware_version` unset
-- **THEN** the client reports the image header's `MAJOR.MINOR.PATCH`
-
diff --git a/openspec/specs/tedge-parameters/spec.md b/openspec/specs/tedge-parameters/spec.md
deleted file mode 100644
index 70a99dc..0000000
--- a/openspec/specs/tedge-parameters/spec.md
+++ /dev/null
@@ -1,158 +0,0 @@
-# tedge-parameters Specification
-
-## Purpose
-TBD - created by archiving change c8y-direct-parameters. Update Purpose after archive.
-## Requirements
-### Requirement: An application declares the settings the cloud may change
-
-When parameters are built in, the client SHALL let the host application
-declare a named set of parameters, each with a type, a default and the
-limits of what it may hold. The client SHALL NOT expose a setting the
-application did not declare.
-
-The set's name SHALL be how the cloud addresses it: the state the client
-reports, the schema an integrator registers, and the changes the cloud
-sends SHALL all be identified by that one name, so that a device that
-declares more than one set has each of them seen and changed separately.
-
-#### Scenario: A device that has settings
-
-- **WHEN** an application declares a parameter set and the device connects
-- **THEN** the cloud shows the set's current values
-
-#### Scenario: A device with more than one set
-
-- **WHEN** an application declares two parameter sets and the cloud changes
-  a value in one of them
-- **THEN** only that set's value changes, and only that set is reported
-  again
-
-#### Scenario: Something that was never declared
-
-- **WHEN** the cloud sends a value for a name the device does not declare
-- **THEN** the device refuses the change and says which name it does not
-  know
-
-#### Scenario: A change for a set the device does not have
-
-- **WHEN** the cloud sends a change addressed to a set name the device does
-  not declare
-- **THEN** the device refuses it naming that set, and no declared set is
-  touched
-
-### Requirement: The client offers its own settings the same way
-
-The client SHALL offer its own settings as a parameter set of its own, so
-that a device can be adjusted in the field whether or not its application
-declares anything. That set SHALL be governed by every rule that governs an
-application's: it is validated before it is applied, it is reported as
-state, and an accepted value survives a restart.
-
-The client SHALL only offer a setting that can take effect on a device that
-is already running. A setting that is fixed when the image is built — one
-that sizes a buffer, a stack or a thread — SHALL NOT be offered, because a
-value the device cannot honour is worse than no value at all.
-
-An application SHALL be able to decline the whole thing at build time, and
-a set an application declares SHALL take precedence over a set of the same
-name from the client, so that adding a client setting can never take a name
-an application was already using.
-
-#### Scenario: An application that declares nothing
-
-- **WHEN** a device whose application declares no parameters connects
-- **THEN** the cloud still shows the client's own settings, and can change
-  them
-
-#### Scenario: Turning off a capability in the field
-
-- **WHEN** an operator turns off the client's remote access and the cloud
-  then asks for a tunnel
-- **THEN** the device refuses the tunnel and says that remote access is
-  turned off, without anyone reflashing it
-
-#### Scenario: Looking into a device that is misbehaving
-
-- **WHEN** an operator raises the client's log level on a device already in
-  the field
-- **THEN** the device logs more from that point on, and still does so after
-  it restarts
-
-#### Scenario: A setting the running image cannot honour
-
-- **WHEN** the client is built without a feature whose setting it would
-  otherwise offer
-- **THEN** that setting is absent from the set, and the cloud is not shown a
-  value the device would ignore
-
-### Requirement: The current values are visible without asking the device
-
-The client SHALL publish the current value of every declared parameter as
-device state on every connection and after every accepted change, so that
-what the cloud shows is what the device is running.
-
-#### Scenario: After a reboot
-
-- **WHEN** a device restarts
-- **THEN** the values the cloud shows are the ones the device is running,
-  without anyone requesting them
-
-### Requirement: A change is validated before any of it is applied
-
-The client SHALL check every value in a change against its declaration —
-type, range, length and allowed values — and SHALL apply the change only if
-all of it passes. A change that fails SHALL leave every parameter as it was
-and SHALL be reported as failed, naming the parameter and what was wrong
-with it. The application SHALL be able to refuse a change it alone can
-judge, and the device SHALL then keep its previous values.
-
-#### Scenario: A value outside its range
-
-- **WHEN** the cloud sets an interval above the declared maximum
-- **THEN** the operation fails naming that parameter and its limit, and the
-  device keeps running the value it had
-
-#### Scenario: A change the application refuses
-
-- **WHEN** a change passes validation but the application's hook rejects it
-- **THEN** the operation fails with the application's reason, and the
-  previous values are what the device runs and reports
-
-#### Scenario: An accepted change
-
-- **WHEN** a change passes validation and the application accepts it
-- **THEN** the device runs the new values, reports them as its state, and
-  the operation succeeds
-
-### Requirement: Values survive a restart
-
-An accepted value SHALL still be in force after the device restarts, without
-the cloud sending it again. A stored value whose parameter the running
-firmware no longer declares SHALL be discarded.
-
-#### Scenario: A power cut
-
-- **WHEN** a parameter is changed and the device loses power
-- **THEN** it comes back running the changed value
-
-#### Scenario: A firmware update that drops a parameter
-
-- **WHEN** a new firmware no longer declares a parameter that had a stored
-  value
-- **THEN** the device drops it and reports only what it declares now
-
-### Requirement: The device can produce the schema it validates against
-
-The client SHALL be able to produce the JSON Schema of a declared set, so
-that what is registered in the cloud is generated from the same declaration
-the device validates against rather than written by hand. What it produces
-SHALL be complete enough to register as it stands, without anyone having to
-add the set's name or work out what the cloud needs in order to make the
-values editable.
-
-#### Scenario: Registering a set in the cloud
-
-- **WHEN** an integrator needs the schema for a device's parameter set
-- **THEN** the device can produce it, and it describes exactly the
-  parameters, types and limits that the device enforces
-
diff --git a/openspec/specs/tedge-remote-access/spec.md b/openspec/specs/tedge-remote-access/spec.md
deleted file mode 100644
index 7fd85a9..0000000
--- a/openspec/specs/tedge-remote-access/spec.md
+++ /dev/null
@@ -1,213 +0,0 @@
-# tedge-remote-access Specification
-
-## Purpose
-Cumulocity remote access through the device: bridging a cloud session to a TCP target on the device's network, the target policy and session cap that bound it, and the events and capacity data that make it auditable.
-## Requirements
-### Requirement: The device bridges a cloud remote-access session to a TCP target
-
-When the remote-access feature is built in, the client SHALL handle the
-Cumulocity remote-access connect operation by opening a TCP connection to the
-requested host and port and bridging it to Cumulocity's device-side WebSocket
-until either side closes. It SHALL authenticate the WebSocket with the token
-it holds for the tenant, and SHALL NOT log the connection key at any log
-level.
-
-#### Scenario: SSH to a host on the device's network
-
-- **WHEN** an operator opens a remote-access session whose endpoint is an SSH
-  server on the device's subnet
-- **THEN** the device bridges the session, and the operator reaches that SSH
-  server
-
-#### Scenario: The target closes
-
-- **WHEN** the target closes the TCP connection
-- **THEN** the device closes the WebSocket and reports the session as ended
-
-### Requirement: Targets are checked before any connection is opened
-
-The client SHALL check the requested target against the build-time policy
-(the device's own IPv4 subnets, an allow-list, or the device itself) and then
-against the application's hook, before opening any socket to it. A target the
-policy or the hook refuses SHALL fail the operation with a reason naming the
-policy, and SHALL NOT be contacted. A host name SHALL be resolved first and
-judged by the address it resolves to; this applies equally to a name
-resolved over unicast DNS and to a `.local` name resolved over mDNS. An
-allow-list entry SHALL match the host as requested, so a `.local` name is
-allowed by listing that name; because any host on the link can answer an
-mDNS query, a `.local` name SHALL in every policy also resolve to an
-address on the device's own IPv4 subnets or to the device itself. A name that cannot be resolved SHALL fail the
-operation with a reason that names the host and says whether it timed out
-or failed.
-
-#### Scenario: Target outside the subnet
-
-- **WHEN** a remote-access connect names an address outside the device's
-  subnets and the policy is the default
-- **THEN** no TCP connection is attempted, and the operation fails with a
-  reason naming the policy
-
-#### Scenario: Application narrows the policy
-
-- **WHEN** the application's hook refuses a target that the built-in policy
-  allows
-- **THEN** the operation fails with the reason, and no connection is made
-
-#### Scenario: An mDNS answer outside the subnet
-
-- **WHEN** a `.local` name resolves to an address outside the device's
-  subnets and the policy is the default
-- **THEN** no TCP connection is attempted, and the operation fails with a
-  reason naming the policy and the address the name resolved to
-
-#### Scenario: A `.local` name on the allow-list
-
-- **WHEN** the policy is the allow-list, it lists `rpi5.local:22`, and a
-  connect names `rpi5.local:22`
-- **THEN** the name is resolved over mDNS and the session is bridged
-
-#### Scenario: A listed `.local` name answers from off the subnet
-
-- **WHEN** the policy is the allow-list, it lists `rpi5.local:22`, and the
-  mDNS answer for `rpi5.local` is an address outside the device's subnets
-- **THEN** no TCP connection is attempted, and the operation fails with a
-  reason naming the address
-
-### Requirement: Concurrent sessions are capped and the refusal is immediate
-
-The number of sessions open at once SHALL NOT exceed the configured maximum.
-A request that would exceed it SHALL be failed as soon as the device receives
-it, with a reason naming the limit, and SHALL NOT disturb the sessions that
-are open.
-
-#### Scenario: Second session while one is open
-
-- **WHEN** the cap is one session and a second remote-access connect arrives
-- **THEN** the device fails the new operation within seconds with a reason
-  naming the limit, and the open session continues
-
-### Requirement: Every session is auditable
-
-The client SHALL publish an event when a tunnel opens and when it ends. The
-open event SHALL name the target host and port; the close event SHALL name
-the target, why the session ended and how many bytes travelled each way.
-When the target was given as a name, both events SHALL also name the
-address it resolved to, so the record shows which machine was reached.
-
-#### Scenario: Session ends
-
-- **WHEN** a tunnel that carried traffic ends because the client disconnected
-- **THEN** an event records the target, the reason and the byte counts
-
-#### Scenario: Session to an mDNS name
-
-- **WHEN** a tunnel to `rpi5.local:22` opens and later ends, and the name
-  resolved to 192.168.68.72
-- **THEN** both events name `rpi5.local` and 192.168.68.72
-
-### Requirement: Remote-access capacity is visible before a session is opened
-
-The client SHALL publish `tedge_RemoteAccess` twin data with the session
-limit, the number of active sessions, the number of free seats and the target
-policy. It SHALL publish it on every connect, with no active sessions, and
-whenever a session opens or ends. Including the open sessions' targets SHALL
-be a build-time option, because those are internal addresses.
-
-#### Scenario: Operator checks before connecting
-
-- **WHEN** one session is open and the cap is one
-- **THEN** the twin data shows `activeSessions` 1 and `freeSessions` 0
-
-#### Scenario: Device reboots with a session open
-
-- **WHEN** the device reboots while a tunnel is open and reconnects
-- **THEN** the twin data it publishes shows no active sessions and all seats
-  free
-
-### Requirement: A telnet target is offered echo by the bridge
-
-When the target port is the telnet port, the bridge SHALL offer echo and
-suppress-go-ahead to the cloud-side client as soon as the tunnel is up, so
-that a browser terminal echoes what the operator types.
-
-#### Scenario: Web terminal to a telnet target
-
-- **WHEN** an operator opens a telnet session through the device
-- **THEN** characters typed in the browser terminal appear as they are typed
-
-### Requirement: A session that goes quiet is ended
-
-The client SHALL close a tunnel that has carried no traffic in either
-direction for the configured idle timeout, free its seat, and name the
-timeout in the close event.
-
-#### Scenario: Forgotten session
-
-- **WHEN** an operator leaves a session open with no traffic for longer than
-  the idle timeout
-- **THEN** the device closes it, publishes the close event and frees the seat
-
-### Requirement: A `.local` target is resolved over mDNS
-
-The client SHALL resolve a remote-access target whose host name ends in
-`.local` by a multicast DNS query on the device's network when mDNS
-resolution is built in, and the session SHALL proceed with the IPv4 address it resolves
-to. Each lookup SHALL be bounded by the configured timeout. A lookup that
-times out or fails with a transient error SHALL be retried after the
-configured delay, up to the configured number of attempts in all, and
-the session SHALL proceed as soon as any attempt resolves; so a host that
-never answers fails the operation within a bounded time rather than
-holding the seat. A
-`.local` target on a build without mDNS resolution SHALL fail with a
-reason saying mDNS is not built in, and SHALL NOT be sent to a unicast DNS
-server.
-
-#### Scenario: SSH to a Raspberry Pi by its mDNS name
-
-- **WHEN** an operator opens a remote-access session whose endpoint is
-  `rpi5-d83add9f145a.local:22`, and that host answers mDNS on the device's
-  subnet
-- **THEN** the device resolves the name, bridges the session to the
-  address it answered with, and the operator reaches its SSH server
-
-#### Scenario: The host has a new DHCP lease
-
-- **WHEN** a session to `rpi5-d83add9f145a.local:22` is opened after the
-  host's address changed since the previous session
-- **THEN** the device connects to the new address, without any change to
-  the endpoint in Cumulocity
-
-#### Scenario: The first lookup goes unanswered
-
-- **WHEN** the first mDNS query for the requested `.local` host gets no
-  answer within the resolve timeout, and the second one does
-- **THEN** the device bridges the session to the address from the second
-  answer, and the operation succeeds
-
-#### Scenario: Nobody answers
-
-- **WHEN** the requested `.local` host does not answer any of the
-  configured attempts
-- **THEN** the operation fails with a reason saying the name did not
-  answer over mDNS and how many attempts were made, no TCP connection is
-  attempted, and the seat is freed
-
-#### Scenario: Retrying is turned off
-
-- **WHEN** the configured number of attempts is one and that lookup gets
-  no answer
-- **THEN** the operation fails after that single lookup
-
-#### Scenario: mDNS is not built in
-
-- **WHEN** a build without mDNS resolution receives a connect for a
-  `.local` target
-- **THEN** the operation fails with a reason saying mDNS is not built in
-
-#### Scenario: The device also advertises itself over mDNS
-
-- **WHEN** the application runs Zephyr's mDNS responder and an operator
-  opens a session to another host's `.local` name
-- **THEN** the name resolves and the session is bridged, and the device
-  keeps answering for its own `.local` name
-
diff --git a/openspec/specs/tedge-telemetry/spec.md b/openspec/specs/tedge-telemetry/spec.md
deleted file mode 100644
index 92fae2e..0000000
--- a/openspec/specs/tedge-telemetry/spec.md
+++ /dev/null
@@ -1,90 +0,0 @@
-# tedge-telemetry Specification
-
-## Purpose
-TBD - created by archiving change c8y-direct-telemetry. Update Purpose after archive.
-## Requirements
-### Requirement: An application can publish measurements, events and alarms
-
-When the telemetry feature is built in, the client SHALL let the host
-application publish measurements, events and alarms, and clear an alarm it
-raised. A call SHALL be safe from any thread and SHALL NOT block on the
-network. The client SHALL NOT produce telemetry the application did not ask
-for, beyond its own health.
-
-#### Scenario: A measurement reaches the cloud
-
-- **WHEN** the application publishes a measurement while the device is
-  connected
-- **THEN** the cloud records it with its series and values
-
-#### Scenario: Published from another thread
-
-- **WHEN** the application publishes from its own thread while the client is
-  busy
-- **THEN** the call returns promptly and the message is sent by the client
-
-#### Scenario: An alarm is raised and cleared
-
-- **WHEN** the application raises an alarm and later clears it
-- **THEN** the cloud shows the alarm raised with its severity and text, and
-  then cleared
-
-### Requirement: Telemetry carries the time it was taken
-
-Every message SHALL carry the time it was produced, so that a message held
-while the device is offline is recorded with the time of the reading rather
-than the time it arrived. The application MAY supply the time; otherwise the
-client SHALL use the current time. When the device has no valid clock, the
-client SHALL still send the message and let the cloud stamp it.
-
-#### Scenario: A reading is buffered and sent later
-
-- **WHEN** the application publishes a measurement while the device is
-  offline, and the device reconnects a minute later
-- **THEN** the cloud records the measurement at the time it was taken
-
-### Requirement: A short outage does not lose data silently
-
-The client SHALL hold messages it cannot send yet in a bounded buffer and
-send them, oldest first, once it is connected. When the buffer is full, the
-client SHALL drop the oldest measurement rather than refuse new data or grow
-its memory, SHALL count what it dropped, and SHALL report that count. Events
-and alarms SHALL take precedence over measurements.
-
-#### Scenario: Brief disconnection
-
-- **WHEN** the connection drops for a few seconds while the application
-  keeps publishing
-- **THEN** the messages are sent once the client reconnects
-
-#### Scenario: The buffer fills
-
-- **WHEN** an application publishes faster than the connection can carry for
-  long enough to fill the buffer
-- **THEN** the oldest measurements are dropped, the client keeps running,
-  and the number dropped is reported
-
-### Requirement: The client reports its own health
-
-When device health is built in, the client SHALL publish its own vital signs
-on an interval: how long it has been running, how much of its heap is free,
-how many telemetry messages it has dropped, and why the device last reset.
-
-#### Scenario: A device that is degrading
-
-- **WHEN** a device's free heap falls over hours while it keeps running
-- **THEN** the trend is visible in the cloud without connecting to the
-  device
-
-### Requirement: Telemetry works on both transports
-
-The same API calls SHALL produce free-form `te/` messages on a transport
-that has them, and the equivalent SmartREST messages where it does not, with
-no change to the application.
-
-#### Scenario: The same application on either transport
-
-- **WHEN** an application that publishes measurements is built once for the
-  MQTT Service and once for Core MQTT
-- **THEN** both builds report the same measurements to the cloud
-
diff --git a/openspec/specs/workspace-structure/spec.md b/openspec/specs/workspace-structure/spec.md
index 1de7844..4d61a17 100644
--- a/openspec/specs/workspace-structure/spec.md
+++ b/openspec/specs/workspace-structure/spec.md
@@ -87,17 +87,17 @@ Firmware SHALL be built by targeting the application directory, i.e.
 ### Requirement: Device-management-only application
 
 The workspace SHALL provide `apps/tedge-agent`, an application that composes
-`lib/common/` with the `tedge-zephyr` module and **no** protocol frontend
+`lib/common/` with the `tiny-edge` module and **no** protocol frontend
 library, for devices whose job is to be managed from the cloud and to give
 it remote access to LAN hosts. It SHALL declare its own firmware name via
 `CONFIG_APP_FIRMWARE_NAME`, use the same provisioning hand-off and MCUboot
-layout as the protocol applications, and keep its `tedge-zephyr` glue in the
+layout as the protocol applications, and keep its `tiny-edge` glue in the
 application, not in the module.
 
 #### Scenario: Composition
 
 - **WHEN** `apps/tedge-agent/` is built
-- **THEN** it links `lib/common/` and `tedge-zephyr` and none of
+- **THEN** it links `lib/common/` and `tiny-edge` and none of
   `lib/opcua/`, `lib/modbus/` or `lib/snmp/`
 
 #### Scenario: Provisioned like the other apps
@@ -110,15 +110,15 @@ application, not in the module.
 ### Requirement: Board-level tedge settings are shared across applications
 
 Every application's `tedge` builds SHALL take the per-board settings a
-`tedge-zephyr` build requires (TLS heap size and location, PSRAM, TLS context
+`tiny-edge` build requires (TLS heap size and location, PSRAM, TLS context
 count, network buffer depths, heap pool) from one application-independent
-file per board under `lib/common/tedge-boards/`. An
+file per board under `tiny-edge/ports/zephyr/profiles/boards/`. An
 application SHALL add only its own measured deltas on top.
 
 #### Scenario: Same board, different apps
 
 - **WHEN** `modbus-server` and `snmp-agent` are built as `tedge-full` for the
   ESP32-S3-DevKitC
-- **THEN** both use `lib/common/tedge-boards/` settings for that board, with
+- **THEN** both use `tiny-edge/ports/zephyr/profiles/boards/` settings for that board, with
   the mbedTLS heap in PSRAM
 
diff --git a/openspec/specs/ztp-provisioning/spec.md b/openspec/specs/ztp-provisioning/spec.md
index 99517df..d07f5bd 100644
--- a/openspec/specs/ztp-provisioning/spec.md
+++ b/openspec/specs/ztp-provisioning/spec.md
@@ -149,7 +149,7 @@ leaves the device with a tenant but no network.
 The provisioning image SHALL write the `c8y.v2` fields to
 `prov/c8y/url`, `prov/c8y/tenant`, `prov/c8y/external_id` and `prov/c8y/otp` in
 the shared `storage` partition. It SHALL NOT write to the `tedge/` settings
-subtree and SHALL NOT link the `tedge-zephyr` module.
+subtree and SHALL NOT link the `tiny-edge` module.
 
 The application image SHALL read those keys at boot, pass the tenant host and
 one-time password to the client through its public API, and use the external ID
@@ -190,7 +190,7 @@ another.
 #### Scenario: The provisioner stays independent of the client module
 
 - **WHEN** the provisioning image is built
-- **THEN** it links no symbol from `tedge-zephyr`, and the module contains no
+- **THEN** it links no symbol from `tiny-edge`, and the module contains no
   reference to ZTP or to BLE
 
 ### Requirement: The bundle's authenticity is trusted on first use, with pinning available
diff --git a/release/devices.yml b/release/devices.yml
index e8e5715..73c12f1 100644
--- a/release/devices.yml
+++ b/release/devices.yml
@@ -23,17 +23,19 @@
 # How each variant is built. `profile` and the device's `tedge_board_conf` go
 # into EXTRA_CONF_FILE (profile first, so the board's sizes win), then the
 # build's `extra_conf`. `provisioner` selects the Wi-Fi provisioner image.
+# Paths are relative to this repository, or "{tiny-edge}/..." for files from
+# the tiny-edge west project (the client's profiles and board sizing).
 variants:
   standalone:
     description: the protocol server alone; Wi-Fi over Improv BLE
     provisioner: improv
   tedge-full:
     description: every thin-edge.io feature; Wi-Fi and Cumulocity over lab-ztp-provisioner
-    profile: tedge-zephyr/profiles/full.conf
+    profile: "{tiny-edge}/ports/zephyr/profiles/full.conf"
     provisioner: ztp
   tedge-ota:
     description: connection, telemetry, restart and firmware update, plus what else fits; Wi-Fi and Cumulocity over lab-ztp-provisioner
-    profile: tedge-zephyr/profiles/ota.conf
+    profile: "{tiny-edge}/ports/zephyr/profiles/ota.conf"
     provisioner: ztp
 
 provisioners:
@@ -47,7 +49,7 @@ devices:
     chip: esp32c6
     flash_size: 4MB
     esptool_before: usb-reset
-    tedge_board_conf: lib/common/tedge-boards/esp32c6-devkitc.conf
+    tedge_board_conf: "{tiny-edge}/ports/zephyr/profiles/boards/esp32c6-devkitc.conf"
     builds:
       - { app: modbus-server, variant: standalone, pr: true }
       - { app: opcua-server, variant: standalone }
@@ -79,7 +81,7 @@ devices:
     chip: esp32s3
     flash_size: 16MB
     esptool_before: default-reset
-    tedge_board_conf: lib/common/tedge-boards/esp32s3-devkitc.conf
+    tedge_board_conf: "{tiny-edge}/ports/zephyr/profiles/boards/esp32s3-devkitc.conf"
     builds:
       - { app: modbus-server, variant: standalone }
       - { app: opcua-server, variant: standalone, pr: true }
@@ -87,7 +89,7 @@ devices:
       - app: modbus-server
         variant: tedge-full
         pr: true
-        extra_conf: [lib/common/tedge-boards/extras/shell-diagnostics.conf]
+        extra_conf: ["{tiny-edge}/ports/zephyr/profiles/extras/shell-diagnostics.conf"]
         measured: DEVICES.md#shell-diagnostics-measured-2026-09-22
       - app: modbus-server
         variant: tedge-ota
@@ -101,7 +103,7 @@ devices:
         measured: DEVICES.md#release-builds-measured-on-the-boards-2026-09-21
       - app: tedge-agent
         variant: tedge-full
-        extra_conf: [lib/common/tedge-boards/extras/shell-diagnostics.conf]
+        extra_conf: ["{tiny-edge}/ports/zephyr/profiles/extras/shell-diagnostics.conf"]
         measured: DEVICES.md#shell-diagnostics-measured-2026-09-22
       - app: tedge-agent
         variant: tedge-ota
@@ -113,14 +115,14 @@ devices:
     chip: esp32s3
     flash_size: 4MB
     esptool_before: usb-reset
-    tedge_board_conf: lib/common/tedge-boards/qtpy-esp32s3.conf
+    tedge_board_conf: "{tiny-edge}/ports/zephyr/profiles/boards/qtpy-esp32s3.conf"
     builds:
       - { app: modbus-server, variant: standalone }
       - { app: opcua-server, variant: standalone }
       - { app: snmp-agent, variant: standalone, pr: true }
       - app: modbus-server
         variant: tedge-full
-        extra_conf: [lib/common/tedge-boards/extras/shell-diagnostics.conf]
+        extra_conf: ["{tiny-edge}/ports/zephyr/profiles/extras/shell-diagnostics.conf"]
         measured: DEVICES.md#shell-diagnostics-measured-2026-09-22
       - app: modbus-server
         variant: tedge-ota
@@ -138,7 +140,7 @@ devices:
       - app: tedge-agent
         variant: tedge-full
         pr: true
-        extra_conf: [lib/common/tedge-boards/extras/shell-diagnostics.conf]
+        extra_conf: ["{tiny-edge}/ports/zephyr/profiles/extras/shell-diagnostics.conf"]
         measured: DEVICES.md#shell-diagnostics-measured-2026-09-22
       - app: tedge-agent
         variant: tedge-ota
@@ -150,7 +152,7 @@ devices:
     chip: esp32
     flash_size: 4MB
     esptool_before: default-reset
-    tedge_board_conf: lib/common/tedge-boards/esp32-devkitc.conf
+    tedge_board_conf: "{tiny-edge}/ports/zephyr/profiles/boards/esp32-devkitc.conf"
     builds:
       - { app: modbus-server, variant: standalone }
       - { app: opcua-server, variant: standalone, pr: true }
@@ -164,13 +166,13 @@ devices:
         variant: tedge-ota
         pr: true
         extra_conf:
-          - lib/common/tedge-boards/extras/parameters.conf
+          - "{tiny-edge}/ports/zephyr/profiles/extras/parameters.conf"
         measured: DEVICES.md#wroom-and-esp32-cam-release-builds-measured-2026-09-23
       - app: tedge-agent
         variant: tedge-ota
         pr: true
         extra_conf:
-          - lib/common/tedge-boards/extras/parameters.conf
+          - "{tiny-edge}/ports/zephyr/profiles/extras/parameters.conf"
         measured: DEVICES.md#wroom-and-esp32-cam-release-builds-measured-2026-09-23
 
   - id: esp32-cam
@@ -179,7 +181,7 @@ devices:
     chip: esp32
     flash_size: 4MB
     esptool_before: default-reset
-    tedge_board_conf: lib/common/tedge-boards/esp32-cam.conf
+    tedge_board_conf: "{tiny-edge}/ports/zephyr/profiles/boards/esp32-cam.conf"
     dtc_overlays: [lib/common/dts/esp32cam-status-led.overlay]
     # One feature set for every tedge build on this board: ota (which
     # includes certificate renewal), parameters and log upload. dram1 (stacks, network buffers)
@@ -191,8 +193,8 @@ devices:
         variant: tedge-ota
         pr: true
         extra_conf: &cam-extras
-          - lib/common/tedge-boards/extras/parameters.conf
-          - lib/common/tedge-boards/extras/log-upload.conf
+          - "{tiny-edge}/ports/zephyr/profiles/extras/parameters.conf"
+          - "{tiny-edge}/ports/zephyr/profiles/extras/log-upload.conf"
         measured: DEVICES.md#wroom-and-esp32-cam-release-builds-measured-2026-09-23
       - app: modbus-server
         variant: tedge-ota
diff --git a/scripts/measure_tedge.sh b/scripts/measure_tedge.sh
index 8168230..a3a4f40 100755
--- a/scripts/measure_tedge.sh
+++ b/scripts/measure_tedge.sh
@@ -1,7 +1,7 @@
 #!/usr/bin/env bash
 # SPDX-License-Identifier: Apache-2.0
 #
-# Footprint of the tedge-zephyr client: each application built with and
+# Footprint of the tiny-edge client: each application built with and
 # without CONFIG_TEDGE, so the table shows what the client costs a host
 # application. Reports for each build:
 #   image_B    the signed application image
@@ -30,9 +30,11 @@ nm_for() {
 }
 
 # --- MODE=module: the client's cost in a host application -------------------
-# The client at the minimal profile, with each board's tedge-zephyr settings.
-TB=/ws/app/lib/common/tedge-boards
-MIN=/ws/app/tedge-zephyr/profiles/minimal.conf
+# The client at the minimal profile, with each board's tiny-edge settings.
+# tiny-edge is a west project; its path in the workspace comes from west.
+TE=$(west list -f '{abspath}' tiny-edge)
+TB=$TE/ports/zephyr/profiles/boards
+MIN=$TE/ports/zephyr/profiles/minimal.conf
 # label : board : app : overlays (on top of the Wi-Fi credentials)
 CASES=${CASES:-"
 c6 modbus, no client:esp32c6_devkitc/esp32c6/hpcore:apps/modbus-server:
@@ -40,7 +42,7 @@ c6 modbus + client:esp32c6_devkitc/esp32c6/hpcore:apps/modbus-server:$MIN;$TB/es
 s3 modbus, no client:esp32s3_devkitc/esp32s3/procpu:apps/modbus-server:
 s3 modbus + client (PSRAM):esp32s3_devkitc/esp32s3/procpu:apps/modbus-server:$MIN;$TB/esp32s3-devkitc.conf;/ws/app/tedge.local.conf
 c6 modbus + client + remote access:esp32c6_devkitc/esp32c6/hpcore:apps/modbus-server:$MIN;$TB/esp32c6-devkitc.conf;/ws/app/tedge.local.conf;/ws/app/build_measure_extra/ra.conf
-c6 samples/minimal:esp32c6_devkitc/esp32c6/hpcore:tedge-zephyr/samples/minimal:!/ws/app/tedge-zephyr/samples/minimal/overlay-c8y.conf;/ws/app/tedge.local.conf
+c6 samples/minimal:esp32c6_devkitc/esp32c6/hpcore:$TE/ports/zephyr/samples/minimal:!$TE/ports/zephyr/samples/minimal/overlay-c8y.conf;/ws/app/tedge.local.conf
 "}
 echo "| case | image_B | text_B | libc_B | tlsheap_B |"
 echo "|---|---|---|---|---|"
diff --git a/scripts/release/build.sh b/scripts/release/build.sh
index 9cba160..0a7bc66 100755
--- a/scripts/release/build.sh
+++ b/scripts/release/build.sh
@@ -41,9 +41,9 @@ done
 
 eval "$(python3 "$root/scripts/release/matrix.py" --entry "$name")"
 
-abs() { # "a;b" repository-relative -> "/root/a;/root/b"
+abs() { # "a;/x/b" repository-relative or absolute -> "/root/a;/x/b"
 	[[ -n $1 ]] || return 0
-	tr ';' '\n' <<< "$1" | sed "s#^#$root/#" | paste -sd';' -
+	tr ';' '\n' <<< "$1" | sed "s#^\([^/]\)#$root/\1#" | paste -sd';' -
 }
 
 args=("-DCONFIG_APP_FIRMWARE_NAME=\"$FIRMWARE_NAME\"")
diff --git a/scripts/release/bump.sh b/scripts/release/bump.sh
index de32c86..133b98a 100755
--- a/scripts/release/bump.sh
+++ b/scripts/release/bump.sh
@@ -10,7 +10,7 @@
 # EXTRAVERSION, and sets .release-please-manifest.json to the same version.
 # Commit the result, then tag that commit vX.Y.Z; the release workflow checks
 # the tag against these files (check-version.sh).
-# tedge-zephyr/VERSION is the module's own and is not touched.
+# tiny-edge has its own version (its VERSION file), pinned in west.yml.
 #
 # Normally release-please's release PR does this (README, "Releasing"); this
 # script is for a pre-release or a release cut by hand. The
diff --git a/scripts/release/matrix.py b/scripts/release/matrix.py
index 6c6ed3c..eb28fe1 100755
--- a/scripts/release/matrix.py
+++ b/scripts/release/matrix.py
@@ -22,8 +22,10 @@
 """
 
 import argparse
+import functools
 import json
 import re
+import subprocess
 import sys
 from dataclasses import dataclass, field
 from pathlib import Path
@@ -33,7 +35,13 @@
 ROOT = Path(__file__).resolve().parents[2]
 DEFAULT_MANIFEST = ROOT / "release" / "devices.yml"
 
-# tedge_identity's firmware_name buffer in tedge-zephyr is 48 bytes.
+# A file from another west project is written "{project}/path", for example
+# "{tiny-edge}/ports/zephyr/profiles/full.conf". It is found with `west list`;
+# where west is not installed (the release workflow's matrix job) such a file
+# is not checked.
+PROJECT_REF = re.compile(r"^\{([\w.-]+)\}/(.+)$")
+
+# tedge_identity's firmware_name buffer in tiny-edge is 48 bytes.
 FIRMWARE_NAME_MAX = 47
 ID_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
 FLASH_RE = re.compile(r"^(1|2|4|8|16|32)MB$")
@@ -93,6 +101,30 @@ def matrix_entry(self, manifest):
         }
 
 
+@functools.lru_cache(maxsize=None)
+def west_project_dir(name):
+    """Absolute path of a west project, or None when west cannot say."""
+    try:
+        out = subprocess.run(["west", "list", "-f", "{abspath}", name],
+                             cwd=ROOT, capture_output=True, text=True,
+                             check=True)
+    except (OSError, subprocess.CalledProcessError):
+        return None
+    return Path(out.stdout.strip())
+
+
+def resolve(root, rel):
+    """A manifest path as a Path: repository-relative or "{project}/...".
+
+    None when it names a west project and west is not available.
+    """
+    m = PROJECT_REF.match(rel)
+    if not m:
+        return Path(root) / rel
+    base = west_project_dir(m.group(1))
+    return base / m.group(2) if base else None
+
+
 def board_dir_name(board):
     """esp32c6_devkitc/esp32c6/hpcore -> esp32c6_devkitc_esp32c6_hpcore"""
     return board.replace("/", "_")
@@ -122,7 +154,8 @@ def load(path=DEFAULT_MANIFEST, root=ROOT):
     problems = []
 
     def need_file(where, rel):
-        if not (root / rel).is_file():
+        path = resolve(root, rel)
+        if path is not None and not path.is_file():
             problems.append(f"{where}: {rel} does not exist")
 
     variants = manifest.get("variants") or {}
@@ -281,7 +314,13 @@ def main(argv=None):
             for b in builds:
                 print(f"  {b.firmware_name}", file=sys.stderr)
             return 1
-        for k, v in build.matrix_entry(manifest).items():
+        entry = build.matrix_entry(manifest)
+        # Files from other west projects become absolute paths here, where
+        # the build runs; build.sh makes the rest absolute.
+        entry["conf_files"] = ";".join(
+            str(resolve(args.root, f)) if PROJECT_REF.match(f) else f
+            for f in build.conf_files())
+        for k, v in entry.items():
             print(f"{k.upper()}='{v}'")
         return 0
     if args.subset == "pr":
diff --git a/scripts/release/notes.py b/scripts/release/notes.py
index d5ef21c..b7cd99d 100755
--- a/scripts/release/notes.py
+++ b/scripts/release/notes.py
@@ -91,7 +91,7 @@ def notes(manifest, metas, tag, repo):
                 f"Each image reports a firmware name unique to its device and app, and "
                 f"the version `{tag.lstrip('v')}`; `c8y-firmware.json` lists them.",
                 "",
-                f"tedge-zephyr {', '.join(versions)}. Features per image:",
+                f"tiny-edge {', '.join(versions)}. Features per image:",
                 "",
                 "| Image | Features |",
                 "|---|---|"]
diff --git a/scripts/release/package.py b/scripts/release/package.py
index b393ecc..be187ce 100755
--- a/scripts/release/package.py
+++ b/scripts/release/package.py
@@ -59,6 +59,15 @@ def app_version(app_dir):
     return m.group(1)
 
 
+def tiny_edge_version():
+    """The tiny-edge release the image was built with (its VERSION file)."""
+    path = matrix.resolve(ROOT, "{tiny-edge}/VERSION")
+    if path is None or not path.is_file():
+        raise SystemExit("cannot find tiny-edge's VERSION: is west installed "
+                         "and tiny-edge in the workspace (west update)?")
+    return path.read_text().strip()
+
+
 def sha256(path):
     return hashlib.sha256(Path(path).read_bytes()).hexdigest()
 
@@ -94,7 +103,7 @@ def device_type(app):
 
 
 def features(cfg):
-    """tedge-zephyr features compiled in, e.g. ["firmware-update", ...]."""
+    """tiny-edge features compiled in, e.g. ["firmware-update", ...]."""
     skip = {"CONFIG_TEDGE", "CONFIG_TEDGE_TRANSPORT_C8Y",
             "CONFIG_TEDGE_C8Y_MQTT_SERVICE", "CONFIG_TEDGE_AUTH_C8Y_CA"}
     names = []
@@ -245,8 +254,7 @@ def main(argv=None):
         "tedge": build.tedge,
         "device_type": device_type(args.app) if build.tedge else None,
         "features": features(cfg) if build.tedge else [],
-        "tedge_zephyr_version": ((ROOT / "tedge-zephyr" / "VERSION").read_text().strip()
-                                 if build.tedge else None),
+        "tedge_zephyr_version": (tiny_edge_version() if build.tedge else None),
         "measured": build.measured,
         "signing": sign,
         "assets": {p.name: sha256(p) for p in (factory, app_bin, bundle)},
diff --git a/scripts/release/tests/test_matrix.py b/scripts/release/tests/test_matrix.py
index e111a76..b31763d 100644
--- a/scripts/release/tests/test_matrix.py
+++ b/scripts/release/tests/test_matrix.py
@@ -77,6 +77,31 @@ def test_valid_manifest(self):
         self.assertEqual(ota["firmware_name"], "modbus-server-tedge-ota-c6")
         json.dumps({"include": entries})  # serialisable
 
+    def test_west_project_paths(self):
+        """"{project}/..." resolves through west, and is skipped without it."""
+        text = VALID.replace("profiles/ota.conf", '"{tiny-edge}/profiles/ota.conf"')
+        project = self.root / "te"
+        (project / "profiles").mkdir(parents=True)
+        (project / "profiles/ota.conf").write_text("")
+        try:
+            matrix.west_project_dir.cache_clear()
+            orig = matrix.west_project_dir
+            # Found: the file is checked where west says the project is.
+            matrix.west_project_dir = lambda name: project if name == "tiny-edge" else None
+            manifest, builds = self.load(text)
+            self.assertEqual(matrix.resolve(self.root, builds[1].conf_files()[0]),
+                             project / "profiles/ota.conf")
+            (project / "profiles/ota.conf").unlink()
+            self.assertIn("{tiny-edge}/profiles/ota.conf does not exist",
+                          self.problems(text))
+            # No west (the matrix job): not checked, and kept as written.
+            matrix.west_project_dir = lambda name: None
+            manifest, builds = self.load(text)
+            self.assertEqual(builds[1].matrix_entry(manifest)["conf_files"],
+                             "{tiny-edge}/profiles/ota.conf;boards/c6.conf")
+        finally:
+            matrix.west_project_dir = orig
+
     def test_unknown_app(self):
         p = self.problems(VALID.replace("app: modbus-server\n", "app: nope\n"))
         self.assertIn("device c6, nope tedge-ota: there is no apps/nope", p)
diff --git a/tedge-zephyr/CMakeLists.txt b/tedge-zephyr/CMakeLists.txt
deleted file mode 100644
index ba9472c..0000000
--- a/tedge-zephyr/CMakeLists.txt
+++ /dev/null
@@ -1,94 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-#
-# tedge-zephyr: thin-edge.io device-management client for any Zephyr
-# application. Nothing is built unless the application enables CONFIG_TEDGE,
-# so an image without it carries no code, RAM or threads from this module.
-#
-# This directory is self-contained: it must never reference files outside
-# itself (scripts/check-independence.py enforces this).
-
-if(CONFIG_TEDGE)
-  file(STRINGS ${CMAKE_CURRENT_LIST_DIR}/VERSION TEDGE_VERSION LIMIT_COUNT 1)
-
-  zephyr_library_named(tedge)
-  zephyr_library_sources(
-    src/tedge_version.c
-    src/tedge_core.c
-    src/tedge_platform.c
-    src/tedge_time.c
-    src/tedge_backoff.c
-    src/tedge_json.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_TELEMETRY src/tedge_telemetry.c)
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_HEALTH src/tedge_health.c)
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_TRANSPORT_C8Y
-    src/tedge_c8y.c
-    src/tedge_smartrest.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_C8Y_OPERATION_JSON
-    src/tedge_op_json.c
-  )
-  # The HTTP client belongs to every file-transfer feature, not to the first
-  # one that needed it.
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_HTTP
-    src/tedge_http_download.c
-    src/tedge_http_upload.c
-    src/tedge_url.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_FIRMWARE_UPDATE
-    src/tedge_firmware.c
-    src/tedge_fw_decision.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_SHELL_COMMAND
-    src/tedge_shell_cmd.c
-    src/tedge_shell_allow_list.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_LOG_UPLOAD
-    src/tedge_log_upload.c
-    src/tedge_log_ring.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_PARAMETERS src/tedge_parameters.c)
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_PARAMETERS_SELF
-    src/tedge_self_params.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_COREDUMP src/tedge_coredump.c)
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_CERT_RENEWAL
-    src/tedge_cert_renew.c
-    src/tedge_cert_decision.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_REMOTE_ACCESS
-    src/tedge_remote_access.c
-    src/tedge_ra_allow_list.c
-    src/tedge_ra_target.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_AUTH_C8Y_CA
-    src/tedge_enroll.c
-    src/tedge_otp.c
-    src/tedge_pkcs7.c
-  )
-  zephyr_library_sources_ifdef(CONFIG_TEDGE_AUTH_BOOTSTRAP src/tedge_bootstrap.c)
-  zephyr_library_compile_definitions(TEDGE_VERSION_STRING="${TEDGE_VERSION}")
-
-  zephyr_include_directories(include)
-
-  # Trust anchors: every PEM in CONFIG_TEDGE_C8Y_CA_FILES is concatenated
-  # into one bundle and embedded as tedge_ca_bundle.inc.
-  if(CONFIG_TEDGE_TRANSPORT_C8Y)
-    separate_arguments(_ca_files UNIX_COMMAND "${CONFIG_TEDGE_C8Y_CA_FILES}")
-    set(_bundle "")
-    foreach(_pem ${_ca_files})
-      if(NOT IS_ABSOLUTE ${_pem})
-        set(_pem ${CMAKE_CURRENT_LIST_DIR}/${_pem})
-      endif()
-      if(NOT EXISTS ${_pem})
-        message(FATAL_ERROR "CONFIG_TEDGE_C8Y_CA_FILES: no such file: ${_pem}")
-      endif()
-      file(READ ${_pem} _content)
-      string(APPEND _bundle "${_content}")
-    endforeach()
-    set(_bundle_pem ${ZEPHYR_BINARY_DIR}/misc/generated/tedge_ca_bundle.pem)
-    file(WRITE ${_bundle_pem} "${_bundle}")
-    generate_inc_file_for_target(tedge ${_bundle_pem}
-      ${ZEPHYR_BINARY_DIR}/include/generated/tedge_ca_bundle.inc)
-  endif()
-endif()
diff --git a/tedge-zephyr/Kconfig b/tedge-zephyr/Kconfig
deleted file mode 100644
index 6bbe7a5..0000000
--- a/tedge-zephyr/Kconfig
+++ /dev/null
@@ -1,823 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-#
-# tedge-zephyr: thin-edge.io device-management client.
-#
-# Every feature is its own option, so an image carries only what the board can
-# afford. Features that are not implemented yet are listed but cannot be
-# selected: each depends on a hidden TEDGE_FEATURE_AVAILABLE_ gate that is
-# only set once the feature lands (or, for development and spikes, by
-# TEDGE_EXPERIMENTAL_FEATURES).
-
-menuconfig TEDGE
-	bool "thin-edge.io device management client (tedge-zephyr)"
-	help
-	  Build the tedge-zephyr client into the application. With this off,
-	  the module adds no code, RAM, threads, sockets or TLS sessions.
-
-if TEDGE
-
-# The client's own kernel needs, whatever the transport.
-config TEDGE_KERNEL_SELECTS
-	bool
-	default y
-	select EVENTS
-
-config TEDGE_EXPERIMENTAL_FEATURES
-	bool "Allow selecting features that are not implemented yet"
-	select EXPERIMENTAL
-	help
-	  Development and spikes only. Makes every feature option selectable,
-	  including the ones with no implementation in this module yet, so that
-	  an application can prototype them and the dependency rules can be
-	  tested. Never enable this in a release image.
-
-# --- Implementation gates (hidden) -----------------------------------------
-# Each flips to "default y" when its feature is implemented in this module.
-
-config TEDGE_FEATURE_AVAILABLE_GATEWAY
-	bool
-	default y if TEDGE_EXPERIMENTAL_FEATURES
-
-config TEDGE_FEATURE_AVAILABLE_TELEMETRY
-	bool
-	default y
-
-config TEDGE_FEATURE_AVAILABLE_HEALTH
-	bool
-	default y
-
-config TEDGE_FEATURE_AVAILABLE_RESTART
-	bool
-	default y
-
-config TEDGE_FEATURE_AVAILABLE_FIRMWARE_UPDATE
-	bool
-	default y
-
-config TEDGE_FEATURE_AVAILABLE_SHELL_COMMAND
-	bool
-	default y
-
-config TEDGE_FEATURE_AVAILABLE_LOG_UPLOAD
-	bool
-	default y
-
-config TEDGE_FEATURE_AVAILABLE_REMOTE_ACCESS
-	bool
-	default y
-
-config TEDGE_FEATURE_AVAILABLE_PARAMETERS
-	bool
-	default y
-
-config TEDGE_FEATURE_AVAILABLE_CERT_RENEWAL
-	bool
-	default y
-
-# --- Transport ----------------------------------------------------------------
-
-choice TEDGE_TRANSPORT
-	prompt "Transport"
-	default TEDGE_TRANSPORT_C8Y
-
-config TEDGE_TRANSPORT_C8Y
-	bool "Direct to Cumulocity"
-	help
-	  The device connects to Cumulocity itself over MQTT/TLS. It owns time
-	  synchronisation, TLS, authentication and the cloud protocol.
-
-config TEDGE_TRANSPORT_GATEWAY
-	bool "Via a thin-edge.io gateway"
-	depends on TEDGE_FEATURE_AVAILABLE_GATEWAY
-	help
-	  The device is a child device of a thin-edge.io gateway on the local
-	  network and speaks the thin-edge.io MQTT API to it.
-
-endchoice
-
-if TEDGE_TRANSPORT_C8Y
-
-# The application owns the network interface; these are what the client
-# itself needs. Everything else (socket counts, mbedTLS heap and record size,
-# PSA algorithms) is the application's to size: see the README and profiles.
-config TEDGE_C8Y_SELECTS
-	bool
-	default y
-	# The client needs the IP stack and sockets. The application still owns
-	# the interface: which L2, credentials, and when to connect.
-	select NETWORKING
-	select NET_MGMT
-	select NET_MGMT_EVENT
-	select NET_IPV4
-	select NET_TCP
-	select NET_UDP if TEDGE_SNTP
-	select DNS_RESOLVER
-	select MQTT_LIB
-	select MQTT_LIB_TLS
-	select MBEDTLS
-	select NET_SOCKETS
-	select NET_SOCKETS_SOCKOPT_TLS
-	select TLS_CREDENTIALS
-	select SETTINGS
-	select SNTP if TEDGE_SNTP
-
-config TEDGE_C8Y_URL
-	string "Cumulocity tenant host"
-	default ""
-	help
-	  Host name of the Cumulocity tenant, e.g. "example.cumulocity.com",
-	  without a scheme. It can be overridden at runtime (settings), so a
-	  fleet build usually sets it once per tenant.
-
-config TEDGE_C8Y_CA_FILES
-	string "Trust anchors for the tenant (PEM files, space-separated)"
-	default "certs/godaddy-root-g2.pem"
-	help
-	  Paths relative to the module directory, or absolute. They are
-	  embedded and registered as the server trust anchors. The default is
-	  the root the *.cumulocity.com chains end in; point this at your own
-	  files for other instances.
-
-config TEDGE_SNTP
-	bool "Set the clock with SNTP when it is not already valid"
-	default y
-	help
-	  Certificates cannot be validated without the time. With this off,
-	  the application must set the realtime clock before the client
-	  connects.
-
-config TEDGE_SNTP_SERVER
-	string "SNTP server"
-	default "pool.ntp.org"
-	depends on TEDGE_SNTP
-
-config TEDGE_C8Y_OPERATION_JSON
-	bool "Receive operations with their id (devicecontrol/notifications)"
-	default y
-	help
-	  Cumulocity's SmartREST operation statuses (501, 502, 503) name a
-	  fragment and act on the oldest operation in that state, not on the
-	  one the device is working on. An operation left executing by a reset
-	  therefore absorbs the results of every operation after it.
-
-	  With this on, the client receives operations as JSON on
-	  devicecontrol/notifications, where the operation id is, and reports
-	  every status with 504, 505 and 506, which name the operation.
-
-	  Turn it off for a tenant or a transport that does not serve that
-	  topic; the client then uses s/ds and the older templates.
-
-config TEDGE_C8Y_PAYLOAD_BYTES
-	int "Largest message the client accepts"
-	default 2048 if TEDGE_C8Y_OPERATION_JSON
-	default 1024
-	range 512 8192
-	help
-	  An operation's JSON carries a delivery log beside its own fields, so
-	  it needs more room than a SmartREST line. A message larger than this
-	  is dropped with a warning naming its size.
-
-config TEDGE_REQUIRED_INTERVAL_MIN
-	int "Required interval reported to Cumulocity (minutes)"
-	default 60
-	help
-	  Cumulocity marks the device unavailable when it has not sent
-	  anything for this long. 0 disables the report.
-
-config TEDGE_RECONNECT_BACKOFF_MAX_S
-	int "Longest wait between reconnect attempts (s)"
-	default 300
-	range 5 3600
-	help
-	  The wait starts at 3 s (a closed TLS connection holds its TCP
-	  context for a moment) and doubles up to this value, with jitter.
-
-choice TEDGE_C8Y_ENDPOINT
-	prompt "Cumulocity MQTT endpoint"
-	default TEDGE_C8Y_MQTT_SERVICE
-
-config TEDGE_C8Y_MQTT_SERVICE
-	bool "MQTT Service (port 9883): SmartREST and free-form topics"
-	# The MQTT Service refuses basic auth for devices, so basic-auth
-	# (bootstrap) builds use Core MQTT. Drop this line once it accepts
-	# basic auth.
-	depends on TEDGE_AUTH_C8Y_CA
-	help
-	  One connection carries device management (SmartREST 2.0) and
-	  free-form telemetry. SmartREST on the MQTT Service needs the tenant
-	  feature mqtt-service.smartrest (Public Preview). Requires
-	  certificate authentication.
-
-config TEDGE_C8Y_CORE_MQTT
-	bool "Core MQTT (port 8883): SmartREST only"
-	help
-	  The generally available endpoint. Telemetry is sent as SmartREST
-	  measurements. Works with both authentication methods, and is the
-	  endpoint basic-auth (bootstrap) builds use.
-
-endchoice
-
-choice TEDGE_AUTH
-	prompt "Authentication"
-	default TEDGE_AUTH_C8Y_CA
-
-config TEDGE_AUTH_C8Y_CA
-	bool "x.509 certificate from the Cumulocity CA"
-	help
-	  The device generates its key, a one-time password and a registration
-	  URL, enrolls through EST (simpleenroll) once registered, and connects
-	  with mutual TLS.
-
-config TEDGE_AUTH_BOOTSTRAP
-	bool "Basic authentication via the bootstrap user"
-	help
-	  The device obtains device credentials through the Cumulocity bootstrap
-	  user once it is accepted in the device registration UI. Features that
-	  need certificate authentication are unavailable.
-
-endchoice
-
-if TEDGE_AUTH_C8Y_CA
-
-# Enrollment needs a key in PSA storage, a CSR writer and an HTTPS client.
-config TEDGE_ENROLL_SELECTS
-	bool
-	default y
-	# The device key lives in PSA ITS. The application chooses the ITS
-	# store (a Kconfig choice, so the module cannot select it); see the
-	# README and the profiles.
-	select SECURE_STORAGE
-	select HTTP_CLIENT
-	select MBEDTLS_X509_CSR_WRITE_C
-	select MBEDTLS_PEM_WRITE_C
-	select MBEDTLS_PK_WRITE_C
-	select MBEDTLS_BASE64_C
-	select MBEDTLS_ASN1_PARSE_C
-	select PSA_WANT_KEY_TYPE_ECC_KEY_PAIR_GENERATE
-	select PSA_WANT_KEY_TYPE_ECC_KEY_PAIR_EXPORT
-	select PSA_WANT_KEY_TYPE_ECC_KEY_PAIR_IMPORT
-	select PSA_WANT_ALG_ECDSA
-	select PSA_WANT_ALG_SHA_256
-
-config TEDGE_PSA_KEY_ID
-	hex "PSA key ID of the device key"
-	default 0x0007E571
-	range 0x00000001 0x3FFFFFFF
-	help
-	  Persistent key ID in PSA secure storage. It must not collide with
-	  the application's own keys, and must be at most PSA_KEY_ID_USER_MAX.
-
-config TEDGE_ENROLL_POLL_S
-	int "Seconds between enrollment attempts"
-	default 10
-	range 2 600
-	help
-	  While the device waits to be registered, it retries simpleenroll at
-	  this interval, backing off to 60 s.
-
-endif # TEDGE_AUTH_C8Y_CA
-
-if TEDGE_AUTH_BOOTSTRAP
-
-config TEDGE_BOOTSTRAP_USER
-	string "Bootstrap user (tenant/username)"
-	default ""
-	help
-	  The Cumulocity bootstrap credentials, usually set once per fleet in
-	  a git-ignored overlay. The application can also set them at runtime
-	  with tedge_set_bootstrap_credentials().
-
-config TEDGE_BOOTSTRAP_PASSWORD
-	string "Bootstrap password"
-	default ""
-
-endif # TEDGE_AUTH_BOOTSTRAP
-
-endif # TEDGE_TRANSPORT_C8Y
-
-# --- Identity -------------------------------------------------------------------
-
-config TEDGE_DEVICE_ID_PREFIX
-	string "Default external ID prefix"
-	default "tedge"
-	help
-	  Unless the application sets an external ID, the device uses
-	  "-".
-
-config TEDGE_DEVICE_TYPE
-	string "Default device type"
-	default "thin-edge.io-zephyr"
-
-# --- Features -------------------------------------------------------------------
-
-menu "Features"
-
-config TEDGE_TELEMETRY
-	bool "Telemetry (measurements, events, alarms)"
-	depends on TEDGE_FEATURE_AVAILABLE_TELEMETRY
-	help
-	  tedge_publish_measurement(), tedge_publish_event() and the alarm
-	  functions. The application decides what to send and when.
-
-if TEDGE_TELEMETRY
-
-config TEDGE_TELEMETRY_BUFFER_BYTES
-	int "Bytes held for telemetry that cannot be sent yet"
-	default 2048
-	range 256 65536
-	help
-	  Messages wait here while the device is offline and are sent oldest
-	  first on reconnect. When it is full the oldest measurement is
-	  dropped and counted; events and alarms are kept. The buffer is RAM,
-	  so a reboot loses what it held.
-
-endif # TEDGE_TELEMETRY
-
-config TEDGE_HEALTH
-	bool "Device health measurements"
-	depends on TEDGE_FEATURE_AVAILABLE_HEALTH
-	help
-	  The client's own vital signs: uptime, its free heap, how much
-	  telemetry it dropped, and the last reset reason. What the
-	  application knows about the device is the application's to publish.
-	select TEDGE_TELEMETRY
-	select HWINFO
-	# The free-heap figure comes from the kernel's heap statistics.
-	select SYS_HEAP_RUNTIME_STATS
-
-config TEDGE_HEALTH_INTERVAL_S
-	int "Seconds between health measurements"
-	default 900
-	range 30 86400
-	depends on TEDGE_HEALTH
-
-config TEDGE_RESTART
-	bool "Restart operation"
-	depends on TEDGE_FEATURE_AVAILABLE_RESTART
-
-config TEDGE_FIRMWARE_UPDATE
-	bool "Firmware update"
-	depends on TEDGE_FEATURE_AVAILABLE_FIRMWARE_UPDATE
-	depends on BOOTLOADER_MCUBOOT
-	select TEDGE_HTTP
-	help
-	  Downloads a signed image into the secondary slot, boots it for test
-	  and confirms it only when the cloud is reachable and the
-	  application's checks pass; MCUboot reverts it otherwise. Requires an
-	  MCUboot (sysbuild) build of the application.
-	select IMG_MANAGER
-	# MCUBOOT_IMG_MANAGER is a Kconfig choice, so it cannot be selected;
-	# it is the default for an MCUboot build, which this feature requires.
-	select STREAM_FLASH
-	select IMG_ERASE_PROGRESSIVELY
-	select FLASH
-	select FLASH_MAP
-
-if TEDGE_FIRMWARE_UPDATE
-
-config TEDGE_FIRMWARE_STACK_SIZE
-	int "Stack of the download thread"
-	default 8192
-	help
-	  The download's TLS handshake runs on this thread, as the
-	  remote-access bridge's does on its own.
-
-config TEDGE_FIRMWARE_CONFIRM_AFTER_CONNECT
-	bool "Confirm a test-booted image once it reaches the cloud"
-	default y
-	help
-	  With this off, an image stays unconfirmed until the application
-	  confirms it, and any reset rolls it back. The application's
-	  firmware-check hook is consulted either way.
-
-config TEDGE_FIRMWARE_CONFIRM_TIMEOUT_S
-	int "Seconds a test-booted image has to confirm itself"
-	default 900
-	range 0 86400
-	help
-	  A new image that cannot reach the cloud never confirms, and nothing
-	  else would reset the device: the network is up, so the
-	  application's connectivity watchdog sees nothing wrong. When this
-	  many seconds pass without a confirmation, the client resets the
-	  device so the bootloader can roll the image back. 0 disables it,
-	  leaving the rollback to whatever else resets the device.
-
-config TEDGE_FIRMWARE_PROGRESS
-	bool "Publish download progress on a free-form topic"
-	default y
-	depends on TEDGE_C8Y_MQTT_SERVICE
-	help
-	  te/device////progress/firmware, at most once (QoS 0): a stream
-	  of hints, so a lost message costs nothing. Core MQTT has no
-	  free-form topics and publishes none.
-
-config TEDGE_FIRMWARE_PROGRESS_PERCENT
-	int "Percentage step between progress messages"
-	default 10
-	range 1 50
-	depends on TEDGE_FIRMWARE_PROGRESS
-
-endif # TEDGE_FIRMWARE_UPDATE
-
-config TEDGE_SHELL_COMMAND
-	bool "Shell command operation"
-	depends on TEDGE_FEATURE_AVAILABLE_SHELL_COMMAND
-	depends on SHELL
-	select SHELL_BACKEND_DUMMY
-	help
-	  Runs an allow-listed Zephyr shell command sent from the cloud and
-	  returns its output. The command runs in-process against a capture
-	  backend, not a shell on the network, and nothing runs until
-	  TEDGE_SHELL_COMMAND_ALLOW_LIST says what may.
-
-if TEDGE_SHELL_COMMAND
-
-config TEDGE_SHELL_COMMAND_ALLOW_LIST
-	string "Commands the cloud may run (comma-separated prefixes)"
-	default ""
-	help
-	  For example "kernel uptime,net iface,tedge diag". A request must
-	  begin with one of these and may add arguments; anything that could
-	  chain a second command is refused whatever this says.
-
-	  Empty, the default, refuses every command: compiling the feature in
-	  is not the same as consenting to run something. What is safe to
-	  expose depends on what else is in the image, which only you know.
-
-config TEDGE_SHELL_COMMAND_TIMEOUT_S
-	int "Seconds before a command is reported as timed out"
-	default 30
-	range 1 600
-	help
-	  Zephyr cannot interrupt a running shell command, so the operation
-	  fails but the thread stays busy until the command returns. Keep
-	  commands that can block for ever off the allow-list.
-
-config TEDGE_SHELL_COMMAND_OUTPUT_BYTES
-	int "Output kept from a command"
-	default 512
-	range 64 4096
-	help
-	  What the command printed, as far as this many bytes. The result
-	  reported to Cumulocity is one SmartREST field and is shorter still;
-	  a command with a lot to say belongs behind a log type.
-
-config TEDGE_SHELL_COMMAND_STACK_SIZE
-	int "Stack of the thread a command runs on"
-	default 4096
-
-endif # TEDGE_SHELL_COMMAND
-
-config TEDGE_LOG_UPLOAD
-	bool "Log upload"
-	depends on TEDGE_FEATURE_AVAILABLE_LOG_UPLOAD
-	select TEDGE_HTTP
-	select LOG
-	select LOG_OUTPUT
-	help
-	  Answers the cloud's request for a log file. The client keeps its own
-	  recent log in RAM and offers it without the application doing
-	  anything; the application can add log types of its own, each a
-	  callback that writes the log when it is asked for.
-
-if TEDGE_LOG_UPLOAD
-
-config TEDGE_LOG_RING_BYTES
-	int "The client's own log kept in RAM"
-	default 2048
-	range 256 16384
-	help
-	  A ring in the module's heap holding the most recent log lines of the
-	  whole image, oldest whole line dropped when it is full. It does not
-	  survive a reboot; that is what the crash dump is for.
-
-config TEDGE_LOG_TYPE_SLOTS
-	int "Log types the client can hold"
-	default 4
-	range 1 16
-	help
-	  The client's own log, the crash dump when it is built in, and the
-	  application's own types.
-
-config TEDGE_LOG_UPLOAD_MAX_BYTES
-	int "Most bytes one request may send"
-	default 8192
-	range 512 262144
-	help
-	  A longer log is cut, with a line saying so, rather than refused.
-	  Nothing of this size is held in RAM: the log is streamed as it is
-	  produced.
-
-config TEDGE_COREDUMP
-	bool "Offer the last crash dump as a log"
-	depends on ARCH_SUPPORTS_COREDUMP
-	select DEBUG_COREDUMP
-	help
-	  On a fatal error Zephyr writes a dump to the coredump partition;
-	  after the reset the client offers it as the log type "coredump", in
-	  the format zephyr/scripts/coredump/coredump_serial_log_parser.py
-	  reads, and erases it once the cloud has it.
-
-	  The backend is a Kconfig choice, so this cannot select it: the
-	  application sets CONFIG_DEBUG_COREDUMP_BACKEND_FLASH_PARTITION=y and
-	  its board needs a "coredump_partition" (every layout in this
-	  repository has one).
-
-config TEDGE_LOG_UPLOAD_STACK_SIZE
-	int "Stack of the upload thread"
-	default 8192
-	help
-	  Two TLS handshakes run on this thread — the identity lookup and the
-	  upload itself — which is why it needs as much as the download and
-	  the remote-access bridge. 4096 overflows during the handshake, and
-	  on a board without CONFIG_HW_STACK_PROTECTION that shows up as an
-	  illegal instruction rather than a stack-overflow message.
-
-endif # TEDGE_LOG_UPLOAD
-
-config TEDGE_REMOTE_ACCESS
-	bool "Remote access (tunnel to the device and its network)"
-	depends on TEDGE_FEATURE_AVAILABLE_REMOTE_ACCESS
-	depends on TEDGE_TRANSPORT_C8Y && TEDGE_AUTH_C8Y_CA
-	help
-	  Bridges Cumulocity Cloud Remote Access sessions (SSH, VNC, Telnet,
-	  passthrough) to a TCP target: the device itself or another host on
-	  its network, within the target policy below. Each session holds one
-	  TLS connection while it is open. Requires certificate authentication.
-	select TEDGE_HTTP
-	select WEBSOCKET_CLIENT
-	# websocket_connect() hashes Sec-WebSocket-Accept with PSA SHA-1 but
-	# does not select it; without this it fails with -EPROTO.
-	select PSA_WANT_ALG_SHA_1
-
-if TEDGE_REMOTE_ACCESS
-
-choice TEDGE_REMOTE_ACCESS_TARGETS
-	prompt "Allowed remote-access targets"
-	default TEDGE_REMOTE_ACCESS_TARGETS_LAN
-
-config TEDGE_REMOTE_ACCESS_TARGETS_LAN
-	bool "Hosts on the device's own IPv4 subnets"
-
-config TEDGE_REMOTE_ACCESS_TARGETS_LIST
-	bool "Only host:port pairs in an allow-list"
-
-config TEDGE_REMOTE_ACCESS_TARGETS_LOCAL
-	bool "Only the device itself"
-
-endchoice
-
-config TEDGE_REMOTE_ACCESS_ALLOW_LIST
-	string "Allow-list"
-	depends on TEDGE_REMOTE_ACCESS
-	default ""
-	help
-	  Comma-separated "host:port" entries, e.g.
-	  "192.168.1.20:22,127.0.0.1:23".
-
-config TEDGE_REMOTE_ACCESS_STACK_SIZE
-	int "Stack of each session's bridge thread"
-	depends on TEDGE_REMOTE_ACCESS
-	default 8192
-	help
-	  The session's TLS handshake runs on this thread, so it needs the
-	  same depth as the client thread. 4096 overflowed it (the fault
-	  shows as a jump to a garbage address).
-
-config TEDGE_REMOTE_ACCESS_TWIN_SESSIONS
-	bool "List the open sessions' targets in the twin data"
-	depends on TEDGE_REMOTE_ACCESS
-	default y
-	help
-	  The session count, the limit and the policy are always published;
-	  the targets are internal addresses, so they are optional.
-
-config TEDGE_REMOTE_ACCESS_MAX_SESSIONS
-	int "Maximum concurrent sessions"
-	range 1 4
-	default 1
-
-config TEDGE_REMOTE_ACCESS_BUF_SIZE
-	int "Bridge buffer size per direction (bytes)"
-	range 512 16384
-	default 2048
-
-config TEDGE_REMOTE_ACCESS_IDLE_TIMEOUT_S
-	int "Close a session after this many idle seconds (0 = never)"
-	default 900
-
-config TEDGE_REMOTE_ACCESS_MDNS
-	bool "Resolve .local targets over mDNS"
-	default y
-	# The client sends its own one-shot query (tedge_remote_access.c,
-	# mdns_once()); Zephyr's mDNS resolver is not needed, and in 4.4 does
-	# not receive answers while the application runs the mDNS responder.
-	select NET_UDP
-	help
-	  A remote-access target named ".local" is resolved by a
-	  multicast query on the device's network, so an operator can reach a
-	  LAN host by its mDNS name whatever its current DHCP address. Any host
-	  on the link can answer, so the answer must be on the device's own
-	  subnets under every target policy. Each lookup opens one UDP socket
-	  for its duration. Without this option a ".local" target fails with
-	  "mDNS is not built in".
-
-config TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS
-	int "How long each lookup of a target name waits (ms)"
-	range 500 10000
-	default 5000
-	help
-	  Applies to mDNS and unicast DNS names alike. A host waking from
-	  Wi-Fi power save can take seconds to answer.
-
-config TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS
-	int "Lookups of a target name before the session fails"
-	range 1 5
-	default 3
-	help
-	  A lookup that times out or hits a transient error is tried again,
-	  up to this many lookups in all; 1 turns retrying off. An
-	  authoritative "no such name" is not retried. The session's seat is
-	  held while it resolves: at most attempts x timeout plus the pauses
-	  between them, which the build keeps under 30 s.
-
-config TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS
-	int "Pause between lookups of a target name (ms)"
-	range 0 5000
-	default 500
-
-endif # TEDGE_REMOTE_ACCESS
-
-config TEDGE_PARAMETERS
-	bool "Parameters"
-	depends on TEDGE_FEATURE_AVAILABLE_PARAMETERS
-	help
-	  Settings an operator may change from the cloud. The application
-	  declares a named set of typed parameters with their defaults and
-	  limits; the client stores them, validates every change against the
-	  declaration, and reports the current values as twin state. Nothing
-	  is uploaded or downloaded, so this needs no HTTP client and no
-	  filesystem.
-
-if TEDGE_PARAMETERS
-
-config TEDGE_PARAMETERS_MAX
-	int "Maximum declared parameters, across all sets"
-	default 16
-	range 1 100
-	help
-	  One current value is kept in RAM per declared parameter. The
-	  declaration itself is the application's const data and costs
-	  nothing here. Cumulocity accepts at most 100 parameters in one
-	  change.
-
-config TEDGE_PARAMETERS_SELF
-	bool "Offer the client's own tunables as a parameter set"
-	default y
-	help
-	  Declares a set named "zephyr_tedge" holding the few client settings that
-	  are worth revisiting on a device already in the field: how much it
-	  logs, how often it reports its own health, the availability window
-	  Cumulocity judges it by, and whether the cloud may tunnel to it.
-	  Every application gets these without writing a line, and its own
-	  parameters live in a set of its own. Raising the log level takes
-	  effect immediately only with CONFIG_LOG_RUNTIME_FILTERING;
-	  otherwise it applies at the next boot.
-
-config TEDGE_PARAMETERS_SCHEMA
-	bool "Print the JSON Schema of a declared set"
-	default y
-	depends on SHELL
-	help
-	  Adds "tedge params schema", which prints what has to be registered
-	  in the tenant's Digital Twin Manager for a set. It is generated
-	  from the same table the client validates against, so the two cannot
-	  drift. Costs text; turn it off once the sets are registered.
-
-endif # TEDGE_PARAMETERS
-
-config TEDGE_CERT_RENEWAL
-	bool "Certificate renewal"
-	depends on TEDGE_FEATURE_AVAILABLE_CERT_RENEWAL
-	depends on TEDGE_TRANSPORT_C8Y && TEDGE_AUTH_C8Y_CA
-	help
-	  Renews the device certificate through EST (simplereenroll) before it
-	  expires. A device whose certificate has expired cannot be reached at
-	  all, so this is what keeps a fleet manageable over years.
-	select TEDGE_HTTP
-	select MBEDTLS_X509_CRT_PARSE_C
-
-if TEDGE_CERT_RENEWAL
-
-config TEDGE_CERT_RENEW_BEFORE_DAYS
-	int "Renew when this many days of lifetime are left"
-	default 30
-	range 1 3650
-	help
-	  The margin covers a device that is offline for a while: it has this
-	  long to come back and renew before the certificate expires.
-
-config TEDGE_CERT_RENEW_ALARM_DAYS
-	int "Raise an alarm when this many days are left and renewal failed"
-	default 7
-	range 0 3650
-	help
-	  0 raises no alarm. Otherwise the device raises a critical alarm
-	  naming the expiry date, and clears it once a renewal succeeds.
-
-config TEDGE_CERT_RENEW_CHECK_HOURS
-	int "Hours between certificate checks"
-	default 24
-	range 1 168
-	help
-	  The check parses the stored certificate; it costs no network
-	  traffic. Checks are spread within the interval so that a fleet does
-	  not renew all at once.
-
-endif # TEDGE_CERT_RENEWAL
-
-endmenu
-
-# Shared HTTPS client and token handling, pulled in by the file-transfer
-# features (firmware update, log upload, configuration management). Every
-# call authenticates with a fresh Bearer JWT and only on the tenant's own
-# domain.
-config TEDGE_HTTP
-	bool
-	select HTTP_CLIENT
-	select NET_SOCKETS
-	select NET_SOCKETS_SOCKOPT_TLS
-	select DNS_RESOLVER
-
-if TEDGE_HTTP
-
-config TEDGE_HTTP_MAX_REDIRECTS
-	int "HTTP redirects to follow"
-	default 3
-	range 0 10
-	help
-	  The JWT is never sent to a host outside the tenant's domain, so a
-	  redirect off it drops the token rather than the request.
-
-endif # TEDGE_HTTP
-
-# --- Resources ------------------------------------------------------------------
-
-menu "Resources"
-
-config TEDGE_THREAD_STACK_SIZE
-	int "Client thread stack size"
-	default 6144
-	help
-	  The measured peak is about 3.5 KB, including the mutual-TLS
-	  handshake and the first enrollment (c8y-direct-spikes).
-
-config TEDGE_THREAD_PRIORITY
-	int "Client thread priority"
-	default 10
-	help
-	  Preemptible priority of the client's threads. Keep it below (a higher
-	  number than) the application's time-critical threads.
-
-config TEDGE_HEAP_SIZE
-	int "Client heap size (bytes)"
-	default 16384 if TEDGE_PARAMETERS
-	default 10240
-	help
-	  The client allocates only from this heap, never from the
-	  application's, so its memory use is bounded and predictable.
-
-	  The default follows the features built: parameters hold their
-	  declarations and twin state here and need 16 KB; without them the
-	  largest live set is the enrolment buffers (7.6 KB), which are not
-	  live at the same time as twin work, so 10 KB holds them with
-	  margin. The health telemetry's free-heap value (tedge_heap_free)
-	  shows what a build leaves after enrolment, a firmware update and a
-	  twin update; keep it above 2 KB, or raise this.
-
-config TEDGE_HEAP_NOINIT
-	bool "Keep the client heap in .noinit"
-	help
-	  Where the client heap's memory lives. By default it is ordinary
-	  .bss. With this option it is in .noinit, where K_HEAP_DEFINE() puts
-	  a kernel heap. It only matters on a SoC with more than one RAM
-	  region: on the classic ESP32 .noinit is dram1 (stacks, network
-	  buffers) and .bss is dram0 (the libc malloc arena, and the mbedTLS
-	  heap unless it is in PSRAM). Pick the region with room: the
-	  ESP32-CAM, whose mbedTLS heap is in PSRAM, has room in dram0; the
-	  ESP32-WROOM-32 keeps its mbedTLS heap in dram0 and needs this.
-
-config TEDGE_TLS_TAG_BASE
-	hex "First TLS credential tag used by the client"
-	default 0x7465
-	help
-	  The client uses tags from this value upwards (one per credential
-	  set). Choose a base that doesn't overlap the application's own tags.
-
-endmenu
-
-module = TEDGE
-module-str = tedge
-source "subsys/logging/Kconfig.template.log_config"
-
-endif # TEDGE
diff --git a/tedge-zephyr/README.md b/tedge-zephyr/README.md
deleted file mode 100644
index 57b051e..0000000
--- a/tedge-zephyr/README.md
+++ /dev/null
@@ -1,660 +0,0 @@
-# tedge-zephyr
-
-A [thin-edge.io](https://thin-edge.io) device-management client for
-[Zephyr RTOS](https://www.zephyrproject.org/) applications. Add it to your own
-Zephyr application to get device management next to whatever the application
-already does:
-
-- remote access, including to other hosts on the device's network;
-- firmware update through MCUboot;
-- telemetry;
-- log retrieval;
-- parameters an operator can see and change.
-
-> **Status: early.** Onboarding, the connection, device state, restart,
-> remote access, firmware update, certificate renewal and telemetry (with the
-> client's own health) work and are verified on hardware (ESP32-C6,
-> ESP32-S3), as do log retrieval, crash dumps and the allow-listed shell
-> command. Parameters are implemented and unit-tested; their hardware
-> verification is still outstanding. Configuration *files* are deliberately
-> not implemented, and the API for them has been removed — see
-> **Parameters** for what replaced them. The API may still change.
-
-## Transports
-
-- **Direct to Cumulocity** (first): MQTTS to the Cumulocity MQTT Service
-  (:9883), with Core MQTT (:8883) as a fallback. Onboarding uses the Cumulocity
-  CA, with bootstrap-user basic-auth credentials as the alternative.
-- **Via a thin-edge.io gateway** (later): the device becomes a child device
-  that speaks the thin-edge.io MQTT API on the local network.
-
-## Using it in an application
-
-The module is named `tedge`. Until it has its own repository, add it as an
-extra module before `find_package(Zephyr)`:
-
-```cmake
-list(APPEND ZEPHYR_EXTRA_MODULES /path/to/tedge-zephyr)
-```
-
-Then enable it in `prj.conf`:
-
-```
-CONFIG_TEDGE=y
-```
-
-With `CONFIG_TEDGE=n` (the default), the module adds nothing to the image.
-
-Every feature is a separate Kconfig option under `CONFIG_TEDGE`, so an image
-carries only what the board can afford. Run `west build -t menuconfig` and see
-"thin-edge.io device management client". Options for features that aren't
-implemented yet are shown but cannot be selected. `CONFIG_TEDGE_EXPERIMENTAL_FEATURES`
-unlocks them for development and spikes only.
-
-## Integration contract
-
-The module is a guest in your image.
-
-| Concern | Your application | tedge-zephyr |
-|---|---|---|
-| Network | Brings up and recovers Wi-Fi or Ethernet | Waits for an IPv4 address (net_mgmt events); never touches the interface |
-| Identity | Sets the external ID, name, type and firmware name/version | Defaults: external ID `-`; firmware from the app's version |
-| Telemetry | Calls `tedge_publish_measurement()`, `tedge_publish_event()` and the alarm functions | Buffers (bounded), encodes and sends; never samples on its own |
-| Operations | May register its own operations, log types and configuration types | Handles its compiled-in features; passes custom ones to the application |
-| Restart | Restart hook: prepare, or veto with a reason | Reboots only after the hook allows it |
-| Firmware confirm | Adds its own health checks | Confirms a new image only when the cloud is reachable **and** your checks pass |
-| Remote access | May narrow the allowed targets | Enforces the Kconfig target policy before opening any connection |
-| State | Receives state changes (for example to drive an LED) | Emits states; never drives GPIO |
-| Watchdog | Owns the task watchdog | Calls an optional progress hook from each thread it owns |
-| Resources | Provides the socket, poll and mbedTLS configuration listed below | Runs its own threads with its own bounded heap; settings under `tedge/`; TLS credential tags from `CONFIG_TEDGE_TLS_TAG_BASE` |
-
-### What your application must configure
-
-The client selects the protocol pieces it needs (MQTT, sockets, TLS sockets,
-credentials, SNTP, settings). These are yours to size, because they are
-shared with the rest of your image:
-
-| Option | Why, and what to set |
-|---|---|
-| `CONFIG_MBEDTLS_HEAP_SIZE` | The TLS memory for **all** TLS in your image. One session needs **52 KB** while it shakes hands and holds **35 KB**; every extra concurrent session (a remote-access tunnel, a download) needs another 52 KB peak. 64 KB for the connection alone, **96 KB** with remote access. |
-| `CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN` | 16384. 8192 works on the MQTT Service and saves 16 KB per session, but leaves only ~2 KB of margin over today's server chain. |
-| `CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS` | One for the client, plus one per remote-access session. |
-| `CONFIG_NET_MAX_CONN`, `CONFIG_NET_MAX_CONTEXTS` | About 4 more than your application needs: a closed TLS connection holds its TCP context while it finishes closing. |
-| `CONFIG_SECURE_STORAGE_ITS_STORE_IMPLEMENTATION_*` | The device key lives in PSA ITS. It is a Kconfig choice, so the module cannot pick it; `..._SETTINGS` works with an NVS settings backend. |
-| mbedTLS ciphersuites and `CONFIG_PSA_WANT_ECC_SECP_R1_256` | The TLS 1.2 baseline Cumulocity needs; see `profiles/`. |
-| `CONFIG_MBEDTLS_AES_ROM_TABLES` | The profiles set it: AES tables in flash instead of 8 KB of RAM. |
-| `CONFIG_TEDGE_HEAP_SIZE` | The client's private heap. Its default follows the features: 16 KB with parameters, 10 KB without. The health telemetry's free-heap value shows the margin a build leaves. |
-| `CONFIG_POSIX_API` | Not needed. The module uses Zephyr's native `zsock_*` socket API and the libc time functions, so an application can leave the POSIX layer (its thread, signal, timer and fd pools) out of the image. |
-
-`profiles/minimal.conf`, `profiles/full.conf` and
-`profiles/remote-access-enabler.conf` set all of this for you.
-
-### Measured cost (ESP32-C6, Modbus application, 2026-09-20)
-
-| Build | Flash (text) | libc heap left |
-|---|---|---|
-| Application alone | 616 KB | 296 KB |
-| + client (onboarding, connection, restart) | 775 KB | 180 KB |
-| + remote access | 798 KB | 130 KB |
-| + firmware update, certificate renewal | 812 KB | 116 KB |
-| + telemetry and health | 815 KB | 114 KB |
-| + log upload and crash dumps | 824 KB | 100 KB |
-| + the shell command (on an image that already has a shell) | +2.5 KB | 90 KB |
-| + parameters (both sets, no schema printer) | +7.2 KB | — |
-| + the schema printer and its shell commands | +1.3 KB | — |
-
-With the TLS heap in PSRAM on an ESP32-S3, the client costs no internal RAM
-beyond its own buffers.
-
-### What the client publishes, and how to map it
-
-On the Cumulocity MQTT Service the client uses thin-edge.io's `te/` topics,
-so the same messages suit a gateway or a Cumulocity Smart Function. On Core
-MQTT the same values go out as direct inventory updates instead.
-
-| Topic | Payload | Meaning |
-|---|---|---|
-| `te/device////twin/tedge_Agent` | `{"name":"tedge-zephyr","version":"0.0.1","transport":"c8y-mqtt-service","firmware":" "}` | which client and firmware the device runs |
-| `te/device////twin/tedge_RemoteAccess` | `{"maxSessions":1,"activeSessions":0,"freeSessions":1,"policy":"lan","sessions":[]}` | remote-access capacity, published on every connect and whenever a session opens or ends |
-| `te/device//service/tedge-zephyr/status/health` | `{"status":"up","time":}` | the client is connected |
-
-Telemetry from `tedge_publish_measurement()`, `tedge_publish_event()` and
-`tedge_raise_alarm()` uses the same `te/` topics:
-
-| Topic | Payload | Meaning |
-|---|---|---|
-| `te/device////m/` | `{"time":"2026-09-21T20:15:30Z","temperature":21.54,"humidity":48.25}` | a measurement: one key per series |
-| `te/device////m/tedge_health` | `{"time":"…","uptime":26114.00,"freeHeap":15920.00,"droppedMessages":0.00,"resetCause":2.00}` | the client's own health (below), every `CONFIG_TEDGE_HEALTH_INTERVAL_S` |
-| `te/device////e/` | `{"time":"…","text":"door opened"}` | an event |
-| `te/device////a/` | `{"time":"…","severity":"major","text":"pump overheating"}` | an alarm (`critical`, `major`, `minor`, `warning`) |
-| `te/device////a/` | *(empty)* | the alarm is cleared |
-
-A measurement payload is flat: `time` (UTC, seconds; left out until the
-clock is set) and one **number** per series, always with two decimals — no
-nesting and **no units**, although the API takes them; a mapping that wants
-units supplies them. Payloads are at most 224 bytes. Measurements go at
-QoS 0, events and alarms at QoS 1. The thin-edge.io convention, which a
-generic Smart Function can follow, is that each key becomes both fragment
-and series: `{"temperature":21.54}` →
-`{"temperature":{"temperature":{"value":21.54}}}`.
-
-`tedge_health`: `uptime` in seconds since boot; `freeHeap`, bytes free in
-the client's own heap (not the system heap); `droppedMessages`, telemetry
-lost while offline because the buffer was full; `resetCause`, Zephyr's
-`hwinfo` reset-cause bits for the last boot (1 pin, 2 software, 4
-brown-out, 8 power-on, 16 watchdog, 256 CPU lockup).
-
-Twin values are **state, not events**: the client republishes all of them
-after every reconnect, so a reboot never leaves a stale value, and nothing
-relies on retained messages.
-
-A Smart Function maps a twin message to a fragment on the managed object.
-The mapping verified on the test tenant takes the `tedge_RemoteAccess`
-message above and writes it unchanged under a `remoteAccess` fragment:
-
-```json
-{"remoteAccess": {"activeSessions": 0, "freeSessions": 1,
-                  "maxSessions": 1, "policy": "lan", "sessions": []}}
-```
-
-Operations stay on SmartREST (`s/ds`, `501`/`503`/`502`), which is how
-Cumulocity tracks an operation's lifecycle.
-
-### Telemetry
-
-`CONFIG_TEDGE_TELEMETRY` gives the application four calls, safe from any
-thread and none of them blocking on the network:
-
-```c
-struct tedge_measurement_value v[] = {
-        { .series = "speed", .value = 1480, .unit = "rpm" },
-        { .series = "pressure", .value = 4.2, .unit = "bar" },
-};
-
-tedge_publish_measurement("pump", v, ARRAY_SIZE(v), 0); /* 0 = now */
-tedge_publish_event("maintenance_due", "500 hours since service", 0);
-tedge_raise_alarm("pump_stalled", TEDGE_ALARM_MAJOR, "no rotation");
-tedge_clear_alarm("pump_stalled");
-```
-
-**The client samples nothing by itself.** What is worth measuring, and how
-often, is the application's decision — it owns the sensors. The client's job
-starts once the call is made.
-
-A call builds its message there and then, with the time it was made, and
-puts it in a buffer; the client thread sends it while connected. That is why
-a reading buffered through an outage is recorded at the time it was taken,
-not the time the device reconnected. An application that sampled earlier
-passes its own Unix time in ms as the last argument.
-
-| Kind | Topic (MQTT Service) | Payload | Core MQTT |
-|---|---|---|---|
-| Measurement | `te/device////m/` | `{"time":"…","speed":1480.00,"pressure":4.20}` | `200,,,` per series |
-| Event | `te/device////e/` | `{"time":"…","text":"…"}` | `400,,""` |
-| Alarm | `te/device////a/` | `{"time":"…","severity":"major","text":"…"}` | `301`–`304` by severity |
-| Clear an alarm | `te/device////a/` | empty payload | `306,` |
-
-Measurements go at QoS 0 and events and alarms at QoS 1: a lost reading is
-one reading, a lost alarm is an unreported fault. Values carry two decimals
-(the client formats them without floating-point printf, which an application
-should not have to pay for).
-
-Two differences belong to Cumulocity rather than the client. On Core MQTT a
-measurement with several series becomes one measurement object per series,
-because the static template carries one; on the MQTT Service the whole
-message arrives as one. And raising an alarm whose type is already active
-updates that alarm instead of creating a second one, keeping the severity it
-was first raised with. `te/` messages also need a Smart Function in the
-tenant before they show up as measurements; SmartREST on Core MQTT needs
-none.
-
-**What the buffer can and cannot do.** It is
-`CONFIG_TEDGE_TELEMETRY_BUFFER_BYTES` (default 2048) of RAM in the client's
-heap — minutes of a typical reporting interval, not hours, and it does not
-survive a reboot. When it is full the oldest *measurement* is dropped and
-counted; events and alarms are never dropped to make room for a measurement.
-`tedge_telemetry_dropped()` returns the count, and the client publishes it in
-its own health, so loss is a number rather than a mystery. An application
-that must lose nothing should keep its own store and publish from it.
-
-With `CONFIG_TEDGE_HEALTH` the client also reports itself every
-`CONFIG_TEDGE_HEALTH_INTERVAL_S` (default 900) as a `tedge_health`
-measurement: uptime, free bytes in its heap, messages dropped, and the reason
-for the last reset. That is the *client's* health; anything the application
-knows about the device — signal strength, battery, sensor status — is the
-application's to publish, because only it knows what those numbers mean.
-
-Without the feature the same calls compile and return `-ENOTSUP`, so an
-application does not need `#ifdef`s to build in a minimal configuration.
-
-### Diagnostics: logs, a crash dump and a command
-
-`CONFIG_TEDGE_LOG_UPLOAD` answers Cumulocity's request for a log file. The
-client always has one log of its own — the most recent lines of everything
-the image logs, kept in a RAM ring of `CONFIG_TEDGE_LOG_RING_BYTES` (2 KB by
-default) — so a device says something useful even if its application never
-built a log. An application adds its own:
-
-```c
-static int status_log(const struct tedge_log_request *req,
-                      tedge_write_fn write, void *ctx, void *user_data)
-{
-        char line[128];
-        int n = snprintf(line, sizeof(line), "pump %s\n", pump_state());
-
-        return write(ctx, line, (size_t)n);  /* pass any error back */
-}
-
-tedge_register_log_type("app-status", status_log, NULL);
-```
-
-A log type is a callback, not a file: nothing is stored, nothing needs a
-filesystem, and no log is ever held whole in RAM. The client applies what it
-can of the request's filters (`search_text`, `max_lines`; the date range is
-passed through for readers that keep wall-clock timestamps — its own ring
-does not). **Your reader may be called twice for one request**, once to
-measure the log and once to send it, so produce the same lines both times.
-
-One request sends at most `CONFIG_TEDGE_LOG_UPLOAD_MAX_BYTES` (8 KB by
-default); a longer log is cut with a line saying so, rather than refused.
-The upload runs on its own thread and leaves the connection working.
-
-**Crash dumps.** `CONFIG_TEDGE_COREDUMP` offers the last crash as the log
-type `coredump`, and it appears in the device's supported logs only when a
-dump is actually stored. Download it and read it with Zephyr's own tooling:
-
-```sh
-python3 $ZEPHYR_BASE/scripts/coredump/coredump_serial_log_parser.py \
-        coredump.log core.bin
-# then: coredump_gdbserver.py, and gdb against the ELF of that firmware
-```
-
-Two things your application must get right, because the client cannot:
-
-- **Pick the backend and the dump mode.** They are Kconfig choices:
-  `CONFIG_DEBUG_COREDUMP_BACKEND_FLASH_PARTITION=y` and
-  `CONFIG_DEBUG_COREDUMP_MEMORY_DUMP_MIN=y`. The default mode dumps all of
-  RAM and never fits a small partition.
-- **Give it a partition big enough.** With the minimal mode a dump is the
-  faulting thread's stack plus its registers, so a 4 KB `coredump_partition`
-  holds a crash in a thread with a stack of about 3 KB. A fault in a larger
-  thread is reported as `-ENOMEM` by the backend and no dump is kept.
-
-The dump is erased only once the cloud has it, so a failed upload can be
-requested again.
-
-**The shell command.** `CONFIG_TEDGE_SHELL_COMMAND` runs `c8y_Command`
-against Zephyr's dummy shell backend — in-process, with no listener on the
-network — on its own thread, and returns what it printed.
-
-**It refuses everything by default.** `CONFIG_TEDGE_SHELL_COMMAND_ALLOW_LIST`
-is empty until you set it, for example:
-
-```
-CONFIG_TEDGE_SHELL_COMMAND_ALLOW_LIST="kernel uptime,net iface,tedge diag"
-```
-
-A request must begin with one of those prefixes and may add arguments.
-Anything containing `;`, `|`, `&`, `` ` ``, `$`, `<`, `>` or a newline is
-refused whatever the list says, so an allowed prefix cannot become a doorway
-to a second command. Compiling the feature in is not consent to run
-something: only you know what is safe to expose in your image.
-
-**`help` lists what may run.** Sending `help` (or `?`) answers with the
-allow-list, one command per line, without executing anything — not with
-Zephyr's own `help`, which would name every command in the image, most of
-them refused. A refused command's reason says so too.
-
-Zephyr cannot interrupt a running command. After
-`CONFIG_TEDGE_SHELL_COMMAND_TIMEOUT_S` the operation is reported as failed,
-but the thread stays busy until the command returns and further commands are
-refused meanwhile — so keep commands that can block for ever off the list.
-The result travels in one SmartREST field: newlines become spaces and a long
-answer is cut. A command with a lot to say belongs behind a log type.
-
-**Operations are answered by id.** The client subscribes to
-`devicecontrol/notifications`, where Cumulocity delivers each operation as
-JSON *with its id*, and reports every status with `504`, `505` and `506`,
-which name the operation. The older `501`/`502`/`503` name only a fragment
-and act on the oldest operation in that state, so a single operation left
-EXECUTING by a reset would swallow the result of every operation after it —
-which is exactly what happened on a test device before this changed.
-
-Turn `CONFIG_TEDGE_C8Y_OPERATION_JSON` off for a tenant that does not serve
-that topic; the client then uses `s/ds` and the older statuses.
-
-**Still one operation at a time.** The client runs one operation and queues
-what arrives meanwhile (two deep), because a second file transfer or tunnel
-needs a second TLS session and another thread, which a constrained board
-does not have. With ids this is a resource limit, not a correctness one.
-
-### Parameters
-
-`CONFIG_TEDGE_PARAMETERS` gives an operator a typed set of settings they can
-see and change from the cloud. There is deliberately no configuration-file
-API: a device with no filesystem has nothing to put in a file, and a blob is
-something an operator can neither read nor validate. A change travels inside
-its operation, so this needs no HTTP client and no second TLS session.
-
-An application declares one `const` table and hands it over before
-`tedge_start()`:
-
-```c
-static const struct tedge_parameter pump_params[] = {
-	TEDGE_PARAM_INT("interval_s", 30, 5, 3600, "Seconds between reads"),
-	TEDGE_PARAM_BOOL("auto_mode", true, "Run the pump automatically"),
-	TEDGE_PARAM_ENUM("profile", "normal", ("normal", "quiet", "boost"),
-			 "Operating profile"),
-	TEDGE_PARAM_STRING("site", "", 32, "Where this device is"),
-};
-
-tedge_declare_parameters("pump", pump_params, ARRAY_SIZE(pump_params),
-			 on_parameters_changed, NULL);
-```
-
-The table stays in flash. What costs RAM is one current value per parameter
-(`CONFIG_TEDGE_PARAMETERS_MAX`, 16 by default) plus one heap allocation per
-string, sized by the declaration and made once.
-
-**The set's name is the one name the cloud knows it by** — `"pump"` above is
-the twin fragment, the schema's identifier and the suffix of the operation
-fragment, all three. That is also how one device offers more than one set.
-Because the identifier is global in the tenant's Digital Twin Manager, give
-a real set a name no one else will pick: this repository's applications
-prefix theirs with the device type (`zephyr_modbus_telemetry`,
-`zephyr_modbus_control`), and the client's own set is `zephyr_tedge`. A name
-is at most 39 characters.
-
-**What the cloud sees.** On every connect, and after every accepted change,
-the client publishes the whole set as twin state:
-
-```
-te/device////twin/pump  {"interval_s":30,"auto_mode":true,"profile":"normal","site":""}
-```
-
-Twin data is state rather than an event, so a reboot never leaves the cloud
-showing a value the device is not running.
-
-**What a change looks like.** Cumulocity sends the *whole* set, not just
-what was edited, in a top-level fragment named after it; the
-`c8y_ParameterUpdate_pump` fragment beside it is an empty marker naming the
-set:
-
-```json
-{"id":"214989","description":"Update parameter 'pump'",
- "pump":{"interval_s":60,"auto_mode":true,"profile":"quiet","site":""},
- "c8y_ParameterUpdate":{}, "c8y_ParameterUpdate_pump":{}}
-```
-
-Only values that actually moved are written to flash, so a whole-set send
-costs no more than an edit of one. Values inside the suffixed fragment are
-accepted too, which is the shape you get building an operation by hand
-through the REST API.
-
-**What a bad value does.** The client checks every value in a change
-against the declaration — type, range, length, allowed values — *before* it
-applies any of them, and a single failure leaves every parameter as it was:
-
-```
-505,,"interval_s: 4000 is outside 5..3600"
-```
-
-A half-applied set is how a device ends up in a state nobody can reproduce,
-so it is all or nothing. The refusal names the parameter and the limit it
-broke, and an undeclared name is refused the same way.
-
-**Your hook is the last word.** It is called once, after the values are in
-place, and may still refuse a combination only the application understands:
-
-```c
-static int on_parameters_changed(const char *set, char *reason,
-				 size_t reason_len, void *user_data)
-{
-	if (bad_combination()) {
-		snprintf(reason, reason_len, "the pump is running");
-		return -EBUSY;   /* the client puts the old values back */
-	}
-	return 0;
-}
-```
-
-Read the current values with `tedge_parameter_get_bool()`,
-`tedge_parameter_get_int()` and `tedge_parameter_get_string()` (an enum
-reads as the string it is).
-
-**What survives a reboot.** An accepted value is written to its own settings
-key, `tedge/param//`, and only when it actually changed — an
-operator pressing "save" with nothing altered costs no flash. At startup the
-stored values replace the declared defaults; a stored value whose parameter
-the running firmware no longer declares, or which no longer fits a narrowed
-range, is dropped.
-
-**One value, one type.** A parameter is a bool, an integer, a string or an
-enum — nothing nested and nothing repeated. An application that wants
-structure should encode it in a string and own the parsing.
-
-#### The client's own set
-
-With `CONFIG_TEDGE_PARAMETERS_SELF` (on by default) the client declares a
-set of its own, named `zephyr_tedge`, so a device can be adjusted in the
-field whether or not your application declares anything:
-
-| Parameter | Type | What it changes |
-|---|---|---|
-| `log_level` | enum | how much the client logs — `off`, `err`, `wrn`, `inf`, `dbg` |
-| `health_interval_s` | int | seconds between its own health measurements, 0 to stop them |
-| `required_interval_min` | int | minutes of silence after which Cumulocity calls the device offline — but see the note below |
-| `remote_access` | bool | whether the cloud may open a tunnel at all; a set of its own, `zephyr_tedge_remote_access` |
-
-Each one is present only if the feature behind it is built in: no health,
-no `health_interval_s`. `remote_access` is declared as a separate set, only
-with remote access built, because Cumulocity's Parameters tab sends a whole
-set on every change and a device refuses a name it does not declare: in the
-main set it made every change fail on an image without remote access. **Only settings a running device can honour are
-offered** — nothing that sizes a buffer, a stack or a thread, because those
-are fixed once the image is linked and a value the device quietly ignores is
-worse than no value.
-
-> **`required_interval_min` is create-only as far as the device is
-> concerned.** The device reports it with SmartREST template `117`, and
-> Cumulocity uses that to *create* `c8y_RequiredAvailability`, not to update
-> one that already exists. On a device that has had an availability set —
-> which is any device that has connected once with this client — changing
-> the parameter changes what the device reports and remembers, and the
-> platform keeps judging it by the value already in the cloud. Change that
-> one in the cloud. The parameter is still worth having: it is what a fresh
-> device registers with, and it records the intent.
-
-`log_level` is the one worth knowing about: raising it on a device that is
-misbehaving, from a desk rather than a bench, is most of why this set
-exists. It takes effect immediately with `CONFIG_LOG_RUNTIME_FILTERING`,
-and at the next boot without it. `remote_access` is the other: an operator
-can close the door on a device without reflashing it.
-
-Your own set is declared before the client's, so a name you have already
-taken stays yours.
-
-#### Registering the set in your tenant
-
-The tenant needs the `dtm` and `device-parameter` microservices, and the
-user doing the registering needs `ROLE_DIGITAL_TWIN_DEFINITIONS_CREATE` and
-`ROLE_DIGITAL_TWIN_DEFINITIONS_ADMIN`.
-
-The schema has to match the declaration in the firmware, and writing it
-twice by hand is how the two drift. With `CONFIG_TEDGE_PARAMETERS_SCHEMA`
-the device prints it, generated from the same table it validates against:
-
-```
-uart:~$ tedge params schema pump
-{"identifier":"pump","jsonSchema":{...},"contexts":["asset","event","operation"]}
-```
-
-Paste that into the Digital Twin Manager:
-
-```sh
-c8y api --raw POST /service/dtm/definitions/properties --file schema.json
-```
-
-`contexts` must contain both `asset` and `operation`, or the UI shows the
-values but will not let anyone edit them — which is why the device emits the
-whole registration body rather than a bare schema. Re-registering an
-existing identifier does not work, so a changed declaration means deleting
-it first:
-
-```sh
-c8y api DELETE "/service/dtm/definitions/properties/pump?contexts=asset,event,operation"
-```
-
-`tedge params list` shows what the running image declares. Turn
-`CONFIG_TEDGE_PARAMETERS_SCHEMA` off once the sets are registered; it is
-only needed to produce them.
-
-### Firmware update
-
-`CONFIG_TEDGE_FIRMWARE_UPDATE` needs an MCUboot (sysbuild) build with a
-second slot — the layout this repository's applications already use.
-
-What happens, and what it costs:
-
-| Step | On an ESP32-C6 |
-|---|---|
-| Download into the second slot | ~30 s for 890 KB, while MQTT stays up |
-| The bootloader swaps | ~40 s (~19 s on an S3) — **the device is offline for this** |
-| The new image confirms itself | once it reaches the cloud and your hook agrees |
-| Whole operation | about 105 s |
-
-**Your `firmware_confirm_check` hook decides whether an update sticks.**
-Check what would make the device useless in the field: that your protocol
-server accepted a connection, that a sensor answers, that the peer you
-depend on is reachable. Returning non-zero leaves the image unconfirmed, and
-it is rolled back.
-
-**The version is the application's own string.** The client reports the
-running firmware as `firmware_name` and `firmware_version` from
-`tedge_identity` (by default the application's `APP_VERSION_STRING`), and
-compares the version of a requested install with it — to refuse the version
-already running, and after the reboot to tell a confirmed install from a
-rollback. MCUboot's image header is only used when the application gives no
-version: it holds `MAJOR.MINOR.PATCH` alone, so a pre-release such as
-`0.4.0-rc1` would read back as `0.4.0` and look rolled back. Put exactly the
-reported version in the cloud's firmware repository.
-
-**An image that never confirms rolls itself back.** It resets the device
-after `CONFIG_TEDGE_FIRMWARE_CONFIRM_TIMEOUT_S` (default 900 s) so the
-bootloader can restore the previous image; the restored image then reports
-the failure to the cloud. This only protects against images that carry the
-client: an image that crashes earlier still needs a hardware watchdog.
-
-Progress is published on `te/device////progress/firmware` at QoS 0:
-
-```json
-{"name":"app","version":"1.6.0","phase":"downloading","percent":40,
- "bytes":355866,"total":888011}
-```
-
-Phases are `downloading`, `installing`, `done` and `failed` (with a
-`reason`). The size comes from a `Range: bytes=0-` request, because
-Cumulocity serves binaries chunked and sends no `Content-Length`.
-
-### Certificate renewal
-
-A device whose certificate has expired cannot be reached at all: no
-operations, no remote access, no firmware update. `CONFIG_TEDGE_CERT_RENEWAL`
-keeps that from happening.
-
-- The client reads the expiry from its own certificate and renews through
-  the CA's re-enrollment endpoint once fewer than
-  `CONFIG_TEDGE_CERT_RENEW_BEFORE_DAYS` (default 30) remain. The margin is
-  what covers a device that is offline for a while.
-- The same key is kept; only the certificate changes. The renewal takes
-  about 4 s on an ESP32-C6, and the client reconnects straight away so the
-  new certificate is in use (the old one is still valid at that point).
-- A renewal that fails changes nothing: the device keeps working on its
-  current certificate and tries again hourly.
-- `te/device////twin/tedge_Certificate` carries the expiry on every
-  connect, so certificate health is a query rather than an investigation:
-
-```json
-{"expires":"2027-09-20T08:04:46Z","daysRemaining":364,"renewals":1}
-```
-
-- If renewal keeps failing and fewer than
-  `CONFIG_TEDGE_CERT_RENEW_ALARM_DAYS` (default 7) remain, the device raises
-  a critical `c8y_CertificateExpiring` alarm naming the date, and clears it
-  when a renewal succeeds.
-
-**If a device does miss its window**, its certificate is no longer accepted
-and it has to be onboarded again, with a new registration and one-time
-password. That is why the alarm exists.
-
-### Remote-access targets
-
-A remote-access endpoint can name its target by IP address, by DNS name or
-by mDNS name (`rpi5-d83add9f145a.local:22`). An mDNS name is looked up
-afresh for every session, so the endpoint keeps working when the host's
-DHCP lease moves it. The address a name resolved to is judged by the target
-policy, and the session's open and close events name both:
-`tunnel to rpi5-d83add9f145a.local (192.168.68.84):22 opened`.
-
-| Option | Default | |
-|---|---|---|
-| `CONFIG_TEDGE_REMOTE_ACCESS_MDNS` | `y` | Resolve `.local` targets over mDNS. Off, a `.local` target fails with "mDNS is not built in" |
-| `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS` | 5000 | How long each lookup waits |
-| `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS` | 3 | Lookups before the session fails; `1` turns retrying off |
-| `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS` | 500 | Pause between lookups |
-
-A lookup that times out or hits a transient error is retried; an
-authoritative "no such name" is not. Each failed attempt is logged as a
-warning, and the session's seat is held while it resolves: about 16 s at
-most with the defaults, never more than 30 s (checked at build time).
-
-Any host on the network can answer an mDNS query, so a `.local` name must
-resolve to an address on the device's own subnets, or the device itself,
-under every target policy. An allow-list entry matches the name as
-requested (`rpi5-d83add9f145a.local:22`).
-
-The client sends its own mDNS query rather than using Zephyr's resolver,
-which in Zephyr 4.4.2 cannot look up `.local` names on a device that takes
-DNS from DHCP or runs the mDNS responder
-([`docs/zephyr-mdns-resolver-bugs.md`](docs/zephyr-mdns-resolver-bugs.md)).
-It needs nothing from the application beyond UDP; each lookup holds one
-socket while it runs.
-
-### Security limitations
-
-- The device key is stored in PSA ITS, encrypted with a key derived from the
-  device ID, and is exported into RAM while the TLS credential is registered.
-  It is obfuscated, not protected: use flash encryption for a real
-  deployment.
-- Remote access reaches whatever the target policy allows. Keep the default
-  (the device's own subnets) or an allow-list, and narrow it further from the
-  application.
-
-## Layout
-
-```
-tedge-zephyr/
-├── zephyr/module.yml         module name: tedge
-├── CMakeLists.txt, Kconfig, VERSION
-├── include/tedge/            public headers
-├── src/                      implementation
-├── profiles/                 feature-set overlays (minimal, full)
-├── samples/minimal/          builds with Zephyr and this module only
-├── tests/                    unit tests (native_sim) and Kconfig checks
-├── docs/                     findings worth keeping (upstream bugs, debugging)
-└── scripts/                  checks (self-containment, no logged secrets)
-```
-
-`docs/debugging-against-real-devices.md` is worth reading before chasing a
-failure on hardware: which counters to trust, the two ESP32 allocation
-failures that look alike, and the measurement traps that make a broken
-session look healthy.
-
-This directory is self-contained. Nothing in it may reference files outside
-it; `scripts/check-independence.py` enforces that, so the directory can move
-into its own repository unchanged.
-
-## Version
-
-`VERSION` holds the module's version. It is independent of any application's
-version and is returned by `tedge_version()`.
diff --git a/tedge-zephyr/VERSION b/tedge-zephyr/VERSION
deleted file mode 100644
index 4e379d2..0000000
--- a/tedge-zephyr/VERSION
+++ /dev/null
@@ -1 +0,0 @@
-0.0.2
diff --git a/tedge-zephyr/certs/godaddy-root-g2.pem b/tedge-zephyr/certs/godaddy-root-g2.pem
deleted file mode 100644
index c2b2907..0000000
--- a/tedge-zephyr/certs/godaddy-root-g2.pem
+++ /dev/null
@@ -1,23 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMx
-EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoT
-EUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRp
-ZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIz
-NTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQH
-EwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UE
-AxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIw
-DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKD
-E6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH
-/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7Rnwy
-DfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlRJQVh
-GkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGR
-tDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEA
-AaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE
-FDqahQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmX
-WWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu
-9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTr
-gIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo
-2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO
-LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI
-4uJEvlz36hz1
------END CERTIFICATE-----
diff --git a/tedge-zephyr/docs/debugging-against-real-devices.md b/tedge-zephyr/docs/debugging-against-real-devices.md
deleted file mode 100644
index 3d1e1b9..0000000
--- a/tedge-zephyr/docs/debugging-against-real-devices.md
+++ /dev/null
@@ -1,140 +0,0 @@
-# Debugging against real devices
-
-Notes from chasing real failures on ESP32 hardware, written down because
-every one of them cost hours and several of them cost a confident wrong
-answer first. The worked example throughout is a remote-access tunnel that
-could not carry `htop`; the traps generalise.
-
-The short version: **on this hardware, most of the time lost goes to
-believing a measurement that was never measuring the right thing.**
-
-## Trust the device's own counters, not your stopwatch
-
-The client logs what a tunnel actually moved:
-
-```
-remote access: tunnel to 192.168.68.72:22 ended (the client closed the
-session) after 5 s: 105885 B up, 3418 B down
-```
-
-That is ground truth: bytes the bridge passed, over the tunnel's lifetime.
-
-An SSH transfer timed from the host is not. It includes TCP connect, the
-SSH handshake and authentication — which on a slow link dominate a small
-transfer. Timing `ssh  'dd …'` once reported **0.9 KB/s** for a
-tunnel the device's own log showed running at **21 KB/s**. Two rounds of
-tuning were spent on that phantom.
-
-If you must time from the host, transfer enough to swamp setup (1 MB, not
-100 KB) and compare against the device's figure before believing it.
-
-## Signals that look like liveness and are not
-
-- **`c8y_Availability.lastMessage`.** It tracks particular message types,
-  not connectivity. It read `07:41` for a device observed connecting at
-  `15:08`. A working image was reverted on the strength of it.
-- **`c8y_Connection.status`.** Can read `CONNECTED` for a device that has
-  been silent for minutes.
-
-What does work: a recent **measurement** (real traffic), or the console.
-
-## The console is authoritative — and on some boards, destructive
-
-On the **ESP32-C6** the console rides USB-Serial-JTAG, and opening it from
-macOS **resets the chip** (`app_liveness: … esp_reason 11`, `ESP_RST_USB`).
-So:
-
-- Attaching mid-session kills the session you are debugging. Symptoms
-  reported as "the board restarts when I use htop" were partly the console
-  being attached underneath.
-- **Attach before the test starts and hold the port open** for its whole
-  duration. Take the reset up front.
-- The boot counter in that same line (`boot 248`) tells you how many resets
-  a board has taken — useful for noticing you are the cause.
-
-## Two ESP32 allocation failures that are easily confused
-
-```
-esp32_wifi: Failed to allocate net buffer          ← RX net_buf pool. Fatal.
-esp32c6_wifi_adapter: memory allocation failed     ← kernel heap. Often survivable.
-```
-
-They come from different pools and need different fixes. Chasing the second
-while the first was the killer cost a full cycle of build-flash-test.
-
-- **`Failed to allocate net buffer`** is
-  `drivers/wifi/esp32`, `net_pkt_rx_alloc_with_buffer(..., K_MSEC(100))`.
-  It means `NET_BUF_RX_COUNT` / `NET_PKT_RX_COUNT` are exhausted. Frames
-  are dropped, TCP retransmits, a TLS socket eventually returns `-116`
-  (`ETIMEDOUT`) and whatever it was carrying dies.
-- **`memory allocation failed`** is `k_malloc` from
-  `CONFIG_HEAP_MEM_POOL_SIZE`. Two consumers share it: the Wi-Fi adapter,
-  and `websocket_send_msg()`, which allocates a masking buffer **per frame**
-  (masking is mandatory client→server). Its `-ENOMEM` surfaces as the
-  bridge's "sending to the cloud failed".
-
-### Burst depth, not bandwidth
-
-`CONFIG_NET_BUF_DATA_SIZE` defaults to **128 bytes**, so one 1500-byte
-frame costs **12 buffers**. `NET_BUF_RX_COUNT=64` is therefore about **five
-full-size packets in flight** — fine for MQTT, nowhere near enough for a
-terminal repainting through a tunnel.
-
-The tell: **it fails at a low byte count**. A session that dies after
-12.9 KB while a 1 MB bulk transfer succeeds is not short of bandwidth; it
-cannot absorb a burst. Size for ~16 full packets before suspecting
-throughput.
-
-## Reproducing an interactive session
-
-A TUI is not a pipe, and the difference decides the result.
-
-- **Piping through `cat`/`tee` makes ncurses emit full repaints.** A real
-  terminal gets **incremental, cursor-addressed** updates, which are far
-  less tolerant of loss. A piped test passes while the real session fails.
-  If the bug is interactive, the test must use a PTY.
-- **`pty.fork()` ignores `LINES`/`COLUMNS`.** Without a `TIOCSWINSZ` ioctl
-  the remote runs at 80×24 and moves a quarter of the data a 200×50 window
-  does. Set the size explicitly and match the size the human is using —
-  frame cost scales with area.
-- **Measure repaints over time, not bytes.** "One screen then frozen" and
-  "updating happily" move similar amounts of data in the first seconds.
-  Count updates and look at the gaps between them; htop's own advancing
-  `TIME+` field is a convenient per-refresh marker.
-- **Do not grep a terminal capture with `strings`.** It splits at escape
-  sequences, so `Tasks:` appears to have no value when it does. That looked
-  like packet loss for an hour. Render the capture, or match on the raw
-  bytes including the escapes.
-
-## Method
-
-1. **Get the device's own account first.** Console attached before the
-   test, plus the failure counters above. Most of this page exists because
-   host-side inference was wrong and the device had been saying so.
-2. **Change one thing.** Two tunings landed together once and the result
-   was unreadable.
-3. **Let the human drive the real test.** A manual session found in one
-   attempt what four increasingly elaborate harnesses had missed, because
-   the harnesses kept accidentally testing something easier.
-4. **Prefer the board with headroom when isolating.** If a failure appears
-   on both an S3 (PSRAM, ~78 KB spare) and a C6 (no PSRAM), it is not about
-   memory, and every memory theory can be dropped at once.
-5. **Check whether a lever does anything before tuning it.** Reducing
-   `MBEDTLS_SSL_MAX_CONTENT_LEN` changed **zero** bytes of static RAM — the
-   mbedTLS heap is a fixed `.bss` array — yet it is the obvious knob and
-   two builds went into it.
-
-## Levers, and whether they help
-
-| Lever | Effect |
-|---|---|
-| `NET_BUF_DATA_SIZE`, `NET_BUF_RX_COUNT` | **the fix** for RX burst starvation |
-| `HEAP_MEM_POOL_SIZE` | needed once a tunnel shares the radio with MQTT |
-| bridge drain loop (poll only when idle) | 2 KB/s → 27 KB/s; without it nothing else matters |
-| `TCP_NODELAY` | correct, and Zephyr defaults Nagle **on** — but not the cause here |
-| `MBEDTLS_SSL_MAX_CONTENT_LEN` | no static effect; only per-session heap use |
-| throttling the bridge | made it **worse** (12 allocation failures vs 2) |
-
-See also `tedge-zephyr/profiles/` for per-board sizing and
-`docs/zephyr-http-chunked-body-bug.md` for an upstream bug found the same
-way.
diff --git a/tedge-zephyr/docs/zephyr-http-chunked-body-bug.md b/tedge-zephyr/docs/zephyr-http-chunked-body-bug.md
deleted file mode 100644
index 65f695b..0000000
--- a/tedge-zephyr/docs/zephyr-http-chunked-body-bug.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# Zephyr's HTTP client mis-reports chunked response bodies
-
-A report prepared for upstream Zephyr (`zephyrproject-rtos/zephyr`). It is
-kept here because the client works around the bug, and the workaround should
-be removed once it is fixed.
-
-- **Affects:** Zephyr 4.4.2 (seen), `subsys/net/lib/http/http_client.c`
-- **Severity:** silent data corruption for any chunked download
-- **Workaround in this module:** `src/tedge_http_download.c` writes every
-  byte from the parser's `on_body` callback and ignores
-  `rsp->body_frag_start` / `rsp->body_frag_len`.
-
-## What happens
-
-With `Transfer-Encoding: chunked`, one receive buffer often holds several
-body segments separated by chunk-size lines:
-
-```
-\r\n  \r\n \r\n  \r\n ...
-```
-
-The HTTP client calls the parser, which reports each segment through
-`on_body`, and the client records the segment in the `http_response` it
-passes to the application's response callback. It keeps **the first
-segment's `body_frag_start`** but overwrites **`body_frag_len` with the last
-segment's length**. An application that copies
-`body_frag_start[0 .. body_frag_len)` therefore stores the wrong bytes: it
-reads from the first segment's offset for the length of the last one,
-including the chunk-size lines that separate them.
-
-## How it showed up
-
-Downloading a ~900 KB firmware image from Cumulocity IoT, which serves
-binaries chunked, into an MCUboot slot on an ESP32-C6. The stored image was
-corrupt in a way that depended on how the segments fell across receive
-buffers. MCUboot rejected it at the signature check and booted the previous
-image, so the failure was safe but silent until the image was inspected.
-
-## Reproduction
-
-1. Serve a file larger than the receive buffer with
-   `Transfer-Encoding: chunked` (any HTTP server; Cumulocity's
-   `/inventory/binaries/` does this).
-2. Fetch it with `http_client_req()` and a `recv_buf` of 1 KB.
-3. In the response callback, append `body_frag_start[0 .. body_frag_len)` to
-   a file for every call.
-4. Compare with the original: the copy differs, and usually contains ASCII
-   chunk-size lines.
-
-Fetching the same file with `Content-Length` instead of chunked encoding
-produces a correct copy, which isolates the chunked path.
-
-## Suggested fix
-
-Report each body segment separately (call the response callback per
-segment), or accumulate the segments so that `body_frag_start` and
-`body_frag_len` describe one contiguous run. Until then, the
-parser's `on_body` callback is the only reliable source of body bytes, and
-that is worth saying in the API documentation.
diff --git a/tedge-zephyr/docs/zephyr-mdns-resolver-bugs.md b/tedge-zephyr/docs/zephyr-mdns-resolver-bugs.md
deleted file mode 100644
index 2745672..0000000
--- a/tedge-zephyr/docs/zephyr-mdns-resolver-bugs.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# Zephyr's resolver cannot look up `.local` names on a typical device
-
-A report prepared for upstream Zephyr (`zephyrproject-rtos/zephyr`). It is
-kept here because the client works around it, and the workaround should be
-removed once it is fixed.
-
-- **Affects:** Zephyr 4.4.2 (seen), `subsys/net/lib/dns/resolve.c` and
-  `dispatcher.c`
-- **Severity:** `.local` lookups never succeed on a device that takes DNS
-  from DHCP and also runs the mDNS responder
-- **Workaround in this module:** `src/tedge_remote_access.c`
-  (`mdns_once()`) sends its own one-shot mDNS query for a remote-access
-  target and reads the answer itself; Zephyr's resolver is used only for
-  unicast names.
-
-Seen on an ESP32-S3-DevKitC-1, `apps/modbus-server` with the `full`
-profile, 2026-10-07, with a Raspberry Pi (systemd-resolved) as the host
-being looked up. Both problems below have to be fixed for a `.local`
-lookup to work; the first hides the second.
-
-## 1. With DNS from DHCP there is no mDNS server
-
-`dns_resolve_init_default()` adds the mDNS server (224.0.0.251:5353) to
-the default context only inside `#if defined(CONFIG_DNS_SERVER_IP_ADDRESSES)`.
-A device that gets its DNS server from DHCP has no static servers, so the
-default context never gets an mDNS server, whatever `CONFIG_MDNS_RESOLVER`
-says. `dns_resolve_name_internal()` then finds no server for the `.local`
-query and returns `-ENOENT`, which `getaddrinfo()` reports as
-`DNS_EAI_SYSTEM` (-11).
-
-```
-uart:~$ net dns
-DNS servers:
-	192.168.178.1:53 via wlan0 (DHCP)
-uart:~$ net dns rpi5-d83add9f145a.local
-Cannot resolve 'rpi5-d83add9f145a.local' (-2)
-```
-
-Setting `CONFIG_DNS_SERVER_IP_ADDRESSES=y` with no servers adds the mDNS
-server, but in **slot 0**: the empty static entry is skipped, and DHCP's
-server lands in slot 1. The resolver keeps `.local` queries off unicast
-servers, but not the reverse: every other name goes to the first server
-with a socket. Ordinary lookups (`pool.ntp.org`) then go to
-224.0.0.251:5353 and time out, and the device cannot connect. Zephyr's own
-default list puts mDNS *after* the configured servers, which is why this
-only shows up when that list is empty.
-
-Adding the mDNS server after DHCP has configured its own
-(`dns_resolve_reconfigure()` with `"224.0.0.251:5353"`, with
-`DNS_RECONFIGURE_CLEANUP` off) keeps the order and fixes this part.
-
-(The `NET_DBG` in the `servers_sa` branch of `dns_resolve_init_locked()`
-also prints `ctx->servers[i]` where it means `ctx->servers[idx]`, so the
-DHCP server is logged with the mDNS flag of slot 0. Misleading while
-debugging this.)
-
-## 2. With the mDNS responder running, the answers are never read
-
-When the application runs `MDNS_RESPONDER`, the responder owns UDP 5353.
-`register_dispatcher()` finds that entry for the resolver's mDNS socket
-and **pairs** the two: the resolver socket is neither bound nor registered
-with the socket service. The resolver then sends its query from that
-unbound socket, so from an ephemeral port. RFC 6762 section 6.7 makes that
-a "legacy unicast" query, which responders answer by unicast to the
-source port; the paired design expects answers on 5353. Nothing reads the
-ephemeral port, and every lookup times out.
-
-A capture on the host being looked up shows the exchange working on the
-wire:
-
-```
-192.168.68.67.39717 > 224.0.0.251.5353: 0+ A (QM)? rpi5-d83add9f145a.local. (41)
-192.168.68.84.5353 > 192.168.68.67.39717: 0*- 1/0/0 A 192.168.68.84 (57)
-```
-
-and the device logs `Query timeout DNS req 0` two seconds later.
-
-## Suggested fixes
-
-1. Add the mDNS (and LLMNR) servers in `dns_resolve_init_default()`
-   whether or not static servers are configured, after them; and skip
-   mDNS servers when choosing a server for a non-`.local` name.
-2. When pairing a resolver socket with a responder, either send the query
-   from the responder's 5353 socket (so answers are multicast and reach
-   it), or also bind and poll the resolver's own socket for unicast
-   answers.
-
-## Removing the workaround
-
-Once both are fixed, `mdns_once()` and the packet helpers in
-`src/tedge_ra_target.c` can go, and `.local` names can take the same
-`dns_get_addr_info()` path as other names, with
-`CONFIG_TEDGE_REMOTE_ACCESS_MDNS` selecting `MDNS_RESOLVER`.
diff --git a/tedge-zephyr/include/tedge/mbedtls_user_config.h b/tedge-zephyr/include/tedge/mbedtls_user_config.h
deleted file mode 100644
index c0e3674..0000000
--- a/tedge-zephyr/include/tedge/mbedtls_user_config.h
+++ /dev/null
@@ -1,47 +0,0 @@
-/* SPDX-License-Identifier: Apache-2.0
- *
- * mbedTLS user configuration for images built with the tedge-zephyr
- * profiles. Zephyr includes it after its own mbedTLS config
- * (CONFIG_MBEDTLS_USER_CONFIG_FILE="tedge/mbedtls_user_config.h"), so it
- * can override what that config derived from Kconfig.
- *
- * Split TLS record buffers. Zephyr sizes both the input and the output
- * record buffer from CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN. The client only
- * ever *sends* small records: MQTT publishes, HTTP request headers, the
- * chunks a producer writes to an upload, WebSocket frames. Only received
- * records need the full size the server may use (16 KB from Cumulocity's
- * MQTT service and its binary downloads). A 4 KB output buffer therefore
- * costs nothing in function - a larger write is split into several
- * records, which is ordinary TLS and transparent to MQTT, HTTP and
- * WebSocket - and saves per concurrent session:
- *
- *   16 KB records: 12 KB    8 KB records: 4 KB
- *
- * which comes off the peak the mbedTLS heap has to hold. The heap sizes
- * in the profiles and board settings are measured with this in place
- * (reduce-memory-footprint, tier 2).
- *
- * One interaction to keep straight: with the default
- * CONFIG_NET_SOCKETS_TLS_SET_MAX_FRAGMENT_LENGTH, Zephyr advertises the
- * *smaller* of the two buffers as the RFC 6066 maximum fragment length
- * whenever it is below 16 KB, so this header alone would also ask the
- * server for 4 KB records. The 16 KB profiles therefore switch that
- * option off (they never advertised one, so the server's records are
- * unchanged), and a board that lowers CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN
- * below 16 KB keeps it on, as the ESP32-WROOM-32 settings do: there the
- * server must be told to stay within the input buffer.
- */
-#ifndef TEDGE_MBEDTLS_USER_CONFIG_H_
-#define TEDGE_MBEDTLS_USER_CONFIG_H_
-
-#undef MBEDTLS_SSL_OUT_CONTENT_LEN
-#define MBEDTLS_SSL_OUT_CONTENT_LEN 4096
-
-/* These devices are TLS clients only, but Zephyr's mbedTLS config defines
- * the server role unconditionally and the linker keeps its handshake
- * (mbedtls_ssl_handshake_server_step, 3,952 B, plus the SNI parser) because
- * the socket layer references it. Dropping the role removes code that is
- * never reached (reduce-memory-footprint, tier 3). */
-#undef MBEDTLS_SSL_SRV_C
-
-#endif /* TEDGE_MBEDTLS_USER_CONFIG_H_ */
diff --git a/tedge-zephyr/include/tedge/tedge.h b/tedge-zephyr/include/tedge/tedge.h
deleted file mode 100644
index bbf6c6c..0000000
--- a/tedge-zephyr/include/tedge/tedge.h
+++ /dev/null
@@ -1,581 +0,0 @@
-/* SPDX-License-Identifier: Apache-2.0 */
-
-/**
- * @file
- * @brief tedge-zephyr public API: thin-edge.io device management for Zephyr.
- *
- * @warning UNSTABLE while the features are being built. The lifecycle,
- * onboarding, twin and operation calls below are implemented (change
- * c8y-direct-core) and are meant to stay as they are; the ones marked
- * "not implemented yet" return -ENOTSUP until their change lands, and may
- * still change shape.
- *
- * Division of responsibilities:
- * - The application owns the network interface, the device identity, what
- *   telemetry to send, and the task watchdog.
- * - The client owns its threads, a bounded heap, the settings subtree
- *   "tedge/" and TLS credential tags from CONFIG_TEDGE_TLS_TAG_BASE.
- *
- * Unless stated otherwise, functions return 0 on success or a negative errno.
- */
-
-#ifndef TEDGE_TEDGE_H_
-#define TEDGE_TEDGE_H_
-
-#include  /* struct sockaddr, for remote-access targets */
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/** Set while the API is an outline that may change without notice. */
-#define TEDGE_API_UNSTABLE 1
-
-/** @brief The module's version (the contents of its VERSION file). */
-const char *tedge_version(void);
-
-/* ------------------------------------------------------------------------ */
-/* Lifecycle and state                                                       */
-/* ------------------------------------------------------------------------ */
-
-/** @brief Client states, reported through tedge_hooks::on_state. */
-enum tedge_state {
-	TEDGE_STATE_STOPPED,
-	/** No IPv4 address yet; waiting for the application's network. */
-	TEDGE_STATE_WAITING_NETWORK,
-	/** Waiting for the clock (SNTP), needed to validate certificates. */
-	TEDGE_STATE_WAITING_TIME,
-	/** Waiting for the operator to register the device (CA onboarding). */
-	TEDGE_STATE_AWAITING_REGISTRATION,
-	TEDGE_STATE_CONNECTING,
-	TEDGE_STATE_CONNECTED,
-	/** A firmware update is downloading or waiting for its test boot. */
-	TEDGE_STATE_UPDATING,
-};
-
-/**
- * @brief Device identity. Any NULL field takes the documented default.
- */
-struct tedge_identity {
-	/** Default: "-". */
-	const char *external_id;
-	/** Default: the external ID. */
-	const char *name;
-	/** Default: CONFIG_TEDGE_DEVICE_TYPE. */
-	const char *type;
-	/** Firmware reported to the cloud. Default: the application's name. */
-	const char *firmware_name;
-	/** Default: the application's version (APP_VERSION_STRING). */
-	const char *firmware_version;
-};
-
-/**
- * @brief Remote-access target, passed to tedge_hooks::remote_access_allow.
- */
-struct tedge_remote_target {
-	const struct sockaddr *addr;
-	uint16_t port;
-};
-
-/**
- * @brief Callbacks into the application. Every member is optional.
- *
- * Hooks run on the client's thread. They must return promptly and must not
- * call back into the client.
- */
-struct tedge_hooks {
-	/** State changed, for example to drive a status LED. */
-	void (*on_state)(enum tedge_state state, void *user_data);
-
-	/**
-	 * The cloud asked for a restart. Prepare for it and return 0 to allow
-	 * it, or return a negative errno and write a reason to veto it.
-	 * The client never reboots without calling this first.
-	 */
-	int (*restart_request)(char *reason, size_t reason_len, void *user_data);
-
-	/**
-	 * The new firmware image is running and connected. Return 0 if the
-	 * application is healthy, so the image can be confirmed. Any other
-	 * value leaves it unconfirmed, and MCUboot reverts it on the next reset.
-	 * Requires CONFIG_TEDGE_FIRMWARE_UPDATE.
-	 */
-	int (*firmware_confirm_check)(void *user_data);
-
-	/**
-	 * Narrows the Kconfig remote-access target policy. Return true to
-	 * allow a target that the policy already allows. Requires
-	 * CONFIG_TEDGE_REMOTE_ACCESS.
-	 */
-	bool (*remote_access_allow)(const struct tedge_remote_target *target,
-				    void *user_data);
-
-	/**
-	 * Reset the device, called for a restart the application allowed.
-	 * When it is NULL the client uses the platform's own full-system
-	 * reset. It must not return.
-	 */
-	void (*reset)(void *user_data);
-
-	/** Called periodically from each client thread, for a task watchdog. */
-	void (*progress)(void *user_data);
-
-	void *user_data;
-};
-
-/**
- * @brief Initialise the client. Call once, before tedge_start().
- *
- * @param identity Device identity, or NULL for all defaults. The client
- *                 copies it.
- * @param hooks    Application callbacks, or NULL. The client keeps the
- *                 pointer, so it must stay valid.
- */
-int tedge_init(const struct tedge_identity *identity,
-	       const struct tedge_hooks *hooks);
-
-/**
- * @brief Start the client thread. It waits for the network, then connects.
- *
- * It may be called before the network is up.
- */
-int tedge_start(void);
-
-/** @brief Disconnect and stop the client thread. */
-int tedge_stop(void);
-
-/** @brief Current state. */
-enum tedge_state tedge_get_state(void);
-
-/* ------------------------------------------------------------------------ */
-/* Onboarding (CONFIG_TEDGE_AUTH_C8Y_CA)                                     */
-/* ------------------------------------------------------------------------ */
-
-/**
- * @brief Override the Cumulocity tenant host (default: CONFIG_TEDGE_C8Y_URL).
- *
- * Stored in settings, so it survives a reboot. Call before tedge_start().
- */
-int tedge_set_c8y_url(const char *host);
-
-/**
- * @brief Use a one-time password issued outside the device.
- *
- * For devices registered with Cumulocity by someone else — typically a
- * zero-touch provisioning server that has already registered the external ID
- * with this password. The client then enrolls with it on the first attempt
- * instead of generating its own, so no registration URL needs to be shown.
- *
- * Stored in settings. Call before tedge_start(), with the external ID the
- * password was issued for set through tedge_init(). The client never logs it
- * above debug level and deletes it once a certificate is issued.
- *
- * Supplying a password re-onboards the device: a certificate from an earlier
- * enrollment is discarded (the key pair is kept), since it belongs to the old
- * registration and another tenant would refuse it. Call it only when a new
- * password has actually been issued, not on every boot.
- *
- * @return 0; -EINVAL for an empty password, one longer than 64 characters, or
- *         one with characters outside printable ASCII; -ENOTSUP with
- *         bootstrap authentication.
- */
-int tedge_set_enroll_otp(const char *password);
-
-/**
- * @brief Set the bootstrap credentials (CONFIG_TEDGE_AUTH_BOOTSTRAP), when
- * they are not built in. Call before tedge_start(). Not stored in settings.
- */
-int tedge_set_bootstrap_credentials(const char *user, const char *password);
-
-/**
- * @brief Copy the registration URL (external ID and one-time password
- * pre-filled) into @p buf, so the application can show it (console,
- * display, provisioning result).
- *
- * @return URL length, -ENOENT if the device is already enrolled, -EACCES
- *         when the password was supplied through tedge_set_enroll_otp():
- *         whoever issued it has registered the device already, and it must
- *         not be shown.
- */
-int tedge_registration_url(char *buf, size_t len);
-
-/* ------------------------------------------------------------------------ */
-/* Twin data                                                                 */
-/* ------------------------------------------------------------------------ */
-
-/**
- * @brief Publish @p json as the twin fragment @p fragment.
- *
- * The client keeps the value and republishes it after every reconnect, so
- * a fragment is never left stale by a reboot. @p json must be a JSON object
- * or value without the fragment name, e.g. "{\"state\":\"ok\"}".
- * Passing NULL forgets the fragment.
- *
- * @return 0 when stored and queued, -ENOMEM if the client's heap or its
- *         fragment table is full.
- */
-int tedge_publish_twin(const char *fragment, const char *json);
-
-/* ------------------------------------------------------------------------ */
-/* Telemetry (CONFIG_TEDGE_TELEMETRY)                                        */
-/*                                                                           */
-/* Every call builds its message there and then and returns: nothing waits   */
-/* on the network, and any thread may call. Messages the client cannot send  */
-/* yet wait in a small RAM buffer (CONFIG_TEDGE_TELEMETRY_BUFFER_BYTES).     */
-/* Without the feature the calls are still there and return -ENOTSUP, so an  */
-/* application builds either way.                                            */
-/* ------------------------------------------------------------------------ */
-
-/** @brief One value of a measurement. */
-struct tedge_measurement_value {
-	/** Series name, e.g. "temperature". */
-	const char *series;
-	double value;
-	/** Unit, e.g. "C". May be NULL. */
-	const char *unit;
-};
-
-enum tedge_alarm_severity {
-	TEDGE_ALARM_CRITICAL,
-	TEDGE_ALARM_MAJOR,
-	TEDGE_ALARM_MINOR,
-	TEDGE_ALARM_WARNING,
-};
-
-#if defined(CONFIG_TEDGE) && !defined(CONFIG_TEDGE_TELEMETRY)
-/* Telemetry is not built in: the calls exist so an application compiles,
- * and cost one instruction each. */
-static inline int tedge_publish_measurement(const char *type,
-					    const struct tedge_measurement_value *values,
-					    size_t count, int64_t timestamp_ms)
-{
-	ARG_UNUSED(type); ARG_UNUSED(values); ARG_UNUSED(count);
-	ARG_UNUSED(timestamp_ms);
-	return -ENOTSUP;
-}
-
-static inline int tedge_publish_event(const char *type, const char *text,
-				      int64_t timestamp_ms)
-{
-	ARG_UNUSED(type); ARG_UNUSED(text); ARG_UNUSED(timestamp_ms);
-	return -ENOTSUP;
-}
-
-static inline int tedge_raise_alarm(const char *type,
-				    enum tedge_alarm_severity severity,
-				    const char *text)
-{
-	ARG_UNUSED(type); ARG_UNUSED(severity); ARG_UNUSED(text);
-	return -ENOTSUP;
-}
-
-static inline int tedge_clear_alarm(const char *type)
-{
-	ARG_UNUSED(type);
-	return -ENOTSUP;
-}
-
-static inline uint32_t tedge_telemetry_dropped(void)
-{
-	return 0;
-}
-#else
-/**
- * @brief Publish a measurement of @p type made of @p count values.
- *
- * @param timestamp_ms Unix time in ms, or 0 for "now".
- * @return 0 when queued, -ENOMEM if the client's buffer is full.
- */
-int tedge_publish_measurement(const char *type,
-			      const struct tedge_measurement_value *values,
-			      size_t count, int64_t timestamp_ms);
-
-/** @brief Publish an event. */
-int tedge_publish_event(const char *type, const char *text,
-			int64_t timestamp_ms);
-
-/** @brief Raise (or update) the alarm of @p type. */
-int tedge_raise_alarm(const char *type, enum tedge_alarm_severity severity,
-		      const char *text);
-
-/** @brief Clear the alarm of @p type. */
-int tedge_clear_alarm(const char *type);
-
-/**
- * @brief How many messages the client has dropped because its buffer was
- * full, since boot. The client reports this in its health measurement; an
- * application that must not lose data can watch it too.
- */
-uint32_t tedge_telemetry_dropped(void);
-#endif /* CONFIG_TEDGE && !CONFIG_TEDGE_TELEMETRY */
-
-/* ------------------------------------------------------------------------ */
-/* Custom operations                                                         */
-/* ------------------------------------------------------------------------ */
-
-/** Opaque handle for an operation in progress. */
-struct tedge_operation;
-
-/**
- * @brief Handler for an application-defined operation.
- *
- * Called on the client's thread with the operation already marked as
- * executing. Finish it with tedge_operation_succeed() or
- * tedge_operation_fail(), now or later from another thread.
- */
-typedef void (*tedge_operation_handler_t)(struct tedge_operation *op,
-					  void *user_data);
-
-/**
- * @brief Register an operation the application implements.
- *
- * The operation is advertised as supported. @p name is the cloud's operation
- * name (for Cumulocity, the fragment, e.g. "c8y_Command").
- */
-int tedge_register_operation(const char *name,
-			     tedge_operation_handler_t handler,
-			     void *user_data);
-
-/** @brief The operation's raw payload (SmartREST CSV or JSON). */
-const char *tedge_operation_payload(const struct tedge_operation *op);
-
-/** @brief Report success, with an optional result string. */
-int tedge_operation_succeed(struct tedge_operation *op, const char *result);
-
-/** @brief Report failure with a reason. */
-int tedge_operation_fail(struct tedge_operation *op, const char *reason);
-
-/* ------------------------------------------------------------------------ */
-/* Log types (CONFIG_TEDGE_LOG_UPLOAD)                                       */
-/*                                                                           */
-/* A log type is a callback, not a file: the client asks the application to  */
-/* produce the log when the cloud requests it, and the application writes it */
-/* through the sink it is given. Nothing needs a filesystem, and no log is   */
-/* ever held whole in RAM.                                                   */
-/* ------------------------------------------------------------------------ */
-
-/**
- * @brief Sink the application writes file contents into. It returns 0, or
- * a negative errno that the application must pass back to abort.
- */
-typedef int (*tedge_write_fn)(void *ctx, const void *data, size_t len);
-
-/** @brief Log request filters (0 or NULL = unset). */
-struct tedge_log_request {
-	int64_t date_from_ms;
-	int64_t date_to_ms;
-	const char *search_text;
-	uint32_t max_lines;
-};
-
-/** @brief Produces a log of one type by writing it through @p write. */
-typedef int (*tedge_log_reader_t)(const struct tedge_log_request *req,
-				  tedge_write_fn write, void *ctx,
-				  void *user_data);
-
-#if defined(CONFIG_TEDGE) && !defined(CONFIG_TEDGE_LOG_UPLOAD)
-/* Log upload is not built in: the call exists so an application compiles. */
-static inline int tedge_register_log_type(const char *type,
-					  tedge_log_reader_t reader,
-					  void *user_data)
-{
-	ARG_UNUSED(type); ARG_UNUSED(reader); ARG_UNUSED(user_data);
-	return -ENOTSUP;
-}
-#else
-/**
- * @brief Add a log type to the ones the device offers.
- *
- * The reader is called on the client's upload thread, possibly twice for one
- * request (once to measure, once to send), so it must produce the same log
- * both times as far as it can.
- *
- * @return 0, -ENOSPC when the client has no free log-type slot, or -ENOTSUP
- *         when log upload is not built in.
- */
-int tedge_register_log_type(const char *type, tedge_log_reader_t reader,
-			    void *user_data);
-#endif /* CONFIG_TEDGE && !CONFIG_TEDGE_LOG_UPLOAD */
-
-/* ------------------------------------------------------------------------ */
-/* Parameters (CONFIG_TEDGE_PARAMETERS)                                      */
-/*                                                                           */
-/* The settings an operator may change from the cloud. The application       */
-/* declares them once, as a const table: a name, a type, a default and the   */
-/* limits of what the value may hold. The client stores them, validates      */
-/* every change against the declaration, reports the current values as twin  */
-/* state, and tells the application when a change was accepted.              */
-/*                                                                           */
-/* There is deliberately no configuration-file API: no snapshot reader, no   */
-/* chunked writer, no upload and no download. A device with no filesystem    */
-/* has nothing to put in a file, and a blob is something an operator can     */
-/* neither read nor validate. See openspec c8y-direct-parameters.            */
-/* ------------------------------------------------------------------------ */
-
-/** @brief What a parameter holds. */
-enum tedge_param_type {
-	TEDGE_PARAM_TYPE_BOOL,
-	TEDGE_PARAM_TYPE_INT,
-	TEDGE_PARAM_TYPE_STRING,
-	/** A string, restricted to a fixed list of allowed values. */
-	TEDGE_PARAM_TYPE_ENUM,
-};
-
-/**
- * @brief One declared parameter. Build these with the TEDGE_PARAM_* macros
- * rather than by hand: they put each value in the right union member.
- */
-struct tedge_parameter {
-	const char *name;
-	const char *description;
-	enum tedge_param_type type;
-	union {
-		bool b;
-		int32_t i;
-		const char *s;
-	} def;
-	/** TEDGE_PARAM_TYPE_INT: the inclusive range. */
-	int32_t min;
-	int32_t max;
-	/** TEDGE_PARAM_TYPE_STRING: the longest value accepted, excluding the NUL. */
-	uint16_t max_len;
-	/** TEDGE_PARAM_TYPE_ENUM: the allowed values, NULL-terminated. */
-	const char *const *allowed;
-};
-
-/** @cond INTERNAL_HIDDEN */
-#define TEDGE_PARAM_LIST(...) __VA_ARGS__
-/** @endcond */
-
-/** @brief Declare a boolean parameter. */
-#define TEDGE_PARAM_BOOL(_name, _default, _desc)                              \
-	{                                                                      \
-		.name = (_name), .description = (_desc),                       \
-		.type = TEDGE_PARAM_TYPE_BOOL, .def.b = (_default),            \
-	}
-
-/** @brief Declare an integer parameter, accepted within [@p _min, @p _max]. */
-#define TEDGE_PARAM_INT(_name, _default, _min, _max, _desc)                   \
-	{                                                                      \
-		.name = (_name), .description = (_desc),                       \
-		.type = TEDGE_PARAM_TYPE_INT, .def.i = (_default),             \
-		.min = (_min), .max = (_max),                                  \
-	}
-
-/** @brief Declare a string parameter of at most @p _max_len characters. */
-#define TEDGE_PARAM_STRING(_name, _default, _max_len, _desc)                  \
-	{                                                                      \
-		.name = (_name), .description = (_desc),                       \
-		.type = TEDGE_PARAM_TYPE_STRING, .def.s = (_default),          \
-		.max_len = (_max_len),                                         \
-	}
-
-/**
- * @brief Declare an enumerated parameter.
- *
- * @p _values is a parenthesised list of string literals, and @p _default
- * must be one of them:
- *
- *     TEDGE_PARAM_ENUM("profile", "normal", ("normal", "quiet", "boost"),
- *                      "Operating profile")
- */
-#define TEDGE_PARAM_ENUM(_name, _default, _values, _desc)                     \
-	{                                                                      \
-		.name = (_name), .description = (_desc),                       \
-		.type = TEDGE_PARAM_TYPE_ENUM, .def.s = (_default),            \
-		.allowed = (const char *const[]){ TEDGE_PARAM_LIST _values,    \
-						  NULL },                      \
-	}
-
-/**
- * @brief Called once after a change has been validated and stored.
- *
- * Read the new values with tedge_parameter_get_*(). Return 0 to accept the
- * change, or a negative errno and a reason to refuse it — the client then
- * puts back the values the device was running and fails the operation with
- * the reason. It runs on the client's thread, so it must return promptly.
- */
-typedef int (*tedge_parameters_changed_t)(const char *set, char *reason,
-					  size_t reason_len, void *user_data);
-
-#if defined(CONFIG_TEDGE) && !defined(CONFIG_TEDGE_PARAMETERS)
-/* Parameters are not built in: the calls exist so an application compiles. */
-static inline int tedge_declare_parameters(const char *set,
-					   const struct tedge_parameter *params,
-					   size_t count,
-					   tedge_parameters_changed_t on_change,
-					   void *user_data)
-{
-	ARG_UNUSED(set); ARG_UNUSED(params); ARG_UNUSED(count);
-	ARG_UNUSED(on_change); ARG_UNUSED(user_data);
-	return -ENOTSUP;
-}
-
-static inline int tedge_parameter_get_bool(const char *set, const char *name,
-					   bool *out)
-{
-	ARG_UNUSED(set); ARG_UNUSED(name); ARG_UNUSED(out);
-	return -ENOTSUP;
-}
-
-static inline int tedge_parameter_get_int(const char *set, const char *name,
-					  int32_t *out)
-{
-	ARG_UNUSED(set); ARG_UNUSED(name); ARG_UNUSED(out);
-	return -ENOTSUP;
-}
-
-static inline int tedge_parameter_get_string(const char *set, const char *name,
-					     char *out, size_t len)
-{
-	ARG_UNUSED(set); ARG_UNUSED(name); ARG_UNUSED(out); ARG_UNUSED(len);
-	return -ENOTSUP;
-}
-#else
-/**
- * @brief Declare the set of parameters the cloud may change.
- *
- * Call it before tedge_start(). @p params must stay valid for the life of
- * the program — it is meant to be a file-scope `static const` table, so it
- * costs flash and no RAM.
- *
- * @p set names the set, and is the one name the cloud knows it by: the twin
- * fragment, the schema's identifier and the operation are all named after
- * it. Each parameter starts at its declared default, which anything already
- * stored then replaces.
- *
- * @return 0, -EEXIST when the set is already declared, -ENOSPC when there
- *         is no free slot (CONFIG_TEDGE_PARAMETERS_MAX), -EINVAL when the
- *         declaration is not self-consistent (a default outside its own
- *         range or absent from its own allowed values, a duplicate name),
- *         or -ENOTSUP when parameters are not built in.
- */
-int tedge_declare_parameters(const char *set,
-			     const struct tedge_parameter *params,
-			     size_t count, tedge_parameters_changed_t on_change,
-			     void *user_data);
-
-/**
- * @brief Read the current value of a declared parameter.
- *
- * @return 0, -ENOENT when the set or the name was never declared, or
- *         -EINVAL when the parameter is not of that type.
- */
-int tedge_parameter_get_bool(const char *set, const char *name, bool *out);
-int tedge_parameter_get_int(const char *set, const char *name, int32_t *out);
-/** @p out is always NUL-terminated; -ENOMEM when @p len is too small. */
-int tedge_parameter_get_string(const char *set, const char *name, char *out,
-			       size_t len);
-#endif /* CONFIG_TEDGE && !CONFIG_TEDGE_PARAMETERS */
-
-#ifdef __cplusplus
-}
-#endif
-
-#endif /* TEDGE_TEDGE_H_ */
diff --git a/tedge-zephyr/profiles/README.md b/tedge-zephyr/profiles/README.md
deleted file mode 100644
index 0d23bee..0000000
--- a/tedge-zephyr/profiles/README.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Feature profiles
-
-Kconfig overlays that select a coherent feature set, for example:
-
-```sh
-west build -b   -- -DEXTRA_CONF_FILE=/path/to/tedge-zephyr/profiles/minimal.conf
-```
-
-- `minimal.conf`: connection, inventory, health, telemetry and restart over one
-  TLS session.
-- `full.conf`: every feature, for boards with room (ESP32-C6, ESP32-S3; on the
-  S3 with the mbedTLS heap in PSRAM).
-- `ota.conf`: `minimal.conf` plus firmware update and certificate renewal — the
-  smallest image that can still be updated over the air, and that stays
-  reachable past its first certificate's expiry. Two TLS sessions (MQTT and the download).
-- `remote-access-enabler.conf`: connection, health, restart and one
-  remote-access tunnel, for a device that only gives the cloud access to LAN
-  hosts. Fits an ESP32-WROOM-32 with nothing else on it.
-
-The measured costs are in each file's header.
-
-`full.conf` asks for every implemented feature. It expects a sysbuild image with
-MCUboot and a shell; on a build without them, Kconfig warns that firmware
-update and the shell command were turned back off, which is the warning doing
-its job.
-
-A board's or application's default profile belongs to the application (its
-`boards/*.conf`), not to this module.
diff --git a/tedge-zephyr/profiles/full.conf b/tedge-zephyr/profiles/full.conf
deleted file mode 100644
index 196401c..0000000
--- a/tedge-zephyr/profiles/full.conf
+++ /dev/null
@@ -1,83 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-#
-# tedge-zephyr profile: full (DRAFT). Every feature, for boards with room:
-# ESP32-C6 and ESP32-S3 in c8y-direct-spikes.
-#
-# Measured cost (ESP32-C6, 16 KB TLS buffers):
-#   MQTT + one HTTPS download: 90.9 KB TLS heap peak (measured)
-#   MQTT + one remote-access tunnel: 69.2 KB connected, 86.2 KB peak
-#   signed image with A+B+C+F: 960 KB of the 1280 KB slot
-#   parameters: +7.2 KB, and +1.3 KB more for the schema printer, which is
-#   only needed until the sets are registered in the tenant
-#   private client heap: CONFIG_TEDGE_HEAP_SIZE defaults to 16 KB here
-#   (parameters hold their declarations and twin state in it).
-# Every feature here is implemented, so this profile needs no experimental
-# gate; the only option still behind one is the thin-edge.io gateway
-# transport, which this profile does not use.
-
-CONFIG_TEDGE=y
-CONFIG_TEDGE_TRANSPORT_C8Y=y
-CONFIG_TEDGE_C8Y_MQTT_SERVICE=y
-CONFIG_TEDGE_AUTH_C8Y_CA=y
-
-CONFIG_TEDGE_TELEMETRY=y
-CONFIG_TEDGE_HEALTH=y
-CONFIG_TEDGE_RESTART=y
-CONFIG_TEDGE_FIRMWARE_UPDATE=y
-# The download's TLS handshake runs on its own thread, as the tunnel's does.
-CONFIG_TEDGE_FIRMWARE_STACK_SIZE=8192
-CONFIG_TEDGE_SHELL_COMMAND=y
-# Nothing runs until the integrator lists what may; see the README.
-CONFIG_TEDGE_SHELL_COMMAND_ALLOW_LIST=""
-CONFIG_TEDGE_LOG_UPLOAD=y
-# Crash dumps also need the application's backend choice:
-#   CONFIG_DEBUG_COREDUMP_BACKEND_FLASH_PARTITION=y
-#   CONFIG_DEBUG_COREDUMP_MEMORY_DUMP_MIN=y
-CONFIG_TEDGE_COREDUMP=y
-CONFIG_TEDGE_REMOTE_ACCESS=y
-CONFIG_TEDGE_REMOTE_ACCESS_MAX_SESSIONS=1
-# The tunnel's TLS handshake runs on the bridge thread.
-CONFIG_TEDGE_REMOTE_ACCESS_STACK_SIZE=8192
-CONFIG_TEDGE_PARAMETERS=y
-CONFIG_TEDGE_CERT_RENEWAL=y
-# A month of margin covers a device that is offline for a while.
-CONFIG_TEDGE_CERT_RENEW_BEFORE_DAYS=30
-
-CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384
-# MQTT plus one transfer or tunnel: 90.9 KB peak measured, plus margin. On the
-# ESP32-S3 add CONFIG_MBEDTLS_HEAP_CUSTOM_SECTION=y and CONFIG_ESP_SPIRAM=y to
-# put this heap in PSRAM.
-CONFIG_MBEDTLS_HEAP_SIZE=98304
-# Split TLS record buffers: the output buffer is 4 KB (the client only sends
-# small records), the input buffer stays at the size above. Saves 12 KB of
-# mbedTLS heap per concurrent session at 16 KB records. The fragment-length
-# option stays off so the server's records are what they were: with it on,
-# Zephyr would advertise the smaller buffer (4 KB) as the maximum. See
-# tedge-zephyr/include/tedge/mbedtls_user_config.h.
-CONFIG_MBEDTLS_USER_CONFIG_FILE="tedge/mbedtls_user_config.h"
-CONFIG_NET_SOCKETS_TLS_SET_MAX_FRAGMENT_LENGTH=n
-# --- What the application must provide (see tedge-zephyr/README.md) ---
-CONFIG_MBEDTLS=y
-CONFIG_MBEDTLS_ENABLE_HEAP=y
-# AES T-tables in flash instead of 8 KB of .bss (with FEWER_TABLES, which
-# Zephyr defaults on, 2.5 KB of flash); a little slower AES, irrelevant
-# beside Wi-Fi throughput (reduce-memory-footprint, tier 1).
-CONFIG_MBEDTLS_AES_ROM_TABLES=y
-CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_PSA_WANT_ECC_SECP_R1_256=y
-CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y
-CONFIG_MBEDTLS_HAVE_TIME_DATE=y
-CONFIG_MBEDTLS_PEM_PARSE_C=y
-CONFIG_MBEDTLS_X509_CRT_PARSE_C=y
-# The device key lives in PSA ITS; the store is a Kconfig choice, so the
-# module cannot select it.
-CONFIG_SECURE_STORAGE_ITS_STORE_IMPLEMENTATION_SETTINGS=y
-
-# Concurrent TLS: MQTT + an HTTPS transfer or a remote-access tunnel.
-CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS=3
-# Headroom for reconnects (a closed TLS connection holds its TCP context
-# until it finishes closing).
-CONFIG_NET_MAX_CONN=10
-CONFIG_NET_MAX_CONTEXTS=12
diff --git a/tedge-zephyr/profiles/minimal.conf b/tedge-zephyr/profiles/minimal.conf
deleted file mode 100644
index 8b59f39..0000000
--- a/tedge-zephyr/profiles/minimal.conf
+++ /dev/null
@@ -1,58 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-#
-# tedge-zephyr profile: minimal (DRAFT). Direct to Cumulocity with one TLS
-# session: connection, inventory, availability, health and restart. No HTTP
-# client, no firmware update, no remote access.
-#
-# Measured cost of this level (c8y-direct-spikes, Spike A, ESP32-C6):
-#   one TLS session: 51.8 KB peak / 34.8 KB connected with 16 KB buffers,
-#   35.4 KB / 18.5 KB with 8 KB buffers (MQTT Service only)
-#   private client heap: CONFIG_TEDGE_HEAP_SIZE defaults to 10 KB here
-#   (no parameters); the health free-heap value shows the margin.
-# Every feature here is implemented, so nothing in this profile needs
-# CONFIG_TEDGE_EXPERIMENTAL_FEATURES.
-
-CONFIG_TEDGE=y
-CONFIG_TEDGE_TRANSPORT_C8Y=y
-CONFIG_TEDGE_C8Y_MQTT_SERVICE=y
-CONFIG_TEDGE_AUTH_C8Y_CA=y
-
-CONFIG_TEDGE_RESTART=y
-CONFIG_TEDGE_HEALTH=y
-CONFIG_TEDGE_TELEMETRY=y
-# A device that reports a few series every minute needs less than the default
-# 2 KB of buffer; the trade is how long an outage it can ride out.
-CONFIG_TEDGE_TELEMETRY_BUFFER_BYTES=1024
-
-# One session at a time; 16 KB TLS buffers are the safe default (8 KB works
-# on the MQTT Service today but leaves ~2 KB margin over its certificate chain).
-CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384
-# One session: 51.8 KB peak / 34.8 KB connected, plus the enrollment request.
-CONFIG_MBEDTLS_HEAP_SIZE=65536
-# Split TLS record buffers: the output buffer is 4 KB (the client only sends
-# small records), the input buffer stays at the size above. Saves 12 KB of
-# mbedTLS heap per concurrent session at 16 KB records. The fragment-length
-# option stays off so the server's records are what they were: with it on,
-# Zephyr would advertise the smaller buffer (4 KB) as the maximum. See
-# tedge-zephyr/include/tedge/mbedtls_user_config.h.
-CONFIG_MBEDTLS_USER_CONFIG_FILE="tedge/mbedtls_user_config.h"
-CONFIG_NET_SOCKETS_TLS_SET_MAX_FRAGMENT_LENGTH=n
-# --- What the application must provide (see tedge-zephyr/README.md) ---
-CONFIG_MBEDTLS=y
-CONFIG_MBEDTLS_ENABLE_HEAP=y
-# AES T-tables in flash instead of 8 KB of .bss (with FEWER_TABLES, which
-# Zephyr defaults on, 2.5 KB of flash); a little slower AES, irrelevant
-# beside Wi-Fi throughput (reduce-memory-footprint, tier 1).
-CONFIG_MBEDTLS_AES_ROM_TABLES=y
-CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_PSA_WANT_ECC_SECP_R1_256=y
-CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y
-CONFIG_MBEDTLS_HAVE_TIME_DATE=y
-CONFIG_MBEDTLS_PEM_PARSE_C=y
-CONFIG_MBEDTLS_X509_CRT_PARSE_C=y
-# The device key lives in PSA ITS; the store is a Kconfig choice, so the
-# module cannot select it.
-CONFIG_SECURE_STORAGE_ITS_STORE_IMPLEMENTATION_SETTINGS=y
-
diff --git a/tedge-zephyr/profiles/ota.conf b/tedge-zephyr/profiles/ota.conf
deleted file mode 100644
index 9594cb4..0000000
--- a/tedge-zephyr/profiles/ota.conf
+++ /dev/null
@@ -1,77 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-#
-# tedge-zephyr profile: ota. The smallest image that can still be updated
-# over the air: connection, inventory, availability, health, telemetry,
-# restart, firmware update and certificate renewal. Firmware update downloads
-# over HTTPS beside the MQTT session, so this profile budgets two concurrent
-# TLS sessions; renewal shares the HTTPS client and its TLS context.
-#
-# Measured (firmware-release-workflow, 2026-09-21):
-#   ESP32-WROOM-32 + Modbus, 8 KB TLS records, 56 KB mbedTLS heap in
-#   internal DRAM: links at 96.3% of dram0; an 800 KB image downloads in
-#   32 s and confirms; 23 KB of system heap stays free.
-#   MQTT + one HTTPS download with 16 KB records: 90.9 KB TLS heap peak
-#   (ESP32-C6, the full profile's measurement).
-#   Run on the ESP32-C6, S3-DevKitC, QT Py S3 and ESP32-CAM with Modbus,
-#   SNMP and no protocol: every image downloaded, installed and confirmed
-#   the next over the air (52-94 s). OPC-UA beside it runs out of memory on
-#   the C6 and the S3-DevKitC.
-#   private client heap: CONFIG_TEDGE_HEAP_SIZE defaults to 10 KB here
-#   (no parameters); the health free-heap value shows the margin.
-# A board without PSRAM may need smaller records and heap: the board's
-# settings file overrides the two sizes below.
-
-CONFIG_TEDGE=y
-CONFIG_TEDGE_TRANSPORT_C8Y=y
-CONFIG_TEDGE_C8Y_MQTT_SERVICE=y
-CONFIG_TEDGE_AUTH_C8Y_CA=y
-
-CONFIG_TEDGE_TELEMETRY=y
-CONFIG_TEDGE_HEALTH=y
-CONFIG_TEDGE_RESTART=y
-CONFIG_TEDGE_FIRMWARE_UPDATE=y
-# The download's TLS handshake runs on its own thread.
-CONFIG_TEDGE_FIRMWARE_STACK_SIZE=8192
-# A device whose certificate expires cannot be reached at all, not even to
-# update it, so every image that can be updated also renews. Measured on the
-# WROOM and ESP32-CAM ota builds (DEVICES.md, 2026-09-23).
-CONFIG_TEDGE_CERT_RENEWAL=y
-# A month of margin covers a device that is offline for a while.
-CONFIG_TEDGE_CERT_RENEW_BEFORE_DAYS=30
-
-CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384
-# MQTT plus one HTTPS download: 90.9 KB peak measured, plus margin.
-CONFIG_MBEDTLS_HEAP_SIZE=98304
-# Split TLS record buffers: the output buffer is 4 KB (the client only sends
-# small records), the input buffer stays at the size above. Saves 12 KB of
-# mbedTLS heap per concurrent session at 16 KB records. The fragment-length
-# option stays off so the server's records are what they were: with it on,
-# Zephyr would advertise the smaller buffer (4 KB) as the maximum. See
-# tedge-zephyr/include/tedge/mbedtls_user_config.h.
-CONFIG_MBEDTLS_USER_CONFIG_FILE="tedge/mbedtls_user_config.h"
-CONFIG_NET_SOCKETS_TLS_SET_MAX_FRAGMENT_LENGTH=n
-# --- What the application must provide (see tedge-zephyr/README.md) ---
-CONFIG_MBEDTLS=y
-CONFIG_MBEDTLS_ENABLE_HEAP=y
-# AES T-tables in flash instead of 8 KB of .bss (with FEWER_TABLES, which
-# Zephyr defaults on, 2.5 KB of flash); a little slower AES, irrelevant
-# beside Wi-Fi throughput (reduce-memory-footprint, tier 1).
-CONFIG_MBEDTLS_AES_ROM_TABLES=y
-CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_PSA_WANT_ECC_SECP_R1_256=y
-CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y
-CONFIG_MBEDTLS_HAVE_TIME_DATE=y
-CONFIG_MBEDTLS_PEM_PARSE_C=y
-CONFIG_MBEDTLS_X509_CRT_PARSE_C=y
-# The device key lives in PSA ITS; the store is a Kconfig choice, so the
-# module cannot select it.
-CONFIG_SECURE_STORAGE_ITS_STORE_IMPLEMENTATION_SETTINGS=y
-
-# Concurrent TLS: MQTT + the HTTPS download.
-CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS=2
-# Headroom for reconnects (a closed TLS connection holds its TCP context
-# until it finishes closing).
-CONFIG_NET_MAX_CONN=10
-CONFIG_NET_MAX_CONTEXTS=12
diff --git a/tedge-zephyr/profiles/remote-access-enabler.conf b/tedge-zephyr/profiles/remote-access-enabler.conf
deleted file mode 100644
index 55e40b8..0000000
--- a/tedge-zephyr/profiles/remote-access-enabler.conf
+++ /dev/null
@@ -1,62 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-#
-# tedge-zephyr profile: remote-access enabler (DRAFT). A device whose only job
-# is to let Cumulocity reach hosts on its LAN (SSH, web UIs): connection,
-# health, restart and one remote-access tunnel, onboarded through the
-# Cumulocity CA. No firmware update over HTTPS (a second concurrent TLS
-# session), no protocol app.
-#
-# Measured (c8y-direct-spikes, section 8 follow-up, ESP32-WROOM-32 spike):
-#   MQTT + one tunnel with 8 KB records: 53.4 KB TLS heap peak, 36.4 KB
-#   connected; 2.8 KB of libc heap left on the WROOM after trimming the
-#   Wi-Fi heap. The WROOM tunnel is slow (~4-17 KB/s) and stalled once (P13):
-#   prefer an ESP32-C6 or S3 for this role.
-#   private client heap: CONFIG_TEDGE_HEAP_SIZE defaults to 10 KB here
-#   (no parameters); the health free-heap value shows the margin.
-# 8 KB records leave ~2 KB of margin over today's 9883 chain (P3).
-
-CONFIG_TEDGE=y
-CONFIG_TEDGE_TRANSPORT_C8Y=y
-CONFIG_TEDGE_C8Y_MQTT_SERVICE=y
-CONFIG_TEDGE_AUTH_C8Y_CA=y
-
-CONFIG_TEDGE_HEALTH=y
-CONFIG_TEDGE_RESTART=y
-CONFIG_TEDGE_REMOTE_ACCESS=y
-CONFIG_TEDGE_REMOTE_ACCESS_MAX_SESSIONS=1
-CONFIG_TEDGE_REMOTE_ACCESS_STACK_SIZE=8192
-
-CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=8192
-CONFIG_MBEDTLS_HEAP_SIZE=57344
-# Split TLS record buffers: the output buffer is 4 KB (the client only sends
-# small records), the input buffer stays at 8 KB. Saves 4 KB of mbedTLS heap
-# per concurrent session. The fragment-length option stays on: with 8 KB
-# records the server has to be told to stay within the input buffer (Zephyr
-# advertises 4 KB, the largest RFC 6066 code below 8 KB, as it did before).
-# See tedge-zephyr/include/tedge/mbedtls_user_config.h.
-CONFIG_MBEDTLS_USER_CONFIG_FILE="tedge/mbedtls_user_config.h"
-CONFIG_NET_SOCKETS_TLS_SET_MAX_FRAGMENT_LENGTH=y
-# --- What the application must provide (see tedge-zephyr/README.md) ---
-CONFIG_MBEDTLS=y
-CONFIG_MBEDTLS_ENABLE_HEAP=y
-# AES T-tables in flash instead of 8 KB of .bss (with FEWER_TABLES, which
-# Zephyr defaults on, 2.5 KB of flash); a little slower AES, irrelevant
-# beside Wi-Fi throughput (reduce-memory-footprint, tier 1).
-CONFIG_MBEDTLS_AES_ROM_TABLES=y
-CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_PSA_WANT_ECC_SECP_R1_256=y
-CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y
-CONFIG_MBEDTLS_HAVE_TIME_DATE=y
-CONFIG_MBEDTLS_PEM_PARSE_C=y
-CONFIG_MBEDTLS_X509_CRT_PARSE_C=y
-# The device key lives in PSA ITS; the store is a Kconfig choice, so the
-# module cannot select it.
-CONFIG_SECURE_STORAGE_ITS_STORE_IMPLEMENTATION_SETTINGS=y
-
-# MQTT + the tunnel's WebSocket.
-CONFIG_NET_SOCKETS_TLS_MAX_CONTEXTS=2
-# At 32 RX / 24 TX the tunnel exhausted the TX pool on the WROOM.
-CONFIG_NET_BUF_RX_COUNT=64
-CONFIG_NET_BUF_TX_COUNT=64
diff --git a/tedge-zephyr/samples/minimal/CMakeLists.txt b/tedge-zephyr/samples/minimal/CMakeLists.txt
deleted file mode 100644
index 65d55c3..0000000
--- a/tedge-zephyr/samples/minimal/CMakeLists.txt
+++ /dev/null
@@ -1,15 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-#
-# Smallest application that uses tedge-zephyr. It builds with Zephyr and this
-# module only, which proves the module does not depend on the repository that
-# hosts it.
-cmake_minimum_required(VERSION 3.20.0)
-
-# The module is this sample's grandparent directory. Once tedge-zephyr is a
-# west project, west finds it and this line can go.
-list(APPEND ZEPHYR_EXTRA_MODULES ${CMAKE_CURRENT_SOURCE_DIR}/../..)
-
-find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
-project(tedge_minimal C)
-
-target_sources(app PRIVATE src/main.c)
diff --git a/tedge-zephyr/samples/minimal/Kconfig b/tedge-zephyr/samples/minimal/Kconfig
deleted file mode 100644
index 0a33117..0000000
--- a/tedge-zephyr/samples/minimal/Kconfig
+++ /dev/null
@@ -1,15 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-
-mainmenu "tedge-zephyr minimal sample"
-
-config TEDGE_SAMPLE_WIFI_SSID
-	string "Wi-Fi SSID"
-	default ""
-	depends on WIFI
-
-config TEDGE_SAMPLE_WIFI_PSK
-	string "Wi-Fi password (empty for an open network)"
-	default ""
-	depends on WIFI
-
-source "Kconfig.zephyr"
diff --git a/tedge-zephyr/samples/minimal/README.md b/tedge-zephyr/samples/minimal/README.md
deleted file mode 100644
index 833c958..0000000
--- a/tedge-zephyr/samples/minimal/README.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# tedge-zephyr minimal sample
-
-The smallest application that includes tedge-zephyr. It prints the module's
-version. It uses nothing outside Zephyr and this module, so a successful build
-shows the module is independent of any host repository.
-
-```sh
-west build -b native_sim/native/64 tedge-zephyr/samples/minimal
-./build/zephyr/zephyr.exe
-```
diff --git a/tedge-zephyr/samples/minimal/overlay-c8y.conf b/tedge-zephyr/samples/minimal/overlay-c8y.conf
deleted file mode 100644
index d13ab61..0000000
--- a/tedge-zephyr/samples/minimal/overlay-c8y.conf
+++ /dev/null
@@ -1,41 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-#
-# What an application must provide for the client's Cumulocity transport.
-# The client selects the protocol pieces (MQTT, sockets, TLS sockets,
-# credentials, SNTP) itself; these are the application's to size.
-
-# --- TLS: the client's requirements, from tedge-zephyr/README.md ---
-CONFIG_MBEDTLS=y
-CONFIG_MBEDTLS_ENABLE_HEAP=y
-CONFIG_MBEDTLS_HEAP_SIZE=57344
-CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384
-CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256=y
-CONFIG_PSA_WANT_ECC_SECP_R1_256=y
-CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y
-CONFIG_MBEDTLS_HAVE_TIME_DATE=y
-CONFIG_MBEDTLS_PEM_PARSE_C=y
-CONFIG_MBEDTLS_X509_CRT_PARSE_C=y
-
-# --- Wi-Fi: the application owns the network ---
-CONFIG_WIFI=y
-CONFIG_WIFI_ESP32=y
-CONFIG_NET_L2_ETHERNET=y
-CONFIG_NET_DHCPV4=y
-CONFIG_NET_CONNECTION_MANAGER=y
-CONFIG_TEDGE_SAMPLE_WIFI_SSID=""
-CONFIG_TEDGE_SAMPLE_WIFI_PSK=""
-
-# --- Settings: the client stores its identity and credentials here ---
-CONFIG_FLASH=y
-CONFIG_FLASH_MAP=y
-CONFIG_NVS=y
-CONFIG_SETTINGS_NVS=y
-# The device key lives in PSA ITS, stored through settings.
-CONFIG_SECURE_STORAGE_ITS_STORE_IMPLEMENTATION_SETTINGS=y
-
-# --- Sockets and connections the client needs (README) ---
-CONFIG_NET_MAX_CONN=6
-CONFIG_NET_MAX_CONTEXTS=8
-CONFIG_ZVFS_OPEN_MAX=8
diff --git a/tedge-zephyr/samples/minimal/prj.conf b/tedge-zephyr/samples/minimal/prj.conf
deleted file mode 100644
index 9e2d834..0000000
--- a/tedge-zephyr/samples/minimal/prj.conf
+++ /dev/null
@@ -1,12 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-#
-# The smallest application that hosts the client. The client itself only
-# needs CONFIG_TEDGE; what the *application* must provide for the Cumulocity
-# transport (mbedTLS, a settings backend, socket budget) is in
-# overlay-c8y.conf, so build this sample with:
-#
-#   west build -b  samples/minimal -- -DEXTRA_CONF_FILE=overlay-c8y.conf
-
-CONFIG_TEDGE=y
-CONFIG_LOG=y
-
diff --git a/tedge-zephyr/samples/minimal/sample.yaml b/tedge-zephyr/samples/minimal/sample.yaml
deleted file mode 100644
index 9d537a2..0000000
--- a/tedge-zephyr/samples/minimal/sample.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-sample:
-  name: tedge-zephyr minimal
-  description: Smallest application that includes the tedge-zephyr module
-common:
-  tags: tedge
-  harness: console
-  harness_config:
-    type: one_line
-    regex:
-      - "tedge-zephyr [0-9]+\\.[0-9]+\\.[0-9]+"
-tests:
-  sample.tedge.minimal:
-    integration_platforms:
-      - native_sim/native/64
-    platform_allow:
-      - native_sim/native/64
-      - esp32c6_devkitc/esp32c6/hpcore
diff --git a/tedge-zephyr/samples/minimal/src/main.c b/tedge-zephyr/samples/minimal/src/main.c
deleted file mode 100644
index 507de0a..0000000
--- a/tedge-zephyr/samples/minimal/src/main.c
+++ /dev/null
@@ -1,78 +0,0 @@
-/* SPDX-License-Identifier: Apache-2.0
- *
- * The smallest application that hosts the thin-edge.io client: join a Wi-Fi
- * network, then start the client and let it do the rest. Everything here is
- * the *application's* job; the client only reacts to the network being up.
- */
-
-#include 
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-
-LOG_MODULE_REGISTER(tedge_minimal, LOG_LEVEL_INF);
-
-static void on_state(enum tedge_state state, void *user_data)
-{
-	ARG_UNUSED(user_data);
-
-	if (state == TEDGE_STATE_AWAITING_REGISTRATION) {
-		char url[220];
-
-		/* The URL carries a one-time password: show it to the operator
-		 * (here the console; a real device might use a display or its
-		 * provisioner). */
-		if (tedge_registration_url(url, sizeof(url)) > 0) {
-			LOG_INF("register this device: %s", url);
-		}
-	}
-}
-
-static const struct tedge_hooks hooks = {
-	.on_state = on_state,
-};
-
-#if defined(CONFIG_WIFI)
-static int wifi_connect(void)
-{
-	struct net_if *iface = net_if_get_first_wifi();
-	struct wifi_connect_req_params p = {
-		.ssid = (const uint8_t *)CONFIG_TEDGE_SAMPLE_WIFI_SSID,
-		.ssid_length = strlen(CONFIG_TEDGE_SAMPLE_WIFI_SSID),
-		.psk = (const uint8_t *)CONFIG_TEDGE_SAMPLE_WIFI_PSK,
-		.psk_length = strlen(CONFIG_TEDGE_SAMPLE_WIFI_PSK),
-		.security = strlen(CONFIG_TEDGE_SAMPLE_WIFI_PSK)
-				    ? WIFI_SECURITY_TYPE_PSK
-				    : WIFI_SECURITY_TYPE_NONE,
-		.channel = WIFI_CHANNEL_ANY,
-		.band = WIFI_FREQ_BAND_2_4_GHZ,
-	};
-
-	if (iface == NULL) {
-		LOG_ERR("no Wi-Fi interface");
-		return -ENODEV;
-	}
-	LOG_INF("joining \"%s\"", CONFIG_TEDGE_SAMPLE_WIFI_SSID);
-	return net_mgmt(NET_REQUEST_WIFI_CONNECT, iface, &p, sizeof(p));
-}
-#endif
-
-int main(void)
-{
-	LOG_INF("tedge-zephyr %s", tedge_version());
-
-#if defined(CONFIG_WIFI)
-	/* The client waits for an address by itself, so a failure here only
-	 * delays it: the application can retry however it likes. */
-	(void)wifi_connect();
-#endif
-
-	if (tedge_init(NULL, &hooks) == 0) {
-		(void)tedge_start();
-	}
-	return 0;
-}
diff --git a/tedge-zephyr/scripts/check-independence.py b/tedge-zephyr/scripts/check-independence.py
deleted file mode 100755
index 95fb7ea..0000000
--- a/tedge-zephyr/scripts/check-independence.py
+++ /dev/null
@@ -1,93 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: Apache-2.0
-"""Check that tedge-zephyr stays self-contained.
-
-The module must be able to move into its own repository unchanged, so no code
-or build file in it may reach outside its directory, or name the internals of
-the repository that incubates it. The script checks:
-
-- relative paths ("../...") that resolve outside the module directory;
-- references to the incubating repository's modules (lib/common, lib/opcua,
-  lib/modbus, lib/snmp, lib/mcuboot-hooks, lib/frontend-template) or apps/;
-- includes of lib/common headers by their bare names (net.h, identity.h, ...).
-
-Documentation (*.md) is not checked; prose may talk about host applications.
-
-Exit status is 0 when clean, 1 when any violation is found.
-"""
-
-import re
-import sys
-from pathlib import Path
-
-MODULE = Path(__file__).resolve().parent.parent
-SELF = Path(__file__).resolve()
-
-CODE_SUFFIXES = {
-    ".c", ".h", ".cmake", ".conf", ".yml", ".yaml", ".overlay", ".dts",
-    ".dtsi", ".sh", ".py", ".ld",
-}
-CODE_NAMES = {"CMakeLists.txt", "Kconfig"}
-
-RELATIVE = re.compile(r"(?:\.\./)+[^\s\"'()<>;]*|(?\"]"
-)
-
-
-def is_code(path: Path) -> bool:
-    return path.name in CODE_NAMES or path.name.startswith("Kconfig") \
-        or path.suffix in CODE_SUFFIXES
-
-
-def check_file(path: Path) -> list[str]:
-    problems = []
-    try:
-        text = path.read_text(encoding="utf-8")
-    except UnicodeDecodeError:
-        return problems
-    rel = path.relative_to(MODULE)
-
-    for lineno, line in enumerate(text.splitlines(), start=1):
-        for match in RELATIVE.finditer(line):
-            target = (path.parent / match.group(0)).resolve()
-            if target != MODULE and MODULE not in target.parents:
-                problems.append(
-                    f"{rel}:{lineno}: path '{match.group(0)}' leaves the module"
-                )
-        if HOST_PATHS.search(line):
-            problems.append(
-                f"{rel}:{lineno}: references the host repository: {line.strip()}"
-            )
-        if HOST_HEADERS.search(line):
-            problems.append(
-                f"{rel}:{lineno}: includes a host-repository header: {line.strip()}"
-            )
-    return problems
-
-
-def main() -> int:
-    problems = []
-    for path in sorted(MODULE.rglob("*")):
-        if not path.is_file() or path == SELF or not is_code(path):
-            continue
-        problems.extend(check_file(path))
-
-    for problem in problems:
-        print(problem)
-    if problems:
-        print(f"tedge-zephyr is not self-contained: {len(problems)} problem(s)")
-        return 1
-    print("tedge-zephyr is self-contained")
-    return 0
-
-
-if __name__ == "__main__":
-    sys.exit(main())
diff --git a/tedge-zephyr/scripts/check-secrets.py b/tedge-zephyr/scripts/check-secrets.py
deleted file mode 100755
index fc4b6de..0000000
--- a/tedge-zephyr/scripts/check-secrets.py
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: Apache-2.0
-"""Check that no log statement in the module prints a secret.
-
-The client handles four things that must never reach a log: the
-remote-access connection key, the enrollment one-time password (and the
-registration URL that carries it, above debug level), the device password
-from bootstrap credentials, and the JWT. This catches the easy mistake of
-passing one of those variables to a LOG_* macro.
-
-Exit status is 0 when clean, 1 when a log statement looks suspicious.
-"""
-
-import re
-import sys
-from pathlib import Path
-
-SRC = Path(__file__).resolve().parent.parent / "src"
-# Variables that hold a secret in this module.
-SECRETS = re.compile(r"(?:\bconn_key\b|->key\b|\bkey\b|\botp\b|"
-                     r"\bpassword\b|\bdev_password\b|\bboot_password\b|"
-                     r"\bpsk\b|\bjwt\b|\btoken\b|\bbasic_b64\b|"
-                     r"\bauth_hdr\b)")
-LOG_CALL = re.compile(r"\bLOG_(ERR|WRN|INF|DBG|HEXDUMP_\w+)\s*\(")
-
-
-def check(path: Path) -> list[str]:
-    text = path.read_text(encoding="utf-8")
-    problems = []
-    for match in LOG_CALL.finditer(text):
-        # Take the call's arguments, up to the closing ");" of the statement.
-        end = text.find(");", match.end())
-        call = text[match.end():end if end > 0 else match.end()]
-        # The format string itself may mention the word; only its arguments
-        # matter, so drop the quoted parts.
-        args = re.sub(r'"(?:[^"\\]|\\.)*"', "", call)
-        hit = SECRETS.search(args)
-        if hit:
-            line = text[:match.start()].count("\n") + 1
-            problems.append(f"{path.name}:{line}: log statement uses "
-                            f"'{hit.group(0)}'")
-    return problems
-
-
-def main() -> int:
-    problems = []
-    for path in sorted(SRC.glob("*.c")):
-        problems.extend(check(path))
-    for problem in problems:
-        print(problem)
-    if problems:
-        print(f"{len(problems)} log statement(s) may print a secret")
-        return 1
-    print("no log statement prints a secret")
-    return 0
-
-
-if __name__ == "__main__":
-    sys.exit(main())
diff --git a/tedge-zephyr/src/tedge_backoff.c b/tedge-zephyr/src/tedge_backoff.c
deleted file mode 100644
index 19bb783..0000000
--- a/tedge-zephyr/src/tedge_backoff.c
+++ /dev/null
@@ -1,31 +0,0 @@
-/* SPDX-License-Identifier: Apache-2.0
- *
- * How long to wait before the next connect attempt: start at a few seconds,
- * double up to the configured maximum, and add jitter so a fleet coming back
- * after an outage does not reconnect in lockstep. The floor exists because a
- * closed TLS connection holds its TCP context for a moment (spikes, 3.5).
- */
-
-#include "tedge_internal.h"
-
-#include 
-#include 
-
-#define BACKOFF_MIN_S 3
-
-uint32_t tedge_backoff_next(uint32_t current, uint32_t max)
-{
-	uint32_t next = (current == 0) ? BACKOFF_MIN_S : current * 2;
-
-	if (next > max) {
-		next = max;
-	}
-	/* ±20% jitter, so a fleet doesn't reconnect in lockstep. */
-	int32_t spread = (int32_t)(next / 5);
-
-	if (spread > 0) {
-		next = (uint32_t)((int32_t)next - spread +
-				  (int32_t)(sys_rand32_get() % (2 * spread + 1)));
-	}
-	return MAX(next, (uint32_t)BACKOFF_MIN_S);
-}
diff --git a/tedge-zephyr/src/tedge_bootstrap.c b/tedge-zephyr/src/tedge_bootstrap.c
deleted file mode 100644
index 4a7ae29..0000000
--- a/tedge-zephyr/src/tedge_bootstrap.c
+++ /dev/null
@@ -1,314 +0,0 @@
-/* SPDX-License-Identifier: Apache-2.0
- *
- * Onboarding with the Cumulocity bootstrap user (the fallback when the device
- * has no certificate): connect to Core MQTT as the tenant's bootstrap user,
- * ask for device credentials on s/ucr until the operator accepts the device,
- * and store the "70,,," reply.
- *
- * It uses its own short-lived MQTT session so the main one always runs as the
- * device. Passwords are never logged.
- */
-
-#include "tedge_internal.h"
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-LOG_MODULE_DECLARE(tedge, CONFIG_TEDGE_LOG_LEVEL);
-
-#define BOOTSTRAP_PORT   8883
-#define POLL_INTERVAL_MS 5000
-#define WINDOW_MS        (2 * 60 * 1000)
-
-static char dev_user[96];
-static char dev_password[96];
-static bool have_credentials;
-
-/* Runtime credentials from tedge_set_bootstrap_credentials(). */
-static char boot_user[96] = CONFIG_TEDGE_BOOTSTRAP_USER;
-static char boot_password[96] = CONFIG_TEDGE_BOOTSTRAP_PASSWORD;
-
-static struct mqtt_client client;
-static struct sockaddr_storage broker;
-static uint8_t rx_buf[512];
-static uint8_t tx_buf[512];
-static uint8_t payload_buf[256];
-static bool connack_seen;
-static int connack_result;
-
-int tedge_set_bootstrap_credentials(const char *user, const char *password)
-{
-	if (!IS_ENABLED(CONFIG_TEDGE_AUTH_BOOTSTRAP)) {
-		return -ENOTSUP;
-	}
-	if (user == NULL || password == NULL || strlen(user) >= sizeof(boot_user) ||
-	    strlen(password) >= sizeof(boot_password)) {
-		return -EINVAL;
-	}
-	strcpy(boot_user, user);
-	strcpy(boot_password, password);
-	return 0;
-}
-
-/* ------------------------------------------------------------------------ */
-/* Settings                                                                  */
-/* ------------------------------------------------------------------------ */
-
-struct blob {
-	char *buf;
-	size_t cap;
-	size_t len;
-};
-
-static int load_cb(const char *key, size_t len, settings_read_cb read_cb,
-		   void *cb_arg, void *param)
-{
-	struct blob *b = param;
-
-	ARG_UNUSED(key);
-	if (len < b->cap) {
-		b->len = read_cb(cb_arg, b->buf, len);
-		b->buf[b->len] = '\0';
-	}
-	return 0;
-}
-
-static void load_stored(void)
-{
-	struct blob u = { .buf = dev_user, .cap = sizeof(dev_user) };
-	struct blob p = { .buf = dev_password, .cap = sizeof(dev_password) };
-
-	(void)settings_load_subtree_direct(TEDGE_KEY_BOOTSTRAP_USER, load_cb, &u);
-	(void)settings_load_subtree_direct(TEDGE_KEY_BOOTSTRAP_PASS, load_cb, &p);
-	have_credentials = (dev_user[0] != '\0' && dev_password[0] != '\0');
-	if (have_credentials) {
-		LOG_INF("device credentials found for \"%s\"", dev_user);
-	}
-}
-
-/* ------------------------------------------------------------------------ */
-/* The bootstrap session                                                     */
-/* ------------------------------------------------------------------------ */
-
-/* "70,,," */
-static void parse_credentials(const char *line)
-{
-	char tenant[48], user[48], password[64];
-
-	if (tedge_sr_template(line) != 70) {
-		return;
-	}
-	if (tedge_sr_field(line, 1, tenant, sizeof(tenant)) < 0 ||
-	    tedge_sr_field(line, 2, user, sizeof(user)) < 0 ||
-	    tedge_sr_field(line, 3, password, sizeof(password)) < 0) {
-		LOG_ERR("malformed device credentials");
-		return;
-	}
-	snprintf(dev_user, sizeof(dev_user), "%s/%s", tenant, user);
-	snprintf(dev_password, sizeof(dev_password), "%s", password);
-	(void)settings_save_one(TEDGE_KEY_BOOTSTRAP_USER, dev_user,
-				strlen(dev_user));
-	(void)settings_save_one(TEDGE_KEY_BOOTSTRAP_PASS, dev_password,
-				strlen(dev_password));
-	memset(password, 0, sizeof(password));
-	have_credentials = true;
-	LOG_INF("device credentials received for \"%s\"", dev_user);
-}
-
-static void evt_cb(struct mqtt_client *c, const struct mqtt_evt *evt)
-{
-	switch (evt->type) {
-	case MQTT_EVT_CONNACK:
-		connack_seen = true;
-		connack_result = evt->result;
-		break;
-	case MQTT_EVT_PUBLISH: {
-		const struct mqtt_publish_param *p = &evt->param.publish;
-		size_t len = p->message.payload.len;
-
-		if (len >= sizeof(payload_buf)) {
-			break;
-		}
-		if (mqtt_readall_publish_payload(c, payload_buf, len) == 0) {
-			payload_buf[len] = '\0';
-			parse_credentials((const char *)payload_buf);
-		}
-		break;
-	}
-	default:
-		break;
-	}
-}
-
-static int pump(int ms, const bool *done)
-{
-	int64_t end = k_uptime_get() + ms;
-
-	do {
-		struct zsock_pollfd fds = { .fd = client.transport.tls.sock,
-					    .events = ZSOCK_POLLIN };
-		int ret = zsock_poll(&fds, 1,
-				     (int)CLAMP(end - k_uptime_get(), 0, 500));
-
-		if (ret < 0) {
-			return -errno;
-		}
-		if (ret > 0 && (fds.revents & ZSOCK_POLLIN)) {
-			ret = mqtt_input(&client);
-			if (ret != 0) {
-				return ret;
-			}
-		}
-		ret = mqtt_live(&client);
-		if (ret != 0 && ret != -EAGAIN) {
-			return ret;
-		}
-	} while (k_uptime_get() < end && !(done && *done));
-	return 0;
-}
-
-static int bootstrap_session(void)
-{
-	static const sec_tag_t tags[] = { TEDGE_TAG_SERVER_CA };
-	struct zsock_addrinfo hints = { .ai_family = AF_INET,
-					.ai_socktype = SOCK_STREAM };
-	struct zsock_addrinfo *res;
-	struct mqtt_utf8 user = { .utf8 = (const uint8_t *)boot_user,
-				  .size = strlen(boot_user) };
-	struct mqtt_utf8 pass = { .utf8 = (const uint8_t *)boot_password,
-				  .size = strlen(boot_password) };
-	struct mqtt_sec_config *tls = &client.transport.tls.config;
-	struct mqtt_topic topic = {
-		.topic.utf8 = (const uint8_t *)"s/dcr",
-		.topic.size = 5,
-		.qos = MQTT_QOS_1_AT_LEAST_ONCE,
-	};
-	struct mqtt_subscription_list sub = { .list = &topic,
-					      .list_count = 1,
-					      .message_id = 1 };
-	int64_t deadline;
-	char port[8];
-	int ret;
-
-	if (boot_user[0] == '\0' || boot_password[0] == '\0') {
-		LOG_ERR("no bootstrap credentials: set CONFIG_TEDGE_BOOTSTRAP_USER "
-			"and _PASSWORD, or call tedge_set_bootstrap_credentials()");
-		return -EACCES;
-	}
-
-	snprintf(port, sizeof(port), "%d", BOOTSTRAP_PORT);
-	ret = zsock_getaddrinfo(tedge_c8y_host(), port, &hints, &res);
-	if (ret != 0) {
-		return -EHOSTUNREACH;
-	}
-	memcpy(&broker, res->ai_addr, res->ai_addrlen);
-	zsock_freeaddrinfo(res);
-
-	mqtt_client_init(&client);
-	client.broker = &broker;
-	client.evt_cb = evt_cb;
-	/* The client ID is the device's external ID, not the bootstrap user:
-	 * that is how Cumulocity knows which registration is asking. */
-	client.client_id.utf8 = (const uint8_t *)tedge_identity()->external_id;
-	client.client_id.size = strlen(tedge_identity()->external_id);
-	client.protocol_version = MQTT_VERSION_3_1_1;
-	client.rx_buf = rx_buf;
-	client.rx_buf_size = sizeof(rx_buf);
-	client.tx_buf = tx_buf;
-	client.tx_buf_size = sizeof(tx_buf);
-	client.keepalive = 60;
-	client.clean_session = 1;
-	client.user_name = &user;
-	client.password = &pass;
-	client.transport.type = MQTT_TRANSPORT_SECURE;
-	tls->peer_verify = TLS_PEER_VERIFY_REQUIRED;
-	tls->sec_tag_list = tags;
-	tls->sec_tag_count = ARRAY_SIZE(tags);
-	tls->hostname = tedge_c8y_host();
-
-	connack_seen = false;
-	ret = mqtt_connect(&client);
-	if (ret != 0) {
-		LOG_ERR("bootstrap connect failed (%d)", ret);
-		return ret;
-	}
-	ret = pump(15000, &connack_seen);
-	if (ret != 0 || !connack_seen || connack_result != 0) {
-		LOG_ERR("no bootstrap CONNACK (ret=%d result=%d)", ret,
-			connack_result);
-		mqtt_abort(&client);
-		return -ECONNREFUSED;
-	}
-	(void)mqtt_subscribe(&client, &sub);
-	(void)pump(500, NULL);
-
-	LOG_INF("waiting for the device registration to be accepted");
-	deadline = k_uptime_get() + WINDOW_MS;
-	while (!have_credentials && k_uptime_get() < deadline) {
-		struct mqtt_publish_param p = {
-			.message.topic.topic.utf8 = (const uint8_t *)"s/ucr",
-			.message.topic.topic.size = 5,
-			.message.topic.qos = MQTT_QOS_0_AT_MOST_ONCE,
-			.message.payload.data = NULL,
-			.message.payload.len = 0,
-			.message_id = 0,
-		};
-
-		(void)mqtt_publish(&client, &p);
-		ret = pump(POLL_INTERVAL_MS, &have_credentials);
-		if (ret != 0) {
-			break;
-		}
-	}
-	(void)mqtt_disconnect(&client, NULL);
-	k_msleep(300);
-	return have_credentials ? 0 : -EAGAIN;
-}
-
-/* ------------------------------------------------------------------------ */
-/* Interface used by the transport                                           */
-/* ------------------------------------------------------------------------ */
-
-int tedge_set_enroll_otp(const char *password)
-{
-	ARG_UNUSED(password);
-	return -ENOTSUP; /* bootstrap devices have no one-time password */
-}
-
-int tedge_registration_url(char *buf, size_t len)
-{
-	ARG_UNUSED(buf);
-	ARG_UNUSED(len);
-	return -ENOTSUP; /* bootstrap devices are registered in the UI */
-}
-
-int tedge_auth_prepare(char *id_out, size_t id_len)
-{
-	snprintf(id_out, id_len, "%s", tedge_identity()->external_id);
-	if (have_credentials) {
-		return 0;
-	}
-	load_stored();
-	if (have_credentials) {
-		return 0;
-	}
-	tedge_set_state(TEDGE_STATE_AWAITING_REGISTRATION);
-	return bootstrap_session();
-}
-
-const char *tedge_auth_username(void)
-{
-	return have_credentials ? dev_user : NULL;
-}
-
-const char *tedge_auth_password(void)
-{
-	return have_credentials ? dev_password : NULL;
-}
diff --git a/tedge-zephyr/src/tedge_c8y.c b/tedge-zephyr/src/tedge_c8y.c
deleted file mode 100644
index 9fa1b94..0000000
--- a/tedge-zephyr/src/tedge_c8y.c
+++ /dev/null
@@ -1,1415 +0,0 @@
-/* SPDX-License-Identifier: Apache-2.0
- *
- * The Cumulocity transport: one MQTTS session (the MQTT Service on 9883 with
- * a certificate, or Core MQTT on 8883), the SmartREST dispatcher, the JWT,
- * the twin/health publishing and the restart operation.
- *
- * Only the client thread (tedge_core.c) touches this file's state: Zephyr's
- * MQTT client is not thread-safe.
- */
-
-#include "tedge_internal.h"
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-LOG_MODULE_DECLARE(tedge, CONFIG_TEDGE_LOG_LEVEL);
-
-#if defined(CONFIG_TEDGE_C8Y_MQTT_SERVICE)
-#define C8Y_PORT 9883
-#define FREE_FORM_TOPICS 1
-#else
-#define C8Y_PORT 8883
-#define FREE_FORM_TOPICS 0
-#endif
-
-#define CONNACK_TIMEOUT_MS 15000
-#define SUBACK_RETRY_MAX   5
-#define JWT_REFRESH_MS     (50 * 60 * 1000)
-/* Three broker closes this soon after CONNACK look like another client using
- * the same ID (c8y-direct-spikes, Spike B finding 6). */
-#define TAKEOVER_WINDOW_MS 10000
-#define TAKEOVER_STRIKES   3
-
-/* The trust anchors listed in CONFIG_TEDGE_C8Y_CA_FILES. */
-static const unsigned char server_ca[] = {
-#include "tedge_ca_bundle.inc"
-	0x00
-};
-
-static const sec_tag_t sec_tags_mtls[] = { TEDGE_TAG_SERVER_CA, TEDGE_TAG_DEVICE };
-static const sec_tag_t sec_tags_basic[] = { TEDGE_TAG_SERVER_CA };
-
-/* Downstream topics: thin-edge.io's Cumulocity bridge set, minus the
- * bootstrap topics (tedge_bootstrap.c owns those). */
-#if defined(CONFIG_TEDGE_C8Y_OPERATION_JSON)
-/* Operations come as JSON, which is the only delivery that carries their
- * id; s/ds would deliver the same operations a second time. */
-#define OP_TOPIC "devicecontrol/notifications"
-static const char *const sub_topics[] = { OP_TOPIC, "s/e", "s/dat" };
-#else
-static const char *const sub_topics[] = { "s/ds", "s/e", "s/dat" };
-#endif
-
-static struct mqtt_client client;
-static struct sockaddr_storage broker;
-static uint8_t rx_buf[2048];
-static uint8_t tx_buf[2048];
-static uint8_t payload_buf[CONFIG_TEDGE_C8Y_PAYLOAD_BYTES];
-static uint16_t next_msg_id = 1;
-static bool session_open;
-static bool connack_seen;
-static int connack_result;
-static char jwt[1024];
-static int64_t jwt_at;
-static char device_id[64];
-
-static struct mqtt_utf8 basic_user;
-static struct mqtt_utf8 basic_password;
-
-static uint16_t last_suback_id;
-static uint8_t last_suback_code;
-
-static int64_t connected_at;
-static int takeover_strikes;
-
-/* Operations the application registered, plus the built-in ones. */
-struct op_entry {
-	const char *name;
-	tedge_operation_handler_t handler;
-	void *user_data;
-};
-#define OP_SLOTS 4
-static struct op_entry ops[OP_SLOTS];
-
-struct tedge_operation {
-	char name[40];
-	char payload[192];
-	bool done;
-};
-static struct tedge_operation current_op;
-
-#if defined(CONFIG_TEDGE_RESTART)
-static bool restart_requested;
-static bool restart_pending_after_boot;
-#endif
-
-/* ------------------------------------------------------------------------ */
-/* Publishing                                                                */
-/* ------------------------------------------------------------------------ */
-
-static int publish(const char *topic, const char *payload, enum mqtt_qos qos)
-{
-	struct mqtt_publish_param p = {
-		.message.topic.topic.utf8 = (const uint8_t *)topic,
-		.message.topic.topic.size = strlen(topic),
-		.message.topic.qos = qos,
-		.message.payload.data = (uint8_t *)payload,
-		.message.payload.len = strlen(payload),
-		.message_id = next_msg_id++,
-	};
-	int ret;
-
-	if (!session_open) {
-		return -ENOTCONN;
-	}
-	ret = mqtt_publish(&client, &p);
-	if (ret) {
-		LOG_ERR("publish to %s failed (%d)", topic, ret);
-	}
-	return ret;
-}
-
-/* One operation at a time.
- *
- * Cumulocity's 501, 502 and 503 act on the *oldest* operation in the
- * matching state, not on an operation named in the message, so a device with
- * two operations executing at once gets their results crossed: the log
- * upload's URL lands on the command, and an operation is left EXECUTING for
- * ever. The client therefore runs one operation at a time and keeps the rest
- * in a small queue.
- *
- * The gate is maintained here, where every status message passes, rather
- * than at each of the places that send one: a feature added later cannot
- * forget it.
- */
-#define OP_QUEUE_DEPTH 2
-/* A firmware request carries a URL, which is the longest line that arrives. */
-#define OP_LINE_MAX    768
-
-static bool op_executing;
-static char op_queue[OP_QUEUE_DEPTH][OP_LINE_MAX];
-static uint8_t op_queued;
-
-int tedge_c8y_publish_sr(const char *line)
-{
-	int tmpl = tedge_sr_template(line);
-
-	if (tmpl == 501 || tmpl == 504) {
-		op_executing = true;
-	} else if (tmpl == 502 || tmpl == 503 || tmpl == 505 || tmpl == 506) {
-		op_executing = false;
-	}
-	return publish("s/us", line, MQTT_QOS_1_AT_LEAST_ONCE);
-}
-
-const char *tedge_c8y_jwt(void)
-{
-	return jwt;
-}
-
-/* ------------------------------------------------------------------------ */
-/* Operations                                                                */
-/* ------------------------------------------------------------------------ */
-
-/* The id of the operation in flight, when the client knows it. Exactly one
- * operation runs at a time (see the queue above), so one id is enough for
- * everything that finishes inside the session; the restart and the firmware
- * update, which finish after a reboot, keep theirs in settings.
- *
- * With an id, a status names the operation it belongs to (504, 505, 506).
- * Without one, it names the fragment and Cumulocity applies it to the
- * oldest operation in that state (501, 502, 503) — which is why knowing the
- * id matters: an operation left executing by a reset otherwise swallows
- * every later result.
- */
-static char current_op_id[24];
-
-static void op_status(int by_id, int by_name, const char *name,
-		      const char *quoted)
-{
-	char line[288];
-
-	if (current_op_id[0] != '\0') {
-		if (quoted != NULL) {
-			snprintf(line, sizeof(line), "%d,%s,%s", by_id,
-				 current_op_id, quoted);
-		} else {
-			snprintf(line, sizeof(line), "%d,%s", by_id,
-				 current_op_id);
-		}
-	} else if (quoted != NULL) {
-		snprintf(line, sizeof(line), "%d,%s,%s", by_name, name, quoted);
-	} else {
-		snprintf(line, sizeof(line), "%d,%s", by_name, name);
-	}
-	(void)tedge_c8y_publish_sr(line);
-}
-
-/* @p reason and @p result are raw text; they are quoted here. */
-static void op_executing_now(const char *name)
-{
-	op_status(504, 501, name, NULL);
-}
-
-static void op_failed(const char *name, const char *reason)
-{
-	char quoted[224];
-
-	(void)tedge_sr_quote((reason != NULL) ? reason : "failed", quoted,
-			     sizeof(quoted));
-	op_status(505, 502, name, quoted);
-}
-
-static void op_succeeded(const char *name, const char *result)
-{
-	char quoted[224];
-
-	if (result == NULL || result[0] == '\0') {
-		op_status(506, 503, name, NULL);
-		return;
-	}
-	(void)tedge_sr_quote(result, quoted, sizeof(quoted));
-	op_status(506, 503, name, quoted);
-}
-
-#if defined(CONFIG_TEDGE_SHELL_COMMAND)
-/* Completes an operation with text whose line breaks matter, such as what
- * a shell command printed: they travel inside the quoted field. */
-static void op_finished_lines(const char *name, bool ok, const char *text)
-{
-	char quoted[224];
-
-	if (ok && (text == NULL || text[0] == '\0')) {
-		op_status(506, 503, name, NULL);
-		return;
-	}
-	(void)tedge_sr_quote_lines((text != NULL) ? text : "failed", quoted,
-				   sizeof(quoted));
-	if (ok) {
-		op_status(506, 503, name, quoted);
-	} else {
-		op_status(505, 502, name, quoted);
-	}
-}
-#endif
-
-/* Completes an operation whose id was kept across a reboot. */
-static void op_finish_by_id(const char *id, const char *name, bool ok,
-			    const char *text)
-{
-	char saved[sizeof(current_op_id)];
-
-	snprintf(saved, sizeof(saved), "%s", current_op_id);
-	snprintf(current_op_id, sizeof(current_op_id), "%s",
-		 (id != NULL) ? id : "");
-	if (ok) {
-		op_succeeded(name, text);
-	} else {
-		op_failed(name, text);
-	}
-	snprintf(current_op_id, sizeof(current_op_id), "%s", saved);
-}
-
-int tedge_register_operation(const char *name, tedge_operation_handler_t handler,
-			     void *user_data)
-{
-	if (name == NULL || handler == NULL) {
-		return -EINVAL;
-	}
-	for (int i = 0; i < OP_SLOTS; i++) {
-		if (ops[i].name == NULL) {
-			ops[i].name = name;
-			ops[i].handler = handler;
-			ops[i].user_data = user_data;
-			return 0;
-		}
-	}
-	LOG_ERR("no free operation slot for \"%s\" (%d)", name, OP_SLOTS);
-	return -ENOMEM;
-}
-
-const char *tedge_operation_payload(const struct tedge_operation *op)
-{
-	return (op != NULL) ? op->payload : NULL;
-}
-
-int tedge_operation_succeed(struct tedge_operation *op, const char *result)
-{
-	if (op == NULL || op->done) {
-		return -EINVAL;
-	}
-	op->done = true;
-	op_succeeded(op->name, result);
-	return 0;
-}
-
-int tedge_operation_fail(struct tedge_operation *op, const char *reason)
-{
-	if (op == NULL || op->done) {
-		return -EINVAL;
-	}
-	op->done = true;
-	op_failed(op->name, reason);
-	return 0;
-}
-
-/* Report an operation the image cannot run (baseline requirement: never leave
- * it pending).
- *
- * 501 first, always: Cumulocity's 502 fails the oldest *executing* operation,
- * so refusing one straight from PENDING would leave it pending for ever —
- * exactly the state this function exists to avoid. */
-static void op_unsupported(const char *name, const char *why)
-{
-	/* Executing first, always: without an id, Cumulocity's failure acts
-	 * on the oldest *executing* operation, so an operation refused
-	 * straight from PENDING would stay pending for ever — exactly the
-	 * state this function exists to avoid. With an id it is harmless. */
-	op_executing_now(name);
-	op_failed(name, why);
-}
-
-/* ------------------------------------------------------------------------ */
-/* Restart (CONFIG_TEDGE_RESTART)                                            */
-/* ------------------------------------------------------------------------ */
-
-#if defined(CONFIG_TEDGE_RESTART)
-static void handle_restart(void)
-{
-	const struct tedge_hooks *hooks = tedge_hook_table();
-	char reason[96] = "";
-	uint8_t one = 1;
-
-	restart_requested = false;
-	if (hooks != NULL && hooks->restart_request != NULL) {
-		int rc = hooks->restart_request(reason, sizeof(reason),
-						hooks->user_data);
-
-		if (rc != 0) {
-			LOG_WRN("restart vetoed by the application (%d): %s", rc,
-				reason[0] ? reason : "no reason given");
-			op_unsupported("c8y_Restart",
-				       reason[0] ? reason
-						 : "the application refused the restart");
-			return;
-		}
-	}
-	LOG_INF("restart requested by the cloud");
-	op_executing_now("c8y_Restart");
-	/* The operation is completed after the reboot, so what identifies it
-	 * has to survive the reboot too. An empty id means "the oldest
-	 * restart operation", which is all the static path can say. */
-	if (current_op_id[0] != '\0') {
-		(void)settings_save_one(TEDGE_KEY_RESTART, current_op_id,
-					strlen(current_op_id) + 1);
-	} else {
-		(void)settings_save_one(TEDGE_KEY_RESTART, &one, sizeof(one));
-	}
-	k_msleep(500);
-	mqtt_disconnect(&client, NULL);
-	k_msleep(300);
-	tedge_platform_reset();
-}
-
-/* The marker is either a single byte (the older format: "a restart was
- * asked for") or the id of the operation that asked. */
-static char restart_op_id[24];
-
-static int restart_marker_cb(const char *key, size_t len,
-			     settings_read_cb read_cb, void *cb_arg, void *param)
-{
-	uint8_t v = 0;
-
-	ARG_UNUSED(key);
-	if (len == sizeof(v) && read_cb(cb_arg, &v, sizeof(v)) == sizeof(v)) {
-		*(bool *)param = (v == 1);
-	} else if (len > 1 && len <= sizeof(restart_op_id)) {
-		ssize_t n = read_cb(cb_arg, restart_op_id,
-				    sizeof(restart_op_id));
-
-		if (n > 0) {
-			restart_op_id[MIN((size_t)n, sizeof(restart_op_id) - 1)] =
-				'\0';
-			*(bool *)param = true;
-		}
-	}
-	return 0;
-}
-#endif /* CONFIG_TEDGE_RESTART */
-
-/* ------------------------------------------------------------------------ */
-/* Downstream messages                                                       */
-/* ------------------------------------------------------------------------ */
-
-static void dispatch_operation(const char *line);
-
-/* Runs @p line now, or holds it until the operation in flight finishes.
- * Called on the client thread only. */
-static void handle_operation(const char *line)
-{
-	int tmpl = tedge_sr_template(line);
-
-	if (op_executing && tmpl >= 510 && tmpl <= 599) {
-		if (op_queued >= OP_QUEUE_DEPTH) {
-			/* Left PENDING deliberately: failing it would fail the
-			 * operation that is actually running, because that is
-			 * the one Cumulocity's 502 acts on. Cumulocity sends
-			 * pending operations again when the device
-			 * reconnects. */
-			LOG_WRN("operation %d arrived while %d were already "
-				"waiting; it stays pending until the next "
-				"connect", tmpl, op_queued);
-			return;
-		}
-		snprintf(op_queue[op_queued], OP_LINE_MAX, "%s", line);
-		op_queued++;
-		LOG_INF("operation %d waits: another one is running", tmpl);
-		return;
-	}
-	dispatch_operation(line);
-}
-
-/* Starts the next waiting operation, if the last one has finished. */
-static void run_queued_operation(void)
-{
-	char line[OP_LINE_MAX];
-
-	if (op_executing || op_queued == 0) {
-		return;
-	}
-	memcpy(line, op_queue[0], sizeof(line));
-	op_queued--;
-	if (op_queued > 0) {
-		memmove(op_queue[0], op_queue[1], OP_LINE_MAX);
-	}
-	dispatch_operation(line);
-}
-
-static void dispatch_operation(const char *line)
-{
-#if defined(CONFIG_TEDGE_C8Y_OPERATION_JSON)
-	/* The first field is the operation's id, where the static templates
-	 * put the device serial nobody read. */
-	if (tedge_sr_field(line, 1, current_op_id, sizeof(current_op_id)) <= 0) {
-		current_op_id[0] = '\0';
-	}
-#endif
-	char op_name[40];
-	int tmpl = tedge_sr_template(line);
-
-	switch (tmpl) {
-	case 510: /* c8y_Restart */
-#if defined(CONFIG_TEDGE_RESTART)
-		restart_requested = true;
-#else
-		op_unsupported("c8y_Restart",
-			       "restart is not built into this image "
-			       "(CONFIG_TEDGE_RESTART)");
-#endif
-		return;
-	case 532: /* c8y_ParameterUpdate_ */
-#if defined(CONFIG_TEDGE_PARAMETERS)
-	{
-		char set[40];
-		char object[OP_LINE_MAX];
-		char reason[160];
-		char frag[64];
-
-		(void)tedge_sr_field(line, 2, set, sizeof(set));
-		(void)tedge_sr_field(line, 3, object, sizeof(object));
-		/* The status has to name the fragment the change arrived as,
-		 * not the set on its own. */
-		snprintf(frag, sizeof(frag), "c8y_ParameterUpdate_%s", set);
-
-		op_executing_now(frag);
-		if (tedge_params_apply(set, object, reason, sizeof(reason)) !=
-		    0) {
-			op_failed(frag, reason);
-		} else {
-			op_succeeded(frag, NULL);
-		}
-	}
-#else
-		op_unsupported("c8y_ParameterUpdate",
-			       "parameters are not built into this image "
-			       "(CONFIG_TEDGE_PARAMETERS)");
-#endif
-		return;
-	case 515:
-#if defined(CONFIG_TEDGE_FIRMWARE_UPDATE)
-	{
-		char reason[128];
-
-		/* Executing first, always: see op_unsupported(). */
-		op_executing_now("c8y_Firmware");
-		if (tedge_fw_request(line, reason, sizeof(reason)) != 0) {
-			op_failed("c8y_Firmware", reason);
-		} else {
-			char sr[224], quoted[160];
-
-			tedge_fw_remember_operation(current_op_id);
-			(void)tedge_sr_quote(tedge_fw_downtime_hint(), quoted,
-					     sizeof(quoted));
-			snprintf(sr, sizeof(sr), "400,c8y_FirmwareUpdateStarted,%s",
-				 quoted);
-			(void)tedge_c8y_publish_sr(sr);
-		}
-	}
-#else
-		op_unsupported("c8y_Firmware",
-			       "firmware update is not built into this image "
-			       "(CONFIG_TEDGE_FIRMWARE_UPDATE)");
-#endif
-		return;
-	case 530:
-#if defined(CONFIG_TEDGE_REMOTE_ACCESS)
-	{
-		char reason[112];
-
-		op_executing_now("c8y_RemoteAccessConnect");
-		if (tedge_ra_request(line, reason, sizeof(reason)) != 0) {
-			op_failed("c8y_RemoteAccessConnect", reason);
-		}
-	}
-#else
-		op_unsupported("c8y_RemoteAccessConnect",
-			       "remote access is not built into this image "
-			       "(CONFIG_TEDGE_REMOTE_ACCESS)");
-#endif
-		return;
-	case 511:
-#if defined(CONFIG_TEDGE_SHELL_COMMAND)
-	{
-		char reason[160];
-
-		op_executing_now("c8y_Command");
-		if (tedge_shell_request(line, reason, sizeof(reason)) != 0) {
-			op_failed("c8y_Command", reason);
-		}
-		return;
-	}
-#else
-		/* Without the feature the application may still register its
-		 * own c8y_Command handler, so fall through to the registered
-		 * operations rather than refusing here. */
-		snprintf(op_name, sizeof(op_name), "c8y_Command");
-		break;
-#endif
-	case 522:
-#if defined(CONFIG_TEDGE_LOG_UPLOAD)
-	{
-		char reason[128];
-
-		op_executing_now("c8y_LogfileRequest");
-		if (tedge_log_request(line, reason, sizeof(reason)) != 0) {
-			op_failed("c8y_LogfileRequest", reason);
-		}
-	}
-#else
-		op_unsupported("c8y_LogfileRequest",
-			       "log upload is not built into this image "
-			       "(CONFIG_TEDGE_LOG_UPLOAD)");
-#endif
-		return;
-	default:
-		if (tmpl < 0) {
-			return; /* not a SmartREST line */
-		}
-		snprintf(op_name, sizeof(op_name), "%d", tmpl);
-		break;
-	}
-
-	for (int i = 0; i < OP_SLOTS; i++) {
-		if (ops[i].name != NULL && strcmp(ops[i].name, op_name) == 0) {
-			char line_sr[64];
-
-			memset(¤t_op, 0, sizeof(current_op));
-			snprintf(current_op.name, sizeof(current_op.name), "%s",
-				 op_name);
-			snprintf(current_op.payload, sizeof(current_op.payload),
-				 "%.*s", (int)sizeof(current_op.payload) - 1,
-				 line);
-			ARG_UNUSED(line_sr);
-			op_executing_now(op_name);
-			ops[i].handler(¤t_op, ops[i].user_data);
-			return;
-		}
-	}
-	/* Cumulocity's operations live in the 5xx templates. One this image
-	 * has no handler for is failed with a reason, never dropped: an
-	 * operation nobody answers stays pending in the cloud for ever, and
-	 * the device looks broken rather than merely incomplete. Anything
-	 * else on this topic is a response, not an operation. */
-	if (tmpl >= 510 && tmpl <= 599) {
-		char why[96];
-
-		snprintf(why, sizeof(why),
-			 "this image has no handler for this operation (%d)",
-			 tmpl);
-		op_unsupported(op_name, why);
-		return;
-	}
-	LOG_DBG("unhandled downstream message: %.40s", line);
-}
-
-static void handle_message(const char *topic, const char *payload, size_t len)
-{
-	if (strcmp(topic, "s/dat") == 0) {
-		/* "71,"; the token itself is never logged. */
-		if (len > 3 && len - 3 < sizeof(jwt)) {
-			memcpy(jwt, payload + 3, len - 3);
-			jwt[len - 3] = '\0';
-			jwt_at = k_uptime_get();
-			LOG_DBG("token received (%zu B)", len - 3);
-		}
-		return;
-	}
-	if (strcmp(topic, "s/e") == 0) {
-		LOG_WRN("cloud error: %.*s", (int)MIN(len, 160), payload);
-		return;
-	}
-#if defined(CONFIG_TEDGE_C8Y_OPERATION_JSON)
-	if (strcmp(topic, OP_TOPIC) == 0) {
-		char line[OP_LINE_MAX];
-
-		if (tedge_operation_from_json(payload, line, sizeof(line))) {
-			handle_operation(line);
-		}
-		return;
-	}
-#endif
-	if (strcmp(topic, "s/ds") == 0) {
-		LOG_DBG("s/ds: %.60s", payload);
-		handle_operation(payload);
-	}
-}
-
-static void mqtt_evt(struct mqtt_client *c, const struct mqtt_evt *evt)
-{
-	switch (evt->type) {
-	case MQTT_EVT_CONNACK:
-		connack_seen = true;
-		connack_result = evt->result;
-		break;
-
-	case MQTT_EVT_SUBACK:
-		last_suback_id = evt->param.suback.message_id;
-		last_suback_code = evt->param.suback.return_codes.len
-					   ? evt->param.suback.return_codes.data[0]
-					   : 0x80;
-		break;
-
-	case MQTT_EVT_PUBLISH: {
-		const struct mqtt_publish_param *p = &evt->param.publish;
-		size_t len = p->message.payload.len;
-		char topic[48];
-		size_t tlen = MIN(p->message.topic.topic.size, sizeof(topic) - 1);
-		int ret;
-
-		memcpy(topic, p->message.topic.topic.utf8, tlen);
-		topic[tlen] = '\0';
-
-		if (len >= sizeof(payload_buf)) {
-			LOG_WRN("%s: %zu-byte message dropped (buffer is %zu)",
-				topic, len, sizeof(payload_buf));
-			while (len > 0) {
-				ret = mqtt_read_publish_payload_blocking(
-					c, payload_buf,
-					MIN(len, sizeof(payload_buf)));
-				if (ret <= 0) {
-					break;
-				}
-				len -= ret;
-			}
-			break;
-		}
-		ret = mqtt_readall_publish_payload(c, payload_buf, len);
-		if (ret) {
-			LOG_ERR("reading the %s payload failed (%d)", topic, ret);
-			break;
-		}
-		payload_buf[len] = '\0';
-		if (p->message.topic.qos == MQTT_QOS_1_AT_LEAST_ONCE) {
-			struct mqtt_puback_param ack = {
-				.message_id = p->message_id,
-			};
-
-			mqtt_publish_qos1_ack(c, &ack);
-		}
-		handle_message(topic, (const char *)payload_buf, len);
-		break;
-	}
-
-	case MQTT_EVT_DISCONNECT:
-		session_open = false;
-		break;
-
-	default:
-		break;
-	}
-}
-
-/* ------------------------------------------------------------------------ */
-/* Session                                                                   */
-/* ------------------------------------------------------------------------ */
-
-/* Read input (and keep the connection alive) for up to @p ms, or until
- * @p done. */
-static int pump(int ms, const bool *done)
-{
-	int64_t end = k_uptime_get() + ms;
-
-	do {
-		struct zsock_pollfd fds = {
-			.fd = client.transport.tls.sock,
-			.events = ZSOCK_POLLIN,
-		};
-		int wait = (int)CLAMP(end - k_uptime_get(), 0, 1000);
-		int ret = zsock_poll(&fds, 1, wait);
-
-		if (ret < 0) {
-			return -errno;
-		}
-		if (ret > 0 && (fds.revents & ZSOCK_POLLIN)) {
-			ret = mqtt_input(&client);
-			if (ret) {
-				return ret;
-			}
-		}
-		if (ret > 0 && (fds.revents & (ZSOCK_POLLERR | ZSOCK_POLLHUP))) {
-			return -ECONNRESET;
-		}
-		ret = mqtt_live(&client);
-		if (ret && ret != -EAGAIN) {
-			return ret;
-		}
-	} while (k_uptime_get() < end && !(done && *done));
-	return 0;
-}
-
-static int resolve_broker(void)
-{
-	struct zsock_addrinfo hints = {
-		.ai_family = AF_INET,
-		.ai_socktype = SOCK_STREAM,
-	};
-	struct zsock_addrinfo *res;
-	char port[8];
-	int ret;
-
-	snprintf(port, sizeof(port), "%d", C8Y_PORT);
-	ret = zsock_getaddrinfo(tedge_c8y_host(), port, &hints, &res);
-	if (ret != 0) {
-		LOG_ERR("DNS lookup of %s failed (%d)", tedge_c8y_host(), ret);
-		return -EHOSTUNREACH;
-	}
-	memcpy(&broker, res->ai_addr, res->ai_addrlen);
-	zsock_freeaddrinfo(res);
-	return 0;
-}
-
-static void client_setup(void)
-{
-	struct mqtt_sec_config *tls = &client.transport.tls.config;
-	const char *user = tedge_auth_username();
-
-	mqtt_client_init(&client);
-	client.broker = &broker;
-	client.evt_cb = mqtt_evt;
-	client.client_id.utf8 = (const uint8_t *)device_id;
-	client.client_id.size = strlen(device_id);
-	client.protocol_version = MQTT_VERSION_3_1_1;
-	client.rx_buf = rx_buf;
-	client.rx_buf_size = sizeof(rx_buf);
-	client.tx_buf = tx_buf;
-	client.tx_buf_size = sizeof(tx_buf);
-	client.keepalive = 60;
-	client.clean_session = 1;
-
-	if (user != NULL) {
-		basic_user.utf8 = (const uint8_t *)user;
-		basic_user.size = strlen(user);
-		basic_password.utf8 = (const uint8_t *)tedge_auth_password();
-		basic_password.size = strlen(tedge_auth_password());
-		client.user_name = &basic_user;
-		client.password = &basic_password;
-	}
-
-	client.transport.type = MQTT_TRANSPORT_SECURE;
-	tls->peer_verify = TLS_PEER_VERIFY_REQUIRED;
-	tls->cipher_list = NULL;
-	if (user != NULL) {
-		tls->sec_tag_list = sec_tags_basic;
-		tls->sec_tag_count = ARRAY_SIZE(sec_tags_basic);
-	} else {
-		tls->sec_tag_list = sec_tags_mtls;
-		tls->sec_tag_count = ARRAY_SIZE(sec_tags_mtls);
-	}
-	tls->hostname = tedge_c8y_host();
-}
-
-/* Subscribe to one filter (the MQTT Service refuses several per packet).
- * Cumulocity refuses a subscription with 0x80 while the device does not exist
- * yet, so retry rather than give up (spikes P8).
- */
-static int subscribe_one(const char *topic)
-{
-	for (int attempt = 1; attempt <= SUBACK_RETRY_MAX; attempt++) {
-		struct mqtt_topic t = {
-			.topic.utf8 = (const uint8_t *)topic,
-			.topic.size = strlen(topic),
-			.qos = MQTT_QOS_1_AT_LEAST_ONCE,
-		};
-		struct mqtt_subscription_list list = {
-			.list = &t,
-			.list_count = 1,
-			.message_id = next_msg_id++,
-		};
-		int ret;
-
-		last_suback_id = 0;
-		last_suback_code = 0;
-		ret = mqtt_subscribe(&client, &list);
-		if (ret != 0) {
-			LOG_ERR("subscribing to %s failed (%d)", topic, ret);
-			return ret;
-		}
-		for (int i = 0; i < 10 && last_suback_id != list.message_id; i++) {
-			ret = pump(300, NULL);
-			if (ret != 0) {
-				return ret;
-			}
-		}
-		if (last_suback_id == list.message_id && last_suback_code != 0x80) {
-			return 0;
-		}
-		LOG_WRN("subscription to %s refused (attempt %d of %d); the "
-			"device may not exist yet", topic, attempt,
-			SUBACK_RETRY_MAX);
-		ret = pump(5000, NULL); /* let 100 be processed, then retry */
-		if (ret != 0) {
-			return ret;
-		}
-	}
-	LOG_ERR("could not subscribe to %s", topic);
-	return -EACCES;
-}
-
-/* The operations this image can actually run. */
-static void publish_supported_ops(void)
-{
-	char line[160] = "114";
-	size_t n = strlen(line);
-
-	if (IS_ENABLED(CONFIG_TEDGE_RESTART)) {
-		n += snprintf(line + n, sizeof(line) - n, ",c8y_Restart");
-	}
-	if (IS_ENABLED(CONFIG_TEDGE_REMOTE_ACCESS)) {
-		n += snprintf(line + n, sizeof(line) - n,
-			      ",c8y_RemoteAccessConnect");
-	}
-	if (IS_ENABLED(CONFIG_TEDGE_FIRMWARE_UPDATE)) {
-		n += snprintf(line + n, sizeof(line) - n, ",c8y_Firmware");
-	}
-	if (IS_ENABLED(CONFIG_TEDGE_SHELL_COMMAND)) {
-		n += snprintf(line + n, sizeof(line) - n, ",c8y_Command");
-	}
-	if (IS_ENABLED(CONFIG_TEDGE_LOG_UPLOAD)) {
-		n += snprintf(line + n, sizeof(line) - n,
-			      ",c8y_LogfileRequest");
-	}
-	for (int i = 0; i < OP_SLOTS && n < sizeof(line); i++) {
-		if (ops[i].name != NULL) {
-			n += snprintf(line + n, sizeof(line) - n, ",%s",
-				      ops[i].name);
-		}
-	}
-	if (n > 3) {
-		(void)tedge_c8y_publish_sr(line);
-	}
-
-#if defined(CONFIG_TEDGE_LOG_UPLOAD)
-	/* The log types this image can actually produce, so an operator is
-	 * never offered one the device does not have. */
-	{
-		char logs[160];
-
-		if (tedge_log_types_line(logs, sizeof(logs)) > 0) {
-			(void)tedge_c8y_publish_sr(logs);
-		}
-	}
-#endif
-}
-
-static int publish_twin_impl(const char *fragment, const char *json)
-{
-	char topic[128];
-	int rc;
-
-	if (FREE_FORM_TOPICS) {
-		snprintf(topic, sizeof(topic), "te/device/%s///twin/%s", device_id,
-			 fragment);
-		rc = publish(topic, json, MQTT_QOS_1_AT_LEAST_ONCE);
-	} else {
-		/* Core MQTT has no free-form topics: a direct inventory update,
-		 * as thin-edge.io's mapper does. */
-		char body[320];
-
-		snprintf(topic, sizeof(topic),
-			 "inventory/managedObjects/update/%s", device_id);
-		snprintf(body, sizeof(body), "{\"%s\":%s}", fragment, json);
-		rc = publish(topic, body, MQTT_QOS_1_AT_LEAST_ONCE);
-	}
-	if (rc == 0) {
-		LOG_INF("twin %s: %s", fragment, json);
-	}
-	return rc;
-}
-
-/* Progress: free-form topic, at most once. Nothing on Core MQTT, which has
- * no free-form topics; the operation status is the report there. */
-int tedge_c8y_publish_progress(const char *kind, const char *json)
-{
-	char topic[128];
-
-	if (!FREE_FORM_TOPICS) {
-		return -ENOTSUP;
-	}
-	snprintf(topic, sizeof(topic), "te/device/%s///progress/%s", device_id,
-		 kind);
-	LOG_DBG("progress/%s: %s", kind, json);
-	return publish(topic, json, MQTT_QOS_0_AT_MOST_ONCE);
-}
-
-#if defined(CONFIG_TEDGE_TELEMETRY)
-/* Core MQTT has no free-form topics: one SmartREST 200 per series, taken
- * back out of the JSON the API built. */
-static int smartrest_measurement(const char *type, const char *payload)
-{
-	size_t pos = 0;
-	char series[40], value[24], line[128], when[32];
-	int sent = 0;
-
-	/* Static template 200 takes ",,