Skip to content

Repository files navigation

OPC-UA Server Firmware (Zephyr RTOS)

Phase 1 firmware that turns a Wi-Fi microcontroller into an OPC-UA server, so a separate collector/gateway can read its data over OPC-UA. Built on Zephyr RTOS with the open62541 OPC-UA stack.

See SCOPE.md for scope/phasing, and openspec/changes/opcua-server-firmware/ for the proposal, design, specs and task breakdown/status.

Status

Verified end-to-end on real ESP32-WROOM-32 hardware: the board joins Wi-Fi, runs the OPC-UA server, advertises over mDNS, and a standard OPC-UA client on the LAN discovers it as tedge-opcua.local, browses the Device object, and reads live, updating measurements (temperature/humidity/pressure).

Targets

Board Zephyr board target Role Status
ESP32-WROOM-32 esp32_devkitc/esp32/procpu co-primary verified on hardware
Adafruit QT Py ESP32-S3 adafruit_qt_py_esp32s3/esp32s3/procpu co-primary verified on hardware
ESP32-C6 (WROOM-1-N4) esp32c6_devkitc/esp32c6/hpcore co-primary verified on hardware — all three apps
ESP32-S3-DevKitC-1 esp32s3_devkitc/esp32s3/procpu co-primary verified on hardware — all three apps
Feather ESP32-S2 TFT adafruit_feather_esp32s2_tft/esp32s2 co-primary builds & flashes; Wi-Fi data path broken upstream — see note
Raspberry Pi Pico W rpi_pico/rp2040/w stretch config authored, not yet built
Host simulation native_sim/native/64 dev / CI builds & runs (see NSOS note)

All hardware targets are Wi-Fi-only (station mode).

ESP32-C6 note — the first RISC-V and Wi-Fi 6 target. Every other board here is Xtensa; the C6 is single-core RISC-V with a Wi-Fi 6 radio, and all three applications built for it without a single source change — only the per-app boards/ files. That is the strongest evidence so far that lib/common/ and the protocol frontends are genuinely architecture-portable rather than accidentally Xtensa-shaped. All three were then verified on hardware: SNMP (snmpget/snmpwalk of the ifTable plus a coldStart trap), Modbus TCP (register reads, and a coil+setpoint write spinning the pump sim up), and OPC-UA (browsing the Device object, live updating measurements). Despite the 4 MB part being the smallest flash in the fleet, even the OPC-UA image uses only ~45% of its 1792 KB slot.

ESP32-S2 Feather Wi-Fi note: the same firmware that works end-to-end on the WROOM builds and flashes on the S2, and the S2 associates (correct SSID, RSSI, and it obtains a DHCP lease), but its IP data path does not pass traffic under Zephyr 4.4.2: no default gateway is installed, ICMP echo requests send but never get a reply, and the network path hangs on the first transmit. Systematically ruled out as causes: DHCP-vs-static IP, MAC block/override, Wi-Fi power-save, router L2 isolation, Kconfig (WIFI_ESP32 / NET_L2_WIFI_MGMT / NET_L2_ETHERNET are correct and identical to the working WROOM), and RAM starvation (server disabled + enlarged Wi-Fi buffers still hang). Diagnose in the field with CONFIG_APP_PING_TARGET + the on-display GWPING line. This is single-core ESP32-S2 Wi-Fi immaturity upstream, not firmware config. Paths forward if the S2 is needed: try a newer Zephyr / hal_espressif, or use ESP-IDF for the S2, or track it via a Zephyr issue. Use the WROOM for real deployments.

The C6 does not repeat the S2 failure. Worth stating explicitly, since the S2 taught us to distrust "associated" as evidence. The C6's Wi-Fi 6 radio associates and passes IPv4 traffic under the same Zephyr 4.4.2: it takes a DHCP lease, answers ICMP with 0% loss over sustained pings, resolves over mDNS, and serves SNMP, Modbus TCP and OPC-UA round-trips to external clients. Whatever ails the single-core S2 is specific to it, not a general weakness of Espressif Wi-Fi under this Zephyr.

Two harmless log lines on the C6 during bring-up: net_arp: Gateway not set for iface 1 in the window before the DHCP lease installs a gateway, and net_dhcpv4: DHCP server provided more DNS servers than can be saved when the router offers more servers than DNS_SERVER_COUNT slots. Neither affects connectivity.

Built and verified with Zephyr v4.4.2 and Zephyr SDK 1.0.1 (as shipped in the zephyrprojectrtos/zephyr-build image).

native_sim note: native_sim uses host-offloaded sockets (NSOS). The firmware builds and the OPC-UA server starts, but the OPC-UA handshake cannot complete under NSOS because its select() does not report readability on accepted sockets. Use real hardware for end-to-end OPC-UA testing.

Prerequisites (macOS)

The dev machine is macOS. native_sim is a Linux binary and the Zephyr SDK cross-toolchains are large, so builds run inside the official Zephyr build container. Flashing runs on the host (Docker on macOS can't reach USB serial).

brew install --cask docker            # or: brew install colima docker && colima start
python3 -m venv ~/flashenv && ~/flashenv/bin/pip install esptool pyserial asyncua

Use esptool ≥ 4.5, which is the first release that knows the esp32c6 chip. Note that esptool 5.x renamed its subcommands to hyphenated forms (write-flash, flash-id, chip-id); the underscore spellings in older notes still work but are deprecated. pymodbus is also handy for exercising the Modbus firmware by hand.

Linux differences

On Linux you can install Zephyr natively (pip install west + Zephyr SDK) and run native_sim directly. Serial devices are /dev/ttyUSB* / /dev/ttyACM* (vs. /dev/cu.usbserial-* on macOS), and your user must be in the dialout 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.

# Persistent build container with this repo mounted at /ws/app.
docker run -dit --name zephyr-dev -u root \
  -v "$PWD":/ws/app -w /ws/app \
  zephyrprojectrtos/zephyr-build:latest sleep infinity

export SDK=/opt/toolchains/zephyr-sdk-1.0.1   # adjust if the image differs

# Initialise + populate the workspace (once; several minutes).
docker exec -w /ws/app zephyr-dev west init -l .
docker exec -w /ws     zephyr-dev west update
docker exec -w /ws     zephyr-dev west zephyr-export

# ESP32 Wi-Fi needs Espressif binary blobs, and mbedtls (pulled in by Wi-Fi)
# needs its PSA-crypto submodule.
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'

Build & run on native_sim

docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \
  west build -b native_sim/native/64 apps/opcua-server --pristine
docker exec -w /ws/app zephyr-dev ./build/zephyr/zephyr.exe   # boots, starts server

Build & flash the ESP32-WROOM-32 (from macOS)

  1. Provide Wi-Fi credentials (see below), then cross-compile in the container:

    docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \
      west build -b esp32_devkitc/esp32/procpu apps/opcua-server --pristine \
      -- -DEXTRA_CONF_FILE=/ws/app/overlay-wifi-credentials.conf

    The build targets the application directory apps/opcua-server. The Wi-Fi credentials overlay stays at the repo root and is shared across apps, so pass it by absolute path (/ws/app/...) rather than relative to the app.

    The build output lands in ./build/ on the host (the repo is mounted).

  2. Flash from the host with esptool (the merged image goes at offset 0x1000):

    ~/flashenv/bin/python -m esptool --chip esp32 --port /dev/cu.usbserial-0001 \
      --baud 460800 write_flash 0x1000 build/zephyr/zephyr.bin

    Find the port with ls /dev/cu.* (ESP32 boards use a CP210x/CH34x USB-serial bridge; install its driver if the port doesn't appear). Watch logs with ~/flashenv/bin/python -m serial.tools.miniterm /dev/cu.usbserial-0001 115200.

Older ESP32 silicon: rev-1.0 WROOM chips need CONFIG_ESP32_USE_UNSUPPORTED_REVISION=y (already set in the board conf) or the v4.4 bootloader refuses to boot.

Raspberry Pi Pico W flashes via UF2: hold BOOTSEL, plug in, copy build/zephyr/zephyr.uf2 to the RPI-RP2 volume (same on macOS/Linux).

Build & flash the Adafruit QT Py ESP32-S3

Dual-core ESP32-S3 — Wi-Fi works end-to-end (unlike the S2). The &wifi node is disabled by default in the S3 SoC devicetree, so the app supplies a board overlay (apps/opcua-server/boards/adafruit_qt_py_esp32s3_esp32s3_procpu.overlay) to enable it.

docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \
  west build -b adafruit_qt_py_esp32s3/esp32s3/procpu apps/opcua-server --pristine \
  -- -DEXTRA_CONF_FILE=/ws/app/overlay-wifi-credentials.conf

The S3 uses native USB (USB-Serial-JTAG), so flash from the host at offset 0x0 (not 0x1000) and use --before usb_reset (plain default_reset drops the CDC port with "Device not configured"):

P=$(ls /dev/cu.usbmodem* | head -1)
~/flashenv/bin/python -m esptool --chip esp32s3 --port "$P" --baud 460800 \
  --before usb_reset --after hard_reset write_flash 0x0 build/zephyr/zephyr.bin

The console (and DHCP IP) is on the same native-USB port at 115200; open it without toggling DTR/RTS so you don't reset the board.

Build & flash the ESP32-S3-DevKitC-1

Espressif's own S3 reference board, as distinct from the Adafruit QT Py above.

Use the board's UART port, not its native USB port. This board has two USB sockets, and unlike the QT Py the right one is the UART socket: its bridge wires DTR/RTS to EN/BOOT, so esptool resets the board itself. Flashing needs no button press, and the console survives a reset so you get the boot log from the first line. The board devicetree already points zephyr,console at uart0, so nothing has to be overridden — the board .overlay only corrects the flash size.

No &wifi overlay is needed either: this board's devicetree already enables the Wi-Fi node, unlike the QT Py S3 and the S2 Feather.

docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \
  west build -b esp32s3_devkitc/esp32s3/procpu apps/snmp-agent --pristine \
  -- -DEXTRA_CONF_FILE=/ws/app/overlay-wifi-credentials.conf

Flash at offset 0x0. The port is the UART bridge — on macOS it may appear as /dev/cu.usbmodem* rather than usbserial*, since newer boards ship a CH343-class bridge in CDC mode rather than a CP210x:

~/flashenv/bin/python -m esptool --chip esp32s3 --port /dev/cu.usbmodem5CE60429731 \
  --baud 460800 write-flash 0x0 build/zephyr/zephyr.bin

The console is on that same port at 115200.

Flash size and layout. The board .overlay includes lib/common/dts/layout-esp32s3-16M.dtsi, which declares &flash0 as 16 MB (the N16R8 module tested here; the upstream devicetree's esp32s3_wroom_n8.dtsi assumes 8 MB) and replaces the partition table with the MCUboot + Wi-Fi provisioner layout. A plain build like the one above ignores the slots and boots from 0x0; for BLE provisioning build with --sysbuild instead (see "Provisioning Wi-Fi over BLE"). The layout needs a 16 MB part: check yours with esptool flash-id. The module's 8 MB of octal PSRAM is not used.

If you do want the native USB port, the overlay carries the console block to paste in, commented. Be warned that it did not come up on the board tested here, and that a board still running factory firmware on that port refuses every esptool reset strategy until you hold BOOT and tap RESET once.

Build & flash the ESP32-C6

The first RISC-V board here, and the first Wi-Fi 6 radio. It needs no special handling beyond its board files — the toolchain (riscv64-zephyr-elf) and the C6 Wi-Fi blobs both ship in the standard container.

docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \
  west build -b esp32c6_devkitc/esp32c6/hpcore apps/snmp-agent --pristine \
  -- -DEXTRA_CONF_FILE=/ws/app/overlay-wifi-credentials.conf

Flash at offset 0x0 with --chip esp32c6, which needs esptool ≥ 4.5:

~/flashenv/bin/python -m esptool --chip esp32c6 --port /dev/cu.usbmodem1101 \
  --baud 460800 --before usb-reset --after hard-reset \
  write-flash 0x0 build/zephyr/zephyr.bin

The C6 board overlay also corrects the declared flash size. The upstream board devicetree includes esp32c6_wroom_n8.dtsi, which claims an 8 MB part; the WROOM-1-N4 module in hand has 4 MB (confirm with esptool flash-id). The overlay includes lib/common/dts/layout-esp32c6-4M.dtsi, which declares the 4 MB and replaces the partition table with the MCUboot + Wi-Fi provisioner layout. A plain build like the one above ignores the slots and boots from 0x0; for BLE provisioning build with --sysbuild instead (see "Provisioning Wi-Fi over BLE").

Console caveat (C6, and any native-USB console). The C6's console rides USB-Serial-JTAG, which re-enumerates when the board resets, so the earliest boot output is lost and a console attached after boot can look completely silent even while the firmware is happily serving on the network — that happened repeatedly during bring-up. Attach before resetting, and check the network (ping, mDNS) before concluding a board has hung. The S3-DevKitC avoids this entirely by using its UART port, which is why that is the recommended port for it; the C6 board tested here exposes only the one USB socket, so it has no such option.

Build & flash the ESP32-S2 Feather TFT

docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \
  west build -b adafruit_feather_esp32s2_tft/esp32s2 apps/opcua-server --pristine \
  -- -DEXTRA_CONF_FILE=/ws/app/overlay-wifi-credentials.conf

The Feather S2 has native USB only (no USB-serial bridge), so:

  • Enter the ROM bootloader before flashing: hold BOOT/DFU, tap RESET, release BOOT. It enumerates as /dev/cu.usbmodem*.
  • Flash without letting esptool touch reset (native USB drops otherwise):
    P=$(ls /dev/cu.usbmodem* | head -1)
    ~/flashenv/bin/python -m esptool --chip esp32s2 --port "$P" \
      --before no_reset --after hard_reset --baud 460800 \
      write_flash 0x1000 build/zephyr/zephyr.bin
  • There is no serial console on this board under Zephyr (its console is on uart1/GPIO39, and the S2 has no USB-Serial-JTAG). Use the on-board TFT status display (below) to read connectivity state instead.

TFT status display

On the Feather ESP32-S2 TFT (CONFIG_APP_DISPLAY_STATUS=y, enabled in its board conf) the screen shows the boot/connectivity stage and the device IP so it can be read without a console:

Screen Meaning
Red Booting
Blue Connecting to Wi-Fi (a number = a Wi-Fi disconnect reason code)
Green + 4 stacked numbers Connected; the numbers are the IPv4 octets
Teal + IP OPC-UA server running
Orange (+ number) Error stage (number = Wi-Fi connect failure code)

Wi-Fi credentials (never commit secrets)

cp overlay-wifi-credentials.conf.example overlay-wifi-credentials.conf
# edit → CONFIG_APP_WIFI_SSID / CONFIG_APP_WIFI_PSK  (2.4 GHz network)

overlay-wifi-credentials.conf is git-ignored and lives at the repo root (shared across apps). Pass it by absolute path on the hardware build: -- -DEXTRA_CONF_FILE=/ws/app/overlay-wifi-credentials.conf.

If you'd rather not bake credentials into the image, see the next section: on the ESP32-C6, the ESP32-S3 boards and the ESP32-WROOM-32 a device can be given its network over BLE after flashing.

Provisioning Wi-Fi over BLE

On boards with a BLE radio the device can join any network without a rebuild. A device with no credentials boots into the Wi-Fi provisioner: it advertises the Improv Wi-Fi BLE service, a phone or laptop sends it an SSID and password, and the device checks them by actually joining the network, stores them, and reboots into the application.

The provisioner is its own firmware image (apps/wifi-provisioner), not part of the application. The applications (OPC-UA, Modbus, SNMP) carry no Bluetooth code and no Bluetooth RAM at all; switching between the two is a reboot through MCUboot, which picks the image:

 flash:  mcuboot | sys | slot0: application | slot1: (OTA) | prov: provisioner | bootreq | storage | scratch
                          ▲                                     ▲
 MCUboot ─ boot request? ─┴── no ──────────────── yes ──────────┘
            (bootreq)      application: no credentials, or the  provisioner: stores the credentials,
                           button pattern → set request, reboot  clears the request, reboots
  • The application reads its credentials from the store in storage, falling back to the compile-time CONFIG_APP_WIFI_SSID/PSK. With neither, it sets the boot request (a 16-byte record in its own flash sector) and reboots.
  • MCUboot sees the request, verifies the provisioner's signature, and launches it from prov (hooks in lib/mcuboot-hooks/). Without a request it boots the application from slot0 through its normal A/B logic, so the slot1 swap-and-revert that OTA will use works as usual.
  • The provisioner stores the credentials only after joining with them, clears the request and reboots into the application. A reset or power cut inside the provisioner comes back into the provisioner (the request is still set).

Build and flash (ESP32-C6, ESP32-S3-DevKitC-1, QT Py ESP32-S3, ESP32-WROOM-32)

One --sysbuild build produces all three images — MCUboot, the signed application and the signed provisioner — with a shared partition layout (lib/common/dts/layout-*.dtsi):

docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \
  west build --sysbuild -b esp32c6_devkitc/esp32c6/hpcore apps/modbus-server \
  -d build_modbus_c6 --pristine

No credentials overlay is needed. You can still pass one (-- -DEXTRA_CONF_FILE=/ws/app/overlay-wifi-credentials.conf): it then acts as a default network, and provisioned credentials take precedence over it.

Flash from the host with scripts/flash.sh, which writes each image at the offset it was linked for:

scripts/flash.sh build_modbus_c6 --port /dev/cu.usbmodem1101 --erase-all  # first time
scripts/flash.sh build_modbus_c6 --port /dev/cu.usbmodem1101 --app-only   # app updates
Option Effect
(none) MCUboot, the application into slot0, the provisioner into prov
--erase-all erase the whole chip first — use it the first time a board moves from a plain build to MCUboot
--erase-storage also erase storage (credentials, identity) and bootreq: back to factory state
--app-only only the application; credentials and the provisioner are kept
--before usb-reset for native-USB boards that refuse the default reset
--dry-run print the esptool commands

flash.sh runs esptool from PATH; set ESPTOOL=/path/to/esptool to use another (e.g. a venv).

Development signing key. The images are signed with MCUboot's public development key (bootloader/mcuboot/root-ec-p256.pem), so anyone can sign an image that these bootloaders accept. That is fine on the bench and not acceptable for production devices; key management belongs to the OTA work.

A plain build without --sysbuild still works on these boards, flashed at 0x0 (0x1000 on the WROOM) as before: it runs without MCUboot and without the provisioner, reads stored credentials if any, and otherwise uses the compile-time ones. The S2 Feather (no BLE radio) has no provisioner layout and builds as before; --sysbuild stops with a message for it.

The QT Py S3 needs --before usb-reset on flash.sh (native USB). On the WROOM MCUboot goes at 0x1000 (the classic ESP32 ROM's bootloader offset); flash.sh handles that.

The Espressif bootloader's own log says Loading image 0 - slot 1 when it launches the provisioner; the hook redirects that load to prov, and the I (prov-hook): launching provisioner line just above it is the one to trust.

Provisioning a device

Any unmodified Improv client works:

  • The Improv web page, https://www.improv-wifi.com/, in Chrome or Edge (desktop or Android — Web Bluetooth; not Safari/iOS).

  • Home Assistant, which discovers Improv BLE devices on its own.

  • scripts/improv_provision.py (needs pip install bleak) for the bench and tests, on macOS or Linux:

    scripts/improv_provision.py scan
    IMPROV_PSK='my-password' scripts/improv_provision.py --name tedge-modbus \
      provision --ssid my-network
    # → device URL: modbus://tedge-modbuse8f60afc320c.local:502

    The password is read from the environment so it stays out of shell history and ps.

The provisioner advertises under the application's unique hostname (e.g. tedge-modbuse8f60afc320c, the name it answers to at .local) and reports the application's service URL (opc.tcp://…:4840, modbus://…:502, snmp://…:161) once it has joined. It learns both from a small identity record the application writes into storage when it is online. On a device whose application has never been online it advertises as tedge-prov<mac> and returns no URL. A wrong password is reported as "unable to connect" and nothing is stored; the client can simply try again.

Which credentials win: stored (provisioned) credentials, then the compile-time CONFIG_APP_WIFI_SSID/PSK, then the provisioner. A device whose stored network stops working (moved, AP password changed) never goes to the provisioner by itself: it keeps reconnecting and doing its last-resort reboots. Re-provision it with the button.

Button (sw0, the BOOT button) — read while the application is running, never at reset (on every target it is the SoC's boot-strapping pin, and holding it through reset enters the ROM serial bootloader instead):

Gesture Effect
3 quick presses within 2 s Reboot into the provisioner. The stored network is kept until new credentials are verified, so an abandoned attempt returns to it when the window expires.
Hold ≥ 10 s, then release Erase the stored credentials and reboot into the provisioner. The LED flickers once the erase is armed, so you know before letting go.
anything else Nothing (1, 2 or 4+ presses, presses too slow, a shorter hold).
a press in the provisioner Restarts an expired window; authorizes provisioning when CONFIG_APP_WIFI_PROV_REQUIRE_AUTH=y.

Provisioning window: the provisioner advertises for CONFIG_APP_WIFI_PROV_WINDOW_S (default 900 s). If it was entered with the button pattern, the window's end returns to the application on its old network. With no credentials it stops advertising and waits for a button press, so an unattended fresh device does not advertise forever.

Status LED (the WROOM's GPIO2 LED; the RGB LED of the C6, the S3-DevKitC-1 and the QT Py S3, which also shows the colour):

Pattern Colour (RGB LED) Meaning
steady on green connected and serving
even blink, 250 ms on / 250 ms off amber not connected (associating, reconnecting)
two short blinks, then a pause (100 ms on, 150 off, 100 on, ~1.5 s off) blue provisioner, waiting for credentials
fast 5 Hz blink white identify request from a client, or the button pattern was recognized (just before the reboot)
very fast flicker red erase hold armed — release to erase
off provisioning window expired; press the button to advertise again

The application also logs every button press and release with its length (app_prov: sw0 released after 140 ms), then what it made of the sequence ("not a gesture, ignored", "Button pattern: …", "Erase armed: …"), so you can check a gesture on the console.

Options. Application (lib/common/Kconfig): APP_PROV_HANDOFF (on by default in an MCUboot build with the provisioner layout), APP_WIFI_PROV_PRESS_COUNT (3), APP_WIFI_PROV_PRESS_WINDOW_MS (2000), APP_WIFI_PROV_ERASE_HOLD_S (10). Provisioner (apps/wifi-provisioner/Kconfig): APP_WIFI_PROV_WINDOW_S (900), APP_WIFI_PROV_CONNECT_TIMEOUT_S (30), APP_WIFI_PROV_REQUIRE_AUTH (off); set them for the provisioner image with a sysbuild image prefix, e.g. -Dwifi-provisioner_CONFIG_APP_WIFI_PROV_REQUIRE_AUTH=y.

Security — read before deploying:

  • Improv sends the password in the clear over the BLE link. Anyone in radio range during an active provisioning session can capture it. The window is bounded and the provisioner is only entered with no credentials or by a deliberate button pattern; CONFIG_APP_WIFI_PROV_REQUIRE_AUTH=y additionally requires a button press on the device before settings are accepted.
  • Stored credentials sit unencrypted in flash (storage_partition) and can be read out with esptool by anyone with physical access — no worse than a baked-in overlay, but not protected either.
  • Images are signed with the public development key (above).

Credentials survive reboots, application updates (--app-only, and a slot1 swap) and a reflash of the provisioner. To wipe them without the button, use scripts/flash.sh <build> --erase-storage.

Provisioning Wi-Fi and Cumulocity with lab-ztp-provisioner

Improv carries only an SSID and a password. Built with CONFIG_APP_PROV_ZTP=y instead, the provisioner speaks the BLE protocol of lab-ztp-provisioner: one session delivers the Wi-Fi credentials and Cumulocity onboarding — tenant, external ID and a one-time password the server has already registered with Cumulocity. The device enrols with the Cumulocity CA on its first connection, and nobody reads a registration URL off a console or opens the Cumulocity UI.

It is a build-time choice (APP_PROV_PROTOCOL), one protocol per image: two 128-bit service UUIDs do not fit one advertisement, and the prov partition 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:

OVR="/ws/app/tedge-zephyr/profiles/full.conf;\
/ws/app/apps/modbus-server/boards/esp32c6_devkitc_esp32c6_hpcore_tedge.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 \
  --pristine -d build-ztp -- "-DEXTRA_CONF_FILE=$OVR" \
  -Dwifi-provisioner_EXTRA_CONF_FILE=/ws/app/apps/wifi-provisioner/overlay-ztp.conf

On the server, the device's profile must select the p256 crypto suite — Mbed TLS, and so every Zephyr build, has no Ed25519 — and mint a token:

name: zephyr
crypto:
  suite: p256
payload:
  wifi:
    networks:
      - { ssid: my-network, password: …, key_mgmt: WPA-PSK }
  cumulocity:
    issuer: { mode: local, credential_ref: my-tenant }

Any lab-ztp-provisioner relay drives it — the web app, the desktop app, or scripts/ztp_provision.py on the bench:

scripts/ztp_provision.py scan
scripts/ztp_provision.py enroll --server https://ztp.local:8443 --insecure --wait 600

What happens: the device advertises ztp; the relay writes the time and asks for an enrollment request, which the device signs with its own P-256 key (kept 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 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.

Trust model — read before deploying:

  • The one-time password is end-to-end encrypted to a key the device makes for the session (P-256 ECDH, HKDF-SHA256, ChaCha20-Poly1305). The relay, the BLE link and the server's own logs only ever see ciphertext.
  • The rest of the bundle, the Wi-Fi password included, is not. It travels in the clear over BLE, as with Improv.
  • The device does not yet verify the server's signature on the bundle (trust on first use, logged as a warning on every provisioning). A hostile relay could therefore choose the Wi-Fi network and the tenant. It cannot read the real token, and cannot enrol as the device. CONFIG_APP_PROV_ZTP_SERVER_PUBKEY is reserved for pinning the server key; setting it today makes the device refuse every bundle rather than pretend to check.
  • A provisioned device keeps its certificate. Re-provisioning it for a different tenant does not work yet: erase storage first (scripts/flash.sh <build> --erase-storage).

Sizes (measured): the provisioner is 96.9% of prov on the ESP32-C6 and 75.5% on the WROOM-32; the application grows by 65 bytes of RAM. Unit tests, checked against the Go server's own output: tests/ztp_provisioner on native_sim.

Flash layouts

4 MB: ESP32-C6, QT Py S3, WROOM-32 16 MB: ESP32-S3-DevKitC-1
mcuboot 0x000000, 64 KB (WROOM: 0x001000, 60 KB) 0x000000, 64 KB
slot0 / slot1 0x020000 / 0x160000, 1280 KB each 0x020000 / 0x320000, 3 MB each
prov 0x2a0000, 1 MB 0x620000, 2 MB
bootreq 0x3a0000, 4 KB 0x820000, 4 KB
storage 0x3b0000, 192 KB 0x830000, 192 KB
scratch 0x3e0000, 124 KB 0x860000, 128 KB

Layout files: lib/common/dts/layout-esp32c6-4M.dtsi, layout-esp32s3-4M.dtsi, layout-esp32-4M.dtsi, layout-esp32s3-16M.dtsi.

BLE provisioning support by board

Board Status
ESP32-C6 verified on hardware: Modbus and OPC-UA provisioned from improv_provision.py and served; SNMP builds and fits
ESP32-S3-DevKitC-1 (N16R8) verified on hardware: Modbus provisioned and served; OPC-UA and SNMP build and fit
QT Py ESP32-S3 (N4R2) verified on hardware: Modbus provisioned from a Raspberry Pi 5 (BlueZ, improv_provision.py) and served
ESP32-WROOM-32 verified on hardware: OPC-UA (two boards) and SNMP (an ESP32-D0WD-V3 board) provisioned from the Pi and served; OPC-UA read and snmpget on the data path
Feather ESP32-S2 TFT unsupported: no BLE radio

Image sizes with the provisioner split out (zephyr.signed.bin; the libc heap is what the application's frontend allocates from, "before" is the same app before BLE provisioning existed; scripts/measure_prov.sh regenerates it):

Board Image Size Partition libc heap (before)
ESP32-C6 Modbus 732 KB 1280 KB 298,528 B (291,664 B)
ESP32-C6 OPC-UA 839 KB 1280 KB 256,384 B (249,568 B)
ESP32-C6 SNMP 731 KB 1280 KB 268,224 B (261,408 B)
ESP32-C6 provisioner 943 KB 1024 KB
ESP32-S3-DevKitC-1 Modbus 582 KB 3072 KB 200,588 B (197,852 B)
ESP32-S3-DevKitC-1 SNMP 581 KB 3072 KB 170,372 B (167,636 B)
ESP32-S3-DevKitC-1 provisioner 662 KB 2048 KB
QT Py ESP32-S3 Modbus 583 KB 1280 KB
QT Py ESP32-S3 provisioner 662 KB 1024 KB
ESP32-WROOM-32 OPC-UA 750 KB 1280 KB 73,488 B (71,696 B)
ESP32-WROOM-32 provisioner 749 KB 1024 KB

The applications keep all of their heap: Bluetooth only costs the provisioner.

Classic ESP32 (WROOM) RAM. Under simple boot the ESP32 linker puts .noinit (thread stacks and network buffers, ~68 KB for OPC-UA) in the SRAM1 region a second-stage bootloader would otherwise use; Zephyr turns that off for MCUboot builds, which cost the OPC-UA app its heap (71 KB → 3 KB). The WROOM board .conf files set CONFIG_ESP32_REGION_1_NOINIT=y again: .noinit is never loaded, so MCUboot has finished with that RAM by the time the application uses it. The same setting is what lets the provisioner link there at all, despite the 55 KB the Bluetooth controller reserves at link time. The C6 provisioner uses 92 % of its partition: that is the one to watch. It can be trimmed (logging, the unused simulation code it links from lib/common) if a Zephyr update grows it.

Connectivity resilience & status LED

The connectivity layer (lib/common/net.c) recovers autonomously from network disruptions — you should not need to power-cycle a device to get it back online:

  • A watchdog on the 3 s status tick treats "not connected or no IPv4 address" as offline (independent of Wi-Fi events), forces reconnects, and detects a silently-lost DHCP lease.
  • Reconnect is robust: a stale association is cleared before retrying, and a failed connect is retried rather than abandoned.
  • Last-resort self-reboot — if a device stays offline past CONFIG_APP_NET_REBOOT_TIMEOUT_S (default 300 s) despite retries, it reboots to recover. Disable with CONFIG_APP_NET_RECONNECT_REBOOT=n (e.g. on the bench). This is network-level recovery: it runs on the status tick, so it cannot help if the firmware itself has stalled. That case is the liveness watchdog's job (see Stalls, liveness watchdog & diagnostics).
  • The status tick and reconnects run on a dedicated connectivity work queue (net_wq, CONFIG_APP_NET_WORKQ_STACK_SIZE, default 3072), not the system workqueue, whose 1 KB stack they overflowed on the ESP32.

Status LED (CONFIG_APP_STATUS_LED, on by default): tells you at a glance whether the device is on the network — blinking = not connected (booting/associating/reconnecting), steady = connected and serving. So if the LED is steady but a collector can't read the device, the problem is the collector/network path, not the device. It uses the board's led0 alias (WROOM: on-board LED on GPIO2, see the board .overlay) or, on boards without one, an addressable RGB LED behind the led-strip alias, which also shows the state in colour (green connected, amber not connected): the ESP32-C6 (WS2812 on GPIO8, lib/common/dts/rgb-led-esp32c6-gpio8.dtsi) and the ESP32-S3-DevKitC-1 (GPIO48 on board v1.0, GPIO38 on v1.1; rgb-led-esp32s3-devkitc.dtsi). It's a QT Py S3's NeoPixel comes from its upstream board devicetree (with a GPIO hog powering it). It's a no-op on boards without an LED (the S2 TFT shows the same state on-screen).

ESP32-CAM (built as esp32_devkitc/esp32/procpu): the DevKitC overlays put led0 on GPIO2, which is an SD card line on the CAM. Its status LED is the small red LED on the back of the module, on GPIO33 and active low; the red LED on the ESP32-CAM-MB USB base is its power LED. Add lib/common/dts/esp32cam-status-led.overlay to the application and, with --sysbuild, to the provisioner:

west build --sysbuild -b esp32_devkitc/esp32/procpu apps/snmp-agent -- \
  -DEXTRA_DTC_OVERLAY_FILE=/ws/app/lib/common/dts/esp32cam-status-led.overlay \
  -Dwifi-provisioner_EXTRA_DTC_OVERLAY_FILE=/ws/app/lib/common/dts/esp32cam-status-led.overlay

Stalls, liveness watchdog & diagnostics

A device that stops answering and never comes back without a power-cycle has a stalled firmware, not just a dropped network. The ESP32 builds had exactly that until the system workqueue stack overflow was fixed; the record is in openspec/changes/esp32-network-freeze-investigation/evidence.md. Three tools help with the next one.

Liveness watchdog (CONFIG_APP_LIVENESS)

Each watched context (the system workqueue, the connectivity queue net_wq, the protocol server thread and, for SNMP, the trap sender) gets a task watchdog channel that is fed only when that context makes progress. If one stops for CONFIG_APP_LIVENESS_TIMEOUT_S (default 30 s), the device resets. The SoC watchdog (watchdog0) backs this up, so a lockup with interrupts masked also resets the device, after about 5 s. The next boot says why:

<err> app_liveness: LIVENESS RESET: context netwq stalled at uptime 1234 s (boot 3)
<inf> app_liveness: reset cause 0x2 (software) esp_reason 3 boot 3
  • LIVENESS RESET: context <ctx> means the named context stopped making progress (wq, netwq, proto or trap), with the uptime when it did.
  • hardware watchdog reset means the task watchdog never got to run: a hard lockup that only the SoC watchdog caught.
  • boot N counts resets since the last power-on, so a reset loop shows up.

The watchdog is on by default for Wi-Fi builds, since the acceptance soaks (24 h and 10 h on four boards, plus an access-point restart) produced no false reset. For bench work under a debugger, where a reset would hide the problem, set CONFIG_APP_LIVENESS=n.

The connectivity queue has its own, longer timeout (CONFIG_APP_LIVENESS_NETWQ_TIMEOUT_S, default 120 s): Espressif Wi-Fi driver calls block it for well over 30 s while an access point disappears or returns, and resetting for that is a false positive. CONFIG_APP_LIVENESS_SELFTEST (test builds only) injects a failure after CONFIG_APP_LIVENESS_SELFTEST_DELAY_S: a blocked system workqueue, a stopped protocol loop, or a busy loop with interrupts masked.

A protocol frontend feeds its channel by calling app_alive(APP_CTX_PROTO) from its own loop. Blocking waits must be bounded so an idle server still feeds it; see lib/common/README.md.

Diagnostic overlay (overlay-diag.conf)

Add overlay-diag.conf to EXTRA_CONF_FILE to get CONFIG_APP_DIAG with immediate logging, net buffer usage, heap statistics and the thread analyzer. Every CONFIG_APP_DIAG_PERIOD_S (default 10 s) a thread that is not on the system workqueue logs:

<inf> app_diag: HEALTH up=120 n=12 beat[wq=1 netwq=2 proto=0 trap=0] work[status=D reconn=- sim=D probe=D] heap=23216/56112 malloc=71324/71324 pkt[rx=8/8 tx=8/8] buf[rx=24/24 tx=24/24] min[rx=19 tx=21] net=up
<inf> app_diag: WIFI st=9 rssi=-61 ch=11
  • beat[...]: seconds since each context last made progress (- means it has not started).
  • work[...]: state of the firmware's work items: R running, Q queued, D delayed, C cancelling, - idle.
  • heap and malloc: free and total bytes. pkt, buf and min: free net packets and buffers, and the lowest buffer count seen.
  • A HEALTH line with no WIFI line after it means the Wi-Fi status query blocked.

When a context has not made progress for three periods, a STALE line names its thread, state and the wait queue it is blocked on (pended_on), followed by a dump of every thread. Map the address to a kernel object with nm -n -S build/zephyr/zephyr.elf, using the ELF of the build that ran.

Immediate logging and the analyzer change timing and use more stack. The overlay raises the workqueue stacks to match; don't ship it.

Soak harness (scripts/soak/)

scripts/soak/run.sh runs one timed experiment against a device and writes comparable records:

# build (inside the container) and flash from the host
scripts/soak/build.sh build_soak snmp-agent esp32_devkitc/esp32/procpu soak-trap.local.conf
scripts/soak/flash.sh build_soak /dev/cu.usbserial-210
# one hour, console captured from boot, SNMP polled every 5 s, traps recorded
scripts/soak/run.sh --board esp32-cam --app snmp --host 192.168.68.74 \
    --port /dev/cu.usbserial-210 --variant baseline --duration 3600

The run's files go to scripts/soak/runs/ (git-ignored):

  • *.console.log: the console, stamped with host time.
  • *.polls.log: one probe and one ping every 5 s.
  • *.traps.log: traps received by an unprivileged snmptrapd on port 1162.
  • *.summary.json: outages (3 failed probes in a row), time to failure, recovery time, whether the device ever answered, and counts of boots and liveness resets from the console.

See scripts/soak/README.md for the options and the per-protocol probes.

On the ESP32-CAM, opening the serial port resets the board, so the harness opens it once, at the start of the run. When soaking the Modbus server, stop other Modbus clients first: the server serves one client at a time.

Liveness and diagnostics options

Kconfig Default Purpose
APP_LIVENESS y on Wi-Fi builds Reset the device when a watched context stalls
APP_LIVENESS_NETWQ_TIMEOUT_S 120 Same, for the connectivity queue
APP_LIVENESS_TIMEOUT_S 30 Seconds without progress before a reset
APP_LIVENESS_SELFTEST n Inject a failure (test builds only)
APP_DIAG n Health lines and stall reports (use overlay-diag.conf)
APP_DIAG_PERIOD_S 10 Health line period
APP_NET_WORKQ_STACK_SIZE 3072 Connectivity work queue stack
APP_MODBUS_CLIENT_IDLE_TIMEOUT_S 60 Drop a Modbus client that sends nothing for this long

Memory footprint (ESP32-WROOM-32, esp32_devkitc/esp32/procpu)

Measured with Zephyr 4.4.2 and SDK 1.0.1 (FLASH and dram0_0_seg from the link map), with only the Wi-Fi credentials overlay unless noted. "Before" is the tree before the freeze fix.

App Before: flash / DRAM Release: flash / DRAM + liveness + overlay-diag.conf + liveness
opcua-server 735,536 B / 124,088 B (63.1%) 735,664 B / 124,288 B (63.2%) 737,840 B / 124,656 B (63.4%) 739,120 B / 124,176 B (63.2%)
modbus-server 575,984 B / 100,784 B (51.3%) 576,224 B / 100,984 B (51.4%) 578,288 B / 101,352 B (51.6%) 580,032 B / 100,872 B (51.3%)
snmp-agent 564,880 B / 125,416 B (63.8%) 564,992 B / 125,616 B (63.9%) 566,960 B / 125,968 B (64.1%) 568,544 B / 125,504 B (63.8%)

Flash is out of 4,194,048 B (all builds use 13–18%).

Thread stacks sit in the no-init RAM section, which the DRAM figure above does not include. The fix grows that section by 6,112 B:

  • the net_wq stack (3,072 B);
  • a larger system workqueue stack (+1,024 B);
  • a larger log thread stack (+1,024 B);
  • a larger socket-service stack (+992 B).

On the WROOM, opcua-server takes its libc malloc arena from the remaining RAM. It reports libc heap size 70 kB at boot both before and after the fix (with liveness on), so the larger stacks don't reduce it.

QT Py ESP32-S3 release builds: opcua-server 684,500 B / 239,792 B (60.1%), modbus-server 579,428 B / 196,488 B (49.2%), snmp-agent 578,068 B / 219,232 B (54.9%). All builds above compile with no warnings.

ESP32-S3-DevKitC-1 (esp32s3_devkitc/esp32s3/procpu), credentials overlay only, RAM out of 399,108 B. Flash is the N16R8's 16,776,960 B, so all three sit under 5%:

App flash dram0
opcua-server 750,836 B 243,296 B (61.0%)
modbus-server 580,244 B 201,256 B (50.4%)
snmp-agent 578,916 B 231,472 B (58.0%)

RAM lands within a point of the QT Py S3, as expected for near-identical silicon. The flash numbers are not comparable to the QT Py release figures above — these are plain debug builds.

ESP32-C6 (esp32c6_devkitc/esp32c6/hpcore), credentials overlay only, RAM out of 509,456 B. Flash here is the 4 MB part, of which slot0_partition is 1792 KB (1,835,008 B) — the figure that actually has to fit:

App flash zephyr.bin (% of slot0) sram0
opcua-server 836,036 B 828,752 B (45.1%) 259,888 B (51.0%)
modbus-server 729,572 B 723,536 B (39.4%) 217,792 B (42.8%)
snmp-agent 662,260 B 709,904 B (38.6%) 248,048 B (48.7%)

RISC-V images run roughly 80 KB larger than their Xtensa equivalents — ordinary code-density difference, and easily absorbed. The smallest-flash board in the fleet still runs the largest application with over half its slot free, so the 4 MB part was never the constraint it looked like.

Finding the device (mDNS / DNS-SD)

The firmware advertises over mDNS, so no IP is needed. On macOS (Bonjour):

ping tedge-opcua.local                 # resolves to the device's DHCP address
dns-sd -B _opcua-tcp._tcp              # lists the "tedge-opcua" OPC-UA service
dns-sd -B _modbus._tcp                 # lists the "tedge-modbus" Modbus service
dns-sd -B _snmp._udp                   # lists the "tedge-snmp<mac>" SNMP agent

Each firmware advertises its own DNS-SD service type (_opcua-tcp/_modbus over TCP, _snmp over UDP) via CONFIG_APP_DNSSD_*, and answers to its unique <hostname>.local name.

Discovery note. The devices answer service-discovery queries correctly: a browse from a Linux host (for example Python zeroconf, or avahi-browse) lists every device with its address, port and TXT record, and <hostname>.local lookups work everywhere. On one macOS machine, dns-sd -B listed nothing for these service types while browsing _ssh._tcp worked; packet captures showed macOS never sent the query, so that is a client-side quirk rather than a device fault. Note also that Zephyr 4.4.2 does not answer direct SRV/TXT queries (only PTR), which is what dns-sd -L asks for.

Point an OPC-UA client at opc.tcp://tedge-opcua.local:4840. Quick check with the bundled Python client:

~/flashenv/bin/python - <<'PY'
import asyncio
from asyncua import Client
async def main():
    async with Client("opc.tcp://tedge-opcua.local:4840", timeout=15) as c:
        dev = [x for x in await c.nodes.objects.get_children()
               if (await x.read_browse_name()).Name == "tedge-opcua-device"][0]
        for m in await dev.get_children():
            print((await m.read_browse_name()).Name, "=", await m.read_value())
asyncio.run(main())
PY

Configuration options

Board-agnostic settings live in Kconfig / prj.conf:

Kconfig Default Purpose
APP_DEVICE_NAME tedge-opcua-device OPC-UA application/server name
APP_OPCUA_PORT 4840 OPC-UA opc.tcp port
APP_SAMPLE_INTERVAL_MS 1000 data-source sampling interval
APP_WIFI_SSID / APP_WIFI_PSK (empty) Wi-Fi credentials (via overlay)

The mDNS hostname is CONFIG_NET_HOSTNAME (tedge-opcua), set per board. Override any value at build time, e.g. -- -DCONFIG_APP_OPCUA_PORT=4855.

Writable data points & subscriptions

All application nodes live in namespace ns=1. The Device object exposes two writable control nodes alongside the read-only measurements:

Node id Name Type Access Notes
ns=1;s=Setpoint Setpoint Int32 read/write operator target; clamped to APP_SETPOINT_MIN..APP_SETPOINT_MAX (default −1000..1000)
ns=1;s=Running Running Boolean read/write whether the simulated process is running

Writes are validated (out-of-range Setpoint is clamped; writing a read-only measurement returns BadNotWritable). Values are held in RAM (not persisted). Example:

from asyncua import ua
await client.get_node("ns=1;s=Setpoint").write_value(ua.Variant(500, ua.VariantType.Int32))
await client.get_node("ns=1;s=Running").write_value(ua.Variant(True, ua.VariantType.Boolean))

Subscriptions (monitored items / change notifications) are off by default. They require open62541's REDUCED namespace-zero, whose larger nodeset OOMs at namespace init on the ESP32-WROOM's ~68 KB heap. So the committed amalgamation uses the minimal profile (writes work, subscriptions off). To enable subscriptions on a higher-RAM board (e.g. an ESP32-S2 with PSRAM), regenerate with the reduced profile and rebuild:

scripts/regen-open62541.sh <open62541-src> 300 reduced   # NS0=REDUCED, subscriptions ON

Subscription resource caps are set from APP_OPCUA_MAX_SUBSCRIPTIONS / APP_OPCUA_MAX_MONITORED_ITEMS.

open62541 integration

open62541 is vendored as a single-file amalgamation in third_party/open62541/, regenerated by scripts/regen-open62541.sh. It is built with a minimal, read-only, POSIX-architecture profile (MINIMAL namespace-0; no subscriptions/methods/discovery/history; single-threaded; 8 kB buffers) and carries small Zephyr portability patches (IPv6 off; skip the interrupt/UDP/ Ethernet connection managers; neutralize pipe(); 8 kB shared RX buffer; getaddrinfo NULL-host fallback to 0.0.0.0; monotonic clock via k_uptime_get()). To refresh it:

docker exec -w /ws/app -e WEST_TOPDIR=/ws zephyr-dev \
  bash scripts/regen-open62541.sh /ws/modules/lib/open62541 300

RAM notes (WROOM-32 has no PSRAM)

open62541 shares the WROOM's ~300 KB SRAM with the Wi-Fi stack (~68 KB free heap after Wi-Fi). It fits only after: MINIMAL namespace-0, an 8 KB shared RX buffer, 8 KB per-connection buffers, and a small CONFIG_HEAP_MEM_POOL_SIZE (open62541 uses the libc malloc arena, sized -1 = all remaining RAM). The ESP32-S2 (2 MB PSRAM) has far more headroom if these limits become tight.

Repository layout (multi-protocol workspace)

Firmware is organised as a shared core plus per-protocol libraries and apps (see lib/common/README.md for the protocol-frontend contract):

lib/common/            shared core: connectivity, display, data model, identity
                       + selectable simulations (sim_environment, sim_pump, sim_switch)
lib/opcua/             OPC-UA frontend (open62541) + address-space adapter
lib/modbus/            Modbus TCP frontend (Zephyr modbus subsystem, port 502)
lib/snmp/              SNMPv2c agent frontend (in-repo BER; UDP 161 + traps 162)
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
  ├── boards/<board>.conf       per-app board overlays (RAM/Wi-Fi tuning)
  └── points.d/<proto>/*.toml   point library: the app's address map for a
                                collector (see "Point libraries" below)

Each firmware is built by targeting its app directory, e.g. west build -b <board> apps/modbus-server. A new protocol becomes a new lib/<protocol> + apps/<protocol> pair; nothing else needs to change.

Simulations (per-firmware, selectable)

The shared data model is driven by a simulation chosen with a Kconfig choice (in each app's prj.conf):

  • CONFIG_APP_SIM_ENVIRONMENT (default) — temperature/humidity/pressure + Setpoint/Running. Used by apps/opcua-server.
  • CONFIG_APP_SIM_PUMP — a control-driven pump/motor: measurements react to the speed_setpoint/running/mode controls via pump affinity laws (flow ∝ speed, pressure ∝ speed²), a motor-thermal lag, run_hours that accrue only while running, and an over-temp fault. Used by apps/modbus-server.
  • CONFIG_APP_SIM_SWITCH — a managed switch/router: a fixed set of Ethernet interfaces (CONFIG_APP_SIM_SWITCH_IF_COUNT, ≤ 8) with admin/oper status, 1 Gbit/s nominal speed and monotonic traffic counters; one port flaps its link every CONFIG_APP_SIM_SWITCH_FLAP_PERIOD_STEPS sampling steps (0 = never) to drive link up/down events. Used by apps/snmp-agent, which sets 900 (a flap every 15 minutes).

Frontends are simulation-agnostic, so any simulation can back any protocol.

Modbus TCP server firmware

Build and flash exactly like the others, targeting apps/modbus-server (Wi-Fi board; no serial/RS-485 — this is Modbus TCP on port 502):

docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \
  west build -b esp32_devkitc/esp32/procpu apps/modbus-server --pristine \
  -- -DEXTRA_CONF_FILE=/ws/app/overlay-wifi-credentials.conf
# flash as for the WROOM/S3 above; advertises _modbus._tcp on port 502

Register map (unit id 1, zero-based; pump simulation):

Object Addr Meaning
Input Reg (RO) 0 / 1 / 2 / 3 / 4 flow ×10 / pressure ×100 / motor_temp ×10 (signed) / rpm / vibration ×100
Input Reg (RO) 10–11 run time, seconds — uint32, big-endian pair
Input Reg (RO) 20–21 / 22–23 / 24–25 flow / pressure / motor_temp as IEEE-754 float (BE pairs)
Holding Reg (RW) 0 / 1 speed_setpoint (0–100) / mode (0=off,1=auto,2=manual)
Coil (RW) 0 running
Discrete In (RO) 0 / 1 / 2 running mirror / fault (over-temp) / network connected

Client test recipe (pymodbus; pip install pymodbus):

from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient("<device-ip>", port=502); c.connect()
c.write_coil(0, True, device_id=1)          # start
c.write_register(1, 2, device_id=1)         # mode = manual
c.write_register(0, 80, device_id=1)        # speed 80 %  (write 150 -> clamps to 100)
print(c.read_input_registers(0, count=5, device_id=1).registers)  # flow,pressure,temp,rpm,vib
print(c.read_discrete_inputs(0, count=3, device_id=1).bits)       # running,fault,net

SNMP agent firmware

apps/snmp-agent presents the device as a managed switch/router: a minimal SNMPv2c agent (UDP 161) answering GET/GETNEXT/GETBULK over the system group + a MIB-II interfaces table, plus traps (UDP 162) on coldStart and interface link up/down. It uses an in-repo BER/ASN.1 codec — no external SNMP stack — and is UDP-only, so it also runs end-to-end on native_sim. On hardware it advertises itself over mDNS as _snmp._udp and answers to <hostname>.local.

docker exec -w /ws/app -e ZEPHYR_SDK_INSTALL_DIR=$SDK zephyr-dev \
  west build -b esp32_devkitc/esp32/procpu apps/snmp-agent --pristine \
  -- -DEXTRA_CONF_FILE=/ws/app/overlay-wifi-credentials.conf
# point traps at your manager, e.g. -DCONFIG_APP_SNMP_TRAP_MANAGER=\"192.168.68.10\"

Discover it (mDNS / DNS-SD):

dns-sd -B _snmp._udp local.                       # lists "tedge-snmp<mac>" instances
dns-sd -L tedge-snmp<mac> _snmp._udp local.        # -> <hostname>.local:161
snmpwalk -v2c -c public tedge-snmp<mac>.local 1.3.6.1.2.1   # poll by name, no IP

The hostname is unique (tedge-snmp + Wi-Fi MAC, from CONFIG_NET_HOSTNAME_UNIQUE), so the base tedge-snmp.local does not resolve — use the full advertised name.

Poll it (net-snmp; -v2c -c public — the agent serves v2c only):

snmpget    -v2c -c public <device-ip> sysDescr.0 sysName.0 sysUpTime.0
snmpwalk   -v2c -c public -On <device-ip> 1.3.6.1.2.1        # system + ifTable
snmpbulkwalk -v2c -c public <device-ip> 1.3.6.1.2.1.2.2      # ifTable via GETBULK

MIB view (all read-only; SET is refused with notWritable):

OID Object Type Source
1.3.6.1.2.1.1.1.0 sysDescr OCTET STRING firmware name/version/build
1.3.6.1.2.1.1.3.0 sysUpTime TimeTicks since agent start
1.3.6.1.2.1.1.5.0 sysName OCTET STRING device hostname
1.3.6.1.2.1.2.1.0 ifNumber INTEGER interface count
…2.2.1.{1,2,3,4,5}.<n> ifIndex/ifDescr/ifType/ifMtu/ifSpeed INTEGER/STRING/Gauge32 switch sim
…2.2.1.{7,8}.<n> ifAdminStatus/ifOperStatus INTEGER (up=1,down=2) switch sim
…2.2.1.{10,11,16,17}.<n> ifIn/OutOctets, ifIn/OutUcastPkts Counter32 switch sim
1.3.6.1.4.1.99999.1.{1,2,3}.0 firmware name / version / build timestamp OCTET STRING lib/common identity

The last three live on the private-enterprise arc sysObjectID.0 names. They are the same strings sysDescr.0 packs into one sentence, given one object each so a collector can report the running firmware without parsing prose:

snmpget -v2c -c public -On <device-ip> 1.3.6.1.4.1.99999.1.2.0   # -> "0.1.0"
snmpwalk -v2c -c public -On <device-ip> 1.3.6.1.4.1.99999        # name, version, build

Receive traps (net-snmp snmptrapd, on the configured manager host):

sudo snmptrapd -f -Lo -c /dev/null      # prints coldStart on boot, then
                                        # linkDown/linkUp with ifIndex as ports flap

Trap volume. One port toggles every CONFIG_APP_SIM_SWITCH_FLAP_PERIOD_STEPS sampling steps. The firmware sets 900 — a flap every 15 minutes, so ~8 notifications an hour, each also raising or clearing an alarm in a collector. The Kconfig default of 15 steps (15 s, ~5,700 notifications a day) makes a short demo lively but a device left running noisy. Override per build:

-DCONFIG_APP_SIM_SWITCH_FLAP_PERIOD_STEPS=15    # a flap every 15 s (demo)
-DCONFIG_APP_SIM_SWITCH_FLAP_PERIOD_STEPS=0     # links never flap

Point libraries (for a tedge-dot collector)

Each firmware ships the point list a collector needs to read it, as a point library — the address map of one device type in its own TOML file, with no connection details in it. The firmware owns that map (the register layout, the node ids, the OIDs), so the list lives next to the firmware and is versioned with it, rather than being copy-pasted into every gateway that polls one of these boards. The format is tedge-dot's (OT-connector contract §3.4).

apps/opcua-server/points.d/opcua/zephyr-opcua.toml          ns=1 nodes: measurements + Setpoint/Running
apps/modbus-server/points.d/modbus/zephyr-modbus-pump.toml  unit 1 register map: pump sim + controls
apps/snmp-agent/points.d/snmp/zephyr-snmp-switch.toml       system group, ifTable rows, traps

A device instance then declares only where to reach the board, and names the library it is an instance of:

[[device]]
name             = "tedge-snmp-device"
protocol_address = { host = "tedge-snmp<mac>.local", port = 161, version = "v2c", community = "public" }
points_from      = ["zephyr-snmp-switch"]

A bare name resolves as <dir>/<protocol>/<name>.toml along the collector's search path, so from a checkout point that path at the app directory (an installed package finds its own copies):

export TEDGE_DOT_POINT_LIBRARY_PATH=/path/to/apps/snmp-agent/points.d
tedge-dot read -c snmp.toml -p if4_oper_status

Installing them on a gateway. Copy the file this repo owns into the collector's site directory, which is searched before the packaged one and is not overwritten by a package upgrade. The file name is what points_from refers to, so name it accordingly:

# on the gateway, from a checkout of this repo
install -o tedge -g tedge -m 644 apps/modbus-server/points.d/modbus/zephyr-modbus-pump.toml \
    /etc/tedge/plugins/ot/points.d/modbus/zephyr-pump.toml        # points_from = ["zephyr-pump"]
install -o tedge -g tedge -m 644 apps/opcua-server/points.d/opcua/zephyr-opcua.toml \
    /etc/tedge/plugins/ot/points.d/opcua/zephyr-opcua.toml
sudo systemctl restart tedge-dot

Don't hand-copy them into /usr/share/tedge-dot/points.d/: those are the package's own, an upgrade replaces them, and a stale copy there silently outlives a fix. (One did: a wrong decimal_shift sign published pump readings up to 10,000x too large until the gateway copy was replaced.) tedge-dot logs which library each device resolved from, so a restart confirms the copy in use.

What the SNMP library declares. Meaning is attached to each point next to its address, so the device shows up usefully with no per-deployment flow parameters:

Points Declared as
Measurements uptime, per-port if<n>_{in,out}_{octets,ucast_pkts} plain points with a unit
Alarms per-port if<n>_oper_status meta.alarm, one alarm type per port, raised when.equals = 2
Events link_down_if, link_up_if, cold_start meta.event with every = true — traps are occurrences
Device state firmware_name, firmware_version, build_timestamp, sys_* meta.measurement = false; the first three also meta.parameter.key = "firmware.*"

Link state is alarmed from the polled ifOperStatus column rather than from the traps, so an alarm names the port it is about and clears on the next poll that reads it up; the traps become events instead. The file documents the other arrangement (alarm on the trap) in a commented block at its end.

Two things to keep in sync when the firmware changes: the ifTable rows cover CONFIG_APP_SIM_SWITCH_IF_COUNT = 5, and polling a row the agent does not serve yields a permanently bad sample rather than nothing — a build with a different interface count needs rows added or switched off with enabled = false.

Adding another Wi-Fi board

  1. Add apps/<app>/boards/<fully-qualified-board>.conf (e.g. esp32s2_saola.conf) with that board's Wi-Fi driver + IP stack + mDNS + CONFIG_NET_HOSTNAME — copy an existing ESP32 conf. HWMv2 matches the fully-qualified filename (board + qualifiers, /_).
  2. Build with -b <board> apps/<app>; the core and OPC-UA logic need no changes.

A port should stay board files only — if it starts wanting changes in lib/ or apps/*/src, that is a portability bug worth reporting rather than patching around. Four checks, learned from the ports above, save most of the time:

  • Does the board devicetree already enable &wifi? Don't add the overlay reflexively. The QT Py S3 and S2 Feather need one; the ESP32-S3-DevKitC and ESP32-C6 already set it to okay, and adding a redundant overlay only obscures which boards genuinely need it.
    docker exec zephyr-dev grep -n -A2 '&wifi' /ws/zephyr/boards/<vendor>/<board>/<board>.dts
  • Which port should you actually use? On a board with both a UART socket and a native USB socket, prefer the UART one: its bridge drives DTR/RTS, so esptool resets the board itself (no BOOT/RESET press) and the console survives a reset, giving you the boot log from line one. Native USB re-enumerates on reset, loses early boot output, and can need a manual BOOT+RESET if unrelated firmware holds the port. Only re-chose the console onto &usb_serial when the board has no UART socket — as on the C6 here. Check the board's zephyr,console against the port you will actually plug into before assuming either way.
  • Does the declared flash match the part you hold? Board devicetrees assume a module variant (..._wroom_n8.dtsi and friends). Read the real part with esptool flash-id and correct &flash0 in the overlay if they disagree.
  • Is there really a led0? Many modern boards have only a WS2812 addressable RGB LED. Don't invent a GPIO led0 for it: give it a led-strip alias instead (see lib/common/dts/rgb-led-*.dtsi, after Zephyr's samples/drivers/led/led_strip overlays), and the status indicator drives it in colour. Without either, the indicator no-ops.

Then verify on the data path, not on association: a DHCP lease and a Wi-Fi "connected" log prove very little on their own, as the S2 demonstrates. Ping the board, resolve its mDNS name, and complete one protocol round-trip before calling a board verified in the targets table.

About

Research into Zephyr RTOS

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages