Skip to main content

rs_matter/dm/networks/
wireless.rs

1/*
2 *
3 *    Copyright (c) 2025-2026 Project CHIP Authors
4 *
5 *    Licensed under the Apache License, Version 2.0 (the "License");
6 *    you may not use this file except in compliance with the License.
7 *    You may obtain a copy of the License at
8 *
9 *        http://www.apache.org/licenses/LICENSE-2.0
10 *
11 *    Unless required by applicable law or agreed to in writing, software
12 *    distributed under the License is distributed on an "AS IS" BASIS,
13 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *    See the License for the specific language governing permissions and
15 *    limitations under the License.
16 */
17
18//! A module containing various types for managing Thread and Wifi networks.
19
20use core::fmt::{Debug, Display};
21
22use crate::dm::clusters::net_comm::{
23    self, NetCtlError, NetworkCommissioningStatusEnum, NetworkType, Networks, NetworksError,
24    ThreadCapabilitiesBitmap, WirelessCreds,
25};
26use crate::dm::clusters::{thread_diag, wifi_diag};
27use crate::error::{Error, ErrorCode};
28use crate::fmt::Bytes;
29use crate::persist::{KvBlobStore, NETWORKS_KEY};
30use crate::tlv::{FromTLV, TLVElement, TLVTag, TLVWrite, ToTLV};
31use crate::transport::network::btp::Btp;
32use crate::utils::cell::RefCell;
33use crate::utils::init::{init, Init};
34use crate::utils::storage::{Vec, WriteBuf};
35use crate::utils::sync::blocking;
36use crate::utils::sync::DynBase;
37
38use super::NetChangeNotif;
39
40pub use mgr::*;
41pub use thread::*;
42pub use wifi::*;
43
44mod mgr;
45mod thread;
46mod wifi;
47
48/// The maximum length of a wireless network ID.
49/// Coincides with the SSID maximum length because
50pub const MAX_WIRELESS_NETWORK_ID_LEN: usize = 32;
51
52/// A type alias for representing an owned ID of a wireless (Thread or Wifi) network.
53/// Both Thread and Wifi networks use the same ID type which is just an octet string.
54///
55/// For Thread networks, this is the Extended PAN ID (`u64` as 8 bytes, network order).
56/// For Wifi networks, this is the SSID (`u8` array of max length 32 bytes).
57pub type OwnedWirelessNetworkId = Vec<u8, MAX_WIRELESS_NETWORK_ID_LEN>;
58
59/// A trait representing the credentials of a wireless network (Wifi or Thread).
60///
61/// The trait has only two implementations: `Wifi` and `Thread`.
62pub trait WirelessNetwork: Send + for<'a> FromTLV<'a> + ToTLV {
63    /// Return the network ID
64    ///
65    /// For Wifi networks, this is the SSID
66    /// For Thread networks, this is the Extended PAN ID (`u64` as 8 bytes, network order)
67    fn id(&self) -> &[u8];
68
69    /// Return an in-place initializer for the type
70    ///
71    /// # Arguments
72    /// - `creds`: The credentials of the network with which to initialize the type
73    fn init_from<'a>(creds: &'a WirelessCreds<'a>) -> impl Init<Self, Error> + 'a;
74
75    /// Update the credentials of the network
76    ///
77    /// # Arguments
78    /// - `creds`: The new credentials to set
79    fn update(&mut self, creds: &WirelessCreds<'_>) -> Result<(), Error>;
80
81    /// Return the credentials of the network
82    fn creds(&self) -> WirelessCreds<'_>;
83
84    /// Return a displayable representation of the network
85    #[cfg(not(feature = "defmt"))]
86    fn display(&self) -> impl Display {
87        Self::display_id(self.id())
88    }
89
90    /// Return a displayable representation of the network
91    #[cfg(feature = "defmt")]
92    fn display(&self) -> impl Display + defmt::Format {
93        Self::display_id(self.id())
94    }
95
96    /// Return a displayable representation of the provided network ID
97    #[cfg(not(feature = "defmt"))]
98    fn display_id(id: &[u8]) -> impl Display;
99
100    /// Return a displayable representation of the provided network ID
101    #[cfg(feature = "defmt")]
102    fn display_id(id: &[u8]) -> impl Display + defmt::Format;
103}
104
105/// A fixed-size storage for wireless networks credentials.
106#[derive(Clone, Debug)]
107#[cfg_attr(feature = "defmt", derive(defmt::Format))]
108pub struct WirelessNetworks<const N: usize, T> {
109    networks: crate::utils::storage::Vec<T, N>,
110    managed: bool,
111}
112
113impl<const N: usize, T> Default for WirelessNetworks<N, T>
114where
115    T: WirelessNetwork,
116{
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl<const N: usize, T> WirelessNetworks<N, T>
123where
124    T: WirelessNetwork,
125{
126    pub const fn new() -> Self {
127        Self {
128            networks: crate::utils::storage::Vec::new(),
129            managed: false,
130        }
131    }
132
133    pub fn init() -> impl Init<Self> {
134        init!(Self {
135            networks <- crate::utils::storage::Vec::init(),
136            managed: false,
137        })
138    }
139
140    /// Reset the state
141    pub fn reset(&mut self) {
142        self.networks.clear();
143        self.managed = false;
144    }
145
146    /// Remove all networks from the provided BLOB store and from memory
147    ///
148    /// # Arguments
149    /// - `store`: the BLOB store to remove the networks from
150    /// - `buf`: a temporary buffer to use for removing the networks
151    pub async fn reset_persist<S: KvBlobStore>(
152        &mut self,
153        mut kv: S,
154        buf: &mut [u8],
155    ) -> Result<(), Error> {
156        self.reset();
157
158        kv.remove(NETWORKS_KEY, buf)?;
159
160        info!("Removed all wireless networks from storage");
161
162        Ok(())
163    }
164
165    /// Load all networks from the provided BLOB store
166    ///
167    /// # Arguments
168    /// - `store`: the BLOB store to load the networks from
169    /// - `buf`: a temporary buffer to use for loading the networks
170    pub async fn load_persist<S: KvBlobStore>(
171        &mut self,
172        mut kv: S,
173        buf: &mut [u8],
174    ) -> Result<(), Error> {
175        self.reset();
176
177        if let Some(data) = kv.load(NETWORKS_KEY, buf)? {
178            self.load(data)?;
179
180            info!(
181                "Loaded {} wireless networks from storage",
182                self.networks.len()
183            );
184        }
185
186        Ok(())
187    }
188
189    /// Load the state from a byte slice.
190    ///
191    /// # Arguments
192    /// - `data`: The byte slice to load the state from
193    pub fn load(&mut self, data: &[u8]) -> Result<(), Error> {
194        let root = TLVElement::new(data);
195
196        self.networks.clear();
197
198        // Try new format: struct { ctx(0): networks array, ctx(1): managed bool }
199        // Fall back to old format: bare TLV array (with managed defaulting to false)
200        if let Ok(structure) = root.structure() {
201            for network in structure.ctx(0)?.array()?.iter() {
202                let network = network?;
203
204                self.networks.push_init(T::init_from_tlv(network), || {
205                    ErrorCode::ResourceExhausted.into()
206                })?;
207            }
208
209            self.managed = structure.ctx(1)?.bool()?;
210        } else {
211            for network in root.array()?.iter() {
212                let network = network?;
213
214                self.networks.push_init(T::init_from_tlv(network), || {
215                    ErrorCode::ResourceExhausted.into()
216                })?;
217            }
218
219            self.managed = false;
220        }
221
222        Ok(())
223    }
224
225    /// Store the state into a byte slice.
226    ///
227    /// # Arguments
228    /// - `buf`: The byte slice to store the state into
229    ///
230    /// Returns the number of bytes written into the buffer.
231    pub fn store(&self, buf: &mut [u8]) -> Result<usize, Error> {
232        let mut wb = WriteBuf::new(buf);
233
234        wb.start_struct(&TLVTag::Anonymous)?;
235
236        self.networks.to_tlv(&TLVTag::Context(0), &mut wb)?;
237        self.managed.to_tlv(&TLVTag::Context(1), &mut wb)?;
238
239        wb.end_container()?;
240
241        let tail = wb.get_tail();
242
243        Ok(tail)
244    }
245
246    /// Iterate over the registered network credentials
247    ///
248    /// # Arguments
249    /// - `f`: A closure that will be called for each network registered in the storage
250    pub fn networks<F>(&self, mut f: F) -> Result<(), Error>
251    where
252        F: FnMut(&T) -> Result<(), Error>,
253    {
254        for network in self.networks.iter() {
255            f(network)?;
256        }
257
258        Ok(())
259    }
260
261    /// Get the credentials of a network by its ID
262    ///
263    /// # Arguments
264    /// - `network_id`: The ID of the network to get
265    /// - `f`: A closure that will be called with the credentials of the network, if the network exists
266    ///
267    /// Returns the index of the network in the storage if the network exists, `NetworkError::NetworkIdNotFound` otherwise
268    pub fn network<F>(&self, network_id: &[u8], f: F) -> Result<u8, NetworksError>
269    where
270        F: FnOnce(&T) -> Result<(), Error>,
271    {
272        let networks = self
273            .networks
274            .iter()
275            .enumerate()
276            .find(|(_, network)| network.id() == network_id);
277
278        if let Some((index, network)) = networks {
279            f(network)?;
280
281            Ok(index as _)
282        } else {
283            Err(NetworksError::NetworkIdNotFound)
284        }
285    }
286
287    /// Get the next network credentials after the one with the given ID
288    ///
289    /// # Arguments
290    /// - `after_network_id`: The ID of the network to get the next one after.
291    ///   If no network with the provided network ID exists, the first network in the storage will be returned.
292    pub fn next_network<F>(&self, last_network_id: Option<&[u8]>, f: F) -> Result<bool, Error>
293    where
294        F: FnOnce(&T) -> Result<(), Error>,
295    {
296        if let Some(last_network_id) = last_network_id {
297            info!(
298                "Looking for network after the one with ID: {}",
299                T::display_id(last_network_id)
300            );
301
302            // Return the network positioned after the last one used
303
304            let mut networks = self.networks.iter();
305
306            for network in &mut networks {
307                if network.id() == last_network_id {
308                    break;
309                }
310            }
311
312            let network = networks.next();
313            if let Some(network) = network {
314                info!("Trying with next network - ID: {}", network.display());
315
316                f(network)?;
317                return Ok(true);
318            }
319        }
320
321        // Wrap over
322        info!("Wrapping over");
323
324        if let Some(network) = self.networks.first() {
325            info!("Trying with first network - ID: {}", network.display());
326
327            f(network)?;
328            Ok(true)
329        } else {
330            info!("No networks available");
331            Ok(false)
332        }
333    }
334
335    /// Add or update a network in the storage
336    ///
337    /// # Arguments
338    /// - `network_id`: The ID of the network to add or update
339    /// - `add`: An in-place initializer for the network to add. The initializer will be used only if a network with the provided
340    ///   network ID does not exist in the storage
341    /// - `update`: A closure that will be called with the network to update. The closure will be called only if a network with the provided
342    ///   network ID exists in the storage
343    pub fn add_or_update<A, U>(
344        &mut self,
345        network_id: &[u8],
346        add: A,
347        update: U,
348    ) -> Result<u8, NetworksError>
349    where
350        A: Init<T, Error>,
351        U: FnOnce(&mut T) -> Result<(), Error>,
352    {
353        let unetwork = self
354            .networks
355            .iter_mut()
356            .enumerate()
357            .find(|(_, unetwork)| unetwork.id() == network_id);
358
359        if let Some((index, unetwork)) = unetwork {
360            // Update
361            update(unetwork)?;
362
363            // A successful list mutation means network changes are now staged
364            // under the (armed) fail-safe: flip to unmanaged so the
365            // `WirelessMgr` stands down until the changes are either committed
366            // (`CommissioningComplete` -> `set_managed(true)`) or reverted
367            // (fail-safe expiry -> `load` of the persisted, committed state).
368            self.managed = false;
369
370            info!("Updated network with ID {}", unetwork.display());
371
372            Ok(index as _)
373        } else if self.networks.len() >= N {
374            warn!(
375                "Adding network with ID {} failed: too many",
376                T::display_id(network_id)
377            );
378
379            Err(NetworksError::BoundsExceeded)
380        } else {
381            // Add
382            self.networks
383                .push_init(add, || ErrorCode::ResourceExhausted.into())?;
384
385            // See the update branch above.
386            self.managed = false;
387
388            info!("Added network with ID {}", T::display_id(network_id));
389
390            Ok((self.networks.len() - 1) as _)
391        }
392    }
393
394    /// Reorder a network in the storage
395    ///
396    /// # Arguments
397    /// - `index`: The new index of the network
398    /// - `network_id`: The ID of the network to reorder
399    ///
400    /// Returns the new index of the network in the storage, if a network with the provided ID exists
401    /// or `NetworkError::NetworkIdNotFound` otherwise
402    pub fn reorder(&mut self, index: u8, network_id: &[u8]) -> Result<u8, NetworksError> {
403        let cur_index = self
404            .networks
405            .iter()
406            .position(|conf| conf.id() == network_id);
407
408        if let Some(cur_index) = cur_index {
409            // Found
410
411            if index < self.networks.len() as u8 {
412                let conf = self.networks.remove(cur_index);
413                unwrap!(self.networks.insert(index as usize, conf).map_err(|_| ()));
414
415                // Staged change - see `add_or_update`.
416                self.managed = false;
417
418                info!(
419                    "Network with ID {} reordered to index {}",
420                    T::display_id(network_id),
421                    index
422                );
423            } else {
424                warn!(
425                    "Reordering network with ID {} to index {} failed: out of range",
426                    T::display_id(network_id),
427                    index
428                );
429
430                Err(NetworksError::OutOfRange)?;
431            }
432        } else {
433            warn!("Network with ID {} not found", T::display_id(network_id));
434            Err(NetworksError::NetworkIdNotFound)?;
435        }
436
437        Ok(index)
438    }
439
440    /// Remove a network from the storage
441    ///
442    /// # Arguments
443    /// - `network_id`: The ID of the network to remove
444    ///
445    /// Returns the index of the network in the storage if the network exists and was removed, `NetworkError::NetworkIdNotFound` otherwise
446    pub fn remove(&mut self, network_id: &[u8]) -> Result<u8, NetworksError> {
447        let index = self
448            .networks
449            .iter()
450            .position(|conf| conf.id() == network_id);
451
452        if let Some(index) = index {
453            // Found
454            self.networks.remove(index);
455
456            // Staged change - see `add_or_update`.
457            self.managed = false;
458
459            info!("Removed network with ID {}", T::display_id(network_id));
460
461            Ok(index as _)
462        } else {
463            warn!("Network with ID {} not found", T::display_id(network_id));
464
465            Err(NetworksError::NetworkIdNotFound)
466        }
467    }
468
469    pub fn managed(&self) -> bool {
470        self.managed
471    }
472
473    pub fn set_managed(&mut self, managed: bool) {
474        self.managed = managed;
475    }
476}
477
478impl<const N: usize, T> Networks for WirelessNetworks<N, T>
479where
480    T: WirelessNetwork,
481{
482    fn max_networks(&self) -> Result<u8, Error> {
483        Ok(N as _)
484    }
485
486    fn networks(&self, f: &mut dyn FnMut(&[u8]) -> Result<(), Error>) -> Result<(), Error> {
487        WirelessNetworks::networks(self, |network| f(network.id()))
488    }
489
490    fn creds(
491        &self,
492        network_id: &[u8],
493        f: &mut dyn FnMut(&net_comm::WirelessCreds) -> Result<(), Error>,
494    ) -> Result<u8, NetworksError> {
495        WirelessNetworks::network(self, network_id, |network| f(&network.creds()))
496    }
497
498    fn next_creds(
499        &self,
500        last_network_id: Option<&[u8]>,
501        f: &mut dyn FnMut(&WirelessCreds) -> Result<(), Error>,
502    ) -> Result<bool, Error> {
503        WirelessNetworks::next_network(self, last_network_id, |network| f(&network.creds()))
504    }
505
506    fn enabled(&self) -> Result<bool, Error> {
507        Ok(true)
508    }
509
510    fn set_enabled(&mut self, _enabled: bool) -> Result<(), Error> {
511        Ok(())
512    }
513
514    fn add_or_update(
515        &mut self,
516        creds: &net_comm::WirelessCreds<'_>,
517    ) -> Result<u8, net_comm::NetworksError> {
518        WirelessNetworks::add_or_update(self, creds.id()?, T::init_from(creds), |network| {
519            network.update(creds)
520        })
521    }
522
523    fn reorder(&mut self, index: u8, network_id: &[u8]) -> Result<u8, NetworksError> {
524        WirelessNetworks::reorder(self, index, network_id)
525    }
526
527    fn remove(&mut self, network_id: &[u8]) -> Result<u8, NetworksError> {
528        WirelessNetworks::remove(self, network_id)
529    }
530
531    fn managed(&self) -> Result<bool, Error> {
532        Ok(self.managed())
533    }
534
535    fn set_managed(&mut self, managed: bool) -> Result<(), Error> {
536        WirelessNetworks::set_managed(self, managed);
537
538        Ok(())
539    }
540
541    fn reset(&mut self) -> Result<(), Error> {
542        WirelessNetworks::reset(self);
543
544        Ok(())
545    }
546
547    fn load(&mut self, data: &[u8]) -> Result<(), Error> {
548        WirelessNetworks::load(self, data)
549    }
550
551    fn save(&self, buf: &mut [u8]) -> Result<Option<usize>, Error> {
552        WirelessNetworks::store(self, buf).map(Some)
553    }
554}
555
556/// An enum capable of displaying a network ID in a human-readable format.
557#[derive(Debug)]
558enum DisplayId<'a> {
559    Wifi(&'a [u8]),
560    Thread(&'a [u8]),
561}
562
563impl Display for DisplayId<'_> {
564    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
565        match self {
566            DisplayId::Wifi(id) => {
567                if let Ok(str) = core::str::from_utf8(id) {
568                    write!(f, "Wifi SSID({})", str)
569                } else {
570                    write!(f, "Wifi SSID({:?})", Bytes(id))
571                }
572            }
573            DisplayId::Thread(id) => write!(f, "Thread ExtPanID({:?})", Bytes(id)),
574        }
575    }
576}
577
578#[cfg(feature = "defmt")]
579impl defmt::Format for DisplayId<'_> {
580    fn format(&self, fmt: defmt::Formatter) {
581        match self {
582            DisplayId::Wifi(id) => {
583                if let Ok(str) = core::str::from_utf8(id) {
584                    defmt::write!(fmt, "Wifi SSID({})", str)
585                } else {
586                    defmt::write!(fmt, "Wifi SSID({:?})", Bytes(id))
587                }
588            }
589            DisplayId::Thread(id) => defmt::write!(fmt, "Thread ExtPanID({:?})", Bytes(id)),
590        }
591    }
592}
593
594/// A no-op implementation of the `net_comm::NetCtl` trait suitable when non-concurrent provisioning over BTP is used.
595///
596/// This implementation will throw `NetworkError::Other(ErrorCode::InvalidAction)` for the `scan` method
597/// and will silently return `Ok(())` for the `connect` method, which is meeting the non-concurrent provisioning expectations.
598pub struct NoopWirelessNetCtl(NetworkType);
599
600impl NoopWirelessNetCtl {
601    /// Create a new instance of `NoopWirelessNetCtl` for the provided network type.
602    ///
603    /// Note that it does not make any sense to use `NetworkType::Ethernet` here, as the Ethernet
604    /// network controller should return errors for both `scan` and `connect` methods.
605    ///
606    /// For Ethernet networks, use `EthNetctl` instead.
607    pub const fn new(net_type: NetworkType) -> Self {
608        Self(net_type)
609    }
610}
611
612impl net_comm::NetCtl for NoopWirelessNetCtl {
613    fn net_type(&self) -> NetworkType {
614        self.0
615    }
616
617    async fn scan<F>(&self, _network: Option<&[u8]>, _f: F) -> Result<(), NetCtlError>
618    where
619        F: FnOnce(&net_comm::NetworkScanInfo) -> Result<(), Error>,
620    {
621        Err(NetCtlError::Other(ErrorCode::InvalidAction.into()))
622    }
623
624    async fn connect(&self, creds: &WirelessCreds<'_>) -> Result<(), NetCtlError> {
625        Ok(creds.check_match(self.0)?)
626    }
627}
628
629impl NetChangeNotif for NoopWirelessNetCtl {
630    async fn wait_changed(&self) {
631        core::future::pending().await
632    }
633}
634
635impl net_comm::NetCtlStatus for NoopWirelessNetCtl {
636    fn last_networking_status(
637        &self,
638    ) -> Result<Option<net_comm::NetworkCommissioningStatusEnum>, Error> {
639        Ok(None)
640    }
641
642    fn last_network_id<F, R>(&self, f: F) -> Result<R, Error>
643    where
644        F: FnOnce(Option<&[u8]>) -> Result<R, Error>,
645    {
646        f(None)
647    }
648
649    fn last_connect_error_value(&self) -> Result<Option<i32>, Error> {
650        Ok(None)
651    }
652}
653
654impl DynBase for NoopWirelessNetCtl {}
655
656impl wifi_diag::WirelessDiag for NoopWirelessNetCtl {}
657
658impl wifi_diag::WifiDiag for NoopWirelessNetCtl {}
659
660impl thread_diag::ThreadDiag for NoopWirelessNetCtl {}
661
662/// A type holding the status of the last `connect` or `scan` operation for the `NetCtlWithStatus` `NetCtl` + `NetCtlStatus` implementation.
663pub struct NetCtlState {
664    /// The network ID used in the last scan or connect operation
665    pub network_id: OwnedWirelessNetworkId,
666    /// The status of the last scan or connect operation
667    pub networking_status: Option<NetworkCommissioningStatusEnum>,
668    /// The error code of the last scan or connect operation.
669    /// If the last operation was scan, this value is `None`.
670    pub connect_error_value: Option<i32>,
671}
672
673impl NetCtlState {
674    /// Create a new, empty instance of `NetCtlState`.
675    pub const fn new() -> Self {
676        Self {
677            network_id: OwnedWirelessNetworkId::new(),
678            networking_status: None,
679            connect_error_value: None,
680        }
681    }
682
683    /// Return an in-place initializer for a new, empty `NetCtlState`.
684    pub fn init() -> impl Init<Self> {
685        init!(Self {
686            network_id <- OwnedWirelessNetworkId::init(),
687            networking_status: None,
688            connect_error_value: None,
689        })
690    }
691
692    /// Create a new, empty instance of `NetCtlState` wrapped in a mutex.
693    pub const fn new_with_mutex() -> NetCtlStateMutex {
694        blocking::Mutex::new(RefCell::new(Self::new()))
695    }
696
697    /// Return an in-place initializer for a new, empty `NetCtlState` wrapped in a mutex.
698    pub fn init_with_mutex() -> impl Init<NetCtlStateMutex> {
699        blocking::Mutex::init(RefCell::init(init!(Self {
700            network_id <- OwnedWirelessNetworkId::init(),
701            networking_status: None,
702            connect_error_value: None,
703        })))
704    }
705
706    /// Return `true` if the network ID is set and the last operation was successful.
707    pub fn is_prov_ready(&self) -> bool {
708        !self.network_id.is_empty()
709            && matches!(
710                self.networking_status,
711                Some(NetworkCommissioningStatusEnum::Success)
712            )
713            && self.connect_error_value.is_none()
714    }
715
716    /// Update the state with the provided network ID and result of the last operation.
717    ///
718    /// Return the result of the last operation.
719    pub fn update<R>(
720        &mut self,
721        network_id: Option<&[u8]>,
722        result: Result<R, NetCtlError>,
723    ) -> Result<R, NetCtlError> {
724        self.network_id.clear();
725
726        if let Some(network_id) = network_id {
727            unwrap!(self.network_id.extend_from_slice(network_id));
728        }
729
730        if let Some((status, err_code)) = NetworkCommissioningStatusEnum::map_ctl_status(&result) {
731            self.networking_status = Some(status);
732            self.connect_error_value = err_code;
733        } else {
734            self.networking_status = None;
735            self.connect_error_value = None;
736        }
737
738        result
739    }
740
741    /// Update the state with the provided network ID and result of the last operation.
742    ///
743    /// Return the result of the last operation.
744    pub fn update_with_mutex<R>(
745        state: &NetCtlStateMutex,
746        network_id: Option<&[u8]>,
747        result: Result<R, NetCtlError>,
748    ) -> Result<R, NetCtlError> {
749        state.lock(|state| state.borrow_mut().update(network_id, result))
750    }
751
752    /// A utility to wait for provisioning over BTP to be ready.
753    /// Provisioning over BTP is considered complete when there is no longer an active connection
754    /// and the network ID is set (i.e. method `NetCtl::connect` was called successfully).
755    ///
756    /// This method is only useful for non-concurrent commisioning using wireless networks and BLE,
757    /// and is likely to be used together with `NoopWirelessNetCtl`.
758    pub async fn wait_prov_ready(state: &NetCtlStateMutex, _btp: &Btp) {
759        while !state.lock(|state| state.borrow().is_prov_ready()) {
760            // Provisioning over BTP is considered complete when there is no longer an active connection
761            // and the network ID is set (i.e. method `NetCtl::connect` was called successfully)
762
763            embassy_time::Timer::after_secs(1).await;
764        }
765    }
766}
767
768impl Default for NetCtlState {
769    fn default() -> Self {
770        Self::new()
771    }
772}
773
774/// A type alias for a `NetCtlState` instance wrapped in a mutex.
775pub type NetCtlStateMutex = blocking::Mutex<RefCell<NetCtlState>>;
776
777/// A wrapper around a `NetCtl` network controller that additionally implements the `NetCtlStatus`trait.
778pub struct NetCtlWithStatusImpl<'a, T> {
779    state: &'a NetCtlStateMutex,
780    net_ctl: T,
781}
782
783impl<'a, T> NetCtlWithStatusImpl<'a, T> {
784    /// Create a new instance of `NetCtlWithStatusImpl`.
785    ///
786    /// # Arguments
787    /// - `state`: A reference to a `NetCtlState` instance wrapped in a mutex
788    /// - `net_ctl`: A network controller that implements the `NetCtl` trait
789    pub const fn new(state: &'a NetCtlStateMutex, net_ctl: T) -> Self {
790        Self { state, net_ctl }
791    }
792}
793
794impl<T> net_comm::NetCtl for NetCtlWithStatusImpl<'_, T>
795where
796    T: net_comm::NetCtl,
797{
798    fn net_type(&self) -> NetworkType {
799        self.net_ctl.net_type()
800    }
801
802    fn connect_max_time_seconds(&self) -> u8 {
803        self.net_ctl.connect_max_time_seconds()
804    }
805
806    fn scan_max_time_seconds(&self) -> u8 {
807        self.net_ctl.scan_max_time_seconds()
808    }
809
810    fn supported_wifi_bands<F>(&self, f: F) -> Result<(), Error>
811    where
812        F: FnMut(net_comm::WiFiBandEnum) -> Result<(), Error>,
813    {
814        self.net_ctl.supported_wifi_bands(f)
815    }
816
817    fn supported_thread_features(&self) -> ThreadCapabilitiesBitmap {
818        self.net_ctl.supported_thread_features()
819    }
820
821    fn thread_version(&self) -> u16 {
822        self.net_ctl.thread_version()
823    }
824
825    async fn scan<F>(&self, network: Option<&[u8]>, f: F) -> Result<(), NetCtlError>
826    where
827        F: FnMut(&net_comm::NetworkScanInfo) -> Result<(), Error>,
828    {
829        self.net_ctl.scan(network, f).await
830    }
831
832    async fn connect(&self, creds: &WirelessCreds<'_>) -> Result<(), NetCtlError> {
833        NetCtlState::update_with_mutex(
834            self.state,
835            Some(creds.id()?),
836            self.net_ctl.connect(creds).await,
837        )
838    }
839}
840
841impl<T> net_comm::NetCtlStatus for NetCtlWithStatusImpl<'_, T>
842where
843    T: net_comm::NetCtl,
844{
845    fn last_networking_status(&self) -> Result<Option<NetworkCommissioningStatusEnum>, Error> {
846        Ok(self.state.lock(|state| state.borrow().networking_status))
847    }
848
849    fn last_network_id<F, R>(&self, f: F) -> Result<R, Error>
850    where
851        F: FnOnce(Option<&[u8]>) -> Result<R, Error>,
852    {
853        self.state.lock(|state| {
854            let state = state.borrow();
855
856            if state.network_id.is_empty() {
857                f(None)
858            } else {
859                f(Some(&state.network_id))
860            }
861        })
862    }
863
864    fn last_connect_error_value(&self) -> Result<Option<i32>, Error> {
865        Ok(self.state.lock(|state| state.borrow().connect_error_value))
866    }
867}
868
869impl<T> NetChangeNotif for NetCtlWithStatusImpl<'_, T>
870where
871    T: NetChangeNotif,
872{
873    async fn wait_changed(&self) {
874        self.net_ctl.wait_changed().await
875    }
876}
877
878#[cfg(feature = "sync-mutex")]
879impl<T> DynBase for NetCtlWithStatusImpl<'_, T> where T: Send + Sync {}
880
881#[cfg(not(feature = "sync-mutex"))]
882impl<T> DynBase for NetCtlWithStatusImpl<'_, T> {}
883
884impl<T> wifi_diag::WirelessDiag for NetCtlWithStatusImpl<'_, T>
885where
886    T: wifi_diag::WirelessDiag,
887{
888    fn connected(&self) -> Result<bool, Error> {
889        self.net_ctl.connected()
890    }
891}
892
893impl<T> wifi_diag::WifiDiag for NetCtlWithStatusImpl<'_, T>
894where
895    T: wifi_diag::WifiDiag,
896{
897    fn bssid(&self, f: &mut dyn FnMut(Option<&[u8]>) -> Result<(), Error>) -> Result<(), Error> {
898        self.net_ctl.bssid(f)
899    }
900
901    fn security_type(&self) -> Result<crate::tlv::Nullable<wifi_diag::SecurityTypeEnum>, Error> {
902        self.net_ctl.security_type()
903    }
904
905    fn wi_fi_version(&self) -> Result<crate::tlv::Nullable<wifi_diag::WiFiVersionEnum>, Error> {
906        self.net_ctl.wi_fi_version()
907    }
908
909    fn channel_number(&self) -> Result<crate::tlv::Nullable<u16>, Error> {
910        self.net_ctl.channel_number()
911    }
912
913    fn rssi(&self) -> Result<crate::tlv::Nullable<i8>, Error> {
914        self.net_ctl.rssi()
915    }
916}
917
918impl<T> thread_diag::ThreadDiag for NetCtlWithStatusImpl<'_, T>
919where
920    T: thread_diag::ThreadDiag,
921{
922    fn channel(&self) -> Result<Option<u16>, Error> {
923        self.net_ctl.channel()
924    }
925
926    fn routing_role(&self) -> Result<Option<thread_diag::RoutingRoleEnum>, Error> {
927        self.net_ctl.routing_role()
928    }
929
930    fn network_name(
931        &self,
932        f: &mut dyn FnMut(Option<&str>) -> Result<(), Error>,
933    ) -> Result<(), Error> {
934        self.net_ctl.network_name(f)
935    }
936
937    fn pan_id(&self) -> Result<Option<u16>, Error> {
938        self.net_ctl.pan_id()
939    }
940
941    fn extended_pan_id(&self) -> Result<Option<u64>, Error> {
942        self.net_ctl.extended_pan_id()
943    }
944
945    fn mesh_local_prefix(
946        &self,
947        f: &mut dyn FnMut(Option<&[u8]>) -> Result<(), Error>,
948    ) -> Result<(), Error> {
949        self.net_ctl.mesh_local_prefix(f)
950    }
951
952    fn neighbor_table(
953        &self,
954        f: &mut dyn FnMut(&thread_diag::NeighborTable) -> Result<(), Error>,
955    ) -> Result<(), Error> {
956        self.net_ctl.neighbor_table(f)
957    }
958
959    fn route_table(
960        &self,
961        f: &mut dyn FnMut(&thread_diag::RouteTable) -> Result<(), Error>,
962    ) -> Result<(), Error> {
963        self.net_ctl.route_table(f)
964    }
965
966    fn partition_id(&self) -> Result<Option<u32>, Error> {
967        self.net_ctl.partition_id()
968    }
969
970    fn weighting(&self) -> Result<Option<u16>, Error> {
971        self.net_ctl.weighting()
972    }
973
974    fn data_version(&self) -> Result<Option<u16>, Error> {
975        self.net_ctl.data_version()
976    }
977
978    fn stable_data_version(&self) -> Result<Option<u16>, Error> {
979        self.net_ctl.stable_data_version()
980    }
981
982    fn leader_router_id(&self) -> Result<Option<u8>, Error> {
983        self.net_ctl.leader_router_id()
984    }
985
986    fn security_policy(&self) -> Result<Option<thread_diag::SecurityPolicy>, Error> {
987        self.net_ctl.security_policy()
988    }
989
990    fn channel_page0_mask(
991        &self,
992        f: &mut dyn FnMut(Option<&[u8]>) -> Result<(), Error>,
993    ) -> Result<(), Error> {
994        self.net_ctl.channel_page0_mask(f)
995    }
996
997    fn operational_dataset_components(
998        &self,
999        f: &mut dyn FnMut(Option<&thread_diag::OperationalDatasetComponents>) -> Result<(), Error>,
1000    ) -> Result<(), Error> {
1001        self.net_ctl.operational_dataset_components(f)
1002    }
1003
1004    fn active_network_faults_list(
1005        &self,
1006        f: &mut dyn FnMut(thread_diag::NetworkFaultEnum) -> Result<(), Error>,
1007    ) -> Result<(), Error> {
1008        self.net_ctl.active_network_faults_list(f)
1009    }
1010
1011    fn mac_counters(
1012        &self,
1013        f: &mut dyn FnMut(Option<&thread_diag::MacCounters>) -> Result<(), Error>,
1014    ) -> Result<(), Error> {
1015        self.net_ctl.mac_counters(f)
1016    }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use crate::dm::clusters::net_comm::{
1022        NetworksAccess, NetworksError, SharedNetworks, WirelessCreds,
1023    };
1024
1025    use super::wifi::{Wifi, WifiNetworks};
1026    use super::WirelessNetwork;
1027
1028    // ── Helper ──
1029
1030    fn wifi_creds<'a>(ssid: &'a [u8], pass: &'a [u8]) -> WirelessCreds<'a> {
1031        WirelessCreds::Wifi { ssid, pass }
1032    }
1033
1034    fn collect_ssids(nets: &WifiNetworks<4>) -> Vec<Vec<u8>> {
1035        let mut ids = Vec::new();
1036        nets.networks(|n| {
1037            ids.push(n.id().to_vec());
1038            Ok(())
1039        })
1040        .unwrap();
1041        ids
1042    }
1043
1044    // ── WirelessNetworks: add / update / remove ──
1045
1046    #[test]
1047    fn add_networks() {
1048        let mut nets = WifiNetworks::<4>::new();
1049
1050        let idx = nets
1051            .add_or_update(b"A", Wifi::init_from(&wifi_creds(b"A", b"PassA")), |_| {
1052                Ok(())
1053            })
1054            .unwrap();
1055        assert_eq!(idx, 0);
1056
1057        let idx = nets
1058            .add_or_update(b"B", Wifi::init_from(&wifi_creds(b"B", b"PassB")), |_| {
1059                Ok(())
1060            })
1061            .unwrap();
1062        assert_eq!(idx, 1);
1063
1064        assert_eq!(collect_ssids(&nets), vec![b"A".to_vec(), b"B".to_vec()]);
1065    }
1066
1067    #[test]
1068    fn update_existing_network() {
1069        let mut nets = WifiNetworks::<4>::new();
1070        nets.add_or_update(b"A", Wifi::init_from(&wifi_creds(b"A", b"Old")), |_| Ok(()))
1071            .unwrap();
1072
1073        // Update: same SSID, new password
1074        nets.add_or_update(b"A", Wifi::init_from(&wifi_creds(b"A", b"New")), |wifi| {
1075            wifi.update(&wifi_creds(b"A", b"New"))
1076        })
1077        .unwrap();
1078
1079        // Still one network
1080        assert_eq!(collect_ssids(&nets).len(), 1);
1081
1082        // Verify updated password via creds
1083        let mut pass = Vec::new();
1084        nets.network(b"A", |w| {
1085            if let WirelessCreds::Wifi { pass: p, .. } = w.creds() {
1086                pass.extend_from_slice(p);
1087            }
1088            Ok(())
1089        })
1090        .unwrap();
1091        assert_eq!(pass, b"New");
1092    }
1093
1094    #[test]
1095    fn add_exceeds_capacity() {
1096        let mut nets = WifiNetworks::<2>::new();
1097        nets.add_or_update(b"A", Wifi::init_from(&wifi_creds(b"A", b"p")), |_| Ok(()))
1098            .unwrap();
1099        nets.add_or_update(b"B", Wifi::init_from(&wifi_creds(b"B", b"p")), |_| Ok(()))
1100            .unwrap();
1101
1102        let err = nets.add_or_update(b"C", Wifi::init_from(&wifi_creds(b"C", b"p")), |_| Ok(()));
1103        assert!(matches!(err, Err(NetworksError::BoundsExceeded)));
1104    }
1105
1106    #[test]
1107    fn remove_network() {
1108        let mut nets = WifiNetworks::<4>::new();
1109        nets.add_or_update(b"A", Wifi::init_from(&wifi_creds(b"A", b"p")), |_| Ok(()))
1110            .unwrap();
1111        nets.add_or_update(b"B", Wifi::init_from(&wifi_creds(b"B", b"p")), |_| Ok(()))
1112            .unwrap();
1113
1114        let idx = nets.remove(b"A").unwrap();
1115        assert_eq!(idx, 0);
1116        assert_eq!(collect_ssids(&nets), vec![b"B".to_vec()]);
1117    }
1118
1119    #[test]
1120    fn remove_nonexistent() {
1121        let mut nets = WifiNetworks::<4>::new();
1122        assert!(matches!(
1123            nets.remove(b"X"),
1124            Err(NetworksError::NetworkIdNotFound)
1125        ));
1126    }
1127
1128    // ── WirelessNetworks: reorder ──
1129
1130    #[test]
1131    fn reorder_moves_to_front() {
1132        let mut nets = WifiNetworks::<4>::new();
1133        for id in [b"A", b"B", b"C"] {
1134            nets.add_or_update(
1135                id.as_slice(),
1136                Wifi::init_from(&wifi_creds(id, b"p")),
1137                |_| Ok(()),
1138            )
1139            .unwrap();
1140        }
1141
1142        // Move C (index 2) to index 0
1143        nets.reorder(0, b"C").unwrap();
1144        assert_eq!(
1145            collect_ssids(&nets),
1146            vec![b"C".to_vec(), b"A".to_vec(), b"B".to_vec()]
1147        );
1148    }
1149
1150    #[test]
1151    fn reorder_out_of_range() {
1152        let mut nets = WifiNetworks::<4>::new();
1153        nets.add_or_update(b"A", Wifi::init_from(&wifi_creds(b"A", b"p")), |_| Ok(()))
1154            .unwrap();
1155
1156        assert!(matches!(
1157            nets.reorder(5, b"A"),
1158            Err(NetworksError::OutOfRange)
1159        ));
1160    }
1161
1162    #[test]
1163    fn reorder_nonexistent() {
1164        let mut nets = WifiNetworks::<4>::new();
1165        assert!(matches!(
1166            nets.reorder(0, b"X"),
1167            Err(NetworksError::NetworkIdNotFound)
1168        ));
1169    }
1170
1171    // ── WirelessNetworks: next_network round-robin ──
1172
1173    #[test]
1174    fn next_network_iterates_and_wraps() {
1175        let mut nets = WifiNetworks::<4>::new();
1176        for id in [b"A", b"B", b"C"] {
1177            nets.add_or_update(
1178                id.as_slice(),
1179                Wifi::init_from(&wifi_creds(id, b"p")),
1180                |_| Ok(()),
1181            )
1182            .unwrap();
1183        }
1184
1185        let get_next = |last: Option<&[u8]>| -> Option<Vec<u8>> {
1186            let mut result = None;
1187            let found = nets
1188                .next_network(last, |w| {
1189                    result = Some(w.id().to_vec());
1190                    Ok(())
1191                })
1192                .unwrap();
1193            if found {
1194                result
1195            } else {
1196                None
1197            }
1198        };
1199
1200        assert_eq!(get_next(None), Some(b"A".to_vec()));
1201        assert_eq!(get_next(Some(b"A")), Some(b"B".to_vec()));
1202        assert_eq!(get_next(Some(b"B")), Some(b"C".to_vec()));
1203        // After last → wraps to first
1204        assert_eq!(get_next(Some(b"C")), Some(b"A".to_vec()));
1205        // Unknown ID → first
1206        assert_eq!(get_next(Some(b"Z")), Some(b"A".to_vec()));
1207    }
1208
1209    #[test]
1210    fn next_network_empty_returns_false() {
1211        let nets = WifiNetworks::<4>::new();
1212        let found = nets.next_network(None, |_| Ok(())).unwrap();
1213        assert!(!found);
1214    }
1215
1216    // ── WirelessNetworks: store / load round-trip ──
1217
1218    #[test]
1219    fn store_load_round_trip() {
1220        let mut nets = WifiNetworks::<4>::new();
1221        nets.add_or_update(
1222            b"Net1",
1223            Wifi::init_from(&wifi_creds(b"Net1", b"P1")),
1224            |_| Ok(()),
1225        )
1226        .unwrap();
1227        nets.add_or_update(
1228            b"Net2",
1229            Wifi::init_from(&wifi_creds(b"Net2", b"P2")),
1230            |_| Ok(()),
1231        )
1232        .unwrap();
1233        nets.set_managed(true);
1234
1235        let mut buf = [0u8; 512];
1236        let len = nets.store(&mut buf).unwrap();
1237
1238        let mut loaded = WifiNetworks::<4>::new();
1239        loaded.load(&buf[..len]).unwrap();
1240
1241        assert_eq!(collect_ssids(&loaded), collect_ssids(&nets));
1242        assert!(loaded.managed());
1243    }
1244
1245    // ── WirelessNetworks: managed state ──
1246
1247    #[test]
1248    fn managed_default_false() {
1249        let nets = WifiNetworks::<4>::new();
1250        assert!(!nets.managed());
1251    }
1252
1253    #[test]
1254    fn set_managed() {
1255        let mut nets = WifiNetworks::<4>::new();
1256        nets.set_managed(true);
1257        assert!(nets.managed());
1258        nets.set_managed(false);
1259        assert!(!nets.managed());
1260    }
1261
1262    #[test]
1263    fn mutations_clear_managed() {
1264        let mut nets = WifiNetworks::<4>::new();
1265
1266        // A successful add stages a change
1267        nets.set_managed(true);
1268        nets.add_or_update(b"A", Wifi::init_from(&wifi_creds(b"A", b"p")), |_| Ok(()))
1269            .unwrap();
1270        assert!(!nets.managed());
1271
1272        // ... as does a successful update ...
1273        nets.set_managed(true);
1274        nets.add_or_update(b"A", Wifi::init_from(&wifi_creds(b"A", b"q")), |network| {
1275            network.update(&wifi_creds(b"A", b"q"))
1276        })
1277        .unwrap();
1278        assert!(!nets.managed());
1279
1280        // ... a successful reorder ...
1281        nets.add_or_update(b"B", Wifi::init_from(&wifi_creds(b"B", b"p")), |_| Ok(()))
1282            .unwrap();
1283        nets.set_managed(true);
1284        nets.reorder(0, b"B").unwrap();
1285        assert!(!nets.managed());
1286
1287        // ... and a successful remove.
1288        nets.set_managed(true);
1289        nets.remove(b"B").unwrap();
1290        assert!(!nets.managed());
1291
1292        // A FAILED operation stages nothing and leaves the state alone.
1293        nets.set_managed(true);
1294        assert!(nets.remove(b"NOSUCH").is_err());
1295        assert!(nets.reorder(42, b"A").is_err());
1296        assert!(nets.managed());
1297    }
1298
1299    // ── WirelessNetworks: reset ──
1300
1301    #[test]
1302    fn reset_clears_all() {
1303        let mut nets = WifiNetworks::<4>::new();
1304        nets.add_or_update(b"A", Wifi::init_from(&wifi_creds(b"A", b"p")), |_| Ok(()))
1305            .unwrap();
1306        nets.set_managed(true);
1307
1308        nets.reset();
1309        assert!(collect_ssids(&nets).is_empty());
1310        assert!(!nets.managed());
1311    }
1312
1313    // ── SharedNetworks: delegates to inner WifiNetworks correctly ──
1314
1315    #[test]
1316    fn shared_networks_access_add_and_read() {
1317        let shared = SharedNetworks::new(WifiNetworks::<4>::new());
1318
1319        shared.access(|networks| {
1320            networks
1321                .add_or_update(&wifi_creds(b"SSID1", b"pass1"))
1322                .unwrap();
1323            networks
1324                .add_or_update(&wifi_creds(b"SSID2", b"pass2"))
1325                .unwrap();
1326        });
1327
1328        // Read back via Networks trait
1329        let count = shared.access(|networks| {
1330            let mut count = 0u8;
1331            networks
1332                .networks(&mut |_info| {
1333                    count += 1;
1334                    Ok(())
1335                })
1336                .unwrap();
1337            count
1338        });
1339
1340        assert_eq!(count, 2);
1341    }
1342
1343    #[test]
1344    fn shared_networks_commissioned_via_access() {
1345        let shared = SharedNetworks::new(WifiNetworks::<4>::new());
1346
1347        let managed = shared.access(|networks| networks.managed().unwrap());
1348        assert!(!managed);
1349
1350        shared.access(|networks| networks.set_managed(true).unwrap());
1351
1352        let managed = shared.access(|networks| networks.managed().unwrap());
1353        assert!(managed);
1354    }
1355
1356    #[test]
1357    fn shared_networks_next_creds_round_robin() {
1358        let shared = SharedNetworks::new(WifiNetworks::<4>::new());
1359
1360        shared.access(|networks| {
1361            for (ssid, pass) in [(b"A", b"pA"), (b"B", b"pB"), (b"C", b"pC")] {
1362                networks
1363                    .add_or_update(&wifi_creds(ssid.as_slice(), pass.as_slice()))
1364                    .unwrap();
1365            }
1366        });
1367
1368        let get_next_ssid = |last: Option<&[u8]>| -> Option<Vec<u8>> {
1369            shared.access(|networks| {
1370                let mut result = None;
1371                let found = networks
1372                    .next_creds(last, &mut |creds| {
1373                        if let WirelessCreds::Wifi { ssid, .. } = creds {
1374                            result = Some(ssid.to_vec());
1375                        }
1376                        Ok(())
1377                    })
1378                    .unwrap();
1379                if found {
1380                    result
1381                } else {
1382                    None
1383                }
1384            })
1385        };
1386
1387        assert_eq!(get_next_ssid(None), Some(b"A".to_vec()));
1388        assert_eq!(get_next_ssid(Some(b"A")), Some(b"B".to_vec()));
1389        assert_eq!(get_next_ssid(Some(b"C")), Some(b"A".to_vec()));
1390    }
1391
1392    // ── Regression: load old-format (bare TLV array, no commissioned field) ──
1393
1394    #[test]
1395    fn load_old_format_bare_array() {
1396        use crate::tlv::{TLVTag, TLVWrite};
1397        use crate::utils::storage::WriteBuf;
1398
1399        // Build old-format TLV data: bare anonymous array of Wifi structs.
1400        // Before the `commissioned` field was added, `store` just serialized the
1401        // networks Vec directly (without wrapping in a struct).
1402        let mut buf = [0u8; 512];
1403        let mut wb = WriteBuf::new(&mut buf);
1404
1405        wb.start_array(&TLVTag::Anonymous).unwrap();
1406
1407        // Wifi entry "A" with password "pA"
1408        wb.start_struct(&TLVTag::Anonymous).unwrap();
1409        wb.str(&TLVTag::Context(0), b"A").unwrap();
1410        wb.str(&TLVTag::Context(1), b"pA").unwrap();
1411        wb.end_container().unwrap();
1412
1413        // Wifi entry "B" with password "pB"
1414        wb.start_struct(&TLVTag::Anonymous).unwrap();
1415        wb.str(&TLVTag::Context(0), b"B").unwrap();
1416        wb.str(&TLVTag::Context(1), b"pB").unwrap();
1417        wb.end_container().unwrap();
1418
1419        wb.end_container().unwrap();
1420        let len = wb.get_tail();
1421
1422        // Load using new code (should handle old format gracefully)
1423        let mut nets = WifiNetworks::<4>::new();
1424        nets.load(&buf[..len]).unwrap();
1425
1426        assert_eq!(collect_ssids(&nets), vec![b"A".to_vec(), b"B".to_vec()]);
1427        assert!(
1428            !nets.managed(),
1429            "Old format should default managed to false"
1430        );
1431    }
1432
1433    // ── Regression: save() must not trigger a change notification ──
1434
1435    #[test]
1436    fn shared_networks_save_does_not_trigger_change() {
1437        use core::pin::pin;
1438        use embassy_futures::select::{select, Either};
1439
1440        let shared = SharedNetworks::new(WifiNetworks::<4>::new());
1441
1442        // Add a network → triggers notification
1443        shared.access(|n| n.add_or_update(&wifi_creds(b"A", b"p")).unwrap());
1444
1445        // Consume the notification
1446        embassy_futures::block_on(shared.wait_state_changed());
1447
1448        // Save (should NOT trigger notification)
1449        shared.access(|n| {
1450            let mut buf = [0u8; 512];
1451            n.save(&mut buf).unwrap();
1452        });
1453
1454        // Check: wait_state_changed should NOT be ready.
1455        // `select` is biased towards the first future, so if the notification was
1456        // triggered, it would resolve first. `ready(())` resolves immediately so
1457        // if the notification was NOT triggered, the second branch wins.
1458        let notified = embassy_futures::block_on(async {
1459            match select(
1460                pin!(shared.wait_state_changed()),
1461                pin!(core::future::ready(())),
1462            )
1463            .await
1464            {
1465                Either::First(_) => true,
1466                Either::Second(_) => false,
1467            }
1468        });
1469
1470        assert!(!notified, "save() must not trigger change notification");
1471    }
1472}