Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9a13530
SPIKE: TREL-like IP backbone for satellite routers (#84)
t3hk0d3 Nov 10, 2026
7828269
SPIKE: stable peer addresses, self-configured test coordinator
t3hk0d3 Nov 10, 2026
408d363
SPIKE: ZLOE backbone interface option
t3hk0d3 Nov 10, 2026
4ce70ab
SPIKE: bench tooling for third-party routers on the test network
t3hk0d3 Nov 10, 2026
360d7be
SPIKE: satellites find the coordinator on the trusted backbone
t3hk0d3 Nov 10, 2026
157e4fe
SPIKE: rename satellite options to --zloe-satellite-*
t3hk0d3 Nov 10, 2026
e43043d
SPIKE: deny experiment results, zloe-ctl lqi and latency
t3hk0d3 Nov 10, 2026
b20a4d7
SPIKE: ZLOE wire v2 with extended PAN ID and IPv6 link-local
t3hk0d3 Nov 10, 2026
2d1458e
SPIKE: design and gap analysis for ZLOE broadcast delivery
t3hk0d3 Nov 10, 2026
0990a86
SPIKE: announce the ZLOE multicast address over mDNS
t3hk0d3 Nov 10, 2026
127bdcd
SPIKE: send ZLOE broadcasts by multicast with probe and fallback
t3hk0d3 Nov 10, 2026
0330035
SPIKE: IPv4 multicast for ZLOE broadcasts
t3hk0d3 Nov 10, 2026
1690492
SPIKE: test hooks for ZLOE multicast families and IPv4-only hosts
t3hk0d3 Nov 10, 2026
06c9678
SPIKE: satellites broadcast Device_annce on start
t3hk0d3 Nov 10, 2026
902cd55
SPIKE: probe only the preferred ZLOE multicast group
t3hk0d3 Nov 10, 2026
430634d
SPIKE: satellites answer the ZDP interview themselves
t3hk0d3 Nov 10, 2026
e30941e
SPIKE: ZHA topology scan of the coordinator; QUIC security design
t3hk0d3 Nov 10, 2026
0b377dd
SPIKE: secure ZLOE over QUIC with enrollment (PoC)
t3hk0d3 Nov 10, 2026
9b6af57
SPIKE: soak report (ZHA, 3 backbone nodes, 52 min)
t3hk0d3 Nov 10, 2026
f6faddc
SPIKE: keep the link-local scope when dialing QUIC peers
t3hk0d3 Nov 10, 2026
9235e04
SPIKE: design and gap analysis for ZLOE satellites on the MCU
t3hk0d3 Nov 10, 2026
a7c014e
SPIKE: drop the #88 next-hop workaround, upstream #89 covers it; carg…
t3hk0d3 Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 329 additions & 2 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/ziggurat-driver/src/zigbee_stack/joining.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1422,7 +1422,7 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
}

/// Whether the trust center authorizes new joins through a router right now.
pub(super) fn trust_center_permitting_joins(&self) -> bool {
pub fn trust_center_permitting_joins(&self) -> bool {
self.core()
.trust_center_joins_until
.is_some_and(|deadline| deadline > self.core_now())
Expand Down
42 changes: 42 additions & 0 deletions crates/ziggurat-driver/src/zigbee_stack/nwk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,41 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
})
}

/// Deliver a frame we addressed to ourselves as if it had been received: the stack
/// handles the ZDP requests it owns (its answers loop back the same way), and the
/// client gets the frame. The send succeeds at once.
fn deliver_locally(&self, nwk_frame: &NwkFrame) -> SendHandle {
use ziggurat_zigbee::aps::frame::{ApsFrame, parse_aps_frame};

let slot = Arc::new(super::SendSlot::default());
let id = self.core().sends.local_id();
slot.settled(Ok(()));

if let Some(payload) = nwk_frame.payload.as_opaque()
&& let Ok(ApsFrame::Data(aps_frame)) = parse_aps_frame(payload)
{
tracing::debug!(
"Delivering frame to ourselves locally: cluster {:#06x}",
aps_frame.cluster_id
);
self.handle_zdp_frame(nwk_frame, &aps_frame);
self.push_notification(super::ZigbeeNotification::ReceivedApsCommand {
source: nwk_frame.nwk_header.source,
destination: nwk_frame.nwk_header.destination,
group: aps_frame.group_id,
profile_id: aps_frame.profile_id,
cluster_id: aps_frame.cluster_id,
src_ep: aps_frame.source_endpoint,
dst_ep: aps_frame.destination_endpoint.unwrap_or(0),
lqi: 255,
rssi: 0,
aps_encrypted: false,
data: aps_frame.asdu.to_vec(),
});
}
SendHandle::new(id, slot)
}

/// Send a unicast: assign its NWK sequence number, resolve a next hop, and enqueue
/// it, held for a route discovery if none is known. Resolution and enqueueing share
/// one core lock, so the next hop cannot change in between. `Err` means nothing was
Expand All @@ -358,6 +393,13 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
);

let destination = nwk_frame.nwk_header.destination;

// SPIKE (#84 bench): a frame addressed to ourselves (zigpy scanning the
// coordinator's own tables with ZDP) is delivered locally instead of routed
if destination == self.state.network_address {
return Ok(self.deliver_locally(&nwk_frame));
}

let now = self.core_now();

let mut core = self.core();
Expand Down
10 changes: 7 additions & 3 deletions crates/ziggurat-driver/src/zigbee_stack/zdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
extended_pan_id: self.state.extended_pan_id,
extended_address: entry.extended_address,
network_address: entry.network_address,
// Entries learned from Link Status default to Router; 0x0000 is
// always the coordinator (SPIKE #84 bench fix)
device_type: match entry.device_type {
_ if entry.network_address == Nwk(0x0000) => ZdpDeviceType::Coordinator,
NwkDeviceType::Coordinator => ZdpDeviceType::Coordinator,
NwkDeviceType::Router => ZdpDeviceType::Router,
NwkDeviceType::EndDevice => ZdpDeviceType::EndDevice,
Expand All @@ -102,9 +105,10 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
_ => ZdpAffinity::NoneOfTheAbove,
},
permit_joining: ZdpPermitJoining::Unknown,
// We are the coordinator (tree depth 0), so every neighbor of
// ours sits at depth 1
depth: 1,
// The coordinator sits at tree depth 0, its neighbors at 1. A
// router does not track depth; its neighbors are reported at 1
// like the coordinator's (SPIKE #84 bench fix for 0x0000)
depth: u8::from(entry.network_address != Nwk(0x0000)),
lqa: entry.lqa().unwrap_or(0),
})
.collect();
Expand Down
529 changes: 529 additions & 0 deletions crates/ziggurat-phy-trel/BROADCAST.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions crates/ziggurat-phy-trel/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "ziggurat-phy-trel"
version.workspace = true
description = "SPIKE: TREL-like IP backbone link wrapped around a ziggurat-phy RadioPhy"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true

[dependencies]
ziggurat-ieee-802154.workspace = true
ziggurat-phy.workspace = true

parking_lot = "0.12.5"
tokio = { version = "1.53.1", features = ["rt", "time", "sync", "net", "macros"] }
tracing = "0.1"
mdns-sd = "0.21.7"
if-addrs = "0.15"
socket2 = { version = "0.6.5", features = ["all"] }
quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
ring = "0.17"
rcgen = "0.13"
rustls-webpki = { version = "0.103.16", default-features = false, features = ["ring", "std"] }

[dev-dependencies]
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "time", "sync", "net"] }
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
155 changes: 155 additions & 0 deletions crates/ziggurat-phy-trel/MCU.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# ZLOE on the MCU: satellites running Ziggurat on the radio chip

Status: design (2026-11-10). Nothing built yet. Part of the zigpy/ziggurat#84 spike.

Styleguide: the repository's (`.pre-commit-config.yaml`: `cargo fmt`, clippy
`-D warnings -Dclippy::all -Dclippy::nursery`, `clang-format` for the C/C++ glue).
Rust for the MCU is `no_std` + `alloc` on the embassy runtime, like
`ziggurat-ot`; C glue follows `firmwares/glue`. Tests stay at spike level: unit tests
for the `no_std` core on the host, bench runs for the firmware.

## Goal

Today a satellite is a host process (`ziggurat --zloe-satellite`) driving an RCP over
USB: the radio sits wherever the host is. Ziggurat already runs on the chip
(`firmwares/esp32-c6`: Ziggurat as a `no_std` staticlib inside OpenThread RCP, its
only outside link the Spinel vendor tunnel over UART). Goal: a satellite that is the
chip, reaching the ZLOE backbone by one of four transports, and a comparison of them.

## Architecture

```
ziggurat-driver (router role, unchanged)
│ RadioPhy
┌───────────┴───────────┐
│ ZloeCore (no_std) │ peer table, link selection, ack/retry,
│ wraps OtLinkPhy │ dedup, LQI from backbone health
└───────────┬───────────┘ (TrelPhy's logic, without tokio/std)
│ BackboneTransport trait
┌───────────────┴────────────────┐
UART relay (Spinel tunnel) lwIP on the chip (C glue: UDP + ESP mDNS)
host: thin relay, puts netif = Wi-Fi (coex) | SPI Ethernet (W5500)
datagrams on ZLOE | esp-hosted (second ESP32 over SPI)
```

- **ZloeCore** is the part of `TrelPhy` that is not I/O: identity, peer table,
"backbone first, radio on failure", per-frame ack with retries, duplicate drop,
backbone health → LQI. It moves into a `no_std` crate, and `TrelPhy` on the host is
rebuilt on top of it, so both sides keep one implementation.
- **BackboneTransport** is what the core needs from below: send a datagram to a peer
address, deliver received datagrams, report peers found by discovery, report link
up/down.
- **Wire format** stays ZLOE v2, unauthenticated (`--zloe-security none`). QUIC on the
MCU (ngtcp2, ~37 KB heap and ~641 ms per handshake on an ESP32) is a later step.
- **Bootstrap:** the satellite fetches the network settings with `NETINFO_REQ` over
the backbone (as host satellites do), parses `NetworkInfoPayload` (the protocol crate
is `no_std`) and configures itself in router role. NWK address from the EUI64,
frame counters seeded from a clock (SNTP on lwIP transports; the host's time via the
relay).

## The four transports

| | UART relay | Wi-Fi (coexistence) | SPI Ethernet | esp-hosted |
|---|---|---|---|---|
| Hardware | none extra | none extra | W5500 (or DM9051) module on SPI | second ESP32 (Wi-Fi) on SPI |
| Network stack | host's | lwIP on the C6 | lwIP on the C6 | lwIP on the C6, Wi-Fi on the slave |
| Radio sharing | none | 802.15.4 and Wi-Fi time-slice one radio | none | none (separate chip) |
| Multicast | host's | unreliable on Wi-Fi: announce none | yes | Wi-Fi: announce none |
| Standalone | no (needs a host) | yes | yes | yes |
| What it tests | on-chip router role | coexistence cost | wired standalone | Wi-Fi without coexistence |

## Phases

1. **ZloeCore extraction** (host-tested): move the logic out of `TrelPhy` into a
`no_std` crate with the transport behind a trait; `TrelPhy` uses it, the existing
loopback and QUIC tests stay green.
2. **UART relay:** a Spinel vendor command pair carries ZLOE datagrams between the
chip and the host; the host relay is `ziggurat-phy-trel` in "relay" mode (mDNS and
UDP on behalf of the chip, no Zigbee stack). Firmware: ZloeCore around `OtLinkPhy`
in `ziggurat-ot`.
3. **lwIP glue:** C glue for UDP sockets, ESP mDNS (`_zloe._udp` announce and browse),
SNTP; Rust FFI behind `BackboneTransport`. Brought up on the transport with the
least risk first: SPI Ethernet if a module is available, otherwise Wi-Fi.
4. **Wi-Fi coexistence:** `CONFIG_ESP_COEX_SW_COEXIST_ENABLE`, coexistence priority
to 802.15.4, Wi-Fi power save off. Credentials in a gitignored local header the
user writes.
5. **esp-hosted:** esp-hosted-mcu host component on the C6 over SPI, slave firmware on
the second ESP32.
6. **Bench comparison:** the deny experiment (On/Off to a device only the MCU
satellite hears) and a soak per transport; record delivery, latency, Zigbee frame
loss on the MCU satellite's radio, Wi-Fi throughput where relevant.

## Gap analysis

| ID | Gap | Severity | Resolution |
|---|---|---|---|
| G1 | RAM: OT RCP + Ziggurat + lwIP (+ Wi-Fi driver ~100 KB) in the C6's 512 KB SRAM. | must | Measure free heap per phase; Wi-Fi and esp-hosted builds drop OT features the RCP does not need; neighbour/route tables capped on the MCU. |
| G2 | Coexistence: 802.15.4 loses airtime whenever Wi-Fi holds the radio; Zigbee frames from end devices and routers are missed. | must (measure) | Coexistence priority to 802.15.4, power save off, measure frame loss against the Ethernet build. The result decides whether Wi-Fi coexistence is viable at all. |
| G3 | ESP-IDF Wi-Fi defaults to modem sleep, which delays downlink by up to a DTIM interval (spinel2quic measurement). | must | `WIFI_PS_NONE`. |
| G4 | Timing: Zigbee MAC ACKs come from the radio; ZLOE acks (50 ms × 3) run on the MCU executor next to the stack. | minor | Same constants as the host; measure retry rates. |
| G5 | The UART relay adds Spinel framing and host scheduling to every backbone frame. | minor | Latency comparison in phase 6; acceptable for a test transport. |
| G6 | Clock for frame-counter seeding: the MCU has no wall clock at boot. | must | SNTP on lwIP transports before configuring; the relay passes the host time; otherwise the satellite refuses to start rather than reuse counters. |
| G7 | mDNS on the MCU: ESP mDNS component, one interface, IPv4 and IPv6 link-local. | minor | Announce `xa`, `xp`, `nwk`, `role=router`, `sec=none`, no `mc` on Wi-Fi. |
| G8 | Settings fetched with unauthenticated `NETINFO_REQ` (network key in clear on the LAN). | accepted | Same as host satellites in `none` mode; QUIC enrollment on the MCU later. |
| G9 | esp-hosted on the C6 as host: SDIO is slave-only on the C6, so the link to the second ESP32 is SPI (or UART). | must | SPI transport; check esp-hosted-mcu support for an ESP32-C6 host. |
| G10 | SPI Ethernet needs a module (W5500/DM9051) and wiring. | hardware | Confirm availability; pins from the devkit's free GPIOs. |
| G11 | Firmware OTA/updates and partition size (RCP + Ziggurat + lwIP + Wi-Fi). | minor | 4 MB flash is enough for one app partition; no OTA in the spike. |
| G12 | Host relay must not become a second Zigbee stack. | minor | Relay only moves datagrams and runs discovery; all ZLOE decisions stay in ZloeCore on the chip. |

## Behaviour

```
ZloeCore
transmit
Broadcast frame
- transmits it on the radio
- sends one backbone datagram per peer (or per announced multicast group)
Unicast to a backbone peer
- sends it as a backbone datagram requesting an ack
- returns Acked when the ack arrives
No ack after three tries
- transmits it on the radio
- lowers the peer's backbone health
Unicast to an unknown destination
- transmits it on the radio
receive
Datagram from a peer on our extended PAN ID
- delivers the frame to the stack once, LQI from the peer's backbone health
- acks it when asked
Radio frame from a live backbone peer
- drops it
Datagram from another network
- drops it
bootstrap
Coordinator found by discovery
- requests the network settings
- configures the stack in router role
- announces itself with Device_annce
Clock unknown
- does not start
UART relay (host)
forward_up
Datagram from the backbone for the chip
- passes it to the chip over the Spinel tunnel unchanged
forward_down
Datagram from the chip with a destination
- sends it on the backbone from the relay's socket
discovery
Peer found by mDNS
- reports it to the chip
lwIP transport (C glue)
start
Network interface up (Wi-Fi, Ethernet or esp-hosted)
- opens the UDP socket
- announces _zloe._udp with xa, xp, nwk, role, sec
- browses for peers and reports them
Network interface down
- reports link down
- ZloeCore sends everything on the radio
```

## Hardware needed

- New ESP32-C6 (in hand): UART relay and Wi-Fi coexistence.
- SPI Ethernet module (W5500 or DM9051) with jumper wires: SPI Ethernet.
- Second ESP32 with Wi-Fi, wired to the C6 over SPI: esp-hosted.
Loading