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