Skip to main content

matc/devman/
mod.rs

1//! Device manager for simplified Matter device interaction.
2//!
3//! Wraps certificate management, transport, controller, mDNS discovery, and a persistent device
4//! registry so that commissioning and connecting to devices is simpler.
5//!
6//! # Features
7//!
8//! - **Commission by address**: [`DeviceManager::commission`] when the device IP is known
9//! - **Commission by pairing code**: [`DeviceManager::commission_with_code`] decodes a manual
10//!   pairing code, discovers the device via commissionable mDNS (`_matterc._udp.local`),
11//!   and commissions it automatically
12//! - **Connect with auto-rediscovery**: [`DeviceManager::connect`] and
13//!   [`DeviceManager::connect_by_name`] try the stored address first; if the connection fails
14//!   (e.g. device changed IP), they automatically re-discover the device via operational mDNS
15//!   (`_matter._tcp.local`) and retry
16//! - **Explicit discovery**: [`DeviceManager::discover_device`] finds the current address of a
17//!   commissioned device via operational mDNS and updates the registry
18//!
19//! # First-time setup
20//! ```no_run
21//! # use matc::devman::{DeviceManager, ManagerConfig};
22//! # #[tokio::main]
23//! # async fn main() -> anyhow::Result<()> {
24//! let config = ManagerConfig { fabric_id: 1000, controller_id: 100,
25//!                              local_address: "0.0.0.0:5555".into() };
26//! let dm = DeviceManager::create("./matter-data", config).await?;
27//! let conn = dm.commission("192.168.1.100:5540", 123456, 300, "kitchen light").await?;
28//! # Ok(())
29//! # }
30//! ```
31//!
32//! # Commission using manual pairing code
33//! ```no_run
34//! # use matc::devman::DeviceManager;
35//! # #[tokio::main]
36//! # async fn main() -> anyhow::Result<()> {
37//! let dm = DeviceManager::load("./matter-data").await?;
38//! let conn = dm.commission_with_code("0251-520-0076", 300, "kitchen light").await?;
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! # Reconnecting later
44//!
45//! If the device changed its IP address since commissioning, the connection automatically
46//! falls back to operational mDNS discovery, updates the stored address, and retries.
47//! ```no_run
48//! # use matc::devman::DeviceManager;
49//! # #[tokio::main]
50//! # async fn main() -> anyhow::Result<()> {
51//! let dm = DeviceManager::load("./matter-data").await?;
52//! let conn = dm.connect_by_name("kitchen light").await?;
53//! # Ok(())
54//! # }
55//! ```
56
57mod config;
58mod device;
59
60pub use config::ManagerConfig;
61pub use device::Device;
62
63use std::sync::Arc;
64
65use anyhow::{Context, Result};
66
67use std::time::Duration;
68
69use crate::{certmanager, controller, discover::{self, MatterDeviceInfo}, fabric::Fabric, mdns2, onboarding, transport};
70
71pub struct DeviceManager {
72    base_path: String,
73    config: ManagerConfig,
74    transport: Arc<transport::Transport>,
75    controller: Arc<controller::Controller>,
76    certmanager: Arc<dyn certmanager::CertManager>,
77    registry: std::sync::Mutex<device::DeviceRegistry>,
78    mdns: Arc<mdns2::MdnsService>,
79}
80
81impl DeviceManager {
82    /// First-time setup: creates directory structure, bootstraps CA,
83    /// creates controller user, and saves config.
84    pub async fn create(base_path: &str, config: ManagerConfig) -> Result<Self> {
85        std::fs::create_dir_all(base_path)
86            .context(format!("creating base directory {}", base_path))?;
87        config::save_config(base_path, &config)?;
88
89        let pem = config::pem_path(base_path);
90        let cm = certmanager::FileCertManager::new(config.fabric_id, &pem);
91        cm.bootstrap()?;
92        cm.create_user(config.controller_id)?;
93
94        let cm: Arc<dyn certmanager::CertManager> = certmanager::FileCertManager::load(&pem)?;
95        let transport = transport::Transport::new(&config.local_address).await?;
96        let controller = controller::Controller::new(&cm, &transport, config.fabric_id)?;
97        let registry = device::DeviceRegistry::load(&config::devices_path(base_path))?;
98        let mdns = mdns2::MdnsService::new().await?;
99
100        Ok(Self {
101            base_path: base_path.to_owned(),
102            config,
103            transport,
104            controller,
105            certmanager: cm,
106            registry: std::sync::Mutex::new(registry),
107            mdns,
108        })
109    }
110
111    /// Load an existing device manager from a previously created base directory.
112    pub async fn load(base_path: &str) -> Result<Self> {
113        let config = config::load_config(base_path)?;
114        let pem = config::pem_path(base_path);
115        let cm: Arc<dyn certmanager::CertManager> = certmanager::FileCertManager::load(&pem)?;
116        let transport = transport::Transport::new(&config.local_address).await?;
117        let controller = controller::Controller::new(&cm, &transport, config.fabric_id)?;
118        let registry = device::DeviceRegistry::load(&config::devices_path(base_path))?;
119        let mdns = mdns2::MdnsService::new().await?;
120
121        Ok(Self {
122            base_path: base_path.to_owned(),
123            config,
124            transport,
125            controller,
126            certmanager: cm,
127            registry: std::sync::Mutex::new(registry),
128            mdns,
129        })
130    }
131
132    /// Commission a device and save it to the registry.
133    /// Returns an authenticated connection ready for commands.
134    pub async fn commission(
135        &self,
136        address: &str,
137        pin: u32,
138        node_id: u64,
139        name: &str,
140    ) -> Result<controller::Connection> {
141        self.commission_at(address, pin, node_id, name, (None, None, None)).await
142    }
143
144    /// Commission at a known address with optional advertised MRP intervals
145    /// (SII/SAI/SAT milliseconds), which are applied to the connection and
146    /// persisted in the registry.
147    async fn commission_at(
148        &self,
149        address: &str,
150        pin: u32,
151        node_id: u64,
152        name: &str,
153        mrp_ms: (Option<u32>, Option<u32>, Option<u32>),
154    ) -> Result<controller::Connection> {
155        let conn = self.transport.create_connection(address).await;
156        conn.set_mrp_params(crate::mrp::MrpParameters::from_txt_ms(mrp_ms.0, mrp_ms.1, mrp_ms.2));
157        let connection = self
158            .controller
159            .commission(&conn, pin, node_id, self.config.controller_id)
160            .await?;
161
162        let device = Device {
163            node_id,
164            address: address.to_owned(),
165            name: name.to_owned(),
166            sii_ms: mrp_ms.0,
167            sai_ms: mrp_ms.1,
168            sat_ms: mrp_ms.2,
169        };
170        self.registry
171            .lock()
172            .map_err(|e| anyhow::anyhow!("registry lock: {}", e))?
173            .add(device)?;
174
175        Ok(connection)
176    }
177
178    /// Connect to a previously commissioned device by node ID.
179    /// If the stored address fails, automatically re-discovers the device via operational mDNS.
180    pub async fn connect(&self, node_id: u64) -> Result<controller::Connection> {
181        let address = {
182            let reg = self.registry.lock().map_err(|e| anyhow::anyhow!("registry lock: {}", e))?;
183            reg.get(node_id)
184                .context(format!("device {} not found in registry", node_id))?
185                .address
186                .clone()
187        };
188        self.connect_with_rediscovery(node_id, &address).await
189    }
190
191    /// Connect to a previously commissioned device by friendly name.
192    /// If the stored address fails, automatically re-discovers the device via operational mDNS.
193    pub async fn connect_by_name(&self, name: &str) -> Result<controller::Connection> {
194        let (node_id, address) = {
195            let reg = self.registry.lock().map_err(|e| anyhow::anyhow!("registry lock: {}", e))?;
196            let dev = reg
197                .get_by_name(name)
198                .context(format!("device '{}' not found in registry", name))?;
199            (dev.node_id, dev.address.clone())
200        };
201        self.connect_with_rediscovery(node_id, &address).await
202    }
203
204    /// Connect with BUSY retry and one round of mDNS rediscovery on non-BUSY failure.
205    /// BUSY handling is delegated to Controller::auth_sigma_with_busy_retry so that
206    /// in-place reauth uses identical retry semantics.
207    async fn connect_with_rediscovery(&self, node_id: u64, address: &str) -> Result<controller::Connection> {
208        let stored_mrp = self
209            .registry
210            .lock()
211            .map_err(|e| anyhow::anyhow!("registry lock: {}", e))?
212            .get(node_id)
213            .map(|d| d.mrp_params())
214            .unwrap_or_default();
215        let mut current_address = address.to_string();
216        // Create connection once and reuse across retries; only replace if address changes.
217        let mut conn = self.transport.create_connection(&current_address).await;
218        conn.set_mrp_params(stored_mrp);
219
220        match self.controller.auth_sigma_with_busy_retry(&conn, node_id, self.config.controller_id).await {
221            Ok(ses) => Ok(controller::Connection::from_parts(conn, ses)),
222            Err(e) => {
223                // Try operational mDNS rediscovery once, then one more attempt.
224                log::info!(
225                    "Connection to {} failed ({}), attempting operational rediscovery...",
226                    current_address, e
227                );
228                let (new_address, matter_info) = self
229                    .discover_device_info(node_id, Duration::from_secs(10))
230                    .await
231                    .context(format!("rediscovery for node {} after connect failure", node_id))?;
232                current_address = new_address;
233                conn = self.transport.create_connection(&current_address).await;
234                conn.set_mrp_params(matter_info.mrp_params());
235                let ses = self
236                    .controller
237                    .auth_sigma_with_busy_retry(&conn, node_id, self.config.controller_id)
238                    .await
239                    .context(format!(
240                        "connection still failed after rediscovery at {}", current_address
241                    ))?;
242                Ok(controller::Connection::from_parts(conn, ses))
243            }
244        }
245    }
246
247    /// Re-run CASE on an existing controller::Connection without tearing down the
248    /// transport channel. Delegates to Connection::reauth which pauses the read loop,
249    /// calls auth_sigma_with_busy_retry, swaps the session, and restarts the loop.
250    pub async fn reauth(&self, conn: &controller::Connection, node_id: u64) -> Result<()> {
251        conn.reauth(&self.controller, node_id, self.config.controller_id).await
252    }
253
254    /// Commission a device using a manual pairing code.
255    /// Decodes the pairing code to extract the discriminator, discovers the device via
256    /// commissionable mDNS, then commissions it. Returns an authenticated connection.
257    pub async fn commission_with_code(
258        &self,
259        pairing_code: &str,
260        node_id: u64,
261        name: &str,
262    ) -> Result<controller::Connection> {
263        let info = onboarding::decode_manual_pairing_code(pairing_code)
264            .context("decoding manual pairing code")?;
265        let discriminator = info.discriminator;
266        let passcode = info.passcode;
267
268        log::info!("Discovering device with discriminator {}...", discriminator);
269
270        let is_short = info.is_short_discriminator;
271        let (_, matter_info) = discover::discover_one(
272            &self.mdns,
273            "_matterc._udp.local",
274            "_matterc._udp.local.",
275            Duration::from_secs(10),
276            move |_, i| {
277                if let Some(ref d) = i.discriminator {
278                    if let Ok(mut disc) = d.parse::<u16>() {
279                        if is_short { disc &= 0xf00; }
280                        return disc == discriminator;
281                    }
282                }
283                false
284            },
285        ).await.context(format!("discovering device with discriminator {}", discriminator))?;
286
287        let mrp_ms = (
288            matter_info.session_idle_interval_ms,
289            matter_info.session_active_interval_ms,
290            matter_info.session_active_threshold_ms,
291        );
292        let ips = matter_info.ips;
293        let port = matter_info.port.unwrap_or(5540);
294
295        if ips.is_empty() {
296            anyhow::bail!("discovered device with discriminator {} but no IPs returned", discriminator);
297        }
298
299        let mut last_err = anyhow::anyhow!("no IPs to try");
300        for ip in &ips {
301            let address = if ip.is_ipv6() {
302                format!("[{}]:{}", ip, port)
303            } else {
304                format!("{}:{}", ip, port)
305            };
306            match self.commission_at(&address, passcode, node_id, name, mrp_ms).await {
307                Ok(conn) => return Ok(conn),
308                Err(e) => {
309                    log::debug!("Commission attempt at {} failed: {}", address, e);
310                    last_err = e;
311                }
312            }
313        }
314        Err(last_err).context(format!("commissioning failed on all IPs for discriminator {}", discriminator))
315    }
316
317    /// Commission a device that is currently advertising over BLE.
318    ///
319    /// Accepts either a manual pairing code (`"0251-520-0076"`) or a QR payload
320    /// (`"MT:..."`). Scans for the matching BLE device, runs PASE + commissioning
321    /// over BTP, optionally provisions network credentials, then completes over
322    /// UDP+CASE once the device is reachable on the IP network.
323    ///
324    /// Requires the `ble` Cargo feature.
325    #[cfg(feature = "ble")]
326    pub async fn commission_ble_with_code(
327        &self,
328        pairing_code: &str,
329        node_id: u64,
330        name: &str,
331        network_creds: crate::commission::NetworkCreds,
332    ) -> Result<controller::Connection> {
333        let info = if pairing_code.starts_with("MT:") || pairing_code.starts_with("mt:") {
334            crate::onboarding::decode_qr_payload(pairing_code)
335        } else {
336            crate::onboarding::decode_manual_pairing_code(pairing_code)
337        }
338        .context("decoding pairing code")?;
339
340        let connection = self
341            .controller
342            .commission_ble(
343                info.discriminator,
344                info.is_short_discriminator,
345                info.passcode,
346                node_id,
347                self.config.controller_id,
348                network_creds,
349                &self.mdns,
350            )
351            .await?;
352
353        let device = crate::devman::Device {
354            node_id,
355            address: String::new(), // will be filled on first connect
356            name: name.to_owned(),
357            ..Default::default()
358        };
359        self.registry
360            .lock()
361            .map_err(|e| anyhow::anyhow!("registry lock: {}", e))?
362            .add(device)?;
363
364        Ok(connection)
365    }
366
367    /// Discover the current address of a commissioned device via operational mDNS.
368    /// Returns as soon as the device is found (no fixed timeout wait).
369    /// Updates the stored address in the registry and returns the new address.
370    pub async fn discover_device(&self, node_id: u64, timeout: Duration) -> Result<String> {
371        let (address, _) = self.discover_device_info(node_id, timeout).await?;
372        Ok(address)
373    }
374
375    /// Operational discovery returning the full mDNS info. Updates the stored
376    /// address and advertised MRP intervals in the registry.
377    async fn discover_device_info(
378        &self,
379        node_id: u64,
380        timeout: Duration,
381    ) -> Result<(String, MatterDeviceInfo)> {
382        let ca_public_key = self.certmanager.get_ca_public_key()?;
383        let fabric = Fabric::new(self.config.fabric_id, 0, &ca_public_key, &self.certmanager.get_ipk_epoch_key());
384        let compressed = fabric.compressed().context("computing compressed fabric ID")?;
385        let instance_name = format!("{}-{:016X}", hex::encode_upper(&compressed), node_id);
386        let expected_target = format!("{}._matter._tcp.local.", instance_name);
387
388        log::info!("Operational discovery for instance {}...", instance_name);
389
390        let (_, matter_info) = discover::discover_one(
391            &self.mdns,
392            "_matter._tcp.local",
393            "_matter._tcp.local.",
394            timeout,
395            move |target, _| target == expected_target,
396        ).await.context(format!("operational discovery for node {}", node_id))?;
397
398        let ip = matter_info.ips.first()
399            .context(format!("discovered {} but no IPs in response", instance_name))?;
400        let port = matter_info.port.unwrap_or(5540);
401        let address = if ip.is_ipv6() {
402            format!("[{}]:{}", ip, port)
403        } else {
404            format!("{}:{}", ip, port)
405        };
406
407        self.update_device_address(node_id, &address)?;
408        if let Err(e) = self.registry
409            .lock()
410            .map_err(|e| anyhow::anyhow!("registry lock: {}", e))?
411            .update_mrp(
412                node_id,
413                matter_info.session_idle_interval_ms,
414                matter_info.session_active_interval_ms,
415                matter_info.session_active_threshold_ms,
416            )
417        {
418            log::debug!("failed to persist MRP intervals for node {}: {}", node_id, e);
419        }
420        Ok((address, matter_info))
421    }
422
423    pub async fn discover_commissionable_devices(&self, timeout: Duration) -> Result<Vec<(String, MatterDeviceInfo)>> {
424        discover::discover_all(
425            &self.mdns,
426            "_matterc._udp.local",
427            "_matterc._udp.local.",
428            timeout,
429        ).await
430    }
431
432    /// List all registered devices.
433    pub fn list_devices(&self) -> Result<Vec<Device>> {
434        let reg = self.registry.lock().map_err(|e| anyhow::anyhow!("registry lock: {}", e))?;
435        Ok(reg.list().to_vec())
436    }
437
438    /// Get a device by node ID.
439    pub fn get_device(&self, node_id: u64) -> Result<Option<Device>> {
440        let reg = self.registry.lock().map_err(|e| anyhow::anyhow!("registry lock: {}", e))?;
441        Ok(reg.get(node_id).cloned())
442    }
443
444    /// Get a device by friendly name.
445    pub fn get_device_by_name(&self, name: &str) -> Result<Option<Device>> {
446        let reg = self.registry.lock().map_err(|e| anyhow::anyhow!("registry lock: {}", e))?;
447        Ok(reg.get_by_name(name).cloned())
448    }
449
450    /// Remove a device from the registry.
451    pub fn remove_device(&self, node_id: u64) -> Result<()> {
452        self.registry
453            .lock()
454            .map_err(|e| anyhow::anyhow!("registry lock: {}", e))?
455            .remove(node_id)
456    }
457
458    /// Rename a device in the registry.
459    pub fn rename_device(&self, node_id: u64, name: &str) -> Result<()> {
460        self.registry
461            .lock()
462            .map_err(|e| anyhow::anyhow!("registry lock: {}", e))?
463            .rename(node_id, name)
464    }
465
466    /// Update the stored address for a device.
467    pub fn update_device_address(&self, node_id: u64, address: &str) -> Result<()> {
468        self.registry
469            .lock()
470            .map_err(|e| anyhow::anyhow!("registry lock: {}", e))?
471            .update_address(node_id, address)
472    }
473
474    /// Get a reference to the shared mDNS service.
475    pub fn mdns(&self) -> &Arc<mdns2::MdnsService> {
476        &self.mdns
477    }
478
479    /// Get a reference to the underlying controller.
480    pub fn controller(&self) -> &Arc<controller::Controller> {
481        &self.controller
482    }
483
484    /// Get a reference to the underlying transport.
485    pub fn transport(&self) -> &Arc<transport::Transport> {
486        &self.transport
487    }
488
489    /// Get a reference to the certificate manager.
490    pub fn certmanager(&self) -> &Arc<dyn certmanager::CertManager> {
491        &self.certmanager
492    }
493
494    /// Get the config.
495    pub fn config(&self) -> &ManagerConfig {
496        &self.config
497    }
498
499    /// Get the base path.
500    pub fn base_path(&self) -> &str {
501        &self.base_path
502    }
503}