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}