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