Skip to main content

sccp_protocol/
types.rs

1//! Application-facing identities, station configuration, and call/media data.
2//!
3//! These types sit above the raw message codec. Applications normally build a
4//! [`DeviceDefinition`], validate it, and pass it to
5//! [`crate::server::Server::bind`]. Runtime events then refer to calls, lines,
6//! conferences, and application transactions through the strongly typed IDs
7//! in this module instead of interchangeable integers.
8
9use std::borrow::Borrow;
10use std::collections::{HashMap, HashSet};
11use std::fmt;
12use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
13use std::num::NonZeroU64;
14use std::ops::Deref;
15use std::str::FromStr;
16
17use crate::message::values::{
18    CallType, Codec, DeviceType, EchoCancellation, KeyMode, ProtocolVersion, SilenceSuppression,
19    SoftKey, Tone,
20};
21use crate::message::wire::CodecError;
22
23/// Validated date and time display template advertised during registration.
24///
25/// Examples include `D/M/Y`, `Y.M.D`, and `M-D-YYA`; the final `A` selects a
26/// twelve-hour clock.
27#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
28pub struct DateTemplate(String);
29
30impl DateTemplate {
31    /// Validates and constructs a station date/time display template.
32    ///
33    /// The template must contain `D`, `M`, and either `Y` or `YY` exactly once,
34    /// separated by `/`, `.`, `-`, or a space. A trailing `A` requests a
35    /// twelve-hour clock.
36    pub fn new(value: impl Into<String>) -> Result<Self, CodecError> {
37        let value = value.into();
38        let date = value.strip_suffix('A').unwrap_or(&value);
39        let mut fields = date.split(|character: char| !character.is_ascii_alphabetic());
40        let parsed = [fields.next(), fields.next(), fields.next()];
41        let separators = date
42            .bytes()
43            .filter(|byte| !byte.is_ascii_alphabetic())
44            .collect::<Vec<_>>();
45        let valid_fields = matches!(parsed, [Some(_), Some(_), Some(_)])
46            && fields.next().is_none()
47            && parsed
48                .into_iter()
49                .flatten()
50                .all(|field| matches!(field, "D" | "M" | "Y" | "YY"))
51            && parsed
52                .into_iter()
53                .flatten()
54                .filter(|field| *field == "D")
55                .count()
56                == 1
57            && parsed
58                .into_iter()
59                .flatten()
60                .filter(|field| *field == "M")
61                .count()
62                == 1
63            && parsed
64                .into_iter()
65                .flatten()
66                .filter(|field| matches!(*field, "Y" | "YY"))
67                .count()
68                == 1;
69        if value.len() > 7
70            || separators.len() != 2
71            || !separators
72                .iter()
73                .all(|byte| matches!(byte, b'/' | b'.' | b'-' | b' '))
74            || !valid_fields
75        {
76            return Err(CodecError::InvalidDefinition(
77                "date template must contain D, M, and Y/YY once, two supported separators, and an optional trailing A for 12-hour time"
78                    .into(),
79            ));
80        }
81        Ok(Self(value))
82    }
83
84    pub fn as_str(&self) -> &str {
85        &self.0
86    }
87
88    pub fn uses_twelve_hour_clock(&self) -> bool {
89        self.0.ends_with('A')
90    }
91}
92
93impl AsRef<str> for DateTemplate {
94    fn as_ref(&self) -> &str {
95        self.as_str()
96    }
97}
98
99impl fmt::Display for DateTemplate {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        formatter.write_str(self.as_str())
102    }
103}
104
105impl FromStr for DateTemplate {
106    type Err = CodecError;
107
108    fn from_str(value: &str) -> Result<Self, Self::Err> {
109        Self::new(value)
110    }
111}
112
113impl TryFrom<String> for DateTemplate {
114    type Error = CodecError;
115
116    fn try_from(value: String) -> Result<Self, Self::Error> {
117        Self::new(value)
118    }
119}
120
121impl Default for DateTemplate {
122    fn default() -> Self {
123        Self("D/M/Y".into())
124    }
125}
126
127impl fmt::Debug for DateTemplate {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        formatter
130            .debug_tuple("DateTemplate")
131            .field(&self.0)
132            .finish()
133    }
134}
135
136/// Exact SCCP station name, normally `SEP` followed by twelve MAC digits.
137#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
138pub struct DeviceId(String);
139
140impl DeviceId {
141    /// Validates and canonicalizes a station identifier.
142    ///
143    /// Leading and trailing whitespace is removed, ASCII letters are folded
144    /// to uppercase, and the result must contain at most 15 alphanumeric
145    /// characters.
146    pub fn new(value: impl Into<String>) -> Result<Self, CodecError> {
147        let value = value.into().trim().to_ascii_uppercase();
148        if value.is_empty() || value.len() > 15 || !value.bytes().all(|b| b.is_ascii_alphanumeric())
149        {
150            return Err(CodecError::InvalidDeviceId(value));
151        }
152        Ok(Self(value))
153    }
154
155    pub fn as_str(&self) -> &str {
156        &self.0
157    }
158}
159
160impl fmt::Display for DeviceId {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        f.write_str(&self.0)
163    }
164}
165
166impl AsRef<str> for DeviceId {
167    fn as_ref(&self) -> &str {
168        self.as_str()
169    }
170}
171
172impl Borrow<str> for DeviceId {
173    fn borrow(&self) -> &str {
174        self.as_str()
175    }
176}
177
178/// Monotonically allocated identity for one accepted station session.
179///
180/// Events from a replaced connection retain its earlier generation, allowing
181/// consumers to discard work that arrives after a station reconnects.
182#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
183pub struct SessionGeneration(NonZeroU64);
184
185impl SessionGeneration {
186    /// Rejects the reserved zero value rather than creating an ambiguous
187    /// session identity.
188    pub const fn new(value: u64) -> Option<Self> {
189        match NonZeroU64::new(value) {
190            Some(value) => Some(Self(value)),
191            None => None,
192        }
193    }
194
195    pub const fn get(self) -> u64 {
196        self.0.get()
197    }
198}
199
200impl From<SessionGeneration> for u64 {
201    fn from(value: SessionGeneration) -> Self {
202        value.get()
203    }
204}
205
206impl fmt::Display for SessionGeneration {
207    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
208        self.0.fmt(formatter)
209    }
210}
211
212impl FromStr for DeviceId {
213    type Err = CodecError;
214
215    fn from_str(s: &str) -> Result<Self, Self::Err> {
216        Self::new(s)
217    }
218}
219
220impl TryFrom<String> for DeviceId {
221    type Error = CodecError;
222
223    fn try_from(value: String) -> Result<Self, Self::Error> {
224        Self::new(value)
225    }
226}
227
228macro_rules! id_newtype {
229    ($(#[$meta:meta])* $name:ident) => {
230        $(#[$meta])*
231        #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
232        pub struct $name(pub u32);
233
234        impl $name {
235            pub const fn new(value: u32) -> Self {
236                Self(value)
237            }
238
239            pub const fn get(self) -> u32 {
240                self.0
241            }
242        }
243
244        impl From<u32> for $name {
245            fn from(value: u32) -> Self {
246                Self(value)
247            }
248        }
249
250        impl From<$name> for u32 {
251            fn from(value: $name) -> Self {
252                value.0
253            }
254        }
255
256        impl fmt::Display for $name {
257            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258                self.0.fmt(f)
259            }
260        }
261    };
262}
263
264id_newtype!(/// A line or button instance on a station.
265    LineInstance);
266id_newtype!(/// A device-visible call reference.
267    CallReference);
268id_newtype!(/// A media passthrough-party identifier.
269    PassthroughPartyId);
270id_newtype!(/// A stable identifier for one device's appearance of a logical line.
271    AppearanceId);
272id_newtype!(/// A conference identifier.
273    ConferenceId);
274id_newtype!(/// A stable identifier for a participant in a conference.
275    ParticipantId);
276id_newtype!(/// An SCCP application identifier.
277    ApplicationId);
278id_newtype!(/// An application transaction identifier.
279    TransactionId);
280
281/// ECN-zeroed traffic-class octet carried by station media commands.
282#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
283pub struct MediaTrafficClass(u8);
284
285impl MediaTrafficClass {
286    pub const fn from_wire(value: u8) -> Self {
287        Self(value)
288    }
289
290    pub const fn from_dscp(dscp: u8) -> Option<Self> {
291        if dscp <= 63 {
292            Some(Self(dscp << 2))
293        } else {
294            None
295        }
296    }
297
298    pub const fn get(self) -> u8 {
299        self.0
300    }
301}
302
303impl From<MediaTrafficClass> for u8 {
304    fn from(value: MediaTrafficClass) -> Self {
305        value.get()
306    }
307}
308
309impl From<MediaTrafficClass> for u32 {
310    fn from(value: MediaTrafficClass) -> Self {
311        u32::from(value.get())
312    }
313}
314
315/// Application-local call identifier.
316///
317/// This is deliberately wider than the station-visible [`CallReference`]. The
318/// server maps between the two so a long-running application does not need to
319/// reuse its own call identities when the wire namespace rolls over.
320#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
321pub struct CallId(pub u64);
322
323impl CallId {
324    pub const fn new(value: u64) -> Self {
325        Self(value)
326    }
327
328    pub const fn get(self) -> u64 {
329        self.0
330    }
331}
332
333impl From<u64> for CallId {
334    fn from(value: u64) -> Self {
335        Self::new(value)
336    }
337}
338
339impl From<CallId> for u64 {
340    fn from(value: CallId) -> Self {
341        value.get()
342    }
343}
344
345impl fmt::Display for CallId {
346    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
347        self.0.fmt(formatter)
348    }
349}
350
351/// A logical directory number and its default station label.
352///
353/// A logical line may be presented on more than one station through distinct
354/// [`LineAppearance`] values.
355#[derive(Clone, Debug, Eq, PartialEq)]
356pub struct LineDefinition {
357    /// Nonempty directory number; [`DeviceDefinition::validate`] limits it to
358    /// 24 bytes when used by a station.
359    pub number: String,
360    pub display_name: String,
361}
362
363/// Optional caller identity substitutions for one line appearance.
364///
365/// `None` keeps the identity supplied by the call owner; an empty string is an
366/// explicit empty override.
367#[derive(Clone, Debug, Default, Eq, PartialEq)]
368pub struct CallerIdOverride {
369    pub name: Option<String>,
370    pub number: Option<String>,
371}
372
373/// Incoming-ring policy for a line appearance.
374#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
375pub enum AppearanceRingMode {
376    #[default]
377    Normal,
378    Silent,
379    Disabled,
380}
381
382/// One device's configured presentation of a logical line.
383#[derive(Clone, Debug, Eq, PartialEq)]
384pub struct LineAppearance {
385    /// Stable within the owning device definition.
386    pub id: AppearanceId,
387    /// Station-visible line instance; instances normally begin at one.
388    pub instance: u32,
389    pub line: LineDefinition,
390    /// Optional button label overriding [`LineDefinition::display_name`].
391    pub label: Option<String>,
392    pub caller_id: CallerIdOverride,
393    pub ring_mode: AppearanceRingMode,
394    /// Tone used when a new outgoing call begins on this appearance.
395    pub initial_tone: Tone,
396    /// Optional subscription identity used by presence integrations.
397    pub subscription_identity: Option<String>,
398    pub privacy: bool,
399}
400
401impl LineAppearance {
402    /// Creates an appearance using the instance as its stable appearance ID.
403    pub fn new(instance: u32, line: LineDefinition) -> Self {
404        Self {
405            id: AppearanceId::new(instance),
406            instance,
407            line,
408            label: None,
409            caller_id: CallerIdOverride::default(),
410            ring_mode: AppearanceRingMode::Normal,
411            initial_tone: Tone::InsideDial,
412            subscription_identity: None,
413            privacy: false,
414        }
415    }
416
417    /// Returns the explicit button label or the logical line's display name.
418    pub fn display_label(&self) -> &str {
419        self.label.as_deref().unwrap_or(&self.line.display_name)
420    }
421}
422
423impl Deref for LineAppearance {
424    type Target = LineDefinition;
425
426    fn deref(&self) -> &Self::Target {
427        &self.line
428    }
429}
430
431/// A programmable button that immediately dials a configured destination.
432#[derive(Clone, Debug, Eq, PartialEq)]
433pub struct SpeedDialDefinition {
434    /// Nonzero station button instance, unique among speed-dial buttons.
435    pub instance: u32,
436    pub number: String,
437    pub display_name: String,
438}
439
440/// A speed-dial button whose lamp and icon follow an external presence target.
441#[derive(Clone, Debug, Eq, PartialEq)]
442pub struct BlfSpeedDialDefinition {
443    /// Nonzero station button instance, unique among monitored speed dials.
444    pub instance: u32,
445    pub number: String,
446    pub display_name: String,
447    /// Presence-provider hint address, normally `extension@context`.
448    pub hint: String,
449}
450
451/// Semantic state of a monitored speed-dial target.
452#[derive(Clone, Copy, Debug, Eq, PartialEq)]
453pub enum BlfState {
454    Idle,
455    Ringing,
456    Busy,
457    Held,
458    Unavailable,
459    Unknown,
460}
461
462/// Caller information that policy has permitted a monitored station to see.
463#[derive(Clone, Debug, Default, Eq, PartialEq)]
464pub struct BlfCallerInfo {
465    pub name: String,
466    pub number: String,
467}
468
469impl BlfCallerInfo {
470    /// Formats the permitted name and number for station presentation.
471    pub fn display(&self) -> String {
472        match (self.name.trim(), self.number.trim()) {
473            ("", "") => String::new(),
474            ("", number) => number.to_owned(),
475            (name, "") => name.to_owned(),
476            (name, number) => format!("{name} ({number})"),
477        }
478    }
479}
480
481/// A programmable feature button and its station-visible label.
482#[derive(Clone, Debug, Eq, PartialEq)]
483pub struct FeatureDefinition {
484    /// Nonzero station button instance, unique among feature buttons.
485    pub instance: u32,
486    pub label: String,
487    pub feature: crate::message::values::ButtonType,
488}
489
490/// A programmable button that opens a phone-hosted HTTP service.
491#[derive(Clone, Debug, Eq, PartialEq)]
492pub struct ServiceDefinition {
493    /// Nonzero station button instance, unique among service buttons.
494    pub instance: u32,
495    pub label: String,
496    /// Absolute HTTP(S) URL opened by the station.
497    ///
498    /// Validation rejects fragments, control characters, excessive query
499    /// parameters, and values that exceed the station wire limits.
500    pub url: String,
501}
502
503/// An expansion module and the station slot where it is attached.
504#[derive(Clone, Debug, Eq, PartialEq)]
505pub struct AddonModuleDefinition {
506    /// One-based expansion-module slot.
507    pub slot: u32,
508    pub device_type: crate::message::values::DeviceType,
509}
510
511impl AddonModuleDefinition {
512    /// Number of physical programmable keys supplied by this sidecar model.
513    pub const fn button_capacity(&self) -> Option<usize> {
514        use crate::message::values::DeviceType;
515
516        match self.device_type {
517            DeviceType::CiscoAddon7914 => Some(14),
518            DeviceType::CiscoAddon7915_12 | DeviceType::CiscoAddon7916_12 => Some(12),
519            DeviceType::CiscoAddon7915_24 | DeviceType::CiscoAddon7916_24 => Some(24),
520            DeviceType::AddonSpa500s | DeviceType::AddonSpa500ds | DeviceType::AddonSpa932ds => {
521                Some(32)
522            }
523            _ => None,
524        }
525    }
526}
527
528/// One entry in a station's ordered physical button layout.
529///
530/// [`DeviceDefinition::validate`] checks instance uniqueness, expansion-module
531/// capacity, and the overall wire limit before the layout is sent to a phone.
532#[derive(Clone, Debug, Eq, PartialEq)]
533pub enum ButtonDefinition {
534    Line(LineAppearance),
535    SpeedDial(SpeedDialDefinition),
536    BlfSpeedDial(BlfSpeedDialDefinition),
537    Feature(FeatureDefinition),
538    Service(ServiceDefinition),
539    AddonModule(AddonModuleDefinition),
540    Unused,
541}
542
543/// Ordered soft-key actions advertised for every known station key mode.
544///
545/// The protocol template remains the canonical 32-entry action catalog; these
546/// sets choose which catalog entries appear in each mode and in which order.
547#[derive(Clone, Debug, Eq, PartialEq)]
548pub struct SoftKeyProfile {
549    sets: HashMap<KeyMode, Vec<SoftKey>>,
550}
551
552impl SoftKeyProfile {
553    /// Maximum number of actions that one station key mode can advertise.
554    pub const MAX_KEYS_PER_MODE: usize = 16;
555
556    /// Builds and validates a complete profile.
557    ///
558    /// Every known [`KeyMode`] must occur exactly once. Use [`Self::empty`] as
559    /// a convenient base when only a few modes should expose actions.
560    pub fn new(
561        sets: impl IntoIterator<Item = (KeyMode, Vec<SoftKey>)>,
562    ) -> Result<Self, CodecError> {
563        let profile = Self {
564            sets: sets.into_iter().collect(),
565        };
566        profile.validate()?;
567        Ok(profile)
568    }
569
570    pub fn empty() -> Self {
571        Self {
572            sets: KeyMode::ALL_KNOWN
573                .iter()
574                .copied()
575                .map(|mode| (mode, Vec::new()))
576                .collect(),
577        }
578    }
579
580    /// The wire-compatible profile used when a station has no configured
581    /// override.
582    pub fn built_in() -> Self {
583        let mut profile = Self::empty();
584        profile.sets.extend([
585            (KeyMode::OnHook, vec![SoftKey::NewCall]),
586            (
587                KeyMode::Connected,
588                vec![SoftKey::Hold, SoftKey::EndCall, SoftKey::Transfer],
589            ),
590            (
591                KeyMode::OnHold,
592                vec![SoftKey::Resume, SoftKey::NewCall, SoftKey::EndCall],
593            ),
594            (KeyMode::RingIn, vec![SoftKey::Answer, SoftKey::EndCall]),
595            (KeyMode::OffHook, vec![SoftKey::EndCall]),
596            (
597                KeyMode::ConnectedTransfer,
598                vec![SoftKey::Hold, SoftKey::EndCall, SoftKey::Transfer],
599            ),
600            (
601                KeyMode::DigitsFollowing,
602                vec![SoftKey::Backspace, SoftKey::EndCall, SoftKey::Dial],
603            ),
604            (
605                KeyMode::ConnectedConference,
606                vec![SoftKey::Hold, SoftKey::EndCall],
607            ),
608            (KeyMode::RingOut, vec![SoftKey::EndCall]),
609            (
610                KeyMode::OffHookFeature,
611                vec![SoftKey::Resume, SoftKey::NewCall, SoftKey::EndCall],
612            ),
613            (
614                KeyMode::OnHookStealable,
615                vec![SoftKey::Intercept, SoftKey::NewCall],
616            ),
617            (
618                KeyMode::HoldConference,
619                vec![SoftKey::Resume, SoftKey::NewCall, SoftKey::EndCall],
620            ),
621        ]);
622        profile
623    }
624
625    pub fn actions(&self, mode: KeyMode) -> &[SoftKey] {
626        self.sets.get(&mode).map_or(&[], Vec::as_slice)
627    }
628
629    pub fn allows(&self, mode: KeyMode, action: SoftKey) -> bool {
630        action.is_known() && self.actions(mode).contains(&action)
631    }
632
633    /// Returns the station bit mask enabling every configured action in `mode`.
634    pub fn valid_mask(&self, mode: KeyMode) -> u32 {
635        let count = self.actions(mode).len();
636        if count == 0 { 0 } else { (1_u32 << count) - 1 }
637    }
638
639    /// Actions whose labels are needed in the station template. The built-in
640    /// profile intentionally retains the historical complete catalog bytes.
641    pub fn template_actions(&self) -> Vec<SoftKey> {
642        if self == &Self::built_in() {
643            return SoftKey::ALL_KNOWN.to_vec();
644        }
645        let configured: HashSet<_> = KeyMode::ALL_KNOWN
646            .iter()
647            .flat_map(|mode| self.actions(*mode).iter().copied())
648            .collect();
649        SoftKey::ALL_KNOWN
650            .iter()
651            .copied()
652            .filter(|action| configured.contains(action))
653            .collect()
654    }
655
656    /// Verifies completeness, per-mode limits, known values, and uniqueness.
657    pub fn validate(&self) -> Result<(), CodecError> {
658        if self.sets.len() != KeyMode::ALL_KNOWN.len()
659            || KeyMode::ALL_KNOWN
660                .iter()
661                .any(|mode| !self.sets.contains_key(mode))
662        {
663            return Err(CodecError::InvalidDefinition(
664                "soft-key profile must define every known key mode".into(),
665            ));
666        }
667        for (&mode, actions) in &self.sets {
668            if !mode.is_known() {
669                return Err(CodecError::InvalidDefinition(format!(
670                    "soft-key profile contains unknown key mode {}",
671                    mode.wire_value()
672                )));
673            }
674            if actions.len() > Self::MAX_KEYS_PER_MODE {
675                return Err(CodecError::InvalidDefinition(format!(
676                    "soft-key mode {} contains {} actions; the protocol limit is {}",
677                    mode.wire_value(),
678                    actions.len(),
679                    Self::MAX_KEYS_PER_MODE
680                )));
681            }
682            let mut seen = HashSet::new();
683            for &action in actions {
684                if !action.is_known() {
685                    return Err(CodecError::InvalidDefinition(format!(
686                        "soft-key mode {} contains unknown action {}",
687                        mode.wire_value(),
688                        action.wire_value()
689                    )));
690                }
691                if !seen.insert(action) {
692                    return Err(CodecError::InvalidDefinition(format!(
693                        "soft-key mode {} repeats action {}",
694                        mode.wire_value(),
695                        action.wire_value()
696                    )));
697                }
698            }
699        }
700        Ok(())
701    }
702}
703
704impl Default for SoftKeyProfile {
705    fn default() -> Self {
706        Self::built_in()
707    }
708}
709
710/// Complete phone-facing configuration for one station.
711///
712/// Call [`Self::validate`] before starting or reconfiguring a server. A valid
713/// definition has at least one line, unique nonzero button instances, a
714/// complete soft-key profile, and no more physical buttons than the protocol
715/// can advertise.
716#[derive(Clone, Debug, Eq, PartialEq)]
717pub struct DeviceDefinition {
718    pub id: DeviceId,
719    pub description: String,
720    pub transport: StationTransportRequirement,
721    /// Socket marking selected after this station identifies itself. `None`
722    /// inherits the server-wide signaling policy.
723    pub signaling_qos: Option<SignalingQos>,
724    /// Physical station buttons in display order.
725    ///
726    /// Line instances remain protocol-level identifiers carried by
727    /// [`LineAppearance`]; they are not inferred from the vector index once
728    /// non-line buttons are present.
729    pub buttons: Vec<ButtonDefinition>,
730    /// Fully resolved station soft-key policy.
731    pub soft_keys: SoftKeyProfile,
732    /// Per-station presentation behavior that belongs at the phone boundary.
733    pub ui: StationUiPolicy,
734}
735
736/// Transport admission policy configured for one station.
737#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
738pub enum StationTransportRequirement {
739    Clear,
740    Secure,
741    #[default]
742    Either,
743}
744
745/// Transport used by an accepted station session.
746#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
747pub enum StationTransport {
748    Clear,
749    Secure,
750}
751
752/// Network-layer marking for station signaling traffic.
753///
754/// DSCP occupies the upper six bits of the IPv4 type-of-service or IPv6
755/// traffic-class field. COS is applied as a socket priority on platforms that
756/// expose that facility; unsupported priority marking is reported separately
757/// from DSCP and does not make a session unusable.
758#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
759pub struct SignalingQos {
760    pub dscp: u8,
761    pub cos: u8,
762}
763
764impl SignalingQos {
765    pub const fn new(dscp: u8, cos: u8) -> Self {
766        Self { dscp, cos }
767    }
768
769    pub(crate) fn validate(self) -> Result<(), CodecError> {
770        if self.dscp > 63 {
771            return Err(CodecError::InvalidDefinition(format!(
772                "signaling DSCP {} is outside 0..=63",
773                self.dscp
774            )));
775        }
776        if self.cos > 7 {
777            return Err(CodecError::InvalidDefinition(format!(
778                "signaling COS {} is outside 0..=7",
779                self.cos
780            )));
781        }
782        Ok(())
783    }
784}
785
786/// Phone-facing behavior that can vary independently for each configured
787/// station without changing logical line ownership.
788#[derive(Clone, Copy, Debug, Eq, PartialEq)]
789pub struct StationUiPolicy {
790    /// Ask capable displays to open their native placed-calls application when
791    /// Redial is pressed. Older displays retain last-number redial.
792    pub placed_calls_redial_menu: bool,
793    /// Permit a ringing-only notification in addition to the ordinary BLF
794    /// icon/lamp projection. Non-ringing BLF states are always delivered.
795    pub hinted_ringing_notification: bool,
796    /// Lamp cadence used while the station has waiting voicemail.
797    pub mwi_lamp_mode: crate::message::values::LampMode,
798    /// Keep MWI visible while any call is active on the station.
799    pub mwi_on_call: bool,
800    /// Single-byte encoding used only when the handset does not advertise
801    /// native UTF-8 text support.
802    pub legacy_code_page: LegacyCodePage,
803}
804
805/// Single-byte character set used for stations without native UTF-8 support.
806/// Characters outside the selected set are replaced during encoding.
807#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
808pub enum LegacyCodePage {
809    #[default]
810    Iso8859_1,
811    Ascii,
812}
813
814impl Default for StationUiPolicy {
815    fn default() -> Self {
816        Self {
817            placed_calls_redial_menu: false,
818            hinted_ringing_notification: false,
819            mwi_lamp_mode: crate::message::values::LampMode::On,
820            mwi_on_call: false,
821            legacy_code_page: LegacyCodePage::Iso8859_1,
822        }
823    }
824}
825
826impl DeviceDefinition {
827    /// Validates the station definition and its nested button/service policies.
828    pub fn validate(&self) -> Result<(), CodecError> {
829        const MAX_BUTTONS: usize = 42;
830
831        self.soft_keys.validate()?;
832        if let Some(signaling_qos) = self.signaling_qos {
833            signaling_qos.validate()?;
834        }
835
836        if self.buttons.len() > MAX_BUTTONS {
837            return Err(CodecError::InvalidDefinition(format!(
838                "device {} has {} buttons; the protocol limit is {MAX_BUTTONS}",
839                self.id,
840                self.buttons.len()
841            )));
842        }
843
844        let mut expanded_buttons = 0_usize;
845        let mut addon_buttons_remaining = None;
846        for button in &self.buttons {
847            if let ButtonDefinition::AddonModule(addon) = button {
848                expanded_buttons += addon_buttons_remaining.take().unwrap_or_default();
849                addon_buttons_remaining = Some(addon.button_capacity().ok_or_else(|| {
850                    CodecError::InvalidDefinition(format!(
851                        "device {} has unsupported addon-module type {}",
852                        self.id,
853                        addon.device_type.wire_value()
854                    ))
855                })?);
856                continue;
857            }
858            expanded_buttons += 1;
859            if let Some(remaining) = &mut addon_buttons_remaining {
860                if *remaining == 0 {
861                    return Err(CodecError::InvalidDefinition(format!(
862                        "device {} configures more buttons than its addon module provides",
863                        self.id
864                    )));
865                }
866                *remaining -= 1;
867            }
868        }
869        expanded_buttons += addon_buttons_remaining.unwrap_or_default();
870        if expanded_buttons > MAX_BUTTONS {
871            return Err(CodecError::InvalidDefinition(format!(
872                "device {} expands to {expanded_buttons} buttons; the protocol limit is {MAX_BUTTONS}",
873                self.id
874            )));
875        }
876
877        let mut instances = HashSet::new();
878        let mut appearance_ids = HashSet::new();
879        for button in &self.buttons {
880            let Some((kind, instance)) = button.instance_key() else {
881                continue;
882            };
883            if instance == 0 {
884                return Err(CodecError::InvalidDefinition(format!(
885                    "device {} has a {kind} button with instance zero",
886                    self.id
887                )));
888            }
889            if !instances.insert((kind, instance)) {
890                return Err(CodecError::InvalidDefinition(format!(
891                    "device {} repeats {kind} button instance {instance}",
892                    self.id
893                )));
894            }
895            if let ButtonDefinition::Line(appearance) = button {
896                if appearance.id.get() == 0 {
897                    return Err(CodecError::InvalidDefinition(format!(
898                        "device {} has a line appearance with identifier zero",
899                        self.id
900                    )));
901                }
902                if !appearance_ids.insert(appearance.id) {
903                    return Err(CodecError::InvalidDefinition(format!(
904                        "device {} repeats line appearance identifier {}",
905                        self.id, appearance.id
906                    )));
907                }
908            }
909            if let ButtonDefinition::Service(service) = button {
910                validate_service_definition(&self.id, service)?;
911            }
912        }
913
914        let lines: Vec<_> = self.lines().collect();
915        if lines.is_empty() {
916            return Err(CodecError::InvalidDefinition(format!(
917                "device {} has no lines",
918                self.id
919            )));
920        }
921        // Extension Mobility appearances have independent slot lifetimes. If
922        // an earlier slot logs out while a later one remains, the live button
923        // template is intentionally sparse until that slot is reused.
924        let permits_sparse_lines = self.buttons.iter().any(|button| {
925            matches!(
926                button,
927                ButtonDefinition::Feature(feature)
928                    if feature.feature == crate::message::values::ButtonType::Mobility
929            )
930        });
931        for (expected, line) in (1_u32..).zip(lines) {
932            if !permits_sparse_lines && line.instance != expected {
933                return Err(CodecError::InvalidDefinition(format!(
934                    "device {} line instances must be contiguous from 1",
935                    self.id
936                )));
937            }
938            if line.number.is_empty() || line.number.len() > 24 {
939                return Err(CodecError::InvalidDefinition(format!(
940                    "device {} has an invalid line number",
941                    self.id
942                )));
943            }
944        }
945        Ok(())
946    }
947
948    pub fn lines(&self) -> impl Iterator<Item = &LineAppearance> {
949        self.buttons.iter().filter_map(|button| match button {
950            ButtonDefinition::Line(line) => Some(line),
951            _ => None,
952        })
953    }
954
955    pub fn line(&self, instance: u32) -> Option<&LineAppearance> {
956        self.lines().find(|line| line.instance == instance)
957    }
958
959    pub fn first_line(&self) -> Option<&LineAppearance> {
960        self.lines().next()
961    }
962
963    pub fn line_count(&self) -> usize {
964        self.lines().count()
965    }
966}
967
968fn validate_service_definition(
969    device: &DeviceId,
970    service: &ServiceDefinition,
971) -> Result<(), CodecError> {
972    const MAX_SERVICE_URL_BYTES: usize = 255;
973    const MAX_SERVICE_LABEL_BYTES: usize = 39;
974    const MAX_SERVICE_PARAMETERS: usize = 32;
975    const MAX_SERVICE_PARAMETER_BYTES: usize = 128;
976
977    if service.label.is_empty()
978        || service.label.len() > MAX_SERVICE_LABEL_BYTES
979        || service.label.chars().any(char::is_control)
980    {
981        return Err(CodecError::InvalidDefinition(format!(
982            "device {device} has an invalid service label"
983        )));
984    }
985    if service.url.is_empty()
986        || service.url.len() > MAX_SERVICE_URL_BYTES
987        || service.url.chars().any(char::is_control)
988    {
989        return Err(CodecError::InvalidDefinition(format!(
990            "device {device} has an invalid service URL"
991        )));
992    }
993    let url = url::Url::parse(&service.url).map_err(|_| {
994        CodecError::InvalidDefinition(format!("device {device} has a malformed service URL"))
995    })?;
996    if !matches!(url.scheme(), "http" | "https")
997        || url.host_str().is_none()
998        || url.fragment().is_some()
999    {
1000        return Err(CodecError::InvalidDefinition(format!(
1001            "device {device} service URL must be HTTP(S) without a fragment"
1002        )));
1003    }
1004    let parameters = url.query_pairs().collect::<Vec<_>>();
1005    if parameters.len() > MAX_SERVICE_PARAMETERS
1006        || parameters.iter().any(|(name, value)| {
1007            name.is_empty()
1008                || name.len() > MAX_SERVICE_PARAMETER_BYTES
1009                || value.len() > MAX_SERVICE_PARAMETER_BYTES
1010                || name.chars().chain(value.chars()).any(char::is_control)
1011        })
1012    {
1013        return Err(CodecError::InvalidDefinition(format!(
1014            "device {device} service URL has invalid or excessive query parameters"
1015        )));
1016    }
1017    Ok(())
1018}
1019
1020impl ButtonDefinition {
1021    fn instance_key(&self) -> Option<(&'static str, u32)> {
1022        match self {
1023            Self::Line(definition) => Some(("line", definition.instance)),
1024            Self::SpeedDial(definition) => Some(("speed dial", definition.instance)),
1025            Self::BlfSpeedDial(definition) => Some(("BLF speed dial", definition.instance)),
1026            Self::Feature(definition) => Some(("feature", definition.instance)),
1027            Self::Service(definition) => Some(("service URL", definition.instance)),
1028            Self::AddonModule(definition) => Some(("addon module", definition.slot)),
1029            Self::Unused => None,
1030        }
1031    }
1032}
1033
1034/// Negotiated identity and network metadata for a live station session.
1035///
1036/// This value is emitted with registration events after the server has
1037/// validated the device definition, transport policy, and protocol version.
1038#[derive(Clone, Debug, Eq, PartialEq)]
1039pub struct DeviceRegistration {
1040    pub id: DeviceId,
1041    pub peer: SocketAddr,
1042    pub transport: StationTransport,
1043    pub reported_address: Option<Ipv4Addr>,
1044    pub reported_ipv6_address: Option<Ipv6Addr>,
1045    pub device_type: DeviceType,
1046    pub protocol: ProtocolVersion,
1047    pub firmware: String,
1048}
1049
1050impl DeviceRegistration {
1051    /// Return the station-reported address matching the signaling peer's
1052    /// effective address family. IPv4-mapped IPv6 peers use the IPv4 report.
1053    pub fn reported_address_for_peer(&self) -> Option<IpAddr> {
1054        match self.peer.ip() {
1055            IpAddr::V4(_) => self.reported_address.map(IpAddr::V4),
1056            IpAddr::V6(peer) => peer.to_ipv4_mapped().map_or_else(
1057                || self.reported_ipv6_address.map(IpAddr::V6),
1058                |_| self.reported_address.map(IpAddr::V4),
1059            ),
1060        }
1061    }
1062}
1063
1064/// Direction of a call relative to the station.
1065#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1066pub enum CallDirection {
1067    Inbound,
1068    Outbound,
1069}
1070
1071impl From<CallDirection> for CallType {
1072    fn from(value: CallDirection) -> Self {
1073        match value {
1074            CallDirection::Inbound => Self::Inbound,
1075            CallDirection::Outbound => Self::Outbound,
1076        }
1077    }
1078}
1079
1080/// Party identity and redirection history presented for a call.
1081///
1082/// Empty strings mean the corresponding identity is unavailable. Presentation
1083/// restrictions are carried separately so applications can retain identity
1084/// internally without accidentally displaying it.
1085#[derive(Clone, Debug, Eq, PartialEq)]
1086pub struct CallInfo {
1087    pub direction: CallDirection,
1088    pub calling_name: String,
1089    pub calling_number: String,
1090    pub called_name: String,
1091    pub called_number: String,
1092    pub original_called_name: String,
1093    pub original_called_number: String,
1094    pub last_redirecting_name: String,
1095    pub last_redirecting_number: String,
1096    pub original_redirect_reason: u32,
1097    pub last_redirect_reason: u32,
1098    /// Protocol restriction mask; `0xf` suppresses all party presentation.
1099    pub party_restrictions: u32,
1100}
1101
1102impl Default for CallInfo {
1103    fn default() -> Self {
1104        Self {
1105            direction: CallDirection::Outbound,
1106            calling_name: String::new(),
1107            calling_number: String::new(),
1108            called_name: String::new(),
1109            called_number: String::new(),
1110            original_called_name: String::new(),
1111            original_called_number: String::new(),
1112            last_redirecting_name: String::new(),
1113            last_redirecting_number: String::new(),
1114            original_redirect_reason: 0,
1115            last_redirect_reason: 0,
1116            party_restrictions: 0,
1117        }
1118    }
1119}
1120
1121/// Default audio packetization interval, in milliseconds.
1122pub const DEFAULT_AUDIO_PACKET_MS: u32 = 20;
1123/// Default maximum number of codec frames carried in one audio packet.
1124pub const DEFAULT_AUDIO_MAX_FRAMES_PER_PACKET: u32 = 1;
1125
1126/// Per-appearance station audio processing sent on the receive and transmit
1127/// channel setup messages.
1128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1129pub struct AudioProcessingPolicy {
1130    pub echo_cancellation: EchoCancellation,
1131    pub silence_suppression: SilenceSuppression,
1132}
1133
1134impl Default for AudioProcessingPolicy {
1135    fn default() -> Self {
1136        Self {
1137            echo_cancellation: EchoCancellation::On,
1138            silence_suppression: SilenceSuppression::Off,
1139        }
1140    }
1141}
1142
1143/// RTP/RTCP endpoint and negotiated audio format for one media leg.
1144///
1145/// Addresses may be IPv4 or IPv6. Ports are host-order values; the message
1146/// codec performs any wire conversion required by the selected layout.
1147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1148pub struct MediaEndpoint {
1149    pub address: IpAddr,
1150    pub rtp_port: u16,
1151    pub rtcp_port: u16,
1152    pub codec: Codec,
1153    pub packet_ms: u32,
1154    pub max_frames_per_packet: u32,
1155    /// Negotiated RTP payload number for telephone events; zero disables it.
1156    pub telephone_event_payload: u8,
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162
1163    #[test]
1164    fn identifiers_are_explicit_and_lossless() {
1165        let reference = CallReference::new(42);
1166        assert_eq!(reference.get(), 42);
1167        assert_eq!(u32::from(reference), 42);
1168
1169        let appearance = AppearanceId::new(7);
1170        assert_eq!(appearance.get(), 7);
1171        assert_eq!(u32::from(appearance), 7);
1172
1173        let conference = ConferenceId::new(9);
1174        assert_eq!(conference.get(), 9);
1175
1176        let participant = ParticipantId::new(11);
1177        assert_eq!(participant.get(), 11);
1178    }
1179
1180    #[test]
1181    fn device_id_is_canonicalized() {
1182        let id: DeviceId = " sep001122334455 ".parse().unwrap();
1183        assert_eq!(id.as_str(), "SEP001122334455");
1184        assert_eq!(id.as_ref(), "SEP001122334455");
1185
1186        let mut devices = HashMap::new();
1187        devices.insert(id, "desk");
1188        assert_eq!(devices.get("SEP001122334455"), Some(&"desk"));
1189    }
1190
1191    #[test]
1192    fn registration_selects_the_report_matching_the_effective_peer_family() {
1193        let registration = DeviceRegistration {
1194            id: DeviceId::new("SEP001122334455").unwrap(),
1195            peer: "[2001:db8::20]:2000".parse().unwrap(),
1196            transport: StationTransport::Clear,
1197            reported_address: Some("192.0.2.20".parse().unwrap()),
1198            reported_ipv6_address: Some("2001:db8::20".parse().unwrap()),
1199            device_type: DeviceType::Cisco7962,
1200            protocol: ProtocolVersion::V22,
1201            firmware: "test".into(),
1202        };
1203        assert_eq!(
1204            registration.reported_address_for_peer(),
1205            Some("2001:db8::20".parse().unwrap())
1206        );
1207
1208        let mapped = DeviceRegistration {
1209            peer: "[::ffff:192.0.2.20]:2000".parse().unwrap(),
1210            ..registration
1211        };
1212        assert_eq!(
1213            mapped.reported_address_for_peer(),
1214            Some("192.0.2.20".parse().unwrap())
1215        );
1216    }
1217
1218    fn line_button(instance: u32, number: &str) -> ButtonDefinition {
1219        ButtonDefinition::Line(LineAppearance::new(
1220            instance,
1221            LineDefinition {
1222                number: number.into(),
1223                display_name: number.into(),
1224            },
1225        ))
1226    }
1227
1228    #[test]
1229    fn station_definition_accepts_non_line_buttons_between_lines() {
1230        let definition = DeviceDefinition {
1231            id: DeviceId::new("SEP001122334455").unwrap(),
1232            description: "Desk".into(),
1233            transport: StationTransportRequirement::Either,
1234            signaling_qos: None,
1235            buttons: vec![
1236                line_button(1, "1001"),
1237                ButtonDefinition::Unused,
1238                ButtonDefinition::SpeedDial(SpeedDialDefinition {
1239                    instance: 1,
1240                    number: "2001".into(),
1241                    display_name: "Warehouse".into(),
1242                }),
1243                ButtonDefinition::BlfSpeedDial(BlfSpeedDialDefinition {
1244                    instance: 1,
1245                    number: "2002".into(),
1246                    display_name: "Dispatch".into(),
1247                    hint: "2002@internal".into(),
1248                }),
1249                ButtonDefinition::Feature(FeatureDefinition {
1250                    instance: 1,
1251                    label: "DND".into(),
1252                    feature: crate::message::values::ButtonType::DoNotDisturb,
1253                }),
1254                ButtonDefinition::Service(ServiceDefinition {
1255                    instance: 1,
1256                    label: "Directory".into(),
1257                    url: "http://pbx.test/directory".into(),
1258                }),
1259                ButtonDefinition::AddonModule(AddonModuleDefinition {
1260                    slot: 1,
1261                    device_type: crate::message::values::DeviceType::CiscoAddon7914,
1262                }),
1263                line_button(2, "1002"),
1264            ],
1265            soft_keys: SoftKeyProfile::default(),
1266            ui: StationUiPolicy::default(),
1267        };
1268
1269        definition.validate().unwrap();
1270        assert_eq!(definition.line_count(), 2);
1271        assert_eq!(definition.line(2).unwrap().number, "1002");
1272    }
1273
1274    #[test]
1275    fn station_definition_rejects_invalid_signaling_markings() {
1276        let mut definition = DeviceDefinition {
1277            id: DeviceId::new("SEP001122334455").unwrap(),
1278            description: "Desk".into(),
1279            transport: StationTransportRequirement::Either,
1280            signaling_qos: Some(SignalingQos::new(64, 0)),
1281            buttons: vec![line_button(1, "1001")],
1282            soft_keys: SoftKeyProfile::default(),
1283            ui: StationUiPolicy::default(),
1284        };
1285
1286        assert!(matches!(
1287            definition.validate(),
1288            Err(CodecError::InvalidDefinition(message)) if message.contains("DSCP 64")
1289        ));
1290
1291        definition.signaling_qos = Some(SignalingQos::new(26, 8));
1292        assert!(matches!(
1293            definition.validate(),
1294            Err(CodecError::InvalidDefinition(message)) if message.contains("COS 8")
1295        ));
1296    }
1297
1298    #[test]
1299    fn line_appearance_keeps_logical_and_device_specific_state_separate() {
1300        let logical = LineDefinition {
1301            number: "1001".into(),
1302            display_name: "Reception".into(),
1303        };
1304        let mut appearance = LineAppearance::new(2, logical.clone());
1305        appearance.label = Some("Private key".into());
1306        appearance.caller_id = CallerIdOverride {
1307            name: Some("Private desk".into()),
1308            number: None,
1309        };
1310        appearance.ring_mode = AppearanceRingMode::Silent;
1311        appearance.subscription_identity = Some("1001@internal".into());
1312        appearance.privacy = true;
1313
1314        assert_eq!(appearance.line, logical);
1315        assert_eq!(appearance.display_label(), "Private key");
1316        assert_eq!(appearance.number, "1001");
1317        assert_eq!(appearance.id, AppearanceId::new(2));
1318    }
1319
1320    #[test]
1321    fn station_definition_rejects_zero_and_duplicate_typed_instances() {
1322        let definition = DeviceDefinition {
1323            id: DeviceId::new("SEP001122334455").unwrap(),
1324            description: "Desk".into(),
1325            transport: StationTransportRequirement::Either,
1326            signaling_qos: None,
1327            buttons: vec![line_button(1, "1001"), line_button(1, "1002")],
1328            soft_keys: SoftKeyProfile::default(),
1329            ui: StationUiPolicy::default(),
1330        };
1331        assert!(matches!(
1332            definition.validate(),
1333            Err(CodecError::InvalidDefinition(message))
1334                if message.contains("repeats line button instance 1")
1335        ));
1336
1337        let definition = DeviceDefinition {
1338            id: DeviceId::new("SEP001122334455").unwrap(),
1339            description: "Desk".into(),
1340            transport: StationTransportRequirement::Either,
1341            signaling_qos: None,
1342            buttons: vec![
1343                line_button(1, "1001"),
1344                ButtonDefinition::Feature(FeatureDefinition {
1345                    instance: 0,
1346                    label: "DND".into(),
1347                    feature: crate::message::values::ButtonType::DoNotDisturb,
1348                }),
1349            ],
1350            soft_keys: SoftKeyProfile::default(),
1351            ui: StationUiPolicy::default(),
1352        };
1353        assert!(matches!(
1354            definition.validate(),
1355            Err(CodecError::InvalidDefinition(message))
1356                if message.contains("feature button with instance zero")
1357        ));
1358    }
1359
1360    #[test]
1361    fn station_definition_enforces_protocol_button_limit() {
1362        let definition = DeviceDefinition {
1363            id: DeviceId::new("SEP001122334455").unwrap(),
1364            description: "Desk".into(),
1365            transport: StationTransportRequirement::Either,
1366            signaling_qos: None,
1367            buttons: std::iter::once(line_button(1, "1001"))
1368                .chain(std::iter::repeat_n(ButtonDefinition::Unused, 42))
1369                .collect(),
1370            soft_keys: SoftKeyProfile::default(),
1371            ui: StationUiPolicy::default(),
1372        };
1373        assert!(matches!(
1374            definition.validate(),
1375            Err(CodecError::InvalidDefinition(message))
1376                if message.contains("protocol limit is 42")
1377        ));
1378    }
1379
1380    #[test]
1381    fn service_urls_require_bounded_http_parameters() {
1382        let service_device = |url: &str| DeviceDefinition {
1383            id: DeviceId::new("SEP001122334455").unwrap(),
1384            description: "Desk".into(),
1385            transport: StationTransportRequirement::Either,
1386            signaling_qos: None,
1387            buttons: vec![
1388                line_button(1, "1001"),
1389                ButtonDefinition::Service(ServiceDefinition {
1390                    instance: 1,
1391                    label: "Directory".into(),
1392                    url: url.into(),
1393                }),
1394            ],
1395            soft_keys: SoftKeyProfile::default(),
1396            ui: StationUiPolicy::default(),
1397        };
1398
1399        service_device("https://pbx.example/sccp/directory?q=Fran%C3%A7ois&page=2")
1400            .validate()
1401            .unwrap();
1402        service_device("https://user:secret@pbx.example/service")
1403            .validate()
1404            .unwrap();
1405        for invalid in [
1406            "file:///etc/passwd",
1407            "https://pbx.example/service#private",
1408            "https://pbx.example/service?=missing-name",
1409            "not a URL",
1410        ] {
1411            let error = service_device(invalid).validate().unwrap_err().to_string();
1412            assert!(!error.contains(invalid));
1413        }
1414        let excessive = format!(
1415            "https://pbx.example/service?{}",
1416            (0..33)
1417                .map(|index| format!("p{index}=v"))
1418                .collect::<Vec<_>>()
1419                .join("&")
1420        );
1421        assert!(service_device(&excessive).validate().is_err());
1422    }
1423
1424    #[test]
1425    fn soft_key_profiles_require_every_mode_and_unique_known_actions() {
1426        assert!(matches!(
1427            SoftKeyProfile::new([(KeyMode::OnHook, vec![SoftKey::NewCall])]),
1428            Err(CodecError::InvalidDefinition(message))
1429                if message.contains("every known key mode")
1430        ));
1431
1432        let duplicate = SoftKeyProfile::new(KeyMode::ALL_KNOWN.iter().copied().map(|mode| {
1433            (
1434                mode,
1435                if mode == KeyMode::Connected {
1436                    vec![SoftKey::Hold, SoftKey::Hold]
1437                } else {
1438                    Vec::new()
1439                },
1440            )
1441        }));
1442        assert!(matches!(
1443            duplicate,
1444            Err(CodecError::InvalidDefinition(message)) if message.contains("repeats action")
1445        ));
1446    }
1447}