Skip to main content

sccp_protocol/phone/xml/
display.rs

1//! Display phone XML document family.
2
3use super::*;
4
5/// The application focus that receives keypad events for a displayable phone
6/// service document.
7#[derive(Clone, Copy, Debug, Default, serde::Deserialize, Eq, PartialEq, Serialize)]
8pub enum PhoneKeypadTarget {
9    #[default]
10    #[serde(rename = "application")]
11    Application,
12    #[serde(rename = "applicationCall")]
13    ApplicationCall,
14    #[serde(rename = "activeCall")]
15    ActiveCall,
16}
17
18/// A physical key event routed by a displayable phone service document.
19#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
20pub enum PhoneXmlKey {
21    KeyPad0,
22    KeyPad1,
23    KeyPad2,
24    KeyPad3,
25    KeyPad4,
26    KeyPad5,
27    KeyPad6,
28    KeyPad7,
29    KeyPad8,
30    KeyPad9,
31    KeyPadStar,
32    KeyPadPound,
33    NavUp,
34    NavDown,
35    NavLeft,
36    NavRight,
37    NavSelect,
38    NavBack,
39    PushToTalk,
40}
41
42/// Optional label and press/release actions assigned to one soft-key slot.
43#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct CiscoIpPhoneSoftKeyItem {
46    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
47    pub name: Option<String>,
48    #[serde(rename = "Position")]
49    pub position: PhoneSoftKeyPosition,
50    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
51    pub url: Option<String>,
52    #[serde(rename = "URLDown", default, skip_serializing_if = "Option::is_none")]
53    pub url_down: Option<String>,
54}
55
56/// A phone soft-key slot. `-1` is the documented application/settings slot;
57/// physical rows use positions 1 through 16. Device-profile code may impose a
58/// smaller maximum without weakening the shared XML model.
59#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
60pub struct PhoneSoftKeyPosition(i8);
61
62impl PhoneSoftKeyPosition {
63    /// Sentinel slot used for the application or settings action.
64    pub const APPLICATION: Self = Self(-1);
65
66    /// Accepts the application sentinel or a physical slot in `1..=16`.
67    pub fn new(value: i16) -> Result<Self, PhoneXmlError> {
68        if value == -1 || (1..=16).contains(&value) {
69            i8::try_from(value)
70                .map(Self)
71                .map_err(|_| PhoneXmlError::InvalidField {
72                    field: "phone soft-key position",
73                    expected: "-1 or between 1 and 16",
74                })
75        } else {
76            Err(PhoneXmlError::InvalidField {
77                field: "phone soft-key position",
78                expected: "-1 or between 1 and 16",
79            })
80        }
81    }
82
83    /// Returns the signed wire value, including `-1` for [`Self::APPLICATION`].
84    pub const fn get(self) -> i8 {
85        self.0
86    }
87}
88
89impl Serialize for PhoneSoftKeyPosition {
90    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
91    where
92        S: serde::Serializer,
93    {
94        serializer.serialize_i8(self.0)
95    }
96}
97
98impl<'de> serde::Deserialize<'de> for PhoneSoftKeyPosition {
99    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
100    where
101        D: serde::Deserializer<'de>,
102    {
103        let value = <i16 as serde::Deserialize>::deserialize(deserializer)?;
104        Self::new(value).map_err(serde::de::Error::custom)
105    }
106}
107
108/// Press and optional release actions bound to one physical key.
109#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
110#[serde(deny_unknown_fields)]
111pub struct CiscoIpPhoneKeyItem {
112    #[serde(rename = "Key")]
113    pub key: PhoneXmlKey,
114    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
115    pub url: Option<String>,
116    #[serde(rename = "URLDown", default, skip_serializing_if = "Option::is_none")]
117    pub url_down: Option<String>,
118}
119
120/// Valid display priority for a phone-service application-data envelope.
121#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
122pub struct PhoneServicePriority(u8);
123
124impl PhoneServicePriority {
125    pub const LOW: Self = Self(0);
126    pub const NORMAL: Self = Self(1);
127    pub const HIGH: Self = Self(2);
128
129    /// Validates a numeric envelope priority in the inclusive range `0..=2`.
130    pub fn new(value: u32) -> Result<Self, PhoneXmlError> {
131        if value > u32::from(Self::HIGH.0) {
132            return Err(PhoneXmlError::InvalidField {
133                field: "phone-service display priority",
134                expected: "between 0 and 2",
135            });
136        }
137        u8::try_from(value)
138            .map(Self)
139            .map_err(|_| PhoneXmlError::InvalidField {
140                field: "phone-service display priority",
141                expected: "between 0 and 2",
142            })
143    }
144
145    pub fn wire(self) -> u32 {
146        u32::from(self.0)
147    }
148}
149
150impl Default for PhoneServicePriority {
151    fn default() -> Self {
152        Self::NORMAL
153    }
154}
155
156/// Typed HTTP refresh metadata for a pulled phone-service document.
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct PhoneXmlRefresh {
159    delay_seconds: u32,
160    url: String,
161}
162
163impl PhoneXmlRefresh {
164    /// Validates an ASCII refresh URL and associates its delay in seconds.
165    pub fn new(delay_seconds: u32, url: impl Into<String>) -> Result<Self, PhoneXmlError> {
166        let refresh = Self {
167            delay_seconds,
168            url: url.into(),
169        };
170        validate_optional_text(
171            "phone XML refresh URL",
172            Some(&refresh.url),
173            1,
174            PHONE_XML_URL_MAX_CHARS,
175        )?;
176        if !refresh.url.is_ascii()
177            || refresh
178                .url
179                .chars()
180                .any(|character| character.is_ascii_whitespace() || character.is_ascii_control())
181        {
182            return Err(PhoneXmlError::InvalidField {
183                field: "phone XML refresh URL",
184                expected: "an ASCII URL between 1 and 256 characters",
185            });
186        }
187        Ok(refresh)
188    }
189
190    pub const fn delay_seconds(&self) -> u32 {
191        self.delay_seconds
192    }
193
194    pub fn url(&self) -> &str {
195        &self.url
196    }
197
198    /// Formats the value for an HTTP `Refresh` response header.
199    pub fn http_header_value(&self) -> String {
200        format!("{};url={}", self.delay_seconds, self.url)
201    }
202}
203
204/// A complete, schema-ordered text display document.
205#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
206#[serde(rename = "CiscoIPPhoneText", deny_unknown_fields)]
207pub struct CiscoIpPhoneText {
208    #[serde(
209        rename = "@keypadTarget",
210        default,
211        skip_serializing_if = "Option::is_none"
212    )]
213    pub keypad_target: Option<PhoneKeypadTarget>,
214    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
215    pub application_id: Option<String>,
216    #[serde(
217        rename = "@onAppFocusLost",
218        default,
219        skip_serializing_if = "Option::is_none"
220    )]
221    pub on_focus_lost: Option<String>,
222    #[serde(
223        rename = "@onAppFocusGained",
224        default,
225        skip_serializing_if = "Option::is_none"
226    )]
227    pub on_focus_gained: Option<String>,
228    #[serde(
229        rename = "@onAppMinimized",
230        default,
231        skip_serializing_if = "Option::is_none"
232    )]
233    pub on_minimized: Option<String>,
234    #[serde(
235        rename = "@onAppClosed",
236        default,
237        skip_serializing_if = "Option::is_none"
238    )]
239    pub on_closed: Option<String>,
240    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
241    pub title: Option<String>,
242    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
243    pub prompt: Option<String>,
244    #[serde(rename = "SoftKeyItem", default)]
245    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
246    #[serde(rename = "KeyItem", default)]
247    pub key_items: Vec<CiscoIpPhoneKeyItem>,
248    #[serde(rename = "Text", default, skip_serializing_if = "Option::is_none")]
249    pub text: Option<String>,
250}
251
252impl CiscoIpPhoneText {
253    /// Builds and validates a text page with no optional lifecycle actions.
254    pub fn new(
255        title: impl Into<String>,
256        prompt: impl Into<String>,
257        text: impl Into<String>,
258    ) -> Result<Self, PhoneXmlError> {
259        let document = Self {
260            keypad_target: None,
261            application_id: None,
262            on_focus_lost: None,
263            on_focus_gained: None,
264            on_minimized: None,
265            on_closed: None,
266            title: Some(title.into()),
267            prompt: Some(prompt.into()),
268            soft_keys: Vec::new(),
269            key_items: Vec::new(),
270            text: Some(text.into()),
271        };
272        document.validate()?;
273        Ok(document)
274    }
275
276    /// Validates display metadata and the text-body character bound.
277    pub fn validate(&self) -> Result<(), PhoneXmlError> {
278        validate_displayable(
279            self.title.as_deref(),
280            self.prompt.as_deref(),
281            self.application_id.as_deref(),
282            [
283                self.on_focus_lost.as_deref(),
284                self.on_focus_gained.as_deref(),
285                self.on_minimized.as_deref(),
286                self.on_closed.as_deref(),
287            ],
288            &self.soft_keys,
289            &self.key_items,
290        )?;
291        validate_optional_text(
292            "phone text body",
293            self.text.as_deref(),
294            0,
295            PHONE_TEXT_MAX_CHARS,
296        )
297    }
298}
299
300/// One of the input modes accepted by a phone input-service field.
301///
302/// The password-modified variants preserve whether `P` precedes or follows
303/// the base mode because both forms are distinct accepted schema values.
304#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
305pub enum PhoneInputFlags {
306    #[serde(rename = "A")]
307    Alphabetic,
308    #[serde(rename = "T")]
309    Telephone,
310    #[serde(rename = "N")]
311    Numeric,
312    #[serde(rename = "E")]
313    Equation,
314    #[serde(rename = "U")]
315    Uppercase,
316    #[serde(rename = "L")]
317    Lowercase,
318    #[serde(rename = "AP")]
319    AlphabeticPassword,
320    #[serde(rename = "TP")]
321    TelephonePassword,
322    #[serde(rename = "NP")]
323    NumericPassword,
324    #[serde(rename = "EP")]
325    EquationPassword,
326    #[serde(rename = "UP")]
327    UppercasePassword,
328    #[serde(rename = "LP")]
329    LowercasePassword,
330    #[serde(rename = "PA")]
331    PasswordAlphabetic,
332    #[serde(rename = "PT")]
333    PasswordTelephone,
334    #[serde(rename = "PN")]
335    PasswordNumeric,
336    #[serde(rename = "PE")]
337    PasswordEquation,
338    #[serde(rename = "PU")]
339    PasswordUppercase,
340    #[serde(rename = "PL")]
341    PasswordLowercase,
342}
343
344impl PhoneInputFlags {
345    /// Every accepted schema spelling, including both password modifier orders.
346    pub const ALL: [Self; 18] = [
347        Self::Alphabetic,
348        Self::Telephone,
349        Self::Numeric,
350        Self::Equation,
351        Self::Uppercase,
352        Self::Lowercase,
353        Self::AlphabeticPassword,
354        Self::TelephonePassword,
355        Self::NumericPassword,
356        Self::EquationPassword,
357        Self::UppercasePassword,
358        Self::LowercasePassword,
359        Self::PasswordAlphabetic,
360        Self::PasswordTelephone,
361        Self::PasswordNumeric,
362        Self::PasswordEquation,
363        Self::PasswordUppercase,
364        Self::PasswordLowercase,
365    ];
366}
367
368/// A schema-bounded name used for one submitted query parameter.
369#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
370#[serde(transparent)]
371pub struct PhoneInputParameterName(String);
372
373impl PhoneInputParameterName {
374    /// Validates a non-empty parameter name of at most 32 characters.
375    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
376        let value = value.into();
377        validate_optional_text("phone input parameter name", Some(&value), 1, 32)?;
378        Ok(Self(value))
379    }
380
381    pub fn as_str(&self) -> &str {
382        &self.0
383    }
384}
385
386impl<'de> serde::Deserialize<'de> for PhoneInputParameterName {
387    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
388    where
389        D: serde::Deserializer<'de>,
390    {
391        let value = <String as serde::Deserialize>::deserialize(deserializer)?;
392        Self::new(value).map_err(serde::de::Error::custom)
393    }
394}
395
396/// One field in a phone input-service document.
397#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
398#[serde(deny_unknown_fields)]
399pub struct CiscoIpPhoneInputItem {
400    #[serde(
401        rename = "DisplayName",
402        default,
403        skip_serializing_if = "Option::is_none"
404    )]
405    pub display_name: Option<String>,
406    #[serde(rename = "QueryStringParam")]
407    pub parameter: PhoneInputParameterName,
408    #[serde(rename = "InputFlags")]
409    pub flags: PhoneInputFlags,
410    #[serde(
411        rename = "DefaultValue",
412        default,
413        skip_serializing_if = "Option::is_none"
414    )]
415    pub default_value: Option<String>,
416}
417
418/// A complete, schema-ordered interactive input document.
419#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
420#[serde(rename = "CiscoIPPhoneInput", deny_unknown_fields)]
421pub struct CiscoIpPhoneInput {
422    #[serde(
423        rename = "@keypadTarget",
424        default,
425        skip_serializing_if = "Option::is_none"
426    )]
427    pub keypad_target: Option<PhoneKeypadTarget>,
428    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
429    pub application_id: Option<String>,
430    #[serde(
431        rename = "@onAppFocusLost",
432        default,
433        skip_serializing_if = "Option::is_none"
434    )]
435    pub on_focus_lost: Option<String>,
436    #[serde(
437        rename = "@onAppFocusGained",
438        default,
439        skip_serializing_if = "Option::is_none"
440    )]
441    pub on_focus_gained: Option<String>,
442    #[serde(
443        rename = "@onAppMinimized",
444        default,
445        skip_serializing_if = "Option::is_none"
446    )]
447    pub on_minimized: Option<String>,
448    #[serde(
449        rename = "@onAppClosed",
450        default,
451        skip_serializing_if = "Option::is_none"
452    )]
453    pub on_closed: Option<String>,
454    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
455    pub title: Option<String>,
456    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
457    pub prompt: Option<String>,
458    #[serde(rename = "SoftKeyItem", default)]
459    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
460    #[serde(rename = "KeyItem", default)]
461    pub key_items: Vec<CiscoIpPhoneKeyItem>,
462    #[serde(rename = "URL")]
463    pub url: String,
464    #[serde(rename = "InputItem", default)]
465    pub items: Vec<CiscoIpPhoneInputItem>,
466}
467
468impl CiscoIpPhoneInput {
469    /// Builds and validates an input form with no optional lifecycle actions.
470    pub fn new(
471        title: impl Into<String>,
472        prompt: impl Into<String>,
473        url: impl Into<String>,
474        items: Vec<CiscoIpPhoneInputItem>,
475    ) -> Result<Self, PhoneXmlError> {
476        let document = Self {
477            keypad_target: None,
478            application_id: None,
479            on_focus_lost: None,
480            on_focus_gained: None,
481            on_minimized: None,
482            on_closed: None,
483            title: Some(title.into()),
484            prompt: Some(prompt.into()),
485            soft_keys: Vec::new(),
486            key_items: Vec::new(),
487            url: url.into(),
488            items,
489        };
490        document.validate()?;
491        Ok(document)
492    }
493
494    /// Validates display metadata, submission URL, field count, and field text.
495    pub fn validate(&self) -> Result<(), PhoneXmlError> {
496        validate_displayable(
497            self.title.as_deref(),
498            self.prompt.as_deref(),
499            self.application_id.as_deref(),
500            [
501                self.on_focus_lost.as_deref(),
502                self.on_focus_gained.as_deref(),
503                self.on_minimized.as_deref(),
504                self.on_closed.as_deref(),
505            ],
506            &self.soft_keys,
507            &self.key_items,
508        )?;
509        validate_optional_text(
510            "phone input submission URL",
511            Some(&self.url),
512            1,
513            PHONE_XML_URL_MAX_CHARS,
514        )?;
515        validate_count(
516            "phone input fields",
517            self.items.len(),
518            PHONE_INPUT_MAX_ITEMS,
519        )?;
520        for item in &self.items {
521            validate_optional_text(
522                "phone input display name",
523                item.display_name.as_deref(),
524                0,
525                32,
526            )?;
527            validate_optional_text(
528                "phone input default value",
529                item.default_value.as_deref(),
530                0,
531                32,
532            )?;
533        }
534        Ok(())
535    }
536}
537
538/// Valid priority for one phone execute action.
539#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
540pub struct PhoneExecutePriority(u8);
541
542impl PhoneExecutePriority {
543    pub const LOW: Self = Self(0);
544    pub const NORMAL: Self = Self(1);
545    pub const HIGH: Self = Self(2);
546
547    /// Validates a numeric action priority in the inclusive range `0..=2`.
548    pub fn new(value: u8) -> Result<Self, PhoneXmlError> {
549        if value > Self::HIGH.0 {
550            return Err(PhoneXmlError::InvalidField {
551                field: "phone execute priority",
552                expected: "between 0 and 2",
553            });
554        }
555        Ok(Self(value))
556    }
557
558    pub const fn wire(self) -> u8 {
559        self.0
560    }
561}
562
563impl Serialize for PhoneExecutePriority {
564    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
565    where
566        S: serde::Serializer,
567    {
568        serializer.serialize_u8(self.0)
569    }
570}
571
572impl<'de> serde::Deserialize<'de> for PhoneExecutePriority {
573    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
574    where
575        D: serde::Deserializer<'de>,
576    {
577        let value = <u8 as serde::Deserialize>::deserialize(deserializer)?;
578        Self::new(value).map_err(serde::de::Error::custom)
579    }
580}
581
582/// The two execution domains understood by phone services.
583#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
584pub enum PhoneActionKind {
585    /// An HTTP or HTTPS action; only one is permitted per execute document.
586    Http,
587    /// A device-local action using a non-HTTP scheme.
588    Internal,
589}
590
591/// A schema-bounded action in a phone execute document.
592#[derive(Clone, Debug, Eq, Hash, PartialEq)]
593pub struct PhoneExecuteUrl {
594    value: String,
595    kind: PhoneActionKind,
596}
597
598impl PhoneExecuteUrl {
599    /// Validates the URL length and classifies its execution domain.
600    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
601        let value = value.into();
602        validate_optional_text(
603            "phone execute URL",
604            Some(&value),
605            1,
606            PHONE_XML_URL_MAX_CHARS,
607        )?;
608        let kind = action_kind(&value);
609        Ok(Self { value, kind })
610    }
611
612    pub fn as_str(&self) -> &str {
613        &self.value
614    }
615
616    pub const fn kind(&self) -> PhoneActionKind {
617        self.kind
618    }
619}
620
621impl Serialize for PhoneExecuteUrl {
622    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
623    where
624        S: serde::Serializer,
625    {
626        serializer.serialize_str(&self.value)
627    }
628}
629
630impl<'de> serde::Deserialize<'de> for PhoneExecuteUrl {
631    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
632    where
633        D: serde::Deserializer<'de>,
634    {
635        let value = <String as serde::Deserialize>::deserialize(deserializer)?;
636        Self::new(value).map_err(serde::de::Error::custom)
637    }
638}
639
640/// One ordered URL action in an execute document.
641#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
642#[serde(deny_unknown_fields)]
643pub struct CiscoIpPhoneExecuteItem {
644    #[serde(rename = "@Priority", default, skip_serializing_if = "Option::is_none")]
645    pub priority: Option<PhoneExecutePriority>,
646    #[serde(rename = "@URL")]
647    pub url: PhoneExecuteUrl,
648}
649
650impl CiscoIpPhoneExecuteItem {
651    /// Creates a validated action with no explicit priority.
652    pub fn new(url: impl Into<String>) -> Result<Self, PhoneXmlError> {
653        Ok(Self {
654            priority: None,
655            url: PhoneExecuteUrl::new(url)?,
656        })
657    }
658
659    /// Creates a validated action with an explicit scheduling priority.
660    pub fn with_priority(
661        url: impl Into<String>,
662        priority: PhoneExecutePriority,
663    ) -> Result<Self, PhoneXmlError> {
664        Ok(Self {
665            priority: Some(priority),
666            url: PhoneExecuteUrl::new(url)?,
667        })
668    }
669}
670
671/// A complete, schema-ordered execute document.
672#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
673#[serde(rename = "CiscoIPPhoneExecute", deny_unknown_fields)]
674pub struct CiscoIpPhoneExecute {
675    #[serde(rename = "ExecuteItem", default)]
676    pub items: Vec<CiscoIpPhoneExecuteItem>,
677}
678
679impl CiscoIpPhoneExecute {
680    /// Builds a document containing one to [`PHONE_EXECUTE_MAX_ITEMS`] actions.
681    pub fn new(items: Vec<CiscoIpPhoneExecuteItem>) -> Result<Self, PhoneXmlError> {
682        let document = Self { items };
683        document.validate()?;
684        Ok(document)
685    }
686
687    /// Checks action count and the single-HTTP-action invariant.
688    pub fn validate(&self) -> Result<(), PhoneXmlError> {
689        if self.items.is_empty() {
690            return Err(PhoneXmlError::InvalidField {
691                field: "phone execute actions",
692                expected: "between 1 and 3 entries",
693            });
694        }
695        validate_count(
696            "phone execute actions",
697            self.items.len(),
698            PHONE_EXECUTE_MAX_ITEMS,
699        )?;
700        if self
701            .items
702            .iter()
703            .filter(|item| item.url.kind() == PhoneActionKind::Http)
704            .count()
705            > 1
706        {
707            return Err(PhoneXmlError::InvalidField {
708                field: "phone execute HTTP actions",
709                expected: "at most one HTTP or HTTPS action",
710            });
711        }
712        Ok(())
713    }
714}