Skip to main content

rs_matter/dm/clusters/app/
cam_av_settings.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//! Implementation of the Matter Camera AV Settings User-Level Management
19//! cluster (0x0552).
20//!
21//! Exposes the user-tunable side of the camera: mechanical pan/tilt/zoom
22//! (MPTZ) position, named MPTZ presets, and per-stream digital
23//! pan/tilt/zoom (DPTZ) viewports. Controllers (SmartThings, Google
24//! Home, Apple Home, `chip-tool`) drive this cluster directly to move
25//! the camera or pan a viewport inside an already-allocated video
26//! stream.
27//!
28//! # Architecture (Pattern B1 — "Hooks")
29//!
30//! [`CamAvSettingsHandler`] owns the spec-defined state — the current
31//! MPTZ position, the preset table, the DPTZ viewport table — and
32//! performs all spec validation. The application supplies a
33//! [`CamAvSettingsHooks`] implementation that translates accepted
34//! commands into actuator / encoder calls.
35//!
36//! ```text
37//! ┌──────────────────────┐  ClusterAsyncHandler   ┌───────────────────┐
38//! │                      │◀── inbound commands ───│   rs-matter IM    │
39//! │ CamAvSettingsHandler │                        │     dispatcher    │
40//! └──────┬───────────────┘                        └───────────────────┘
41//!        │ delegates apply / move
42//!        ▼
43//! ┌──────────────────────┐
44//! │ CamAvSettingsHooks   │  user-supplied (PTZ motors / GPU crop)
45//! └──────────────────────┘
46//! ```
47//!
48//! # Const generics
49//!
50//! * `NP` — maximum number of MPTZ presets stored at once.
51//! * `NS` — maximum number of `DPTZStreams` rows.
52//!
53//! # Feature support
54//!
55//! All five spec features are implemented:
56//!
57//! | Feature             | Bit | Effect                                       |
58//! |---------------------|-----|----------------------------------------------|
59//! | `DIGITAL_PTZ`       | 0x1 | DPTZStreams attribute + DPTZSet/RelativeMove |
60//! | `MECHANICAL_PAN`    | 0x2 | Pan range + pan field on MPTZPosition        |
61//! | `MECHANICAL_TILT`   | 0x4 | Tilt range + tilt field on MPTZPosition      |
62//! | `MECHANICAL_ZOOM`   | 0x8 | ZoomMax + zoom field on MPTZPosition         |
63//! | `MECHANICAL_PRESETS`| 0x10| MaxPresets/MPTZPresets + 3 preset commands   |
64//!
65//! Any subset is valid; the handler refuses commands and rejects
66//! attribute reads for features that aren't enabled. Three convenience
67//! `CLUSTER_*` constants ([`CLUSTER_FULL`], [`CLUSTER_DPTZ_ONLY`],
68//! [`CLUSTER_MPTZ_ALL`]) advertise the matching attribute and command
69//! lists.
70
71use core::cell::RefCell;
72use core::future::Future;
73
74use heapless::String as HString;
75
76use crate::dm::{ArrayAttributeRead, Cluster, Dataver, EndptId, InvokeContext, ReadContext};
77use crate::error::{Error, ErrorCode};
78use crate::tlv::{TLVBuilderParent, Utf8Str};
79use crate::utils::storage::Vec;
80use crate::utils::sync::blocking::Mutex;
81use crate::with;
82
83#[allow(unused_imports)]
84pub use crate::dm::clusters::decl::camera_av_settings_user_level_management::*;
85
86use super::super::decl::camera_av_settings_user_level_management as decl;
87
88/// Maximum length, in bytes, of an MPTZ preset name (Matter spec cap).
89pub const MAX_PRESET_NAME_LEN: usize = 32;
90
91// -----------------------------------------------------------------------
92// Public domain types
93// -----------------------------------------------------------------------
94
95/// Errors that a [`CamAvSettingsHooks`] implementation can surface.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97#[cfg_attr(feature = "defmt", derive(defmt::Format))]
98pub enum CamAvSettingsError {
99    /// The hardware refused the request (e.g. motor stalled).
100    Failure,
101    /// The preset / stream id does not exist.
102    NotFound,
103    /// The application's storage for presets / streams is full.
104    ResourceExhausted,
105    /// The request is well-formed but currently impossible (e.g.
106    /// camera locked by privacy mode).
107    DynamicConstraint,
108}
109
110impl From<CamAvSettingsError> for Error {
111    fn from(e: CamAvSettingsError) -> Self {
112        match e {
113            CamAvSettingsError::Failure => ErrorCode::Failure.into(),
114            CamAvSettingsError::NotFound => ErrorCode::NotFound.into(),
115            CamAvSettingsError::ResourceExhausted => ErrorCode::ResourceExhausted.into(),
116            CamAvSettingsError::DynamicConstraint => ErrorCode::ConstraintError.into(),
117        }
118    }
119}
120
121/// Mechanical pan/tilt/zoom triple. Each axis is `None` when the
122/// corresponding feature bit is disabled.
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
124#[cfg_attr(feature = "defmt", derive(defmt::Format))]
125pub struct Mptz {
126    pub pan: Option<i16>,
127    pub tilt: Option<i16>,
128    pub zoom: Option<u8>,
129}
130
131/// One row in the `MPTZPresets` attribute.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct MptzPreset {
134    pub preset_id: u8,
135    pub name: HString<MAX_PRESET_NAME_LEN>,
136    pub settings: Mptz,
137}
138
139/// One row in the `DPTZStreams` attribute. Coordinates are interpreted
140/// in the underlying video stream's full sensor coordinate space.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142#[cfg_attr(feature = "defmt", derive(defmt::Format))]
143pub struct DptzView {
144    pub video_stream_id: u16,
145    pub x1: u16,
146    pub y1: u16,
147    pub x2: u16,
148    pub y2: u16,
149}
150
151impl DptzView {
152    fn validate(&self) -> Result<(), Error> {
153        if self.x2 <= self.x1 || self.y2 <= self.y1 {
154            return Err(ErrorCode::ConstraintError.into());
155        }
156        Ok(())
157    }
158}
159
160/// Static configuration for a [`CamAvSettingsHandler`].
161///
162/// Range fields are honoured only when the matching feature bit is
163/// enabled. `default_position` is the boot-time `MPTZPosition` value
164/// reported before the first `MPTZSetPosition` command — fields for
165/// disabled features are ignored.
166#[derive(Debug, Clone, Copy)]
167pub struct CamAvSettingsConfig {
168    /// Inclusive pan limits in hundredths of degrees, used when
169    /// `MECHANICAL_PAN` is enabled. Spec range: -18000..=17999.
170    pub pan_range: (i16, i16),
171    /// Inclusive tilt limits in hundredths of degrees, used when
172    /// `MECHANICAL_TILT` is enabled. Spec range: -9000..=9000.
173    pub tilt_range: (i16, i16),
174    /// Maximum zoom factor (1 = no zoom), used when `MECHANICAL_ZOOM`
175    /// is enabled. Spec range: 1..=100.
176    pub zoom_max: u8,
177    /// Boot-time MPTZ position. Disabled-feature fields are ignored.
178    pub default_position: Mptz,
179    /// Maximum number of presets the device exposes. Capped to `NP`
180    /// at construction. Required when `MECHANICAL_PRESETS` is enabled.
181    pub max_presets: u8,
182}
183
184/// Application hooks for the side-effecting pieces of the cluster.
185/// All hooks have a no-op default implementation.
186#[allow(unused_variables)]
187pub trait CamAvSettingsHooks {
188    /// Apply a mechanical pan/tilt/zoom target. Called after the
189    /// handler has validated bounds against the configured ranges and
190    /// the enabled feature set.
191    ///
192    /// On `Err` the handler rolls back its internal `MPTZPosition`
193    /// snapshot to the prior value.
194    fn mptz_apply(&self, target: &Mptz) -> impl Future<Output = Result<(), CamAvSettingsError>> {
195        async { Ok(()) }
196    }
197
198    /// Apply a digital viewport for a video stream. Called after the
199    /// handler has validated `x1<x2`, `y1<y2`. Hardware-specific
200    /// per-stream bounds (resolution caps, aspect-ratio constraints)
201    /// are the implementation's responsibility.
202    fn dptz_apply(&self, view: &DptzView) -> impl Future<Output = Result<(), CamAvSettingsError>> {
203        async { Ok(()) }
204    }
205
206    /// Notify that a preset was saved or overwritten. The handler has
207    /// already updated its `MPTZPresets` table; this hook is purely
208    /// informational so the application can persist the preset across
209    /// reboots if it wishes. Returning `Err` rolls back the save.
210    fn preset_saved(
211        &self,
212        preset: &MptzPreset,
213    ) -> impl Future<Output = Result<(), CamAvSettingsError>> {
214        async { Ok(()) }
215    }
216
217    /// Notify that a preset was removed. Returning `Err` rolls back.
218    fn preset_removed(
219        &self,
220        preset_id: u8,
221    ) -> impl Future<Output = Result<(), CamAvSettingsError>> {
222        async { Ok(()) }
223    }
224}
225
226impl<T> CamAvSettingsHooks for &T
227where
228    T: CamAvSettingsHooks,
229{
230    fn mptz_apply(&self, target: &Mptz) -> impl Future<Output = Result<(), CamAvSettingsError>> {
231        (**self).mptz_apply(target)
232    }
233
234    fn dptz_apply(&self, view: &DptzView) -> impl Future<Output = Result<(), CamAvSettingsError>> {
235        (**self).dptz_apply(view)
236    }
237
238    fn preset_saved(
239        &self,
240        preset: &MptzPreset,
241    ) -> impl Future<Output = Result<(), CamAvSettingsError>> {
242        (**self).preset_saved(preset)
243    }
244
245    fn preset_removed(
246        &self,
247        preset_id: u8,
248    ) -> impl Future<Output = Result<(), CamAvSettingsError>> {
249        (**self).preset_removed(preset_id)
250    }
251}
252
253// -----------------------------------------------------------------------
254// Internal state
255// -----------------------------------------------------------------------
256
257struct State<const NP: usize, const NS: usize> {
258    mptz: Mptz,
259    presets: Vec<MptzPreset, NP>,
260    dptz: Vec<DptzView, NS>,
261    seeded: bool,
262}
263
264impl<const NP: usize, const NS: usize> State<NP, NS> {
265    const fn new() -> Self {
266        Self {
267            mptz: Mptz {
268                pan: None,
269                tilt: None,
270                zoom: None,
271            },
272            presets: Vec::new(),
273            dptz: Vec::new(),
274            seeded: false,
275        }
276    }
277}
278
279// -----------------------------------------------------------------------
280// CLUSTER constants
281// -----------------------------------------------------------------------
282
283/// `CLUSTER` advertising every feature: full MPTZ + presets + DPTZ.
284pub const CLUSTER_FULL: Cluster<'static> = decl::FULL_CLUSTER
285    .with_revision(1)
286    .with_features(
287        decl::Feature::DIGITAL_PTZ.bits()
288            | decl::Feature::MECHANICAL_PAN.bits()
289            | decl::Feature::MECHANICAL_TILT.bits()
290            | decl::Feature::MECHANICAL_ZOOM.bits()
291            | decl::Feature::MECHANICAL_PRESETS.bits(),
292    )
293    .with_attrs(with!(
294        required;
295        AttributeId::MPTZPosition
296            | AttributeId::MaxPresets
297            | AttributeId::MPTZPresets
298            | AttributeId::DPTZStreams
299            | AttributeId::ZoomMax
300            | AttributeId::TiltMin
301            | AttributeId::TiltMax
302            | AttributeId::PanMin
303            | AttributeId::PanMax
304            | AttributeId::MovementState
305    ))
306    .with_cmds(with!(
307        decl::CommandId::MPTZSetPosition
308            | decl::CommandId::MPTZRelativeMove
309            | decl::CommandId::MPTZMoveToPreset
310            | decl::CommandId::MPTZSavePreset
311            | decl::CommandId::MPTZRemovePreset
312            | decl::CommandId::DPTZSetViewport
313            | decl::CommandId::DPTZRelativeMove
314    ));
315
316/// `CLUSTER` advertising only `DIGITAL_PTZ` — fixed lens, software pan
317/// inside the encoded frame.
318pub const CLUSTER_DPTZ_ONLY: Cluster<'static> = decl::FULL_CLUSTER
319    .with_revision(1)
320    .with_features(decl::Feature::DIGITAL_PTZ.bits())
321    .with_attrs(with!(required; AttributeId::DPTZStreams))
322    .with_cmds(with!(
323        decl::CommandId::DPTZSetViewport | decl::CommandId::DPTZRelativeMove
324    ));
325
326/// `CLUSTER` advertising the full mechanical PTZ surface (`PAN | TILT |
327/// ZOOM | PRESETS`), but no `DIGITAL_PTZ`.
328pub const CLUSTER_MPTZ_ALL: Cluster<'static> = decl::FULL_CLUSTER
329    .with_revision(1)
330    .with_features(
331        decl::Feature::MECHANICAL_PAN.bits()
332            | decl::Feature::MECHANICAL_TILT.bits()
333            | decl::Feature::MECHANICAL_ZOOM.bits()
334            | decl::Feature::MECHANICAL_PRESETS.bits(),
335    )
336    .with_attrs(with!(
337        required;
338        AttributeId::MPTZPosition
339            | AttributeId::MaxPresets
340            | AttributeId::MPTZPresets
341            | AttributeId::ZoomMax
342            | AttributeId::TiltMin
343            | AttributeId::TiltMax
344            | AttributeId::PanMin
345            | AttributeId::PanMax
346            | AttributeId::MovementState
347    ))
348    .with_cmds(with!(
349        decl::CommandId::MPTZSetPosition
350            | decl::CommandId::MPTZRelativeMove
351            | decl::CommandId::MPTZMoveToPreset
352            | decl::CommandId::MPTZSavePreset
353            | decl::CommandId::MPTZRemovePreset
354    ));
355
356// -----------------------------------------------------------------------
357// Handler
358// -----------------------------------------------------------------------
359
360/// Handler for the Camera AV Settings User-Level Management cluster
361/// (0x0552). Pattern B1 ("Hooks").
362pub struct CamAvSettingsHandler<H, const NP: usize, const NS: usize>
363where
364    H: CamAvSettingsHooks,
365{
366    dataver: Dataver,
367    endpoint_id: EndptId,
368    cluster: Cluster<'static>,
369    config: CamAvSettingsConfig,
370    hooks: H,
371    state: Mutex<RefCell<State<NP, NS>>>,
372}
373
374impl<H, const NP: usize, const NS: usize> CamAvSettingsHandler<H, NP, NS>
375where
376    H: CamAvSettingsHooks,
377{
378    /// Construct a new handler.
379    ///
380    /// `cluster` must be one of [`CLUSTER_FULL`], [`CLUSTER_DPTZ_ONLY`],
381    /// [`CLUSTER_MPTZ_ALL`], or any user-built [`Cluster`] derived from
382    /// [`FULL_CLUSTER`]. Its `feature_map` drives runtime gating.
383    pub const fn new(
384        dataver: Dataver,
385        endpoint_id: EndptId,
386        cluster: Cluster<'static>,
387        config: CamAvSettingsConfig,
388        hooks: H,
389    ) -> Self {
390        Self {
391            dataver,
392            endpoint_id,
393            cluster,
394            config,
395            hooks,
396            state: Mutex::new(RefCell::new(State::new())),
397        }
398    }
399
400    /// Wrap in the async dispatcher adaptor for registration on an
401    /// async-handler chain.
402    pub const fn adapt(self) -> decl::HandlerAsyncAdaptor<Self> {
403        decl::HandlerAsyncAdaptor(self)
404    }
405
406    /// Endpoint id this handler was constructed against.
407    pub const fn endpoint_id(&self) -> EndptId {
408        self.endpoint_id
409    }
410
411    /// Feature bits advertised on this handler's [`Cluster`].
412    pub fn features(&self) -> u32 {
413        self.cluster.feature_map
414    }
415
416    fn has_feature(&self, bit: u32) -> bool {
417        self.features() & bit != 0
418    }
419
420    /// Pre-seed a DPTZ stream entry at boot. Useful when the camera has
421    /// a fixed default viewport per video stream that controllers
422    /// should see immediately on subscription.
423    pub fn add_preallocated_dptz(&self, view: DptzView) -> Result<(), Error> {
424        view.validate()?;
425        self.state.lock(|cell| {
426            let mut s = cell.borrow_mut();
427            if s.dptz
428                .iter()
429                .any(|v| v.video_stream_id == view.video_stream_id)
430            {
431                return Err(Error::from(ErrorCode::ConstraintError));
432            }
433            s.dptz
434                .push(view)
435                .map_err(|_| Error::from(ErrorCode::ResourceExhausted))
436        })?;
437        self.dataver.changed();
438        Ok(())
439    }
440
441    /// Remove a DPTZ stream entry by video stream ID.
442    /// Returns `NotFound` if no entry with that ID exists.
443    pub fn remove_dptz_stream(&self, video_stream_id: u16) -> Result<(), Error> {
444        self.state.lock(|cell| -> Result<(), Error> {
445            let mut s = cell.borrow_mut();
446            let pos = s
447                .dptz
448                .iter()
449                .position(|v| v.video_stream_id == video_stream_id)
450                .ok_or(Error::from(ErrorCode::NotFound))?;
451            s.dptz.swap_remove(pos);
452            Ok(())
453        })?;
454        self.dataver.changed();
455        Ok(())
456    }
457
458    /// Pre-seed an MPTZ preset at boot. Returns `ConstraintError` if
459    /// the id is out of range, `ResourceExhausted` if the table is full.
460    pub fn add_preallocated_preset(&self, preset: MptzPreset) -> Result<(), Error> {
461        self.validate_preset_id(preset.preset_id)?;
462        self.validate_mptz(&preset.settings)?;
463        if preset.name.is_empty() {
464            return Err(ErrorCode::ConstraintError.into());
465        }
466        self.state.lock(|cell| {
467            let mut s = cell.borrow_mut();
468            if s.presets.iter().any(|p| p.preset_id == preset.preset_id) {
469                return Err(Error::from(ErrorCode::ConstraintError));
470            }
471            s.presets
472                .push(preset)
473                .map_err(|_| Error::from(ErrorCode::ResourceExhausted))
474        })?;
475        self.dataver.changed();
476        Ok(())
477    }
478
479    /// Snapshot of the current MPTZ position. Useful for tests and
480    /// example wiring.
481    pub fn current_mptz(&self) -> Mptz {
482        self.ensure_seeded();
483        self.state.lock(|cell| cell.borrow().mptz)
484    }
485
486    /// Snapshot of the DPTZ viewport list.
487    pub fn dptz_streams(&self) -> Vec<DptzView, NS> {
488        self.state.lock(|cell| cell.borrow().dptz.clone())
489    }
490
491    /// Snapshot of the preset list.
492    pub fn presets(&self) -> Vec<MptzPreset, NP> {
493        self.state.lock(|cell| cell.borrow().presets.clone())
494    }
495
496    fn ensure_seeded(&self) {
497        self.state.lock(|cell| {
498            let mut s = cell.borrow_mut();
499            if !s.seeded {
500                s.mptz = self.masked_default_position();
501                s.seeded = true;
502            }
503        });
504    }
505
506    /// Filter `default_position` against the enabled feature set so
507    /// disabled axes are reported as `None`.
508    fn masked_default_position(&self) -> Mptz {
509        Mptz {
510            pan: if self.has_feature(decl::Feature::MECHANICAL_PAN.bits()) {
511                self.config.default_position.pan
512            } else {
513                None
514            },
515            tilt: if self.has_feature(decl::Feature::MECHANICAL_TILT.bits()) {
516                self.config.default_position.tilt
517            } else {
518                None
519            },
520            zoom: if self.has_feature(decl::Feature::MECHANICAL_ZOOM.bits()) {
521                self.config.default_position.zoom
522            } else {
523                None
524            },
525        }
526    }
527
528    fn any_mptz_axis(&self) -> bool {
529        self.has_feature(decl::Feature::MECHANICAL_PAN.bits())
530            || self.has_feature(decl::Feature::MECHANICAL_TILT.bits())
531            || self.has_feature(decl::Feature::MECHANICAL_ZOOM.bits())
532    }
533
534    fn validate_preset_id(&self, id: u8) -> Result<(), Error> {
535        if id == 0 || id > self.config.max_presets {
536            return Err(ErrorCode::ConstraintError.into());
537        }
538        Ok(())
539    }
540
541    /// Validate an MPTZ target against the enabled feature set and the
542    /// configured ranges. Disabled axes must be `None`; enabled axes
543    /// must be `Some` and in range.
544    fn validate_mptz(&self, m: &Mptz) -> Result<(), Error> {
545        let pan_en = self.has_feature(decl::Feature::MECHANICAL_PAN.bits());
546        let tilt_en = self.has_feature(decl::Feature::MECHANICAL_TILT.bits());
547        let zoom_en = self.has_feature(decl::Feature::MECHANICAL_ZOOM.bits());
548
549        match (pan_en, m.pan) {
550            (true, Some(v)) if v < self.config.pan_range.0 || v > self.config.pan_range.1 => {
551                return Err(ErrorCode::ConstraintError.into());
552            }
553            (true, None) | (true, Some(_)) => {}
554            (false, Some(_)) => return Err(ErrorCode::ConstraintError.into()),
555            (false, None) => {}
556        }
557        match (tilt_en, m.tilt) {
558            (true, Some(v)) if v < self.config.tilt_range.0 || v > self.config.tilt_range.1 => {
559                return Err(ErrorCode::ConstraintError.into());
560            }
561            (true, None) | (true, Some(_)) => {}
562            (false, Some(_)) => return Err(ErrorCode::ConstraintError.into()),
563            (false, None) => {}
564        }
565        match (zoom_en, m.zoom) {
566            (true, Some(v)) if v < 1 || v > self.config.zoom_max => {
567                return Err(ErrorCode::ConstraintError.into());
568            }
569            (true, None) | (true, Some(_)) => {}
570            (false, Some(_)) => return Err(ErrorCode::ConstraintError.into()),
571            (false, None) => {}
572        }
573        Ok(())
574    }
575
576    fn clamp_axis_i16(&self, lo: i16, hi: i16, v: i32) -> i16 {
577        v.max(lo as i32).min(hi as i32) as i16
578    }
579
580    fn clamp_zoom(&self, v: i32) -> u8 {
581        v.max(1).min(self.config.zoom_max as i32) as u8
582    }
583}
584
585// -----------------------------------------------------------------------
586// ClusterAsyncHandler impl
587// -----------------------------------------------------------------------
588
589impl<H, const NP: usize, const NS: usize> ClusterAsyncHandler for CamAvSettingsHandler<H, NP, NS>
590where
591    H: CamAvSettingsHooks,
592{
593    const CLUSTER: Cluster<'static> = CLUSTER_FULL;
594
595    fn dataver(&self) -> u32 {
596        self.dataver.get()
597    }
598
599    fn dataver_changed(&self) {
600        self.dataver.changed();
601    }
602
603    // ---- attributes ----
604
605    async fn mptz_position<P: TLVBuilderParent>(
606        &self,
607        _ctx: impl ReadContext,
608        builder: MPTZStructBuilder<P>,
609    ) -> Result<P, Error> {
610        if !self.any_mptz_axis() {
611            return Err(ErrorCode::AttributeNotFound.into());
612        }
613        self.ensure_seeded();
614        let m = self.state.lock(|cell| cell.borrow().mptz);
615        builder.pan(m.pan)?.tilt(m.tilt)?.zoom(m.zoom)?.end()
616    }
617
618    async fn max_presets(&self, _ctx: impl ReadContext) -> Result<u8, Error> {
619        if !self.has_feature(decl::Feature::MECHANICAL_PRESETS.bits()) {
620            return Err(ErrorCode::AttributeNotFound.into());
621        }
622        Ok(self.config.max_presets.min(NP as u8))
623    }
624
625    async fn mptz_presets<P: TLVBuilderParent>(
626        &self,
627        _ctx: impl ReadContext,
628        builder: ArrayAttributeRead<MPTZPresetStructArrayBuilder<P>, MPTZPresetStructBuilder<P>>,
629    ) -> Result<P, Error> {
630        if !self.has_feature(decl::Feature::MECHANICAL_PRESETS.bits()) {
631            return Err(ErrorCode::AttributeNotFound.into());
632        }
633        let snapshot = self.state.lock(|cell| cell.borrow().presets.clone());
634        match builder {
635            ArrayAttributeRead::ReadAll(mut b) => {
636                for p in snapshot.iter() {
637                    b = write_preset(b.push()?, p)?;
638                }
639                b.end()
640            }
641            ArrayAttributeRead::ReadOne(idx, b) => {
642                let Some(p) = snapshot.get(idx as usize) else {
643                    return Err(ErrorCode::ConstraintError.into());
644                };
645                write_preset(b, p)
646            }
647            ArrayAttributeRead::ReadNone(b) => b.end(),
648        }
649    }
650
651    async fn dptz_streams<P: TLVBuilderParent>(
652        &self,
653        _ctx: impl ReadContext,
654        builder: ArrayAttributeRead<DPTZStructArrayBuilder<P>, DPTZStructBuilder<P>>,
655    ) -> Result<P, Error> {
656        if !self.has_feature(decl::Feature::DIGITAL_PTZ.bits()) {
657            return Err(ErrorCode::AttributeNotFound.into());
658        }
659        let snapshot = self.state.lock(|cell| cell.borrow().dptz.clone());
660        match builder {
661            ArrayAttributeRead::ReadAll(mut b) => {
662                for v in snapshot.iter() {
663                    b = write_dptz(b.push()?, v)?;
664                }
665                b.end()
666            }
667            ArrayAttributeRead::ReadOne(idx, b) => {
668                let Some(v) = snapshot.get(idx as usize) else {
669                    return Err(ErrorCode::ConstraintError.into());
670                };
671                write_dptz(b, v)
672            }
673            ArrayAttributeRead::ReadNone(b) => b.end(),
674        }
675    }
676
677    async fn zoom_max(&self, _ctx: impl ReadContext) -> Result<u8, Error> {
678        if !self.has_feature(decl::Feature::MECHANICAL_ZOOM.bits()) {
679            return Err(ErrorCode::AttributeNotFound.into());
680        }
681        Ok(self.config.zoom_max)
682    }
683
684    async fn tilt_min(&self, _ctx: impl ReadContext) -> Result<i16, Error> {
685        if !self.has_feature(decl::Feature::MECHANICAL_TILT.bits()) {
686            return Err(ErrorCode::AttributeNotFound.into());
687        }
688        Ok(self.config.tilt_range.0)
689    }
690
691    async fn tilt_max(&self, _ctx: impl ReadContext) -> Result<i16, Error> {
692        if !self.has_feature(decl::Feature::MECHANICAL_TILT.bits()) {
693            return Err(ErrorCode::AttributeNotFound.into());
694        }
695        Ok(self.config.tilt_range.1)
696    }
697
698    async fn pan_min(&self, _ctx: impl ReadContext) -> Result<i16, Error> {
699        if !self.has_feature(decl::Feature::MECHANICAL_PAN.bits()) {
700            return Err(ErrorCode::AttributeNotFound.into());
701        }
702        Ok(self.config.pan_range.0)
703    }
704
705    async fn pan_max(&self, _ctx: impl ReadContext) -> Result<i16, Error> {
706        if !self.has_feature(decl::Feature::MECHANICAL_PAN.bits()) {
707            return Err(ErrorCode::AttributeNotFound.into());
708        }
709        Ok(self.config.pan_range.1)
710    }
711
712    async fn movement_state(&self, _ctx: impl ReadContext) -> Result<PhysicalMovementEnum, Error> {
713        if !self.any_mptz_axis() {
714            return Err(ErrorCode::AttributeNotFound.into());
715        }
716        // Commands complete synchronously from the data-model's
717        // perspective, so we always report Idle outside of an in-flight
718        // invocation.
719        Ok(PhysicalMovementEnum::Idle)
720    }
721
722    // ---- commands ----
723
724    async fn handle_mptz_set_position(
725        &self,
726        ctx: impl InvokeContext,
727        request: MPTZSetPositionRequest<'_>,
728    ) -> Result<(), Error> {
729        if !self.any_mptz_axis() {
730            return Err(ErrorCode::InvalidAction.into());
731        }
732        self.ensure_seeded();
733
734        let req_pan = request.pan()?;
735        let req_tilt = request.tilt()?;
736        let req_zoom = request.zoom()?;
737
738        let prior = self.state.lock(|cell| cell.borrow().mptz);
739        let target = Mptz {
740            pan: if self.has_feature(decl::Feature::MECHANICAL_PAN.bits()) {
741                req_pan.or(prior.pan)
742            } else {
743                None
744            },
745            tilt: if self.has_feature(decl::Feature::MECHANICAL_TILT.bits()) {
746                req_tilt.or(prior.tilt)
747            } else {
748                None
749            },
750            zoom: if self.has_feature(decl::Feature::MECHANICAL_ZOOM.bits()) {
751                req_zoom.or(prior.zoom)
752            } else {
753                None
754            },
755        };
756        // Reject sets touching disabled axes outright.
757        if (!self.has_feature(decl::Feature::MECHANICAL_PAN.bits()) && req_pan.is_some())
758            || (!self.has_feature(decl::Feature::MECHANICAL_TILT.bits()) && req_tilt.is_some())
759            || (!self.has_feature(decl::Feature::MECHANICAL_ZOOM.bits()) && req_zoom.is_some())
760        {
761            return Err(ErrorCode::ConstraintError.into());
762        }
763        self.validate_mptz(&target)?;
764
765        self.state.lock(|cell| cell.borrow_mut().mptz = target);
766        if let Err(e) = self.hooks.mptz_apply(&target).await {
767            self.state.lock(|cell| cell.borrow_mut().mptz = prior);
768            return Err(e.into());
769        }
770        ctx.notify_own_attr_changed(AttributeId::MPTZPosition as _);
771        Ok(())
772    }
773
774    async fn handle_mptz_relative_move(
775        &self,
776        ctx: impl InvokeContext,
777        request: MPTZRelativeMoveRequest<'_>,
778    ) -> Result<(), Error> {
779        if !self.any_mptz_axis() {
780            return Err(ErrorCode::InvalidAction.into());
781        }
782        self.ensure_seeded();
783
784        let pan_delta = request.pan_delta()?;
785        let tilt_delta = request.tilt_delta()?;
786        let zoom_delta = request.zoom_delta()?;
787
788        if (!self.has_feature(decl::Feature::MECHANICAL_PAN.bits()) && pan_delta.is_some())
789            || (!self.has_feature(decl::Feature::MECHANICAL_TILT.bits()) && tilt_delta.is_some())
790            || (!self.has_feature(decl::Feature::MECHANICAL_ZOOM.bits()) && zoom_delta.is_some())
791        {
792            return Err(ErrorCode::ConstraintError.into());
793        }
794
795        let prior = self.state.lock(|cell| cell.borrow().mptz);
796        let mut target = prior;
797        if let (true, Some(d)) = (
798            self.has_feature(decl::Feature::MECHANICAL_PAN.bits()),
799            pan_delta,
800        ) {
801            let cur = prior.pan.unwrap_or(0) as i32 + d as i32;
802            target.pan =
803                Some(self.clamp_axis_i16(self.config.pan_range.0, self.config.pan_range.1, cur));
804        }
805        if let (true, Some(d)) = (
806            self.has_feature(decl::Feature::MECHANICAL_TILT.bits()),
807            tilt_delta,
808        ) {
809            let cur = prior.tilt.unwrap_or(0) as i32 + d as i32;
810            target.tilt =
811                Some(self.clamp_axis_i16(self.config.tilt_range.0, self.config.tilt_range.1, cur));
812        }
813        if let (true, Some(d)) = (
814            self.has_feature(decl::Feature::MECHANICAL_ZOOM.bits()),
815            zoom_delta,
816        ) {
817            let cur = prior.zoom.unwrap_or(1) as i32 + d as i32;
818            target.zoom = Some(self.clamp_zoom(cur));
819        }
820
821        self.state.lock(|cell| cell.borrow_mut().mptz = target);
822        if let Err(e) = self.hooks.mptz_apply(&target).await {
823            self.state.lock(|cell| cell.borrow_mut().mptz = prior);
824            return Err(e.into());
825        }
826        ctx.notify_own_attr_changed(AttributeId::MPTZPosition as _);
827        Ok(())
828    }
829
830    async fn handle_mptz_move_to_preset(
831        &self,
832        ctx: impl InvokeContext,
833        request: MPTZMoveToPresetRequest<'_>,
834    ) -> Result<(), Error> {
835        if !self.has_feature(decl::Feature::MECHANICAL_PRESETS.bits()) {
836            return Err(ErrorCode::InvalidAction.into());
837        }
838        let id = request.preset_id()?;
839        self.validate_preset_id(id)?;
840        let preset = self.state.lock(|cell| {
841            cell.borrow()
842                .presets
843                .iter()
844                .find(|p| p.preset_id == id)
845                .cloned()
846        });
847        let Some(preset) = preset else {
848            return Err(ErrorCode::NotFound.into());
849        };
850        // Apply via the same path as set_position, with rollback.
851        self.ensure_seeded();
852        let prior = self.state.lock(|cell| cell.borrow().mptz);
853        self.state
854            .lock(|cell| cell.borrow_mut().mptz = preset.settings);
855        if let Err(e) = self.hooks.mptz_apply(&preset.settings).await {
856            self.state.lock(|cell| cell.borrow_mut().mptz = prior);
857            return Err(e.into());
858        }
859        ctx.notify_own_attr_changed(AttributeId::MPTZPosition as _);
860        Ok(())
861    }
862
863    async fn handle_mptz_save_preset(
864        &self,
865        ctx: impl InvokeContext,
866        request: MPTZSavePresetRequest<'_>,
867    ) -> Result<(), Error> {
868        if !self.has_feature(decl::Feature::MECHANICAL_PRESETS.bits()) {
869            return Err(ErrorCode::InvalidAction.into());
870        }
871        self.ensure_seeded();
872
873        let req_id = request.preset_id()?;
874        let name_str: Utf8Str<'_> = request.name()?;
875        let mut name: HString<MAX_PRESET_NAME_LEN> = HString::new();
876        if name_str.is_empty()
877            || name_str.len() > MAX_PRESET_NAME_LEN
878            || name.push_str(name_str).is_err()
879        {
880            return Err(ErrorCode::ConstraintError.into());
881        }
882
883        let id = match req_id {
884            Some(i) => {
885                self.validate_preset_id(i)?;
886                i
887            }
888            None => {
889                // Allocate the lowest free id within
890                // 1..=max_presets.
891                let max_id = self.config.max_presets.min(NP as u8);
892                let used: heapless::Vec<u8, NP> = self.state.lock(|cell| {
893                    let mut v: heapless::Vec<u8, NP> = heapless::Vec::new();
894                    for p in cell.borrow().presets.iter() {
895                        let _ = v.push(p.preset_id);
896                    }
897                    v
898                });
899                let mut chosen: Option<u8> = None;
900                for cand in 1..=max_id {
901                    if !used.contains(&cand) {
902                        chosen = Some(cand);
903                        break;
904                    }
905                }
906                chosen.ok_or_else(|| Error::from(ErrorCode::ResourceExhausted))?
907            }
908        };
909
910        let settings = self.state.lock(|cell| cell.borrow().mptz);
911        self.validate_mptz(&settings)?;
912        let preset = MptzPreset {
913            preset_id: id,
914            name,
915            settings,
916        };
917
918        // Replace or insert; remember prior for rollback.
919        let prior = self.state.lock(|cell| {
920            cell.borrow()
921                .presets
922                .iter()
923                .find(|p| p.preset_id == id)
924                .cloned()
925        });
926        let pushed = self.state.lock(|cell| {
927            let mut s = cell.borrow_mut();
928            if let Some(p) = s.presets.iter_mut().find(|p| p.preset_id == id) {
929                *p = preset.clone();
930                true
931            } else {
932                s.presets.push(preset.clone()).is_ok()
933            }
934        });
935        if !pushed {
936            return Err(ErrorCode::ResourceExhausted.into());
937        }
938
939        if let Err(e) = self.hooks.preset_saved(&preset).await {
940            self.state.lock(|cell| {
941                let mut s = cell.borrow_mut();
942                match prior {
943                    Some(p) => {
944                        if let Some(slot) = s.presets.iter_mut().find(|x| x.preset_id == id) {
945                            *slot = p;
946                        }
947                    }
948                    None => s.presets.retain(|x| x.preset_id != id),
949                }
950            });
951            return Err(e.into());
952        }
953        ctx.notify_own_attr_changed(AttributeId::MPTZPresets as _);
954        Ok(())
955    }
956
957    async fn handle_mptz_remove_preset(
958        &self,
959        ctx: impl InvokeContext,
960        request: MPTZRemovePresetRequest<'_>,
961    ) -> Result<(), Error> {
962        if !self.has_feature(decl::Feature::MECHANICAL_PRESETS.bits()) {
963            return Err(ErrorCode::InvalidAction.into());
964        }
965        let id = request.preset_id()?;
966        self.validate_preset_id(id)?;
967        let existed = self
968            .state
969            .lock(|cell| cell.borrow().presets.iter().any(|p| p.preset_id == id));
970        if !existed {
971            return Err(ErrorCode::NotFound.into());
972        }
973        self.hooks.preset_removed(id).await?;
974        self.state.lock(|cell| {
975            let mut s = cell.borrow_mut();
976            s.presets.retain(|p| p.preset_id != id);
977        });
978        ctx.notify_own_attr_changed(AttributeId::MPTZPresets as _);
979        Ok(())
980    }
981
982    async fn handle_dptz_set_viewport(
983        &self,
984        ctx: impl InvokeContext,
985        request: DPTZSetViewportRequest<'_>,
986    ) -> Result<(), Error> {
987        if !self.has_feature(decl::Feature::DIGITAL_PTZ.bits()) {
988            return Err(ErrorCode::InvalidAction.into());
989        }
990        let stream_id = request.video_stream_id()?;
991        let vp = request.viewport()?;
992        let view = DptzView {
993            video_stream_id: stream_id,
994            x1: vp.x_1()?,
995            y1: vp.y_1()?,
996            x2: vp.x_2()?,
997            y2: vp.y_2()?,
998        };
999        view.validate()?;
1000
1001        let prior = self.state.lock(|cell| {
1002            cell.borrow()
1003                .dptz
1004                .iter()
1005                .find(|v| v.video_stream_id == stream_id)
1006                .copied()
1007        });
1008        let pushed = self.state.lock(|cell| {
1009            let mut s = cell.borrow_mut();
1010            if let Some(slot) = s.dptz.iter_mut().find(|v| v.video_stream_id == stream_id) {
1011                *slot = view;
1012                true
1013            } else {
1014                s.dptz.push(view).is_ok()
1015            }
1016        });
1017        if !pushed {
1018            return Err(ErrorCode::ResourceExhausted.into());
1019        }
1020
1021        if let Err(e) = self.hooks.dptz_apply(&view).await {
1022            self.state.lock(|cell| {
1023                let mut s = cell.borrow_mut();
1024                match prior {
1025                    Some(p) => {
1026                        if let Some(slot) =
1027                            s.dptz.iter_mut().find(|v| v.video_stream_id == stream_id)
1028                        {
1029                            *slot = p;
1030                        }
1031                    }
1032                    None => s.dptz.retain(|v| v.video_stream_id != stream_id),
1033                }
1034            });
1035            return Err(e.into());
1036        }
1037        ctx.notify_own_attr_changed(AttributeId::DPTZStreams as _);
1038        Ok(())
1039    }
1040
1041    async fn handle_dptz_relative_move(
1042        &self,
1043        ctx: impl InvokeContext,
1044        request: DPTZRelativeMoveRequest<'_>,
1045    ) -> Result<(), Error> {
1046        if !self.has_feature(decl::Feature::DIGITAL_PTZ.bits()) {
1047            return Err(ErrorCode::InvalidAction.into());
1048        }
1049        let stream_id = request.video_stream_id()?;
1050        let dx = request.delta_x()?.unwrap_or(0) as i32;
1051        let dy = request.delta_y()?.unwrap_or(0) as i32;
1052        let dz = request.zoom_delta()?.unwrap_or(0) as i32;
1053
1054        let prior = self.state.lock(|cell| {
1055            cell.borrow()
1056                .dptz
1057                .iter()
1058                .find(|v| v.video_stream_id == stream_id)
1059                .copied()
1060        });
1061        let Some(prior) = prior else {
1062            return Err(ErrorCode::NotFound.into());
1063        };
1064
1065        // Translate. Zoom delta is interpreted as a pixel shrink/expand
1066        // applied symmetrically — clamps prevent inversion. The actual
1067        // pixel-perfect math is the application's job; the handler
1068        // only enforces ordering invariants.
1069        let mut x1 = prior.x1 as i32 + dx;
1070        let mut x2 = prior.x2 as i32 + dx;
1071        let mut y1 = prior.y1 as i32 + dy;
1072        let mut y2 = prior.y2 as i32 + dy;
1073        x1 = x1.max(0);
1074        y1 = y1.max(0);
1075        x2 = x2.max(x1 + 1);
1076        y2 = y2.max(y1 + 1);
1077        if dz != 0 {
1078            let half = dz / 2;
1079            x1 = (x1 - half).max(0);
1080            y1 = (y1 - half).max(0);
1081            x2 = (x2 + half).max(x1 + 1);
1082            y2 = (y2 + half).max(y1 + 1);
1083        }
1084        let view = DptzView {
1085            video_stream_id: stream_id,
1086            x1: x1.min(u16::MAX as i32) as u16,
1087            y1: y1.min(u16::MAX as i32) as u16,
1088            x2: x2.min(u16::MAX as i32) as u16,
1089            y2: y2.min(u16::MAX as i32) as u16,
1090        };
1091        view.validate()?;
1092
1093        self.state.lock(|cell| {
1094            let mut s = cell.borrow_mut();
1095            if let Some(slot) = s.dptz.iter_mut().find(|v| v.video_stream_id == stream_id) {
1096                *slot = view;
1097            }
1098        });
1099        if let Err(e) = self.hooks.dptz_apply(&view).await {
1100            self.state.lock(|cell| {
1101                let mut s = cell.borrow_mut();
1102                if let Some(slot) = s.dptz.iter_mut().find(|v| v.video_stream_id == stream_id) {
1103                    *slot = prior;
1104                }
1105            });
1106            return Err(e.into());
1107        }
1108        ctx.notify_own_attr_changed(AttributeId::DPTZStreams as _);
1109        Ok(())
1110    }
1111}
1112
1113// -----------------------------------------------------------------------
1114// Local helpers
1115// -----------------------------------------------------------------------
1116
1117fn write_preset<P: TLVBuilderParent>(
1118    builder: MPTZPresetStructBuilder<P>,
1119    p: &MptzPreset,
1120) -> Result<P, Error> {
1121    let b = builder.preset_id(p.preset_id)?.name(p.name.as_str())?;
1122    let s = b.settings()?;
1123    s.pan(p.settings.pan)?
1124        .tilt(p.settings.tilt)?
1125        .zoom(p.settings.zoom)?
1126        .end()?
1127        .end()
1128}
1129
1130fn write_dptz<P: TLVBuilderParent>(
1131    builder: DPTZStructBuilder<P>,
1132    v: &DptzView,
1133) -> Result<P, Error> {
1134    let b = builder.video_stream_id(v.video_stream_id)?;
1135    b.viewport()?
1136        .x_1(v.x1)?
1137        .y_1(v.y1)?
1138        .x_2(v.x2)?
1139        .y_2(v.y2)?
1140        .end()?
1141        .end()
1142}