Skip to main content

dvb_ci_runtime/
resource.rs

1//! The resource layer — application-layer state machines (ETSI EN 50221 §8),
2//! one per resource, driven by the session layer's APDUs.
3//!
4//! Each resource implements [`Resource`]: it reacts to its session opening and
5//! to incoming APDUs, producing APDUs to send back, host [`Notification`]s, and
6//! requests to open further (module-provided) resources. This module ships the
7//! mandatory [`ResourceManager`]; application_information / conditional_access /
8//! date_time / mmi land as further `Resource` impls.
9
10use std::time::Duration;
11
12use dvb_ci::objects::application_info::{ApplicationInfo, ApplicationInfoEnq};
13use dvb_ci::objects::ca_info::{CaInfo, CaInfoEnq};
14use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
15use dvb_ci::objects::date_time::{DateTime as CiDateTime, DateTimeEnq, UTC_TIME_LEN};
16use dvb_ci::objects::mmi_high::{Enq, Menu};
17use dvb_ci::objects::resource_manager::{Profile, ProfileChange, ProfileEnq};
18use dvb_ci::resource::{
19    ResourceId, APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, DATE_TIME, MMI,
20    RESOURCE_MANAGER,
21};
22use dvb_ci::tag::{self, ApduTag};
23use dvb_common::{Parse, Serialize};
24
25use crate::event::{MmiEvent, Notification};
26
27/// Decode MMI `text_char` bytes to a `String` (lossy; full EN 300 468 Annex A
28/// decoding is the application's concern).
29fn text(chars: &[u8]) -> String {
30    String::from_utf8_lossy(chars).into_owned()
31}
32
33pub(crate) fn ser<S: Serialize>(s: &S) -> Vec<u8> {
34    let mut b = vec![0u8; s.serialized_len()];
35    match s.serialize_into(&mut b) {
36        Ok(n) => b.truncate(n),
37        Err(_) => b.clear(),
38    }
39    b
40}
41
42/// The 3-byte `apdu_tag` at the start of an APDU, if present.
43pub(crate) fn peek_tag(apdu: &[u8]) -> Option<ApduTag> {
44    (apdu.len() >= 3).then(|| ApduTag::from_bytes(apdu[0], apdu[1], apdu[2]))
45}
46
47/// What a resource wants done after reacting to an input.
48#[derive(Debug, Default, Clone, PartialEq, Eq)]
49pub struct ResourceOut {
50    /// APDUs to send on this resource's session.
51    pub apdus: Vec<Vec<u8>>,
52    /// Host-facing notifications.
53    pub notify: Vec<Notification>,
54    /// Module-provided resources the host should now open (`create_session`).
55    pub open: Vec<ResourceId>,
56}
57
58/// An EN 50221 application-layer resource.
59pub trait Resource {
60    /// The resource this handler serves.
61    fn id(&self) -> ResourceId;
62    /// The session for this resource just opened.
63    fn on_open(&mut self) -> ResourceOut {
64        ResourceOut::default()
65    }
66    /// An APDU arrived on this resource's session.
67    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut;
68    /// Logical time advanced (for resources with timers, e.g. date_time).
69    fn tick(&mut self, _elapsed: Duration) -> ResourceOut {
70        ResourceOut::default()
71    }
72}
73
74/// Resource Manager (§8.4.1) — host-provided. Drives the profile exchange and,
75/// once complete, reports [`Notification::CamReady`] and asks the host to open
76/// the module-provided resources it understands.
77#[derive(Debug)]
78pub struct ResourceManager {
79    host_resources: Vec<ResourceId>,
80    module_resources: Vec<ResourceId>,
81    module_profiled: bool,
82    ready: bool,
83}
84
85impl ResourceManager {
86    /// New RM advertising `host_resources` in its profile reply.
87    #[must_use]
88    pub fn new(host_resources: Vec<ResourceId>) -> Self {
89        Self {
90            host_resources,
91            module_resources: Vec::new(),
92            module_profiled: false,
93            ready: false,
94        }
95    }
96
97    /// Resources the module advertised (valid once the profile exchange ran).
98    #[must_use]
99    pub fn module_resources(&self) -> &[ResourceId] {
100        &self.module_resources
101    }
102}
103
104impl Resource for ResourceManager {
105    fn id(&self) -> ResourceId {
106        RESOURCE_MANAGER
107    }
108
109    fn on_open(&mut self) -> ResourceOut {
110        // Kick off the handshake: ask the module for its profile.
111        ResourceOut {
112            apdus: vec![ser(&ProfileEnq)],
113            ..ResourceOut::default()
114        }
115    }
116
117    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
118        let mut out = ResourceOut::default();
119        match peek_tag(apdu) {
120            // Module asks for the host's profile → reply with our resource list.
121            Some(t) if t == tag::PROFILE_ENQ => {
122                out.apdus.push(ser(&Profile {
123                    resources: self.host_resources.clone(),
124                }));
125            }
126            // Module's profile → record its resources.
127            Some(t) if t == tag::PROFILE => {
128                if let Ok(p) = Profile::parse(apdu) {
129                    self.module_resources = p.resources;
130                    self.module_profiled = true;
131                }
132            }
133            // Resource set changed → re-enquire.
134            Some(t) if t == tag::PROFILE_CHANGE => {
135                out.apdus.push(ser(&ProfileEnq));
136                self.module_profiled = false;
137                self.ready = false;
138            }
139            _ => {}
140        }
141        // Once we have the module's profile, the host builds its resource list
142        // and sends `profile_change` (§8.4.1.1). That is the gate the module is
143        // waiting on: until it arrives the module can neither open nor accept
144        // sessions, so it idles after its `profile` reply (#340). After it, the
145        // module opens its own sessions (application_information, conditional_
146        // access, mmi) — the host does NOT `create_session` for them (§7.2.3;
147        // create_session is inter-module routing only).
148        if self.module_profiled && !self.ready {
149            self.ready = true;
150            out.apdus.push(ser(&ProfileChange));
151            out.notify.push(Notification::CamReady);
152        }
153        out
154    }
155}
156
157/// Application Information (§8.4.2) — module-provided. On open, enquires the
158/// module's application info; surfaces it as [`Notification::ApplicationInfo`].
159#[derive(Debug, Default)]
160pub struct ApplicationInformation;
161
162impl Resource for ApplicationInformation {
163    fn id(&self) -> ResourceId {
164        APPLICATION_INFORMATION
165    }
166
167    fn on_open(&mut self) -> ResourceOut {
168        ResourceOut {
169            apdus: vec![ser(&ApplicationInfoEnq)],
170            ..ResourceOut::default()
171        }
172    }
173
174    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
175        let mut out = ResourceOut::default();
176        if peek_tag(apdu) == Some(tag::APPLICATION_INFO) {
177            if let Ok(ai) = ApplicationInfo::parse(apdu) {
178                out.notify.push(Notification::ApplicationInfo {
179                    application_type: ai.application_type.to_u8(),
180                    manufacturer: ai.application_manufacturer,
181                    code: ai.manufacturer_code,
182                    menu: String::from_utf8_lossy(ai.menu_string).into_owned(),
183                });
184            }
185        }
186        out
187    }
188}
189
190/// Conditional Access Support (§8.4.3) — module-provided. On open, enquires the
191/// module's supported `CA_system_id`s ([`Notification::CaInfo`]); decodes
192/// `ca_pmt_reply` ([`Notification::CaPmtReply`]). The host sends `ca_pmt` via
193/// [`HostRequest::SendCaPmt`](crate::event::HostRequest::SendCaPmt).
194#[derive(Debug, Default)]
195pub struct ConditionalAccess;
196
197impl Resource for ConditionalAccess {
198    fn id(&self) -> ResourceId {
199        CONDITIONAL_ACCESS_SUPPORT
200    }
201
202    fn on_open(&mut self) -> ResourceOut {
203        ResourceOut {
204            apdus: vec![ser(&CaInfoEnq)],
205            ..ResourceOut::default()
206        }
207    }
208
209    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
210        let mut out = ResourceOut::default();
211        match peek_tag(apdu) {
212            Some(t) if t == tag::CA_INFO => {
213                if let Ok(ci) = CaInfo::parse(apdu) {
214                    out.notify.push(Notification::CaInfo {
215                        ca_system_ids: ci.ca_system_ids,
216                    });
217                }
218            }
219            Some(t) if t == tag::CA_PMT_REPLY => {
220                if let Ok(r) = CaPmtReply::parse(apdu) {
221                    let descrambling_ok = r.ca_enable.is_some_and(|e| {
222                        matches!(
223                            e,
224                            CaEnable::Possible
225                                | CaEnable::PossiblePurchaseDialogue
226                                | CaEnable::PossibleTechnicalDialogue
227                        )
228                    });
229                    out.notify.push(Notification::CaPmtReply {
230                        program_number: r.program_number,
231                        descrambling_ok,
232                    });
233                }
234            }
235            _ => {}
236        }
237        out
238    }
239}
240
241const SECS_PER_DAY: u64 = 86_400;
242/// Modified Julian Date of the Unix epoch (1970-01-01).
243const MJD_UNIX_EPOCH: u64 = 40_587;
244
245fn bcd(v: u64) -> u8 {
246    (((v / 10) << 4) | (v % 10)) as u8
247}
248
249/// Encode a Unix timestamp as the 5-byte DVB `UTC_time` (MJD `[15:0]` + BCD
250/// HH:MM:SS), per EN 300 468 Annex C.
251fn unix_to_mjd_bcd(unix_secs: u64) -> [u8; UTC_TIME_LEN] {
252    let mjd = (MJD_UNIX_EPOCH + unix_secs / SECS_PER_DAY) as u16;
253    let sod = unix_secs % SECS_PER_DAY;
254    [
255        (mjd >> 8) as u8,
256        mjd as u8,
257        bcd(sod / 3600),
258        bcd((sod % 3600) / 60),
259        bcd(sod % 60),
260    ]
261}
262
263fn system_utc() -> [u8; UTC_TIME_LEN] {
264    let secs = std::time::SystemTime::now()
265        .duration_since(std::time::UNIX_EPOCH)
266        .map(|d| d.as_secs())
267        .unwrap_or(0);
268    unix_to_mjd_bcd(secs)
269}
270
271/// Date-Time (§8.5.2) — host-provided. On `date_time_enq` replies with the
272/// current UTC; if the enquiry's `response_interval` is non-zero, re-sends every
273/// `response_interval` seconds (driven by [`tick`](Resource::tick)).
274pub struct DateTime {
275    clock: fn() -> [u8; UTC_TIME_LEN],
276    interval: u8,
277    since: Duration,
278}
279
280impl Default for DateTime {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286impl DateTime {
287    /// New handler using the system clock.
288    #[must_use]
289    pub fn new() -> Self {
290        Self {
291            clock: system_utc,
292            interval: 0,
293            since: Duration::ZERO,
294        }
295    }
296
297    /// New handler with an injected clock (for tests / a host-supplied source).
298    #[must_use]
299    pub fn with_clock(clock: fn() -> [u8; UTC_TIME_LEN]) -> Self {
300        Self {
301            clock,
302            interval: 0,
303            since: Duration::ZERO,
304        }
305    }
306
307    fn reply(&self) -> Vec<u8> {
308        ser(&CiDateTime {
309            utc_time: (self.clock)(),
310            local_offset: None,
311        })
312    }
313}
314
315impl Resource for DateTime {
316    fn id(&self) -> ResourceId {
317        DATE_TIME
318    }
319
320    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
321        let mut out = ResourceOut::default();
322        if peek_tag(apdu) == Some(tag::DATE_TIME_ENQ) {
323            if let Ok(enq) = DateTimeEnq::parse(apdu) {
324                self.interval = enq.response_interval;
325                self.since = Duration::ZERO;
326                out.apdus.push(self.reply());
327            }
328        }
329        out
330    }
331
332    fn tick(&mut self, elapsed: Duration) -> ResourceOut {
333        let mut out = ResourceOut::default();
334        if self.interval > 0 {
335            self.since += elapsed;
336            if self.since >= Duration::from_secs(u64::from(self.interval)) {
337                self.since = Duration::ZERO;
338                out.apdus.push(self.reply());
339            }
340        }
341        out
342    }
343}
344
345/// MMI (§8.6) — module-provided. Surfaces the module's menus/enquiries and the
346/// close as [`Notification::Mmi`] events for the application to display. (The
347/// module drives the dialog; answering — `menu_answ`/`answ` — is a later
348/// addition.)
349#[derive(Debug, Default)]
350pub struct Mmi;
351
352impl Resource for Mmi {
353    fn id(&self) -> ResourceId {
354        MMI
355    }
356
357    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
358        let mut out = ResourceOut::default();
359        match peek_tag(apdu) {
360            Some(t) if t == tag::ENQ => {
361                if let Ok(e) = Enq::parse(apdu) {
362                    out.notify.push(Notification::Mmi(MmiEvent::Enquiry {
363                        prompt: text(e.text_chars),
364                        blind: e.blind_answer,
365                        answer_len: e.answer_text_length,
366                    }));
367                }
368            }
369            Some(t) if t == tag::MENU_LAST => {
370                if let Ok(m) = Menu::parse(apdu) {
371                    out.notify.push(Notification::Mmi(MmiEvent::Menu {
372                        title: text(m.title.text_chars),
373                        items: m.choices.iter().map(|c| text(c.text_chars)).collect(),
374                    }));
375                }
376            }
377            Some(t) if t == tag::CLOSE_MMI => {
378                out.notify.push(Notification::Mmi(MmiEvent::Close));
379            }
380            _ => {}
381        }
382        out
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use dvb_ci::objects::resource_manager::Profile;
390
391    #[test]
392    fn on_open_sends_profile_enq() {
393        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
394        let out = rm.on_open();
395        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
396    }
397
398    #[test]
399    fn module_profile_triggers_profile_change_and_camready() {
400        // #340: after the module's `profile`, the host fires CamReady and sends
401        // `profile_change` — the gate that unblocks the module to open its own
402        // resource sessions (§8.4.1.1). The host does NOT open them itself.
403        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
404        rm.on_open();
405        let module_profile = ser(&Profile {
406            resources: vec![APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT],
407        });
408        let o = rm.on_apdu(&module_profile);
409        assert!(o.notify.contains(&Notification::CamReady));
410        assert_eq!(o.apdus.len(), 1, "host sends profile_change");
411        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE_CHANGE));
412        assert!(
413            o.open.is_empty(),
414            "the module opens its own sessions, not the host"
415        );
416    }
417
418    #[test]
419    fn answers_a_module_profile_enquiry_without_re_readying() {
420        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
421        rm.on_open();
422        rm.on_apdu(&ser(&Profile {
423            resources: vec![APPLICATION_INFORMATION],
424        }));
425        // A later module profile_enq → reply with our profile, no second CamReady.
426        let o = rm.on_apdu(&ser(&ProfileEnq));
427        assert_eq!(o.apdus.len(), 1);
428        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE));
429        assert!(!o.notify.contains(&Notification::CamReady));
430    }
431
432    #[test]
433    fn mmi_surfaces_enquiry_and_close() {
434        let mut h = Mmi;
435        // enquiry
436        let enq = ser(&Enq {
437            blind_answer: true,
438            answer_text_length: 4,
439            text_chars: b"PIN?",
440        });
441        assert_eq!(
442            h.on_apdu(&enq).notify,
443            vec![Notification::Mmi(MmiEvent::Enquiry {
444                prompt: "PIN?".to_string(),
445                blind: true,
446                answer_len: 4,
447            })]
448        );
449        // close_mmi (tag 9F 88 00) — surfaced as Close
450        let close = [0x9F, 0x88, 0x00, 0x01, 0x00];
451        assert_eq!(
452            h.on_apdu(&close).notify,
453            vec![Notification::Mmi(MmiEvent::Close)]
454        );
455    }
456
457    #[test]
458    fn profile_change_re_enquires() {
459        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
460        let out = rm.on_apdu(&ser(&dvb_ci::objects::resource_manager::ProfileChange));
461        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
462    }
463
464    #[test]
465    fn application_information_surfaces_notification() {
466        use dvb_ci::objects::application_info::ApplicationType;
467        let mut h = ApplicationInformation;
468        assert_eq!(h.on_open().apdus, vec![ser(&ApplicationInfoEnq)]);
469        let ai = ser(&ApplicationInfo {
470            application_type: ApplicationType::ConditionalAccess,
471            application_manufacturer: 0x1234,
472            manufacturer_code: 0x5678,
473            menu_string: b"Acme CAM",
474        });
475        let out = h.on_apdu(&ai);
476        assert_eq!(
477            out.notify,
478            vec![Notification::ApplicationInfo {
479                application_type: 0x01,
480                manufacturer: 0x1234,
481                code: 0x5678,
482                menu: "Acme CAM".to_string(),
483            }]
484        );
485    }
486
487    #[test]
488    fn mjd_bcd_encoding_is_correct() {
489        // Unix epoch 1970-01-01 00:00:00 → MJD 40587 (0x9E8B), 00:00:00.
490        assert_eq!(unix_to_mjd_bcd(0), [0x9E, 0x8B, 0x00, 0x00, 0x00]);
491        // 1970-01-02 13:45:09 → MJD 40588 (0x9E8C), BCD 13 45 09.
492        let secs = SECS_PER_DAY + 13 * 3600 + 45 * 60 + 9;
493        assert_eq!(unix_to_mjd_bcd(secs), [0x9E, 0x8C, 0x13, 0x45, 0x09]);
494    }
495
496    #[test]
497    fn date_time_replies_to_enq_and_resends_on_interval() {
498        let fixed = || [0x9E, 0x7B, 0x00, 0x00, 0x00];
499        let mut h = DateTime::with_clock(fixed);
500        // enquiry with a 5s response interval → immediate reply
501        let enq = ser(&DateTimeEnq {
502            response_interval: 5,
503        });
504        let out = h.on_apdu(&enq);
505        assert_eq!(out.apdus.len(), 1);
506        assert_eq!(peek_tag(&out.apdus[0]), Some(tag::DATE_TIME));
507        // before the interval: no resend
508        assert!(h.tick(Duration::from_secs(3)).apdus.is_empty());
509        // crossing the interval: resend
510        assert_eq!(h.tick(Duration::from_secs(3)).apdus.len(), 1);
511    }
512
513    #[test]
514    fn date_time_interval_zero_does_not_resend() {
515        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
516        h.on_apdu(&ser(&DateTimeEnq {
517            response_interval: 0,
518        }));
519        assert!(h.tick(Duration::from_secs(60)).apdus.is_empty());
520    }
521
522    #[test]
523    fn conditional_access_surfaces_ca_info_and_pmt_reply() {
524        let mut h = ConditionalAccess;
525        assert_eq!(h.on_open().apdus, vec![ser(&CaInfoEnq)]);
526        // ca_info -> CaInfo notification
527        let ci = ser(&CaInfo {
528            ca_system_ids: vec![0x0B00, 0x1800],
529        });
530        assert_eq!(
531            h.on_apdu(&ci).notify,
532            vec![Notification::CaInfo {
533                ca_system_ids: vec![0x0B00, 0x1800],
534            }]
535        );
536        // ca_pmt_reply (descrambling possible) -> CaPmtReply notification
537        let reply = ser(&CaPmtReply {
538            program_number: 0x0042,
539            version_number: 0,
540            current_next_indicator: true,
541            ca_enable: Some(CaEnable::Possible),
542            streams: vec![],
543        });
544        assert_eq!(
545            h.on_apdu(&reply).notify,
546            vec![Notification::CaPmtReply {
547                program_number: 0x0042,
548                descrambling_ok: true,
549            }]
550        );
551    }
552}