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
49/// Serialize an APDU/SPDU object to owned bytes — the one helper the resource,
50/// session and stack layers share (audit r10-O-12: it was copied three times).
51///
52/// r10-W-20: `serialize_into` failing here used to be swallowed into a
53/// silently-empty `Vec` (and, in a later pass, turned into a panic) — either
54/// way the failure never reached the caller as a value it could act on. These
55/// layers react to APDUs that ultimately originate from the CAM/card, so a
56/// serialize error must propagate as `Err`, not corrupt the wire exchange or
57/// crash the driver.
58pub(crate) fn ser<S: Serialize<Error = dvb_ci::Error>>(s: &S) -> dvb_ci::Result<Vec<u8>> {
59    s.try_to_bytes()
60}
61
62/// The 3-byte `apdu_tag` at the start of an APDU, if present.
63pub(crate) fn peek_tag(apdu: &[u8]) -> Option<ApduTag> {
64    (apdu.len() >= 3).then(|| ApduTag::from_bytes(apdu[0], apdu[1], apdu[2]))
65}
66
67/// What a resource wants done after reacting to an input.
68#[derive(Debug, Default, Clone, PartialEq, Eq)]
69pub struct ResourceOut {
70    /// APDUs to send on this resource's session.
71    pub apdus: Vec<Vec<u8>>,
72    /// Host-facing notifications.
73    pub notify: Vec<Notification>,
74    /// Module-provided resources the host should now open (`create_session`).
75    pub open: Vec<ResourceId>,
76}
77
78/// An EN 50221 application-layer resource.
79pub trait Resource {
80    /// The resource this handler serves.
81    fn id(&self) -> ResourceId;
82    /// The session for this resource just opened.
83    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
84        Ok(ResourceOut::default())
85    }
86    /// An APDU arrived on this resource's session.
87    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut>;
88    /// Logical time advanced (for resources with timers, e.g. date_time).
89    fn tick(&mut self, _elapsed: Duration) -> dvb_ci::Result<ResourceOut> {
90        Ok(ResourceOut::default())
91    }
92    /// Reset this resource's internal state (r10-W-19): called on
93    /// [`HostRequest::Init`](crate::event::HostRequest::Init) and
94    /// [`HostRequest::Shutdown`](crate::event::HostRequest::Shutdown) so a
95    /// re-inserted CAM starts a fresh handshake against clean state rather
96    /// than one latched by whatever the previous module left behind (e.g. a
97    /// `ready` flag that never re-opens the `profile_change` gate on a
98    /// second `profile` exchange). The default no-op is only correct for a
99    /// resource that carries no state between calls.
100    fn reset(&mut self) {}
101}
102
103/// Resource Manager (§8.4.1) — host-provided. Drives the profile exchange and,
104/// once complete, reports [`Notification::CamReady`] and asks the host to open
105/// the module-provided resources it understands.
106#[derive(Debug)]
107pub struct ResourceManager {
108    host_resources: Vec<ResourceId>,
109    module_resources: Vec<ResourceId>,
110    module_profiled: bool,
111    ready: bool,
112}
113
114impl ResourceManager {
115    /// New RM advertising `host_resources` in its profile reply.
116    #[must_use]
117    pub fn new(host_resources: Vec<ResourceId>) -> Self {
118        Self {
119            host_resources,
120            module_resources: Vec::new(),
121            module_profiled: false,
122            ready: false,
123        }
124    }
125
126    /// Resources the module advertised (valid once the profile exchange ran).
127    #[must_use]
128    pub fn module_resources(&self) -> &[ResourceId] {
129        &self.module_resources
130    }
131}
132
133impl Resource for ResourceManager {
134    fn id(&self) -> ResourceId {
135        RESOURCE_MANAGER
136    }
137
138    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
139        // Kick off the handshake: ask the module for its profile.
140        Ok(ResourceOut {
141            apdus: vec![ser(&ProfileEnq)?],
142            ..ResourceOut::default()
143        })
144    }
145
146    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
147        let mut out = ResourceOut::default();
148        match peek_tag(apdu) {
149            // Module asks for the host's profile → reply with our resource list.
150            Some(t) if t == tag::PROFILE_ENQ => {
151                out.apdus.push(ser(&Profile {
152                    resources: self.host_resources.clone(),
153                })?);
154            }
155            // Module's profile → record its resources.
156            Some(t) if t == tag::PROFILE => {
157                // A CAM-originated APDU that fails to parse is a protocol error to
158                // surface, not to drop silently (audit #1092 interop).
159                let p = Profile::parse(apdu)?;
160                self.module_resources = p.resources;
161                self.module_profiled = true;
162            }
163            // Resource set changed → re-enquire.
164            Some(t) if t == tag::PROFILE_CHANGE => {
165                out.apdus.push(ser(&ProfileEnq)?);
166                self.module_profiled = false;
167                self.ready = false;
168            }
169            _ => {}
170        }
171        // Once we have the module's profile, the host sends `profile_change`
172        // (§8.4.1.1) — the gate the module waits on; until it arrives the module
173        // idles after its `profile` reply (#340 round 1).
174        //
175        // The host does NOT open application_information / conditional_access /
176        // mmi itself. Confirmed on hardware (#340, live AlphaCrypt): the module
177        // ignores a host `open_session_request` for them and rejects a
178        // `create_session` (`status=0xF0`). Those resources are **host-provided**
179        // — the host advertises them in its `profile` reply (see
180        // `CiStack::host_provided`), and the *module* opens sessions to them
181        // (module → host `open_session_request`), exactly as it does for
182        // resource_manager / date_time. The host just accepts. Each session's
183        // `on_open` then drives its enquiry (app_info_enq, ca_info_enq).
184        if self.module_profiled && !self.ready {
185            self.ready = true;
186            out.apdus.push(ser(&ProfileChange)?);
187            out.notify.push(Notification::CamReady);
188        }
189        Ok(out)
190    }
191
192    /// r10-W-19: without this, `ready`/`module_profiled` stay latched from a
193    /// prior handshake, so a second `profile` (e.g. after a hot-plug
194    /// re-insert routed through a fresh `Init`) never re-opens the
195    /// `profile_change`/`CamReady` gate — the module then idles forever
196    /// (`on_apdu`'s doc comment above).
197    fn reset(&mut self) {
198        self.module_resources.clear();
199        self.module_profiled = false;
200        self.ready = false;
201    }
202}
203
204/// Application Information (§8.4.2) — module-provided. On open, enquires the
205/// module's application info; surfaces it as [`Notification::ApplicationInfo`].
206#[derive(Debug, Default)]
207pub struct ApplicationInformation;
208
209impl Resource for ApplicationInformation {
210    fn id(&self) -> ResourceId {
211        APPLICATION_INFORMATION
212    }
213
214    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
215        Ok(ResourceOut {
216            apdus: vec![ser(&ApplicationInfoEnq)?],
217            ..ResourceOut::default()
218        })
219    }
220
221    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
222        let mut out = ResourceOut::default();
223        if peek_tag(apdu) == Some(tag::APPLICATION_INFO) {
224            let ai = ApplicationInfo::parse(apdu)?;
225            out.notify.push(Notification::ApplicationInfo {
226                application_type: ai.application_type.to_u8(),
227                manufacturer: ai.application_manufacturer,
228                code: ai.manufacturer_code,
229                menu: String::from_utf8_lossy(ai.menu_string).into_owned(),
230            });
231        }
232        Ok(out)
233    }
234    // No state carried between calls — the default `reset()` no-op applies.
235}
236
237/// Conditional Access Support (§8.4.3) — module-provided. On open, enquires the
238/// module's supported `CA_system_id`s ([`Notification::CaInfo`]); decodes
239/// `ca_pmt_reply` ([`Notification::CaPmtReply`]). The host sends `ca_pmt` via
240/// [`HostRequest::SendCaPmt`](crate::event::HostRequest::SendCaPmt).
241#[derive(Debug, Default)]
242pub struct ConditionalAccess;
243
244impl Resource for ConditionalAccess {
245    fn id(&self) -> ResourceId {
246        CONDITIONAL_ACCESS_SUPPORT
247    }
248
249    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
250        Ok(ResourceOut {
251            apdus: vec![ser(&CaInfoEnq)?],
252            ..ResourceOut::default()
253        })
254    }
255
256    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
257        let mut out = ResourceOut::default();
258        match peek_tag(apdu) {
259            Some(t) if t == tag::CA_INFO => {
260                // A CAM-originated APDU that fails to parse is a protocol error to
261                // surface, not to drop silently (audit #1092 interop).
262                let ci = CaInfo::parse(apdu)?;
263                out.notify.push(Notification::CaInfo {
264                    ca_system_ids: ci.ca_system_ids,
265                });
266            }
267            Some(t) if t == tag::CA_PMT_REPLY => {
268                // A CAM-originated APDU that fails to parse is a protocol error to
269                // surface, not to drop silently (audit #1092 interop).
270                let r = CaPmtReply::parse(apdu)?;
271                // EN 50221 §8.4.3.5 Table 26: programme-level `CA_enable`.
272                // Plumb the object's own `Option<CaEnable>` straight
273                // through — `None` means the programme
274                // `CA_enable_flag` bit was clear (no programme-level
275                // status given), which is distinct from a genuine
276                // flag-set reserved code and must not be collapsed to a
277                // sentinel.
278                let descrambling_ok = matches!(
279                    r.ca_enable,
280                    Some(
281                        CaEnable::Possible
282                            | CaEnable::PossiblePurchaseDialogue
283                            | CaEnable::PossibleTechnicalDialogue
284                    )
285                );
286                out.notify.push(Notification::CaPmtReply {
287                    program_number: r.program_number,
288                    ca_enable: r.ca_enable,
289                    descrambling_ok,
290                });
291            }
292            _ => {}
293        }
294        Ok(out)
295    }
296    // No state carried between calls — the default `reset()` no-op applies.
297}
298
299const SECS_PER_DAY: u64 = 86_400;
300/// Modified Julian Date of the Unix epoch (1970-01-01).
301const MJD_UNIX_EPOCH: u64 = 40_587;
302
303fn bcd(v: u64) -> u8 {
304    (((v / 10) << 4) | (v % 10)) as u8
305}
306
307/// Encode a Unix timestamp as the 5-byte DVB `UTC_time` (MJD `[15:0]` + BCD
308/// HH:MM:SS), per EN 300 468 Annex C.
309fn unix_to_mjd_bcd(unix_secs: u64) -> [u8; UTC_TIME_LEN] {
310    let mjd = (MJD_UNIX_EPOCH + unix_secs / SECS_PER_DAY) as u16;
311    let sod = unix_secs % SECS_PER_DAY;
312    [
313        (mjd >> 8) as u8,
314        mjd as u8,
315        bcd(sod / 3600),
316        bcd((sod % 3600) / 60),
317        bcd(sod % 60),
318    ]
319}
320
321fn system_utc() -> [u8; UTC_TIME_LEN] {
322    let secs = std::time::SystemTime::now()
323        .duration_since(std::time::UNIX_EPOCH)
324        .map(|d| d.as_secs())
325        .unwrap_or(0);
326    unix_to_mjd_bcd(secs)
327}
328
329/// Date-Time (§8.5.2) — host-provided. On `date_time_enq` replies with the
330/// current UTC; if the enquiry's `response_interval` is non-zero, re-sends every
331/// `response_interval` seconds (driven by [`tick`](Resource::tick)).
332pub struct DateTime {
333    clock: fn() -> [u8; UTC_TIME_LEN],
334    interval: u8,
335    since: Duration,
336}
337
338impl Default for DateTime {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344impl DateTime {
345    /// New handler using the system clock.
346    #[must_use]
347    pub fn new() -> Self {
348        Self {
349            clock: system_utc,
350            interval: 0,
351            since: Duration::ZERO,
352        }
353    }
354
355    /// New handler with an injected clock (for tests / a host-supplied source).
356    #[must_use]
357    pub fn with_clock(clock: fn() -> [u8; UTC_TIME_LEN]) -> Self {
358        Self {
359            clock,
360            interval: 0,
361            since: Duration::ZERO,
362        }
363    }
364
365    fn reply(&self) -> dvb_ci::Result<Vec<u8>> {
366        ser(&CiDateTime {
367            utc_time: (self.clock)(),
368            local_offset: None,
369        })
370    }
371}
372
373impl Resource for DateTime {
374    fn id(&self) -> ResourceId {
375        DATE_TIME
376    }
377
378    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
379        let mut out = ResourceOut::default();
380        if peek_tag(apdu) == Some(tag::DATE_TIME_ENQ) {
381            let enq = DateTimeEnq::parse(apdu)?;
382            self.interval = enq.response_interval;
383            self.since = Duration::ZERO;
384            out.apdus.push(self.reply()?);
385        }
386        Ok(out)
387    }
388
389    fn tick(&mut self, elapsed: Duration) -> dvb_ci::Result<ResourceOut> {
390        let mut out = ResourceOut::default();
391        if self.interval > 0 {
392            self.since += elapsed;
393            if self.since >= Duration::from_secs(u64::from(self.interval)) {
394                self.since = Duration::ZERO;
395                out.apdus.push(self.reply()?);
396            }
397        }
398        Ok(out)
399    }
400
401    /// r10-W-19: without this, a stale `interval`/`since` from a previous
402    /// module survives into the next connection, so the resend cadence
403    /// negotiated with the OLD CAM keeps firing (or a leftover partial
404    /// `since` fires early) before the new module ever sends its own
405    /// `date_time_enq`.
406    fn reset(&mut self) {
407        self.interval = 0;
408        self.since = Duration::ZERO;
409    }
410}
411
412/// MMI (§8.6) — module-provided. Surfaces the module's menus/enquiries and the
413/// close as [`Notification::Mmi`] events for the application to display, and
414/// answers the module's `display_control` mode negotiation. The host drives the
415/// dialog back through [`Driver::mmi_menu_answer`](crate::Driver::mmi_menu_answer)
416/// / [`mmi_enquiry_answer`](crate::Driver::mmi_enquiry_answer) /
417/// [`mmi_cancel`](crate::Driver::mmi_cancel) (sent by [`CiStack`](crate::CiStack)
418/// on the open MMI session).
419#[derive(Debug, Default)]
420pub struct Mmi;
421
422impl Resource for Mmi {
423    fn id(&self) -> ResourceId {
424        MMI
425    }
426
427    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
428        let mut out = ResourceOut::default();
429        match peek_tag(apdu) {
430            Some(t) if t == tag::ENQ => {
431                // A CAM-originated APDU that fails to parse is a protocol error to
432                // surface, not to drop silently (audit #1092 interop).
433                let e = Enq::parse(apdu)?;
434                out.notify.push(Notification::Mmi(MmiEvent::Enquiry {
435                    prompt: text(e.text_chars),
436                    blind: e.blind_answer,
437                    answer_len: e.answer_text_length,
438                }));
439            }
440            Some(t) if t == tag::MENU_LAST => {
441                // A CAM-originated APDU that fails to parse is a protocol error to
442                // surface, not to drop silently (audit #1092 interop).
443                let m = Menu::parse(apdu)?;
444                out.notify
445                    .push(Notification::Mmi(MmiEvent::Menu(to_menu(&m))));
446            }
447            Some(t) if t == tag::LIST_LAST => {
448                // A CAM-originated APDU that fails to parse is a protocol error to
449                // surface, not to drop silently (audit #1092 interop).
450                let l = List::parse(apdu)?;
451                out.notify
452                    .push(Notification::Mmi(MmiEvent::List(to_menu(&l.0))));
453            }
454            Some(t) if t == tag::CLOSE_MMI => {
455                dvb_ci::objects::mmi_close::CloseMmi::parse(apdu)?;
456                out.notify.push(Notification::Mmi(MmiEvent::Close));
457            }
458            // High-level MMI mode negotiation (§8.6.1): the module opens an MMI
459            // session and sends `display_control`. The host MUST answer
460            // `display_reply` or the module aborts the MMI — verified live: a
461            // real AlphaCrypt opens MMI after `ca_pmt` and, with no reply, closes
462            // the session and never descrambles (an Enigma2 box answers it).
463            Some(t) if t == tag::DISPLAY_CONTROL => {
464                // A CAM-originated APDU that fails to parse is a protocol error to
465                // surface, not to drop silently (audit #1092 interop).
466                let dc = DisplayControl::parse(apdu)?;
467                let reply = match dc.cmd {
468                    // Acknowledge the requested MMI mode (echo it back).
469                    DisplayControlCmd::SetMmiMode => DisplayReply {
470                        reply_id: DisplayReplyId::MmiModeAck,
471                        body: DisplayReplyBody::MmiModeAck(
472                            dc.mmi_mode.unwrap_or(MmiMode::HighLevel),
473                        ),
474                    },
475                    // We don't implement the character-table / graphics
476                    // queries; tell the module so per Table 35.
477                    _ => DisplayReply {
478                        reply_id: DisplayReplyId::UnknownDisplayControlCmd,
479                        body: DisplayReplyBody::None,
480                    },
481                };
482                out.apdus.push(ser(&reply)?);
483            }
484            _ => {}
485        }
486        Ok(out)
487    }
488    // No state carried between calls — the default `reset()` no-op applies.
489}
490
491/// Host Control (§8.5.1) — host-provided. The module opens a host_control
492/// session and issues `tune` / `replace` / `clear_replace` / `ask_release`
493/// objects; this handler decodes each and surfaces it as
494/// [`Notification::HostControl`]. The host acts on the request out of band (it
495/// retunes / replaces PIDs itself) — the runtime does not re-tune, so there is
496/// no reply APDU.
497#[derive(Debug, Default)]
498pub struct HostControl;
499
500impl Resource for HostControl {
501    fn id(&self) -> ResourceId {
502        HOST_CONTROL
503    }
504
505    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
506        let mut out = ResourceOut::default();
507        // A CAM-originated host-control APDU that fails to parse (e.g. a padded
508        // `tune`) is a protocol error: `?` surfaces it as `Notification::Error`
509        // through the stack instead of dropping it silently.
510        let event = match peek_tag(apdu) {
511            Some(t) if t == tag::TUNE => {
512                let t = Tune::parse(apdu)?;
513                Some(HostControlEvent::Tune {
514                    network_id: t.network_id,
515                    original_network_id: t.original_network_id,
516                    transport_stream_id: t.transport_stream_id,
517                    service_id: t.service_id,
518                })
519            }
520            Some(t) if t == tag::REPLACE => {
521                let r = Replace::parse(apdu)?;
522                Some(HostControlEvent::Replace {
523                    replacement_ref: r.replacement_ref,
524                    replaced_pid: r.replaced_pid,
525                    replacement_pid: r.replacement_pid,
526                })
527            }
528            Some(t) if t == tag::CLEAR_REPLACE => {
529                let c = ClearReplace::parse(apdu)?;
530                Some(HostControlEvent::ClearReplace {
531                    replacement_ref: c.replacement_ref,
532                })
533            }
534            Some(t) if t == tag::ASK_RELEASE => {
535                AskRelease::parse(apdu)?;
536                Some(HostControlEvent::AskRelease)
537            }
538            _ => None,
539        };
540        if let Some(event) = event {
541            out.notify.push(Notification::HostControl(event));
542        }
543        Ok(out)
544    }
545    // No state carried between calls — the default `reset()` no-op applies.
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use dvb_ci::objects::resource_manager::Profile;
552
553    /// Local test helper: unwrap a serialize that must succeed for the
554    /// fixed/bounded values these tests construct.
555    fn s<S: Serialize<Error = dvb_ci::Error>>(v: &S) -> Vec<u8> {
556        ser(v).expect("test value must serialize")
557    }
558
559    #[test]
560    fn on_open_sends_profile_enq() {
561        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
562        let out = rm.on_open().unwrap();
563        assert_eq!(out.apdus, vec![s(&ProfileEnq)]);
564    }
565
566    #[test]
567    fn module_profile_triggers_profile_change_and_camready() {
568        // #340: after the module's `profile`, the host fires CamReady and sends
569        // `profile_change` (the §8.4.1.1 gate) — and nothing else. It does NOT
570        // open application_information / conditional_access / mmi itself: those
571        // are host-provided resources the module opens sessions to (verified on
572        // a live AlphaCrypt, which rejects/ignores host-initiated opens).
573        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
574        rm.on_open().unwrap();
575        let empty_profile = s(&Profile { resources: vec![] });
576        let o = rm.on_apdu(&empty_profile).unwrap();
577        assert!(o.notify.contains(&Notification::CamReady));
578        assert_eq!(o.apdus.len(), 1, "host sends profile_change");
579        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE_CHANGE));
580        assert!(o.open.is_empty(), "host opens no sessions itself");
581    }
582
583    #[test]
584    fn answers_a_module_profile_enquiry_without_re_readying() {
585        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
586        rm.on_open().unwrap();
587        rm.on_apdu(&s(&Profile {
588            resources: vec![APPLICATION_INFORMATION],
589        }))
590        .unwrap();
591        // A later module profile_enq → reply with our profile, no second CamReady.
592        let o = rm.on_apdu(&s(&ProfileEnq)).unwrap();
593        assert_eq!(o.apdus.len(), 1);
594        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE));
595        assert!(!o.notify.contains(&Notification::CamReady));
596    }
597
598    /// r10-W-19: `reset()` must clear every piece of the RM's latched state
599    /// (module resources, `module_profiled`, `ready`) so a fresh handshake
600    /// after a re-init behaves exactly like a brand-new `ResourceManager` —
601    /// in particular so `ready` does not stay latched and silently suppress
602    /// the next `CamReady`.
603    #[test]
604    fn resource_manager_reset_clears_latched_handshake_state() {
605        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
606        rm.on_open().unwrap();
607        let o = rm
608            .on_apdu(&s(&Profile {
609                resources: vec![APPLICATION_INFORMATION],
610            }))
611            .unwrap();
612        assert!(o.notify.contains(&Notification::CamReady), "dirtied: ready");
613        assert!(
614            !rm.module_resources().is_empty(),
615            "dirtied: module_resources"
616        );
617
618        rm.reset();
619        assert!(
620            rm.module_resources().is_empty(),
621            "reset must clear module_resources"
622        );
623
624        // Pre-fix (no reset()), `ready` stayed latched `true` and this second
625        // profile exchange would NOT re-fire CamReady.
626        rm.on_open().unwrap();
627        let o2 = rm.on_apdu(&s(&Profile { resources: vec![] })).unwrap();
628        assert!(
629            o2.notify.contains(&Notification::CamReady),
630            "a fresh handshake after reset() must fire CamReady again"
631        );
632    }
633
634    #[test]
635    fn mmi_surfaces_enquiry_and_close() {
636        let mut h = Mmi;
637        // enquiry
638        let enq = s(&Enq {
639            blind_answer: true,
640            answer_text_length: 4,
641            text_chars: b"PIN?",
642        });
643        assert_eq!(
644            h.on_apdu(&enq).unwrap().notify,
645            vec![Notification::Mmi(MmiEvent::Enquiry {
646                prompt: "PIN?".to_string(),
647                blind: true,
648                answer_len: 4,
649            })]
650        );
651        // close_mmi (tag 9F 88 00) — surfaced as Close
652        let close = [0x9F, 0x88, 0x00, 0x01, 0x00];
653        assert_eq!(
654            h.on_apdu(&close).unwrap().notify,
655            vec![Notification::Mmi(MmiEvent::Close)]
656        );
657    }
658
659    #[test]
660    fn mmi_surfaces_structured_menu_and_list() {
661        use dvb_ci::objects::mmi_high::{List, Menu, Text};
662        let txt = |s: &'static [u8]| Text {
663            more: false,
664            text_chars: s,
665        };
666        let mut h = Mmi;
667        // A `menu()` → MmiEvent::Menu with header lines and choices kept distinct.
668        let menu = s(&Menu {
669            more: false,
670            choice_nb: 2,
671            title: txt(b"AlphaCrypt"),
672            subtitle: txt(b"Module Mainmenu"),
673            bottom: txt(b"Select item and press OK"),
674            choices: vec![txt(b"Smartcard"), txt(b"Quit")],
675        });
676        assert_eq!(
677            h.on_apdu(&menu).unwrap().notify,
678            vec![Notification::Mmi(MmiEvent::Menu(MmiMenu {
679                title: "AlphaCrypt".to_string(),
680                subtitle: "Module Mainmenu".to_string(),
681                bottom: "Select item and press OK".to_string(),
682                choices: vec!["Smartcard".to_string(), "Quit".to_string()],
683            }))]
684        );
685        // A `list()` (same body) → MmiEvent::List.
686        let list = s(&List(Menu {
687            more: false,
688            choice_nb: 0xFF,
689            title: txt(b"Entitlements"),
690            subtitle: txt(b""),
691            bottom: txt(b""),
692            choices: vec![txt(b"ORF AUT")],
693        }));
694        assert_eq!(
695            h.on_apdu(&list).unwrap().notify,
696            vec![Notification::Mmi(MmiEvent::List(MmiMenu {
697                title: "Entitlements".to_string(),
698                subtitle: String::new(),
699                bottom: String::new(),
700                choices: vec!["ORF AUT".to_string()],
701            }))]
702        );
703    }
704
705    #[test]
706    fn mmi_answers_display_control_set_mmi_mode() {
707        use dvb_ci::objects::mmi_display::{DisplayControl, DisplayControlCmd, MmiMode};
708        let mut h = Mmi;
709        let dc = s(&DisplayControl {
710            cmd: DisplayControlCmd::SetMmiMode,
711            mmi_mode: Some(MmiMode::HighLevel),
712        });
713        let out = h.on_apdu(&dc).unwrap();
714        // mmi_mode_ack(high_level): 9F 88 02 02 01 01.
715        assert_eq!(out.apdus, vec![vec![0x9F, 0x88, 0x02, 0x02, 0x01, 0x01]]);
716        assert!(out.notify.is_empty());
717    }
718
719    #[test]
720    fn profile_change_re_enquires() {
721        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
722        let out = rm
723            .on_apdu(&s(&dvb_ci::objects::resource_manager::ProfileChange))
724            .unwrap();
725        assert_eq!(out.apdus, vec![s(&ProfileEnq)]);
726    }
727
728    #[test]
729    fn application_information_surfaces_notification() {
730        use dvb_ci::objects::application_info::ApplicationType;
731        let mut h = ApplicationInformation;
732        assert_eq!(h.on_open().unwrap().apdus, vec![s(&ApplicationInfoEnq)]);
733        let ai = s(&ApplicationInfo {
734            application_type: ApplicationType::ConditionalAccess,
735            application_manufacturer: 0x1234,
736            manufacturer_code: 0x5678,
737            menu_string: b"Acme CAM",
738        });
739        let out = h.on_apdu(&ai).unwrap();
740        assert_eq!(
741            out.notify,
742            vec![Notification::ApplicationInfo {
743                application_type: 0x01,
744                manufacturer: 0x1234,
745                code: 0x5678,
746                menu: "Acme CAM".to_string(),
747            }]
748        );
749    }
750
751    #[test]
752    fn host_control_surfaces_tune_replace_clear_and_ask_release() {
753        let mut h = HostControl;
754        assert_eq!(h.id(), HOST_CONTROL);
755
756        // tune() → HostControlEvent::Tune with the four 16-bit identifiers.
757        let tune = s(&Tune {
758            network_id: 0x1122,
759            original_network_id: 0x3344,
760            transport_stream_id: 0x5566,
761            service_id: 0x7788,
762        });
763        assert_eq!(
764            h.on_apdu(&tune).unwrap().notify,
765            vec![Notification::HostControl(HostControlEvent::Tune {
766                network_id: 0x1122,
767                original_network_id: 0x3344,
768                transport_stream_id: 0x5566,
769                service_id: 0x7788,
770            })]
771        );
772
773        // replace() → HostControlEvent::Replace with the 13-bit PIDs decoded.
774        let replace = s(&Replace {
775            replacement_ref: 0x07,
776            replaced_pid: 0x0123,
777            replacement_pid: 0x01FF,
778        });
779        assert_eq!(
780            h.on_apdu(&replace).unwrap().notify,
781            vec![Notification::HostControl(HostControlEvent::Replace {
782                replacement_ref: 0x07,
783                replaced_pid: 0x0123,
784                replacement_pid: 0x01FF,
785            })]
786        );
787
788        // clear_replace() → HostControlEvent::ClearReplace.
789        let clear = s(&ClearReplace {
790            replacement_ref: 0x42,
791        });
792        assert_eq!(
793            h.on_apdu(&clear).unwrap().notify,
794            vec![Notification::HostControl(HostControlEvent::ClearReplace {
795                replacement_ref: 0x42,
796            })]
797        );
798
799        // ask_release() → HostControlEvent::AskRelease (header-only).
800        let ask = s(&AskRelease);
801        assert_eq!(
802            h.on_apdu(&ask).unwrap().notify,
803            vec![Notification::HostControl(HostControlEvent::AskRelease)]
804        );
805
806        // The host acts out of band: no reply APDU is produced.
807        assert!(h.on_apdu(&tune).unwrap().apdus.is_empty());
808    }
809
810    #[test]
811    fn mjd_bcd_encoding_is_correct() {
812        // Unix epoch 1970-01-01 00:00:00 → MJD 40587 (0x9E8B), 00:00:00.
813        assert_eq!(unix_to_mjd_bcd(0), [0x9E, 0x8B, 0x00, 0x00, 0x00]);
814        // 1970-01-02 13:45:09 → MJD 40588 (0x9E8C), BCD 13 45 09.
815        let secs = SECS_PER_DAY + 13 * 3600 + 45 * 60 + 9;
816        assert_eq!(unix_to_mjd_bcd(secs), [0x9E, 0x8C, 0x13, 0x45, 0x09]);
817    }
818
819    #[test]
820    fn date_time_replies_to_enq_and_resends_on_interval() {
821        let fixed = || [0x9E, 0x7B, 0x00, 0x00, 0x00];
822        let mut h = DateTime::with_clock(fixed);
823        // enquiry with a 5s response interval → immediate reply
824        let enq = s(&DateTimeEnq {
825            response_interval: 5,
826        });
827        let out = h.on_apdu(&enq).unwrap();
828        assert_eq!(out.apdus.len(), 1);
829        assert_eq!(peek_tag(&out.apdus[0]), Some(tag::DATE_TIME));
830        // before the interval: no resend
831        assert!(h.tick(Duration::from_secs(3)).unwrap().apdus.is_empty());
832        // crossing the interval: resend
833        assert_eq!(h.tick(Duration::from_secs(3)).unwrap().apdus.len(), 1);
834    }
835
836    #[test]
837    fn date_time_interval_zero_does_not_resend() {
838        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
839        h.on_apdu(&s(&DateTimeEnq {
840            response_interval: 0,
841        }))
842        .unwrap();
843        assert!(h.tick(Duration::from_secs(60)).unwrap().apdus.is_empty());
844    }
845
846    /// r10-W-19: `reset()` must clear the resend timer (`interval`/`since`)
847    /// so a stale cadence negotiated with a PREVIOUS module does not keep
848    /// firing (or fire early on a leftover partial `since`) before the new
849    /// module ever sends its own `date_time_enq`.
850    #[test]
851    fn date_time_reset_clears_interval_and_resend_timer() {
852        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
853        h.on_apdu(&s(&DateTimeEnq {
854            response_interval: 5,
855        }))
856        .unwrap();
857        // Dirty: interval = 5, partway through the resend window.
858        assert!(h.tick(Duration::from_secs(3)).unwrap().apdus.is_empty());
859
860        h.reset();
861        // Pre-fix, the leftover `since` (3s) plus this tick would cross the
862        // still-latched 5s interval and resend; post-reset, interval is 0 so
863        // nothing fires no matter how much time passes.
864        assert!(
865            h.tick(Duration::from_secs(60)).unwrap().apdus.is_empty(),
866            "reset must clear the resend cadence from the previous module"
867        );
868    }
869
870    #[test]
871    fn conditional_access_surfaces_ca_info_and_pmt_reply() {
872        let mut h = ConditionalAccess;
873        assert_eq!(h.on_open().unwrap().apdus, vec![s(&CaInfoEnq)]);
874        // ca_info -> CaInfo notification
875        let ci = s(&CaInfo {
876            ca_system_ids: vec![0x0B00, 0x1800],
877        });
878        assert_eq!(
879            h.on_apdu(&ci).unwrap().notify,
880            vec![Notification::CaInfo {
881                ca_system_ids: vec![0x0B00, 0x1800],
882            }]
883        );
884        // ca_pmt_reply (descrambling possible) -> CaPmtReply notification
885        let reply = s(&CaPmtReply {
886            program_number: 0x0042,
887            version_number: 0,
888            current_next_indicator: true,
889            ca_enable: Some(CaEnable::Possible),
890            streams: vec![],
891        });
892        assert_eq!(
893            h.on_apdu(&reply).unwrap().notify,
894            vec![Notification::CaPmtReply {
895                program_number: 0x0042,
896                ca_enable: Some(CaEnable::Possible),
897                descrambling_ok: true,
898            }]
899        );
900    }
901
902    #[test]
903    fn conditional_access_ca_pmt_reply_flag_clear_surfaces_none() {
904        let mut h = ConditionalAccess;
905        h.on_open().unwrap();
906        // Programme `CA_enable_flag` clear -> no programme-level status given.
907        let reply = s(&CaPmtReply {
908            program_number: 0x0007,
909            version_number: 0,
910            current_next_indicator: true,
911            ca_enable: None,
912            streams: vec![],
913        });
914        assert_eq!(
915            h.on_apdu(&reply).unwrap().notify,
916            vec![Notification::CaPmtReply {
917                program_number: 0x0007,
918                ca_enable: None,
919                descrambling_ok: false,
920            }]
921        );
922    }
923}