Skip to main content

dvb_ci_runtime/
resource.rs

1//! The resource layer — application-layer state machines (ETSI EN 50221 §8),
2//! one per resource, driven by the session layer's APDUs.
3//!
4//! Each resource implements [`Resource`]: it reacts to its session opening and
5//! to incoming APDUs, producing APDUs to send back, host [`Notification`]s, and
6//! requests to open further (module-provided) resources. This module ships the
7//! mandatory [`ResourceManager`]; application_information / conditional_access /
8//! date_time / mmi land as further `Resource` impls.
9
10use std::time::Duration;
11
12use broadcast_common::{Parse, Serialize};
13use dvb_ci::objects::application_info::{ApplicationInfo, ApplicationInfoEnq};
14use dvb_ci::objects::ca_info::{CaInfo, CaInfoEnq};
15use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
16use dvb_ci::objects::date_time::{DateTime as CiDateTime, DateTimeEnq, UTC_TIME_LEN};
17use dvb_ci::objects::host_control::{AskRelease, ClearReplace, Replace, Tune};
18use dvb_ci::objects::mmi_display::{
19    DisplayControl, DisplayControlCmd, DisplayReply, DisplayReplyBody, DisplayReplyId, MmiMode,
20};
21use dvb_ci::objects::mmi_high::{Enq, List, Menu};
22use dvb_ci::objects::resource_manager::{Profile, ProfileChange, ProfileEnq};
23use dvb_ci::resource::{
24    ResourceId, APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, DATE_TIME, HOST_CONTROL, MMI,
25    RESOURCE_MANAGER,
26};
27use dvb_ci::tag::{self, ApduTag};
28
29use crate::event::{HostControlEvent, MmiEvent, MmiMenu, Notification};
30
31/// Decode MMI `text_char` bytes to a `String` (lossy; full EN 300 468 Annex A
32/// decoding is the application's concern).
33fn text(chars: &[u8]) -> String {
34    String::from_utf8_lossy(chars).into_owned()
35}
36
37/// Project a parsed high-level [`Menu`] (also the body of a `list()`) onto the
38/// host-facing [`MmiMenu`] — the three header lines and the choice list kept
39/// distinct for display.
40fn to_menu(m: &Menu<'_>) -> MmiMenu {
41    MmiMenu {
42        title: text(m.title.text_chars),
43        subtitle: text(m.subtitle.text_chars),
44        bottom: text(m.bottom.text_chars),
45        choices: m.choices.iter().map(|c| text(c.text_chars)).collect(),
46    }
47}
48
49pub(crate) fn ser<S: Serialize>(s: &S) -> Vec<u8> {
50    let mut b = vec![0u8; s.serialized_len()];
51    match s.serialize_into(&mut b) {
52        Ok(n) => b.truncate(n),
53        Err(_) => b.clear(),
54    }
55    b
56}
57
58/// The 3-byte `apdu_tag` at the start of an APDU, if present.
59pub(crate) fn peek_tag(apdu: &[u8]) -> Option<ApduTag> {
60    (apdu.len() >= 3).then(|| ApduTag::from_bytes(apdu[0], apdu[1], apdu[2]))
61}
62
63/// What a resource wants done after reacting to an input.
64#[derive(Debug, Default, Clone, PartialEq, Eq)]
65pub struct ResourceOut {
66    /// APDUs to send on this resource's session.
67    pub apdus: Vec<Vec<u8>>,
68    /// Host-facing notifications.
69    pub notify: Vec<Notification>,
70    /// Module-provided resources the host should now open (`create_session`).
71    pub open: Vec<ResourceId>,
72}
73
74/// An EN 50221 application-layer resource.
75pub trait Resource {
76    /// The resource this handler serves.
77    fn id(&self) -> ResourceId;
78    /// The session for this resource just opened.
79    fn on_open(&mut self) -> ResourceOut {
80        ResourceOut::default()
81    }
82    /// An APDU arrived on this resource's session.
83    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut;
84    /// Logical time advanced (for resources with timers, e.g. date_time).
85    fn tick(&mut self, _elapsed: Duration) -> ResourceOut {
86        ResourceOut::default()
87    }
88}
89
90/// Resource Manager (§8.4.1) — host-provided. Drives the profile exchange and,
91/// once complete, reports [`Notification::CamReady`] and asks the host to open
92/// the module-provided resources it understands.
93#[derive(Debug)]
94pub struct ResourceManager {
95    host_resources: Vec<ResourceId>,
96    module_resources: Vec<ResourceId>,
97    module_profiled: bool,
98    ready: bool,
99}
100
101impl ResourceManager {
102    /// New RM advertising `host_resources` in its profile reply.
103    #[must_use]
104    pub fn new(host_resources: Vec<ResourceId>) -> Self {
105        Self {
106            host_resources,
107            module_resources: Vec::new(),
108            module_profiled: false,
109            ready: false,
110        }
111    }
112
113    /// Resources the module advertised (valid once the profile exchange ran).
114    #[must_use]
115    pub fn module_resources(&self) -> &[ResourceId] {
116        &self.module_resources
117    }
118}
119
120impl Resource for ResourceManager {
121    fn id(&self) -> ResourceId {
122        RESOURCE_MANAGER
123    }
124
125    fn on_open(&mut self) -> ResourceOut {
126        // Kick off the handshake: ask the module for its profile.
127        ResourceOut {
128            apdus: vec![ser(&ProfileEnq)],
129            ..ResourceOut::default()
130        }
131    }
132
133    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
134        let mut out = ResourceOut::default();
135        match peek_tag(apdu) {
136            // Module asks for the host's profile → reply with our resource list.
137            Some(t) if t == tag::PROFILE_ENQ => {
138                out.apdus.push(ser(&Profile {
139                    resources: self.host_resources.clone(),
140                }));
141            }
142            // Module's profile → record its resources.
143            Some(t) if t == tag::PROFILE => {
144                if let Ok(p) = Profile::parse(apdu) {
145                    self.module_resources = p.resources;
146                    self.module_profiled = true;
147                }
148            }
149            // Resource set changed → re-enquire.
150            Some(t) if t == tag::PROFILE_CHANGE => {
151                out.apdus.push(ser(&ProfileEnq));
152                self.module_profiled = false;
153                self.ready = false;
154            }
155            _ => {}
156        }
157        // Once we have the module's profile, the host sends `profile_change`
158        // (§8.4.1.1) — the gate the module waits on; until it arrives the module
159        // idles after its `profile` reply (#340 round 1).
160        //
161        // The host does NOT open application_information / conditional_access /
162        // mmi itself. Confirmed on hardware (#340, live AlphaCrypt): the module
163        // ignores a host `open_session_request` for them and rejects a
164        // `create_session` (`status=0xF0`). Those resources are **host-provided**
165        // — the host advertises them in its `profile` reply (see
166        // `CiStack::host_provided`), and the *module* opens sessions to them
167        // (module → host `open_session_request`), exactly as it does for
168        // resource_manager / date_time. The host just accepts. Each session's
169        // `on_open` then drives its enquiry (app_info_enq, ca_info_enq).
170        if self.module_profiled && !self.ready {
171            self.ready = true;
172            out.apdus.push(ser(&ProfileChange));
173            out.notify.push(Notification::CamReady);
174        }
175        out
176    }
177}
178
179/// Application Information (§8.4.2) — module-provided. On open, enquires the
180/// module's application info; surfaces it as [`Notification::ApplicationInfo`].
181#[derive(Debug, Default)]
182pub struct ApplicationInformation;
183
184impl Resource for ApplicationInformation {
185    fn id(&self) -> ResourceId {
186        APPLICATION_INFORMATION
187    }
188
189    fn on_open(&mut self) -> ResourceOut {
190        ResourceOut {
191            apdus: vec![ser(&ApplicationInfoEnq)],
192            ..ResourceOut::default()
193        }
194    }
195
196    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
197        let mut out = ResourceOut::default();
198        if peek_tag(apdu) == Some(tag::APPLICATION_INFO) {
199            if let Ok(ai) = ApplicationInfo::parse(apdu) {
200                out.notify.push(Notification::ApplicationInfo {
201                    application_type: ai.application_type.to_u8(),
202                    manufacturer: ai.application_manufacturer,
203                    code: ai.manufacturer_code,
204                    menu: String::from_utf8_lossy(ai.menu_string).into_owned(),
205                });
206            }
207        }
208        out
209    }
210}
211
212/// Conditional Access Support (§8.4.3) — module-provided. On open, enquires the
213/// module's supported `CA_system_id`s ([`Notification::CaInfo`]); decodes
214/// `ca_pmt_reply` ([`Notification::CaPmtReply`]). The host sends `ca_pmt` via
215/// [`HostRequest::SendCaPmt`](crate::event::HostRequest::SendCaPmt).
216#[derive(Debug, Default)]
217pub struct ConditionalAccess;
218
219impl Resource for ConditionalAccess {
220    fn id(&self) -> ResourceId {
221        CONDITIONAL_ACCESS_SUPPORT
222    }
223
224    fn on_open(&mut self) -> ResourceOut {
225        ResourceOut {
226            apdus: vec![ser(&CaInfoEnq)],
227            ..ResourceOut::default()
228        }
229    }
230
231    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
232        let mut out = ResourceOut::default();
233        match peek_tag(apdu) {
234            Some(t) if t == tag::CA_INFO => {
235                if let Ok(ci) = CaInfo::parse(apdu) {
236                    out.notify.push(Notification::CaInfo {
237                        ca_system_ids: ci.ca_system_ids,
238                    });
239                }
240            }
241            Some(t) if t == tag::CA_PMT_REPLY => {
242                if let Ok(r) = CaPmtReply::parse(apdu) {
243                    let descrambling_ok = r.ca_enable.is_some_and(|e| {
244                        matches!(
245                            e,
246                            CaEnable::Possible
247                                | CaEnable::PossiblePurchaseDialogue
248                                | CaEnable::PossibleTechnicalDialogue
249                        )
250                    });
251                    out.notify.push(Notification::CaPmtReply {
252                        program_number: r.program_number,
253                        descrambling_ok,
254                    });
255                }
256            }
257            _ => {}
258        }
259        out
260    }
261}
262
263const SECS_PER_DAY: u64 = 86_400;
264/// Modified Julian Date of the Unix epoch (1970-01-01).
265const MJD_UNIX_EPOCH: u64 = 40_587;
266
267fn bcd(v: u64) -> u8 {
268    (((v / 10) << 4) | (v % 10)) as u8
269}
270
271/// Encode a Unix timestamp as the 5-byte DVB `UTC_time` (MJD `[15:0]` + BCD
272/// HH:MM:SS), per EN 300 468 Annex C.
273fn unix_to_mjd_bcd(unix_secs: u64) -> [u8; UTC_TIME_LEN] {
274    let mjd = (MJD_UNIX_EPOCH + unix_secs / SECS_PER_DAY) as u16;
275    let sod = unix_secs % SECS_PER_DAY;
276    [
277        (mjd >> 8) as u8,
278        mjd as u8,
279        bcd(sod / 3600),
280        bcd((sod % 3600) / 60),
281        bcd(sod % 60),
282    ]
283}
284
285fn system_utc() -> [u8; UTC_TIME_LEN] {
286    let secs = std::time::SystemTime::now()
287        .duration_since(std::time::UNIX_EPOCH)
288        .map(|d| d.as_secs())
289        .unwrap_or(0);
290    unix_to_mjd_bcd(secs)
291}
292
293/// Date-Time (§8.5.2) — host-provided. On `date_time_enq` replies with the
294/// current UTC; if the enquiry's `response_interval` is non-zero, re-sends every
295/// `response_interval` seconds (driven by [`tick`](Resource::tick)).
296pub struct DateTime {
297    clock: fn() -> [u8; UTC_TIME_LEN],
298    interval: u8,
299    since: Duration,
300}
301
302impl Default for DateTime {
303    fn default() -> Self {
304        Self::new()
305    }
306}
307
308impl DateTime {
309    /// New handler using the system clock.
310    #[must_use]
311    pub fn new() -> Self {
312        Self {
313            clock: system_utc,
314            interval: 0,
315            since: Duration::ZERO,
316        }
317    }
318
319    /// New handler with an injected clock (for tests / a host-supplied source).
320    #[must_use]
321    pub fn with_clock(clock: fn() -> [u8; UTC_TIME_LEN]) -> Self {
322        Self {
323            clock,
324            interval: 0,
325            since: Duration::ZERO,
326        }
327    }
328
329    fn reply(&self) -> Vec<u8> {
330        ser(&CiDateTime {
331            utc_time: (self.clock)(),
332            local_offset: None,
333        })
334    }
335}
336
337impl Resource for DateTime {
338    fn id(&self) -> ResourceId {
339        DATE_TIME
340    }
341
342    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
343        let mut out = ResourceOut::default();
344        if peek_tag(apdu) == Some(tag::DATE_TIME_ENQ) {
345            if let Ok(enq) = DateTimeEnq::parse(apdu) {
346                self.interval = enq.response_interval;
347                self.since = Duration::ZERO;
348                out.apdus.push(self.reply());
349            }
350        }
351        out
352    }
353
354    fn tick(&mut self, elapsed: Duration) -> ResourceOut {
355        let mut out = ResourceOut::default();
356        if self.interval > 0 {
357            self.since += elapsed;
358            if self.since >= Duration::from_secs(u64::from(self.interval)) {
359                self.since = Duration::ZERO;
360                out.apdus.push(self.reply());
361            }
362        }
363        out
364    }
365}
366
367/// MMI (§8.6) — module-provided. Surfaces the module's menus/enquiries and the
368/// close as [`Notification::Mmi`] events for the application to display, and
369/// answers the module's `display_control` mode negotiation. The host drives the
370/// dialog back through [`Driver::mmi_menu_answer`](crate::Driver::mmi_menu_answer)
371/// / [`mmi_enquiry_answer`](crate::Driver::mmi_enquiry_answer) /
372/// [`mmi_cancel`](crate::Driver::mmi_cancel) (sent by [`CiStack`](crate::CiStack)
373/// on the open MMI session).
374#[derive(Debug, Default)]
375pub struct Mmi;
376
377impl Resource for Mmi {
378    fn id(&self) -> ResourceId {
379        MMI
380    }
381
382    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
383        let mut out = ResourceOut::default();
384        match peek_tag(apdu) {
385            Some(t) if t == tag::ENQ => {
386                if let Ok(e) = Enq::parse(apdu) {
387                    out.notify.push(Notification::Mmi(MmiEvent::Enquiry {
388                        prompt: text(e.text_chars),
389                        blind: e.blind_answer,
390                        answer_len: e.answer_text_length,
391                    }));
392                }
393            }
394            Some(t) if t == tag::MENU_LAST => {
395                if let Ok(m) = Menu::parse(apdu) {
396                    out.notify
397                        .push(Notification::Mmi(MmiEvent::Menu(to_menu(&m))));
398                }
399            }
400            Some(t) if t == tag::LIST_LAST => {
401                if let Ok(l) = List::parse(apdu) {
402                    out.notify
403                        .push(Notification::Mmi(MmiEvent::List(to_menu(&l.0))));
404                }
405            }
406            Some(t) if t == tag::CLOSE_MMI => {
407                out.notify.push(Notification::Mmi(MmiEvent::Close));
408            }
409            // High-level MMI mode negotiation (§8.6.1): the module opens an MMI
410            // session and sends `display_control`. The host MUST answer
411            // `display_reply` or the module aborts the MMI — verified live: a
412            // real AlphaCrypt opens MMI after `ca_pmt` and, with no reply, closes
413            // the session and never descrambles (an Enigma2 box answers it).
414            Some(t) if t == tag::DISPLAY_CONTROL => {
415                if let Ok(dc) = DisplayControl::parse(apdu) {
416                    let reply = match dc.cmd {
417                        // Acknowledge the requested MMI mode (echo it back).
418                        DisplayControlCmd::SetMmiMode => DisplayReply {
419                            reply_id: DisplayReplyId::MmiModeAck,
420                            body: DisplayReplyBody::MmiModeAck(
421                                dc.mmi_mode.unwrap_or(MmiMode::HighLevel),
422                            ),
423                        },
424                        // We don't implement the character-table / graphics
425                        // queries; tell the module so per Table 35.
426                        _ => DisplayReply {
427                            reply_id: DisplayReplyId::UnknownDisplayControlCmd,
428                            body: DisplayReplyBody::None,
429                        },
430                    };
431                    out.apdus.push(ser(&reply));
432                }
433            }
434            _ => {}
435        }
436        out
437    }
438}
439
440/// Host Control (§8.5.1) — host-provided. The module opens a host_control
441/// session and issues `tune` / `replace` / `clear_replace` / `ask_release`
442/// objects; this handler decodes each and surfaces it as
443/// [`Notification::HostControl`]. The host acts on the request out of band (it
444/// retunes / replaces PIDs itself) — the runtime does not re-tune, so there is
445/// no reply APDU.
446#[derive(Debug, Default)]
447pub struct HostControl;
448
449impl Resource for HostControl {
450    fn id(&self) -> ResourceId {
451        HOST_CONTROL
452    }
453
454    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
455        let mut out = ResourceOut::default();
456        let event = match peek_tag(apdu) {
457            Some(t) if t == tag::TUNE => Tune::parse(apdu).ok().map(|t| HostControlEvent::Tune {
458                network_id: t.network_id,
459                original_network_id: t.original_network_id,
460                transport_stream_id: t.transport_stream_id,
461                service_id: t.service_id,
462            }),
463            Some(t) if t == tag::REPLACE => {
464                Replace::parse(apdu)
465                    .ok()
466                    .map(|r| HostControlEvent::Replace {
467                        replacement_ref: r.replacement_ref,
468                        replaced_pid: r.replaced_pid,
469                        replacement_pid: r.replacement_pid,
470                    })
471            }
472            Some(t) if t == tag::CLEAR_REPLACE => {
473                ClearReplace::parse(apdu)
474                    .ok()
475                    .map(|c| HostControlEvent::ClearReplace {
476                        replacement_ref: c.replacement_ref,
477                    })
478            }
479            Some(t) if t == tag::ASK_RELEASE => AskRelease::parse(apdu)
480                .ok()
481                .map(|_| HostControlEvent::AskRelease),
482            _ => None,
483        };
484        if let Some(event) = event {
485            out.notify.push(Notification::HostControl(event));
486        }
487        out
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use dvb_ci::objects::resource_manager::Profile;
495
496    #[test]
497    fn on_open_sends_profile_enq() {
498        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
499        let out = rm.on_open();
500        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
501    }
502
503    #[test]
504    fn module_profile_triggers_profile_change_and_camready() {
505        // #340: after the module's `profile`, the host fires CamReady and sends
506        // `profile_change` (the §8.4.1.1 gate) — and nothing else. It does NOT
507        // open application_information / conditional_access / mmi itself: those
508        // are host-provided resources the module opens sessions to (verified on
509        // a live AlphaCrypt, which rejects/ignores host-initiated opens).
510        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
511        rm.on_open();
512        let empty_profile = ser(&Profile { resources: vec![] });
513        let o = rm.on_apdu(&empty_profile);
514        assert!(o.notify.contains(&Notification::CamReady));
515        assert_eq!(o.apdus.len(), 1, "host sends profile_change");
516        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE_CHANGE));
517        assert!(o.open.is_empty(), "host opens no sessions itself");
518    }
519
520    #[test]
521    fn answers_a_module_profile_enquiry_without_re_readying() {
522        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
523        rm.on_open();
524        rm.on_apdu(&ser(&Profile {
525            resources: vec![APPLICATION_INFORMATION],
526        }));
527        // A later module profile_enq → reply with our profile, no second CamReady.
528        let o = rm.on_apdu(&ser(&ProfileEnq));
529        assert_eq!(o.apdus.len(), 1);
530        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE));
531        assert!(!o.notify.contains(&Notification::CamReady));
532    }
533
534    #[test]
535    fn mmi_surfaces_enquiry_and_close() {
536        let mut h = Mmi;
537        // enquiry
538        let enq = ser(&Enq {
539            blind_answer: true,
540            answer_text_length: 4,
541            text_chars: b"PIN?",
542        });
543        assert_eq!(
544            h.on_apdu(&enq).notify,
545            vec![Notification::Mmi(MmiEvent::Enquiry {
546                prompt: "PIN?".to_string(),
547                blind: true,
548                answer_len: 4,
549            })]
550        );
551        // close_mmi (tag 9F 88 00) — surfaced as Close
552        let close = [0x9F, 0x88, 0x00, 0x01, 0x00];
553        assert_eq!(
554            h.on_apdu(&close).notify,
555            vec![Notification::Mmi(MmiEvent::Close)]
556        );
557    }
558
559    #[test]
560    fn mmi_surfaces_structured_menu_and_list() {
561        use dvb_ci::objects::mmi_high::{List, Menu, Text};
562        let txt = |s: &'static [u8]| Text {
563            more: false,
564            text_chars: s,
565        };
566        let mut h = Mmi;
567        // A `menu()` → MmiEvent::Menu with header lines and choices kept distinct.
568        let menu = ser(&Menu {
569            more: false,
570            choice_nb: 2,
571            title: txt(b"AlphaCrypt"),
572            subtitle: txt(b"Module Mainmenu"),
573            bottom: txt(b"Select item and press OK"),
574            choices: vec![txt(b"Smartcard"), txt(b"Quit")],
575        });
576        assert_eq!(
577            h.on_apdu(&menu).notify,
578            vec![Notification::Mmi(MmiEvent::Menu(MmiMenu {
579                title: "AlphaCrypt".to_string(),
580                subtitle: "Module Mainmenu".to_string(),
581                bottom: "Select item and press OK".to_string(),
582                choices: vec!["Smartcard".to_string(), "Quit".to_string()],
583            }))]
584        );
585        // A `list()` (same body) → MmiEvent::List.
586        let list = ser(&List(Menu {
587            more: false,
588            choice_nb: 0xFF,
589            title: txt(b"Entitlements"),
590            subtitle: txt(b""),
591            bottom: txt(b""),
592            choices: vec![txt(b"ORF AUT")],
593        }));
594        assert_eq!(
595            h.on_apdu(&list).notify,
596            vec![Notification::Mmi(MmiEvent::List(MmiMenu {
597                title: "Entitlements".to_string(),
598                subtitle: String::new(),
599                bottom: String::new(),
600                choices: vec!["ORF AUT".to_string()],
601            }))]
602        );
603    }
604
605    #[test]
606    fn mmi_answers_display_control_set_mmi_mode() {
607        use dvb_ci::objects::mmi_display::{DisplayControl, DisplayControlCmd, MmiMode};
608        let mut h = Mmi;
609        let dc = ser(&DisplayControl {
610            cmd: DisplayControlCmd::SetMmiMode,
611            mmi_mode: Some(MmiMode::HighLevel),
612        });
613        let out = h.on_apdu(&dc);
614        // mmi_mode_ack(high_level): 9F 88 02 02 01 01.
615        assert_eq!(out.apdus, vec![vec![0x9F, 0x88, 0x02, 0x02, 0x01, 0x01]]);
616        assert!(out.notify.is_empty());
617    }
618
619    #[test]
620    fn profile_change_re_enquires() {
621        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
622        let out = rm.on_apdu(&ser(&dvb_ci::objects::resource_manager::ProfileChange));
623        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
624    }
625
626    #[test]
627    fn application_information_surfaces_notification() {
628        use dvb_ci::objects::application_info::ApplicationType;
629        let mut h = ApplicationInformation;
630        assert_eq!(h.on_open().apdus, vec![ser(&ApplicationInfoEnq)]);
631        let ai = ser(&ApplicationInfo {
632            application_type: ApplicationType::ConditionalAccess,
633            application_manufacturer: 0x1234,
634            manufacturer_code: 0x5678,
635            menu_string: b"Acme CAM",
636        });
637        let out = h.on_apdu(&ai);
638        assert_eq!(
639            out.notify,
640            vec![Notification::ApplicationInfo {
641                application_type: 0x01,
642                manufacturer: 0x1234,
643                code: 0x5678,
644                menu: "Acme CAM".to_string(),
645            }]
646        );
647    }
648
649    #[test]
650    fn host_control_surfaces_tune_replace_clear_and_ask_release() {
651        let mut h = HostControl;
652        assert_eq!(h.id(), HOST_CONTROL);
653
654        // tune() → HostControlEvent::Tune with the four 16-bit identifiers.
655        let tune = ser(&Tune {
656            network_id: 0x1122,
657            original_network_id: 0x3344,
658            transport_stream_id: 0x5566,
659            service_id: 0x7788,
660        });
661        assert_eq!(
662            h.on_apdu(&tune).notify,
663            vec![Notification::HostControl(HostControlEvent::Tune {
664                network_id: 0x1122,
665                original_network_id: 0x3344,
666                transport_stream_id: 0x5566,
667                service_id: 0x7788,
668            })]
669        );
670
671        // replace() → HostControlEvent::Replace with the 13-bit PIDs decoded.
672        let replace = ser(&Replace {
673            replacement_ref: 0x07,
674            replaced_pid: 0x0123,
675            replacement_pid: 0x01FF,
676        });
677        assert_eq!(
678            h.on_apdu(&replace).notify,
679            vec![Notification::HostControl(HostControlEvent::Replace {
680                replacement_ref: 0x07,
681                replaced_pid: 0x0123,
682                replacement_pid: 0x01FF,
683            })]
684        );
685
686        // clear_replace() → HostControlEvent::ClearReplace.
687        let clear = ser(&ClearReplace {
688            replacement_ref: 0x42,
689        });
690        assert_eq!(
691            h.on_apdu(&clear).notify,
692            vec![Notification::HostControl(HostControlEvent::ClearReplace {
693                replacement_ref: 0x42,
694            })]
695        );
696
697        // ask_release() → HostControlEvent::AskRelease (header-only).
698        let ask = ser(&AskRelease);
699        assert_eq!(
700            h.on_apdu(&ask).notify,
701            vec![Notification::HostControl(HostControlEvent::AskRelease)]
702        );
703
704        // The host acts out of band: no reply APDU is produced.
705        assert!(h.on_apdu(&tune).apdus.is_empty());
706    }
707
708    #[test]
709    fn mjd_bcd_encoding_is_correct() {
710        // Unix epoch 1970-01-01 00:00:00 → MJD 40587 (0x9E8B), 00:00:00.
711        assert_eq!(unix_to_mjd_bcd(0), [0x9E, 0x8B, 0x00, 0x00, 0x00]);
712        // 1970-01-02 13:45:09 → MJD 40588 (0x9E8C), BCD 13 45 09.
713        let secs = SECS_PER_DAY + 13 * 3600 + 45 * 60 + 9;
714        assert_eq!(unix_to_mjd_bcd(secs), [0x9E, 0x8C, 0x13, 0x45, 0x09]);
715    }
716
717    #[test]
718    fn date_time_replies_to_enq_and_resends_on_interval() {
719        let fixed = || [0x9E, 0x7B, 0x00, 0x00, 0x00];
720        let mut h = DateTime::with_clock(fixed);
721        // enquiry with a 5s response interval → immediate reply
722        let enq = ser(&DateTimeEnq {
723            response_interval: 5,
724        });
725        let out = h.on_apdu(&enq);
726        assert_eq!(out.apdus.len(), 1);
727        assert_eq!(peek_tag(&out.apdus[0]), Some(tag::DATE_TIME));
728        // before the interval: no resend
729        assert!(h.tick(Duration::from_secs(3)).apdus.is_empty());
730        // crossing the interval: resend
731        assert_eq!(h.tick(Duration::from_secs(3)).apdus.len(), 1);
732    }
733
734    #[test]
735    fn date_time_interval_zero_does_not_resend() {
736        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
737        h.on_apdu(&ser(&DateTimeEnq {
738            response_interval: 0,
739        }));
740        assert!(h.tick(Duration::from_secs(60)).apdus.is_empty());
741    }
742
743    #[test]
744    fn conditional_access_surfaces_ca_info_and_pmt_reply() {
745        let mut h = ConditionalAccess;
746        assert_eq!(h.on_open().apdus, vec![ser(&CaInfoEnq)]);
747        // ca_info -> CaInfo notification
748        let ci = ser(&CaInfo {
749            ca_system_ids: vec![0x0B00, 0x1800],
750        });
751        assert_eq!(
752            h.on_apdu(&ci).notify,
753            vec![Notification::CaInfo {
754                ca_system_ids: vec![0x0B00, 0x1800],
755            }]
756        );
757        // ca_pmt_reply (descrambling possible) -> CaPmtReply notification
758        let reply = ser(&CaPmtReply {
759            program_number: 0x0042,
760            version_number: 0,
761            current_next_indicator: true,
762            ca_enable: Some(CaEnable::Possible),
763            streams: vec![],
764        });
765        assert_eq!(
766            h.on_apdu(&reply).notify,
767            vec![Notification::CaPmtReply {
768                program_number: 0x0042,
769                descrambling_ok: true,
770            }]
771        );
772    }
773}