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