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