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