From 2b703be7b4ae66fe17471f0ab24f9d587865a1a8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:44:00 +0000 Subject: [PATCH 1/4] Add Green Power frame RX support --- crates/ziggurat-driver/src/zigbee_stack.rs | 10 ++++ .../ziggurat-driver/src/zigbee_stack/mac.rs | 47 +++++++++++++++++-- crates/ziggurat-protocol/src/bridge.rs | 14 ++++++ crates/ziggurat-protocol/src/wire.rs | 20 ++++++++ crates/ziggurat-zigbee/src/constants.rs | 3 ++ crates/ziggurat-zigbee/src/gp.rs | 45 ++++++++++++++++++ crates/ziggurat-zigbee/src/lib.rs | 1 + 7 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 crates/ziggurat-zigbee/src/gp.rs diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 0eb1baf..2c814d5 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -869,6 +869,16 @@ pub enum ZigbeeNotification { aps_encrypted: bool, data: Vec, }, + /// A Green Power frame received directly from a GPD, without any processing: the + /// stub NWK header onward, including the MIC (GP A.1.2.1.3). Of the MAC header, + /// only the fields GP uses are kept. + ReceivedGpdf { + mac_sequence_number: u8, + source_ieee: Option, + lqi: u8, + rssi: i8, + data: Vec, + }, /// The outgoing NWK security frame counter has advanced; the client should persist /// it to prevent a rollback on restart FrameCounterUpdate { frame_counter: u32 }, diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index f1e6b6f..9695eac 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -6,11 +6,13 @@ use crate::ziggurat_ieee_802154::{ Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, Ieee802154FrameVersion, }; -use abstract_bits::AbstractBits; +use abstract_bits::{AbstractBits, BitReader}; use alloc::vec::Vec; use ziggurat_ieee_802154::types::{Nwk, PanId}; use ziggurat_phy::{RadioPhy, TxFrame, TxResult}; use ziggurat_zigbee::beacon::ZigbeeBeacon; +use ziggurat_zigbee::constants::GP_PROTOCOL_VERSION; +use ziggurat_zigbee::gp::GpNwkFrameControl; use ziggurat_zigbee::nwk::frame::{ BROADCAST_ALL_ROUTERS_AND_COORDINATOR, EncryptedNwkFrame, NwkFrame, NwkPayload, NwkSecurityHeaderKeyId, NwkSecurityLevel, @@ -20,7 +22,7 @@ use crate::frame_token::TrafficClass; use super::{ DeliveryError, NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, SendKind, TxOutcome, TxPolicy, - TxPriority, ZigbeeStack, + TxPriority, ZigbeeNotification, ZigbeeStack, }; /// Spacing between sprayed beacons while a [`hack_beacon_spam_duration`] window is open. @@ -232,9 +234,17 @@ impl ZigbeeStack { return None; } - // Only process packets destined for our PAN ID let pan_id = self.core().mac.pan_id; + // A GPDF carries a stub NWK header instead (GP A.1.4.1), for the client to process + if GpNwkFrameControl::read_abstract_bits(&mut BitReader::from(&data_frame.payload[..])) + .is_ok_and(|control| control.protocol_version == GP_PROTOCOL_VERSION) + { + self.process_gpdf(data_frame, pan_id, lqi, rssi); + return None; + } + + // Only process packets destined for our PAN ID match data_frame.header.dest_pan_id { None => { tracing::debug!("Ignoring frame, destination PAN ID is not present"); @@ -367,6 +377,37 @@ impl ZigbeeStack { Some(decrypted_nwk_frame) } + fn process_gpdf(&self, data_frame: &Ieee802154DataFrame, pan_id: PanId, lqi: u8, rssi: i8) { + // A GPD is not part of the PAN and usually sends to the broadcast PAN ID + // (GP A.1.4.1.1) + match data_frame.header.dest_pan_id { + Some(dest_pan_id) if dest_pan_id == pan_id || dest_pan_id == PanId(0xFFFF) => (), + dest_pan_id => { + tracing::debug!("Ignoring GPDF, destination PAN ID {dest_pan_id:?} is not ours"); + return; + } + } + + let Some(mac_sequence_number) = data_frame.header.sequence_number else { + tracing::debug!("Ignoring GPDF, MAC sequence number is suppressed"); + return; + }; + + // Only an IEEE source identifies the GPD, for ApplicationID 0b010 (GP A.1.4.1.1) + let source_ieee = match data_frame.header.src_address { + Some(Ieee802154Address::Eui64(eui64)) => Some(eui64), + _ => None, + }; + + self.push_notification(ZigbeeNotification::ReceivedGpdf { + mac_sequence_number, + source_ieee, + lqi, + rssi, + data: data_frame.payload.to_vec(), + }); + } + pub(super) async fn send_802154_frame( &self, frame: Ieee802154Frame, diff --git a/crates/ziggurat-protocol/src/bridge.rs b/crates/ziggurat-protocol/src/bridge.rs index 9ca6613..ccffaa9 100644 --- a/crates/ziggurat-protocol/src/bridge.rs +++ b/crates/ziggurat-protocol/src/bridge.rs @@ -521,6 +521,20 @@ pub fn notification_frame(update: &ZigbeeNotification) -> Option> { router_ieee: router_ieee.unwrap_or(Eui64([0; 8])), }) } + ZigbeeNotification::ReceivedGpdf { + mac_sequence_number, + source_ieee, + lqi, + rssi, + data, + } => Notification::ReceivedGpdf(ReceivedGpdfPayload { + mac_sequence_number: *mac_sequence_number, + has_source_ieee: source_ieee.is_some(), + source_ieee: source_ieee.unwrap_or(Eui64([0; 8])), + lqi: *lqi, + rssi: *rssi, + data: data.clone(), + }), ZigbeeNotification::FrameCounterUpdate { frame_counter } => { Notification::FrameCounter(FrameCounterPayload { frame_counter: *frame_counter, diff --git a/crates/ziggurat-protocol/src/wire.rs b/crates/ziggurat-protocol/src/wire.rs index e09e506..846b8d5 100644 --- a/crates/ziggurat-protocol/src/wire.rs +++ b/crates/ziggurat-protocol/src/wire.rs @@ -105,6 +105,7 @@ pub enum NotificationCommand { SendConfirm = 0x11, ApsAckConfirm = 0x12, BroadcastConfirm = 0x13, + ReceivedGpdf = 0x14, // Network membership. DeviceJoined = 0x20, DeviceLeft = 0x21, @@ -676,6 +677,22 @@ pub struct ReceivedApsPayload { pub data: Vec, } +/// A Green Power frame received directly from a GPD, unprocessed: `data` is the stub NWK +/// header onward, including the MIC. +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct ReceivedGpdfPayload { + pub mac_sequence_number: u8, + pub has_source_ieee: bool, + pub reserved: u7, + pub source_ieee: Eui64, // only for ApplicationID 0b010 + pub lqi: u8, + pub rssi: i8, + pub data_len: u16, + #[abstract_bits(length_from = data_len)] + pub data: Vec, +} + /// A send's terminal verdict, carried in its `SendConfirm` / `ApsAckConfirm` / /// `BroadcastConfirm` notification. Mirrors the driver's `DeliveryError`; admission /// failures ride the synchronous `Error` frame's [`Status`] instead. @@ -965,6 +982,7 @@ pub enum Notification { SendConfirm(RequestId, SendConfirmPayload), ApsAckConfirm(RequestId, ApsAckConfirmPayload), BroadcastConfirm(RequestId, BroadcastConfirmPayload), + ReceivedGpdf(ReceivedGpdfPayload), DeviceJoined(DeviceJoinedPayload), DeviceLeft(DeviceLeftPayload), FrameCounter(FrameCounterPayload), @@ -985,6 +1003,7 @@ impl Notification { Self::BroadcastConfirm(request_id, _) => { (NotificationCommand::BroadcastConfirm, *request_id) } + Self::ReceivedGpdf(_) => (NotificationCommand::ReceivedGpdf, 0), Self::DeviceJoined(_) => (NotificationCommand::DeviceJoined, 0), Self::DeviceLeft(_) => (NotificationCommand::DeviceLeft, 0), Self::FrameCounter(_) => (NotificationCommand::FrameCounter, 0), @@ -1001,6 +1020,7 @@ impl Notification { Self::SendConfirm(_, payload) => append(&mut bytes, payload), Self::ApsAckConfirm(_, payload) => append(&mut bytes, payload), Self::BroadcastConfirm(_, payload) => append(&mut bytes, payload), + Self::ReceivedGpdf(payload) => append(&mut bytes, payload), Self::DeviceJoined(payload) => append(&mut bytes, payload), Self::DeviceLeft(payload) => append(&mut bytes, payload), Self::FrameCounter(payload) => append(&mut bytes, payload), diff --git a/crates/ziggurat-zigbee/src/constants.rs b/crates/ziggurat-zigbee/src/constants.rs index 01dadd7..dddd75d 100644 --- a/crates/ziggurat-zigbee/src/constants.rs +++ b/crates/ziggurat-zigbee/src/constants.rs @@ -13,6 +13,9 @@ use crate::nwk::commands::EndDeviceTimeout; /// `nwkcProtocolVersion`: the NWK protocol version carried in every frame. pub const PROTOCOL_VERSION: u8 = 2; +/// The protocol version in the stub NWK header of a Green Power frame (GP A.1.4.1.2). +pub const GP_PROTOCOL_VERSION: u8 = 3; + /// The stack profile advertised in beacons: 2 is Zigbee PRO. pub const STACK_PROFILE: u8 = 2; diff --git a/crates/ziggurat-zigbee/src/gp.rs b/crates/ziggurat-zigbee/src/gp.rs new file mode 100644 index 0000000..77469e4 --- /dev/null +++ b/crates/ziggurat-zigbee/src/gp.rs @@ -0,0 +1,45 @@ +use abstract_bits::abstract_bits; +use num_enum::TryFromPrimitive; + +#[abstract_bits(bits = 2)] +#[derive(Debug, Eq, PartialEq, TryFromPrimitive, Clone, Copy)] +#[repr(u8)] +pub enum GpFrameType { + Data = 0b00, + Maintenance = 0b01, +} + +/// The NWK Frame Control field of a GPDF (GP A.1.4.1.2). It shares the position of +/// the protocol version with the NWK frame control of a Zigbee frame. +#[abstract_bits] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GpNwkFrameControl { + pub frame_type: GpFrameType, + pub protocol_version: u4, + pub auto_commissioning: bool, + pub extended: bool, +} + +#[cfg(test)] +mod test { + use super::*; + use abstract_bits::{AbstractBits, BitReader}; + use hex_literal::hex; + + #[test] + fn test_gp_nwk_frame_control() { + // The transmitted packet for SecurityLevel 0b10 (GP A.1.5.4.2.1) + let bytes = hex!("8C 10 21436587 02000000 20 CF787E72"); + + let mut reader = BitReader::from(&bytes[..]); + assert_eq!( + GpNwkFrameControl::read_abstract_bits(&mut reader).unwrap(), + GpNwkFrameControl { + frame_type: GpFrameType::Data, + protocol_version: 3, + auto_commissioning: false, + extended: true, + } + ); + } +} diff --git a/crates/ziggurat-zigbee/src/lib.rs b/crates/ziggurat-zigbee/src/lib.rs index 14b6f34..bf2cc2a 100644 --- a/crates/ziggurat-zigbee/src/lib.rs +++ b/crates/ziggurat-zigbee/src/lib.rs @@ -7,6 +7,7 @@ pub mod beacon; pub mod constants; pub mod crypto; pub mod flat_map; +pub mod gp; pub mod indirect; pub mod nwk; pub mod ring_buffer; From 96b170aa9032e1515d9876d0da0ff103e425de45 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:00:53 +0000 Subject: [PATCH 2/4] Update README --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7312845..f197296 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ in terms of functionality exposed by consumer Zigbee devices: - [x] Source routing - [x] Device joining - [x] Child device message holding and management -- [ ] Zigbee Green Power (TODO) +- [x] Zigbee Green Power: direct reception of GPDFs, which are passed unprocessed to zigpy +- [ ] Zigbee Green Power: transmission of GPDFs (needed for bidirectional GPDs) Zigbee R23 and R23.1 support (i.e. Zigbee 4.0) are low on the list but planned for the future. Keep in mind Zigbee is both forward and backward compatible and practically nothing @@ -105,7 +106,8 @@ The adapter itself is stateless and ZHA will push all network configuration to i dynamically configurable role on startup. Adding the ability to join a network as a router would allow for some useful end-to-end testing scenarios, like a synthetic Spinel layer that can connect multiple Ziggurat instances together. -- Zigbee Green Power. +- Zigbee Green Power transmission: a GPD only listens 20-25 ms after it transmits, too + short for a round trip to zigpy, so Ziggurat has to queue the response and send it itself. - Exploring more routing algorithms. --- From 5e6290605dad62e42678c7004b8ab837433bdf38 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:01:30 +0000 Subject: [PATCH 3/4] Drop stale microcontroller comment --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index f197296..c1d8d3c 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,7 @@ The adapter itself is stateless and ZHA will push all network configuration to i #### Future work - Ziggurat currently statically allocates most protocol-level parsing buffers, there are only - a few places left in the stack (e.g. `HashMap`) that rely on dynamic allocation. This is - in preparation for eventually trying to run Ziggurat on a microcontroller directly, probably - the ESP32-C6. + a few places left in the stack (e.g. `HashMap`) that rely on dynamic allocation. - For testing purposes (especially CI) it would be convenient to allow Ziggurat to have a dynamically configurable role on startup. Adding the ability to join a network as a router would allow for some useful end-to-end testing scenarios, like a synthetic Spinel layer From d7b7702dd55fd483a47c75a738c1f14d5789bafa Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:05:36 +0000 Subject: [PATCH 4/4] Clean up README a bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c1d8d3c..49709fb 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Ziggurat's public API is a simple WebSocket server, which can be run locally ins in a Home Assistant addon: ```bash -cargo run --bin ziggurat /dev/cu.SLAB_USBtoUART --baudrate 460800 --host 0.0.0.0:9999 +cargo run --bin ziggurat -- --device /dev/cu.SLAB_USBtoUART --listen 0.0.0.0:9999 ``` The adapter itself is stateless and ZHA will push all network configuration to it on startup.