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