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