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