diff --git a/Cargo.lock b/Cargo.lock index 2f82944..c7a9dc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1700,6 +1700,21 @@ dependencies = [ "ziggurat-zigbee", ] +[[package]] +name = "ziggurat-api" +version = "0.0.1" +dependencies = [ + "hex", + "serde", + "serde_json", + "tokio", + "tracing", + "ziggurat", + "ziggurat-ieee-802154", + "ziggurat-spinel", + "ziggurat-zigbee", +] + [[package]] name = "ziggurat-ieee-802154" version = "0.1.0" @@ -1720,7 +1735,6 @@ version = "0.0.1" dependencies = [ "clap", "futures-util", - "hex", "serde", "serde_json", "tokio", @@ -1729,8 +1743,8 @@ dependencies = [ "tracing", "tracing-subscriber", "ziggurat", + "ziggurat-api", "ziggurat-spinel", - "ziggurat-zigbee", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cef932b..1df7914 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,10 @@ [workspace] resolver = "2" members = ["crates/*"] -exclude = ["fuzz"] +# ziggurat-py is a PyO3 extension module built with maturin; it stays out of the +# workspace so its cdylib link flags and unwinding panics (a panic must become a Python +# exception, never abort the host process) do not perturb the server's build profile. +exclude = ["fuzz", "crates/ziggurat-py"] [workspace.package] rust-version = "1.96" # latest as of creating this diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml new file mode 100644 index 0000000..6b81746 --- /dev/null +++ b/crates/api/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ziggurat-api" +version = "0.0.1" +description = "Transport-agnostic JSON-RPC dispatch over the Ziggurat Zigbee stack" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +ieee-802154 = { package = "ziggurat-ieee-802154", path = "../ieee-802154", version = "0.1.0" } +spinel = { package = "ziggurat-spinel", path = "../spinel", version = "0.1.0" } +ziggurat = { path = "../ziggurat", version = "0.0.1" } +zigbee = { package = "ziggurat-zigbee", path = "../zigbee", version = "0.1.0" } + +tracing = "0.1" +hex = "0.4.3" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" +tokio = { version = "1.43.0", features = ["rt", "macros", "time", "sync", "io-util"] } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs new file mode 100644 index 0000000..fd3c717 --- /dev/null +++ b/crates/api/src/lib.rs @@ -0,0 +1,808 @@ +//! Transport-agnostic JSON-RPC dispatch over the Ziggurat Zigbee stack. +//! +//! [`Api`] owns the stack lifecycle and notification hub and turns a parsed request +//! (`id`, `method`, `params`) into a single response value, emitting any intermediate +//! `event` messages through a caller-supplied sink. A transport (the WebSocket server, +//! the Python bindings) is a thin shell: it carries bytes, frames them, and calls +//! [`Api::dispatch`]. The serial port lives behind the [`SpinelClient`] handed in at +//! construction, so the same dispatch serves a port-owning server and an embedder that +//! shuttles bytes itself. + +use serde::Deserialize; +use serde_json::json; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::{broadcast, mpsc}; +use tokio::task::JoinHandle; + +use spinel::client::{SpinelClient, TxPriority}; +use zigbee::aps::frame::ApsDeliveryMode; +use ziggurat::ieee_802154::types::{Eui64, Key, Nwk, PanId}; +use ziggurat::zigbee_stack::aps_security::TclkFlavor; +use ziggurat::zigbee_stack::{ + ApsAck, DeviceLeaveReason, NetworkBeacon, NetworkConfig, TclkSeed, Tunables, + WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, +}; + +/// Bumped on any breaking change to the wire protocol; sent in the `hello` greeting. +pub const PROTOCOL_VERSION: u32 = 1; + +/// The server-level notification hub buffers this many notifications for slow +/// connection forwarders before they start lagging. +const NOTIFICATION_HUB_DEPTH: usize = 1024; + +/// The radio transmit power (in dBm) used when `configure` does not specify one. +const DEFAULT_TX_POWER: i8 = 8; + +/// Constructs the [`SpinelClient`] on first use. +/// +/// The server opens a serial port here (and can fail); an embedder that owns the byte +/// stream returns a client built over an in-memory pipe. The returned client must +/// already have its reader spawned. +pub type SpinelFactory = Box Result, String> + Send + Sync>; + +/// Big-endian colon-separated hex, the format used by zigpy for EUI64 addresses +fn eui64_to_string(eui64: Eui64) -> String { + let mut bytes = eui64.to_bytes(); + bytes.reverse(); + + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") +} + +fn key_to_string(key: &Key) -> String { + key.to_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") +} + +fn network_beacon_json(beacon: &NetworkBeacon) -> serde_json::Value { + json!({ + "channel": beacon.channel, + "source": beacon.source.map(|nwk| format!("{:04x}", nwk.0)), + "pan_id": format!("{:04x}", beacon.pan_id.0), + "extended_pan_id": eui64_to_string(beacon.extended_pan_id), + "permit_joining": beacon.permit_joining, + "stack_profile": beacon.stack_profile, + "protocol_version": beacon.protocol_version, + "router_capacity": beacon.router_capacity, + "end_device_capacity": beacon.end_device_capacity, + "device_depth": beacon.device_depth, + "update_id": beacon.update_id, + "lqi": beacon.lqi, + "rssi": beacon.rssi, + }) +} + +// The client wire protocol: requests carry a client-chosen correlation id; the +// server answers each request with exactly one `response`, preceded by zero or more +// `event` messages sharing the id. `notification` messages are unsolicited. + +pub fn event(id: u64, event: &str) -> serde_json::Value { + json!({"type": "event", "id": id, "event": event}) +} + +pub fn event_data(id: u64, event: &str, data: serde_json::Value) -> serde_json::Value { + json!({"type": "event", "id": id, "event": event, "data": data}) +} + +pub fn response(id: u64, result: serde_json::Value) -> serde_json::Value { + json!({"type": "response", "id": id, "result": result}) +} + +pub fn error_response(id: u64, code: &str, message: impl ToString) -> serde_json::Value { + json!({ + "type": "response", "id": id, + "error": {"code": code, "message": message.to_string()}, + }) +} + +fn notification(event: &str, data: serde_json::Value) -> serde_json::Value { + json!({"type": "notification", "event": event, "data": data}) +} + +/// The greeting a transport sends as soon as a client connects, reporting the protocol +/// version and whether a stack is already running. +pub fn hello(is_configured: bool) -> serde_json::Value { + let state = if is_configured { + "running" + } else { + "awaiting_configuration" + }; + + json!({"type": "hello", "version": PROTOCOL_VERSION, "state": state}) +} + +// Each `params` payload deserializes into the struct matching its `method`. + +#[derive(Deserialize, Debug)] +struct KeyTableEntry { + partner_ieee: Eui64, + key: Key, +} + +#[derive(Deserialize, Debug)] +struct ConfigureRequest { + channel: u8, + nwk_update_id: u8, + pan_id: PanId, + extended_pan_id: Eui64, + nwk_address: Nwk, + ieee_address: Eui64, + network_key: Key, + network_key_seq: u8, + network_key_tx_counter: u32, + tc_link_key: Option, + /// A TCLK seed carried over from a microcontroller stack; unique link keys are + /// derived from it instead of generated randomly. Requires `tclk_flavor`. + tclk_seed: Option, + tclk_flavor: Option, + #[serde(default)] + key_table: Vec, + #[serde(default)] + source_routing: bool, + /// Radio transmit power in dBm + tx_power: Option, +} + +#[derive(Deserialize, Debug)] +struct SendApsRequest { + delivery_mode: ApsDeliveryMode, + /// Resolved through the address map; takes precedence over `destination` + destination_eui64: Option, + destination: Option, + profile_id: u16, + cluster_id: u16, + src_ep: u8, + dst_ep: u8, + aps_ack: bool, + aps_seq: u8, + radius: u8, + /// Hex-encoded ASDU + data: String, + /// APS-encrypt the ASDU with the destination's link key; requires a unicast + /// `destination_eui64` + #[serde(default)] + aps_encryption: bool, + #[serde(default)] + priority: i8, +} + +#[derive(Deserialize, Debug)] +struct EnergyScanRequest { + channels: Vec, + duration_per_channel_ms: u16, +} + +#[derive(Deserialize, Debug)] +struct NetworkScanRequest { + channels: Vec, + duration_per_channel_ms: u16, +} + +#[derive(Deserialize, Debug)] +struct PermitJoinsRequest { + #[serde(default)] + duration: u64, + #[serde(default = "default_accept_direct_joins")] + accept_direct_joins: bool, +} + +const fn default_accept_direct_joins() -> bool { + true +} + +#[derive(Deserialize, Debug)] +struct SetProvisionalKeyRequest { + ieee: Eui64, + key: Key, +} + +#[derive(Deserialize, Debug)] +struct SetChannelRequest { + channel: u8, +} + +#[derive(Deserialize, Debug)] +struct SetNwkUpdateIdRequest { + nwk_update_id: u8, +} + +/// Renders an unsolicited stack notification as its wire JSON message. +pub fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json::Value { + match notification_event { + ZigbeeNotification::ReceivedApsCommand { + source, + destination, + group, + profile_id, + cluster_id, + src_ep, + dst_ep, + lqi, + rssi, + data, + } => notification( + "received_aps_command", + json!({ + "source": hex::encode(source.to_bytes()), + "destination": hex::encode(destination.to_bytes()), + "group": group, + "profile_id": profile_id, + "cluster_id": cluster_id, "src_ep": src_ep, "dst_ep": dst_ep, + "lqi": lqi, "rssi": rssi, "data": hex::encode(data), + }), + ), + ZigbeeNotification::FrameCounterUpdate { frame_counter } => notification( + "frame_counter_update", + json!({"frame_counter": frame_counter}), + ), + ZigbeeNotification::LinkKeyUpdate { ieee, key } => notification( + "link_key_update", + json!({ + "ieee": eui64_to_string(ieee), + "key": key_to_string(&key), + }), + ), + ZigbeeNotification::DeviceJoined { nwk, ieee, parent } => notification( + "device_joined", + json!({ + "nwk": hex::encode(nwk.to_bytes()), + "ieee": eui64_to_string(ieee), + "parent": hex::encode(parent.to_bytes()), + }), + ), + ZigbeeNotification::DeviceLeft { nwk, ieee, reason } => { + let mut params = json!({ + "nwk": hex::encode(nwk.to_bytes()), + "ieee": ieee.map(eui64_to_string), + }); + match reason { + DeviceLeaveReason::Announced { rejoin } => { + params["reason"] = json!("announced"); + params["rejoin"] = json!(rejoin); + } + DeviceLeaveReason::RouterReported { + router, + router_ieee, + } => { + params["reason"] = json!("router_reported"); + params["router"] = json!(hex::encode(router.to_bytes())); + params["router_ieee"] = json!(router_ieee.map(eui64_to_string)); + } + DeviceLeaveReason::KeepaliveTimeout => { + params["reason"] = json!("keepalive_timeout"); + } + } + notification("device_left", params) + } + ZigbeeNotification::ApsDecryptionFailure { + source, + source_ieee, + frame_counter, + key_id, + } => notification( + "aps_decryption_failure", + json!({ + "source": hex::encode(source.to_bytes()), + "source_ieee": eui64_to_string(source_ieee), + "frame_counter": frame_counter, + "key_id": key_id, + }), + ), + } +} + +/// The protocol core shared by every transport. It holds the stack lifecycle and the +/// notification hub; a transport subscribes to [`Api::subscribe`] and feeds parsed +/// requests to [`Api::dispatch`]. +pub struct Api { + /// The Spinel client owns the serial transport for the lifetime of the process: it is + /// built lazily by the first command that needs it and never rebuilt, so stack + /// replacement cannot race a straggling handle. + spinel: Mutex>>, + spinel_factory: SpinelFactory, + stack: Mutex>>, + /// Connections subscribe to this hub, and it survives stack replacement (the + /// forwarder task is swapped instead). + notification_tx: broadcast::Sender, + notification_forwarder: Mutex>>, +} + +impl Api { + /// The Spinel client is not built and the Zigbee stack is not created until a + /// request needs them; `spinel_factory` is what builds the client on first use. + pub fn new(spinel_factory: SpinelFactory) -> Arc { + let (notification_tx, _) = broadcast::channel(NOTIFICATION_HUB_DEPTH); + + Arc::new(Self { + spinel: Mutex::new(None), + spinel_factory, + stack: Mutex::new(None), + notification_tx, + notification_forwarder: Mutex::new(None), + }) + } + + /// A receiver for the unsolicited notification stream, one per transport connection. + pub fn subscribe(&self) -> broadcast::Receiver { + self.notification_tx.subscribe() + } + + /// True once a stack is running, used by transports to report initial state. + pub fn is_configured(&self) -> bool { + self.current_stack().is_some() + } + + fn current_stack(&self) -> Option> { + self.stack.lock().unwrap().clone() + } + + /// The process-lifetime Spinel client, built on first use via the factory. + fn spinel_client(&self) -> Result, String> { + let mut spinel = self.spinel.lock().unwrap(); + + if let Some(spinel) = &*spinel { + return Ok(spinel.clone()); + } + + let client = (self.spinel_factory)()?; + *spinel = Some(client.clone()); + drop(spinel); + + Ok(client) + } + + /// Runs a single request to its terminal response, emitting any intermediate `event` + /// messages through `events`. The caller spawns this and is responsible for sending + /// the returned response value back to the client. + pub async fn dispatch( + &self, + id: u64, + method: &str, + params: serde_json::Value, + events: &mpsc::Sender, + ) -> serde_json::Value { + match method { + "ping" => self.handle_ping(id).await, + "configure" => self.handle_configure(id, params).await, + "get_hw_address" => self.handle_get_hw_address(id).await, + "get_network_info" => self.handle_get_network_info(id), + "send_aps" => self.handle_send_aps(id, params, events).await, + "energy_scan" => self.handle_energy_scan(id, params, events).await, + "network_scan" => self.handle_network_scan(id, params, events).await, + "permit_joins" => self.handle_permit_joins(id, params), + "set_provisional_key" => self.handle_set_provisional_key(id, params), + "set_nwk_update_id" => self.handle_set_nwk_update_id(id, params), + "set_channel" => self.handle_set_channel(id, params).await, + _ => error_response(id, "unknown_method", method), + } + } + + /// Liveness probe. Yielding makes the reply round-trip through the runtime like + /// every real command, so a starved executor shows up in the latency. + async fn handle_ping(&self, id: u64) -> serde_json::Value { + tokio::task::yield_now().await; + + response(id, json!({"status": "pong"})) + } + + /// (Re)initializes the Zigbee stack. The stack deliberately outlives client + /// connections; reconfiguring replaces it wholesale. + #[allow(clippy::significant_drop_tightening)] + async fn handle_configure(&self, id: u64, params: serde_json::Value) -> serde_json::Value { + let request: ConfigureRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let tclk_seed = match (request.tclk_seed, request.tclk_flavor) { + (Some(seed), Some(flavor)) => Some(TclkSeed { seed, flavor }), + (None, None) => None, + _ => { + return error_response( + id, + "invalid_request", + "tclk_seed and tclk_flavor must be provided together", + ); + } + }; + + // A replaced stack must be fully stopped before its successor registers its + // own receivers with the shared Spinel client + let old_stack = self.stack.lock().unwrap().take(); + if let Some(old_stack) = old_stack { + tracing::info!("Replacing the running Zigbee stack"); + old_stack.shutdown().await; + } + + let old_forwarder = self.notification_forwarder.lock().unwrap().take(); + if let Some(old_forwarder) = old_forwarder { + old_forwarder.abort(); + } + + tracing::info!("Initializing Zigbee stack with new settings..."); + let spinel = match self.spinel_client() { + Ok(s) => s, + Err(e) => return error_response(id, "serial_port_error", e), + }; + + let (stack, mut stack_notification_rx) = ZigbeeStack::new( + spinel, + NetworkConfig { + channel: request.channel, + update_id: request.nwk_update_id, + pan_id: request.pan_id, + extended_pan_id: request.extended_pan_id, + network_address: request.nwk_address, + ieee_address: request.ieee_address, + network_key: request.network_key, + network_key_seq_number: request.network_key_seq, + network_key_tx_counter: request.network_key_tx_counter, + tc_link_key: request.tc_link_key.unwrap_or(WELL_KNOWN_LINK_KEY), + tclk_seed, + tx_power: request.tx_power.unwrap_or(DEFAULT_TX_POWER), + source_routing: request.source_routing, + }, + Tunables::new(), + ); + + // Restore unique trust center link keys negotiated in earlier sessions + if !request.key_table.is_empty() { + let mut core = stack.state.core.lock(); + + for entry in request.key_table { + core.aib + .aps_security + .restore_device_key(entry.partner_ieee, entry.key); + } + + tracing::info!( + "Restored {} trust center link keys", + core.aib.aps_security.device_key_count() + ); + } + + // The success response is the client's permission to send commands: the + // network must be fully up (RCP reset handled, radio programmed) before + // replying, or the client's first command would race with the boot-time reset. + if let Err(e) = stack.start_network().await { + stack.shutdown().await; + return error_response(id, "network_start_failed", e); + } + + let stack_clone = stack.clone(); + stack.spawn_tracked(async move { + stack_clone.run().await; + }); + + // Pump the stack's notifications into the server-level hub + let hub_tx = self.notification_tx.clone(); + let forwarder = tokio::spawn(async move { + while let Ok(event) = stack_notification_rx.recv().await { + // Send errors just mean no client is connected right now + let _ = hub_tx.send(event); + } + }); + + *self.stack.lock().unwrap() = Some(stack); + *self.notification_forwarder.lock().unwrap() = Some(forwarder); + + tracing::info!("Zigbee stack initialized and running."); + response(id, json!({"status": "success"})) + } + + /// Updates the `nwkUpdateId` advertised in beacons, the companion to + /// `set_channel` during a network-wide channel migration. + fn handle_set_nwk_update_id(&self, id: u64, params: serde_json::Value) -> serde_json::Value { + let request: SetNwkUpdateIdRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = self.current_stack() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.set_nwk_update_id(request.nwk_update_id); + response(id, json!({"status": "success"})) + } + + /// Retunes the radio to a new channel, the coordinator's half of a network-wide + /// channel migration; broadcasting `Mgmt_NWK_Update_req` to the other devices is + /// the client's job. + async fn handle_set_channel(&self, id: u64, params: serde_json::Value) -> serde_json::Value { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = self.current_stack() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + match stack.set_channel(request.channel).await { + Ok(()) => response(id, json!({"status": "success"})), + Err(e) => error_response(id, "set_channel_failed", e), + } + } + + /// Reads back the running network's settings, the counterpart of `configure`. + /// While the stack runs, the server is the authoritative holder of the live state + /// (e.g. frame counters), not the client that configured it. + #[allow(clippy::significant_drop_tightening)] + fn handle_get_network_info(&self, id: u64) -> serde_json::Value { + let Some(stack) = self.current_stack() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + let state = &stack.state; + let core = state.core.lock(); + let nwk_security = &core.nib.nwk_security; + let aps_security = &core.aib.aps_security; + let tclk_seed = &stack.config.tclk_seed; + + response( + id, + json!({ + "channel": core.mac.channel, + "nwk_update_id": core.nib.update_id, + "pan_id": format!("{:04x}", core.mac.pan_id.0), + "extended_pan_id": eui64_to_string(state.extended_pan_id), + "nwk_address": format!("{:04x}", state.network_address.as_u16()), + "ieee_address": eui64_to_string(state.ieee_address), + "network_key": key_to_string(&nwk_security.network_key()), + "network_key_seq": nwk_security.key_seq_number(), + "network_key_tx_counter": nwk_security.outgoing_frame_counter(), + "tc_link_key": key_to_string(&stack.config.tc_link_key), + "tx_power": stack.config.tx_power, + "tclk_seed": tclk_seed.as_ref().map(|tclk| hex::encode(tclk.seed.to_bytes())), + "tclk_flavor": tclk_seed.as_ref().map(|tclk| match tclk.flavor { + TclkFlavor::ZStack => "zstack", + TclkFlavor::Ezsp => "ezsp", + }), + "key_table": aps_security + .device_keys() + .map(|(partner_ieee, entry)| json!({ + "partner_ieee": eui64_to_string(partner_ieee), + "key": key_to_string(&entry.key), + })) + .collect::>(), + }), + ) + } + + /// Reads the radio's factory-programmed EUI64, which a client needs before it can + /// form a network with `configure`. + async fn handle_get_hw_address(&self, id: u64) -> serde_json::Value { + let spinel = match self.spinel_client() { + Ok(s) => s, + Err(e) => return error_response(id, "serial_port_error", e), + }; + + match spinel.get_hw_address().await { + Ok(ieee) => response(id, json!({"ieee_address": eui64_to_string(ieee)})), + Err(e) => error_response(id, "hw_address_failed", e), + } + } + + async fn handle_send_aps( + &self, + id: u64, + params: serde_json::Value, + events: &mpsc::Sender, + ) -> serde_json::Value { + let request: SendApsRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = self.current_stack() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + // A network address is authoritative when given (`destination_eui64` then only + // selects the link key); EUI64-only packets are resolved through the address map + let destination = match (request.destination_eui64, request.destination) { + (_, Some(nwk)) => nwk, + (Some(eui64), None) => { + let nwk = stack.state.core.lock().nib.address_map.nwk_for(eui64); + + match nwk { + Some(nwk) => nwk, + None => { + return error_response( + id, + "unknown_destination_eui64", + format!("{eui64:?}"), + ); + } + } + } + (None, None) => { + return error_response(id, "missing_destination", "no destination given"); + } + }; + + let asdu = match hex::decode(&request.data) { + Ok(asdu) => asdu, + Err(e) => return error_response(id, "invalid_data", e), + }; + + // Link keys are pairwise: encryption needs a unicast EUI64-addressed target + let aps_security = if request.aps_encryption { + match (request.destination_eui64, request.delivery_mode) { + (Some(eui64), ApsDeliveryMode::Unicast) => Some(eui64), + _ => { + return error_response( + id, + "invalid_request", + "aps_encryption requires a unicast destination_eui64", + ); + } + } + } else { + None + }; + + let ack_waiter = match stack + .send_aps_command( + request.delivery_mode, + destination, + request.profile_id, + request.cluster_id, + request.src_ep, + request.dst_ep, + if request.aps_ack { + ApsAck::Request + } else { + ApsAck::None + }, + request.radius, + request.aps_seq, + asdu, + aps_security, + TxPriority(request.priority), + ) + .await + { + Ok(ack_waiter) => ack_waiter, + Err(e) => return error_response(id, "transmit_failed", e), + }; + + // The frame is on the air (or extracted from the indirect queue); the + // terminal response then reports end-to-end delivery when an ack was requested + let _ = events.send(event(id, "transmitted")).await; + + match ack_waiter { + None => response(id, json!({"status": "sent"})), + Some(waiter) => match stack.wait_aps_ack(waiter).await { + Ok(()) => response(id, json!({"status": "delivered"})), + Err(e) => error_response(id, "aps_ack_timeout", e), + }, + } + } + + async fn handle_energy_scan( + &self, + id: u64, + params: serde_json::Value, + events: &mpsc::Sender, + ) -> serde_json::Value { + let request: EnergyScanRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = self.current_stack() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + let (result_tx, mut result_rx) = mpsc::channel::<(u8, i8)>(32); + + // The scan runs on its own task so it always reaches its channel restore, even if + // this request's task is dropped. Its only sender lives until the scan ends, so + // the drain loop below terminates exactly when the scan is done. + let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); + let scan = tokio::spawn(async move { + stack + .energy_scan(&request.channels, duration, result_tx) + .await + }); + + while let Some((channel, rssi)) = result_rx.recv().await { + let _ = events + .send(event_data( + id, + "energy_result", + json!({"channel": channel, "rssi": rssi}), + )) + .await; + } + + match scan.await { + Ok(Ok(())) => response(id, json!({"status": "complete"})), + Ok(Err(e)) => error_response(id, "energy_scan_failed", e), + Err(e) => error_response(id, "energy_scan_failed", e), + } + } + + async fn handle_network_scan( + &self, + id: u64, + params: serde_json::Value, + events: &mpsc::Sender, + ) -> serde_json::Value { + let request: NetworkScanRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = self.current_stack() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + let (found_tx, mut found_rx) = mpsc::channel::(32); + + // The scan runs on its own task so it always reaches its channel restore, even if + // this request's task is dropped. Its only sender lives until the scan ends, so + // the drain loop below terminates exactly when the scan is done. + let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); + let scan = tokio::spawn(async move { + stack + .network_scan(&request.channels, duration, found_tx) + .await + }); + + while let Some(beacon) = found_rx.recv().await { + let _ = events + .send(event_data( + id, + "network_found", + network_beacon_json(&beacon), + )) + .await; + } + + match scan.await { + Ok(Ok(())) => response(id, json!({"status": "complete"})), + Ok(Err(e)) => error_response(id, "network_scan_failed", e), + Err(e) => error_response(id, "network_scan_failed", e), + } + } + + fn handle_permit_joins(&self, id: u64, params: serde_json::Value) -> serde_json::Value { + let request: PermitJoinsRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = self.current_stack() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.permit_joins(request.duration, request.accept_direct_joins); + + response(id, json!({"status": "success"})) + } + + fn handle_set_provisional_key(&self, id: u64, params: serde_json::Value) -> serde_json::Value { + let request: SetProvisionalKeyRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = self.current_stack() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.set_provisional_key(request.ieee, request.key); + + response(id, json!({"status": "success"})) + } +} diff --git a/crates/spinel/src/client.rs b/crates/spinel/src/client.rs index abc7fc3..4385ad6 100644 --- a/crates/spinel/src/client.rs +++ b/crates/spinel/src/client.rs @@ -10,10 +10,14 @@ use thiserror::Error; use tokio_serial::SerialStream; use crate::priority_lock::PriorityLock; +use std::pin::Pin; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicU32, Ordering}; -use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf}; +use std::task::{Context, Poll}; +use tokio::io::{ + AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf, ReadHalf, WriteHalf, +}; use tokio::sync::Mutex as AsyncMutex; use tokio::sync::mpsc; use tokio::time::{Duration, timeout}; @@ -25,6 +29,65 @@ const TIMEOUT: Duration = Duration::from_secs(2); /// reset-recovery path is triggered. const MAX_CONSECUTIVE_TIMEOUTS: u32 = 4; +/// The byte transport under the Spinel client, either a serial port or a raw stream. +#[derive(Debug)] +pub enum SpinelTransport { + Serial(SerialStream), + Duplex(DuplexStream), +} + +impl From for SpinelTransport { + fn from(port: SerialStream) -> Self { + Self::Serial(port) + } +} + +impl From for SpinelTransport { + fn from(pipe: DuplexStream) -> Self { + Self::Duplex(pipe) + } +} + +impl AsyncRead for SpinelTransport { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Serial(port) => Pin::new(port).poll_read(cx, buf), + Self::Duplex(pipe) => Pin::new(pipe).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for SpinelTransport { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Serial(port) => Pin::new(port).poll_write(cx, buf), + Self::Duplex(pipe) => Pin::new(pipe).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Serial(port) => Pin::new(port).poll_flush(cx), + Self::Duplex(pipe) => Pin::new(pipe).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Serial(port) => Pin::new(port).poll_shutdown(cx), + Self::Duplex(pipe) => Pin::new(pipe).poll_shutdown(cx), + } + } +} + #[derive(Debug, PartialEq, Eq, Clone)] pub struct SpinelTxFrame { pub psdu: Vec, @@ -230,7 +293,7 @@ pub enum SpinelError { /// time, so two persistent buffers cover every TX without per-frame allocation. #[derive(Debug)] struct SpinelWriter { - port: WriteHalf, + port: WriteHalf, frame_scratch: Vec, hdlc_scratch: Vec, } @@ -257,7 +320,7 @@ impl Default for TxPriority { #[derive(Debug)] pub struct SpinelClient { /// The reader half of the port, owned by the task spawned in `spawn_reader`. - reader: Mutex>>, + reader: Mutex>>, /// The writer half of the port. The mutex also serializes outbound HDLC writes so /// concurrent commands cannot interleave partial frames inside the byte stream. writer: AsyncMutex, @@ -273,8 +336,8 @@ pub struct SpinelClient { } impl SpinelClient { - pub fn new(port: SerialStream) -> Self { - let (reader, writer) = tokio::io::split(port); + pub fn new(transport: impl Into) -> Self { + let (reader, writer) = tokio::io::split(transport.into()); Self { reader: Mutex::new(Some(reader)), @@ -315,6 +378,17 @@ impl SpinelClient { /// Serial death (USB yank, EOF) exits the whole process: the supervisor (Docker) /// restarts us. A half-dead process with a deaf radio is the worst failure mode. pub fn spawn_reader(&self) { + self.spawn_reader_inner(true); + } + + /// Like [`Self::spawn_reader`] but stops the reader task on transport close instead + /// of exiting the process. For embedders (e.g. the Python bindings) whose transport + /// closing is a normal shutdown, not a fault that warrants killing the host process. + pub fn spawn_reader_graceful(&self) { + self.spawn_reader_inner(false); + } + + fn spawn_reader_inner(&self, exit_on_close: bool) { let mut reader = self .reader .lock() @@ -332,14 +406,22 @@ impl SpinelClient { let mut protocol = protocol.lock().expect("Failed to lock Spinel"); protocol.handle_inbound_bytes(&buffer[..n]) } - Ok(_) => { + Ok(_) if exit_on_close => { tracing::error!("Serial port EOF, exiting"); std::process::exit(1); } - Err(e) => { + Err(e) if exit_on_close => { tracing::error!("Serial port read failed ({e}), exiting"); std::process::exit(1); } + Ok(_) => { + tracing::warn!("Spinel transport closed, stopping reader"); + return; + } + Err(e) => { + tracing::warn!("Spinel transport read failed ({e}), stopping reader"); + return; + } } } }); diff --git a/crates/ziggurat-py/Cargo.lock b/crates/ziggurat-py/Cargo.lock new file mode 100644 index 0000000..4a99d6c --- /dev/null +++ b/crates/ziggurat-py/Cargo.lock @@ -0,0 +1,1345 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "abstract-bits" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c265591a83d97ca12d32d679e8e0df1b11ff21b333a1679a52ff1bec2e16add" +dependencies = [ + "abstract-bits-derive", + "arbitrary-int 1.3.0", + "bitvec", + "thiserror", +] + +[[package]] +name = "abstract-bits-derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00ad589d11a94666dca636f13148a47005575d58034ed0f9d63d24b661c9d622" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aead" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef60ac202874e574ce7a7158cc8bca7313dd344322482e4fadee288bf4a306b8" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "arbitrary-int" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993a810118f8f37e9c4411c86f1c4c940a09a7ab34b7bf2d88d06f50c553fab7" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "ccm" +version = "0.6.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edea5ea70a1285565ac264767613d6c88351a9a0557e7af793a0942590baaed" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc_all" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c46c1a17ebeef917714db3ae9a17bd2184f7e9977d8e020c6c8bcf59a28a6f1b" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mio-serial" +version = "5.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d4ba3f20276f21b7cad3f1b54c97489cf096a3894fd627cc6951cb3abdd4c60" +dependencies = [ + "log", + "mio", + "nix 0.31.3", + "serialport", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-log" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64083bd3a16a353d9d62335808e8e13d0552d2a2b83fdb084496192dcfa9fcd" +dependencies = [ + "arc-swap", + "log", + "pyo3", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-serial" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd00f5f8b1e01c3e5afccd9e42ed80c2ad2df6d007877f29f8592c62e69cd116" +dependencies = [ + "cfg-if", + "futures-core", + "futures-sink", + "log", + "mio-serial", + "serialport", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "ziggurat" +version = "0.0.1" +dependencies = [ + "abstract-bits", + "arbitrary-int 2.1.1", + "parking_lot", + "rand", + "thiserror", + "tokio", + "tracing", + "ziggurat-ieee-802154", + "ziggurat-spinel", + "ziggurat-zigbee", +] + +[[package]] +name = "ziggurat-api" +version = "0.0.1" +dependencies = [ + "hex", + "serde", + "serde_json", + "tokio", + "tracing", + "ziggurat", + "ziggurat-ieee-802154", + "ziggurat-spinel", + "ziggurat-zigbee", +] + +[[package]] +name = "ziggurat-ieee-802154" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "educe", + "heapless", + "hex", + "num_enum", + "serde", + "thiserror", +] + +[[package]] +name = "ziggurat-py" +version = "0.0.1" +dependencies = [ + "pyo3", + "pyo3-log", + "serde_json", + "tokio", + "tracing", + "ziggurat-api", + "ziggurat-spinel", +] + +[[package]] +name = "ziggurat-spinel" +version = "0.1.0" +dependencies = [ + "crc_all", + "num_enum", + "thiserror", + "tokio", + "tokio-serial", + "tracing", + "ziggurat-ieee-802154", +] + +[[package]] +name = "ziggurat-zigbee" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "aes", + "arbitrary-int 2.1.1", + "ccm", + "educe", + "hex", + "num_enum", + "serde", + "subtle", + "thiserror", + "tracing", + "ziggurat-ieee-802154", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/ziggurat-py/Cargo.toml b/crates/ziggurat-py/Cargo.toml new file mode 100644 index 0000000..c6e6ee6 --- /dev/null +++ b/crates/ziggurat-py/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ziggurat-py" +version = "0.0.1" +description = "Python bindings embedding the Ziggurat Zigbee stack" +edition = "2024" +rust-version = "1.96" +license = "Apache-2.0" +authors = [] +repository = "https://github.com/zigpy/ziggurat" + +[lib] +name = "ziggurat_py" +crate-type = ["cdylib", "rlib"] + +[dependencies] +pyo3 = { version = "0.29.0", features = ["extension-module"] } +pyo3-log = "0.13.4" +serde_json = "1.0.150" +spinel = { package = "ziggurat-spinel", path = "../spinel", version = "0.1.0" } +tokio = { version = "1.52.3", features = ["rt-multi-thread", "io-util", "sync", "macros"] } +tracing = { version = "0.1.44", features = ["log-always"] } +ziggurat-api = { path = "../api", version = "0.0.1" } diff --git a/crates/ziggurat-py/pyproject.toml b/crates/ziggurat-py/pyproject.toml new file mode 100644 index 0000000..be4c0b8 --- /dev/null +++ b/crates/ziggurat-py/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["maturin>=1.7,<2"] +build-backend = "maturin" + +[project] +name = "ziggurat-py" +description = "Python bindings embedding the Ziggurat Zigbee stack" +requires-python = ">=3.13" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] + +[dependency-groups] +dev = ["maturin>=1.7,<2"] diff --git a/crates/ziggurat-py/src/lib.rs b/crates/ziggurat-py/src/lib.rs new file mode 100644 index 0000000..62ba62e --- /dev/null +++ b/crates/ziggurat-py/src/lib.rs @@ -0,0 +1,407 @@ +//! Python bindings embedding the Ziggurat Zigbee stack in-process. +//! +//! The host process (zigpy-ziggurat) owns the serial port. It shuttles raw bytes across +//! the FFI boundary — [`Ziggurat::feed`] for bytes read from the radio, +//! [`Ziggurat::read_outbound`] for bytes the stack wants written — while the control +//! plane stays the same JSON protocol the WebSocket server speaks, exchanged through +//! [`Ziggurat::send_message`] / [`Ziggurat::recv_message`]. Internally the Spinel client +//! runs over an in-memory duplex whose far end is the byte shuttle, and a session task +//! drives [`Api`] exactly as the server's connection handler does. +//! +//! Each instance owns its own tokio runtime, so there is no process-global runtime: the +//! task graph is scoped to the instance and reclaimed wholesale when it is closed or +//! dropped (`shutdown_background` drops the task futures, which breaks the stack's +//! self-referential `Arc` cycle that an abort-free drop would otherwise leak). + +use std::future::Future; +use std::sync::{Arc, Mutex, OnceLock}; + +use pyo3::IntoPyObjectExt; +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf}; +use tokio::runtime::{Builder, Handle, Runtime}; +use tokio::sync::{Mutex as AsyncMutex, broadcast, mpsc}; +use tokio::task::AbortHandle; + +use spinel::client::SpinelClient; +use ziggurat_api::{Api, error_response, event, hello, notification_to_message}; + +/// Capacity of the in-memory pipe between the Spinel client and the byte shuttle. Spinel +/// frames are a couple of KiB at most; this leaves ample slack so a write never blocks +/// waiting on the reader. +const DUPLEX_BUF: usize = 64 * 1024; + +/// Control-plane messages buffered toward the Python client before backpressure. Mirrors +/// the WebSocket server's outbound queue depth. +const OUTBOUND_QUEUE_DEPTH: usize = 1024; + +/// The maximum chunk handed to Python per `read_outbound` call. +const OUTBOUND_CHUNK: usize = 2048; + +/// Worker threads per instance. Zigbee traffic is light, so a small pool keeps the thread +/// footprint bounded while still avoiding the single-thread starvation a current-thread +/// runtime could hit during a burst of crypto work. +const WORKER_THREADS: usize = 2; + +/// Resolves a pending [`asyncio.Future`] from a runtime thread, guarded so a cancellation +/// that already completed the future is a no-op instead of an `InvalidStateError`. Cached +/// per interpreter; built once on first use. +static COMPLETE: OnceLock> = OnceLock::new(); + +fn complete_fn<'py>(py: Python<'py>) -> PyResult> { + if let Some(complete) = COMPLETE.get() { + return Ok(complete.bind(py).clone()); + } + + let code = c" +def complete(fut, is_exception, value): + if fut.cancelled(): + return + if is_exception: + fut.set_exception(value) + else: + fut.set_result(value) +"; + let module = PyModule::from_code(py, code, c"_ziggurat_bridge.py", c"_ziggurat_bridge")?; + let complete = module.getattr("complete")?.unbind(); + + // A lost init race just drops the duplicate; the winner is equivalent. + let _ = COMPLETE.set(complete); + Ok(COMPLETE.get().unwrap().bind(py).clone()) +} + +/// An already-resolved asyncio future. Used by the context-manager dunders, which run on +/// the loop thread and must not route through the runtime (`__aexit__` is shutting it +/// down), so the result is set synchronously here. +fn resolved_future<'py>( + py: Python<'py>, + value: Bound<'py, PyAny>, +) -> PyResult> { + let event_loop = py.import("asyncio")?.call_method0("get_running_loop")?; + let py_fut = event_loop.call_method0("create_future")?; + py_fut.call_method1("set_result", (value,))?; + Ok(py_fut) +} + +/// A future done-callback that aborts the backing runtime task if the asyncio future was +/// cancelled, so a cancelled `recv_message` cannot leave a task that steals the next +/// message off the channel. A normal completion makes the abort a no-op. +#[pyclass] +struct Canceller { + abort: Mutex>, +} + +#[pymethods] +impl Canceller { + fn __call__(&self, future: &Bound<'_, PyAny>) -> PyResult<()> { + if future.call_method0("cancelled")?.extract::()? + && let Ok(mut abort) = self.abort.lock() + && let Some(abort) = abort.take() + { + abort.abort(); + } + Ok(()) + } +} + +/// Bridge a Rust future onto the running asyncio loop: create a loop-owned future now, +/// drive the Rust future on `handle`, and complete the asyncio future from the runtime +/// thread via `call_soon_threadsafe`. Cancelling the asyncio future aborts the backing +/// task; shutting the runtime down drops it. Either way the asyncio future stops +/// resolving, which is what the caller wants when tearing down. +fn future_into_py<'py, F, T>( + py: Python<'py>, + handle: &Handle, + future: F, +) -> PyResult> +where + F: Future> + Send + 'static, + T: for<'a> IntoPyObject<'a> + Send + 'static, +{ + let event_loop = py.import("asyncio")?.call_method0("get_running_loop")?; + let py_fut = event_loop.call_method0("create_future")?; + + let loop_handle: Py = event_loop.clone().unbind(); + let fut_handle: Py = py_fut.clone().unbind(); + + let task = handle.spawn(async move { + let result = future.await; + + Python::attach(|py| { + let event_loop = loop_handle.bind(py); + let py_fut = fut_handle.bind(py); + let Ok(complete) = complete_fn(py) else { + return; + }; + + // A closed loop makes call_soon_threadsafe raise; nothing to do then. + let _ = match result { + Ok(value) => match value.into_bound_py_any(py) { + Ok(obj) => event_loop + .call_method1("call_soon_threadsafe", (&complete, py_fut, false, obj)), + Err(e) => event_loop.call_method1( + "call_soon_threadsafe", + (&complete, py_fut, true, e.into_value(py)), + ), + }, + Err(err) => event_loop.call_method1( + "call_soon_threadsafe", + (&complete, py_fut, true, err.into_value(py)), + ), + }; + }); + }); + + let canceller = Bound::new( + py, + Canceller { + abort: Mutex::new(Some(task.abort_handle())), + }, + )?; + py_fut.call_method1("add_done_callback", (canceller,))?; + + Ok(py_fut) +} + +/// An embedded Ziggurat stack, owning its tokio runtime. +#[pyclass] +struct Ziggurat { + /// For spawning the bridge futures; valid until the runtime is shut down. + handle: Handle, + /// The owned runtime, taken on `close` (or drop) to reclaim the whole task graph. + runtime: Mutex>, + /// Carries request JSON strings from Python into the session task. + inbound_tx: mpsc::Sender, + /// Carries response/event/notification JSON values from the session task to Python. + outbound_rx: Arc>>, + /// Bytes received from the radio are written here; the Spinel client reads them. + serial_in: Arc>>, + /// Bytes the Spinel client wants on the wire surface here, to be sent to the radio. + serial_out: Arc>>, +} + +#[pymethods] +impl Ziggurat { + #[new] + fn new() -> PyResult { + let runtime = Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .enable_all() + .thread_name("ziggurat") + .build() + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + let handle = runtime.handle().clone(); + + // `spawn_reader_graceful` calls `tokio::spawn`, which needs the runtime context + // active on this thread for the duration of construction. + let _guard = handle.enter(); + + // One end of the pipe is the Spinel client's transport; the other is the byte + // shuttle. Writing to `serial_in` is readable by the client (radio -> stack); + // reading `serial_out` drains what the client wrote (stack -> radio). + let (client_side, embedder_side) = tokio::io::duplex(DUPLEX_BUF); + let spinel = Arc::new(SpinelClient::new(client_side)); + // The transport closing is a normal shutdown here, not a fault: never kill the + // host process the way the standalone server does. + spinel.spawn_reader_graceful(); + + // The client already exists, so the factory is infallible and never reopens. + let api = Api::new(Box::new(move || Ok(spinel.clone()))); + + let (inbound_tx, inbound_rx) = mpsc::channel::(64); + let (outbound_tx, outbound_rx) = mpsc::channel::(OUTBOUND_QUEUE_DEPTH); + + handle.spawn(run_session(api, inbound_rx, outbound_tx)); + + let (serial_out, serial_in) = tokio::io::split(embedder_side); + + Ok(Self { + handle, + runtime: Mutex::new(Some(runtime)), + inbound_tx, + outbound_rx: Arc::new(AsyncMutex::new(outbound_rx)), + serial_in: Arc::new(AsyncMutex::new(serial_in)), + serial_out: Arc::new(AsyncMutex::new(serial_out)), + }) + } + + /// Submit a JSON-RPC request string, the analogue of a WebSocket text send. + fn send_message<'py>(&self, py: Python<'py>, message: String) -> PyResult> { + let inbound_tx = self.inbound_tx.clone(); + + future_into_py(py, &self.handle, async move { + inbound_tx + .send(message) + .await + .map_err(|_| PyRuntimeError::new_err("ziggurat stack stopped"))?; + Ok(()) + }) + } + + /// Await the next outbound control-plane message (response, event, or notification) + /// as a JSON string, the analogue of a WebSocket text receive. + fn recv_message<'py>(&self, py: Python<'py>) -> PyResult> { + let outbound_rx = self.outbound_rx.clone(); + + future_into_py(py, &self.handle, async move { + let mut outbound_rx = outbound_rx.lock().await; + + outbound_rx + .recv() + .await + .map(|value| value.to_string()) + .ok_or_else(|| PyRuntimeError::new_err("ziggurat stack stopped")) + }) + } + + /// Hand the stack bytes read from the radio. + fn feed<'py>(&self, py: Python<'py>, data: Vec) -> PyResult> { + let serial_in = self.serial_in.clone(); + + future_into_py(py, &self.handle, async move { + let mut serial_in = serial_in.lock().await; + + serial_in + .write_all(&data) + .await + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + Ok(()) + }) + } + + /// Await the next chunk of bytes the stack wants written to the radio. An empty + /// result means the stack has shut down its side of the pipe. + fn read_outbound<'py>(&self, py: Python<'py>) -> PyResult> { + let serial_out = self.serial_out.clone(); + + future_into_py(py, &self.handle, async move { + let mut serial_out = serial_out.lock().await; + let mut buffer = [0u8; OUTBOUND_CHUNK]; + + let n = serial_out + .read(&mut buffer) + .await + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + Python::attach(|py| Ok(PyBytes::new(py, &buffer[..n]).unbind())) + }) + } + + /// Stop the stack and reclaim every task. Idempotent; further calls are no-ops. + fn close(&self) { + self.shutdown(); + } + + /// `async with Ziggurat() as z:` — entering yields the stack itself. + fn __aenter__<'py>(slf: Bound<'py, Self>, py: Python<'py>) -> PyResult> { + resolved_future(py, slf.into_any()) + } + + /// Exiting closes the stack. Returns `None` (falsy) so an exception in the body is + /// not suppressed. + #[pyo3(signature = (_exc_type=None, _exc_value=None, _traceback=None))] + fn __aexit__<'py>( + &self, + py: Python<'py>, + _exc_type: Option>, + _exc_value: Option>, + _traceback: Option>, + ) -> PyResult> { + self.shutdown(); + resolved_future(py, py.None().into_bound(py)) + } +} + +impl Ziggurat { + /// Take and shut down the runtime without blocking. Dropping the runtime drops every + /// task future, releasing the `Arc` clones they hold and tearing down + /// the stack; the in-flight bridge futures simply never resolve. + fn shutdown(&self) { + if let Ok(mut runtime) = self.runtime.lock() + && let Some(runtime) = runtime.take() + { + runtime.shutdown_background(); + } + } +} + +impl Drop for Ziggurat { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// Drives [`Api`] over the in-process channels, mirroring the WebSocket server's +/// per-connection handler: greet, forward notifications, then dispatch each request on +/// its own task so a slow command never blocks the others. +async fn run_session( + api: Arc, + mut inbound: mpsc::Receiver, + outbound: mpsc::Sender, +) { + let _ = outbound.send(hello(api.is_configured())).await; + + let mut notifications = api.subscribe(); + let notification_outbound = outbound.clone(); + tokio::spawn(async move { + loop { + match notifications.recv().await { + Ok(event) => { + if notification_outbound + .send(notification_to_message(event)) + .await + .is_err() + { + break; + } + } + Err(broadcast::error::RecvError::Lagged(count)) => { + tracing::warn!("Python client lagged {count} notifications"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); + + while let Some(text) = inbound.recv().await { + let request: Value = match serde_json::from_str(&text) { + Ok(value) => value, + Err(e) => { + let _ = outbound.send(error_response(0, "invalid_request", e)).await; + continue; + } + }; + + let (Some(id), Some(method)) = (request["id"].as_u64(), request["method"].as_str()) else { + let _ = outbound + .send(error_response(0, "invalid_request", "missing id or method")) + .await; + continue; + }; + let method = method.to_owned(); + let params = request.get("params").cloned().unwrap_or(Value::Null); + + let _ = outbound.send(event(id, "accepted")).await; + + let api = api.clone(); + let outbound = outbound.clone(); + tokio::spawn(async move { + let message = api.dispatch(id, &method, params, &outbound).await; + let _ = outbound.send(message).await; + }); + } +} + +#[pymodule] +fn ziggurat_py(m: &Bound<'_, PyModule>) -> PyResult<()> { + // Route the stack's `tracing` events into Python's `logging`. The `log-always` + // feature on `tracing` turns every event into a `log` record, which pyo3-log + // forwards. Ignore a double-init if the module is imported more than once. + let _ = pyo3_log::try_init(); + + m.add_class::()?; + Ok(()) +} diff --git a/crates/ziggurat-py/uv.lock b/crates/ziggurat-py/uv.lock new file mode 100644 index 0000000..1ce38ae --- /dev/null +++ b/crates/ziggurat-py/uv.lock @@ -0,0 +1,38 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "maturin" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/d0/b7c8b7778cc44df3efbc96eb23acaa995e06ea1a60eb9b02f29858fcbd08/maturin-1.14.0.tar.gz", hash = "sha256:f7f82a6aca4a6c402bf00b99200be199d4874d04b9b9e74e825726a3478bba7f", size = 367010, upload-time = "2026-06-12T00:13:30.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/51/49367dcd8f6ec139e69ef0c695c8ff5075223673382101812b4affa53216/maturin-1.14.0-py3-none-linux_armv6l.whl", hash = "sha256:019ea3ec7e71f4c9759a367d4d21022ed5a3a621a2ce123abf3fb114ab3711ca", size = 10204135, upload-time = "2026-06-12T00:13:34.308Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2a/487ce56c838d25e0ce64350e75ec4e3dc89544c0a6233221c229d6aa1a84/maturin-1.14.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6948a10f5f3470b791f79319be51debdd8bfd1778b36f2409f98e1314bc3859b", size = 19736800, upload-time = "2026-06-12T00:13:40.456Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a5/12f2efc18f419edce3282a93629cba16278bb502135dac95cd04ef7c2eae/maturin-1.14.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1506e86b1e273a98074a62e281b13f27ac96f8cdef85f7f98d3e3589a9387a23", size = 10201144, upload-time = "2026-06-12T00:13:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/3789e72273fd8bc80c33a11c787634b3251c4989d7a7203a92438836d4ff/maturin-1.14.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:df10ce4f7ba97fd3423f624f39b94c888ae3e5b470642a91918e1ccec81282fd", size = 10182394, upload-time = "2026-06-12T00:13:13.693Z" }, + { url = "https://files.pythonhosted.org/packages/40/79/15957eb4e055597f217e6310963a9c1371372e63c5b4a3e30803365addd2/maturin-1.14.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:75bcd4468a7fe597652cc2980c6bb16ce4bb8c411e3eb85dac2c4418cef0e95a", size = 10616603, upload-time = "2026-06-12T00:13:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4b/d1822f88cd5e855640f0e10ee00c39b9be614c1ef2f827e9792332d94b9f/maturin-1.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d123337e817f8dfe23755d6760139c01104137bb63e9e20c289c547e25ec857", size = 10075309, upload-time = "2026-06-12T00:13:38.274Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/c1b160d2163e8784489285e82a5c811fdcef3e0704e35b34c1cfe1828de3/maturin-1.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:107f84110d890090a01bb1ecd01761fdfae925c23c659ba492c9b83dd179eab4", size = 10024058, upload-time = "2026-06-12T00:13:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/88a9d1872997d4535af10ebe79f550e834880bf613cf8e50b50d2d938e3b/maturin-1.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:9a84277aa907961cd47ad26fef1539e79efa30611972eaf7499606e773e991b2", size = 13302073, upload-time = "2026-06-12T00:13:29.027Z" }, + { url = "https://files.pythonhosted.org/packages/4a/13/3f6d28bb7b744558b9bc78c995c1855d7e5ff21ad475f46d9de5c3dab039/maturin-1.14.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:095714b2a904927e3c868a1c5d078257ff0443c5049f7623777352966768306e", size = 10863616, upload-time = "2026-06-12T00:13:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/39352d2b402efa3a7dd01d4ed197b301ea35eec10208ba2b8c649101f4df/maturin-1.14.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:20229d332f87166b930e4ca07cdbee8a1726f2eea87a337610aa25bba3ddf4b4", size = 10399943, upload-time = "2026-06-12T00:13:36.273Z" }, + { url = "https://files.pythonhosted.org/packages/58/77/641504541336240fef3836b2d15a785eaeb33c941fb118513c267dd70840/maturin-1.14.0-py3-none-win32.whl", hash = "sha256:4ba1e3c3f33609f461d587b7549104c81a15fd6d42ba63a73cea9376a1e9876e", size = 8905117, upload-time = "2026-06-12T00:13:18.38Z" }, + { url = "https://files.pythonhosted.org/packages/02/4a/ca247a0c43069b2f48cf783c5b13c3a9eb92c8f596dc7fbdb9f75fea4414/maturin-1.14.0-py3-none-win_amd64.whl", hash = "sha256:cb09a313f097adeb4dda0082277871a28d1bd26615dbadab42e6234b6df6fe69", size = 10309099, upload-time = "2026-06-12T00:13:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a4/f14a3f6086cc3caaa90d12e832e4aa41de771c310041959f0d35dd4efe17/maturin-1.14.0-py3-none-win_arm64.whl", hash = "sha256:8c1a8188195f5b6ce1aab99ae2d92e342900298f901456b43ca028947fd3b288", size = 9719100, upload-time = "2026-06-12T00:13:24.741Z" }, +] + +[[package]] +name = "ziggurat-py" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "maturin" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "maturin", specifier = ">=1.7,<2" }] diff --git a/crates/ziggurat-server/Cargo.toml b/crates/ziggurat-server/Cargo.toml index 40253b0..fb8f1e2 100644 --- a/crates/ziggurat-server/Cargo.toml +++ b/crates/ziggurat-server/Cargo.toml @@ -11,12 +11,11 @@ repository.workspace = true [dependencies] spinel = { package = "ziggurat-spinel", path = "../spinel", version = "0.1.0" } ziggurat = { path = "../ziggurat", version = "0.0.1" } -zigbee = { package = "ziggurat-zigbee", path = "../zigbee", version = "0.1.0" } +ziggurat-api = { path = "../api", version = "0.0.1" } clap = { version = "4.5", features = ["derive"] } tracing = "0.1" futures-util = { version = "0.3", default-features = false, features = ["std", "sink"] } -hex = "0.4.3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index db3f278..e6dc236 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -1,13 +1,10 @@ use clap::{Parser, ValueEnum}; use futures_util::{SinkExt, StreamExt}; use serde::Deserialize; -use serde_json::json; -use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::sync::Arc; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::{TcpListener, UnixListener}; use tokio::sync::{broadcast, mpsc}; -use tokio::task::JoinHandle; use tokio_serial::{FlowControl, SerialPortBuilderExt}; use tokio_tungstenite::tungstenite::Message; use tracing::Instrument; @@ -15,67 +12,14 @@ use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::prelude::*; use tracing_subscriber::{EnvFilter, fmt}; -use spinel::client::{SpinelClient, TxPriority}; -use zigbee::aps::frame::ApsDeliveryMode; -use ziggurat::ieee_802154::types::{Eui64, Key, Nwk, PanId}; -use ziggurat::zigbee_stack::aps_security::TclkFlavor; -use ziggurat::zigbee_stack::{ - ApsAck, DeviceLeaveReason, NetworkBeacon, NetworkConfig, TclkSeed, Tunables, - WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, -}; - -const PROTOCOL_VERSION: u32 = 1; +use spinel::client::SpinelClient; +use ziggurat_api::{Api, error_response, event, hello, notification_to_message}; /// Outbound messages a connection can queue before it is considered too slow and /// disconnected. Received frames dominate the traffic; a client that cannot keep up /// with them is broken. const OUTBOUND_QUEUE_DEPTH: usize = 1024; -/// The server-level notification hub buffers this many notifications for slow -/// connection forwarders before they start lagging. -const NOTIFICATION_HUB_DEPTH: usize = 1024; - -/// The radio transmit power (in dBm) used when `configure` does not specify one. -const DEFAULT_TX_POWER: i8 = 8; - -/// Big-endian colon-separated hex, the format used by zigpy for EUI64 addresses -fn eui64_to_string(eui64: Eui64) -> String { - let mut bytes = eui64.to_bytes(); - bytes.reverse(); - - bytes - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(":") -} - -fn key_to_string(key: &Key) -> String { - key.to_bytes() - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(":") -} - -fn network_beacon_json(beacon: &NetworkBeacon) -> serde_json::Value { - json!({ - "channel": beacon.channel, - "source": beacon.source.map(|nwk| format!("{:04x}", nwk.0)), - "pan_id": format!("{:04x}", beacon.pan_id.0), - "extended_pan_id": eui64_to_string(beacon.extended_pan_id), - "permit_joining": beacon.permit_joining, - "stack_profile": beacon.stack_profile, - "protocol_version": beacon.protocol_version, - "router_capacity": beacon.router_capacity, - "end_device_capacity": beacon.end_device_capacity, - "device_depth": beacon.device_depth, - "update_id": beacon.update_id, - "lqi": beacon.lqi, - "rssi": beacon.rssi, - }) -} - // The client wire protocol: requests carry a client-chosen correlation id; the // server answers each request with exactly one `response`, preceded by zero or more // `event` messages sharing the id. `notification` messages are unsolicited. @@ -88,234 +32,29 @@ struct Request { params: serde_json::Value, } -fn event(id: u64, event: &str) -> serde_json::Value { - json!({"type": "event", "id": id, "event": event}) -} - -fn event_data(id: u64, event: &str, data: serde_json::Value) -> serde_json::Value { - json!({"type": "event", "id": id, "event": event, "data": data}) -} - -fn response(id: u64, result: serde_json::Value) -> serde_json::Value { - json!({"type": "response", "id": id, "result": result}) -} - -fn error_response(id: u64, code: &str, message: impl ToString) -> serde_json::Value { - json!({ - "type": "response", "id": id, - "error": {"code": code, "message": message.to_string()}, - }) -} - -fn notification(event: &str, data: serde_json::Value) -> serde_json::Value { - json!({"type": "notification", "event": event, "data": data}) -} - -// Each `params` payload deserializes into the struct matching its `method`. - -#[derive(Deserialize, Debug)] -struct KeyTableEntry { - partner_ieee: Eui64, - key: Key, -} - -#[derive(Deserialize, Debug)] -struct ConfigureRequest { - channel: u8, - nwk_update_id: u8, - pan_id: PanId, - extended_pan_id: Eui64, - nwk_address: Nwk, - ieee_address: Eui64, - network_key: Key, - network_key_seq: u8, - network_key_tx_counter: u32, - tc_link_key: Option, - /// A TCLK seed carried over from a microcontroller stack; unique link keys are - /// derived from it instead of generated randomly. Requires `tclk_flavor`. - tclk_seed: Option, - tclk_flavor: Option, - #[serde(default)] - key_table: Vec, - #[serde(default)] - source_routing: bool, - /// Radio transmit power in dBm - tx_power: Option, -} - -#[derive(Deserialize, Debug)] -struct SendApsRequest { - delivery_mode: ApsDeliveryMode, - /// Resolved through the address map; takes precedence over `destination` - destination_eui64: Option, - destination: Option, - profile_id: u16, - cluster_id: u16, - src_ep: u8, - dst_ep: u8, - aps_ack: bool, - aps_seq: u8, - radius: u8, - /// Hex-encoded ASDU - data: String, - /// APS-encrypt the ASDU with the destination's link key; requires a unicast - /// `destination_eui64` - #[serde(default)] - aps_encryption: bool, - #[serde(default)] - priority: i8, -} - -#[derive(Deserialize, Debug)] -struct EnergyScanRequest { - channels: Vec, - duration_per_channel_ms: u16, -} - -#[derive(Deserialize, Debug)] -struct NetworkScanRequest { - channels: Vec, - duration_per_channel_ms: u16, -} - -#[derive(Deserialize, Debug)] -struct PermitJoinsRequest { - #[serde(default)] - duration: u64, - #[serde(default = "default_accept_direct_joins")] - accept_direct_joins: bool, -} - -const fn default_accept_direct_joins() -> bool { - true -} - -#[derive(Deserialize, Debug)] -struct SetProvisionalKeyRequest { - ieee: Eui64, - key: Key, -} - -#[derive(Deserialize, Debug)] -struct SetChannelRequest { - channel: u8, -} - -#[derive(Deserialize, Debug)] -struct SetNwkUpdateIdRequest { - nwk_update_id: u8, -} - -fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json::Value { - match notification_event { - ZigbeeNotification::ReceivedApsCommand { - source, - destination, - group, - profile_id, - cluster_id, - src_ep, - dst_ep, - lqi, - rssi, - data, - } => notification( - "received_aps_command", - json!({ - "source": hex::encode(source.to_bytes()), - "destination": hex::encode(destination.to_bytes()), - "group": group, - "profile_id": profile_id, - "cluster_id": cluster_id, "src_ep": src_ep, "dst_ep": dst_ep, - "lqi": lqi, "rssi": rssi, "data": hex::encode(data), - }), - ), - ZigbeeNotification::FrameCounterUpdate { frame_counter } => notification( - "frame_counter_update", - json!({"frame_counter": frame_counter}), - ), - ZigbeeNotification::LinkKeyUpdate { ieee, key } => notification( - "link_key_update", - json!({ - "ieee": eui64_to_string(ieee), - "key": key_to_string(&key), - }), - ), - ZigbeeNotification::DeviceJoined { nwk, ieee, parent } => notification( - "device_joined", - json!({ - "nwk": hex::encode(nwk.to_bytes()), - "ieee": eui64_to_string(ieee), - "parent": hex::encode(parent.to_bytes()), - }), - ), - ZigbeeNotification::DeviceLeft { nwk, ieee, reason } => { - let mut params = json!({ - "nwk": hex::encode(nwk.to_bytes()), - "ieee": ieee.map(eui64_to_string), - }); - match reason { - DeviceLeaveReason::Announced { rejoin } => { - params["reason"] = json!("announced"); - params["rejoin"] = json!(rejoin); - } - DeviceLeaveReason::RouterReported { - router, - router_ieee, - } => { - params["reason"] = json!("router_reported"); - params["router"] = json!(hex::encode(router.to_bytes())); - params["router_ieee"] = json!(router_ieee.map(eui64_to_string)); - } - DeviceLeaveReason::KeepaliveTimeout => { - params["reason"] = json!("keepalive_timeout"); - } - } - notification("device_left", params) - } - ZigbeeNotification::ApsDecryptionFailure { - source, - source_ieee, - frame_counter, - key_id, - } => notification( - "aps_decryption_failure", - json!({ - "source": hex::encode(source.to_bytes()), - "source_ieee": eui64_to_string(source_ieee), - "frame_counter": frame_counter, - "key_id": key_id, - }), - ), - } -} - pub struct ZigguratServer { - serial: SerialConfig, - /// The Spinel client owns the serial port for the lifetime of the process: it is - /// opened lazily by the first command that needs it and never reopened, so stack - /// replacement cannot race a straggling port handle (`EBUSY`) - spinel: Mutex>>, - stack: Mutex>>, - /// The server-level notification hub: connections subscribe to it, and it - /// survives stack replacement (the forwarder task is swapped instead) - notification_tx: broadcast::Sender, - notification_forwarder: Mutex>>, + api: Arc, } impl ZigguratServer { /// The serial port is not opened and the Zigbee stack is not created until a /// client sends a command that needs them. pub fn new(serial: SerialConfig) -> Self { - let (notification_tx, _) = broadcast::channel(NOTIFICATION_HUB_DEPTH); - - Self { - serial, - spinel: Mutex::new(None), - stack: Mutex::new(None), - notification_tx, - notification_forwarder: Mutex::new(None), - } + // The factory opens the serial port on first use, building the process-lifetime + // Spinel client. Without flow control the RCP's UART drops bytes under load, + // corrupting host->RCP frames ("Framing error" + command timeout). + let api = Api::new(Box::new(move || { + let port = tokio_serial::new(&serial.device, serial.baudrate) + .flow_control(serial.flow_control.into()) + .open_native_async() + .map_err(|e| e.to_string())?; + + let client = Arc::new(SpinelClient::new(port)); + client.spawn_reader(); + Ok(client) + })); + + Self { api } } pub async fn run(self: Arc, listen_addr: &str) -> std::io::Result<()> { @@ -369,32 +108,6 @@ impl ZigguratServer { }); } - fn current_stack(&self) -> Option> { - self.stack.lock().unwrap().clone() - } - - /// The process-lifetime Spinel client, opening the serial port on first use. - fn spinel_client(&self) -> Result, tokio_serial::Error> { - let mut spinel = self.spinel.lock().unwrap(); - - if let Some(spinel) = &*spinel { - return Ok(spinel.clone()); - } - - // Without flow control the RCP's UART drops bytes under load, corrupting - // host->RCP frames ("Framing error" + command timeout) - let port = tokio_serial::new(&self.serial.device, self.serial.baudrate) - .flow_control(self.serial.flow_control.into()) - .open_native_async()?; - - let client = Arc::new(SpinelClient::new(port)); - client.spawn_reader(); - *spinel = Some(client.clone()); - drop(spinel); - - Ok(client) - } - async fn handle_connection( self: &Arc, socket: S, @@ -423,17 +136,10 @@ impl ZigguratServer { let _ = sink.close().await; }); - let state = if self.current_stack().is_some() { - "running" - } else { - "awaiting_configuration" - }; - outbound_tx - .send(json!({"type": "hello", "version": PROTOCOL_VERSION, "state": state})) - .await?; + outbound_tx.send(hello(self.api.is_configured())).await?; // Forward hub notifications to this connection - let mut notification_rx = self.notification_tx.subscribe(); + let mut notification_rx = self.api.subscribe(); let notification_outbound = outbound_tx.clone(); let forwarder_addr = addr.to_owned(); let notification_forwarder = tokio::spawn(async move { @@ -491,7 +197,7 @@ impl ZigguratServer { /// Dispatches a request, spawning everything that can block on network activity: /// a command waiting on a slow device must never delay other commands. fn dispatch(self: &Arc, request: Request, outbound: mpsc::Sender) { - let server = self.clone(); + let api = self.api.clone(); // One span per request so the handler work nests under it and the close line // reports the full request-to-response latency. @@ -501,448 +207,13 @@ impl ZigguratServer { async move { let Request { id, method, params } = request; - let message = match method.as_str() { - "ping" => server.handle_ping(id).await, - "configure" => server.handle_configure(id, params).await, - "get_hw_address" => server.handle_get_hw_address(id).await, - "get_network_info" => server.handle_get_network_info(id), - "send_aps" => server.handle_send_aps(id, params, &outbound).await, - "energy_scan" => server.handle_energy_scan(id, params, &outbound).await, - "network_scan" => server.handle_network_scan(id, params, &outbound).await, - "permit_joins" => server.handle_permit_joins(id, params), - "set_provisional_key" => server.handle_set_provisional_key(id, params), - "set_nwk_update_id" => server.handle_set_nwk_update_id(id, params), - "set_channel" => server.handle_set_channel(id, params).await, - _ => error_response(id, "unknown_method", method), - }; + let message = api.dispatch(id, &method, params, &outbound).await; let _ = outbound.send(message).await; } .instrument(span), ); } - - /// Liveness probe. Yielding makes the reply round-trip through the runtime like - /// every real command, so a starved executor shows up in the latency. - async fn handle_ping(&self, id: u64) -> serde_json::Value { - tokio::task::yield_now().await; - - response(id, json!({"status": "pong"})) - } - - /// (Re)initializes the Zigbee stack. The stack deliberately outlives client - /// connections; reconfiguring replaces it wholesale. - #[allow(clippy::significant_drop_tightening)] - async fn handle_configure(&self, id: u64, params: serde_json::Value) -> serde_json::Value { - let request: ConfigureRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), - }; - - let tclk_seed = match (request.tclk_seed, request.tclk_flavor) { - (Some(seed), Some(flavor)) => Some(TclkSeed { seed, flavor }), - (None, None) => None, - _ => { - return error_response( - id, - "invalid_request", - "tclk_seed and tclk_flavor must be provided together", - ); - } - }; - - // A replaced stack must be fully stopped before its successor registers its - // own receivers with the shared Spinel client - let old_stack = self.stack.lock().unwrap().take(); - if let Some(old_stack) = old_stack { - tracing::info!("Replacing the running Zigbee stack"); - old_stack.shutdown().await; - } - - let old_forwarder = self.notification_forwarder.lock().unwrap().take(); - if let Some(old_forwarder) = old_forwarder { - old_forwarder.abort(); - } - - tracing::info!("Initializing Zigbee stack with new settings..."); - let spinel = match self.spinel_client() { - Ok(s) => s, - Err(e) => return error_response(id, "serial_port_error", e), - }; - - let (stack, mut stack_notification_rx) = ZigbeeStack::new( - spinel, - NetworkConfig { - channel: request.channel, - update_id: request.nwk_update_id, - pan_id: request.pan_id, - extended_pan_id: request.extended_pan_id, - network_address: request.nwk_address, - ieee_address: request.ieee_address, - network_key: request.network_key, - network_key_seq_number: request.network_key_seq, - network_key_tx_counter: request.network_key_tx_counter, - tc_link_key: request.tc_link_key.unwrap_or(WELL_KNOWN_LINK_KEY), - tclk_seed, - tx_power: request.tx_power.unwrap_or(DEFAULT_TX_POWER), - source_routing: request.source_routing, - }, - Tunables::new(), - ); - - // Restore unique trust center link keys negotiated in earlier sessions - if !request.key_table.is_empty() { - let mut core = stack.state.core.lock(); - - for entry in request.key_table { - core.aib - .aps_security - .restore_device_key(entry.partner_ieee, entry.key); - } - - tracing::info!( - "Restored {} trust center link keys", - core.aib.aps_security.device_key_count() - ); - } - - // The success response is the client's permission to send commands: the - // network must be fully up (RCP reset handled, radio programmed) before - // replying, or the client's first command would race with the boot-time reset. - if let Err(e) = stack.start_network().await { - stack.shutdown().await; - return error_response(id, "network_start_failed", e); - } - - let stack_clone = stack.clone(); - stack.spawn_tracked(async move { - stack_clone.run().await; - }); - - // Pump the stack's notifications into the server-level hub - let hub_tx = self.notification_tx.clone(); - let forwarder = tokio::spawn(async move { - while let Ok(event) = stack_notification_rx.recv().await { - // Send errors just mean no client is connected right now - let _ = hub_tx.send(event); - } - }); - - *self.stack.lock().unwrap() = Some(stack); - *self.notification_forwarder.lock().unwrap() = Some(forwarder); - - tracing::info!("Zigbee stack initialized and running."); - response(id, json!({"status": "success"})) - } - - /// Updates the `nwkUpdateId` advertised in beacons, the companion to - /// `set_channel` during a network-wide channel migration. - fn handle_set_nwk_update_id(&self, id: u64, params: serde_json::Value) -> serde_json::Value { - let request: SetNwkUpdateIdRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), - }; - - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); - }; - - stack.set_nwk_update_id(request.nwk_update_id); - response(id, json!({"status": "success"})) - } - - /// Retunes the radio to a new channel, the coordinator's half of a network-wide - /// channel migration; broadcasting `Mgmt_NWK_Update_req` to the other devices is - /// the client's job. - async fn handle_set_channel(&self, id: u64, params: serde_json::Value) -> serde_json::Value { - let request: SetChannelRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), - }; - - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); - }; - - match stack.set_channel(request.channel).await { - Ok(()) => response(id, json!({"status": "success"})), - Err(e) => error_response(id, "set_channel_failed", e), - } - } - - /// Reads back the running network's settings, the counterpart of `configure`. - /// While the stack runs, the server is the authoritative holder of the live state - /// (e.g. frame counters), not the client that configured it. - #[allow(clippy::significant_drop_tightening)] - fn handle_get_network_info(&self, id: u64) -> serde_json::Value { - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); - }; - - let state = &stack.state; - let core = state.core.lock(); - let nwk_security = &core.nib.nwk_security; - let aps_security = &core.aib.aps_security; - let tclk_seed = &stack.config.tclk_seed; - - response( - id, - json!({ - "channel": core.mac.channel, - "nwk_update_id": core.nib.update_id, - "pan_id": format!("{:04x}", core.mac.pan_id.0), - "extended_pan_id": eui64_to_string(state.extended_pan_id), - "nwk_address": format!("{:04x}", state.network_address.as_u16()), - "ieee_address": eui64_to_string(state.ieee_address), - "network_key": key_to_string(&nwk_security.network_key()), - "network_key_seq": nwk_security.key_seq_number(), - "network_key_tx_counter": nwk_security.outgoing_frame_counter(), - "tc_link_key": key_to_string(&stack.config.tc_link_key), - "tx_power": stack.config.tx_power, - "tclk_seed": tclk_seed.as_ref().map(|tclk| hex::encode(tclk.seed.to_bytes())), - "tclk_flavor": tclk_seed.as_ref().map(|tclk| match tclk.flavor { - TclkFlavor::ZStack => "zstack", - TclkFlavor::Ezsp => "ezsp", - }), - "key_table": aps_security - .device_keys() - .map(|(partner_ieee, entry)| json!({ - "partner_ieee": eui64_to_string(partner_ieee), - "key": key_to_string(&entry.key), - })) - .collect::>(), - }), - ) - } - - /// Reads the radio's factory-programmed EUI64, which a client needs before it can - /// form a network with `configure`. - async fn handle_get_hw_address(&self, id: u64) -> serde_json::Value { - let spinel = match self.spinel_client() { - Ok(s) => s, - Err(e) => return error_response(id, "serial_port_error", e), - }; - - match spinel.get_hw_address().await { - Ok(ieee) => response(id, json!({"ieee_address": eui64_to_string(ieee)})), - Err(e) => error_response(id, "hw_address_failed", e), - } - } - - async fn handle_send_aps( - &self, - id: u64, - params: serde_json::Value, - outbound: &mpsc::Sender, - ) -> serde_json::Value { - let request: SendApsRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), - }; - - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); - }; - - // A network address is authoritative when given (`destination_eui64` then only - // selects the link key); EUI64-only packets are resolved through the address map - let destination = match (request.destination_eui64, request.destination) { - (_, Some(nwk)) => nwk, - (Some(eui64), None) => { - let nwk = stack.state.core.lock().nib.address_map.nwk_for(eui64); - - match nwk { - Some(nwk) => nwk, - None => { - return error_response( - id, - "unknown_destination_eui64", - format!("{eui64:?}"), - ); - } - } - } - (None, None) => { - return error_response(id, "missing_destination", "no destination given"); - } - }; - - let asdu = match hex::decode(&request.data) { - Ok(asdu) => asdu, - Err(e) => return error_response(id, "invalid_data", e), - }; - - // Link keys are pairwise: encryption needs a unicast EUI64-addressed target - let aps_security = if request.aps_encryption { - match (request.destination_eui64, request.delivery_mode) { - (Some(eui64), ApsDeliveryMode::Unicast) => Some(eui64), - _ => { - return error_response( - id, - "invalid_request", - "aps_encryption requires a unicast destination_eui64", - ); - } - } - } else { - None - }; - - let ack_waiter = match stack - .send_aps_command( - request.delivery_mode, - destination, - request.profile_id, - request.cluster_id, - request.src_ep, - request.dst_ep, - if request.aps_ack { - ApsAck::Request - } else { - ApsAck::None - }, - request.radius, - request.aps_seq, - asdu, - aps_security, - TxPriority(request.priority), - ) - .await - { - Ok(ack_waiter) => ack_waiter, - Err(e) => return error_response(id, "transmit_failed", e), - }; - - // The frame is on the air (or extracted from the indirect queue); the - // terminal response then reports end-to-end delivery when an ack was requested - let _ = outbound.send(event(id, "transmitted")).await; - - match ack_waiter { - None => response(id, json!({"status": "sent"})), - Some(waiter) => match stack.wait_aps_ack(waiter).await { - Ok(()) => response(id, json!({"status": "delivered"})), - Err(e) => error_response(id, "aps_ack_timeout", e), - }, - } - } - - async fn handle_energy_scan( - &self, - id: u64, - params: serde_json::Value, - outbound: &mpsc::Sender, - ) -> serde_json::Value { - let request: EnergyScanRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), - }; - - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); - }; - - let (result_tx, mut result_rx) = mpsc::channel::<(u8, i8)>(32); - - // The scan runs on its own task so it always reaches its channel restore, even if - // this request's task is dropped. Its only sender lives until the scan ends, so - // the drain loop below terminates exactly when the scan is done. - let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); - let scan = tokio::spawn(async move { - stack - .energy_scan(&request.channels, duration, result_tx) - .await - }); - - while let Some((channel, rssi)) = result_rx.recv().await { - let _ = outbound - .send(event_data( - id, - "energy_result", - json!({"channel": channel, "rssi": rssi}), - )) - .await; - } - - match scan.await { - Ok(Ok(())) => response(id, json!({"status": "complete"})), - Ok(Err(e)) => error_response(id, "energy_scan_failed", e), - Err(e) => error_response(id, "energy_scan_failed", e), - } - } - - async fn handle_network_scan( - &self, - id: u64, - params: serde_json::Value, - outbound: &mpsc::Sender, - ) -> serde_json::Value { - let request: NetworkScanRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), - }; - - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); - }; - - let (found_tx, mut found_rx) = mpsc::channel::(32); - - // The scan runs on its own task so it always reaches its channel restore, even if - // this request's task is dropped. Its only sender lives until the scan ends, so - // the drain loop below terminates exactly when the scan is done. - let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); - let scan = tokio::spawn(async move { - stack - .network_scan(&request.channels, duration, found_tx) - .await - }); - - while let Some(beacon) = found_rx.recv().await { - let _ = outbound - .send(event_data( - id, - "network_found", - network_beacon_json(&beacon), - )) - .await; - } - - match scan.await { - Ok(Ok(())) => response(id, json!({"status": "complete"})), - Ok(Err(e)) => error_response(id, "network_scan_failed", e), - Err(e) => error_response(id, "network_scan_failed", e), - } - } - - fn handle_permit_joins(&self, id: u64, params: serde_json::Value) -> serde_json::Value { - let request: PermitJoinsRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), - }; - - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); - }; - - stack.permit_joins(request.duration, request.accept_direct_joins); - - response(id, json!({"status": "success"})) - } - - fn handle_set_provisional_key(&self, id: u64, params: serde_json::Value) -> serde_json::Value { - let request: SetProvisionalKeyRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), - }; - - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); - }; - - stack.set_provisional_key(request.ieee, request.key); - - response(id, json!({"status": "success"})) - } } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] @@ -962,7 +233,7 @@ impl From for FlowControl { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SerialConfig { device: String, baudrate: u32,