Skip to main content

dvb_ci_runtime/
stack.rs

1//! The CI protocol stack — composes the transport + session layers (and, as
2//! they land, the resource state machines) into one sans-IO core.
3//!
4//! [`CiStack::handle`] is the pure entry point: feed it an [`Event`], get back
5//! the [`Action`]s the driver must perform. No I/O, threads, or clock here.
6
7use crate::event::{Action, Event, HostRequest, Notification};
8use crate::resource::{
9    ApplicationInformation, ConditionalAccess, DateTime, Mmi, Resource, ResourceManager,
10    ResourceOut,
11};
12use crate::session::{SessionLayer, SessionOut};
13use crate::transport::{Out as TransportOut, Transport};
14
15use dvb_ci::builder::{build_ca_pmt, build_ca_pmt_for_caids};
16use dvb_ci::objects::ca_pmt::{CaPmtCmdId, CaPmtListManagement};
17use dvb_ci::objects::mmi_high::{Answ, AnswId, MenuAnsw};
18use dvb_ci::resource::{ResourceId, CONDITIONAL_ACCESS_SUPPORT, DATE_TIME, MMI, RESOURCE_MANAGER};
19use dvb_common::{Parse, Serialize};
20use dvb_si::tables::pmt::PmtSection;
21
22/// Serialize an APDU object to owned bytes (buffer is sized exactly).
23fn ser_apdu<S: Serialize>(s: &S) -> Vec<u8> {
24    let mut b = vec![0u8; s.serialized_len()];
25    match s.serialize_into(&mut b) {
26        Ok(n) => b.truncate(n),
27        Err(_) => b.clear(),
28    }
29    b
30}
31
32/// The composed EN 50221 protocol core.
33pub struct CiStack {
34    transport: Transport,
35    session: SessionLayer,
36    /// Application-layer resource handlers, dispatched by `ResourceId`.
37    resources: Vec<Box<dyn Resource>>,
38    /// Resources the host **provides** — the module opens sessions to these, so
39    /// the host accepts an incoming `open_session_request` for them
40    /// (resource_manager, date_time). Module-provided resources are opened the
41    /// other way, by the host's `create_session` (#340).
42    host_provided: Vec<ResourceId>,
43    /// `CA_system_id`s the CAM advertised in its `ca_info` (the descramble
44    /// filter set; empty until `ca_info` arrives).
45    cam_caids: Vec<u16>,
46    /// PMT section bytes of an in-flight [`HostRequest::Descramble`], awaiting
47    /// the `ca_pmt_reply` that triggers the `ok_descrambling` follow-up.
48    pending_descramble: Option<Vec<u8>>,
49}
50
51impl Default for CiStack {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl CiStack {
58    /// New stack on transport connection `t_c_id = 1`. The host advertises the
59    /// Resource Manager and registers the RM + application_information +
60    /// conditional_access handlers.
61    #[must_use]
62    pub fn new() -> Self {
63        // The host *provides* Resource Manager + Date-Time: the module opens
64        // sessions to these (the host accepts), and they are the list the RM
65        // advertises in its `profile` reply. The module-provided resources
66        // (app_info, conditional_access, mmi) are opened by the host via
67        // `create_session` instead (#340).
68        let host_provided = vec![RESOURCE_MANAGER, DATE_TIME];
69        Self {
70            transport: Transport::new(1),
71            session: SessionLayer::new(),
72            resources: vec![
73                Box::new(ResourceManager::new(host_provided.clone())),
74                Box::new(ApplicationInformation),
75                Box::new(ConditionalAccess),
76                Box::new(DateTime::new()),
77                Box::new(Mmi),
78            ],
79            host_provided,
80            cam_caids: Vec::new(),
81            pending_descramble: None,
82        }
83    }
84
85    /// Register an additional resource handler.
86    pub fn register(&mut self, resource: Box<dyn Resource>) -> &mut Self {
87        self.resources.push(resource);
88        self
89    }
90
91    /// Index of the registered handler for `resource`, if any.
92    fn handler_index(&self, resource: ResourceId) -> Option<usize> {
93        self.resources.iter().position(|r| r.id() == resource)
94    }
95
96    /// The pure sans-IO entry point.
97    pub fn handle(&mut self, event: Event<'_>) -> Vec<Action> {
98        match event {
99            Event::Host(HostRequest::Init) => {
100                let mut actions = vec![Action::Reset, Action::QuerySlot];
101                let out = self.transport.init();
102                actions.extend(self.emit_transport(out));
103                actions
104            }
105            Event::Tick { elapsed } => {
106                let out = self.transport.tick(elapsed);
107                let mut actions = self.emit_transport(out);
108                // Advance each open resource's timers (e.g. date_time resend).
109                for (session_nb, resource) in self.session.sessions() {
110                    if let Some(i) = self.handler_index(resource) {
111                        let out = self.resources[i].tick(elapsed);
112                        actions.extend(self.process_resource_out(session_nb, out));
113                    }
114                }
115                actions
116            }
117            Event::Readable(frame) => {
118                let out = self.transport.on_frame(frame);
119                self.emit_transport(out)
120            }
121            Event::Host(HostRequest::SendCaPmt(apdu)) => {
122                self.send_to_resource(CONDITIONAL_ACCESS_SUPPORT, apdu)
123            }
124            Event::Host(HostRequest::Descramble(pmt)) => self.descramble(pmt),
125            Event::Host(HostRequest::MmiMenuAnswer(choice_ref)) => {
126                let apdu = ser_apdu(&MenuAnsw { choice_ref });
127                self.send_to_resource(MMI, &apdu)
128            }
129            Event::Host(HostRequest::MmiEnquiryAnswer(text)) => {
130                let apdu = ser_apdu(&Answ {
131                    answ_id: AnswId::Answer,
132                    text_chars: text,
133                });
134                self.send_to_resource(MMI, &apdu)
135            }
136            Event::Host(HostRequest::MmiCancel) => {
137                let apdu = ser_apdu(&Answ {
138                    answ_id: AnswId::Cancel,
139                    text_chars: &[],
140                });
141                self.send_to_resource(MMI, &apdu)
142            }
143            Event::Host(HostRequest::Shutdown) => Vec::new(),
144        }
145    }
146
147    /// React to a CA notification as it is surfaced: cache the CAM's CAIDs from
148    /// `ca_info`, and complete a pending [`HostRequest::Descramble`] by sending
149    /// `ok_descrambling` when the `ca_pmt_reply` says descrambling is possible.
150    fn on_ca_notification(&mut self, note: &Notification) -> Vec<Action> {
151        match note {
152            Notification::CaInfo { ca_system_ids } => {
153                self.cam_caids = ca_system_ids.clone();
154                Vec::new()
155            }
156            Notification::CaPmtReply {
157                descrambling_ok, ..
158            } => match self.pending_descramble.take() {
159                Some(pmt) if *descrambling_ok => {
160                    match self.build_ca_pmt_bytes(&pmt, CaPmtCmdId::OkDescrambling) {
161                        Ok(bytes) => self.send_to_resource(CONDITIONAL_ACCESS_SUPPORT, &bytes),
162                        Err(detail) => vec![Action::Notify(Notification::Error { detail })],
163                    }
164                }
165                _ => Vec::new(),
166            },
167            _ => Vec::new(),
168        }
169    }
170
171    /// Begin a [`HostRequest::Descramble`]: build a CAID-filtered `ca_pmt` with
172    /// `cmd_id = query` and send it, recording the PMT so the `ok_descrambling`
173    /// follow-up can be built when the reply arrives.
174    fn descramble(&mut self, pmt: &[u8]) -> Vec<Action> {
175        let bytes = match self.build_ca_pmt_bytes(pmt, CaPmtCmdId::Query) {
176            Ok(b) => b,
177            Err(detail) => return vec![Action::Notify(Notification::Error { detail })],
178        };
179        self.pending_descramble = Some(pmt.to_vec());
180        self.send_to_resource(CONDITIONAL_ACCESS_SUPPORT, &bytes)
181    }
182
183    /// Build a CAID-filtered `ca_pmt` APDU (`list_management = only`) for `pmt`
184    /// with the given command id. Filters to the CAM's advertised CAIDs once
185    /// `ca_info` is known; falls back to all `CA_descriptor`s before then.
186    fn build_ca_pmt_bytes(&self, pmt: &[u8], cmd_id: CaPmtCmdId) -> Result<Vec<u8>, String> {
187        let parsed = PmtSection::parse(pmt).map_err(|e| format!("invalid PMT: {e}"))?;
188        let lm = CaPmtListManagement::Only;
189        let built = if self.cam_caids.is_empty() {
190            build_ca_pmt(&parsed, lm, cmd_id)
191        } else {
192            build_ca_pmt_for_caids(&parsed, &self.cam_caids, lm, cmd_id)
193        };
194        Ok(built.to_bytes())
195    }
196
197    /// Send an APDU to the open session bound to `resource` (if any).
198    fn send_to_resource(&mut self, resource: ResourceId, apdu: &[u8]) -> Vec<Action> {
199        // Find the session_nb for the resource (linear scan over the small set).
200        let nb = (1u16..=u16::MAX).find(|&n| self.session.resource_of(n) == Some(resource));
201        match nb {
202            Some(nb) => {
203                let spdu = self.session.send_apdu(nb, apdu);
204                let out = self.transport.send_spdu(&spdu);
205                self.emit_transport(out)
206            }
207            None => vec![Action::Notify(Notification::Error {
208                detail: format!("no open session for resource {}", resource.name()),
209            })],
210        }
211    }
212
213    /// Convert a transport [`Out`](TransportOut) into actions, driving any
214    /// reassembled SPDUs up through the session layer.
215    fn emit_transport(&mut self, out: TransportOut) -> Vec<Action> {
216        let mut actions = Vec::new();
217        for w in out.writes {
218            actions.push(Action::Write(w));
219        }
220        if let Some(after) = out.timer {
221            actions.push(Action::SetTimer { after });
222        }
223        if let Some(err) = out.error {
224            actions.push(Action::Notify(Notification::Error {
225                detail: err.to_string(),
226            }));
227        }
228        for spdu in out.spdus {
229            actions.extend(self.drive_session(&spdu));
230        }
231        actions
232    }
233
234    /// Feed one SPDU to the session layer and convert its output to actions.
235    fn drive_session(&mut self, spdu: &[u8]) -> Vec<Action> {
236        // The module opens sessions to **host-provided** resources
237        // (resource_manager, date_time); the host accepts those. Module-provided
238        // resources (application_information, conditional_access, mmi) are opened
239        // the other way — by the host's `create_session` (#340) — so an incoming
240        // `open_session_request` for them is *not* accepted here.
241        let host_provided = self.host_provided.clone();
242        let SessionOut {
243            spdus,
244            apdus,
245            opened,
246            closed,
247        } = self.session.on_spdu(spdu, |r| host_provided.contains(&r));
248
249        let mut actions = Vec::new();
250        // Session-layer SPDUs (e.g. open_session_response) go down the transport.
251        for s in spdus {
252            actions.extend(self.send_spdu_actions(&s));
253        }
254        for (session_nb, resource) in opened {
255            actions.push(Action::Notify(Notification::SessionOpened { resource }));
256            // Drive the resource handler's on_open (e.g. RM sends profile_enq).
257            if let Some(i) = self.handler_index(resource) {
258                let out = self.resources[i].on_open();
259                actions.extend(self.process_resource_out(session_nb, out));
260            }
261        }
262        for session_nb in closed {
263            actions.push(Action::Notify(Notification::SessionClosed { session_nb }));
264        }
265        // Route each APDU to the resource handler bound to its session.
266        for (session_nb, apdu) in apdus {
267            if let Some(resource) = self.session.resource_of(session_nb) {
268                if let Some(i) = self.handler_index(resource) {
269                    let out = self.resources[i].on_apdu(&apdu);
270                    actions.extend(self.process_resource_out(session_nb, out));
271                }
272            }
273        }
274        actions
275    }
276
277    /// Wrap an SPDU as a `T_Data_Last` and collect the resulting actions.
278    fn send_spdu_actions(&mut self, spdu: &[u8]) -> Vec<Action> {
279        let t = self.transport.send_spdu(spdu);
280        let mut actions = Vec::new();
281        for w in t.writes {
282            actions.push(Action::Write(w));
283        }
284        if let Some(after) = t.timer {
285            actions.push(Action::SetTimer { after });
286        }
287        actions
288    }
289
290    /// Convert a [`ResourceOut`] into actions: send its APDUs on `session_nb`,
291    /// surface its notifications, and open any module resources it requested.
292    fn process_resource_out(&mut self, session_nb: u16, out: ResourceOut) -> Vec<Action> {
293        let mut actions = Vec::new();
294        for apdu in out.apdus {
295            let spdu = self.session.send_apdu(session_nb, &apdu);
296            actions.extend(self.send_spdu_actions(&spdu));
297        }
298        for note in out.notify {
299            // Drive the auto-descramble sequence off the CA notifications.
300            let follow = self.on_ca_notification(&note);
301            actions.push(Action::Notify(note));
302            actions.extend(follow);
303        }
304        for resource in out.open {
305            let spdu = self.session.create_session(resource);
306            actions.extend(self.send_spdu_actions(&spdu));
307        }
308        actions
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::transport::DEFAULT_POLL_INTERVAL;
316    use dvb_ci::resource::RESOURCE_MANAGER;
317    use dvb_ci::spdu::{tags as spdu_tags, OpenSessionRequest};
318    use dvb_ci::tpdu::{tags as tpdu_tags, SbValue};
319    use dvb_common::Serialize;
320
321    fn ser<S: Serialize>(s: &S) -> Vec<u8> {
322        let mut b = vec![0u8; s.serialized_len()];
323        match s.serialize_into(&mut b) {
324            Ok(n) => b.truncate(n),
325            Err(_) => b.clear(),
326        }
327        b
328    }
329
330    /// Wrap an SPDU as a module→host `T_Data_Last` R_TPDU (+ T_SB, DA clear).
331    fn r_data(tcid: u8, spdu: &[u8]) -> Vec<u8> {
332        let mut v = vec![tpdu_tags::DATA_LAST, (1 + spdu.len()) as u8, tcid];
333        v.extend_from_slice(spdu);
334        v.extend_from_slice(&[tpdu_tags::SB, 0x02, tcid, SbValue::new(false).0]);
335        v
336    }
337
338    #[test]
339    fn init_resets_and_opens_transport() {
340        let mut s = CiStack::new();
341        let a = s.handle(Event::Host(HostRequest::Init));
342        assert_eq!(a[0], Action::Reset);
343        assert_eq!(a[1], Action::QuerySlot);
344        assert!(matches!(&a[2], Action::Write(w) if w[0] == tpdu_tags::CREATE_T_C));
345    }
346
347    #[test]
348    fn full_pipeline_opens_a_session_for_a_provided_resource() {
349        let mut s = CiStack::new();
350        s.handle(Event::Host(HostRequest::Init));
351        // module accepts the transport connection
352        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
353        // module opens a session to the host's resource_manager (carried in an
354        // R_TPDU data block)
355        let osr = ser(&OpenSessionRequest {
356            resource: RESOURCE_MANAGER,
357        });
358        let actions = s.handle(Event::Readable(&r_data(1, &osr)));
359
360        // a SessionOpened notification surfaced...
361        assert!(actions.iter().any(|x| matches!(
362            x,
363            Action::Notify(Notification::SessionOpened {
364                resource
365            }) if *resource == RESOURCE_MANAGER
366        )));
367        // ...and an open_session_response was written back down (inside a TPDU).
368        let wrote_osr = actions.iter().any(|x| match x {
369            Action::Write(w) => w
370                .windows(1)
371                .any(|_| w.contains(&spdu_tags::OPEN_SESSION_RESPONSE)),
372            _ => false,
373        });
374        assert!(wrote_osr, "open_session_response must be sent down");
375
376        // and the session is tracked + a valid response decodes
377        let nb = (1u16..16).find(|&n| s.session.resource_of(n).is_some());
378        assert!(nb.is_some());
379    }
380
381    #[test]
382    fn tick_drives_poll_when_active() {
383        let mut s = CiStack::new();
384        s.handle(Event::Host(HostRequest::Init));
385        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
386        let a = s.handle(Event::Tick {
387            elapsed: DEFAULT_POLL_INTERVAL,
388        });
389        assert!(a
390            .iter()
391            .any(|x| matches!(x, Action::Write(w) if w.first() == Some(&tpdu_tags::DATA_LAST))));
392    }
393
394    // --- #334: the auto-descramble (query -> reply -> ok) sequence ---
395
396    /// Feed standalone `T_SB`s (data_available = 0) — the module acking each host
397    /// block — until the stack stops writing, collecting every action. This
398    /// drains the transport's one-block-per-turn outbound queue (#337).
399    fn pump_sbs(s: &mut CiStack) -> Vec<Action> {
400        let mut all = Vec::new();
401        for _ in 0..16 {
402            let a = s.handle(Event::Readable(&[
403                tpdu_tags::SB,
404                0x02,
405                0x01,
406                SbValue::new(false).0,
407            ]));
408            let wrote = a.iter().any(|x| matches!(x, Action::Write(_)));
409            all.extend(a);
410            if !wrote {
411                break;
412            }
413        }
414        all
415    }
416
417    /// Wrap an APDU for delivery on `session_nb` (session_number prefix), then as
418    /// a module→host R_TPDU.
419    fn r_apdu(session_nb: u16, apdu: &[u8]) -> Vec<u8> {
420        use dvb_ci::spdu::SessionNumber;
421        let mut spdu = ser(&SessionNumber { session_nb });
422        spdu.extend_from_slice(apdu);
423        r_data(1, &spdu)
424    }
425
426    /// Minimal PMT: program_info has one CA_descriptor (CAID 0x0B00) + a non-CA
427    /// descriptor; one clear ES. Mirrors the dvb-ci builder fixture.
428    fn build_pmt() -> Vec<u8> {
429        let prog_ca = [0x09u8, 0x04, 0x0B, 0x00, 0xE1, 0x00];
430        let reg = [0x05u8, 0x04, b'H', b'D', b'M', b'V'];
431        let mut program_info = Vec::new();
432        program_info.extend_from_slice(&prog_ca);
433        program_info.extend_from_slice(&reg);
434        let lang = [0x0Au8, 0x04, b'e', b'n', b'g', 0x00];
435
436        let mut body = Vec::new();
437        body.push(0x02); // table_id
438        body.push(0);
439        body.push(0); // section_length placeholder
440        body.extend_from_slice(&[0x00, 0x01]); // program_number 1
441        body.push(0xC3); // version 1, current_next 1
442        body.push(0x00);
443        body.push(0x00);
444        body.push(0xE0 | 0x02); // PCR_PID 0x0200
445        body.push(0x00);
446        let pil = program_info.len();
447        body.push(0xF0 | ((pil >> 8) as u8 & 0x0F));
448        body.push(pil as u8);
449        body.extend_from_slice(&program_info);
450        // one clear ES
451        body.push(0x03);
452        body.push(0xE0 | 0x02);
453        body.push(0x01);
454        body.push(0xF0 | ((lang.len() >> 8) as u8 & 0x0F));
455        body.push(lang.len() as u8);
456        body.extend_from_slice(&lang);
457
458        let section_length = body.len() - 3 + 4;
459        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
460        body[2] = section_length as u8;
461        let crc = dvb_common::crc32_mpeg2::compute(&body);
462        body.extend_from_slice(&crc.to_be_bytes());
463        body
464    }
465
466    /// Drive the full handshake to open conditional-access + mmi sessions with
467    /// the CAM's CAIDs learned, following the real flow (#340): module opens RM →
468    /// host sends `profile_change` and `create_session`s the module-provided
469    /// resources → module accepts each with `create_session_response`.
470    fn stack_with_ca_session() -> CiStack {
471        use dvb_ci::objects::ca_info::CaInfo;
472        use dvb_ci::objects::resource_manager::Profile;
473        use dvb_ci::resource::{APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, MMI};
474        use dvb_ci::spdu::{CreateSessionResponse, OpenSessionRequest, SessionStatus};
475
476        let mut s = CiStack::new();
477        s.handle(Event::Host(HostRequest::Init));
478        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
479        // module opens the host's resource_manager → RM session 1
480        s.handle(Event::Readable(&r_data(
481            1,
482            &ser(&OpenSessionRequest {
483                resource: RESOURCE_MANAGER,
484            }),
485        )));
486        // module sends its profile → host: CamReady + profile_change +
487        // create_session for each module-provided resource (alloc nb 2,3,4).
488        s.handle(Event::Readable(&r_apdu(
489            1,
490            &ser(&Profile {
491                resources: vec![APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, MMI],
492            }),
493        )));
494        pump_sbs(&mut s); // flush profile_change + the first create_session
495                          // The module accepts each create_session → its session opens (+ on_open
496                          // enq); each acceptance frees the link so the next create_session flushes.
497        for (nb, res) in [
498            (2u16, APPLICATION_INFORMATION),
499            (3, CONDITIONAL_ACCESS_SUPPORT),
500            (4, MMI),
501        ] {
502            s.handle(Event::Readable(&r_data(
503                1,
504                &ser(&CreateSessionResponse {
505                    status: SessionStatus::Ok,
506                    resource: res,
507                    session_nb: nb,
508                }),
509            )));
510            pump_sbs(&mut s);
511        }
512        // module advertises its CAIDs on the CA session
513        let ca_nb = s
514            .session
515            .sessions()
516            .into_iter()
517            .find(|&(_, r)| r == CONDITIONAL_ACCESS_SUPPORT)
518            .map(|(n, _)| n)
519            .expect("CA session open");
520        s.handle(Event::Readable(&r_apdu(
521            ca_nb,
522            &ser(&CaInfo {
523                ca_system_ids: vec![0x0B00, 0x1800],
524            }),
525        )));
526        s
527    }
528
529    #[test]
530    fn descramble_filters_then_queries_then_oks() {
531        use dvb_ci::objects::ca_pmt::CaPmtCmdId;
532        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
533        use dvb_ci::resource::CONDITIONAL_ACCESS_SUPPORT;
534
535        let mut s = stack_with_ca_session();
536        let ca_nb = s
537            .session
538            .sessions()
539            .into_iter()
540            .find(|&(_, r)| r == CONDITIONAL_ACCESS_SUPPORT)
541            .map(|(n, _)| n)
542            .unwrap();
543
544        let pmt = build_pmt();
545        let mut query_actions = s.handle(Event::Host(HostRequest::Descramble(&pmt)));
546        // The query is queued behind the in-flight link; the module's SB flushes
547        // it (one block per turn — #337).
548        query_actions.extend(pump_sbs(&mut s));
549        // A ca_pmt with cmd_id = query was sent, filtered to the CAM's CAIDs.
550        let q = first_ca_pmt(&query_actions).expect("ca_pmt query sent");
551        assert_eq!(q.cmd_id, CaPmtCmdId::Query);
552        assert_eq!(
553            q.program_ca_descriptors.as_slice(),
554            &[0x09, 0x04, 0x0B, 0x00, 0xE1, 0x00]
555        );
556
557        // Module replies that descrambling is possible → stack auto-sends OK.
558        let mut ok_actions = s.handle(Event::Readable(&r_apdu(
559            ca_nb,
560            &ser(&CaPmtReply {
561                program_number: 1,
562                version_number: 1,
563                current_next_indicator: true,
564                ca_enable: Some(CaEnable::Possible),
565                streams: vec![],
566            }),
567        )));
568        assert!(ok_actions.iter().any(|a| matches!(
569            a,
570            Action::Notify(Notification::CaPmtReply {
571                descrambling_ok: true,
572                ..
573            })
574        )));
575        ok_actions.extend(pump_sbs(&mut s));
576        assert!(
577            all_ca_pmts(&ok_actions)
578                .iter()
579                .any(|c| c.cmd_id == CaPmtCmdId::OkDescrambling),
580            "ca_pmt ok_descrambling sent after a positive reply"
581        );
582    }
583
584    #[test]
585    fn descramble_reply_not_possible_sends_no_ok() {
586        use dvb_ci::objects::ca_pmt::CaPmtCmdId;
587        use dvb_ci::objects::ca_pmt_reply::CaPmtReply;
588        use dvb_ci::resource::CONDITIONAL_ACCESS_SUPPORT;
589
590        let mut s = stack_with_ca_session();
591        let ca_nb = s
592            .session
593            .sessions()
594            .into_iter()
595            .find(|&(_, r)| r == CONDITIONAL_ACCESS_SUPPORT)
596            .map(|(n, _)| n)
597            .unwrap();
598        let pmt = build_pmt();
599        let mut actions = s.handle(Event::Host(HostRequest::Descramble(&pmt)));
600        // ca_enable = None → descrambling not possible → no OK follow-up.
601        actions.extend(s.handle(Event::Readable(&r_apdu(
602            ca_nb,
603            &ser(&CaPmtReply {
604                program_number: 1,
605                version_number: 1,
606                current_next_indicator: true,
607                ca_enable: None,
608                streams: vec![],
609            }),
610        ))));
611        actions.extend(pump_sbs(&mut s));
612        // The query may be flushed, but no ok_descrambling is ever sent.
613        assert!(
614            all_ca_pmts(&actions)
615                .iter()
616                .all(|c| c.cmd_id != CaPmtCmdId::OkDescrambling),
617            "no ok_descrambling without a positive reply"
618        );
619    }
620
621    /// Whether any written frame carries the 3-byte APDU tag `want`.
622    fn wrote_apdu(actions: &[Action], want: [u8; 3]) -> bool {
623        actions
624            .iter()
625            .any(|a| matches!(a, Action::Write(w) if w.windows(3).any(|x| x == want)))
626    }
627
628    #[test]
629    fn mmi_menu_answer_sends_menu_answ() {
630        let mut s = stack_with_ca_session();
631        let mut acts = s.handle(Event::Host(HostRequest::MmiMenuAnswer(2)));
632        acts.extend(pump_sbs(&mut s));
633        // menu_answ APDU (9F 88 0B) reaches the wire.
634        assert!(wrote_apdu(&acts, [0x9F, 0x88, 0x0B]));
635    }
636
637    #[test]
638    fn mmi_enquiry_answer_sends_answ() {
639        let mut s = stack_with_ca_session();
640        let mut acts = s.handle(Event::Host(HostRequest::MmiEnquiryAnswer(b"1234")));
641        acts.extend(pump_sbs(&mut s));
642        // answ APDU (9F 88 08) reaches the wire.
643        assert!(wrote_apdu(&acts, [0x9F, 0x88, 0x08]));
644    }
645
646    /// Parse every `ca_pmt` (tag `9F 80 32`) found in the written frames,
647    /// returning each one's `cmd_id` + programme CA-descriptor bytes (owned).
648    fn all_ca_pmts(actions: &[Action]) -> Vec<CaPmtSummary> {
649        use dvb_ci::objects::ca_pmt::CaPmt;
650        use dvb_common::Parse;
651        let tag = [0x9F, 0x80, 0x32];
652        let mut out = Vec::new();
653        for a in actions {
654            if let Action::Write(w) = a {
655                if let Some(pos) = w.windows(3).position(|x| x == tag) {
656                    if let Ok(p) = CaPmt::parse(&w[pos..]) {
657                        out.push(CaPmtSummary {
658                            cmd_id: p.cmd_id.expect("programme cmd_id present"),
659                            program_ca_descriptors: p.program_ca_descriptors.to_vec(),
660                        });
661                    }
662                }
663            }
664        }
665        out
666    }
667
668    /// The first `ca_pmt` in the written frames.
669    fn first_ca_pmt(actions: &[Action]) -> Option<CaPmtSummary> {
670        all_ca_pmts(actions).into_iter().next()
671    }
672
673    struct CaPmtSummary {
674        cmd_id: dvb_ci::objects::ca_pmt::CaPmtCmdId,
675        program_ca_descriptors: Vec<u8>,
676    }
677}