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