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