Skip to main content

dvb_ci_runtime/
managed.rs

1//! Managed CAS-layer state (#763) — the [`Driver`](crate::Driver)'s owned view
2//! of the slot's active descrambled-service set.
3//!
4//! This is Layer 1 of the #763 CAS orchestration design
5//! (`docs/superpowers/specs/2026-07-24-dvb-ci-cas-layer-design.md`): parsed
6//! `dvb-si` structures in (never raw bytes), the existing
7//! `dvb_ci::builder::build_ca_pmt` PMT→`ca_pmt` projection
8//! (ETSI EN 50221 §8.4.3.4, Table 25) does the wire work, and this module just
9//! tracks what was sent. [`Driver::add_service`](crate::driver::Driver::add_service)
10//! builds + sends the `ca_pmt` and records the service here.
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::time::Duration;
14
15use dvb_ci::objects::ca_pmt::CaPmtCmdId;
16use dvb_ci::objects::ca_pmt_reply::CaEnable;
17use dvb_si::descriptors::DescriptorLoop;
18use dvb_si::descriptors::ca::TAG as CA_DESCRIPTOR_TAG;
19use dvb_si::tables::cat::CatCaEntry;
20use dvb_si::tables::pmt::PmtSection;
21
22/// Default entitlement re-query cadence (#763 Task 5's `Resource::tick`-driven
23/// refresh; `Duration::ZERO` disables it). Set on [`ManagedCa::new`] so the
24/// field is in place before the re-query timer is wired up.
25///
26/// **`Duration::ZERO` (opt-in, since #1032).** The feature was on by default
27/// at 10s until #1032: the timer's `query` half (EN 50221 §8.4.3.5) is
28/// needed to elicit a fresh `ca_pmt_reply`, but `stack.rs`'s `descramble`
29/// already documents that a live AlphaCrypt/Irdeto module never answers
30/// `query` at all — so on the one CAM this crate has verified against, the
31/// query half is a no-op and the feature delivers nothing a host doesn't
32/// already get from spontaneous `ca_pmt_reply`s. Call
33/// [`Driver::set_requery_interval`](crate::driver::Driver::set_requery_interval)
34/// explicitly once re-query is verified against your own CAM.
35pub const REQUERY_DEFAULT: Duration = Duration::ZERO;
36
37/// Sentinel `PCR_PID` value meaning "no PCR carried for this programme" (ISO/IEC
38/// 13818-1 §2.4.4.8, Table 2-33's `PCR_PID` field). A `ManagedService` whose
39/// `pcr_pid` is this value carries no dedicated PCR PID to route — it must be
40/// excluded from [`ManagedCa::required_pids`], never treated as a routable PID.
41const PCR_PID_NONE: u16 = 0x1FFF;
42
43/// Errors from the managed CAS-layer API (`Driver::add_service` and friends,
44/// #763).
45#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum CaError {
48    /// The PMT carries no `CA_descriptor` (ETSI EN 300 468 §6.2.16, tag
49    /// `0x09`) at programme or elementary-stream level — there is nothing for
50    /// the CAM to descramble, so no `ca_pmt` is built or sent.
51    #[error("PMT for program_number {program_number} has no CA_descriptor at program or ES level")]
52    NoCaDescriptor {
53        /// The programme whose PMT carried no CA info.
54        program_number: u16,
55    },
56    /// Sending the built `ca_pmt` to the device failed.
57    #[error("ca_pmt send failed: {0}")]
58    Io(#[from] std::io::Error),
59    /// The `ca_pmt` projected from the caller's PMT has no valid wire
60    /// encoding (a `program_info_length`/`ES_info_length` or `version_number`
61    /// out of its spec range — e.g. a source PMT whose descriptor loop is
62    /// corrupted past the section end) or its stored raw PMT no longer
63    /// re-parses. r10-W-20: `Driver::add_service` and the re-query timer
64    /// must reject such a PMT as an `Err` value rather than panic inside
65    /// the serialization.
66    #[error("ca_pmt serialization failed: {0}")]
67    Serialize(#[from] dvb_ci::Error),
68    /// The raw PMT bytes stored at `add_service` time no longer re-parse on
69    /// a re-query tick (corruption of the caller's own state); the re-query
70    /// is reported as an error instead of panicking (r10-W-20). Shares the
71    /// `dvb_si::error::Error` payload of the `Cat` arm.
72    #[error("stored PMT re-parse failed: {0}")]
73    PmtParse(dvb_si::error::Error),
74    /// The CAT's descriptor loop (ISO/IEC 13818-1 §2.4.4.5) carried a
75    /// truncated `CA_descriptor` (EN 300 468 §6.2.16) — [`Driver::set_cat`](crate::driver::Driver::set_cat)
76    /// could not extract the CAID/EMM-PID map.
77    #[error("CAT CA_descriptor parse failed: {0}")]
78    Cat(#[from] dvb_si::error::Error),
79}
80
81/// One actively-managed service (owned, no borrowed lifetime — copied out of
82/// the caller's `PmtSection` at [`Driver::add_service`](crate::driver::Driver::add_service)
83/// time).
84///
85/// `cmd` and `last_ca_enable` are recorded starting now but are only *read* by
86/// Task 6's `remove_service`; `last_ca_enable`/`last_descrambling_ok` are also
87/// read+written by `ManagedCa::record_reply` (#763 Task 5's edge-triggered
88/// `Notification::Entitlement`).
89#[derive(Debug, Clone, PartialEq, Eq)]
90#[non_exhaustive]
91pub struct ManagedService {
92    /// Elementary-stream PIDs carried by this programme (every stream, not
93    /// only the CA-bearing ones — a caller routing PIDs into `ci0` needs the
94    /// full component set).
95    pub es_pids: Vec<u16>,
96    /// `CA_PID`s (ECM PIDs) advertised by this programme's `CA_descriptor`s,
97    /// programme- and ES-level combined.
98    pub ca_pids: Vec<u16>,
99    /// This programme's PMT `PCR_PID` (ISO/IEC 13818-1 §2.4.4.8) — the PID
100    /// carrying the programme clock reference. May coincide with an
101    /// `es_pids` entry (PCR piggybacked on a component stream) or be a PID of
102    /// its own (a dedicated PCR PID, carrying no other stream) — either way a
103    /// caller routing PIDs into `ci0` needs it, or the descrambled TS loses
104    /// its clock reference. `0x1FFF` means the programme carries no PCR (ISO/IEC
105    /// 13818-1 §2.4.4.8) and is excluded from routing.
106    pub pcr_pid: u16,
107    /// The `ca_pmt_cmd_id` last sent for this service (EN 50221 §8.4.3.4
108    /// Table 25).
109    pub cmd: CaPmtCmdId,
110    /// The last observed programme-level `CA_enable` (EN 50221 §8.4.3.5 Table
111    /// 26), for the Task 5 edge-triggered `Notification::Entitlement`. `None`
112    /// until a `ca_pmt_reply` has been seen for this programme, or when the
113    /// last-seen reply's programme `CA_enable_flag` was clear.
114    pub last_ca_enable: Option<CaEnable>,
115    /// The last observed `descrambling_ok` (derived from `last_ca_enable`),
116    /// paired with it for the Task 5 transition diff.
117    pub(crate) last_descrambling_ok: bool,
118    /// The exact `ca_pmt` bytes sent by [`Driver::add_service`](crate::driver::Driver::add_service)
119    /// to start descrambling — `list_management = only`/`add`,
120    /// `cmd_id = ok_descrambling` (EN 50221 §8.4.3.4 Table 25). Kept for the
121    /// add_service oracle test to assert what was actually sent; this is
122    /// *not* what the #765/#1032 re-query timer resends —
123    /// `Driver::requery_tick` rebuilds a fresh `list_management = update`
124    /// `query`/`ok_descrambling` pair (#1032) per tick from
125    /// [`pmt_raw`](Self::pmt_raw), against the *current* active set each
126    /// time (not frozen at this service's `add_service` time).
127    pub(crate) built_ca_pmt: Vec<u8>,
128    /// The owned raw PMT section bytes this service was built from (#763
129    /// Task 6), kept so [`Driver::remove_service`](crate::driver::Driver::remove_service)
130    /// can re-drive the existing [`Driver::remove_program`](crate::driver::Driver::remove_program)
131    /// path (which needs the raw PMT to build the `Update`/`NotSelected`
132    /// `ca_pmt`, EN 50221 §8.4.3.4 Table 25) without the caller re-supplying
133    /// it.
134    pub(crate) pmt_raw: Vec<u8>,
135}
136
137/// The [`Driver`](crate::Driver)'s owned CAS-layer state (#763 Layer 1) — one
138/// CI slot's active service set plus the entitlement re-query cadence.
139#[derive(Debug, Clone)]
140pub struct ManagedCa {
141    /// Active services, keyed by `program_number`.
142    services: BTreeMap<u16, ManagedService>,
143    /// Entitlement re-query cadence (Task 5); `Duration::ZERO` disables it.
144    requery_interval: Duration,
145    /// Elapsed time accumulated since the last re-query (Task 5's
146    /// [`tick`](Self::tick), mirroring `resource.rs`'s `DateTime::tick`
147    /// accumulate-then-fire pattern).
148    since: Duration,
149    /// The last `set_cat`'s CAID → EMM PID map (ISO/IEC 13818-1 §2.4.4.5's
150    /// `CA_descriptor`s, EN 300 468 §6.2.16). Kept even when it yields no
151    /// `emm_pids` (no `ca_info` seen yet) so a later `ca_info` can recompute
152    /// against it (#763 Task 4).
153    cat_emm_pids: BTreeMap<u16, u16>,
154    /// The last-observed `Notification::CaInfo` CAID set — the CAM's
155    /// advertised systems (#763 Task 4).
156    cam_caids: BTreeSet<u16>,
157    /// `cat_emm_pids` ∩ `cam_caids` — the EMM PIDs to route into `ci0`.
158    /// Recomputed on every [`set_cat`](Self::set_cat)/
159    /// [`set_cam_caids`](Self::set_cam_caids) call.
160    emm_pids: Vec<u16>,
161    /// Union of active services' ES PIDs. Recomputed on every
162    /// [`record`](Self::record)/[`remove`](Self::remove) call.
163    descramble_pids: Vec<u16>,
164    /// Union of active services' `ca_pids` (ECM PIDs, programme+ES combined).
165    /// Recomputed on every [`record`](Self::record)/[`remove`](Self::remove)
166    /// call, exactly like [`descramble_pids`](Self::descramble_pids).
167    ca_pids: Vec<u16>,
168}
169
170impl Default for ManagedCa {
171    fn default() -> Self {
172        Self {
173            services: BTreeMap::new(),
174            requery_interval: REQUERY_DEFAULT,
175            since: Duration::ZERO,
176            cat_emm_pids: BTreeMap::new(),
177            cam_caids: BTreeSet::new(),
178            emm_pids: Vec::new(),
179            descramble_pids: Vec::new(),
180            ca_pids: Vec::new(),
181        }
182    }
183}
184
185impl ManagedCa {
186    /// New, empty managed-CA state at the default re-query cadence
187    /// ([`REQUERY_DEFAULT`]).
188    #[must_use]
189    pub fn new() -> Self {
190        Self::default()
191    }
192
193    /// The currently-tracked services, keyed by `program_number`.
194    #[must_use]
195    pub fn services(&self) -> &BTreeMap<u16, ManagedService> {
196        &self.services
197    }
198
199    /// The re-query cadence currently configured (Task 5 consumes this).
200    #[must_use]
201    pub fn requery_interval(&self) -> Duration {
202        self.requery_interval
203    }
204
205    /// Set the entitlement re-query cadence
206    /// ([`Driver::set_requery_interval`](crate::driver::Driver::set_requery_interval)).
207    /// `Duration::ZERO` disables re-query. Resets the accumulated `since` so a
208    /// newly-set interval doesn't fire immediately off stale accumulation.
209    pub(crate) fn set_requery_interval(&mut self, interval: Duration) {
210        self.requery_interval = interval;
211        self.since = Duration::ZERO;
212    }
213
214    /// Whether no service is currently tracked — used to pick
215    /// `CaPmtListManagement::Only` (first-ever service) vs `Add` (joining an
216    /// already-active set) for the next `add_service`, mirroring
217    /// [`Driver::descramble_programs`](crate::driver::Driver::descramble_programs)/
218    /// [`Driver::add_program`](crate::driver::Driver::add_program)'s existing
219    /// multi-programme list-management convention (EN 50221 §8.4.3.4 Table 25).
220    #[must_use]
221    pub(crate) fn is_empty(&self) -> bool {
222        self.services.is_empty()
223    }
224
225    /// Record a service after its `ca_pmt` has been built and sent.
226    pub(crate) fn record(&mut self, program_number: u16, service: ManagedService) {
227        self.services.insert(program_number, service);
228        self.recompute_service_pids();
229    }
230
231    /// Stop tracking `program_number` (#763 Task 6's
232    /// [`Driver::remove_service`](crate::driver::Driver::remove_service)),
233    /// recomputing [`descramble_pids`](Self::descramble_pids)/[`ca_pids`](Self::ca_pids)
234    /// afterwards. Returns whether the programme was actually tracked
235    /// (`false` is a no-op — nothing to remove).
236    pub(crate) fn remove(&mut self, program_number: u16) -> bool {
237        let removed = self.services.remove(&program_number).is_some();
238        if removed {
239            self.recompute_service_pids();
240        }
241        removed
242    }
243
244    /// Clear all module-scoped managed state (#763 Task 6's CAM hot-plug
245    /// fix): the active service set, the CAT/CAM CAID-derived EMM-PID state,
246    /// and the descramble-PID union, plus the re-query accumulator (`since`)
247    /// so a freshly (re)inserted module doesn't inherit a departed module's
248    /// partially-elapsed re-query countdown. `requery_interval` is
249    /// deliberately **not** reset — it is host configuration
250    /// ([`set_requery_interval`](Self::set_requery_interval)), not
251    /// per-module state, and must survive a CAM insert/remove edge.
252    pub(crate) fn clear(&mut self) {
253        self.services.clear();
254        self.cat_emm_pids.clear();
255        self.cam_caids.clear();
256        self.emm_pids.clear();
257        self.descramble_pids.clear();
258        self.ca_pids.clear();
259        self.since = Duration::ZERO;
260    }
261
262    /// The EMM PIDs to route into `ci0`: the last `set_cat`'s CAID → EMM-PID
263    /// map, intersected with the CAM's advertised CAIDs (last `ca_info`) — a
264    /// CAT entry for a CAID the CAM never advertised is never fed (#763 Task
265    /// 4).
266    #[must_use]
267    pub fn emm_pids(&self) -> &[u16] {
268        &self.emm_pids
269    }
270
271    /// The union of every actively-managed service's elementary-stream PIDs
272    /// — the PIDs a caller must route into `ci0` for descrambling.
273    #[must_use]
274    pub fn descramble_pids(&self) -> &[u16] {
275        &self.descramble_pids
276    }
277
278    /// The union of every actively-managed service's `CA_PID`s (ECM PIDs,
279    /// ISO/IEC 13818-1 §2.6.16 `CA_descriptor` `CA_PID`, programme + ES level
280    /// combined) — the control-word channel a caller must route into `ci0`
281    /// alongside [`descramble_pids`](Self::descramble_pids); without these
282    /// the module has ES to descramble but no control words to do it with.
283    #[must_use]
284    pub fn ca_pids(&self) -> &[u16] {
285        &self.ca_pids
286    }
287
288    /// `descramble_pids ∪ ca_pids ∪ emm_pids ∪ PCR` — every PID class a
289    /// caller must route into `ci0` for this slot to both descramble the
290    /// tracked services (ES + ECM), keep entitlements current (EMM), and
291    /// carry each active service's programme clock reference (PCR — ISO/IEC
292    /// 13818-1 §2.4.4.8). The PCR PID is folded in here rather than into
293    /// [`descramble_pids`](Self::descramble_pids) because a dedicated PCR PID
294    /// carries no elementary stream of its own; a service whose `pcr_pid` is
295    /// `0x1FFF` ("no PCR") contributes nothing. Computed on demand (small
296    /// sets); dedup + sorted.
297    #[must_use]
298    pub fn required_pids(&self) -> Vec<u16> {
299        let mut pids: BTreeSet<u16> = BTreeSet::new();
300        pids.extend(self.descramble_pids.iter().copied());
301        pids.extend(self.ca_pids.iter().copied());
302        pids.extend(self.emm_pids.iter().copied());
303        for service in self.services.values() {
304            if service.pcr_pid != PCR_PID_NONE {
305                pids.insert(service.pcr_pid);
306            }
307        }
308        pids.into_iter().collect()
309    }
310
311    /// Store the CAT's CAID → EMM-PID map (ISO/IEC 13818-1 §2.4.4.5's
312    /// `CA_descriptor`s, EN 300 468 §6.2.16) and recompute [`emm_pids`](Self::emm_pids).
313    /// Calling this before any `ca_info` is not an error: the map is kept so
314    /// a later [`set_cam_caids`](Self::set_cam_caids) recomputes against it.
315    pub(crate) fn set_cat(&mut self, entries: &[CatCaEntry]) {
316        self.cat_emm_pids = entries.iter().map(|e| (e.ca_system_id, e.ca_pid)).collect();
317        self.recompute_emm_pids();
318    }
319
320    /// Record the CAM's advertised CAID set (from `Notification::CaInfo`)
321    /// and recompute [`emm_pids`](Self::emm_pids).
322    pub(crate) fn set_cam_caids(&mut self, caids: BTreeSet<u16>) {
323        self.cam_caids = caids;
324        self.recompute_emm_pids();
325    }
326
327    /// The CAM's advertised CAID set (last `Notification::CaInfo`), for a
328    /// caller that must CAID-filter a `ca_pmt` before sending it (#1067) —
329    /// mirrors `CiStack`'s existing filter on the raw `descramble` path
330    /// (`dvb_ci::builder::build_ca_pmt_for_caids`, whose own doc cites: "a
331    /// CICAM rejects a `ca_pmt` carrying a `CA_descriptor` for a
332    /// `CA_system_id` it does not support, declining even the streams it
333    /// could descramble"). Empty means no `ca_info` has been observed yet —
334    /// the caller falls back to sending every `CA_descriptor` unfiltered,
335    /// same as `CiStack::build_ca_pmt_bytes`.
336    #[must_use]
337    pub(crate) fn cam_caids(&self) -> &BTreeSet<u16> {
338        &self.cam_caids
339    }
340
341    /// `emm_pids` = `cat_emm_pids` ∩ `cam_caids`, deduped and sorted by PID
342    /// (matching [`recompute_service_pids`](Self::recompute_service_pids)'s
343    /// convention) — two CAIDs the CAT maps to the *same* EMM PID must not
344    /// list that PID twice.
345    fn recompute_emm_pids(&mut self) {
346        let pids: BTreeSet<u16> = self
347            .cat_emm_pids
348            .iter()
349            .filter(|(caid, _)| self.cam_caids.contains(caid))
350            .map(|(_, pid)| *pid)
351            .collect();
352        self.emm_pids = pids.into_iter().collect();
353    }
354
355    /// Advance the entitlement re-query cadence by `elapsed` — mirrors
356    /// `resource.rs`'s `DateTime::tick` accumulate-then-fire pattern:
357    /// accumulate `since`, and once it reaches `requery_interval`, reset it
358    /// and report that a re-query is due. Returns `false` (never fires) when
359    /// re-query is disabled (`requery_interval == Duration::ZERO`) or there
360    /// are no active services to re-query.
361    pub(crate) fn tick(&mut self, elapsed: Duration) -> bool {
362        if self.requery_interval.is_zero() || self.services.is_empty() {
363            return false;
364        }
365        self.since += elapsed;
366        if self.since >= self.requery_interval {
367            self.since = Duration::ZERO;
368            true
369        } else {
370            false
371        }
372    }
373
374    /// Diff an incoming `ca_pmt_reply`'s programme-level status (EN 50221
375    /// §8.4.3.5 Table 26) against the last-observed status for
376    /// `program_number` and report the edge-triggered transition (#763 Task
377    /// 5): `Some((v, descrambling_ok))` fires only when `ca_enable` is
378    /// `Some(v)` *and* `(ca_enable, descrambling_ok)` differs from what was
379    /// last observed for this programme — including the first-ever reply
380    /// (no prior observation) establishing the baseline and reporting it.
381    /// `ca_enable == None` (programme status withdrawn) never fires: there is
382    /// no per-programme status to report (the coarse withdrawal signal is
383    /// #726 `HotPlug`'s job). The last-observed status is updated
384    /// unconditionally, whether or not this call fires.
385    ///
386    /// No-op (`None`) if `program_number` names no actively-managed service
387    /// (never `add_service`'d, or already removed) — there is nothing to
388    /// diff against.
389    pub(crate) fn record_reply(
390        &mut self,
391        program_number: u16,
392        ca_enable: Option<CaEnable>,
393        descrambling_ok: bool,
394    ) -> Option<(CaEnable, bool)> {
395        let service = self.services.get_mut(&program_number)?;
396        let prev = (service.last_ca_enable, service.last_descrambling_ok);
397        service.last_ca_enable = ca_enable;
398        service.last_descrambling_ok = descrambling_ok;
399        match ca_enable {
400            Some(v) if prev != (ca_enable, descrambling_ok) => Some((v, descrambling_ok)),
401            _ => None,
402        }
403    }
404
405    /// `descramble_pids`/`ca_pids` = the union (dedup, sorted) of every
406    /// active service's `es_pids`/`ca_pids` respectively.
407    fn recompute_service_pids(&mut self) {
408        let mut descramble: BTreeSet<u16> = BTreeSet::new();
409        let mut ca: BTreeSet<u16> = BTreeSet::new();
410        for service in self.services.values() {
411            descramble.extend(service.es_pids.iter().copied());
412            ca.extend(service.ca_pids.iter().copied());
413        }
414        self.descramble_pids = descramble.into_iter().collect();
415        self.ca_pids = ca.into_iter().collect();
416    }
417}
418
419/// `true` if `loop_` carries at least one `CA_descriptor` (ISO/IEC 13818-1
420/// §2.6.16 / ETSI EN 300 468 §6.2.16, tag `0x09`).
421fn has_ca_descriptor(loop_: &DescriptorLoop<'_>) -> bool {
422    loop_.raw_tags().any(|(tag, _)| tag == CA_DESCRIPTOR_TAG)
423}
424
425/// Byte offset within a `CA_descriptor` body (after tag + length) where the
426/// `reserved(3)`/`CA_PID(13)` field starts — `CA_system_id` occupies the first
427/// two bytes (ISO/IEC 13818-1 §2.6.16).
428const CA_PID_BODY_OFFSET: usize = 2;
429/// `CA_PID` field width in bytes.
430const CA_PID_FIELD_LEN: usize = 2;
431/// Mask for the `CA_PID`'s upper byte (top 3 bits are reserved, set to `1`).
432const CA_PID_HIGH_MASK: u8 = 0x1F;
433
434/// The `CA_PID` carried by one `CA_descriptor` body (tag + length already
435/// stripped by [`DescriptorLoop::raw_tags`]), if the body is long enough to
436/// carry the mandatory fields.
437fn ca_pid_of(body: &[u8]) -> Option<u16> {
438    let field = body.get(CA_PID_BODY_OFFSET..CA_PID_BODY_OFFSET + CA_PID_FIELD_LEN)?;
439    Some((u16::from(field[0] & CA_PID_HIGH_MASK) << 8) | u16::from(field[1]))
440}
441
442/// Collect the `CA_PID`s from every `CA_descriptor` in `loop_`.
443fn ca_pids_in(loop_: &DescriptorLoop<'_>) -> Vec<u16> {
444    loop_
445        .raw_tags()
446        .filter(|(tag, _)| *tag == CA_DESCRIPTOR_TAG)
447        .filter_map(|(_, body)| ca_pid_of(body))
448        .collect()
449}
450
451/// Whether `pmt` carries a `CA_descriptor` at programme or any ES level (EN
452/// 300 468 §6.2.16) — used to reject a CA-free PMT before building a useless
453/// `ca_pmt`.
454pub(crate) fn pmt_has_ca(pmt: &PmtSection<'_>) -> bool {
455    has_ca_descriptor(&pmt.program_info)
456        || pmt.streams.iter().any(|s| has_ca_descriptor(&s.es_info))
457}
458
459/// The owned [`ManagedService`] state to record for `pmt`, sent with `cmd`;
460/// `built_ca_pmt` is the exact `ok_descrambling` bytes sent (kept for the
461/// add_service oracle test). `pmt_raw` is kept so a later re-query
462/// (`Driver::requery_tick`, #765) can rebuild a fresh `query`-variant
463/// `ca_pmt` against the *current* active set rather than freezing
464/// `list_management` at this moment.
465pub(crate) fn service_of(
466    pmt: &PmtSection<'_>,
467    cmd: CaPmtCmdId,
468    built_ca_pmt: Vec<u8>,
469    pmt_raw: Vec<u8>,
470) -> ManagedService {
471    let mut ca_pids = ca_pids_in(&pmt.program_info);
472    for s in &pmt.streams {
473        ca_pids.extend(ca_pids_in(&s.es_info));
474    }
475    ManagedService {
476        es_pids: pmt.streams.iter().map(|s| s.elementary_pid).collect(),
477        ca_pids,
478        pcr_pid: pmt.pcr_pid,
479        cmd,
480        last_ca_enable: None,
481        last_descrambling_ok: false,
482        built_ca_pmt,
483        pmt_raw,
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn new_managed_ca_is_empty_at_default_cadence() {
493        let m = ManagedCa::new();
494        assert!(m.is_empty());
495        assert!(m.services().is_empty());
496        assert_eq!(m.requery_interval(), REQUERY_DEFAULT);
497    }
498
499    #[test]
500    fn record_tracks_the_service() {
501        let mut m = ManagedCa::new();
502        let svc = ManagedService {
503            es_pids: vec![0x100, 0x101],
504            ca_pids: vec![0x0064],
505            pcr_pid: PCR_PID_NONE,
506            cmd: CaPmtCmdId::OkDescrambling,
507            last_ca_enable: None,
508            last_descrambling_ok: false,
509            built_ca_pmt: vec![0xAA, 0xBB],
510            pmt_raw: vec![0x02, 0x00],
511        };
512        m.record(7, svc.clone());
513        assert!(!m.is_empty());
514        assert_eq!(m.services().get(&7), Some(&svc));
515    }
516
517    #[test]
518    fn ca_error_no_ca_descriptor_displays_program_number() {
519        let e = CaError::NoCaDescriptor { program_number: 42 };
520        assert!(e.to_string().contains("42"));
521    }
522
523    // --- #763 Task 5 ---
524
525    #[test]
526    fn set_requery_interval_updates_and_resets_accumulator() {
527        let mut m = ManagedCa::new();
528        assert_eq!(m.requery_interval(), REQUERY_DEFAULT);
529        m.set_requery_interval(Duration::from_secs(3));
530        assert_eq!(m.requery_interval(), Duration::from_secs(3));
531    }
532
533    #[test]
534    fn tick_fires_once_interval_elapses_and_resets() {
535        let mut m = ManagedCa::new();
536        m.set_requery_interval(Duration::from_secs(5));
537        m.record(
538            1,
539            ManagedService {
540                es_pids: vec![0x100],
541                ca_pids: vec![0x64],
542                pcr_pid: PCR_PID_NONE,
543                cmd: CaPmtCmdId::OkDescrambling,
544                last_ca_enable: None,
545                last_descrambling_ok: false,
546                built_ca_pmt: vec![],
547                pmt_raw: vec![],
548            },
549        );
550        assert!(
551            !m.tick(Duration::from_secs(3)),
552            "before the interval: no fire"
553        );
554        assert!(
555            m.tick(Duration::from_secs(3)),
556            "crossing the interval: fires"
557        );
558        assert!(!m.tick(Duration::from_secs(1)), "since resets after firing");
559    }
560
561    #[test]
562    fn tick_disabled_at_zero_interval_never_fires() {
563        let mut m = ManagedCa::new();
564        m.set_requery_interval(Duration::ZERO);
565        m.record(
566            1,
567            ManagedService {
568                es_pids: vec![0x100],
569                ca_pids: vec![0x64],
570                pcr_pid: PCR_PID_NONE,
571                cmd: CaPmtCmdId::OkDescrambling,
572                last_ca_enable: None,
573                last_descrambling_ok: false,
574                built_ca_pmt: vec![],
575                pmt_raw: vec![],
576            },
577        );
578        assert!(!m.tick(Duration::from_secs(1000)));
579    }
580
581    #[test]
582    fn tick_with_no_active_services_never_fires() {
583        let mut m = ManagedCa::new();
584        m.set_requery_interval(Duration::from_secs(1));
585        assert!(!m.tick(Duration::from_secs(1000)));
586    }
587
588    #[test]
589    fn record_reply_first_ever_some_establishes_baseline_and_reports() {
590        let mut m = ManagedCa::new();
591        m.record(
592            1,
593            ManagedService {
594                es_pids: vec![0x100],
595                ca_pids: vec![0x64],
596                pcr_pid: PCR_PID_NONE,
597                cmd: CaPmtCmdId::OkDescrambling,
598                last_ca_enable: None,
599                last_descrambling_ok: false,
600                built_ca_pmt: vec![],
601                pmt_raw: vec![],
602            },
603        );
604        let out = m.record_reply(1, Some(CaEnable::NotPossibleNoEntitlement), false);
605        assert_eq!(out, Some((CaEnable::NotPossibleNoEntitlement, false)));
606    }
607
608    #[test]
609    fn record_reply_unchanged_status_does_not_re_fire() {
610        let mut m = ManagedCa::new();
611        m.record(
612            1,
613            ManagedService {
614                es_pids: vec![0x100],
615                ca_pids: vec![0x64],
616                pcr_pid: PCR_PID_NONE,
617                cmd: CaPmtCmdId::OkDescrambling,
618                last_ca_enable: None,
619                last_descrambling_ok: false,
620                built_ca_pmt: vec![],
621                pmt_raw: vec![],
622            },
623        );
624        assert!(m.record_reply(1, Some(CaEnable::Possible), true).is_some());
625        assert_eq!(m.record_reply(1, Some(CaEnable::Possible), true), None);
626    }
627
628    #[test]
629    fn record_reply_none_never_fires_but_updates_last() {
630        let mut m = ManagedCa::new();
631        m.record(
632            1,
633            ManagedService {
634                es_pids: vec![0x100],
635                ca_pids: vec![0x64],
636                pcr_pid: PCR_PID_NONE,
637                cmd: CaPmtCmdId::OkDescrambling,
638                last_ca_enable: None,
639                last_descrambling_ok: false,
640                built_ca_pmt: vec![],
641                pmt_raw: vec![],
642            },
643        );
644        assert!(m.record_reply(1, Some(CaEnable::Possible), true).is_some());
645        // Withdrawn: None never fires.
646        assert_eq!(m.record_reply(1, None, false), None);
647        // Re-affirmed with the SAME value as before the withdrawal: fires
648        // again, because `last` was overwritten to `None` in between.
649        assert_eq!(
650            m.record_reply(1, Some(CaEnable::Possible), true),
651            Some((CaEnable::Possible, true))
652        );
653    }
654
655    #[test]
656    fn record_reply_unknown_program_is_a_no_op() {
657        let mut m = ManagedCa::new();
658        assert_eq!(m.record_reply(99, Some(CaEnable::Possible), true), None);
659    }
660
661    // --- #763 Task 6: remove + clear ---
662
663    #[test]
664    fn remove_drops_tracked_service_and_recomputes_descramble_pids_false_for_untracked() {
665        let mut m = ManagedCa::new();
666        m.record(
667            1,
668            ManagedService {
669                es_pids: vec![0x100, 0x101],
670                ca_pids: vec![0x64],
671                pcr_pid: PCR_PID_NONE,
672                cmd: CaPmtCmdId::OkDescrambling,
673                last_ca_enable: None,
674                last_descrambling_ok: false,
675                built_ca_pmt: vec![],
676                pmt_raw: vec![],
677            },
678        );
679        m.record(
680            2,
681            ManagedService {
682                es_pids: vec![0x200],
683                ca_pids: vec![0x65],
684                pcr_pid: PCR_PID_NONE,
685                cmd: CaPmtCmdId::OkDescrambling,
686                last_ca_enable: None,
687                last_descrambling_ok: false,
688                built_ca_pmt: vec![],
689                pmt_raw: vec![],
690            },
691        );
692
693        assert!(
694            !m.remove(99),
695            "removing an untracked program_number must return false"
696        );
697        assert_eq!(
698            m.services().len(),
699            2,
700            "an untracked remove must not disturb the tracked set"
701        );
702
703        assert!(
704            m.remove(1),
705            "removing a tracked program_number must return true"
706        );
707        assert!(m.services().get(&1).is_none());
708        assert_eq!(
709            m.descramble_pids(),
710            &[0x200],
711            "descramble_pids must recompute (drop program 1's PIDs) after remove"
712        );
713    }
714
715    #[test]
716    fn clear_resets_module_state_but_preserves_requery_interval() {
717        use dvb_si::tables::cat::CatCaEntry;
718
719        let mut m = ManagedCa::new();
720        m.set_requery_interval(Duration::from_secs(3));
721        m.record(
722            1,
723            ManagedService {
724                es_pids: vec![0x100],
725                ca_pids: vec![0x64],
726                pcr_pid: PCR_PID_NONE,
727                cmd: CaPmtCmdId::OkDescrambling,
728                last_ca_enable: None,
729                last_descrambling_ok: false,
730                built_ca_pmt: vec![],
731                pmt_raw: vec![],
732            },
733        );
734        m.set_cat(&[CatCaEntry {
735            ca_system_id: 0x0648,
736            ca_pid: 0x1FF0,
737            private_data: Vec::new(),
738        }]);
739        m.set_cam_caids([0x0648].into_iter().collect());
740        assert!(!m.emm_pids().is_empty(), "precondition: emm_pids populated");
741        assert!(
742            !m.descramble_pids().is_empty(),
743            "precondition: descramble_pids populated"
744        );
745
746        m.clear();
747
748        assert!(m.services().is_empty(), "services must be cleared");
749        assert!(m.emm_pids().is_empty(), "emm_pids must be cleared");
750        assert!(
751            m.descramble_pids().is_empty(),
752            "descramble_pids must be cleared"
753        );
754        assert!(m.ca_pids().is_empty(), "ca_pids must be cleared");
755        assert_eq!(
756            m.requery_interval(),
757            Duration::from_secs(3),
758            "requery_interval is host config, must survive clear()"
759        );
760    }
761
762    // --- #763 Task 7: ca_pids()/required_pids() ---
763
764    #[test]
765    fn ca_pids_is_the_dedup_sorted_union_of_active_services_and_required_pids_unions_all_three() {
766        use dvb_si::tables::cat::CatCaEntry;
767
768        let mut m = ManagedCa::new();
769        m.record(
770            1,
771            ManagedService {
772                es_pids: vec![0x0100, 0x0101],
773                ca_pids: vec![0x0064, 0x0065],
774                pcr_pid: PCR_PID_NONE,
775                cmd: CaPmtCmdId::OkDescrambling,
776                last_ca_enable: None,
777                last_descrambling_ok: false,
778                built_ca_pmt: vec![],
779                pmt_raw: vec![],
780            },
781        );
782        m.record(
783            2,
784            ManagedService {
785                es_pids: vec![0x0200],
786                // Shares 0x0065 with program 1 to prove dedup, plus a
787                // distinct 0x0066.
788                ca_pids: vec![0x0065, 0x0066],
789                pcr_pid: PCR_PID_NONE,
790                cmd: CaPmtCmdId::OkDescrambling,
791                last_ca_enable: None,
792                last_descrambling_ok: false,
793                built_ca_pmt: vec![],
794                pmt_raw: vec![],
795            },
796        );
797
798        assert_eq!(
799            m.ca_pids(),
800            &[0x0064, 0x0065, 0x0066],
801            "ca_pids must be the dedup+sorted union of both services' ca_pids"
802        );
803
804        // Populate emm_pids too (CAT ∩ ca_info CAIDs), so required_pids
805        // exercises all three classes.
806        m.set_cat(&[CatCaEntry {
807            ca_system_id: 0x0648,
808            ca_pid: 0x1FF0,
809            private_data: Vec::new(),
810        }]);
811        m.set_cam_caids([0x0648].into_iter().collect());
812        assert_eq!(m.emm_pids(), &[0x1FF0], "precondition: emm_pids populated");
813
814        assert_eq!(
815            m.required_pids(),
816            vec![0x0064, 0x0065, 0x0066, 0x0100, 0x0101, 0x0200, 0x1FF0],
817            "required_pids must be descramble_pids ∪ ca_pids ∪ emm_pids"
818        );
819    }
820
821    // --- #763 final-review Fix 1: dedicated PCR PID routing ---
822
823    #[test]
824    fn service_of_captures_pcr_pid_and_required_pids_includes_dedicated_pcr() {
825        use broadcast_common::Parse;
826        use dvb_si::tables::pmt::PmtSection;
827
828        // A PMT whose PCR is carried on its own dedicated PID (0x00FF),
829        // distinct from every ES PID (0x0100/0x0101) and CA PID (0x0064/
830        // 0x0065) — a legitimate DVB config `service_of` must not lose.
831        let pmt_bytes = crate::driver::tests::build_ca_pmt_fixture_dedicated_pcr(1550);
832        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
833        assert_eq!(
834            pmt.pcr_pid, 0x00FF,
835            "fixture precondition: dedicated PCR PID outside the ES/CA set"
836        );
837
838        let svc = service_of(&pmt, CaPmtCmdId::OkDescrambling, vec![], vec![]);
839        assert_eq!(svc.pcr_pid, 0x00FF, "service_of must capture pmt.pcr_pid");
840        assert_eq!(svc.es_pids, vec![0x0100, 0x0101]);
841        assert_eq!(svc.ca_pids, vec![0x0064, 0x0065]);
842
843        let mut m = ManagedCa::new();
844        m.record(1550, svc);
845
846        // Bite: without folding pcr_pid into required_pids, this PID is
847        // absent (it's neither an ES nor a CA nor an EMM PID) and the
848        // dedicated PCR clock reference never reaches ci0.
849        assert!(
850            m.required_pids().contains(&0x00FF),
851            "required_pids must include the service's dedicated PCR PID, got {:?}",
852            m.required_pids()
853        );
854    }
855
856    #[test]
857    fn required_pids_excludes_pcr_pid_none() {
858        let mut m = ManagedCa::new();
859        m.record(
860            1,
861            ManagedService {
862                es_pids: vec![0x0100],
863                ca_pids: vec![0x0064],
864                // PCR_PID_NONE (0x1FFF, ISO/IEC 13818-1 §2.4.4.8) — this
865                // programme carries no PCR of its own.
866                pcr_pid: PCR_PID_NONE,
867                cmd: CaPmtCmdId::OkDescrambling,
868                last_ca_enable: None,
869                last_descrambling_ok: false,
870                built_ca_pmt: vec![],
871                pmt_raw: vec![],
872            },
873        );
874        assert_eq!(
875            m.required_pids(),
876            vec![0x0064, 0x0100],
877            "PCR_PID_NONE (0x1FFF) must never be added to required_pids"
878        );
879    }
880
881    #[test]
882    fn required_pids_pcr_pid_matching_an_es_pid_adds_no_spurious_pid() {
883        let mut m = ManagedCa::new();
884        m.record(
885            1,
886            ManagedService {
887                es_pids: vec![0x0100],
888                ca_pids: vec![0x0064],
889                // PCR piggybacked on the ES PID (the common case, already
890                // covered by every other fixture in this file) — must not
891                // produce a duplicate/spurious entry.
892                pcr_pid: 0x0100,
893                cmd: CaPmtCmdId::OkDescrambling,
894                last_ca_enable: None,
895                last_descrambling_ok: false,
896                built_ca_pmt: vec![],
897                pmt_raw: vec![],
898            },
899        );
900        assert_eq!(
901            m.required_pids(),
902            vec![0x0064, 0x0100],
903            "a pcr_pid coinciding with an ES PID must not duplicate/add a spurious entry"
904        );
905    }
906}