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.
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).
| 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 thatlib/common/and the protocol frontends are genuinely architecture-portable rather than accidentally Xtensa-shaped. All three were then verified on hardware: SNMP (snmpget/snmpwalkof 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_ETHERNETare correct and identical to the working WROOM), and RAM starvation (server disabled + enlarged Wi-Fi buffers still hang). Diagnose in the field withCONFIG_APP_PING_TARGET+ the on-displayGWPINGline. 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 1in the window before the DHCP lease installs a gateway, andnet_dhcpv4: DHCP server provided more DNS servers than can be savedwhen the router offers more servers thanDNS_SERVER_COUNTslots. 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_simuses host-offloaded sockets (NSOS). The firmware builds and the OPC-UA server starts, but the OPC-UA handshake cannot complete under NSOS because itsselect()does not report readability on accepted sockets. Use real hardware for end-to-end OPC-UA testing.
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 asyncuaUse 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.
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.
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'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-
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). -
Flash from the host with
esptool(the merged image goes at offset0x1000):~/flashenv/bin/python -m esptool --chip esp32 --port /dev/cu.usbserial-0001 \ --baud 460800 write_flash 0x1000 build/zephyr/zephyr.binFind 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).
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.confThe 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.binThe 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.
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.confFlash 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.binThe console is on that same port at 115200.
Flash size and layout. The board
.overlayincludeslib/common/dts/layout-esp32s3-16M.dtsi, which declares&flash0as 16 MB (the N16R8 module tested here; the upstream devicetree'sesp32s3_wroom_n8.dtsiassumes 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 from0x0; for BLE provisioning build with--sysbuildinstead (see "Provisioning Wi-Fi over BLE"). The layout needs a 16 MB part: check yours withesptool 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.
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.confFlash 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.binThe 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.
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.confThe Feather S2 has native USB only (no USB-serial bridge), so:
- Enter the ROM bootloader before flashing: hold
BOOT/DFU, tapRESET, releaseBOOT. 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.
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) |
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.
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-timeCONFIG_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 inlib/mcuboot-hooks/). Without a request it boots the application fromslot0through its normal A/B logic, so theslot1swap-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).
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 --pristineNo 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.
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(needspip 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=yadditionally 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.
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.confOn 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 600What 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_PUBKEYis 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
storagefirst (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.
| 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.
| 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.
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 withCONFIG_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.overlayA 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.
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,protoortrap), with the uptime when it did.hardware watchdog resetmeans the task watchdog never got to run: a hard lockup that only the SoC watchdog caught.boot Ncounts 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.
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:Rrunning,Qqueued,Ddelayed,Ccancelling,-idle.heapandmalloc: free and total bytes.pkt,bufandmin: free net packets and buffers, and the lowest buffer count seen.- A
HEALTHline with noWIFIline 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.
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 3600The 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 unprivilegedsnmptrapdon 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.
| 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 |
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_wqstack (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.
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 agentEach 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, oravahi-browse) lists every device with its address, port and TXT record, and<hostname>.locallookups work everywhere. On one macOS machine,dns-sd -Blisted nothing for these service types while browsing_ssh._tcpworked; 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 whatdns-sd -Lasks 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())
PYBoard-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.
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 ONSubscription resource caps are set from APP_OPCUA_MAX_SUBSCRIPTIONS /
APP_OPCUA_MAX_MONITORED_ITEMS.
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 300open62541 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.
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.
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 byapps/opcua-server.CONFIG_APP_SIM_PUMP— a control-driven pump/motor: measurements react to thespeed_setpoint/running/modecontrols via pump affinity laws (flow ∝ speed, pressure ∝ speed²), a motor-thermal lag,run_hoursthat accrue only while running, and an over-temp fault. Used byapps/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 everyCONFIG_APP_SIM_SWITCH_FLAP_PERIOD_STEPSsampling steps (0= never) to drive link up/down events. Used byapps/snmp-agent, which sets 900 (a flap every 15 minutes).
Frontends are simulation-agnostic, so any simulation can back any protocol.
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 502Register 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,netapps/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 IPThe 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 GETBULKMIB 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, buildReceive 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 flapTrap 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 flapEach 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_statusInstalling 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-dotDon'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.
- 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,/→_). - 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 tookay, 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_serialwhen the board has no UART socket — as on the C6 here. Check the board'szephyr,consoleagainst 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.dtsiand friends). Read the real part withesptool flash-idand correct&flash0in the overlay if they disagree. - Is there really a
led0? Many modern boards have only a WS2812 addressable RGB LED. Don't invent a GPIOled0for it: give it aled-stripalias instead (seelib/common/dts/rgb-led-*.dtsi, after Zephyr'ssamples/drivers/led/led_stripoverlays), 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.