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:
142        //   1. sends `profile_change` (§8.4.1.1) — the gate the module waits on;
143        //      until it arrives the module can neither open nor accept sessions
144        //      (it idles after its `profile` reply — #340 round 1), and
145        //   2. opens a session to each **module-provided** resource it engages
146        //      (application_information, conditional_access, mmi) with
147        //      `create_session`. The direction rule (confirmed on hardware,
148        //      #340 round 2): the module opens sessions to *host*-provided
149        //      resources (resource_manager, date_time); the *host* opens sessions
150        //      to *module*-provided resources.
151        if self.module_profiled && !self.ready {
152            self.ready = true;
153            out.apdus.push(ser(&ProfileChange));
154            out.notify.push(Notification::CamReady);
155            for r in [APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, MMI] {
156                if self.module_resources.contains(&r) {
157                    out.open.push(r);
158                }
159            }
160        }
161        out
162    }
163}
164
165/// Application Information (§8.4.2) — module-provided. On open, enquires the
166/// module's application info; surfaces it as [`Notification::ApplicationInfo`].
167#[derive(Debug, Default)]
168pub struct ApplicationInformation;
169
170impl Resource for ApplicationInformation {
171    fn id(&self) -> ResourceId {
172        APPLICATION_INFORMATION
173    }
174
175    fn on_open(&mut self) -> ResourceOut {
176        ResourceOut {
177            apdus: vec![ser(&ApplicationInfoEnq)],
178            ..ResourceOut::default()
179        }
180    }
181
182    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
183        let mut out = ResourceOut::default();
184        if peek_tag(apdu) == Some(tag::APPLICATION_INFO) {
185            if let Ok(ai) = ApplicationInfo::parse(apdu) {
186                out.notify.push(Notification::ApplicationInfo {
187                    application_type: ai.application_type.to_u8(),
188                    manufacturer: ai.application_manufacturer,
189                    code: ai.manufacturer_code,
190                    menu: String::from_utf8_lossy(ai.menu_string).into_owned(),
191                });
192            }
193        }
194        out
195    }
196}
197
198/// Conditional Access Support (§8.4.3) — module-provided. On open, enquires the
199/// module's supported `CA_system_id`s ([`Notification::CaInfo`]); decodes
200/// `ca_pmt_reply` ([`Notification::CaPmtReply`]). The host sends `ca_pmt` via
201/// [`HostRequest::SendCaPmt`](crate::event::HostRequest::SendCaPmt).
202#[derive(Debug, Default)]
203pub struct ConditionalAccess;
204
205impl Resource for ConditionalAccess {
206    fn id(&self) -> ResourceId {
207        CONDITIONAL_ACCESS_SUPPORT
208    }
209
210    fn on_open(&mut self) -> ResourceOut {
211        ResourceOut {
212            apdus: vec![ser(&CaInfoEnq)],
213            ..ResourceOut::default()
214        }
215    }
216
217    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
218        let mut out = ResourceOut::default();
219        match peek_tag(apdu) {
220            Some(t) if t == tag::CA_INFO => {
221                if let Ok(ci) = CaInfo::parse(apdu) {
222                    out.notify.push(Notification::CaInfo {
223                        ca_system_ids: ci.ca_system_ids,
224                    });
225                }
226            }
227            Some(t) if t == tag::CA_PMT_REPLY => {
228                if let Ok(r) = CaPmtReply::parse(apdu) {
229                    let descrambling_ok = r.ca_enable.is_some_and(|e| {
230                        matches!(
231                            e,
232                            CaEnable::Possible
233                                | CaEnable::PossiblePurchaseDialogue
234                                | CaEnable::PossibleTechnicalDialogue
235                        )
236                    });
237                    out.notify.push(Notification::CaPmtReply {
238                        program_number: r.program_number,
239                        descrambling_ok,
240                    });
241                }
242            }
243            _ => {}
244        }
245        out
246    }
247}
248
249const SECS_PER_DAY: u64 = 86_400;
250/// Modified Julian Date of the Unix epoch (1970-01-01).
251const MJD_UNIX_EPOCH: u64 = 40_587;
252
253fn bcd(v: u64) -> u8 {
254    (((v / 10) << 4) | (v % 10)) as u8
255}
256
257/// Encode a Unix timestamp as the 5-byte DVB `UTC_time` (MJD `[15:0]` + BCD
258/// HH:MM:SS), per EN 300 468 Annex C.
259fn unix_to_mjd_bcd(unix_secs: u64) -> [u8; UTC_TIME_LEN] {
260    let mjd = (MJD_UNIX_EPOCH + unix_secs / SECS_PER_DAY) as u16;
261    let sod = unix_secs % SECS_PER_DAY;
262    [
263        (mjd >> 8) as u8,
264        mjd as u8,
265        bcd(sod / 3600),
266        bcd((sod % 3600) / 60),
267        bcd(sod % 60),
268    ]
269}
270
271fn system_utc() -> [u8; UTC_TIME_LEN] {
272    let secs = std::time::SystemTime::now()
273        .duration_since(std::time::UNIX_EPOCH)
274        .map(|d| d.as_secs())
275        .unwrap_or(0);
276    unix_to_mjd_bcd(secs)
277}
278
279/// Date-Time (§8.5.2) — host-provided. On `date_time_enq` replies with the
280/// current UTC; if the enquiry's `response_interval` is non-zero, re-sends every
281/// `response_interval` seconds (driven by [`tick`](Resource::tick)).
282pub struct DateTime {
283    clock: fn() -> [u8; UTC_TIME_LEN],
284    interval: u8,
285    since: Duration,
286}
287
288impl Default for DateTime {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294impl DateTime {
295    /// New handler using the system clock.
296    #[must_use]
297    pub fn new() -> Self {
298        Self {
299            clock: system_utc,
300            interval: 0,
301            since: Duration::ZERO,
302        }
303    }
304
305    /// New handler with an injected clock (for tests / a host-supplied source).
306    #[must_use]
307    pub fn with_clock(clock: fn() -> [u8; UTC_TIME_LEN]) -> Self {
308        Self {
309            clock,
310            interval: 0,
311            since: Duration::ZERO,
312        }
313    }
314
315    fn reply(&self) -> Vec<u8> {
316        ser(&CiDateTime {
317            utc_time: (self.clock)(),
318            local_offset: None,
319        })
320    }
321}
322
323impl Resource for DateTime {
324    fn id(&self) -> ResourceId {
325        DATE_TIME
326    }
327
328    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
329        let mut out = ResourceOut::default();
330        if peek_tag(apdu) == Some(tag::DATE_TIME_ENQ) {
331            if let Ok(enq) = DateTimeEnq::parse(apdu) {
332                self.interval = enq.response_interval;
333                self.since = Duration::ZERO;
334                out.apdus.push(self.reply());
335            }
336        }
337        out
338    }
339
340    fn tick(&mut self, elapsed: Duration) -> ResourceOut {
341        let mut out = ResourceOut::default();
342        if self.interval > 0 {
343            self.since += elapsed;
344            if self.since >= Duration::from_secs(u64::from(self.interval)) {
345                self.since = Duration::ZERO;
346                out.apdus.push(self.reply());
347            }
348        }
349        out
350    }
351}
352
353/// MMI (§8.6) — module-provided. Surfaces the module's menus/enquiries and the
354/// close as [`Notification::Mmi`] events for the application to display. (The
355/// module drives the dialog; answering — `menu_answ`/`answ` — is a later
356/// addition.)
357#[derive(Debug, Default)]
358pub struct Mmi;
359
360impl Resource for Mmi {
361    fn id(&self) -> ResourceId {
362        MMI
363    }
364
365    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
366        let mut out = ResourceOut::default();
367        match peek_tag(apdu) {
368            Some(t) if t == tag::ENQ => {
369                if let Ok(e) = Enq::parse(apdu) {
370                    out.notify.push(Notification::Mmi(MmiEvent::Enquiry {
371                        prompt: text(e.text_chars),
372                        blind: e.blind_answer,
373                        answer_len: e.answer_text_length,
374                    }));
375                }
376            }
377            Some(t) if t == tag::MENU_LAST => {
378                if let Ok(m) = Menu::parse(apdu) {
379                    out.notify.push(Notification::Mmi(MmiEvent::Menu {
380                        title: text(m.title.text_chars),
381                        items: m.choices.iter().map(|c| text(c.text_chars)).collect(),
382                    }));
383                }
384            }
385            Some(t) if t == tag::CLOSE_MMI => {
386                out.notify.push(Notification::Mmi(MmiEvent::Close));
387            }
388            _ => {}
389        }
390        out
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use dvb_ci::objects::resource_manager::Profile;
398
399    #[test]
400    fn on_open_sends_profile_enq() {
401        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
402        let out = rm.on_open();
403        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
404    }
405
406    #[test]
407    fn module_profile_triggers_profile_change_camready_and_opens() {
408        // #340: after the module's `profile`, the host (1) fires CamReady, (2)
409        // sends `profile_change` — the gate that unblocks the module (§8.4.1.1) —
410        // and (3) opens (`create_session`, via ResourceOut::open) the
411        // module-provided resources it engages.
412        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
413        rm.on_open();
414        let module_profile = ser(&Profile {
415            resources: vec![APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT],
416        });
417        let o = rm.on_apdu(&module_profile);
418        assert!(o.notify.contains(&Notification::CamReady));
419        assert_eq!(o.apdus.len(), 1, "host sends profile_change");
420        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE_CHANGE));
421        assert!(o.open.contains(&APPLICATION_INFORMATION));
422        assert!(o.open.contains(&CONDITIONAL_ACCESS_SUPPORT));
423        assert!(!o.open.contains(&MMI), "module didn't advertise MMI");
424    }
425
426    #[test]
427    fn answers_a_module_profile_enquiry_without_re_readying() {
428        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
429        rm.on_open();
430        rm.on_apdu(&ser(&Profile {
431            resources: vec![APPLICATION_INFORMATION],
432        }));
433        // A later module profile_enq → reply with our profile, no second CamReady.
434        let o = rm.on_apdu(&ser(&ProfileEnq));
435        assert_eq!(o.apdus.len(), 1);
436        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE));
437        assert!(!o.notify.contains(&Notification::CamReady));
438    }
439
440    #[test]
441    fn mmi_surfaces_enquiry_and_close() {
442        let mut h = Mmi;
443        // enquiry
444        let enq = ser(&Enq {
445            blind_answer: true,
446            answer_text_length: 4,
447            text_chars: b"PIN?",
448        });
449        assert_eq!(
450            h.on_apdu(&enq).notify,
451            vec![Notification::Mmi(MmiEvent::Enquiry {
452                prompt: "PIN?".to_string(),
453                blind: true,
454                answer_len: 4,
455            })]
456        );
457        // close_mmi (tag 9F 88 00) — surfaced as Close
458        let close = [0x9F, 0x88, 0x00, 0x01, 0x00];
459        assert_eq!(
460            h.on_apdu(&close).notify,
461            vec![Notification::Mmi(MmiEvent::Close)]
462        );
463    }
464
465    #[test]
466    fn profile_change_re_enquires() {
467        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
468        let out = rm.on_apdu(&ser(&dvb_ci::objects::resource_manager::ProfileChange));
469        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
470    }
471
472    #[test]
473    fn application_information_surfaces_notification() {
474        use dvb_ci::objects::application_info::ApplicationType;
475        let mut h = ApplicationInformation;
476        assert_eq!(h.on_open().apdus, vec![ser(&ApplicationInfoEnq)]);
477        let ai = ser(&ApplicationInfo {
478            application_type: ApplicationType::ConditionalAccess,
479            application_manufacturer: 0x1234,
480            manufacturer_code: 0x5678,
481            menu_string: b"Acme CAM",
482        });
483        let out = h.on_apdu(&ai);
484        assert_eq!(
485            out.notify,
486            vec![Notification::ApplicationInfo {
487                application_type: 0x01,
488                manufacturer: 0x1234,
489                code: 0x5678,
490                menu: "Acme CAM".to_string(),
491            }]
492        );
493    }
494
495    #[test]
496    fn mjd_bcd_encoding_is_correct() {
497        // Unix epoch 1970-01-01 00:00:00 → MJD 40587 (0x9E8B), 00:00:00.
498        assert_eq!(unix_to_mjd_bcd(0), [0x9E, 0x8B, 0x00, 0x00, 0x00]);
499        // 1970-01-02 13:45:09 → MJD 40588 (0x9E8C), BCD 13 45 09.
500        let secs = SECS_PER_DAY + 13 * 3600 + 45 * 60 + 9;
501        assert_eq!(unix_to_mjd_bcd(secs), [0x9E, 0x8C, 0x13, 0x45, 0x09]);
502    }
503
504    #[test]
505    fn date_time_replies_to_enq_and_resends_on_interval() {
506        let fixed = || [0x9E, 0x7B, 0x00, 0x00, 0x00];
507        let mut h = DateTime::with_clock(fixed);
508        // enquiry with a 5s response interval → immediate reply
509        let enq = ser(&DateTimeEnq {
510            response_interval: 5,
511        });
512        let out = h.on_apdu(&enq);
513        assert_eq!(out.apdus.len(), 1);
514        assert_eq!(peek_tag(&out.apdus[0]), Some(tag::DATE_TIME));
515        // before the interval: no resend
516        assert!(h.tick(Duration::from_secs(3)).apdus.is_empty());
517        // crossing the interval: resend
518        assert_eq!(h.tick(Duration::from_secs(3)).apdus.len(), 1);
519    }
520
521    #[test]
522    fn date_time_interval_zero_does_not_resend() {
523        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
524        h.on_apdu(&ser(&DateTimeEnq {
525            response_interval: 0,
526        }));
527        assert!(h.tick(Duration::from_secs(60)).apdus.is_empty());
528    }
529
530    #[test]
531    fn conditional_access_surfaces_ca_info_and_pmt_reply() {
532        let mut h = ConditionalAccess;
533        assert_eq!(h.on_open().apdus, vec![ser(&CaInfoEnq)]);
534        // ca_info -> CaInfo notification
535        let ci = ser(&CaInfo {
536            ca_system_ids: vec![0x0B00, 0x1800],
537        });
538        assert_eq!(
539            h.on_apdu(&ci).notify,
540            vec![Notification::CaInfo {
541                ca_system_ids: vec![0x0B00, 0x1800],
542            }]
543        );
544        // ca_pmt_reply (descrambling possible) -> CaPmtReply notification
545        let reply = ser(&CaPmtReply {
546            program_number: 0x0042,
547            version_number: 0,
548            current_next_indicator: true,
549            ca_enable: Some(CaEnable::Possible),
550            streams: vec![],
551        });
552        assert_eq!(
553            h.on_apdu(&reply).notify,
554            vec![Notification::CaPmtReply {
555                program_number: 0x0042,
556                descrambling_ok: true,
557            }]
558        );
559    }
560}