Skip to main content

osdp/command/
text.rs

1//! `osdp_TEXT` (`0x6B`) — reader text output.
2//!
3//! # Spec: §6.12, Tables 21–22
4//!
5//! Body layout:
6//!
7//! ```text
8//! +--------+----------+--------+--------+--------+--------+-- ... --+
9//! | reader | command  | temp_t | row    | column | length | text…   |
10//! +--------+----------+--------+--------+--------+--------+-- ... --+
11//! ```
12
13use crate::error::Error;
14use crate::payload_util::require_at_least;
15use alloc::vec::Vec;
16
17/// Text command codes (Table 21).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19#[repr(u8)]
20#[allow(missing_docs)]
21pub enum TextCommand {
22    PermanentNoWrap = 0x01,
23    PermanentWrap = 0x02,
24    TemporaryNoWrap = 0x03,
25    TemporaryWrap = 0x04,
26}
27
28impl TextCommand {
29    /// Parse from byte.
30    pub const fn from_byte(b: u8) -> Result<Self, Error> {
31        Ok(match b {
32            0x01 => Self::PermanentNoWrap,
33            0x02 => Self::PermanentWrap,
34            0x03 => Self::TemporaryNoWrap,
35            0x04 => Self::TemporaryWrap,
36            _ => {
37                return Err(Error::MalformedPayload {
38                    code: 0x6B,
39                    reason: "unknown TEXT command code",
40                });
41            }
42        })
43    }
44
45    /// Raw byte.
46    pub const fn as_byte(self) -> u8 {
47        self as u8
48    }
49}
50
51/// `osdp_TEXT` body.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Text {
54    /// Reader number.
55    pub reader: u8,
56    /// Text command code.
57    pub command: TextCommand,
58    /// Temporary on-time in seconds (1..=255). Set 0 for permanent commands.
59    pub temp_time_s: u8,
60    /// Display row (1..).
61    pub row: u8,
62    /// Display column (1..).
63    pub column: u8,
64    /// Printable ASCII text (`0x20..=0x7E`).
65    pub text: Vec<u8>,
66}
67
68impl Text {
69    /// Spec-compliant ASCII bound.
70    fn validate_ascii(s: &[u8]) -> Result<(), Error> {
71        for &b in s {
72            if !(0x20..=0x7E).contains(&b) {
73                return Err(Error::MalformedPayload {
74                    code: 0x6B,
75                    reason: "TEXT must be printable ASCII",
76                });
77            }
78        }
79        Ok(())
80    }
81
82    /// Encode.
83    pub fn encode(&self) -> Result<Vec<u8>, Error> {
84        Self::validate_ascii(&self.text)?;
85        if self.text.len() > u8::MAX as usize {
86            return Err(Error::MalformedPayload {
87                code: 0x6B,
88                reason: "TEXT length exceeds 255 bytes",
89            });
90        }
91        let mut out = Vec::with_capacity(6 + self.text.len());
92        out.push(self.reader);
93        out.push(self.command.as_byte());
94        out.push(self.temp_time_s);
95        out.push(self.row);
96        out.push(self.column);
97        out.push(self.text.len() as u8);
98        out.extend_from_slice(&self.text);
99        Ok(out)
100    }
101
102    /// Decode.
103    pub fn decode(data: &[u8]) -> Result<Self, Error> {
104        require_at_least(data, 6, 0x6B)?;
105        let length = data[5] as usize;
106        if data.len() != 6 + length {
107            return Err(Error::MalformedPayload {
108                code: 0x6B,
109                reason: "TEXT length field disagrees with payload",
110            });
111        }
112        let text = data[6..6 + length].to_vec();
113        Self::validate_ascii(&text)?;
114        Ok(Self {
115            reader: data[0],
116            command: TextCommand::from_byte(data[1])?,
117            temp_time_s: data[2],
118            row: data[3],
119            column: data[4],
120            text,
121        })
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn roundtrip() {
131        let body = Text {
132            reader: 0x00,
133            command: TextCommand::PermanentNoWrap,
134            temp_time_s: 0,
135            row: 1,
136            column: 1,
137            text: alloc::vec![b'O', b'K'],
138        };
139        let bytes = body.encode().unwrap();
140        assert_eq!(bytes, [0x00, 0x01, 0x00, 0x01, 0x01, 0x02, b'O', b'K']);
141        assert_eq!(Text::decode(&bytes).unwrap(), body);
142    }
143
144    #[test]
145    fn rejects_non_ascii_text_on_encode() {
146        let body = Text {
147            reader: 0,
148            command: TextCommand::PermanentNoWrap,
149            temp_time_s: 0,
150            row: 1,
151            column: 1,
152            text: alloc::vec![0x1F], // below printable
153        };
154        assert!(matches!(
155            body.encode(),
156            Err(Error::MalformedPayload { code: 0x6B, .. })
157        ));
158    }
159
160    #[test]
161    fn decode_rejects_unknown_command_code() {
162        assert!(matches!(
163            Text::decode(&[0x00, 0x99, 0x00, 0x01, 0x01, 0x00]),
164            Err(Error::MalformedPayload { code: 0x6B, .. })
165        ));
166    }
167
168    #[test]
169    fn decode_rejects_length_mismatch() {
170        // Header claims 5-byte text but only 1 byte follows.
171        assert!(matches!(
172            Text::decode(&[0x00, 0x01, 0x00, 0x01, 0x01, 0x05, b'A']),
173            Err(Error::MalformedPayload { code: 0x6B, .. })
174        ));
175    }
176}