Skip to main content

dvb_ci/ci_plus/
content_control.rs

1//! Content Control resource — multi-stream extensions — ETSI TS 103 205 V1.4.1
2//! §6.4.3, Tables 6-13 (PDF pp. 34-38). See `docs/ts_103_205/content-control.md`.
3//!
4//! Resource ID `0x008C1041` (Class 140, Type 65, Version 1). For multi-stream
5//! reception the Content Control APDUs are extended with the Local TS identifier
6//! (`LTS_id`). Only the two extended APDUs whose syntax TS 103 205 actually
7//! prints are encoded here:
8//!
9//! - `cc_PIN_reply` (`9F 90 14`, Table 7) — CICAM → Host.
10//! - `cc_PIN_event` (`9F 90 15`, Table 8) — CICAM → Host.
11//!
12//! The remaining Table 6 APDUs (`cc_open_*`, `cc_data_*`, `cc_sync_*`,
13//! `cc_sac_*`, `cc_PIN_capabilities_*`, `cc_PIN_cmd`, `cc_PIN_playback`,
14//! `cc_PIN_MMI_req`) defer to CI Plus V1.3 \[3\] §11.3.x and are **not encoded**.
15//!
16//! §6.4.3.3 (Tables 9-13) specifies the SAC protocol-message *content* — the
17//! ordered datatype loops carried inside the `cc_sac_data_req`/`cc_sac_data_cnf`
18//! envelope. These are modelled as [`SacMessage`] / [`SacDatatype`] (the
19//! envelope APDU itself defers to CI Plus V1.3, so no `9F9007`/`9F9008` APDU is
20//! produced here). Crypto / license / PIN payload bytes are carried as opaque
21//! borrowed slices.
22
23use crate::error::{Error, Result};
24use crate::objects;
25use crate::tag::ApduTag;
26use alloc::vec::Vec;
27use broadcast_common::{Parse, Serialize};
28
29/// Resource-scoped `apdu_tag`s implemented for the Content Control resource
30/// (the printed-syntax subset of Table 6).
31pub mod tag {
32    use crate::tag::ApduTag;
33    /// `cc_PIN_reply_tag` = `9F 90 14`.
34    pub const CC_PIN_REPLY: ApduTag = ApduTag::from_bytes(0x9F, 0x90, 0x14);
35    /// `cc_PIN_event_tag` = `9F 90 15`.
36    pub const CC_PIN_EVENT: ApduTag = ApduTag::from_bytes(0x9F, 0x90, 0x15);
37}
38
39// --- cc_PIN_reply (Table 7) ---
40
41/// `cc_PIN_reply()` (Table 7): CICAM → Host. Extended for the record-start
42/// protocol to optionally carry `LTS_id`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize))]
45pub struct CcPinReply {
46    /// `LTS_id` (8) when `LTS_bound_flag == 1`; `None` (the `else` reserved(8)
47    /// branch) when the reply is not associated with a Local TS.
48    pub lts_id: Option<u8>,
49    /// `PINcode_status_field` (8) — semantics in CI Plus V1.3 \[3\] §11.3.2.3
50    /// (proprietary, carried verbatim).
51    pub pincode_status: u8,
52}
53
54// reserved(7)+LTS_bound_flag(1) byte + LTS_id-or-reserved(1) + PINcode_status(1).
55const CC_PIN_REPLY_BODY: usize = 3;
56// The LTS_bound_flag bit (LSB of the reserved(7)+flag(1) byte).
57const LTS_BOUND_FLAG_BIT: u8 = 0x01;
58
59impl<'a> Parse<'a> for CcPinReply {
60    type Error = Error;
61    fn parse(bytes: &'a [u8]) -> Result<Self> {
62        let body = objects::parse_apdu_header(bytes, tag::CC_PIN_REPLY, "cc_PIN_reply")?;
63        if body.len() < CC_PIN_REPLY_BODY {
64            return Err(Error::BufferTooShort {
65                need: CC_PIN_REPLY_BODY,
66                have: body.len(),
67                what: "cc_PIN_reply",
68            });
69        }
70        let lts_bound = body[0] & LTS_BOUND_FLAG_BIT != 0;
71        let lts_id = if lts_bound { Some(body[1]) } else { None };
72        Ok(Self {
73            lts_id,
74            pincode_status: body[2],
75        })
76    }
77}
78impl Serialize for CcPinReply {
79    type Error = Error;
80    fn serialized_len(&self) -> usize {
81        objects::apdu_len(CC_PIN_REPLY_BODY)
82    }
83    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
84        let pos = objects::write_apdu_header(tag::CC_PIN_REPLY, CC_PIN_REPLY_BODY, buf)?;
85        match self.lts_id {
86            Some(id) => {
87                buf[pos] = LTS_BOUND_FLAG_BIT;
88                buf[pos + 1] = id;
89            }
90            None => {
91                buf[pos] = 0;
92                // else branch: reserved(8).
93                buf[pos + 1] = 0;
94            }
95        }
96        buf[pos + 2] = self.pincode_status;
97        Ok(pos + CC_PIN_REPLY_BODY)
98    }
99}
100
101// --- cc_PIN_event (Table 8) ---
102
103/// Width of the `private_data` field (Table 8: `8x15` bits = 15 bytes).
104pub const PIN_EVENT_PRIVATE_DATA_LEN: usize = 15;
105
106/// `cc_PIN_event()` (Table 8): CICAM → Host. Extended for the record-start
107/// protocol to include `LTS_id`. The field meanings (other than `LTS_id`) are in
108/// CI Plus V1.3 \[3\] §11.3.2.4 (proprietary) and carried verbatim.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize))]
111pub struct CcPinEvent {
112    /// `LTS_id` (8) — Local TS identifier.
113    pub lts_id: u8,
114    /// `program_number` (16).
115    pub program_number: u16,
116    /// `PINcode_status_field` (8).
117    pub pincode_status: u8,
118    /// `rating` (8).
119    pub rating: u8,
120    /// `pin_event_time_utc` (40) — held in the low 5 bytes of the `u64`.
121    pub pin_event_time_utc: u64,
122    /// `pin_event_time_centiseconds` (8).
123    pub pin_event_time_centiseconds: u8,
124    /// `private_data` (`8x15` = 15 bytes).
125    pub private_data: [u8; PIN_EVENT_PRIVATE_DATA_LEN],
126}
127
128// LTS_id(1)+program_number(2)+PINcode_status(1)+rating(1)+utc(5)+centi(1)+private(15).
129const CC_PIN_EVENT_BODY: usize = 1 + 2 + 1 + 1 + 5 + 1 + PIN_EVENT_PRIVATE_DATA_LEN;
130
131impl<'a> Parse<'a> for CcPinEvent {
132    type Error = Error;
133    fn parse(bytes: &'a [u8]) -> Result<Self> {
134        let body = objects::parse_apdu_header(bytes, tag::CC_PIN_EVENT, "cc_PIN_event")?;
135        if body.len() < CC_PIN_EVENT_BODY {
136            return Err(Error::BufferTooShort {
137                need: CC_PIN_EVENT_BODY,
138                have: body.len(),
139                what: "cc_PIN_event",
140            });
141        }
142        // Layout (Table 8): LTS_id(1) program_number(2) PINcode_status(1)
143        // rating(1) pin_event_time_utc(5) pin_event_time_centiseconds(1)
144        // private_data(15).
145        let mut utc = 0u64;
146        for &b in &body[5..10] {
147            utc = (utc << 8) | b as u64;
148        }
149        let mut private_data = [0u8; PIN_EVENT_PRIVATE_DATA_LEN];
150        private_data.copy_from_slice(&body[11..11 + PIN_EVENT_PRIVATE_DATA_LEN]);
151        Ok(Self {
152            lts_id: body[0],
153            program_number: u16::from_be_bytes([body[1], body[2]]),
154            pincode_status: body[3],
155            rating: body[4],
156            pin_event_time_utc: utc,
157            pin_event_time_centiseconds: body[10],
158            private_data,
159        })
160    }
161}
162impl Serialize for CcPinEvent {
163    type Error = Error;
164    fn serialized_len(&self) -> usize {
165        objects::apdu_len(CC_PIN_EVENT_BODY)
166    }
167    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
168        let pos = objects::write_apdu_header(tag::CC_PIN_EVENT, CC_PIN_EVENT_BODY, buf)?;
169        buf[pos] = self.lts_id;
170        buf[pos + 1..pos + 3].copy_from_slice(&self.program_number.to_be_bytes());
171        buf[pos + 3] = self.pincode_status;
172        buf[pos + 4] = self.rating;
173        // utc(40) — low 5 bytes, big-endian.
174        let utc = self.pin_event_time_utc.to_be_bytes();
175        buf[pos + 5..pos + 10].copy_from_slice(&utc[3..8]);
176        buf[pos + 10] = self.pin_event_time_centiseconds;
177        buf[pos + 11..pos + 11 + PIN_EVENT_PRIVATE_DATA_LEN].copy_from_slice(&self.private_data);
178        Ok(pos + CC_PIN_EVENT_BODY)
179    }
180}
181
182// --- SAC protocol datatypes (§6.4.3.3, Tables 9-13) ---
183
184/// `datatype_id` of a SAC protocol datatype (§6.4.3.3, Tables 9-13 + the new
185/// multi-stream `LTS_id` = 50). The named values are those referenced by the
186/// TS 103 205 protocol tables; other values are [`DatatypeId::Other`].
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188#[cfg_attr(feature = "serde", derive(serde::Serialize))]
189#[non_exhaustive]
190pub enum DatatypeId {
191    /// `uri_message` (25).
192    UriMessage,
193    /// `program_number` (26).
194    ProgramNumber,
195    /// `uri_confirm` (27).
196    UriConfirm,
197    /// `cicam_license` (33) — opaque license blob.
198    CicamLicense,
199    /// `license_status` (34).
200    LicenseStatus,
201    /// `license_rcvd_status` (35).
202    LicenseRcvdStatus,
203    /// `operating_mode` (38).
204    OperatingMode,
205    /// `PINcode` data (39) — opaque.
206    PinCode,
207    /// `record_start_status` (40).
208    RecordStartStatus,
209    /// `mode_change_status` (41).
210    ModeChangeStatus,
211    /// `record_stop_status` (42).
212    RecordStopStatus,
213    /// `LTS_id` (50) — the new multi-stream datatype.
214    LtsId,
215    /// Any other `datatype_id`.
216    Other(u8),
217}
218
219impl DatatypeId {
220    /// Decode a `datatype_id` byte.
221    #[must_use]
222    pub fn from_u8(v: u8) -> Self {
223        match v {
224            25 => Self::UriMessage,
225            26 => Self::ProgramNumber,
226            27 => Self::UriConfirm,
227            33 => Self::CicamLicense,
228            34 => Self::LicenseStatus,
229            35 => Self::LicenseRcvdStatus,
230            38 => Self::OperatingMode,
231            39 => Self::PinCode,
232            40 => Self::RecordStartStatus,
233            41 => Self::ModeChangeStatus,
234            42 => Self::RecordStopStatus,
235            50 => Self::LtsId,
236            other => Self::Other(other),
237        }
238    }
239    /// Wire byte.
240    #[must_use]
241    pub const fn to_u8(self) -> u8 {
242        match self {
243            Self::UriMessage => 25,
244            Self::ProgramNumber => 26,
245            Self::UriConfirm => 27,
246            Self::CicamLicense => 33,
247            Self::LicenseStatus => 34,
248            Self::LicenseRcvdStatus => 35,
249            Self::OperatingMode => 38,
250            Self::PinCode => 39,
251            Self::RecordStartStatus => 40,
252            Self::ModeChangeStatus => 41,
253            Self::RecordStopStatus => 42,
254            Self::LtsId => 50,
255            Self::Other(v) => v,
256        }
257    }
258    /// Spec token, or `"reserved"`.
259    #[must_use]
260    pub fn name(&self) -> &'static str {
261        match self {
262            Self::UriMessage => "uri_message",
263            Self::ProgramNumber => "program_number",
264            Self::UriConfirm => "uri_confirm",
265            Self::CicamLicense => "cicam_license",
266            Self::LicenseStatus => "license_status",
267            Self::LicenseRcvdStatus => "license_rcvd_status",
268            Self::OperatingMode => "operating_mode",
269            Self::PinCode => "PINcode",
270            Self::RecordStartStatus => "record_start_status",
271            Self::ModeChangeStatus => "mode_change_status",
272            Self::RecordStopStatus => "record_stop_status",
273            Self::LtsId => "LTS_id",
274            Self::Other(_) => "reserved",
275        }
276    }
277}
278broadcast_common::impl_spec_display!(DatatypeId, Other);
279
280/// `operating_mode` (datatype 38) values (§6.4.3.3.2 prose): the CICAM treats
281/// the programme as unattended when this is `Timeshift` or `UnattendedRecording`.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283#[cfg_attr(feature = "serde", derive(serde::Serialize))]
284#[non_exhaustive]
285pub enum OperatingMode {
286    /// `0x01` — Timeshift.
287    Timeshift,
288    /// `0x02` — Unattended Recording.
289    UnattendedRecording,
290    /// Any other value (reserved / attended modes are CI Plus V1.3 defined).
291    Other(u8),
292}
293
294impl OperatingMode {
295    /// Decode an `operating_mode` byte.
296    #[must_use]
297    pub fn from_u8(v: u8) -> Self {
298        match v {
299            0x01 => Self::Timeshift,
300            0x02 => Self::UnattendedRecording,
301            other => Self::Other(other),
302        }
303    }
304    /// Wire byte.
305    #[must_use]
306    pub const fn to_u8(self) -> u8 {
307        match self {
308            Self::Timeshift => 0x01,
309            Self::UnattendedRecording => 0x02,
310            Self::Other(v) => v,
311        }
312    }
313    /// Spec token, or `"reserved"`.
314    #[must_use]
315    pub fn name(&self) -> &'static str {
316        match self {
317            Self::Timeshift => "Timeshift",
318            Self::UnattendedRecording => "Unattended_Recording",
319            Self::Other(_) => "reserved",
320        }
321    }
322}
323broadcast_common::impl_spec_display!(OperatingMode, Other);
324
325/// A single SAC protocol datatype: `datatype_id` (8) + `datatype_length` (16,
326/// number of value bytes) + value. Value bytes are carried opaque so crypto /
327/// license / PIN payloads round-trip verbatim.
328#[derive(Debug, Clone, PartialEq, Eq)]
329#[cfg_attr(feature = "serde", derive(serde::Serialize))]
330pub struct SacDatatype<'a> {
331    /// `datatype_id` (8).
332    pub datatype_id: DatatypeId,
333    /// `datatype` value bytes (length given by the 16-bit `datatype_length`).
334    #[cfg_attr(feature = "serde", serde(borrow, with = "crate::objects::bytes_serde"))]
335    pub value: &'a [u8],
336}
337
338// datatype_id(1) + datatype_length(2).
339const SAC_DATATYPE_HEADER: usize = 3;
340
341impl SacDatatype<'_> {
342    /// Serialized length of this datatype (header + value).
343    #[must_use]
344    pub fn serialized_len(&self) -> usize {
345        SAC_DATATYPE_HEADER + self.value.len()
346    }
347}
348
349/// A SAC protocol message (§6.4.3.3, Tables 9-13): the ordered loop of datatypes
350/// carried inside a `cc_sac_data_req` / `cc_sac_data_cnf` envelope. The envelope
351/// APDU itself (`9F9007`/`9F9008`) defers to CI Plus V1.3, so this models only the
352/// datatype loop content.
353#[derive(Debug, Clone, PartialEq, Eq)]
354#[cfg_attr(feature = "serde", derive(serde::Serialize))]
355pub struct SacMessage<'a> {
356    /// The ordered datatypes (the `send`/`request` datatype loop content).
357    #[cfg_attr(feature = "serde", serde(borrow))]
358    pub datatypes: Vec<SacDatatype<'a>>,
359}
360
361impl<'a> SacMessage<'a> {
362    /// Parse a contiguous SAC datatype loop (`datatype_nbr` entries are inferred
363    /// from the buffer; the buffer must contain exactly whole datatypes).
364    pub fn parse(mut body: &'a [u8]) -> Result<Self> {
365        let mut datatypes = Vec::new();
366        while !body.is_empty() {
367            if body.len() < SAC_DATATYPE_HEADER {
368                return Err(Error::BufferTooShort {
369                    need: SAC_DATATYPE_HEADER,
370                    have: body.len(),
371                    what: "SAC datatype header",
372                });
373            }
374            let datatype_id = DatatypeId::from_u8(body[0]);
375            let len = u16::from_be_bytes([body[1], body[2]]) as usize;
376            let end = SAC_DATATYPE_HEADER + len;
377            if body.len() < end {
378                return Err(Error::BufferTooShort {
379                    need: end,
380                    have: body.len(),
381                    what: "SAC datatype value",
382                });
383            }
384            datatypes.push(SacDatatype {
385                datatype_id,
386                value: &body[SAC_DATATYPE_HEADER..end],
387            });
388            body = &body[end..];
389        }
390        Ok(Self { datatypes })
391    }
392
393    /// Serialized length of the whole datatype loop.
394    #[must_use]
395    pub fn serialized_len(&self) -> usize {
396        self.datatypes.iter().map(SacDatatype::serialized_len).sum()
397    }
398
399    /// Serialize the datatype loop into `buf`, returning the bytes written.
400    pub fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
401        let total = self.serialized_len();
402        if buf.len() < total {
403            return Err(Error::OutputBufferTooSmall {
404                need: total,
405                have: buf.len(),
406            });
407        }
408        let mut pos = 0;
409        for dt in &self.datatypes {
410            if dt.value.len() > u16::MAX as usize {
411                return Err(Error::LengthTooLarge(dt.value.len()));
412            }
413            buf[pos] = dt.datatype_id.to_u8();
414            buf[pos + 1..pos + 3].copy_from_slice(&(dt.value.len() as u16).to_be_bytes());
415            pos += SAC_DATATYPE_HEADER;
416            buf[pos..pos + dt.value.len()].copy_from_slice(dt.value);
417            pos += dt.value.len();
418        }
419        Ok(pos)
420    }
421
422    /// Serialize the datatype loop to a `Vec`.
423    #[must_use]
424    pub fn to_bytes(&self) -> Vec<u8> {
425        let mut buf = alloc::vec![0u8; self.serialized_len()];
426        let n = self.serialize_into(&mut buf).expect("buffer sized exactly");
427        debug_assert_eq!(n, buf.len());
428        buf
429    }
430}
431
432/// Resource-scoped dispatch over the printed-syntax Content Control objects.
433#[derive(Debug, Clone, PartialEq, Eq)]
434#[cfg_attr(feature = "serde", derive(serde::Serialize))]
435#[non_exhaustive]
436pub enum ContentControlApdu {
437    /// `cc_PIN_reply` (`9F 90 14`).
438    CcPinReply(CcPinReply),
439    /// `cc_PIN_event` (`9F 90 15`).
440    CcPinEvent(CcPinEvent),
441}
442
443impl ContentControlApdu {
444    /// Parse a Content Control APDU, dispatching on the leading `apdu_tag`.
445    ///
446    /// Only the two extended APDUs whose syntax TS 103 205 prints
447    /// (`cc_PIN_reply` / `cc_PIN_event`) are recognized; all other Table 6 tags
448    /// defer to CI Plus V1.3 and yield [`Error::UnexpectedApduTag`].
449    pub fn parse(body: &[u8]) -> Result<Self> {
450        if body.len() < 3 {
451            return Err(Error::BufferTooShort {
452                need: 3,
453                have: body.len(),
454                what: "content_control apdu_tag",
455            });
456        }
457        let t = ApduTag::from_bytes(body[0], body[1], body[2]);
458        match t {
459            tag::CC_PIN_REPLY => Ok(Self::CcPinReply(CcPinReply::parse(body)?)),
460            tag::CC_PIN_EVENT => Ok(Self::CcPinEvent(CcPinEvent::parse(body)?)),
461            _ => Err(Error::UnexpectedApduTag {
462                got: t.as_u24(),
463                expected: tag::CC_PIN_REPLY.as_u24(),
464                what: "content_control",
465            }),
466        }
467    }
468}
469
470impl Serialize for ContentControlApdu {
471    type Error = Error;
472    fn serialized_len(&self) -> usize {
473        match self {
474            Self::CcPinReply(o) => o.serialized_len(),
475            Self::CcPinEvent(o) => o.serialized_len(),
476        }
477    }
478    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
479        match self {
480            Self::CcPinReply(o) => o.serialize_into(buf),
481            Self::CcPinEvent(o) => o.serialize_into(buf),
482        }
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn cc_pin_reply_bound_round_trips_and_bites() {
492        let r = CcPinReply {
493            lts_id: Some(0x07),
494            pincode_status: 0x42,
495        };
496        let bytes = r.to_bytes();
497        // tag(3) + len(0x03) + flag-byte(0x01) + LTS_id(0x07) + status(0x42).
498        assert_eq!(bytes, [0x9F, 0x90, 0x14, 0x03, 0x01, 0x07, 0x42]);
499        assert_eq!(CcPinReply::parse(&bytes).unwrap(), r);
500        // Field-mutation: dropping the LTS binding changes the wire.
501        let other = CcPinReply {
502            lts_id: None,
503            pincode_status: 0x42,
504        };
505        let ob = other.to_bytes();
506        assert_eq!(ob, [0x9F, 0x90, 0x14, 0x03, 0x00, 0x00, 0x42]);
507        assert_ne!(bytes, ob);
508        assert_eq!(CcPinReply::parse(&ob).unwrap(), other);
509    }
510
511    #[test]
512    fn cc_pin_event_round_trips_and_bites() {
513        let e = CcPinEvent {
514            lts_id: 0x03,
515            program_number: 0x1234,
516            pincode_status: 0x05,
517            rating: 0x0A,
518            pin_event_time_utc: 0x01_0203_0405,
519            pin_event_time_centiseconds: 0x63,
520            private_data: [
521                0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D,
522                0x1E,
523            ],
524        };
525        let bytes = e.to_bytes();
526        let expected = [
527            0x9F, 0x90, 0x15, 0x1A, // tag + len (0x1A = 26 body bytes)
528            0x03, // LTS_id
529            0x12, 0x34, // program_number
530            0x05, // PINcode_status
531            0x0A, // rating
532            0x01, 0x02, 0x03, 0x04, 0x05, // utc (40 bits)
533            0x63, // centiseconds
534            0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D,
535            0x1E, // private_data (15)
536        ];
537        assert_eq!(bytes, expected);
538        assert_eq!(CcPinEvent::parse(&bytes).unwrap(), e);
539        // Field-mutation: bump utc.
540        let mut other = e;
541        other.pin_event_time_utc += 1;
542        assert_ne!(bytes, other.to_bytes());
543    }
544
545    #[test]
546    fn sac_message_two_datatypes_round_trips() {
547        // Record Stop content (Table 11): program_number(26)+LTS_id(50).
548        let msg = SacMessage {
549            datatypes: alloc::vec![
550                SacDatatype {
551                    datatype_id: DatatypeId::ProgramNumber,
552                    value: &[0x12, 0x34],
553                },
554                SacDatatype {
555                    datatype_id: DatatypeId::LtsId,
556                    value: &[0x07],
557                },
558            ],
559        };
560        let bytes = msg.to_bytes();
561        // 26=0x1A len 0x0002 12 34 ; 50=0x32 len 0x0001 07
562        assert_eq!(
563            bytes,
564            [0x1A, 0x00, 0x02, 0x12, 0x34, 0x32, 0x00, 0x01, 0x07]
565        );
566        assert_eq!(SacMessage::parse(&bytes).unwrap(), msg);
567        // Field-mutation: change a datatype_id.
568        let mut other = msg.clone();
569        other.datatypes[1].datatype_id = DatatypeId::Other(99);
570        assert_ne!(bytes, other.to_bytes());
571        assert_eq!(other.to_bytes()[5], 99);
572    }
573
574    #[test]
575    fn sac_message_with_opaque_license() {
576        // License Exchange (Table 13) subset: cicam_license(33) opaque blob.
577        let msg = SacMessage {
578            datatypes: alloc::vec![SacDatatype {
579                datatype_id: DatatypeId::CicamLicense,
580                value: &[0xDE, 0xAD, 0xBE, 0xEF],
581            }],
582        };
583        let bytes = msg.to_bytes();
584        assert_eq!(bytes, [0x21, 0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF]);
585        let parsed = SacMessage::parse(&bytes).unwrap();
586        assert_eq!(parsed, msg);
587        assert_eq!(parsed.datatypes[0].datatype_id.name(), "cicam_license");
588    }
589
590    #[test]
591    fn datatype_id_and_operating_mode_labels() {
592        assert_eq!(DatatypeId::LtsId.to_u8(), 50);
593        assert_eq!(DatatypeId::from_u8(50), DatatypeId::LtsId);
594        assert_eq!(DatatypeId::from_u8(33), DatatypeId::CicamLicense);
595        assert_eq!(DatatypeId::from_u8(39), DatatypeId::PinCode);
596        assert_eq!(DatatypeId::PinCode.name(), "PINcode");
597        assert_eq!(DatatypeId::Other(7).name(), "reserved");
598        assert_eq!(OperatingMode::from_u8(0x01), OperatingMode::Timeshift);
599        assert_eq!(
600            OperatingMode::from_u8(0x02),
601            OperatingMode::UnattendedRecording
602        );
603        assert_eq!(OperatingMode::Timeshift.to_u8(), 0x01);
604    }
605
606    #[test]
607    fn dispatch_routes_each_tag() {
608        let r = CcPinReply {
609            lts_id: None,
610            pincode_status: 0,
611        }
612        .to_bytes();
613        let parsed = ContentControlApdu::parse(&r).unwrap();
614        assert!(matches!(parsed, ContentControlApdu::CcPinReply(_)));
615        assert_eq!(parsed.to_bytes(), r);
616        // An unprinted Table 6 tag (cc_open_req 9F9001) is not encoded here.
617        let cc_open = [0x9F, 0x90, 0x01, 0x00];
618        assert!(matches!(
619            ContentControlApdu::parse(&cc_open),
620            Err(Error::UnexpectedApduTag { .. })
621        ));
622    }
623}