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