Why a host protocol?

In our Opener status update, we explained that the stack is built bottom-up and that only the driver is finished today. Our boards need more than a driver, though. The NR+Gate pairs an nRF9151, which carries the NR+ modem, with a Rockchip RK3566 Linux module. To run a sniffer, a web UI or a test tool on that gateway, the Linux software has to be able to control the radio.

That is exactly OpenThread's RCP (Radio Co-Processor) model: the radio microcontroller runs only the real-time part, and a host drives it over a serial link using a protocol called Spinel. We chose to reuse Spinel rather than design our own protocol, for three reasons:

  • A proven format: compact type encoding, transaction IDs, generic get/set/insert properties, and ranges reserved for vendor extensions.
  • Existing transports: HDLC-lite framing for UART and OpenThread's SPI framing are specified and widely deployed.
  • Reusable tooling: OpenThread's C codec drops in as is, and a tool like pyspinel can talk to an Opener device.

Layered control

This first version exposes the driver level. The host schedules radio operations (TX, RX, RSSI1), reads the modem time and the radio capabilities, and receives the driver events. The MAC, DLC and convergence layers then run on the host. Property ranges are reserved for the higher levels: once the embedded stack has a MAC, a later version can expose it without breaking the protocol.

RangeLevelStatus
0x3C00–0x3C7FLeft free (used by ESP-IDF)—
0x3C80–0x3CFFOpener coreReserved
0x3D00–0x3DFFDriverImplemented
0x3E00–0x3EFFMACReserved
0x3F00–0x3F7FDLCReserved
0x3F80–0x3FFFConvergence layer and applicationsReserved

The device advertises the levels it exposes in PROP_CAPS and identifies itself with a new interface type, 0x4E52 ("NR"). A host that only speaks the Thread or IEEE 802.15.4 properties simply gets PROP_NOT_FOUND.

From the Opener driver to Spinel properties

The design work consisted of mapping each function of the Opener driver API (dectnrp_driver.h) to a Spinel command on a property. The result fits in about ten properties:

Driver APISpinelNotes
start() / stop()SET DRV_ENABLED (0x3D00)The driver stays stopped at boot until the host enables it.
get_capabilities()GET DRV_CAPS (0x3D01)ETSI TS 103 636-4 radio capabilities, ticks per symbol, frame size, number of slots and driver latencies.
get_time()GET DRV_TIME (0x3D02)Modem time, in ticks (64-bit).
configure()SET DRV_NETWORK_ID, DRV_FILTER_SHORT_NWID, DRV_FILTER_SHORT_RXADDRNetwork ID and receive filters.
schedule(op)INSERT DRV_OP_QUEUE (0x3D10)One TX, RX or RSSI1 operation, immediate or scheduled.
Event callbackDRV_EVENT (0x3D11), unsolicitedFrame received, error, RSSI1 results, operation finished.
—GET DRV_STATS (0x3D20)Seven interface counters, for diagnostics.

Operations: a token chosen by the host

Most of the work goes through DRV_OP_QUEUE. Each insert carries a common prefix (token, type, channel, flags, start time in modem ticks), followed by a suffix specific to the operation type: modulation parameters and data for TX, duration and expected RSSI for RX, number of subslots for RSSI1. The flags cover scheduling, LBT, HARQ, beacons and reception mode. The device rejects any flag that makes no sense for the operation type with INVALID_ARGUMENT.

The token is chosen by the host and must be unique among the operations in flight. The device replies INSERTED once the driver has accepted the operation, and every event that follows carries the token. Driver errors are translated into Spinel statuses: -ENOMEM becomes NOMEM, -EBUSY becomes BUSY, and so on. Other errno values are passed through in a vendor status range.

Explicit delivery guarantees

A host that drives a radio remotely needs to know when it can reuse a resource. So we wrote the contract down:

  • after INSERTED, the device sends exactly one OP_FINISHED event for the token, and it is the last event of the operation;
  • the other events share a buffer pool. When it is full, they are dropped and counted, and the number dropped is reported in the next event sent;
  • OP_FINISHED has its own buffer per operation slot: it is never dropped, so the host never waits forever;
  • CMD_RESET stops the driver, waits for the operations to finish, frees every slot and invalidates every token, without rebooting the device.
Example: reading the protocol version (UART)
# Host → device: TID 1, CMD_PROP_VALUE_GET, PROP_PROTOCOL_VERSION
7E 81 02 01 <FCS> 7E

# Device → host: TID 1, CMD_PROP_VALUE_IS, PROP_PROTOCOL_VERSION = 4.3
7E 81 06 01 04 03 <FCS> 7E

The implementation in Opener

The Spinel interface is an optional Opener subsystem (subsys/spinel). It sits between the driver and the transport, and is split into blocks with clear responsibilities:

Host
Linux or PC: the protocol logic (in our case, our Rust implementation).
Transport
uart_hdlc.c, spi.c, none.c: framing on the link. The devicetree selects the transport, and the none transport is used in tests.
Core
spinel_core.c: RX and TX threads, command dispatch, identity and driver configuration properties, reset.
Broker
spinel_ops.c: operation slots, calls to schedule(), conversion of driver events into DRV_EVENT frames.
Codec
lib/spinel.c: OpenThread's codec, vendored unmodified (BSD-3-Clause) and pinned to a specific commit.
L2
l2.c: at driver level, the L2 lives on the host. Spinel therefore provides the L2 hook the driver calls at init, and the network interface stays down on the device.
Driver
Opener driver (nRF91x1, or the mock driver in tests), used only through its public API.

All memory is bounded and sized through Kconfig: 8 operation slots, 16 event buffers, and 4 receive and 4 transmit buffers by default, with frames of at most 1300 bytes (the NR+ MTU plus headers). There is no dynamic allocation. Enabling it takes a few lines:

prj.conf
CONFIG_NETWORKING=y
CONFIG_DECTNRP_DRIVER_SCHEDULED_API=y
CONFIG_DECTNRP_SPINEL=y

UART: HDLC-lite

On UART, frames use Spinel's HDLC-lite framing: 0x7E flag bytes, escaping of control bytes and a CRC-16/X-25 FCS. Both directions are interrupt-driven, at 1 Mbaud. Hardware flow control is essential: without it, the UARTE loses bytes from the host whenever its interrupt is served late. This is the transport we use with the nRF9151 DK and nRF9131 EK development kits, plugged into a PC over USB.

SPI: OpenThread framing, plus a BUSY line

On the NR+Gate, the nRF9151 is an SPI slave of the RK3566. We reuse OpenThread's SPI framing: every transaction starts, in both directions, with a 5-byte header (flags, largest frame accepted, length of the frame sent), followed by the raw Spinel frame, without HDLC.

Devicetree: NR+Gate
&spi2 {
        compatible = "nordic,nrf-spis";
        def-char = <0xff>;

        spinel@0 {
                compatible = "opener,spinel-spi";
                reg = <0>;
                spi-max-frequency = <8000000>;
                irq-gpios = <&gpio0 9 GPIO_ACTIVE_HIGH>;
                busy-gpios = <&gpio0 26 GPIO_ACTIVE_HIGH>;
        };
};

SPI raises a problem UART does not have: the slave cannot send anything until the master clocks, and a transaction started while the slave is not ready is lost. Two lines therefore complete the bus:

  • IRQ: the device has a frame for the host;
  • BUSY, our extension to the OpenThread specification: the device is not ready for a transaction. It goes busy at the end of each transaction and releases BUSY once the next one is armed, after updating IRQ. The host therefore reads IRQ only while BUSY is inactive.

To cut latency, the device holds BUSY after a transaction that brought it a request, for at most 2 ms by default, until the reply is ready. The reply then goes out in the host's next transaction, which can itself carry the next request: a round trip takes two transactions. And if no host reads, the frames queued behind the offered frame are dropped after one second, so the buffers do not run out.

Hardware note: the SPI slave controller's default character (def-char) is set to 0xFF. A transaction clocked while the device is not armed therefore reads an invalid header, which the host safely ignores.

The dectnrp-spinel sample in opener-samples provides a ready-made firmware: UART for the nRF9151 DK and nRF9131 EK, SPI for the NR+Gate. Its main() only prints a banner: the interface starts on its own at boot.

Testing with Twister

A radio control protocol is hard to test by hand: the interesting bugs live in the edge cases (pool full, reset during an operation, an SPI transaction at the wrong moment). So we wrote the tests along with the code, using ztest and Twister, Zephyr's test runner. Everything runs on native_sim, with no hardware.

ScenarioWhat it checks
opener.spinel.codecIntegration of the OpenThread codec.
opener.spinel.hdlcHDLC-lite framing: escaping, FCS, invalid or oversized frames.
opener.spinel.spi_linkSPI header: flags, pattern, lengths, truncated frames.
opener.spinel.coreDispatch, identity, driver properties, error statuses, reset.
opener.spinel.opsOperation broker: duplicate tokens, exhausted slots, full event pool, reset with operations in flight.
opener.spinel.mock / mock_autoThe mock driver itself, including its mode that emits periodic frames.
opener.spinel.spi_transportThe full SPI transport against a fake SPI slave controller: frame order, BUSY hold, ignored transactions, drops.
opener.spinel.simdevA complete simulated device (build only), used by the host tests.

Two building blocks make these tests possible. A mock driver implements the Opener driver API with a simulated 69.12 MHz modem clock: tests choose which events it returns, or let it generate traffic on its own. A fake SPI slave controller, declared in the test devicetree, lets the test play the master one transaction at a time and check the IRQ and BUSY lines at every step.

Running the tests
west twister -T deps/dectnrp-opener/tests/subsys/spinel -p native_sim/native/64
9
Twister scenarios
76/76
test cases passing
~75 s
on a PC, no hardware

The simdev scenario deserves a mention. It builds a complete Spinel firmware for native_sim, with the mock driver, and exposes the Spinel UART on a pseudo-terminal of the PC. To the host, it is a real device: our host-side integration tests connect to it as they would to a real board. The protocol is thus tested end to end, firmware and host together, with no hardware at all.

Then on real hardware

Tests on native_sim validate the logic, but not the electrical behaviour of a real bus. So the interface was also tested on our NR+Gate: the dectnrp-spinel firmware on the nRF9151, driven over SPI by the RK3566, with real NR+ frames on the air. That is the setup that ran our demos at the DECT World 2026 pre-event, both for sniffing and for remote control.

What's next?

A Spinel host interface is in the "beyond" part of the Opener roadmap. Our implementation currently lives on the spinel-ctrl branch of our public Opener fork, and we intend to propose it upstream. A few points remain to be discussed with the community:

  • the interface type value (0x4E52) and the vendor ID, to be agreed upstream with Spinel;
  • the optional CRC of the SPI framing, which we do not support yet;
  • exposing the MAC, DLC and CVG levels, once those layers exist in the embedded stack.
Codium's view — Nov. 2026

Reusing Spinel spared us from inventing a protocol and turned the Opener radio into a co-processor that any host can drive. The real work was elsewhere: writing a clear delivery contract, then testing it down to the edge cases, first without hardware, then on our gateways.

Designing an NR+ gateway?
Embedded firmware, Linux BSP, host link and radio: we design complete DECT NR+ products. Let's talk about your architecture.
Discuss your project Our DECT NR+ boards