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