Proposed approach does not compete with Matter protocol, it should show how to add ultra low-power devices into available Thread PHY-layer below Matter and with similar UX, so that the user commissions new device to deployed Thread network safely using NFC in his smartphone and the device appears in Home Assistant UI automatically. So it reuses available hardware/software and is much simpler to program than Matter stack. The blue blocks below show the application code blocks written for Zephyr stack and for the Home Assistant stack.
Target board for CoAP Server: Xiao Seeed nRF54L15
nRF Connect SDK: v3.2.1
Zephyr Version: v4.3, zephyr-sdk-0.17.4
Home Assistant: Core 2026.2.1 (all add-ons as of 2/26 updated)
Status:
Thread protocol is extremely power efficient compared to WiFi, it builds a solid PHY with IPv6-based mesh networking Layer for devices that don't need high bandwidth and work for many months or even 1-2 years on battery.
Matter protocol on top of Thread creates a safe application layer with a straightforward service discovery and UI on all available home automation platforms. Programming Matter is complicated for DIY, even the installation of the toolchain is a challenge. Nordic provides a few Matter examples, but the Matter itself is not yet even planned for mainstream Zephyr RTOS integration.
CoAP (RFC 7252 Constrained Application Protocol) was invented before Matter, it is UDP-based and stateless, has no pre-defined device classes (light, binary sensor) and no commissioning concept to integrate the new gadget into existing Thread network. But it is simple and has both Python and Zephyr support, what makes CoAP over Thread more attractive for DIY development. The application layer should be built on top of CoAP and it exists: Home Assistant has automatic service discovery for many types of devices, so one should push the proper messages to MQTT Add-on to register the new devices.
The proposed architecture consists of two parts:
-
CoAP Client with Data Model as a Bridge between existing OpenThread Add-on and MQTT Add-on, it is a container one can download from GitHub and install on HA OS in 1 minute. It is not a simple relay between Thread and MQTT, it manages a small database for device commissioning and decommissioning
-
CoAP Server on the device side, in this example a cheap siren with integrated Xiao Seeed nRF54L15 board, the board is flashed using nRF Connect (Zephyr-based, but not native Zephyr). Vanilla Zephyr does not yet support Nordic's NFC libraries needed for device commissioning using smartphone, that's why nRF SDK. If you can live with copying credentials over Zephyr shell, then use the Debug chapter, it doesn't need nRF Connect at all.
The shown architecture integrates both CoAP-over-Thread and Matter-over-Thread on one Home Assistant host, all devices run on the same Thread PHY and over same border router.
After easy commissioning described in chapter below the device appears with all its functions in Home Assistant UI:
- buttons
- leds (in this application used for alarm on/off)
- battery level in voltage
- battery level in percent
- uptime since last boot (very useful function for tracking devices)

Now you can use these services in HA Automation routines, e.g. trigger condition like motion sensor on the picture and siren turns on.
CoAP has two established mechanisms:
-
"Polling Mechanism" CoAP Client (on HA) requests CoAP Server (target board) with GET/PUT for sensor/actor actions, the Server's Thread radio should be ON at that time, otherwise there is no chance of communication. There is NO mechanism of waking up a sleeping Thread device!
1.1 Thread Sleepy End Device (SED) wakes up every N seconds/hours and polls the Thread Leader to provide him the stored requests (Thread leader has his cash to store incoming requests)
1.2 CoAP client gets all the requested information during that wake-up period, so the Thread device can now go back to power saving sleep
1.3 Example: LED turn-on command takes worst-case N seconds/hours, this time is defined by Thread device itself, nobody else can influence that, there is no negotiations on that -
"Observe/Push Mechanism" CoAP Client can register the resource of interest
2.1 The CoAP server responds with the current state of the resource and includes an Observe option with a sequence number. This establishes the observation relationship
2.2 Whenever the resource changes, the CoAP server sends a notification (a new response). But the layer below CoAP - Thread - knows nothing about that relationship.
2.3 Zephyr Code in this example wakes up the Thread radio on Button (sw0 and sw1) event interrupt and the CoAP server pushes the notification to client
That solves a common misunderstanding: how can a sleeping device with long downtime be fast in case of emergency, e.g. in case of alarm/motion/occupancy sensor should push its notification immediately on event. This is a nice example of how CoAP-over-Thread saves the battery power.
The target embedded device is the Xiao Seeed nRF54L15 board with soldered JST battery connector and NFC Antenna attached to NFC pads. This board has Thread-capable radio in MCU and on-board 2.4GHz Antenna.
The board is integrated into a cheap siren system with PIR sensor, so nRF54L15 can only trigger alarm, it does not change any functions.
The system is mounted together into alarm box
The firmware is flashed using OpenOCD over USB, no additional hardware needed. The board starts at boot the NFC commissioning mode, so it waits for Thread dataset to be pushed from smartphone.
Prerequisites:
- running Home Assistant (HAOS) with 3 Add-ons installed and running (MQTT, OpenThread and CoAP Bridge), Terminal Add-on recommended for debugging
- available Thread network, so an OpenThread border router (OTBR) like ZBT-2 on the diagram or even another nRF54L15 board with flashed OTBR image from Nordic samples (it was successfully tested as part of this project)
- App like NFC Tools for initial commissioning over NFC-capable smartphone
- HomeAssistant Companion App installed on same smartphone
Step 1: copy the working Thread TLV dataset from HAOS Settings/Devices/Thread

Step 2: Paste the dataset into NFC Tools and save it
Write/Add Dataset/Text
Step 3: Place smartphone near NFC antenna and press "Write"

Now the credentials are in non-volatile memory and you can verify the device joining the Thread network using Terminal Add-on
➜ ~ docker exec -it addon_core_openthread_border_router /bin/bash
root@homeassistant:/usr/src# ot-ctl state
leader
Done
root@homeassistant:/usr/src# ot-ctl neighbor table
| Role | RLOC16 | Age | Avg RSSI | Last RSSI |R|D|N| Extended MAC | Version |
+------+--------+-----+----------+-----------+-+-+-+------------------+---------+
| C | 0x3401 | 1 | -68 | -68 |0|0|0| c2d7101ffc8a974b | 4 |
| C | 0x3402 | 11 | -71 | -71 |0|0|0| 9e57ff5e2bfafda3 | 4 |
| C | 0x340a | 1 | -79 | -79 |0|0|0| 3ad296c17cef650a | 5 |
The firmware appears as Minimal Thread Device in child role (C) and gives CoAP client the grace period to discover the new device: it does not sleep during first 2 minutes after boot, so the CoAP client polling every minute can commission the new CoAP devices. You can track the RDN state 100 in the table above, that means MTD. After the timer expiration the device goes into SED (Sleepy End Device) mode, RDN=000 like on picture above. It wakes up every 5 seconds, polls to thread leader that he is ready for instructions, Thread leader signals to CoAP application it can accept instructions and the CoAP Client can send him UDP requests.
A Zephyr-based Thread CoAP server designed for integration with Home Assistant via the Thread CoAP Bridge add-on. Supports LED control, button input, battery monitoring with real ADC measurements, NFC-based commissioning, and automatic network reconnection.
https://github.com/Rosfly/thread-coap-bridge-addon
- NFC Commissioning: Provision Thread credentials via Android phone NFC - no cables needed
- Thread SED Mode: Runs as Sleepy End Device for ultra-low power consumption
- SED Discovery Grace Period: Stays awake for 2 minutes after boot for bridge discovery
- CoAP Server: Exposes
/led,/sw(button),/uptime,/battery, and/voltageresources - CoAP Observe: Push notifications for LED and button state changes (RFC 7641)
- Battery Monitoring: Real ADC measurements with LiPo discharge curve lookup table
- Power Optimization: TPS22916C load switch enables voltage divider only during measurement to save power
- Automatic Reconnection: Network monitor thread handles disconnection recovery
- NVS Storage: Thread credentials persist across reboots
- Seeed XIAO nRF54L15 (primary target)
- NFC antenna connected to NFC1/NFC2 pads
- Other nRF52/nRF53/nRF54 boards with Thread support
VBAT ─── TPS22916C ─── R5(10K) ───┬─── P1.14 (AIN7)
(load switch) │
P1.15 (EN) R6(10K)
│
GND
- Voltage divider ratio: 2.0 (20K/10K)
- ADC: 12-bit, 4x oversampling, 1/4 gain
- Power saving: TPS22916C disables divider when not measuring
The Seeed XIAO nRF54L15 board includes an on-board ceramic 2.4 GHz antenna and an IPEX connector for an external antenna. An on-board RF switch (controlled via P2.03 and P2.05) routes the radio signal to one of the two paths.
The RF switch is controlled by two GPIO pins on Port 2:
| Signal | Pin | Logic | Meaning |
|---|---|---|---|
rfsw_pwr |
P2.03 | HIGH = switch powered | Must be HIGH for the switch to operate |
rfsw_ctl |
P2.05 | HIGH = external IPEX | LOW = ceramic on-board antenna |
Both pins are defined in the board DTS as regulator-fixed nodes with regulator-boot-on. Without CONFIG_REGULATOR (which is not set in this project — enabling it would brick the nRF54L15 by re-initializing internal CPU voltage regulators), neither pin is driven at reset and the radio falls back to the ceramic antenna.
To use the external IPEX antenna both pins must be explicitly driven from application code. The overlay disables both regulator nodes so the application owns the pins:
/* boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay */
&rfsw_pwr { status = "disabled"; }; /* release P2.03 */
&rfsw_ctl { status = "disabled"; }; /* release P2.05 */
Enable external IPEX antenna as the boot default in prj.conf:
CONFIG_DEFAULT_ANTENNA_EXTERNAL=y
When enabled, main.c drives both pins immediately after coap_init():
gpio_pin_configure(gpio2, 3, GPIO_OUTPUT | GPIO_OUTPUT_INIT_HIGH); /* rfsw_pwr: switch on */
gpio_pin_configure(gpio2, 5, GPIO_OUTPUT | GPIO_OUTPUT_INIT_HIGH); /* rfsw_ctl: external */Set CONFIG_DEFAULT_ANTENNA_EXTERNAL=n (or remove the line) to revert to the ceramic antenna — no other changes needed.
Connect an IPEX-to-SMA pigtail to the IPEX connector on the board and mount the external antenna away from the PCB. For a device installed inside a plastic enclosure this can significantly improve Thread RSSI and reduce packet loss, especially if the ceramic antenna is shielded by the enclosure wall or a metal chassis.
Important: This project requires nRF Connect SDK, not mainline Zephyr. The NFC libraries are only available in nRF Connect SDK.
Default build disables UART/logging for battery-only boot:
cd <ncs_sdk_path>
# you can build in nRF Connect directly
west build -p always -b xiao_nrf54l15/nrf54l15/cpuapp \
--shield seeed_xiao_expansion_board \
-s <project_path>
# this step is needed always
cd <project_path>/build && west flashOr use nRF side bar in VSCode
For development with USB serial console and OpenThread shell.
Note: This build will NOT boot from battery alone - USB connection required. Add prj_uart to configuration:
cd <ncs_sdk_path>
west build -p always -b xiao_nrf54l15/nrf54l15/cpuapp \
--shield seeed_xiao_expansion_board \
-s <project_path> \
-- -DOVERLAY_CONFIG="prj_uart.conf"
# this step is needed always
cd <project_path>/build && west flash
# Monitor serial output (115200 baud)
# Use VSCode Serial Monitor or: screen /dev/ttyACM0 115200The debug build enables:
- UART console logging
- OpenThread shell (
otcommands) - Useful for troubleshooting and manual commissioning
The device needs to be commissioned to your Thread network once. After commissioning, credentials are stored in NVS and the device auto-joins on subsequent boots.
No cables needed - just tap your phone to provision the device.
From Home Assistant:
- Go to Settings → Devices & Services → Thread
- Click on your Thread network
- Find the Active Operational Dataset (TLV hex string)
Or export (-x) from OpenThread Border Router:
# SSH into OTBR container
docker exec -it addon_core_openthread_border_router ot-ctl dataset active -xExample output:
0e080000000000010000000300000f35060004001fffe00208dead...
Download NFC Tools (free) on your Android phone.
- Open NFC Tools
- Tap Write → Add a record → Text
- Paste the hex string from Step 1
- Tap Write
- Hold phone to device's NFC antenna
| State | LED Pattern | Meaning |
|---|---|---|
| Slow blink (500ms) | Waiting for NFC | Ready to receive dataset |
| Rapid blink (100ms) | Phone detected | Processing data |
| Solid 1 second | Success | Dataset applied, joining network |
| 3 rapid blinks | Error | Invalid data, retry |
Use this method when debugging or if NFC isn't available.
# Connect to serial console (115200 baud)
# Set the Thread dataset
ot dataset set active 0e080000000000010000000300000f35060004001fffe00208...
# Enable IPv6 interface
ot ifconfig up
# Start Thread
ot thread start
# Verify connection (wait 10-30 seconds)
ot state
# Should show: childNote: After shell commissioning with debug build, flash the production build for battery operation.
BOOT
│
▼
Check sw0 button
│
├─── sw0 held ───────► Clear stored dataset
│ │
▼ ▼
Check NVS for dataset NFC mode (LED blinks)
│ │
├─── Dataset found ──► Start Thread ──► Join network ──► SED mode
│ │
└─── No dataset ────► NFC mode (LED blinks)
│
▼
Phone taps NFC
│
▼
Parse & apply dataset
│
▼
Start Thread ──► Join network ──► SED mode
To switch to a different Thread network, hold the sw0 button while pressing reset switch. The device clears its stored dataset and enters NFC commissioning mode. Then provision the new network dataset via NFC as usual.
The firmware includes a network monitor that handles disconnection:
- Detection: Monitor thread detects
DETACHEDstate - Wait: Allows OpenThread's internal reattachment (up to 1 minute)
- Force Restart: If still detached after 1 minute, forces Thread stack restart
- Rejoin: Device rejoins network with fresh state
GET - Returns current LED state:
{"device_id": "f4ce3616e67c7a1c", "leds": [{"led_id": 0, "state": 1}]}PUT - Control LED:
{"led_id": 0, "state": 1} // 0=OFF, 1=ON, 2=TOGGLEObserve - Subscribe for push notifications on state changes.
GET - Returns button state:
{"device_id": "f4ce3616e67c7a1c", "btns": [{"btn_id": 0, "state": 0}]}Observe - Subscribe for push notifications on button press/release.
GET - Returns battery percentage:
{"device_id": "f4ce3616e67c7a1c", "value": 75}GET - Returns battery voltage in millivolts:
{"device_id": "f4ce3616e67c7a1c", "value": 3850}GET - Returns milliseconds since boot:
{"device_id": "f4ce3616e67c7a1c", "value": 123456}Used by the bridge to detect device reboots and re-register CoAP Observe subscriptions.
GET - Returns CoRE Link Format:
</led>;rt="led",</sw>;rt="button",</battery>;rt="battery",</voltage>;rt="voltage",</uptime>;rt="uptime"
The device operates as a Sleepy End Device for optimal battery life:
- Radio OFF most of the time
- Wakes every 5 seconds to poll parent for queued messages (CONFIG_OPENTHREAD_POLL_PERIOD=5000 in prj.conf:70)
- Wakes immediately on button press (GPIO interrupt)
After joining the network, the device stays awake for 2 minutes to allow the bridge to discover it via multicast. After the grace period, it switches to full SED sleep mode.
Boot → Join Thread → Grace Period (2 min, awake) → SED Mode (sleeping)
↑
Bridge discovers device here
| Operation | Latency | Notes |
|---|---|---|
| Button notification | ~100-200ms | GPIO wakes device immediately |
| GET /battery | Up to 5s | Request queued until next poll |
| PUT /led | Up to 5s | Command queued until next poll |
# Thread SED mode
CONFIG_OPENTHREAD_MTD=y
CONFIG_OPENTHREAD_MTD_SED=y
CONFIG_OPENTHREAD_POLL_PERIOD=5000
# NFC Commissioning
CONFIG_OT_COAP_NFC_COMMISSION=y
CONFIG_NFC_T4T_NRFXLIB=y
CONFIG_NFC_NDEF=y
CONFIG_NFC_NDEF_MSG=y
CONFIG_NFC_NDEF_PARSER=y
# System workqueue stack (required for NFC processing)
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096
# TX power for range
CONFIG_OPENTHREAD_DEFAULT_TX_POWER=8
CONFIG_OT_COAP_SAMPLE_SERVER=y
CONFIG_OT_COAP_SAMPLE_LED=y
CONFIG_OT_COAP_SAMPLE_SW=y
CONFIG_OT_COAP_SAMPLE_BATTERY=y
Available when built with prj_uart.conf:
# OpenThread commands
ot state # Show current role (child/detached/disabled)
ot ipaddr # Show IPv6 addresses
ot dataset active # Show active dataset
ot dataset active -x # Show dataset as hex
ot ping <ipv6> # Ping another device
# Check SED mode
ot mode # Should show "-" (no 'r' = SED)
ot pollperiod # Should show 15000 (ms)- Ensure NFC antenna is properly connected to NFC1/NFC2 pads
- Check that phone NFC is enabled
- Try different phone positioning (center of antenna)
- Verify hex string is valid (even number of characters, 0-9 and a-f only)
- Ensure you're using a Text record, not URI or other types
- Check dataset length (max 254 bytes = 508 hex chars)
- Hold sw0 while pressing reset to clear the stored dataset and enter NFC mode
- Then tap your phone with the new Thread dataset as usual
- Verify dataset is correct:
ot dataset active(debug build) - Check channel matches your network
- Verify Thread is enabled:
ot stateshould not be "disabled"
- Check signal strength - move closer to border router
- Check for interference on Thread channel
- TX power is set to maximum (+8 dBm)
You're building with mainline Zephyr instead of nRF Connect SDK. NFC libraries are only in nRF Connect SDK.
This firmware works with the Thread CoAP Bridge Home Assistant add-on:
- Install the Thread CoAP Bridge add-on
- Flash this firmware and commission via NFC
- Within 2 minutes, the bridge discovers the device via multicast
- Device appears in Home Assistant with LED control, button sensor, battery monitoring
- After 2 minutes, device enters SED sleep mode
The bridge fully supports SED devices with 75-second timeouts for all CoAP operations.
The NFC implementation uses a two-phase approach to avoid crashes:
- Work Queue Phase: NDEF parsing runs in system workqueue (handles NFC callback safely)
- Main Thread Phase: OpenThread API calls (
otDatasetSetActiveTlvs,otIp6SetEnabled,otThreadSetEnabled) run in main thread
This separation is necessary because OpenThread APIs require specific thread context and sufficient stack space.
Use the current non-deprecated API:
// Get instance directly
otInstance *ot = openthread_get_default_instance();
// Lock/unlock without context parameter
openthread_mutex_lock();
// ... OpenThread API calls ...
openthread_mutex_unlock();The /led resource drives an additional digital output on pin P1.06 (XIAO connector D2), intended for triggering an external alarm or relay circuit.
Behavior:
| LED command | LED (P2.0) | Alarm pin (P1.06) |
|---|---|---|
| OFF (state=0) | OFF | High-impedance (floating) |
| ON (state=1) | ON | Driven HIGH (3.3V) for ~1 second, then high-impedance |
| TOGGLE (state=2) | Toggles | Follows resulting ON/OFF state |
Implementation details:
- Pin is defined as
alarm_pinin the devicetree overlay (boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay) - Default state is
GPIO_INPUT(high-impedance / disconnected) - On LED ON: pin is reconfigured to
GPIO_OUTPUT_HIGH(standard push-pull drive to VDD) - A
k_timerfires after 1 second and reconfigures the pin back toGPIO_INPUT(high-impedance) - On LED OFF: any pending pulse timer is cancelled and pin returns to high-impedance immediately
- Only
led_id == 0triggers the alarm pulse - All alarm pin code is guarded by
#if DT_NODE_EXISTS(DT_NODELABEL(alarm_pin))so it compiles out cleanly if the overlay node is removed
Thread dataset persistence is handled automatically:
CONFIG_SETTINGSis auto-enabled by OpenThread whenCONFIG_FLASH=yotDatasetSetActiveTlvs()saves to NVS through the Settings subsystem- On boot, OpenThread loads the dataset from NVS automatically
Software re-flash does not delete available dataset in NVS, so erase the MCU completely in case of debugging.
| File | Purpose |
|---|---|
src/main.c |
Entry point, NFC commissioning check |
src/nfc_commission.c |
NFC T4T handling, NDEF parsing |
src/nfc_commission.h |
NFC public API |
src/coap_utils.c |
CoAP server initialization |
src/led.c |
LED resource |
src/button.c |
Button resource with observe |
src/battery.c |
Battery/voltage ADC measurement |
src/network_monitor.c |
Connection recovery, SED grace period |
prj.conf |
Production config (no UART) |
prj_uart.conf |
Debug overlay (UART + shell) |
-
Hold button on boot to clear dataset and re-enter NFC mode
-
Encryption on CoAP layer (now only Thread encrypted)
-
Add IPEX Antenna to target board to improve radio link
-
BLE commissioning like Matter as alternative method
-
Home Assistant add-on to generate NFC tags with dataset
One medium issue — race condition in `src/coap_observe.c: The observer list (resource->observers[], resource->observer_count) is accessed from both CoAP request callbacks and button/LED notification paths without synchronization. In practice this is low-risk because OpenThread serializes most of these callbacks, but adding a k_mutex to struct coap_observe_resource would be the clean fix.
Minor: src/coap_discovery.c` builds the .well-known/core response into a 128-byte buffer without checking snprintf return for truncation. Currently fits, but fragile if more resources are added.
MIT License - see LICENSE file.




