Skip to main content

rs_matter/dm/clusters/
ota_req.rs

1/*
2 *
3 *    Copyright (c) 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//! The OTA Software Update Requestor cluster.
19//!
20//! The device hosts the cluster (server role) so an Administrator can configure
21//! its provider list and observe its update state. Rather than a ready-made
22//! update loop, this exposes the building blocks and leaves the policy (when to
23//! check, how to download and apply) to the application:
24//!
25//! - [`Providers`] keeps the persistent, fabric-scoped `DefaultOTAProviders` list
26//!   (at most one entry per fabric) plus a transient cache of providers learned
27//!   via `AnnounceOTAProvider`; [`Providers::wait_changed`] lets the app react.
28//! - [`Provider::query`] asks one provider whether a newer image is available.
29//! - [`OtaState`] holds the reported update state; [`OtaState::initiate_update`]
30//!   is an RAII session the app uses to report progress.
31//! - [`parse_bdx_url`] turns a `bdx://` image URI into a `(node, file-designator)`
32//!   pair for a BDX download via [`Exchange::download`](crate::bdx::BdxDownloadInitiator::download).
33
34use core::num::NonZeroU8;
35
36use crate::crypto::Crypto;
37use crate::dm::{
38    ArrayAttributeRead, ArrayAttributeWrite, AttrChangeNotifier, Cluster, Dataver, InvokeContext,
39    ReadContext, WriteContext,
40};
41use crate::dm::{AttrId, EndptId, NodeId};
42use crate::error::{Error, ErrorCode};
43use crate::fabric::MAX_FABRICS;
44use crate::persist::{KvBlobStore, Persist, OTA_PROVIDERS_KEY};
45use crate::tlv::{FromTLV, Nullable, Octets, TLVArray, TLVBuilderParent, TLVElement, ToTLV};
46use crate::transport::exchange::Exchange;
47use crate::utils::cell::RefCell;
48use crate::utils::init::{init, Init};
49use crate::utils::storage::Vec;
50use crate::utils::sync::blocking::Mutex;
51use crate::utils::sync::Notification;
52use crate::with;
53use crate::Matter;
54
55pub use crate::dm::clusters::decl::ota_software_update_requestor::*;
56
57use crate::dm::clusters::decl::ota_software_update_provider::{
58    ApplyUpdateActionEnum, DownloadProtocolEnum, OtaSoftwareUpdateProviderClient,
59    QueryImageResponse,
60};
61use crate::dm::clusters::ota_prov::OtaApplyOutcome;
62
63/// The number of transient providers (learned via `AnnounceOTAProvider`) cached
64/// at once. These are one-shot hints, deduplicated by `(fabric, node)`; the
65/// oldest is evicted when the cache is full.
66const ANNOUNCED_PROVIDERS: usize = 4;
67
68/// One OTA Provider, scoped to the fabric that registered (or announced) it.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, FromTLV, ToTLV)]
70#[cfg_attr(feature = "defmt", derive(defmt::Format))]
71pub struct Provider {
72    /// The fabric this provider belongs to.
73    pub fab_idx: NonZeroU8,
74    /// The provider node's id.
75    pub node_id: NodeId,
76    /// The endpoint on the provider node hosting the OTA Provider cluster.
77    pub endpoint: EndptId,
78}
79
80impl Provider {
81    /// Ask this provider whether a software image newer than `current_version`
82    /// (defaulting to this device's `sw_ver` from `BasicInfoConfig` when `None`)
83    /// is available, advertising the given download `protocols`.
84    ///
85    /// The request's `VendorID`, `ProductID`, `SoftwareVersion` and
86    /// `HardwareVersion` are taken from the node's [`BasicInfoConfig`], and
87    /// `Location` from the configured Basic Information settings - the spec
88    /// requires each to equal the corresponding Basic Information cluster
89    /// attribute. `requestor_can_consent` declares whether this requestor can
90    /// obtain user consent on its own (via built-in UI); pass `true` only if so,
91    /// as it lets the provider delegate consent (see [`OtaImagesRegistry`]).
92    ///
93    /// Opens a CASE exchange to the provider, sends `QueryImage`, and invokes `f`
94    /// with the [`QueryImageResponse`] *before* the exchange is released - so `f`
95    /// reads `image_uri`/`software_version`/`update_token` straight off the RX
96    /// buffer without copying them onto the stack. `f`'s return value is returned.
97    ///
98    /// `query` does not interpret the response (it checks neither `status` nor the
99    /// returned version); that is left to `f`. On a `bdx://` `image_uri`, use
100    /// [`parse_bdx_url`] + [`Exchange::download`](crate::bdx::BdxDownloadInitiator::download) to fetch.
101    ///
102    /// [`BasicInfoConfig`]: crate::dm::clusters::basic_info::BasicInfoConfig
103    /// [`OtaImagesRegistry`]: crate::dm::clusters::ota_prov::OtaImagesRegistry
104    pub async fn query<C, F, R>(
105        &self,
106        matter: &Matter<'_>,
107        crypto: C,
108        protocols: &[DownloadProtocolEnum],
109        current_version: Option<u32>,
110        requestor_can_consent: bool,
111        f: F,
112    ) -> Result<R, Error>
113    where
114        C: Crypto,
115        F: FnOnce(&QueryImageResponse<'_>) -> Result<R, Error>,
116    {
117        let dev = matter.dev_det();
118        let version = current_version.unwrap_or(dev.sw_ver);
119
120        // `Location`, per spec, mirrors the Basic Information cluster Location
121        // attribute (a 2-char region code) when one is configured. Copy it out so
122        // the state lock is not held across the exchange.
123        let location = matter.with_state(|state| state.basic_info_settings.location.clone());
124        let location = location.as_deref();
125
126        let exchange = Exchange::initiate(matter, crypto, self.fab_idx, self.node_id).await?;
127
128        let handle = exchange
129            .ota_software_update_provider()
130            .query_image(self.endpoint, |b| {
131                let mut protos = b
132                    .vendor_id(dev.vid)?
133                    .product_id(dev.pid)?
134                    .software_version(version)?
135                    .protocols_supported()?;
136                for proto in protocols {
137                    protos = protos.push(proto)?;
138                }
139                protos
140                    .end()?
141                    .hardware_version(Some(dev.hw_ver))?
142                    .location(location)?
143                    .requestor_can_consent(Some(requestor_can_consent))?
144                    .metadata_for_provider(None)?
145                    .end()
146            })
147            .await?;
148
149        // Hand the response to `f` while it is still valid (borrows the RX buffer),
150        // then release the exchange.
151        let result = {
152            let response = handle.response()?;
153            f(&response)
154        };
155
156        handle.complete().await?;
157
158        result
159    }
160
161    /// Ask this provider how to apply the already-downloaded image identified by
162    /// `update_token` (the `UpdateToken` from the provider's
163    /// [`QueryImageResponse`]), which upgrades to `new_version`.
164    ///
165    /// Opens a CASE exchange and sends `ApplyUpdateRequest`. The returned
166    /// [`OtaApplyOutcome`] is the provider's decision: [`Proceed`] (apply, after
167    /// its delay), [`Await`] (wait the delay and call this again), or
168    /// [`Discontinue`] (discard the image).
169    ///
170    /// [`Proceed`]: OtaApplyOutcome::Proceed
171    /// [`Await`]: OtaApplyOutcome::Await
172    /// [`Discontinue`]: OtaApplyOutcome::Discontinue
173    pub async fn apply_update<C>(
174        &self,
175        matter: &Matter<'_>,
176        crypto: C,
177        update_token: &[u8],
178        new_version: u32,
179    ) -> Result<OtaApplyOutcome, Error>
180    where
181        C: Crypto,
182    {
183        let exchange = Exchange::initiate(matter, crypto, self.fab_idx, self.node_id).await?;
184
185        let handle = exchange
186            .ota_software_update_provider()
187            .apply_update_request(self.endpoint, |b| {
188                b.update_token(Octets(update_token))?
189                    .new_version(new_version)?
190                    .end()
191            })
192            .await?;
193
194        let outcome = {
195            let response = handle.response()?;
196            let delay_secs = response.delayed_action_time()?;
197
198            match response.action()? {
199                ApplyUpdateActionEnum::Proceed => OtaApplyOutcome::Proceed { delay_secs },
200                ApplyUpdateActionEnum::AwaitNextAction => OtaApplyOutcome::Await { delay_secs },
201                ApplyUpdateActionEnum::Discontinue => OtaApplyOutcome::Discontinue,
202            }
203        };
204
205        handle.complete().await?;
206
207        Ok(outcome)
208    }
209
210    /// Tell this provider that the image identified by `update_token` has been
211    /// applied, now running `software_version`. Opens a CASE exchange and sends
212    /// `NotifyUpdateApplied`; there is no response payload.
213    pub async fn notify_applied<C>(
214        &self,
215        matter: &Matter<'_>,
216        crypto: C,
217        update_token: &[u8],
218        software_version: u32,
219    ) -> Result<(), Error>
220    where
221        C: Crypto,
222    {
223        let exchange = Exchange::initiate(matter, crypto, self.fab_idx, self.node_id).await?;
224
225        exchange
226            .ota_software_update_provider()
227            .notify_update_applied(self.endpoint, |b| {
228                b.update_token(Octets(update_token))?
229                    .software_version(software_version)?
230                    .end()
231            })
232            .await
233    }
234}
235
236/// The OTA Requestor's provider registry: the persistent, fabric-scoped
237/// `DefaultOTAProviders` list (at most one entry per fabric) and a transient
238/// cache of providers learned via `AnnounceOTAProvider`.
239///
240/// Modeled on the Binding cluster's registry: the default list is persisted as a
241/// single TLV blob under [`OTA_PROVIDERS_KEY`]; announced providers are never
242/// persisted. [`wait_changed`](Self::wait_changed) signals the application when
243/// the set changes (an admin write or an announcement).
244pub struct Providers {
245    state: Mutex<RefCell<ProvidersState>>,
246    changed: Notification,
247}
248
249struct ProvidersState {
250    /// The persisted `DefaultOTAProviders` list - at most one entry per fabric.
251    default: Vec<Provider, MAX_FABRICS>,
252    /// Transient providers learned via `AnnounceOTAProvider` (not persisted).
253    announced: Vec<Provider, ANNOUNCED_PROVIDERS>,
254}
255
256impl ProvidersState {
257    fn init() -> impl Init<Self> {
258        init!(Self {
259            default <- Vec::init(),
260            announced <- Vec::init(),
261        })
262    }
263}
264
265impl Providers {
266    /// Create an empty registry. Prefer [`Self::init`] for a large `MAX_FABRICS`.
267    pub const fn new() -> Self {
268        Self {
269            state: Mutex::new(RefCell::new(ProvidersState {
270                default: Vec::new(),
271                announced: Vec::new(),
272            })),
273            changed: Notification::new(),
274        }
275    }
276
277    /// An in-place initializer for an empty registry.
278    pub fn init() -> impl Init<Self> {
279        init!(Self {
280            state <- Mutex::init(RefCell::init(ProvidersState::init())),
281            changed: Notification::new(),
282        })
283    }
284
285    /// Re-hydrate the default provider list from `store`. Call once at startup,
286    /// before exposing the data model.
287    pub async fn load_persist<S: KvBlobStore>(
288        &self,
289        mut store: S,
290        buf: &mut [u8],
291    ) -> Result<(), Error> {
292        let Some(data) = store.load(OTA_PROVIDERS_KEY, buf)? else {
293            self.state.lock(|cell| cell.borrow_mut().default.clear());
294            return Ok(());
295        };
296
297        let loaded = Vec::<Provider, MAX_FABRICS>::from_tlv(&TLVElement::new(data))?;
298        self.state.lock(|cell| cell.borrow_mut().default = loaded);
299
300        info!("Loaded OTA provider entries from storage");
301
302        Ok(())
303    }
304
305    /// Persist the default provider list to `ctx.kv()`.
306    fn store_persist<C: WriteContext>(&self, ctx: &C) -> Result<(), Error> {
307        let mut persist = Persist::new(ctx.kv());
308
309        self.state.lock(|cell| {
310            let state = cell.borrow();
311            persist.store_tlv(OTA_PROVIDERS_KEY, &state.default)
312        })?;
313
314        persist.run()
315    }
316
317    /// The number of configured default providers (across all fabrics).
318    pub fn len(&self) -> usize {
319        self.state.lock(|cell| cell.borrow().default.len())
320    }
321
322    /// Whether there are no configured default providers.
323    pub fn is_empty(&self) -> bool {
324        self.len() == 0
325    }
326
327    /// The `index`-th default provider, cloned out so the registry lock is not
328    /// held across the caller's subsequent (likely `async`) work.
329    pub fn get(&self, index: usize) -> Option<Provider> {
330        self.state
331            .lock(|cell| cell.borrow().default.get(index).copied())
332    }
333
334    /// The number of cached announced providers.
335    pub fn announced_len(&self) -> usize {
336        self.state.lock(|cell| cell.borrow().announced.len())
337    }
338
339    /// The `index`-th announced provider, cloned out.
340    pub fn announced(&self, index: usize) -> Option<Provider> {
341        self.state
342            .lock(|cell| cell.borrow().announced.get(index).copied())
343    }
344
345    /// Drop all cached announced providers (e.g. once the app has queried them).
346    pub fn clear_announced(&self) {
347        self.state.lock(|cell| cell.borrow_mut().announced.clear());
348    }
349
350    /// Atomically remove and return all cached announced providers.
351    ///
352    /// Prefer this to iterating [`announced`](Self::announced) and then calling
353    /// [`clear_announced`](Self::clear_announced) in an update loop: providers
354    /// learned via `AnnounceOTAProvider` *while the loop is busy* processing this
355    /// batch land in a fresh `announced` set (and re-arm [`wait_changed`](Self::wait_changed))
356    /// instead of being discarded unprocessed by a trailing clear.
357    pub fn take_announced(&self) -> Vec<Provider, ANNOUNCED_PROVIDERS> {
358        self.state.lock(|cell| {
359            let mut state = cell.borrow_mut();
360            let taken = state.announced.clone();
361            state.announced.clear();
362            taken
363        })
364    }
365
366    /// Wait until the provider set changes (a `DefaultOTAProviders` write or an
367    /// `AnnounceOTAProvider` command).
368    pub async fn wait_changed(&self) {
369        self.changed.wait().await;
370    }
371
372    /// Replace the (single) default provider for `fab_idx` with `provider`, or
373    /// clear it when `None`.
374    fn replace_default<C: WriteContext>(
375        &self,
376        ctx: &C,
377        fab_idx: NonZeroU8,
378        provider: Option<Provider>,
379    ) -> Result<(), Error> {
380        self.state.lock(|cell| {
381            let mut state = cell.borrow_mut();
382            state.default.retain(|p| p.fab_idx != fab_idx);
383            if let Some(provider) = provider {
384                state
385                    .default
386                    .push(provider)
387                    .map_err(|_| ErrorCode::ResourceExhausted)?;
388            }
389            Ok::<_, Error>(())
390        })?;
391
392        self.changed.notify();
393
394        self.store_persist(ctx)
395    }
396
397    /// Add a default provider for `fab_idx`, failing with `CONSTRAINT_ERROR` if it
398    /// already has one (at most one entry per fabric).
399    fn add_default<C: WriteContext>(
400        &self,
401        ctx: &C,
402        fab_idx: NonZeroU8,
403        provider: Provider,
404    ) -> Result<(), Error> {
405        self.state.lock(|cell| {
406            let mut state = cell.borrow_mut();
407            if state.default.iter().any(|p| p.fab_idx == fab_idx) {
408                return Err(ErrorCode::ConstraintError.into());
409            }
410            state
411                .default
412                .push(provider)
413                .map_err(|_| ErrorCode::ResourceExhausted)?;
414            Ok::<_, Error>(())
415        })?;
416
417        self.changed.notify();
418
419        self.store_persist(ctx)
420    }
421
422    /// Cache a transient provider learned via `AnnounceOTAProvider` and wake any
423    /// waiter. Deduplicated by `(fabric, node)`; the oldest is evicted if full.
424    fn add_announced(&self, provider: Provider) {
425        self.state.lock(|cell| {
426            let mut state = cell.borrow_mut();
427            state
428                .announced
429                .retain(|p| !(p.fab_idx == provider.fab_idx && p.node_id == provider.node_id));
430            if state.announced.is_full() {
431                state.announced.remove(0);
432            }
433            // Cannot fail: we just made room.
434            let _ = state.announced.push(provider);
435        });
436
437        self.changed.notify();
438    }
439
440    /// Render the (fabric-filtered) default list into the attribute builder.
441    fn render<P: TLVBuilderParent>(
442        &self,
443        fab_filter: Option<NonZeroU8>,
444        builder: ArrayAttributeRead<ProviderLocationArrayBuilder<P>, ProviderLocationBuilder<P>>,
445    ) -> Result<P, Error> {
446        self.state.lock(|cell| {
447            let state = cell.borrow();
448            let mut iter = state
449                .default
450                .iter()
451                .filter(|p| fab_filter.is_none_or(|f| p.fab_idx == f));
452
453            match builder {
454                ArrayAttributeRead::ReadAll(mut array) => {
455                    for p in iter {
456                        array = array
457                            .push()?
458                            .provider_node_id(p.node_id)?
459                            .endpoint(p.endpoint)?
460                            .fabric_index(Some(p.fab_idx.get()))?
461                            .end()?;
462                    }
463                    array.end()
464                }
465                ArrayAttributeRead::ReadOne(index, item) => {
466                    let Some(p) = iter.nth(index as usize) else {
467                        return Err(ErrorCode::ConstraintError.into());
468                    };
469                    item.provider_node_id(p.node_id)?
470                        .endpoint(p.endpoint)?
471                        .fabric_index(Some(p.fab_idx.get()))?
472                        .end()
473                }
474                ArrayAttributeRead::ReadNone(array) => array.end(),
475            }
476        })
477    }
478}
479
480impl Default for Providers {
481    fn default() -> Self {
482        Self::new()
483    }
484}
485
486/// The OTA Requestor's reported update state: the `UpdateState`,
487/// `UpdateStateProgress` and `UpdatePossible` attributes.
488///
489/// Held separately from [`Providers`] because it is transient runtime state, not
490/// configuration. The application reports progress through an [`OtaUpdate`]
491/// session obtained from [`initiate_update`](Self::initiate_update).
492///
493/// Because progress is reported from the application's own update loop (outside
494/// any cluster-handler `ctx`), the reporting calls take the data model's
495/// [`AttrChangeNotifier`] (typically the `InteractionModel`) so each change bumps the
496/// cluster's data version and wakes subscribers. It is passed in rather than
497/// stored because the `InteractionModel` is constructed *after* this state (it borrows
498/// it), so there is never a moment at which it could be stored here.
499pub struct OtaState {
500    endpoint_id: EndptId,
501    reported: Mutex<RefCell<Reported>>,
502}
503
504struct Reported {
505    update_state: UpdateStateEnum,
506    progress: Option<u8>,
507    update_possible: bool,
508}
509
510impl OtaState {
511    /// Create idle, update-possible state for the endpoint hosting the OTA
512    /// Requestor cluster (the one whose [`OtaRequestorHandler`] reads this state),
513    /// so change notifications target the right cluster instance.
514    pub const fn new(endpoint_id: EndptId) -> Self {
515        Self {
516            endpoint_id,
517            reported: Mutex::new(RefCell::new(Reported {
518                update_state: UpdateStateEnum::Idle,
519                progress: None,
520                update_possible: true,
521            })),
522        }
523    }
524
525    /// Set the `UpdatePossible` attribute (e.g. `false` when the battery is too
526    /// low to apply an update), notifying `notifier` of the change.
527    pub fn set_update_possible(&self, notifier: &dyn AttrChangeNotifier, possible: bool) {
528        self.reported
529            .lock(|cell| cell.borrow_mut().update_possible = possible);
530
531        self.notify(notifier, AttributeId::UpdatePossible as _);
532    }
533
534    fn update_state(&self) -> UpdateStateEnum {
535        self.reported.lock(|cell| cell.borrow().update_state)
536    }
537
538    fn progress(&self) -> Option<u8> {
539        self.reported.lock(|cell| cell.borrow().progress)
540    }
541
542    fn update_possible(&self) -> bool {
543        self.reported.lock(|cell| cell.borrow().update_possible)
544    }
545
546    /// Update the reported `UpdateState`/`UpdateStateProgress` and notify.
547    fn report(
548        &self,
549        notifier: &dyn AttrChangeNotifier,
550        state: UpdateStateEnum,
551        progress: Option<u8>,
552    ) {
553        self.reported.lock(|cell| {
554            let mut reported = cell.borrow_mut();
555            reported.update_state = state;
556            reported.progress = progress;
557        });
558
559        // Both `UpdateState` and `UpdateStateProgress` changed; a single
560        // cluster-level notification bumps the data version once and re-reports
561        // any subscriber interested in either attribute.
562        notifier.notify_cluster_changed(self.endpoint_id, FULL_CLUSTER.id);
563    }
564
565    /// Notify the data model that `attr_id` of this cluster instance changed.
566    fn notify(&self, notifier: &dyn AttrChangeNotifier, attr_id: AttrId) {
567        notifier.notify_attr_changed(self.endpoint_id, FULL_CLUSTER.id, attr_id);
568    }
569
570    /// Begin an update session, reporting changes through `notifier`. The returned
571    /// [`OtaUpdate`] reports progress; on [`complete`](OtaUpdate::complete) - or if
572    /// dropped without it - the reported state returns to `Idle` (`UpdateStateEnum`
573    /// has no dedicated failure value).
574    pub fn initiate_update<'a>(&'a self, notifier: &'a dyn AttrChangeNotifier) -> OtaUpdate<'a> {
575        OtaUpdate {
576            state: self,
577            notifier,
578            done: false,
579        }
580    }
581}
582
583/// An in-progress update session (RAII). Report progress with
584/// [`querying`](Self::querying) / [`downloading`](Self::downloading) /
585/// [`applying`](Self::applying) / [`report`](Self::report); call
586/// [`complete`](Self::complete) when done. Dropping it without `complete` reverts
587/// the reported state to `Idle`, so an aborted update never leaves the cluster
588/// stuck mid-transfer.
589pub struct OtaUpdate<'a> {
590    state: &'a OtaState,
591    notifier: &'a dyn AttrChangeNotifier,
592    done: bool,
593}
594
595impl OtaUpdate<'_> {
596    /// Report `Querying`.
597    pub fn querying(&self) {
598        self.state
599            .report(self.notifier, UpdateStateEnum::Querying, None);
600    }
601
602    /// Report `Downloading` at the given percent (`None` if unknown).
603    pub fn downloading(&self, percent: Option<u8>) {
604        self.state
605            .report(self.notifier, UpdateStateEnum::Downloading, percent);
606    }
607
608    /// Report `Applying`.
609    pub fn applying(&self) {
610        self.state
611            .report(self.notifier, UpdateStateEnum::Applying, None);
612    }
613
614    /// Report an arbitrary `state` and `progress`.
615    pub fn report(&self, state: UpdateStateEnum, progress: Option<u8>) {
616        self.state.report(self.notifier, state, progress);
617    }
618
619    /// Finish the session, returning the reported state to `Idle`.
620    pub fn complete(mut self) {
621        self.state
622            .report(self.notifier, UpdateStateEnum::Idle, None);
623        self.done = true;
624    }
625}
626
627impl Drop for OtaUpdate<'_> {
628    fn drop(&mut self) {
629        if !self.done {
630            // Abandoned mid-update: revert to Idle.
631            self.state
632                .report(self.notifier, UpdateStateEnum::Idle, None);
633        }
634    }
635}
636
637/// The server-side handler for the OTA Software Update Requestor cluster.
638pub struct OtaRequestorHandler<'a> {
639    dataver: Dataver,
640    providers: &'a Providers,
641    state: &'a OtaState,
642}
643
644impl<'a> OtaRequestorHandler<'a> {
645    /// Create a handler backed by the shared [`Providers`] registry and
646    /// [`OtaState`].
647    pub const fn new(dataver: Dataver, providers: &'a Providers, state: &'a OtaState) -> Self {
648        Self {
649            dataver,
650            providers,
651            state,
652        }
653    }
654
655    /// Adapt this handler to the generic `rs-matter` `Handler` trait.
656    pub const fn adapt(self) -> HandlerAdaptor<Self> {
657        HandlerAdaptor(self)
658    }
659}
660
661impl ClusterHandler for OtaRequestorHandler<'_> {
662    const CLUSTER: Cluster<'static> = FULL_CLUSTER.with_attrs(with!(required));
663
664    fn dataver(&self) -> u32 {
665        self.dataver.get()
666    }
667
668    fn dataver_changed(&self) {
669        self.dataver.changed();
670    }
671
672    fn default_ota_providers<P: TLVBuilderParent>(
673        &self,
674        ctx: impl ReadContext,
675        builder: ArrayAttributeRead<ProviderLocationArrayBuilder<P>, ProviderLocationBuilder<P>>,
676    ) -> Result<P, Error> {
677        let attr = ctx.attr();
678        let fab_filter = if attr.fab_filter {
679            Some(NonZeroU8::new(attr.fab_idx).ok_or(ErrorCode::UnsupportedAccess)?)
680        } else {
681            None
682        };
683
684        self.providers.render(fab_filter, builder)
685    }
686
687    fn update_possible(&self, _ctx: impl ReadContext) -> Result<bool, Error> {
688        Ok(self.state.update_possible())
689    }
690
691    fn update_state(&self, _ctx: impl ReadContext) -> Result<UpdateStateEnum, Error> {
692        Ok(self.state.update_state())
693    }
694
695    fn update_state_progress(&self, _ctx: impl ReadContext) -> Result<Nullable<u8>, Error> {
696        Ok(self
697            .state
698            .progress()
699            .map(Nullable::some)
700            .unwrap_or_else(Nullable::none))
701    }
702
703    fn set_default_ota_providers(
704        &self,
705        ctx: impl WriteContext,
706        value: ArrayAttributeWrite<TLVArray<'_, ProviderLocation<'_>>, ProviderLocation<'_>>,
707    ) -> Result<(), Error> {
708        // Fabric-scoped writes require a valid accessing fabric.
709        let fab_idx = NonZeroU8::new(ctx.attr().fab_idx).ok_or(ErrorCode::UnsupportedAccess)?;
710
711        let to_provider = |loc: &ProviderLocation<'_>| -> Result<Provider, Error> {
712            Ok(Provider {
713                fab_idx,
714                node_id: loc.provider_node_id()?,
715                endpoint: loc.endpoint()?,
716            })
717        };
718
719        match value {
720            // At most one entry per fabric: a replacement list may carry zero or
721            // one entry; more than one is a `CONSTRAINT_ERROR`.
722            ArrayAttributeWrite::Replace(list) => {
723                let mut iter = list.iter();
724                let first = iter.next().transpose()?;
725                if iter.next().is_some() {
726                    return Err(ErrorCode::ConstraintError.into());
727                }
728
729                // Parse before mutating, so a malformed entry leaves the existing
730                // default intact.
731                let parsed = first.map(|loc| to_provider(&loc)).transpose()?;
732                self.providers.replace_default(&ctx, fab_idx, parsed)?;
733            }
734            // Adding a second entry for a fabric would exceed the one-per-fabric
735            // limit; reject it.
736            ArrayAttributeWrite::Add(loc) => {
737                self.providers
738                    .add_default(&ctx, fab_idx, to_provider(&loc)?)?;
739            }
740            // Per-index update/remove on a fabric-scoped list are converted to
741            // `InvalidAction` by the framework; reject defensively.
742            ArrayAttributeWrite::Update(_, _) | ArrayAttributeWrite::Remove(_) => {
743                return Err(ErrorCode::InvalidAction.into());
744            }
745        }
746
747        // Notify subscribers of the changed attribute (also bumps the dataver).
748        ctx.notify_changed();
749
750        Ok(())
751    }
752
753    fn handle_announce_ota_provider(
754        &self,
755        ctx: impl InvokeContext,
756        request: AnnounceOTAProviderRequest<'_>,
757    ) -> Result<(), Error> {
758        let fab_idx = NonZeroU8::new(ctx.cmd().fab_idx).ok_or(ErrorCode::UnsupportedAccess)?;
759
760        let provider = Provider {
761            fab_idx,
762            node_id: request.provider_node_id()?,
763            endpoint: request.endpoint()?,
764        };
765
766        // Per spec, an announced provider is a transient hint and SHALL NOT be
767        // added to `DefaultOTAProviders`; cache it separately and wake the app.
768        self.providers.add_announced(provider);
769
770        Ok(())
771    }
772}
773
774/// Parse a `bdx://<node-id>/<file-designator>` image URI into its `(node id, file
775/// designator)` pair. The node id is hex-encoded, as minted by an OTA Provider's
776/// `QueryImage` response.
777pub fn parse_bdx_url(url: &str) -> Result<(NodeId, &str), Error> {
778    let rest = url.strip_prefix("bdx://").ok_or(ErrorCode::InvalidData)?;
779    let (node, fd) = rest.split_once('/').ok_or(ErrorCode::InvalidData)?;
780    if fd.is_empty() {
781        // A BDX transfer needs a non-empty file designator to identify the file.
782        return Err(ErrorCode::InvalidData.into());
783    }
784    let node_id = u64::from_str_radix(node, 16).map_err(|_| ErrorCode::InvalidData)?;
785
786    Ok((node_id, fd))
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn parse_bdx_url_extracts_node_and_fd() {
795        let (node, fd) = parse_bdx_url("bdx://00112233AABBCCDD/my-firmware.ota").unwrap();
796        assert_eq!(node, 0x0011_2233_AABB_CCDD);
797        assert_eq!(fd, "my-firmware.ota");
798
799        assert!(parse_bdx_url("https://example.com/x").is_err());
800        assert!(parse_bdx_url("bdx://nodeid-no-slash").is_err());
801        assert!(parse_bdx_url("bdx://zzzz/fd").is_err());
802    }
803
804    #[test]
805    fn announced_dedup_evict_and_clear() {
806        let providers = Providers::new();
807        let provider = |node| Provider {
808            fab_idx: NonZeroU8::new(1).unwrap(),
809            node_id: node,
810            endpoint: 0,
811        };
812
813        // Deduplicated by (fabric, node).
814        providers.add_announced(provider(0xaa));
815        providers.add_announced(provider(0xaa));
816        assert_eq!(providers.announced_len(), 1);
817
818        // Capacity-bounded: the oldest is evicted.
819        for n in 0..(ANNOUNCED_PROVIDERS as u64 + 2) {
820            providers.add_announced(provider(0x100 + n));
821        }
822        assert_eq!(providers.announced_len(), ANNOUNCED_PROVIDERS);
823
824        providers.clear_announced();
825        assert_eq!(providers.announced_len(), 0);
826    }
827}