commit 1bbd7c3573163ded450173a667af1cc9168d54d3 Author: Pat Nakajima Date: Sun May 17 18:44:11 2026 -0700 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf6578b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.vscode +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3ec84e1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "toes-matter" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +publish = false + +[features] +default = ["zeroconf"] +zeroconf = ["rs-matter/zeroconf"] + +[dependencies] +async-io = "2" +embassy-futures = "0.1" +embassy-time = { version = "0.5", features = ["std"] } +embassy-time-queue-utils = { version = "0.3", features = ["generic-queue-64"] } +env_logger = "0.11" +futures-lite = "2" +log = "0.4" +rand = { version = "0.8", features = ["std", "std_rng"] } +rs-matter = { path = "../../rs-matter/rs-matter", default-features = false, features = [ + "log", + "os", + "rustcrypto", + "zbus", + "max-sessions-32", + "max-groups-per-fabric-12", + "max-group-keys-per-fabric-2", + "max-group-endpoints-per-fabric-3", +] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..4335f6d --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# toes-matter + +Tiny facade around `rs-matter` for the Toes RGB light flow: + +```rust +#![recursion_limit = "256"] + +fn main() -> toes_matter::Result<()> { + env_logger::init_from_env( + env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"), + ); + + futures_lite::future::block_on(async { + toes_matter::generate_credentials("./creds").await?; + toes_matter::provision().await?; + toes_matter::listen().await + }) +} +``` + +## Runtime assumptions + +- Linux + BlueZ on system D-Bus +- `wpa_supplicant` controlling the Wi-Fi interface +- `wpa_cli`, `ip`, and a DHCP client available +- `rgbled` in `$PATH` +- Avahi/zeroconf for mDNS + +## Environment + +- `TOES_MATTER_CREDS_DIR` / `MATTER_CREDS_DIR` default: `./creds` +- `TOES_MATTER_STATE_DIR` / `MATTER_KV_DIR` default: `/state` +- `TOES_MATTER_WIFI_IFACE` / `MATTER_WIFI_IFACE` default: `wlan0` +- `RGBLED_ORDER` default: `grb` + +## Development credentials + +`generate_credentials()` writes rs-matter's built-in development/test DAC/PAI/CD and the test PAA trust-store cert. These are for local development, not production. diff --git a/credentials/Chip-Test-PAA-FFF1-Cert.der b/credentials/Chip-Test-PAA-FFF1-Cert.der new file mode 100644 index 0000000..cb287bf Binary files /dev/null and b/credentials/Chip-Test-PAA-FFF1-Cert.der differ diff --git a/examples/basic.rs b/examples/basic.rs new file mode 100644 index 0000000..bc6ddbd --- /dev/null +++ b/examples/basic.rs @@ -0,0 +1,13 @@ +#![recursion_limit = "256"] + +fn main() -> toes_matter::Result<()> { + env_logger::init_from_env( + env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"), + ); + + futures_lite::future::block_on(async { + toes_matter::generate_credentials("./creds").await?; + toes_matter::provision().await?; + toes_matter::listen().await + }) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9a246b0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,1140 @@ +#![recursion_limit = "256"] +#![allow(async_fn_in_trait)] + +use core::cell::RefCell; +use core::pin::pin; + +use std::net::UdpSocket; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use embassy_futures::select::{select, select4}; +use log::{info, warn}; +use rand::RngCore; + +use rs_matter::crypto::{default_crypto, Crypto}; +use rs_matter::dm::clusters::app::level_control::LevelControlHooks; +use rs_matter::dm::clusters::app::on_off::OnOffHooks; +use rs_matter::dm::clusters::app::{level_control, on_off}; +use rs_matter::dm::clusters::decl::color_control; +use rs_matter::dm::clusters::decl::color_control::ClusterAsyncHandler as ColorControlHandler; +use rs_matter::dm::clusters::desc::{ClusterHandler as _, DescHandler}; +use rs_matter::dm::clusters::dev_att::DeviceAttestation; +use rs_matter::dm::clusters::net_comm::{ + NetCtl, NetCtlError, NetworkScanInfo, NetworkType, SharedNetworks, ThreadCapabilitiesBitmap, + WiFiBandEnum, WirelessCreds, +}; +use rs_matter::dm::clusters::wifi_diag::{ + SecurityTypeEnum, WiFiVersionEnum, WifiDiag, WirelessDiag, +}; +use rs_matter::dm::devices::test::{DAC_PRIVKEY, TEST_DEV_ATT, TEST_DEV_COMM, TEST_DEV_DET}; +use rs_matter::dm::endpoints; +use rs_matter::dm::events::Events; +use rs_matter::dm::networks::unix::UnixNetifs; +use rs_matter::dm::networks::wireless::{NetCtlState, NetCtlWithStatusImpl, WifiNetworks}; +use rs_matter::dm::networks::NetChangeNotif; +use rs_matter::dm::subscriptions::Subscriptions; +use rs_matter::dm::{ + Async, Cluster, DataModel, Dataver, EmptyHandler, Endpoint, EpClMatcher, Node, +}; +use rs_matter::error::{Error, ErrorCode}; +use rs_matter::pairing::qr::{no_optional_data, CommFlowType, QrPayload, QrTextType}; +use rs_matter::pairing::DiscoveryCapabilities; +use rs_matter::persist::{DirKvBlobStore, SharedKvBlobStore}; +use rs_matter::respond::DefaultResponder; +use rs_matter::sc::pase::MAX_COMM_WINDOW_TIMEOUT_SECS; +use rs_matter::tlv::Nullable; +use rs_matter::transport::network::btp::bluez; +use rs_matter::transport::network::btp::{AdvData, Btp}; +use rs_matter::transport::network::mdns::zeroconf::ZeroconfMdnsResponder; +use rs_matter::transport::network::wifi::wpa_supp::unix::DhClientCtl; +use rs_matter::transport::network::wifi::wpa_supp::WpaSuppCtl; +use rs_matter::transport::network::NoNetwork; +use rs_matter::transport::MATTER_SOCKET_BIND_ADDR; +use rs_matter::utils::select::Coalesce; +use rs_matter::utils::storage::pooled::PooledBuffers; +use rs_matter::utils::sync::blocking::Mutex; +use rs_matter::utils::sync::DynBase; +use rs_matter::utils::zbus::Connection; +use rs_matter::{clusters, devices, root_endpoint, with, Matter, MATTER_PORT}; + +pub type Result = core::result::Result; + +const DEFAULT_CREDS_DIR: &str = "./creds"; +const DEFAULT_WIFI_IFACE: &str = "wlan0"; +const LIGHT_ENDPOINT_ID: u16 = 1; +const KV_BUF_SIZE: usize = 4096; + +const DEV_TYPE_EXTENDED_COLOR_LIGHT: rs_matter::dm::DeviceType = rs_matter::dm::DeviceType { + dtype: 0x010d, + drev: 3, +}; + +const NODE: Node = Node { + endpoints: &[ + root_endpoint!(wifi), + Endpoint { + id: LIGHT_ENDPOINT_ID, + device_types: devices!(DEV_TYPE_EXTENDED_COLOR_LIGHT), + clusters: clusters!( + DescHandler::CLUSTER, + RgbLight::ON_OFF_CLUSTER, + RgbLight::LEVEL_CLUSTER, + RgbLight::COLOR_CLUSTER + ), + }, + ], +}; + +/// Runtime configuration used by [`provision_with_config`] and [`listen_with_config`]. +#[derive(Clone, Debug)] +pub struct Config { + pub creds_dir: PathBuf, + pub state_dir: PathBuf, + pub wifi_iface: String, + pub rgbled_order: String, +} + +impl Config { + pub fn from_env() -> Self { + let creds_dir = std::env::var_os("TOES_MATTER_CREDS_DIR") + .or_else(|| std::env::var_os("MATTER_CREDS_DIR")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_CREDS_DIR)); + + let state_dir = std::env::var_os("TOES_MATTER_STATE_DIR") + .or_else(|| std::env::var_os("MATTER_KV_DIR")) + .map(PathBuf::from) + .unwrap_or_else(|| creds_dir.join("state")); + + let wifi_iface = std::env::var("TOES_MATTER_WIFI_IFACE") + .or_else(|_| std::env::var("MATTER_WIFI_IFACE")) + .unwrap_or_else(|_| DEFAULT_WIFI_IFACE.into()); + + let rgbled_order = std::env::var("RGBLED_ORDER").unwrap_or_else(|_| "grb".into()); + + Self { + creds_dir, + state_dir, + wifi_iface, + rgbled_order, + } + } +} + +/// Write the development Matter credentials and setup metadata used by this crate. +/// +/// This intentionally uses `rs-matter`'s upstream development/test attestation +/// credentials. These are good for chip-tool and local development, not production. +pub async fn generate_credentials(path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + let paa_dir = path.join("paa"); + + std::fs::create_dir_all(&paa_dir)?; + + std::fs::write(path.join("dac.der"), TEST_DEV_ATT.dac())?; + std::fs::write(path.join("pai.der"), TEST_DEV_ATT.pai())?; + std::fs::write( + path.join("certification-declaration.der"), + TEST_DEV_ATT.cert_declaration(), + )?; + std::fs::write( + path.join("dac-private-key.raw"), + TEST_DEV_ATT.dac_priv_key().access(), + )?; + std::fs::write( + paa_dir.join("Chip-Test-PAA-FFF1-Cert.der"), + include_bytes!("../credentials/Chip-Test-PAA-FFF1-Cert.der"), + )?; + + let payload = QrPayload::new_from_basic_info( + DiscoveryCapabilities::BLE, + CommFlowType::Standard, + TEST_DEV_COMM, + &TEST_DEV_DET, + no_optional_data, + ); + + let mut qr_buf = [0_u8; 512]; + let (qr_text, _) = payload.as_str(&mut qr_buf)?; + + let setup = format!( + "setup_passcode=20202021\n\ + discriminator=3840\n\ + manual_code={}\n\ + qr_code={}\n\ + chip_tool_paa_trust_store={}\n", + TEST_DEV_COMM.compute_pretty_pairing_code(), + qr_text, + paa_dir.display(), + ); + + std::fs::write(path.join("setup.txt"), setup)?; + + Ok(()) +} + +/// Provision this device over BLE Matter commissioning and Wi-Fi Network Commissioning. +/// +/// Returns immediately if a fabric is already stored in the configured state dir. +/// Otherwise advertises over BlueZ, receives Wi-Fi credentials, connects the Wi-Fi +/// interface via wpa_supplicant, persists Matter state, then returns. +pub async fn provision() -> Result<()> { + provision_with_config(Config::from_env()).await +} + +pub async fn provision_with_config(config: Config) -> Result<()> { + std::fs::create_dir_all(&config.state_dir)?; + + let connection = Connection::system().await?; + let net_ctl = PersistingWifiCtl::new( + WpaSuppCtl::new( + &connection, + &config.wifi_iface, + DhClientCtl::new(&config.wifi_iface, true), + ), + &config.wifi_iface, + ); + + run_provision(&connection, net_ctl, &config).await +} + +/// Listen forever for Matter commands on the provisioned Wi-Fi/IP network. +/// +/// RGB commands are applied by invoking `rgbled --order `. +pub async fn listen() -> Result<()> { + listen_with_config(Config::from_env()).await +} + +pub async fn listen_with_config(config: Config) -> Result<()> { + std::fs::create_dir_all(&config.state_dir)?; + + let connection = Connection::system().await?; + let net_ctl = PersistingWifiCtl::new( + WpaSuppCtl::new( + &connection, + &config.wifi_iface, + DhClientCtl::new(&config.wifi_iface, true), + ), + &config.wifi_iface, + ); + + run_listen(net_ctl, &config).await +} + +async fn run_provision(connection: &Connection, net_ctl: N, config: &Config) -> Result<()> +where + N: NetCtl + WifiDiag + NetChangeNotif, +{ + let mut matter = Matter::new_default(&TEST_DEV_DET, TEST_DEV_COMM, &TEST_DEV_ATT, MATTER_PORT); + let mut networks = WifiNetworks::<3>::new(); + let mut events: Events = Events::new_default(); + let mut kv_buf = [0_u8; KV_BUF_SIZE]; + let mut kv = DirKvBlobStore::new(config.state_dir.clone()); + + matter.load_persist(&mut kv, &mut kv_buf).await?; + networks.load_persist(&mut kv, &mut kv_buf).await?; + events.load_persist(&mut kv, &mut kv_buf).await?; + + if matter.is_commissioned() { + info!("Matter fabric already exists; skipping BLE provisioning"); + return Ok(()); + } + + let networks = SharedNetworks::new(networks); + let buffers = PooledBuffers::<10, _>::new(0); + let subscriptions: Subscriptions = Subscriptions::new(); + let crypto = default_crypto(rand::thread_rng(), DAC_PRIVKEY); + let mut rand = crypto.rand()?; + + let rgb_light = RgbLight::new(Dataver::new_rand(&mut rand), config.rgbled_order.clone()); + let on_off = + on_off::OnOffHandler::new(Dataver::new_rand(&mut rand), LIGHT_ENDPOINT_ID, &rgb_light); + let level = level_control::LevelControlHandler::new( + Dataver::new_rand(&mut rand), + LIGHT_ENDPOINT_ID, + &rgb_light, + level_control::AttributeDefaults::default(), + ); + + on_off.init(Some(&level)); + level.init(Some(&on_off)); + + let net_ctl_state = NetCtlState::new_with_mutex(); + let net_ctl = NetCtlWithStatusImpl::new(&net_ctl_state, net_ctl); + + let handler = rgb_handler(&mut rand, &rgb_light, &on_off, &level); + let dm = DataModel::new( + &matter, + &crypto, + &buffers, + &subscriptions, + &events, + ( + NODE, + endpoints::with_wifi_sys(&true, &(), &UnixNetifs, &net_ctl, &net_ctl, rand, handler), + ), + SharedKvBlobStore::new(kv, kv_buf.as_mut_slice()), + &networks, + ); + + let responder = DefaultResponder::new(&dm); + let mut respond = pin!(responder.run::<4, 4>()); + let mut dm_job = pin!(dm.run()); + + matter.print_standard_qr_text(DiscoveryCapabilities::BLE)?; + matter.print_standard_qr_code(QrTextType::Unicode, DiscoveryCapabilities::BLE)?; + dm.open_basic_comm_window(MAX_COMM_WINDOW_TIMEOUT_SECS)?; + + let btp = Btp::new(); + btp.set_relaxed_mtu_nego(true); + + let adv_data = AdvData::new(&TEST_DEV_DET, TEST_DEV_COMM.discriminator); + let mut bluetooth = pin!(bluez::run_peripheral( + connection, None, "TOES", &adv_data, &btp + )); + let mut transport = pin!(matter.run(&crypto, &btp, &btp, NoNetwork)); + let mut wifi_prov_task = pin!(async { + NetCtlState::wait_prov_ready(&net_ctl_state, &btp).await; + Ok(()) + }); + + select4( + &mut transport, + &mut bluetooth, + &mut wifi_prov_task, + select(&mut respond, &mut dm_job).coalesce(), + ) + .coalesce() + .await?; + + matter.reset_transport()?; + + info!("Wi-Fi provisioning completed; call toes_matter::listen().await next"); + + Ok(()) +} + +async fn run_listen(net_ctl: N, config: &Config) -> Result<()> +where + N: NetCtl + WifiDiag + NetChangeNotif, +{ + let mut matter = Matter::new_default(&TEST_DEV_DET, TEST_DEV_COMM, &TEST_DEV_ATT, MATTER_PORT); + let mut networks = WifiNetworks::<3>::new(); + let mut events: Events = Events::new_default(); + let mut kv_buf = [0_u8; KV_BUF_SIZE]; + let mut kv = DirKvBlobStore::new(config.state_dir.clone()); + + matter.load_persist(&mut kv, &mut kv_buf).await?; + networks.load_persist(&mut kv, &mut kv_buf).await?; + events.load_persist(&mut kv, &mut kv_buf).await?; + + if !matter.is_commissioned() { + warn!("Matter state is not commissioned; run toes_matter::provision().await first"); + return Err(ErrorCode::InvalidState.into()); + } + + let networks = SharedNetworks::new(networks); + let buffers = PooledBuffers::<10, _>::new(0); + let subscriptions: Subscriptions = Subscriptions::new(); + let crypto = default_crypto(rand::thread_rng(), DAC_PRIVKEY); + let mut rand = crypto.rand()?; + + let rgb_light = RgbLight::new(Dataver::new_rand(&mut rand), config.rgbled_order.clone()); + let on_off = + on_off::OnOffHandler::new(Dataver::new_rand(&mut rand), LIGHT_ENDPOINT_ID, &rgb_light); + let level = level_control::LevelControlHandler::new( + Dataver::new_rand(&mut rand), + LIGHT_ENDPOINT_ID, + &rgb_light, + level_control::AttributeDefaults::default(), + ); + + on_off.init(Some(&level)); + level.init(Some(&on_off)); + + let net_ctl_state = NetCtlState::new_with_mutex(); + let net_ctl = NetCtlWithStatusImpl::new(&net_ctl_state, net_ctl); + + let handler = rgb_handler(&mut rand, &rgb_light, &on_off, &level); + let dm = DataModel::new( + &matter, + &crypto, + &buffers, + &subscriptions, + &events, + ( + NODE, + endpoints::with_wifi_sys(&false, &(), &UnixNetifs, &net_ctl, &net_ctl, rand, handler), + ), + SharedKvBlobStore::new(kv, kv_buf.as_mut_slice()), + &networks, + ); + + let responder = DefaultResponder::new(&dm); + let mut respond = pin!(responder.run::<4, 4>()); + let mut dm_job = pin!(dm.run()); + let mut mdns_responder = ZeroconfMdnsResponder::new(); + let mut mdns = pin!(mdns_responder.run(&matter)); + + let udp = async_io::Async::::bind(MATTER_SOCKET_BIND_ADDR)?; + let mut transport = pin!(matter.run(&crypto, &udp, &udp, &udp)); + + select4(&mut transport, &mut mdns, &mut respond, &mut dm_job) + .coalesce() + .await +} + +fn rgb_handler<'a, OH, LH>( + rand: &mut impl RngCore, + rgb_light: &'a RgbLight, + on_off: &'a on_off::OnOffHandler<'a, OH, LH>, + level: &'a level_control::LevelControlHandler<'a, LH, OH>, +) -> impl rs_matter::dm::AsyncHandler + 'a +where + OH: OnOffHooks + 'a, + LH: LevelControlHooks + 'a, +{ + EmptyHandler + .chain( + EpClMatcher::new(Some(LIGHT_ENDPOINT_ID), Some(RgbLight::ON_OFF_CLUSTER.id)), + on_off::HandlerAsyncAdaptor(on_off), + ) + .chain( + EpClMatcher::new(Some(LIGHT_ENDPOINT_ID), Some(RgbLight::LEVEL_CLUSTER.id)), + level_control::HandlerAsyncAdaptor(level), + ) + .chain( + EpClMatcher::new(Some(LIGHT_ENDPOINT_ID), Some(RgbLight::COLOR_CLUSTER.id)), + color_control::HandlerAsyncAdaptor(rgb_light), + ) + .chain( + EpClMatcher::new(Some(LIGHT_ENDPOINT_ID), Some(DescHandler::CLUSTER.id)), + Async(DescHandler::new(Dataver::new_rand(rand)).adapt()), + ) +} + +struct PersistingWifiCtl<'a, C> { + inner: C, + ifname: &'a str, +} + +impl<'a, C> PersistingWifiCtl<'a, C> { + const fn new(inner: C, ifname: &'a str) -> Self { + Self { inner, ifname } + } +} + +impl DynBase for PersistingWifiCtl<'_, C> where C: DynBase {} + +impl NetCtl for PersistingWifiCtl<'_, C> +where + C: NetCtl, +{ + fn net_type(&self) -> NetworkType { + self.inner.net_type() + } + + fn connect_max_time_seconds(&self) -> u8 { + self.inner.connect_max_time_seconds() + } + + fn scan_max_time_seconds(&self) -> u8 { + self.inner.scan_max_time_seconds() + } + + fn supported_wifi_bands(&self, f: F) -> Result<()> + where + F: FnMut(WiFiBandEnum) -> Result<()>, + { + self.inner.supported_wifi_bands(f) + } + + fn supported_thread_features(&self) -> ThreadCapabilitiesBitmap { + self.inner.supported_thread_features() + } + + fn thread_version(&self) -> u16 { + self.inner.thread_version() + } + + async fn scan(&self, network: Option<&[u8]>, f: F) -> core::result::Result<(), NetCtlError> + where + F: FnMut(&NetworkScanInfo) -> Result<()>, + { + self.inner.scan(network, f).await + } + + async fn connect(&self, creds: &WirelessCreds<'_>) -> core::result::Result<(), NetCtlError> { + self.inner.connect(creds).await?; + + if let Err(err) = persist_wifi_creds_to_wpa_supplicant(self.ifname, creds) { + warn!("failed to persist Wi-Fi credentials to wpa_supplicant: {err:?}"); + } + + Ok(()) + } +} + +impl WirelessDiag for PersistingWifiCtl<'_, C> +where + C: WirelessDiag, +{ + fn connected(&self) -> Result { + self.inner.connected() + } +} + +impl WifiDiag for PersistingWifiCtl<'_, C> +where + C: WifiDiag, +{ + fn bssid(&self, f: &mut dyn FnMut(Option<&[u8]>) -> Result<()>) -> Result<()> { + self.inner.bssid(f) + } + + fn security_type(&self) -> Result> { + self.inner.security_type() + } + + fn wi_fi_version(&self) -> Result> { + self.inner.wi_fi_version() + } + + fn channel_number(&self) -> Result> { + self.inner.channel_number() + } + + fn rssi(&self) -> Result> { + self.inner.rssi() + } +} + +impl NetChangeNotif for PersistingWifiCtl<'_, C> +where + C: NetChangeNotif, +{ + async fn wait_changed(&self) { + self.inner.wait_changed().await; + } +} + +fn persist_wifi_creds_to_wpa_supplicant( + ifname: &str, + creds: &WirelessCreds<'_>, +) -> core::result::Result<(), NetCtlError> { + let WirelessCreds::Wifi { ssid, pass } = creds else { + return Ok(()); + }; + + let ssid = + std::str::from_utf8(ssid).map_err(|_| NetCtlError::Other(ErrorCode::InvalidData.into()))?; + let pass = + std::str::from_utf8(pass).map_err(|_| NetCtlError::Other(ErrorCode::InvalidData.into()))?; + + let network_id = match find_wpa_network_id(ifname, ssid)? { + Some(network_id) => network_id, + None => add_wpa_network(ifname)?, + }; + + wpa_cli( + ifname, + &["set_network", &network_id, "ssid", &wpa_cli_string(ssid)], + )?; + + if pass.is_empty() { + wpa_cli(ifname, &["set_network", &network_id, "key_mgmt", "NONE"])?; + } else { + wpa_cli( + ifname, + &["set_network", &network_id, "psk", &wpa_cli_string(pass)], + )?; + } + + wpa_cli(ifname, &["enable_network", &network_id])?; + wpa_cli(ifname, &["save_config"])?; + + info!("Persisted Wi-Fi credentials for SSID {ssid} to wpa_supplicant"); + + Ok(()) +} + +fn find_wpa_network_id( + ifname: &str, + ssid: &str, +) -> core::result::Result, NetCtlError> { + let output = wpa_cli_output(ifname, &["list_networks"])?; + + Ok(output.lines().skip(1).find_map(|line| { + let mut fields = line.split('\t'); + let id = fields.next()?; + let listed_ssid = fields.next()?; + + (listed_ssid == ssid).then(|| id.to_owned()) + })) +} + +fn add_wpa_network(ifname: &str) -> core::result::Result { + let output = wpa_cli_output(ifname, &["add_network"])?; + Ok(output.trim().to_owned()) +} + +fn wpa_cli(ifname: &str, args: &[&str]) -> core::result::Result<(), NetCtlError> { + let output = wpa_cli_raw(ifname, args)?; + + if output.status.success() && String::from_utf8_lossy(&output.stdout).trim() != "FAIL" { + Ok(()) + } else { + warn!( + "wpa_cli -i {ifname} {} failed: status={:?}, stdout={}, stderr={}", + wpa_cli_desc(args), + output.status.code(), + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim(), + ); + Err(NetCtlError::Other(ErrorCode::Invalid.into())) + } +} + +fn wpa_cli_output(ifname: &str, args: &[&str]) -> core::result::Result { + let output = wpa_cli_raw(ifname, args)?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + warn!( + "wpa_cli -i {ifname} {} failed: status={:?}, stdout={}, stderr={}", + wpa_cli_desc(args), + output.status.code(), + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim(), + ); + Err(NetCtlError::Other(ErrorCode::Invalid.into())) + } +} + +fn wpa_cli_desc(args: &[&str]) -> String { + if matches!(args, ["set_network", _, "psk", _]) { + format!("set_network {} psk ***", args[1]) + } else { + args.join(" ") + } +} + +fn wpa_cli_raw( + ifname: &str, + args: &[&str], +) -> core::result::Result { + Command::new("wpa_cli") + .arg("-i") + .arg(ifname) + .args(args) + .output() + .map_err(|err| NetCtlError::Other(err.into())) +} + +fn wpa_cli_string(value: &str) -> String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + + for ch in value.chars() { + match ch { + '"' | '\\' => { + escaped.push('\\'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + + escaped.push('"'); + escaped +} + +struct RgbLight { + state: Mutex>, + color_dataver: Dataver, + rgbled_order: String, +} + +struct RgbLightState { + on: bool, + level: Option, + hue: u8, + saturation: u8, + options: color_control::OptionsBitmap, + start_up_on_off: Nullable, + start_up_level: Option, +} + +impl RgbLight { + const ON_OFF_CLUSTER: Cluster<'static> = on_off::FULL_CLUSTER + .with_revision(6) + .with_features(on_off::Feature::LIGHTING.bits()) + .with_attrs(with!( + required; + on_off::AttributeId::OnOff + | on_off::AttributeId::GlobalSceneControl + | on_off::AttributeId::OnTime + | on_off::AttributeId::OffWaitTime + | on_off::AttributeId::StartUpOnOff + )) + .with_cmds(with!( + on_off::CommandId::Off + | on_off::CommandId::On + | on_off::CommandId::Toggle + | on_off::CommandId::OffWithEffect + | on_off::CommandId::OnWithRecallGlobalScene + | on_off::CommandId::OnWithTimedOff + )); + + const LEVEL_CLUSTER: Cluster<'static> = level_control::FULL_CLUSTER + .with_revision(6) + .with_features(level_control::Feature::ON_OFF.bits()) + .with_attrs(with!( + required; + level_control::AttributeId::CurrentLevel + | level_control::AttributeId::MinLevel + | level_control::AttributeId::MaxLevel + | level_control::AttributeId::OnLevel + | level_control::AttributeId::Options + )) + .with_cmds(with!( + level_control::CommandId::MoveToLevel + | level_control::CommandId::Move + | level_control::CommandId::Step + | level_control::CommandId::Stop + | level_control::CommandId::MoveToLevelWithOnOff + | level_control::CommandId::MoveWithOnOff + | level_control::CommandId::StepWithOnOff + | level_control::CommandId::StopWithOnOff + )); + + const COLOR_CLUSTER: Cluster<'static> = color_control::FULL_CLUSTER + .with_revision(7) + .with_features(color_control::Feature::HUE_AND_SATURATION.bits()) + .with_attrs(with!( + required; + color_control::AttributeId::CurrentHue + | color_control::AttributeId::CurrentSaturation + | color_control::AttributeId::ColorMode + | color_control::AttributeId::Options + | color_control::AttributeId::NumberOfPrimaries + | color_control::AttributeId::EnhancedColorMode + | color_control::AttributeId::ColorCapabilities + )) + .with_cmds(with!( + color_control::CommandId::MoveToHue + | color_control::CommandId::MoveHue + | color_control::CommandId::StepHue + | color_control::CommandId::MoveToSaturation + | color_control::CommandId::MoveSaturation + | color_control::CommandId::StepSaturation + | color_control::CommandId::MoveToHueAndSaturation + | color_control::CommandId::StopMoveStep + )); + + fn new(color_dataver: Dataver, rgbled_order: String) -> Self { + Self { + state: Mutex::new(RefCell::new(RgbLightState { + on: false, + level: Some(254), + hue: 0, + saturation: 254, + options: color_control::OptionsBitmap::from_bits(0).unwrap(), + start_up_on_off: Nullable::none(), + start_up_level: None, + })), + color_dataver, + rgbled_order, + } + } + + fn apply(&self) { + let (on, level, hue, saturation) = self.state.lock(|state| { + let state = state.borrow(); + ( + state.on, + state.level.unwrap_or(254), + state.hue, + state.saturation, + ) + }); + + let (r, g, b) = if on { + hsv_to_rgb(hue, saturation, level) + } else { + (0, 0, 0) + }; + + let color = format!("{r:02x}{g:02x}{b:02x}"); + info!("Setting RGB LED to #{color}"); + + match Command::new("rgbled") + .arg("--order") + .arg(&self.rgbled_order) + .arg(&color) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => warn!( + "rgbled --order {} exited with status {status}", + self.rgbled_order + ), + Err(err) => warn!("failed to run rgbled: {err}"), + } + } + + fn set_hue_sat(&self, hue: Option, saturation: Option) { + self.state.lock(|state| { + let mut state = state.borrow_mut(); + if let Some(hue) = hue { + state.hue = hue; + } + if let Some(saturation) = saturation { + state.saturation = saturation; + } + }); + self.color_dataver.changed(); + self.apply(); + } +} + +impl DynBase for RgbLight {} + +impl OnOffHooks for RgbLight { + const CLUSTER: Cluster<'static> = Self::ON_OFF_CLUSTER; + + fn on_off(&self) -> bool { + self.state.lock(|state| state.borrow().on) + } + + fn set_on_off(&self, on: bool) { + self.state.lock(|state| state.borrow_mut().on = on); + self.apply(); + } + + fn start_up_on_off(&self) -> Nullable { + self.state + .lock(|state| state.borrow().start_up_on_off.clone()) + } + + fn set_start_up_on_off(&self, value: Nullable) -> Result<()> { + self.state + .lock(|state| state.borrow_mut().start_up_on_off = value); + Ok(()) + } + + async fn handle_off_with_effect(&self, _effect: on_off::EffectVariantEnum) { + self.set_on_off(false); + } +} + +impl LevelControlHooks for RgbLight { + const MIN_LEVEL: u8 = 1; + const MAX_LEVEL: u8 = 254; + const FASTEST_RATE: u8 = 50; + const CLUSTER: Cluster<'static> = Self::LEVEL_CLUSTER; + + fn set_device_level(&self, level: u8) -> core::result::Result, ()> { + self.state + .lock(|state| state.borrow_mut().level = Some(level)); + self.apply(); + Ok(Some(level)) + } + + fn current_level(&self) -> Option { + self.state.lock(|state| state.borrow().level) + } + + fn set_current_level(&self, level: Option) { + self.state.lock(|state| state.borrow_mut().level = level); + } + + fn start_up_current_level(&self) -> Result> { + Ok(self.state.lock(|state| state.borrow().start_up_level)) + } + + fn set_start_up_current_level(&self, value: Option) -> Result<()> { + self.state + .lock(|state| state.borrow_mut().start_up_level = value); + Ok(()) + } +} + +impl ColorControlHandler for RgbLight { + const CLUSTER: Cluster<'static> = Self::COLOR_CLUSTER; + + fn dataver(&self) -> u32 { + self.color_dataver.get() + } + + fn dataver_changed(&self) { + self.color_dataver.changed(); + } + + async fn current_hue(&self, _ctx: impl rs_matter::dm::ReadContext) -> Result { + Ok(self.state.lock(|state| state.borrow().hue)) + } + + async fn current_saturation(&self, _ctx: impl rs_matter::dm::ReadContext) -> Result { + Ok(self.state.lock(|state| state.borrow().saturation)) + } + + async fn color_mode( + &self, + _ctx: impl rs_matter::dm::ReadContext, + ) -> Result { + Ok(color_control::ColorModeEnum::CurrentHueAndCurrentSaturation) + } + + async fn options( + &self, + _ctx: impl rs_matter::dm::ReadContext, + ) -> Result { + Ok(self.state.lock(|state| state.borrow().options)) + } + + async fn number_of_primaries( + &self, + _ctx: impl rs_matter::dm::ReadContext, + ) -> Result> { + Ok(Nullable::none()) + } + + async fn enhanced_color_mode( + &self, + _ctx: impl rs_matter::dm::ReadContext, + ) -> Result { + Ok(color_control::EnhancedColorModeEnum::CurrentHueAndCurrentSaturation) + } + + async fn color_capabilities( + &self, + _ctx: impl rs_matter::dm::ReadContext, + ) -> Result { + Ok(color_control::ColorCapabilitiesBitmap::HUE_SATURATION) + } + + async fn set_options( + &self, + _ctx: impl rs_matter::dm::WriteContext, + value: color_control::OptionsBitmap, + ) -> Result<()> { + self.state.lock(|state| state.borrow_mut().options = value); + self.color_dataver.changed(); + Ok(()) + } + + async fn handle_move_to_hue( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + request: color_control::MoveToHueRequest<'_>, + ) -> Result<()> { + self.set_hue_sat(Some(request.hue()?), None); + Ok(()) + } + + async fn handle_move_hue( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + request: color_control::MoveHueRequest<'_>, + ) -> Result<()> { + let hue = self.state.lock(|state| state.borrow().hue); + let rate = request.rate().unwrap_or(8); + let hue = match request.move_mode()? { + color_control::MoveModeEnum::Up => hue.wrapping_add(rate), + color_control::MoveModeEnum::Down => hue.wrapping_sub(rate), + color_control::MoveModeEnum::Stop => hue, + }; + self.set_hue_sat(Some(hue), None); + Ok(()) + } + + async fn handle_step_hue( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + request: color_control::StepHueRequest<'_>, + ) -> Result<()> { + let hue = self.state.lock(|state| state.borrow().hue); + let hue = match request.step_mode()? { + color_control::StepModeEnum::Up => hue.wrapping_add(request.step_size()?), + color_control::StepModeEnum::Down => hue.wrapping_sub(request.step_size()?), + }; + self.set_hue_sat(Some(hue), None); + Ok(()) + } + + async fn handle_move_to_saturation( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + request: color_control::MoveToSaturationRequest<'_>, + ) -> Result<()> { + self.set_hue_sat(None, Some(request.saturation()?)); + Ok(()) + } + + async fn handle_move_saturation( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + request: color_control::MoveSaturationRequest<'_>, + ) -> Result<()> { + let saturation = self.state.lock(|state| state.borrow().saturation); + let rate = request.rate().unwrap_or(8); + let saturation = match request.move_mode()? { + color_control::MoveModeEnum::Up => saturation.saturating_add(rate), + color_control::MoveModeEnum::Down => saturation.saturating_sub(rate), + color_control::MoveModeEnum::Stop => saturation, + }; + self.set_hue_sat(None, Some(saturation)); + Ok(()) + } + + async fn handle_step_saturation( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + request: color_control::StepSaturationRequest<'_>, + ) -> Result<()> { + let saturation = self.state.lock(|state| state.borrow().saturation); + let saturation = match request.step_mode()? { + color_control::StepModeEnum::Up => saturation.saturating_add(request.step_size()?), + color_control::StepModeEnum::Down => saturation.saturating_sub(request.step_size()?), + }; + self.set_hue_sat(None, Some(saturation)); + Ok(()) + } + + async fn handle_move_to_hue_and_saturation( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + request: color_control::MoveToHueAndSaturationRequest<'_>, + ) -> Result<()> { + self.set_hue_sat(Some(request.hue()?), Some(request.saturation()?)); + Ok(()) + } + + async fn handle_move_to_color( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::MoveToColorRequest<'_>, + ) -> Result<()> { + Err(ErrorCode::InvalidAction.into()) + } + + async fn handle_move_color( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::MoveColorRequest<'_>, + ) -> Result<()> { + Err(ErrorCode::InvalidAction.into()) + } + + async fn handle_step_color( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::StepColorRequest<'_>, + ) -> Result<()> { + Err(ErrorCode::InvalidAction.into()) + } + + async fn handle_move_to_color_temperature( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::MoveToColorTemperatureRequest<'_>, + ) -> Result<()> { + Err(ErrorCode::InvalidAction.into()) + } + + async fn handle_enhanced_move_to_hue( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + request: color_control::EnhancedMoveToHueRequest<'_>, + ) -> Result<()> { + self.set_hue_sat(Some((request.enhanced_hue()? >> 8) as u8), None); + Ok(()) + } + + async fn handle_enhanced_move_hue( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::EnhancedMoveHueRequest<'_>, + ) -> Result<()> { + Err(ErrorCode::InvalidAction.into()) + } + + async fn handle_enhanced_step_hue( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::EnhancedStepHueRequest<'_>, + ) -> Result<()> { + Err(ErrorCode::InvalidAction.into()) + } + + async fn handle_enhanced_move_to_hue_and_saturation( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + request: color_control::EnhancedMoveToHueAndSaturationRequest<'_>, + ) -> Result<()> { + self.set_hue_sat( + Some((request.enhanced_hue()? >> 8) as u8), + Some(request.saturation()?), + ); + Ok(()) + } + + async fn handle_color_loop_set( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::ColorLoopSetRequest<'_>, + ) -> Result<()> { + Err(ErrorCode::InvalidAction.into()) + } + + async fn handle_stop_move_step( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::StopMoveStepRequest<'_>, + ) -> Result<()> { + Ok(()) + } + + async fn handle_move_color_temperature( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::MoveColorTemperatureRequest<'_>, + ) -> Result<()> { + Err(ErrorCode::InvalidAction.into()) + } + + async fn handle_step_color_temperature( + &self, + _ctx: impl rs_matter::dm::InvokeContext, + _request: color_control::StepColorTemperatureRequest<'_>, + ) -> Result<()> { + Err(ErrorCode::InvalidAction.into()) + } +} + +fn hsv_to_rgb(hue: u8, saturation: u8, value: u8) -> (u8, u8, u8) { + if saturation == 0 { + return (value, value, value); + } + + let region = hue as u16 * 6 / 256; + let remainder = (hue as u16 * 6) % 256; + let value = value as u16; + let saturation = saturation as u16; + + let p = value * (255 - saturation) / 255; + let q = value * (255 - (saturation * remainder / 255)) / 255; + let t = value * (255 - (saturation * (255 - remainder) / 255)) / 255; + + match region { + 0 => (value as u8, t as u8, p as u8), + 1 => (q as u8, value as u8, p as u8), + 2 => (p as u8, value as u8, t as u8), + 3 => (p as u8, q as u8, value as u8), + 4 => (t as u8, p as u8, value as u8), + _ => (value as u8, p as u8, q as u8), + } +}