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    APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, DATE_TIME, HOST_CONTROL, MMI,
25    RESOURCE_MANAGER, ResourceId,
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                    // EN 50221 §8.4.3.5 Table 26: programme-level `CA_enable`.
244                    // Plumb the object's own `Option<CaEnable>` straight
245                    // through — `None` means the programme
246                    // `CA_enable_flag` bit was clear (no programme-level
247                    // status given), which is distinct from a genuine
248                    // flag-set reserved code and must not be collapsed to a
249                    // sentinel.
250                    let descrambling_ok = matches!(
251                        r.ca_enable,
252                        Some(
253                            CaEnable::Possible
254                                | CaEnable::PossiblePurchaseDialogue
255                                | CaEnable::PossibleTechnicalDialogue
256                        )
257                    );
258                    out.notify.push(Notification::CaPmtReply {
259                        program_number: r.program_number,
260                        ca_enable: r.ca_enable,
261                        descrambling_ok,
262                    });
263                }
264            }
265            _ => {}
266        }
267        out
268    }
269}
270
271const SECS_PER_DAY: u64 = 86_400;
272/// Modified Julian Date of the Unix epoch (1970-01-01).
273const MJD_UNIX_EPOCH: u64 = 40_587;
274
275fn bcd(v: u64) -> u8 {
276    (((v / 10) << 4) | (v % 10)) as u8
277}
278
279/// Encode a Unix timestamp as the 5-byte DVB `UTC_time` (MJD `[15:0]` + BCD
280/// HH:MM:SS), per EN 300 468 Annex C.
281fn unix_to_mjd_bcd(unix_secs: u64) -> [u8; UTC_TIME_LEN] {
282    let mjd = (MJD_UNIX_EPOCH + unix_secs / SECS_PER_DAY) as u16;
283    let sod = unix_secs % SECS_PER_DAY;
284    [
285        (mjd >> 8) as u8,
286        mjd as u8,
287        bcd(sod / 3600),
288        bcd((sod % 3600) / 60),
289        bcd(sod % 60),
290    ]
291}
292
293fn system_utc() -> [u8; UTC_TIME_LEN] {
294    let secs = std::time::SystemTime::now()
295        .duration_since(std::time::UNIX_EPOCH)
296        .map(|d| d.as_secs())
297        .unwrap_or(0);
298    unix_to_mjd_bcd(secs)
299}
300
301/// Date-Time (§8.5.2) — host-provided. On `date_time_enq` replies with the
302/// current UTC; if the enquiry's `response_interval` is non-zero, re-sends every
303/// `response_interval` seconds (driven by [`tick`](Resource::tick)).
304pub struct DateTime {
305    clock: fn() -> [u8; UTC_TIME_LEN],
306    interval: u8,
307    since: Duration,
308}
309
310impl Default for DateTime {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316impl DateTime {
317    /// New handler using the system clock.
318    #[must_use]
319    pub fn new() -> Self {
320        Self {
321            clock: system_utc,
322            interval: 0,
323            since: Duration::ZERO,
324        }
325    }
326
327    /// New handler with an injected clock (for tests / a host-supplied source).
328    #[must_use]
329    pub fn with_clock(clock: fn() -> [u8; UTC_TIME_LEN]) -> Self {
330        Self {
331            clock,
332            interval: 0,
333            since: Duration::ZERO,
334        }
335    }
336
337    fn reply(&self) -> Vec<u8> {
338        ser(&CiDateTime {
339            utc_time: (self.clock)(),
340            local_offset: None,
341        })
342    }
343}
344
345impl Resource for DateTime {
346    fn id(&self) -> ResourceId {
347        DATE_TIME
348    }
349
350    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
351        let mut out = ResourceOut::default();
352        if peek_tag(apdu) == Some(tag::DATE_TIME_ENQ) {
353            if let Ok(enq) = DateTimeEnq::parse(apdu) {
354                self.interval = enq.response_interval;
355                self.since = Duration::ZERO;
356                out.apdus.push(self.reply());
357            }
358        }
359        out
360    }
361
362    fn tick(&mut self, elapsed: Duration) -> ResourceOut {
363        let mut out = ResourceOut::default();
364        if self.interval > 0 {
365            self.since += elapsed;
366            if self.since >= Duration::from_secs(u64::from(self.interval)) {
367                self.since = Duration::ZERO;
368                out.apdus.push(self.reply());
369            }
370        }
371        out
372    }
373}
374
375/// MMI (§8.6) — module-provided. Surfaces the module's menus/enquiries and the
376/// close as [`Notification::Mmi`] events for the application to display, and
377/// answers the module's `display_control` mode negotiation. The host drives the
378/// dialog back through [`Driver::mmi_menu_answer`](crate::Driver::mmi_menu_answer)
379/// / [`mmi_enquiry_answer`](crate::Driver::mmi_enquiry_answer) /
380/// [`mmi_cancel`](crate::Driver::mmi_cancel) (sent by [`CiStack`](crate::CiStack)
381/// on the open MMI session).
382#[derive(Debug, Default)]
383pub struct Mmi;
384
385impl Resource for Mmi {
386    fn id(&self) -> ResourceId {
387        MMI
388    }
389
390    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
391        let mut out = ResourceOut::default();
392        match peek_tag(apdu) {
393            Some(t) if t == tag::ENQ => {
394                if let Ok(e) = Enq::parse(apdu) {
395                    out.notify.push(Notification::Mmi(MmiEvent::Enquiry {
396                        prompt: text(e.text_chars),
397                        blind: e.blind_answer,
398                        answer_len: e.answer_text_length,
399                    }));
400                }
401            }
402            Some(t) if t == tag::MENU_LAST => {
403                if let Ok(m) = Menu::parse(apdu) {
404                    out.notify
405                        .push(Notification::Mmi(MmiEvent::Menu(to_menu(&m))));
406                }
407            }
408            Some(t) if t == tag::LIST_LAST => {
409                if let Ok(l) = List::parse(apdu) {
410                    out.notify
411                        .push(Notification::Mmi(MmiEvent::List(to_menu(&l.0))));
412                }
413            }
414            Some(t) if t == tag::CLOSE_MMI => {
415                out.notify.push(Notification::Mmi(MmiEvent::Close));
416            }
417            // High-level MMI mode negotiation (§8.6.1): the module opens an MMI
418            // session and sends `display_control`. The host MUST answer
419            // `display_reply` or the module aborts the MMI — verified live: a
420            // real AlphaCrypt opens MMI after `ca_pmt` and, with no reply, closes
421            // the session and never descrambles (an Enigma2 box answers it).
422            Some(t) if t == tag::DISPLAY_CONTROL => {
423                if let Ok(dc) = DisplayControl::parse(apdu) {
424                    let reply = match dc.cmd {
425                        // Acknowledge the requested MMI mode (echo it back).
426                        DisplayControlCmd::SetMmiMode => DisplayReply {
427                            reply_id: DisplayReplyId::MmiModeAck,
428                            body: DisplayReplyBody::MmiModeAck(
429                                dc.mmi_mode.unwrap_or(MmiMode::HighLevel),
430                            ),
431                        },
432                        // We don't implement the character-table / graphics
433                        // queries; tell the module so per Table 35.
434                        _ => DisplayReply {
435                            reply_id: DisplayReplyId::UnknownDisplayControlCmd,
436                            body: DisplayReplyBody::None,
437                        },
438                    };
439                    out.apdus.push(ser(&reply));
440                }
441            }
442            _ => {}
443        }
444        out
445    }
446}
447
448/// Host Control (§8.5.1) — host-provided. The module opens a host_control
449/// session and issues `tune` / `replace` / `clear_replace` / `ask_release`
450/// objects; this handler decodes each and surfaces it as
451/// [`Notification::HostControl`]. The host acts on the request out of band (it
452/// retunes / replaces PIDs itself) — the runtime does not re-tune, so there is
453/// no reply APDU.
454#[derive(Debug, Default)]
455pub struct HostControl;
456
457impl Resource for HostControl {
458    fn id(&self) -> ResourceId {
459        HOST_CONTROL
460    }
461
462    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
463        let mut out = ResourceOut::default();
464        let event = match peek_tag(apdu) {
465            Some(t) if t == tag::TUNE => Tune::parse(apdu).ok().map(|t| HostControlEvent::Tune {
466                network_id: t.network_id,
467                original_network_id: t.original_network_id,
468                transport_stream_id: t.transport_stream_id,
469                service_id: t.service_id,
470            }),
471            Some(t) if t == tag::REPLACE => {
472                Replace::parse(apdu)
473                    .ok()
474                    .map(|r| HostControlEvent::Replace {
475                        replacement_ref: r.replacement_ref,
476                        replaced_pid: r.replaced_pid,
477                        replacement_pid: r.replacement_pid,
478                    })
479            }
480            Some(t) if t == tag::CLEAR_REPLACE => {
481                ClearReplace::parse(apdu)
482                    .ok()
483                    .map(|c| HostControlEvent::ClearReplace {
484                        replacement_ref: c.replacement_ref,
485                    })
486            }
487            Some(t) if t == tag::ASK_RELEASE => AskRelease::parse(apdu)
488                .ok()
489                .map(|_| HostControlEvent::AskRelease),
490            _ => None,
491        };
492        if let Some(event) = event {
493            out.notify.push(Notification::HostControl(event));
494        }
495        out
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use dvb_ci::objects::resource_manager::Profile;
503
504    #[test]
505    fn on_open_sends_profile_enq() {
506        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
507        let out = rm.on_open();
508        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
509    }
510
511    #[test]
512    fn module_profile_triggers_profile_change_and_camready() {
513        // #340: after the module's `profile`, the host fires CamReady and sends
514        // `profile_change` (the §8.4.1.1 gate) — and nothing else. It does NOT
515        // open application_information / conditional_access / mmi itself: those
516        // are host-provided resources the module opens sessions to (verified on
517        // a live AlphaCrypt, which rejects/ignores host-initiated opens).
518        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
519        rm.on_open();
520        let empty_profile = ser(&Profile { resources: vec![] });
521        let o = rm.on_apdu(&empty_profile);
522        assert!(o.notify.contains(&Notification::CamReady));
523        assert_eq!(o.apdus.len(), 1, "host sends profile_change");
524        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE_CHANGE));
525        assert!(o.open.is_empty(), "host opens no sessions itself");
526    }
527
528    #[test]
529    fn answers_a_module_profile_enquiry_without_re_readying() {
530        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
531        rm.on_open();
532        rm.on_apdu(&ser(&Profile {
533            resources: vec![APPLICATION_INFORMATION],
534        }));
535        // A later module profile_enq → reply with our profile, no second CamReady.
536        let o = rm.on_apdu(&ser(&ProfileEnq));
537        assert_eq!(o.apdus.len(), 1);
538        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE));
539        assert!(!o.notify.contains(&Notification::CamReady));
540    }
541
542    #[test]
543    fn mmi_surfaces_enquiry_and_close() {
544        let mut h = Mmi;
545        // enquiry
546        let enq = ser(&Enq {
547            blind_answer: true,
548            answer_text_length: 4,
549            text_chars: b"PIN?",
550        });
551        assert_eq!(
552            h.on_apdu(&enq).notify,
553            vec![Notification::Mmi(MmiEvent::Enquiry {
554                prompt: "PIN?".to_string(),
555                blind: true,
556                answer_len: 4,
557            })]
558        );
559        // close_mmi (tag 9F 88 00) — surfaced as Close
560        let close = [0x9F, 0x88, 0x00, 0x01, 0x00];
561        assert_eq!(
562            h.on_apdu(&close).notify,
563            vec![Notification::Mmi(MmiEvent::Close)]
564        );
565    }
566
567    #[test]
568    fn mmi_surfaces_structured_menu_and_list() {
569        use dvb_ci::objects::mmi_high::{List, Menu, Text};
570        let txt = |s: &'static [u8]| Text {
571            more: false,
572            text_chars: s,
573        };
574        let mut h = Mmi;
575        // A `menu()` → MmiEvent::Menu with header lines and choices kept distinct.
576        let menu = ser(&Menu {
577            more: false,
578            choice_nb: 2,
579            title: txt(b"AlphaCrypt"),
580            subtitle: txt(b"Module Mainmenu"),
581            bottom: txt(b"Select item and press OK"),
582            choices: vec![txt(b"Smartcard"), txt(b"Quit")],
583        });
584        assert_eq!(
585            h.on_apdu(&menu).notify,
586            vec![Notification::Mmi(MmiEvent::Menu(MmiMenu {
587                title: "AlphaCrypt".to_string(),
588                subtitle: "Module Mainmenu".to_string(),
589                bottom: "Select item and press OK".to_string(),
590                choices: vec!["Smartcard".to_string(), "Quit".to_string()],
591            }))]
592        );
593        // A `list()` (same body) → MmiEvent::List.
594        let list = ser(&List(Menu {
595            more: false,
596            choice_nb: 0xFF,
597            title: txt(b"Entitlements"),
598            subtitle: txt(b""),
599            bottom: txt(b""),
600            choices: vec![txt(b"ORF AUT")],
601        }));
602        assert_eq!(
603            h.on_apdu(&list).notify,
604            vec![Notification::Mmi(MmiEvent::List(MmiMenu {
605                title: "Entitlements".to_string(),
606                subtitle: String::new(),
607                bottom: String::new(),
608                choices: vec!["ORF AUT".to_string()],
609            }))]
610        );
611    }
612
613    #[test]
614    fn mmi_answers_display_control_set_mmi_mode() {
615        use dvb_ci::objects::mmi_display::{DisplayControl, DisplayControlCmd, MmiMode};
616        let mut h = Mmi;
617        let dc = ser(&DisplayControl {
618            cmd: DisplayControlCmd::SetMmiMode,
619            mmi_mode: Some(MmiMode::HighLevel),
620        });
621        let out = h.on_apdu(&dc);
622        // mmi_mode_ack(high_level): 9F 88 02 02 01 01.
623        assert_eq!(out.apdus, vec![vec![0x9F, 0x88, 0x02, 0x02, 0x01, 0x01]]);
624        assert!(out.notify.is_empty());
625    }
626
627    #[test]
628    fn profile_change_re_enquires() {
629        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
630        let out = rm.on_apdu(&ser(&dvb_ci::objects::resource_manager::ProfileChange));
631        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
632    }
633
634    #[test]
635    fn application_information_surfaces_notification() {
636        use dvb_ci::objects::application_info::ApplicationType;
637        let mut h = ApplicationInformation;
638        assert_eq!(h.on_open().apdus, vec![ser(&ApplicationInfoEnq)]);
639        let ai = ser(&ApplicationInfo {
640            application_type: ApplicationType::ConditionalAccess,
641            application_manufacturer: 0x1234,
642            manufacturer_code: 0x5678,
643            menu_string: b"Acme CAM",
644        });
645        let out = h.on_apdu(&ai);
646        assert_eq!(
647            out.notify,
648            vec![Notification::ApplicationInfo {
649                application_type: 0x01,
650                manufacturer: 0x1234,
651                code: 0x5678,
652                menu: "Acme CAM".to_string(),
653            }]
654        );
655    }
656
657    #[test]
658    fn host_control_surfaces_tune_replace_clear_and_ask_release() {
659        let mut h = HostControl;
660        assert_eq!(h.id(), HOST_CONTROL);
661
662        // tune() → HostControlEvent::Tune with the four 16-bit identifiers.
663        let tune = ser(&Tune {
664            network_id: 0x1122,
665            original_network_id: 0x3344,
666            transport_stream_id: 0x5566,
667            service_id: 0x7788,
668        });
669        assert_eq!(
670            h.on_apdu(&tune).notify,
671            vec![Notification::HostControl(HostControlEvent::Tune {
672                network_id: 0x1122,
673                original_network_id: 0x3344,
674                transport_stream_id: 0x5566,
675                service_id: 0x7788,
676            })]
677        );
678
679        // replace() → HostControlEvent::Replace with the 13-bit PIDs decoded.
680        let replace = ser(&Replace {
681            replacement_ref: 0x07,
682            replaced_pid: 0x0123,
683            replacement_pid: 0x01FF,
684        });
685        assert_eq!(
686            h.on_apdu(&replace).notify,
687            vec![Notification::HostControl(HostControlEvent::Replace {
688                replacement_ref: 0x07,
689                replaced_pid: 0x0123,
690                replacement_pid: 0x01FF,
691            })]
692        );
693
694        // clear_replace() → HostControlEvent::ClearReplace.
695        let clear = ser(&ClearReplace {
696            replacement_ref: 0x42,
697        });
698        assert_eq!(
699            h.on_apdu(&clear).notify,
700            vec![Notification::HostControl(HostControlEvent::ClearReplace {
701                replacement_ref: 0x42,
702            })]
703        );
704
705        // ask_release() → HostControlEvent::AskRelease (header-only).
706        let ask = ser(&AskRelease);
707        assert_eq!(
708            h.on_apdu(&ask).notify,
709            vec![Notification::HostControl(HostControlEvent::AskRelease)]
710        );
711
712        // The host acts out of band: no reply APDU is produced.
713        assert!(h.on_apdu(&tune).apdus.is_empty());
714    }
715
716    #[test]
717    fn mjd_bcd_encoding_is_correct() {
718        // Unix epoch 1970-01-01 00:00:00 → MJD 40587 (0x9E8B), 00:00:00.
719        assert_eq!(unix_to_mjd_bcd(0), [0x9E, 0x8B, 0x00, 0x00, 0x00]);
720        // 1970-01-02 13:45:09 → MJD 40588 (0x9E8C), BCD 13 45 09.
721        let secs = SECS_PER_DAY + 13 * 3600 + 45 * 60 + 9;
722        assert_eq!(unix_to_mjd_bcd(secs), [0x9E, 0x8C, 0x13, 0x45, 0x09]);
723    }
724
725    #[test]
726    fn date_time_replies_to_enq_and_resends_on_interval() {
727        let fixed = || [0x9E, 0x7B, 0x00, 0x00, 0x00];
728        let mut h = DateTime::with_clock(fixed);
729        // enquiry with a 5s response interval → immediate reply
730        let enq = ser(&DateTimeEnq {
731            response_interval: 5,
732        });
733        let out = h.on_apdu(&enq);
734        assert_eq!(out.apdus.len(), 1);
735        assert_eq!(peek_tag(&out.apdus[0]), Some(tag::DATE_TIME));
736        // before the interval: no resend
737        assert!(h.tick(Duration::from_secs(3)).apdus.is_empty());
738        // crossing the interval: resend
739        assert_eq!(h.tick(Duration::from_secs(3)).apdus.len(), 1);
740    }
741
742    #[test]
743    fn date_time_interval_zero_does_not_resend() {
744        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
745        h.on_apdu(&ser(&DateTimeEnq {
746            response_interval: 0,
747        }));
748        assert!(h.tick(Duration::from_secs(60)).apdus.is_empty());
749    }
750
751    #[test]
752    fn conditional_access_surfaces_ca_info_and_pmt_reply() {
753        let mut h = ConditionalAccess;
754        assert_eq!(h.on_open().apdus, vec![ser(&CaInfoEnq)]);
755        // ca_info -> CaInfo notification
756        let ci = ser(&CaInfo {
757            ca_system_ids: vec![0x0B00, 0x1800],
758        });
759        assert_eq!(
760            h.on_apdu(&ci).notify,
761            vec![Notification::CaInfo {
762                ca_system_ids: vec![0x0B00, 0x1800],
763            }]
764        );
765        // ca_pmt_reply (descrambling possible) -> CaPmtReply notification
766        let reply = ser(&CaPmtReply {
767            program_number: 0x0042,
768            version_number: 0,
769            current_next_indicator: true,
770            ca_enable: Some(CaEnable::Possible),
771            streams: vec![],
772        });
773        assert_eq!(
774            h.on_apdu(&reply).notify,
775            vec![Notification::CaPmtReply {
776                program_number: 0x0042,
777                ca_enable: Some(CaEnable::Possible),
778                descrambling_ok: true,
779            }]
780        );
781    }
782
783    #[test]
784    fn conditional_access_ca_pmt_reply_flag_clear_surfaces_none() {
785        let mut h = ConditionalAccess;
786        h.on_open();
787        // Programme `CA_enable_flag` clear -> no programme-level status given.
788        let reply = ser(&CaPmtReply {
789            program_number: 0x0007,
790            version_number: 0,
791            current_next_indicator: true,
792            ca_enable: None,
793            streams: vec![],
794        });
795        assert_eq!(
796            h.on_apdu(&reply).notify,
797            vec![Notification::CaPmtReply {
798                program_number: 0x0007,
799                ca_enable: None,
800                descrambling_ok: false,
801            }]
802        );
803    }
804}