Skip to main content

dvb_ci_runtime/
driver.rs

1//! The driver — the one place I/O happens. It pumps a [`CaDevice`] against the
2//! sans-IO [`CiStack`]: reads frames in, executes the stack's [`Action`]s
3//! (writes/ioctls) out, tracks the requested poll timer, and collects
4//! [`Notification`]s for the host application.
5
6use std::collections::BTreeSet;
7use std::io;
8use std::time::{Duration, Instant};
9
10use broadcast_common::Serialize;
11use dvb_ci::builder::{build_ca_pmt, build_ca_pmt_for_caids};
12use dvb_ci::objects::ca_pmt::{CaPmtCmdId, CaPmtListManagement};
13use dvb_si::tables::cat::CatSection;
14use dvb_si::tables::pmt::PmtSection;
15
16use broadcast_common::Parse;
17
18use crate::device::{CaDevice, SlotInfo};
19use crate::event::{Action, Event, HostRequest, HotPlug, MmiEvent, Notification};
20use crate::managed::{self, CaError, ManagedCa};
21use crate::stack::CiStack;
22
23/// Substrings (case-insensitive) in MMI menu/list/enquiry text that
24/// heuristically indicate the smart card is absent. **Best-effort**: EN 50221
25/// defines no card-detect signal, so this is free-text sniffing of real CAM
26/// MMI copy, not a spec-defined mechanism.
27const MMI_CARD_ABSENT_KEYWORDS: &[&str] = &[
28    "no card",
29    "insert card",
30    "insert smart card",
31    "card removed",
32    "please insert",
33];
34
35/// Substrings (case-insensitive) in MMI menu/list/enquiry text that
36/// heuristically indicate a valid smart card is present (entitlements
37/// readable). **Best-effort**, same caveat as
38/// [`MMI_CARD_ABSENT_KEYWORDS`].
39const MMI_CARD_PRESENT_KEYWORDS: &[&str] = &["entitlement", "card valid", "subscription active"];
40
41/// Clock source for [`Driver::pump`]'s elapsed-time measurement (r10-W-21).
42/// A boxed closure (not a bare `fn` pointer) so a test's [`Driver::with_clock`]
43/// can capture mutable state to simulate a jump of arbitrary size in one call,
44/// which real wall-clock time cannot do deterministically.
45type Clock = Box<dyn Fn() -> Instant>;
46
47fn default_clock() -> Clock {
48    Box::new(Instant::now)
49}
50
51/// Drives a [`CaDevice`] with the [`CiStack`].
52pub struct Driver<D: CaDevice> {
53    device: D,
54    stack: CiStack,
55    notifications: Vec<Notification>,
56    /// Delay the stack last asked to be polled after (`None` = none pending).
57    next_timer: Option<Duration>,
58    /// Read buffer for one link-layer frame.
59    buf: Vec<u8>,
60    /// Last observed slot status (Part A hot-plug edge detection, #726).
61    /// `None` means no [`SlotInfo`] has been observed yet — the first
62    /// observation only establishes the baseline; it never itself fires
63    /// [`Notification::HotPlug`] carrying [`HotPlug::CamPresent`]/
64    /// [`CamRemoved`](HotPlug::CamRemoved), so `Driver::init` on an
65    /// already-inserted module doesn't spuriously re-drive its own handshake.
66    last_slot: Option<SlotInfo>,
67    /// Last `ca_info` CAID set seen for the current module (Part B card
68    /// inference, best-effort). `None` = not seen yet (baseline only).
69    last_caids: Option<BTreeSet<u16>>,
70    /// Last `ca_pmt_reply` `descrambling_ok` seen for the current module
71    /// (Part B card inference, best-effort). `None` = not seen yet.
72    last_descrambling_ok: Option<bool>,
73    /// Card state last inferred from MMI text (`Some(true)` present,
74    /// `Some(false)` absent, `None` = no card keyword seen yet): the keyword
75    /// heuristic only reports a card *transition*, so a CAM that re-sends
76    /// the same "insert card" menu does not repeat the notification (audit
77    /// r10-O-13).
78    last_mmi_card_present: Option<bool>,
79    /// The slot's managed CAS-layer state (#763 Layer 1) — active services
80    /// built via [`add_service`](Self::add_service).
81    managed: ManagedCa,
82    /// Wall-clock time of the start of the previous [`Self::pump`] call
83    /// (r10-W-21). `None` before the first call — that call's `timeout`
84    /// argument is used as-is, since there is no previous call to measure
85    /// from.
86    last_pump: Option<Instant>,
87    /// Clock source for [`Self::pump`] — see [`Clock`]/[`Self::with_clock`].
88    clock: Clock,
89    /// r10-W-20: a re-query-tick serialization failure recorded for later
90    /// reporting (a corrupt service must not silently swallow the healthy
91    /// services' resend); drained with priority on the next tick.
92    requery_pending: Option<CaError>,
93}
94
95impl<D: CaDevice> Driver<D> {
96    /// New driver over `device`, single transport connection.
97    #[must_use]
98    pub fn new(device: D) -> Self {
99        Self {
100            device,
101            stack: CiStack::new(),
102            notifications: Vec::new(),
103            next_timer: None,
104            buf: vec![0u8; 4096],
105            last_slot: None,
106            last_caids: None,
107            last_descrambling_ok: None,
108            last_mmi_card_present: None,
109            managed: ManagedCa::new(),
110            last_pump: None,
111            clock: default_clock(),
112            requery_pending: None,
113        }
114    }
115
116    /// Override [`Self::pump`]'s clock source (r10-W-21) — `Instant::now`
117    /// by default. A test uses this to simulate an arbitrary elapsed jump
118    /// deterministically, in place of a real sleep.
119    #[must_use]
120    pub fn with_clock(mut self, clock: impl Fn() -> Instant + 'static) -> Self {
121        self.clock = Box::new(clock);
122        self
123    }
124
125    /// The slot's managed CAS-layer state (#763 Layer 1) — the active
126    /// service set built via [`add_service`](Self::add_service).
127    pub fn managed_ca(&self) -> &ManagedCa {
128        &self.managed
129    }
130
131    /// Borrow the underlying device (e.g. to inspect a mock's recorded ops).
132    pub fn device(&self) -> &D {
133        &self.device
134    }
135
136    /// Mutably borrow the underlying device (e.g. to script a mock's inbound
137    /// frames between pumps).
138    pub fn device_mut(&mut self) -> &mut D {
139        &mut self.device
140    }
141
142    /// The poll delay the stack most recently requested, if any.
143    pub fn next_timer(&self) -> Option<Duration> {
144        self.next_timer
145    }
146
147    /// Drain the notifications collected so far.
148    pub fn take_notifications(&mut self) -> Vec<Notification> {
149        core::mem::take(&mut self.notifications)
150    }
151
152    /// Bring the interface up (reset + open the transport connection).
153    pub fn init(&mut self) -> io::Result<()> {
154        let actions = self.stack.handle(Event::Host(HostRequest::Init));
155        self.run(actions)
156    }
157
158    /// Request the module descramble the services in `ca_pmt` (a serialized
159    /// `ca_pmt` APDU body, e.g. from `dvb_ci::build_ca_pmt`).
160    pub fn send_ca_pmt(&mut self, ca_pmt: &[u8]) -> io::Result<()> {
161        let actions = self
162            .stack
163            .handle(Event::Host(HostRequest::SendCaPmt(ca_pmt)));
164        self.run(actions)
165    }
166
167    /// Descramble the services in a PMT section: the stack filters the PMT's
168    /// `CA_descriptor`s to the CAM's advertised CAIDs and sends a `ca_pmt`
169    /// (`list_management = only`, `cmd_id = ok_descrambling`). The outcome
170    /// surfaces as [`Notification::CaPmtReply`]. Call after the CAM is ready and
171    /// its `ca_info` has been received (otherwise no CAID filter is applied).
172    pub fn descramble(&mut self, pmt_section: &[u8]) -> io::Result<()> {
173        let actions = self
174            .stack
175            .handle(Event::Host(HostRequest::Descramble(pmt_section)));
176        self.run(actions)
177    }
178
179    /// Descramble a set of programmes in one CA-PMT list (`first`/`more`/`last`),
180    /// replacing any previously selected set. Each element is a raw PMT section.
181    pub fn descramble_programs(&mut self, pmt_sections: &[&[u8]]) -> io::Result<()> {
182        let actions = self
183            .stack
184            .handle(Event::Host(HostRequest::DescramblePrograms(pmt_sections)));
185        self.run(actions)
186    }
187
188    /// Add one programme to the descrambled set (`list_management = add`) without
189    /// re-listing the others — for a capacity manager adding a viewer's service.
190    pub fn add_program(&mut self, pmt_section: &[u8]) -> io::Result<()> {
191        let actions = self
192            .stack
193            .handle(Event::Host(HostRequest::AddProgram(pmt_section)));
194        self.run(actions)
195    }
196
197    /// Remove one programme from the descrambled set (`list_management = update`,
198    /// `cmd_id = not_selected`) — tells the CAM to stop descrambling it.
199    pub fn remove_program(&mut self, pmt_section: &[u8]) -> io::Result<()> {
200        let actions = self
201            .stack
202            .handle(Event::Host(HostRequest::RemoveProgram(pmt_section)));
203        self.run(actions)
204    }
205
206    /// Build the `ca_pmt` projection of `pmt`, CAID-filtered to the CAM's
207    /// advertised `ca_info` CAIDs once known (#1067): a CICAM rejects a
208    /// `ca_pmt` carrying a `CA_descriptor` for a `CA_system_id` it does not
209    /// support, declining even the streams it could descramble
210    /// (`dvb_ci::builder::build_ca_pmt_for_caids`'s own doc). Shared by
211    /// [`add_service`](Self::add_service) and the entitlement re-query timer
212    /// ([`requery_tick`](Self::requery_tick)) so both apply the same filter
213    /// the raw [`descramble`](Self::descramble) path already does
214    /// (`CiStack::build_ca_pmt_bytes`). Falls back to every `CA_descriptor`
215    /// unfiltered before any `ca_info` has been observed, matching that same
216    /// fallback.
217    fn build_ca_pmt_filtered(
218        &self,
219        pmt: &PmtSection<'_>,
220        list_management: CaPmtListManagement,
221        cmd_id: CaPmtCmdId,
222    ) -> dvb_ci::builder::CaPmtBuilt {
223        let cam_caids = self.managed.cam_caids();
224        if cam_caids.is_empty() {
225            build_ca_pmt(pmt, list_management, cmd_id)
226        } else {
227            let allowed: Vec<u16> = cam_caids.iter().copied().collect();
228            build_ca_pmt_for_caids(pmt, &allowed, list_management, cmd_id)
229        }
230    }
231
232    /// Build + send the `ca_pmt` for `pmt` (via
233    /// [`dvb_ci::builder::build_ca_pmt`], ETSI EN 50221 §8.4.3.4 Table 25) and
234    /// track it in the slot's managed active-service set (#763 Layer 1).
235    /// Additive alongside the raw [`send_ca_pmt`](Self::send_ca_pmt) and the
236    /// existing multi-programme API
237    /// ([`descramble_programs`](Self::descramble_programs)/
238    /// [`add_program`](Self::add_program)).
239    ///
240    /// `list_management` (EN 50221 Table 25) is auto-selected from the tracked
241    /// set: `Only` when this is the first service added to an empty managed
242    /// set, `Add` when joining an already-active set. (Contrast the raw
243    /// [`add_program`](Self::add_program), which always sends `Add` and leaves
244    /// list-management sequencing to the caller.)
245    ///
246    /// # Errors
247    /// [`CaError::NoCaDescriptor`] if `pmt` carries no `CA_descriptor`
248    /// (ETSI EN 300 468 §6.2.16, tag `0x09`) at programme or
249    /// elementary-stream level — there would be nothing for the CAM to
250    /// descramble. [`CaError::Io`] if sending the built `ca_pmt` fails.
251    pub fn add_service(&mut self, pmt: &PmtSection<'_>) -> Result<(), CaError> {
252        if !managed::pmt_has_ca(pmt) {
253            return Err(CaError::NoCaDescriptor {
254                program_number: pmt.program_number,
255            });
256        }
257        let list_management = if self.managed.is_empty() {
258            CaPmtListManagement::Only
259        } else {
260            CaPmtListManagement::Add
261        };
262        let cmd_id = CaPmtCmdId::OkDescrambling;
263        // r10-W-20: `try_to_bytes`, not `to_bytes` — a PMT whose descriptor
264        // loop is corrupted past the section end projects into a `ca_pmt`
265        // with no valid wire encoding; reject that as `Err` instead of
266        // panicking inside the serializer. Checked BEFORE recording, so a
267        // PMT whose ca_pmt cannot be encoded is never added to the managed
268        // set (the re-query timer would fail the same encoding forever).
269        let built_bytes = self
270            .build_ca_pmt_filtered(pmt, list_management, cmd_id)
271            .try_to_bytes()?;
272        // `PmtSection` has no raw-bytes accessor — re-serialize (byte-identical
273        // round-trip, a project invariant) to recover owned PMT bytes so
274        // `remove_service` (#763 Task 6) can later re-drive `remove_program`
275        // (which needs the raw section), and so `requery_tick` (#765) can
276        // rebuild a fresh `query`-variant `ca_pmt` against the *current*
277        // active set on every re-query tick rather than freezing
278        // `list_management` at this moment.
279        let mut pmt_raw = vec![0u8; pmt.serialized_len()];
280        let n = pmt.serialize_into(&mut pmt_raw)?;
281        pmt_raw.truncate(n);
282        self.send_ca_pmt(&built_bytes)?;
283        self.managed.record(
284            pmt.program_number,
285            managed::service_of(pmt, cmd_id, built_bytes, pmt_raw),
286        );
287        Ok(())
288    }
289
290    /// Stop descrambling a previously-added service (#763 Task 6): sends the
291    /// removal `ca_pmt` (`list_management = update`, `cmd_id = not_selected`,
292    /// EN 50221 §8.4.3.4 Table 25) via the existing
293    /// [`remove_program`](Self::remove_program) path — re-driving it with the
294    /// raw PMT bytes stashed at [`add_service`](Self::add_service) time — then
295    /// drops the service from the managed set.
296    ///
297    /// Removing a `program_number` that isn't currently tracked (never
298    /// `add_service`'d, or already removed) is a **no-op**, not an error:
299    /// [`CaError`] has no not-found arm, and `remove_service` is idempotent.
300    ///
301    /// # Errors
302    /// [`CaError::Io`] if sending the removal `ca_pmt` fails.
303    pub fn remove_service(&mut self, program_number: u16) -> Result<(), CaError> {
304        let raw = self
305            .managed
306            .services()
307            .get(&program_number)
308            .map(|s| s.pmt_raw.clone());
309        let Some(raw) = raw else {
310            return Ok(());
311        };
312        self.remove_program(&raw)?;
313        self.managed.remove(program_number);
314        Ok(())
315    }
316
317    /// Set the entitlement re-query cadence (#763 Task 5): every
318    /// `interval`, the driver re-sends each actively-managed service's
319    /// `ca_pmt` as a `list_management = update` pair — `cmd_id = query`
320    /// immediately followed by `cmd_id = ok_descrambling` — see
321    /// `requery_tick` for why both, #1032. `Duration::ZERO` disables
322    /// re-query. **Opt-in**: defaults to [`managed::REQUERY_DEFAULT`]
323    /// (`Duration::ZERO`) at construction — see that constant's doc for why
324    /// (#1032: not hardware-verified).
325    pub fn set_requery_interval(&mut self, interval: Duration) {
326        self.managed.set_requery_interval(interval);
327    }
328
329    /// Feed a freshly-parsed CAT (ISO/IEC 13818-1 §2.4.4.5) to the managed
330    /// CAS-layer state: extracts its `CA_descriptor`s (EN 300 468 §6.2.16,
331    /// CAID → EMM PID) and recomputes [`emm_pids`](Self::emm_pids) against
332    /// the CAM's advertised CAIDs (last `Notification::CaInfo`, captured
333    /// automatically as it arrives — see [`pump`](Self::pump)).
334    ///
335    /// Calling this before any `ca_info` has been observed is **not** an
336    /// error: [`emm_pids`](Self::emm_pids) stays empty until the CAM
337    /// advertises its CAIDs, then recomputes against the CAT stored here —
338    /// `set_cat` need not be re-called once `ca_info` arrives.
339    ///
340    /// # Errors
341    /// [`CaError::Cat`] if the CAT's descriptor loop carries a truncated
342    /// `CA_descriptor`.
343    pub fn set_cat(&mut self, cat: &CatSection<'_>) -> Result<(), CaError> {
344        let entries = cat.ca_descriptors().map_err(CaError::Cat)?;
345        self.managed.set_cat(&entries);
346        Ok(())
347    }
348
349    /// The EMM PIDs to route into `ci0` — the last [`set_cat`](Self::set_cat)'s
350    /// CAID → EMM-PID map intersected with the CAM's advertised CAIDs (#763
351    /// Task 4).
352    #[must_use]
353    pub fn emm_pids(&self) -> &[u16] {
354        self.managed.emm_pids()
355    }
356
357    /// The PIDs to route into `ci0` for descrambling — the union of every
358    /// actively-managed service's elementary-stream PIDs (#763 Task 4).
359    #[must_use]
360    pub fn descramble_pids(&self) -> &[u16] {
361        self.managed.descramble_pids()
362    }
363
364    /// The union of every actively-managed service's `CA_PID`s (ECM PIDs —
365    /// ISO/IEC 13818-1 §2.6.16 `CA_descriptor` `CA_PID`, programme + ES level
366    /// combined) — the control-word channel, without which the module has ES
367    /// to descramble but no control words to do it with (#763 Task 7).
368    #[must_use]
369    pub fn ca_pids(&self) -> &[u16] {
370        self.managed.ca_pids()
371    }
372
373    /// `descramble_pids() ∪ ca_pids() ∪ emm_pids() ∪ PCR` — every PID class
374    /// this slot needs on `ci0` (ES to descramble ∪ ECM for control words ∪
375    /// EMM for entitlements ∪ each active service's PCR PID — ISO/IEC
376    /// 13818-1 §2.4.4.8 — so the descrambled TS keeps its clock reference
377    /// even when the PCR rides a dedicated PID). #763 Task 7's turnkey
378    /// [`CaDescrambler`](crate::descrambler::CaDescrambler) filters its
379    /// `feed_ts` input to exactly this set.
380    #[must_use]
381    pub fn required_pids(&self) -> Vec<u16> {
382        self.managed.required_pids()
383    }
384
385    /// Answer an MMI menu/list by 1-based `choice_ref` (0 = back/cancel).
386    pub fn mmi_menu_answer(&mut self, choice_ref: u8) -> io::Result<()> {
387        let actions = self
388            .stack
389            .handle(Event::Host(HostRequest::MmiMenuAnswer(choice_ref)));
390        self.run(actions)
391    }
392
393    /// Answer an MMI enquiry with the user's input (EN 300 468 Annex A bytes).
394    pub fn mmi_enquiry_answer(&mut self, text: &[u8]) -> io::Result<()> {
395        let actions = self
396            .stack
397            .handle(Event::Host(HostRequest::MmiEnquiryAnswer(text)));
398        self.run(actions)
399    }
400
401    /// Abort the current MMI dialogue (`answ` with `answ_id = cancel`).
402    pub fn mmi_cancel(&mut self) -> io::Result<()> {
403        let actions = self.stack.handle(Event::Host(HostRequest::MmiCancel));
404        self.run(actions)
405    }
406
407    /// Ask the module to open its MMI menu (`enter_menu`) — e.g. to read card /
408    /// entitlement info from the module's own menus.
409    pub fn enter_menu(&mut self) -> io::Result<()> {
410        let actions = self.stack.handle(Event::Host(HostRequest::EnterMenu));
411        self.run(actions)
412    }
413
414    /// One pump step: if the device is readable within `timeout`, read a frame
415    /// and feed it; otherwise advance the stack's timers by `timeout` (driving
416    /// the poll cadence). Returns whether a frame was processed.
417    ///
418    /// Also samples [`SlotInfo`] once per call (the DVB-CA slot has no
419    /// interrupt/event of its own; `CA_GET_SLOT_INFO` is a poll) so a hot-plug
420    /// edge is caught between reads — see [`Notification::HotPlug`] carrying
421    /// [`HotPlug::CamPresent`]/[`CamRemoved`](HotPlug::CamRemoved) (#726).
422    pub fn pump(&mut self, timeout: Duration) -> io::Result<bool> {
423        // r10-W-21: advance the stack's timers by the REAL wall-clock time
424        // since the previous call, not by `timeout` — `timeout` is only how
425        // long THIS call's `poll` was willing to wait; the actual elapsed
426        // time can be far less (poll returns as soon as the device is
427        // readable, or a signal interrupts it early) or far more (the
428        // caller's own loop didn't call `pump` again promptly). Using
429        // `timeout` as if it were the measurement let the reply-timeout/poll
430        // cadence drift out of sync with real time in either direction.
431        // `None` (first call) has no previous point to measure from, so
432        // `timeout` is used as a reasonable initial estimate, same as before.
433        let now = (self.clock)();
434        let elapsed = self
435            .last_pump
436            .map_or(timeout, |prev| now.saturating_duration_since(prev));
437        self.last_pump = Some(now);
438
439        self.run(vec![Action::QuerySlot])?;
440        if self.device.poll(timeout)? {
441            let n = self.device.read(&mut self.buf)?;
442            if n > 0 {
443                // `stack` and `buf` are disjoint fields: borrow the received
444                // bytes in place instead of copying each frame (audit
445                // r10-O-11).
446                let actions = self.stack.handle(Event::Readable(&self.buf[..n]));
447                self.run(actions)?;
448                return Ok(true);
449            }
450        }
451        let actions = self.stack.handle(Event::Tick { elapsed });
452        self.run(actions)?;
453        self.requery_tick(elapsed)?;
454        Ok(false)
455    }
456
457    /// Advance the #763 Task 5 entitlement re-query cadence by `elapsed`
458    /// ([`ManagedCa::tick`](crate::managed::ManagedCa::tick), mirroring
459    /// `resource.rs`'s `DateTime::tick` accumulate-then-fire pattern). When
460    /// the interval elapses, rebuild and re-send, for every actively-managed
461    /// service, a `list_management = update` **pair**:
462    ///
463    /// 1. `cmd_id = query` (EN 50221 §8.4.3.5: "host expects a CA PMT Reply;
464    ///    application not allowed to start descrambling or MMI before a new
465    ///    CA PMT with `ok_descrambling`/`ok_mmi`") — solicits a fresh
466    ///    `ca_pmt_reply` from a CAM that answers queries, surfacing as
467    ///    [`Notification::CaPmtReply`] and, on a status change,
468    ///    [`Notification::Entitlement`].
469    /// 2. `cmd_id = ok_descrambling` (§8.4.3.5: "host expects no answer; the
470    ///    application may start descrambling ... immediately"), sent
471    ///    unconditionally right after — so descrambling is never left barred
472    ///    by the query above, whether or not the CAM answers it.
473    ///
474    /// **#1032 fix**: the pre-fix code sent only `only`/`first` + `query`.
475    /// EN 50221 §8.4.3.4 Table 25's `first`/`only` *replaces* every
476    /// previously-selected programme, and §8.4.3.5's `query` bars
477    /// descrambling until an `ok_descrambling` that was never sent — so
478    /// descrambling silently stopped ~`interval` after it started. `update`
479    /// ("the CA PMT of a programme already in the list is sent again ...
480    /// List management commands act only at programme level", §8.4.3.4)
481    /// never replaces the list, and the unconditional follow-up
482    /// `ok_descrambling` guarantees the bar is always lifted even though
483    /// `stack.rs`'s `descramble` already documents that a live
484    /// AlphaCrypt/Irdeto module never answers `query` at all — this is why
485    /// [`managed::REQUERY_DEFAULT`] is `Duration::ZERO`: the feature is
486    /// opt-in until the `query` half is verified to elicit a reply on a
487    /// caller's own hardware.
488    ///
489    /// **#765 (retained)**: the re-query set is rebuilt fresh from each
490    /// service's stored `pmt_raw` on *every* tick against the *current*
491    /// active set (`services`, a `BTreeMap`) — a removed service is not
492    /// resent, and a survivor is rebuilt from its own current PMT — rather
493    /// than freezing anything at `add_service` time. `update` needs no
494    /// first/more/last position bookkeeping (§8.4.3.4: "act only at
495    /// programme level"), so unlike the pre-fix code this no longer depends
496    /// on the active set's size or a service's position within it.
497    ///
498    /// CAID-filtered via [`build_ca_pmt_filtered`](Self::build_ca_pmt_filtered)
499    /// (#1067), same as [`add_service`](Self::add_service).
500    fn requery_tick(&mut self, elapsed: Duration) -> io::Result<()> {
501        // r10-W-20: a previously-recorded rejection is reported FIRST, on
502        // every pump until the caller fixes its state — never swallowed by
503        // an earlier `tick` return.
504        if let Some(err) = self.requery_pending.take() {
505            return Err(io::Error::other(err));
506        }
507        if !self.managed.tick(elapsed) {
508            return Ok(());
509        }
510        let mut ca_pmts: Vec<(Vec<u8>, Vec<u8>)> =
511            Vec::with_capacity(self.managed.services().len());
512        for s in self.managed.services().values() {
513            // r10-W-20: no `expect`/`to_bytes` here — `pmt_raw` is only
514            // as trustworthy as whatever the caller's CAM (or a
515            // corrupted capture) supplied, so a re-parse or re-serialize
516            // failure must end this re-query as `Err`, not a panic.
517            let outcome: Result<(Vec<u8>, Vec<u8>), CaError> = (|| {
518                let pmt = PmtSection::parse(&s.pmt_raw).map_err(CaError::PmtParse)?;
519                let query = self
520                    .build_ca_pmt_filtered(&pmt, CaPmtListManagement::Update, CaPmtCmdId::Query)
521                    .try_to_bytes()?;
522                let ok_descrambling = self
523                    .build_ca_pmt_filtered(
524                        &pmt,
525                        CaPmtListManagement::Update,
526                        CaPmtCmdId::OkDescrambling,
527                    )
528                    .try_to_bytes()?;
529                Ok((query, ok_descrambling))
530            })();
531            match outcome {
532                Ok(pair) => ca_pmts.push(pair),
533                Err(err) => {
534                    // One corrupt (or CAM-supplied) service must not poison
535                    // the healthy services' resend this tick: record it and
536                    // return the failure after the good pair(s) went out.
537                    self.requery_pending = Some(err);
538                }
539            }
540        }
541        for (query, ok_descrambling) in ca_pmts {
542            self.send_ca_pmt(&query)?;
543            self.send_ca_pmt(&ok_descrambling)?;
544        }
545        if let Some(err) = self.requery_pending.take() {
546            return Err(io::Error::other(err));
547        }
548        Ok(())
549    }
550
551    /// Pump once ([`pump`](Self::pump)), then invoke `handler` for each
552    /// [`Notification`] produced this cycle (drain-and-dispatch via
553    /// [`take_notifications`](Self::take_notifications)). Returns the same
554    /// bool as `pump`. The closure is per-call — nothing is stored, so there
555    /// are no lifetime constraints beyond the call itself. This crate is
556    /// sync/sans-IO (no channels/async runtime), so a closure callback is the
557    /// idiomatic push-style alternative to poll-draining `take_notifications`
558    /// yourself.
559    pub fn pump_with<F: FnMut(&Notification)>(
560        &mut self,
561        timeout: Duration,
562        mut handler: F,
563    ) -> io::Result<bool> {
564        let progressed = self.pump(timeout)?;
565        for n in self.take_notifications() {
566            handler(&n);
567        }
568        Ok(progressed)
569    }
570
571    /// Convenience over [`pump_with`](Self::pump_with): invoke `handler` only
572    /// for [`HotPlug`] transitions, ignoring every other [`Notification`]
573    /// produced this cycle.
574    pub fn pump_hotplug<F: FnMut(HotPlug)>(
575        &mut self,
576        timeout: Duration,
577        mut handler: F,
578    ) -> io::Result<bool> {
579        self.pump_with(timeout, |n| {
580            if let Some(h) = n.hotplug() {
581                handler(h);
582            }
583        })
584    }
585
586    /// Execute the stack's actions against the device.
587    fn run(&mut self, actions: Vec<Action>) -> io::Result<()> {
588        for action in actions {
589            match action {
590                Action::Write(bytes) => self.device.write(&bytes)?,
591                Action::Reset => self.device.reset()?,
592                Action::QuerySlot => {
593                    let info = self.device.slot_info()?;
594                    self.handle_slot_info(info)?;
595                }
596                Action::SetTimer { after } => self.next_timer = Some(after),
597                Action::Notify(n) => {
598                    let inferred = self.infer_card(&n);
599                    self.notifications.push(n);
600                    self.notifications.extend(inferred);
601                }
602            }
603        }
604        Ok(())
605    }
606
607    /// Compare a freshly-queried [`SlotInfo`] against the last one observed
608    /// and react to a `module_present` edge (Part A, #726): the *first*
609    /// observation ever (`self.last_slot == None`) only establishes the
610    /// baseline — it must not fire a notification, or `Driver::init` against
611    /// an already-inserted module would spuriously report a hot-plug and
612    /// recurse into re-driving its own in-progress handshake.
613    fn handle_slot_info(&mut self, info: SlotInfo) -> io::Result<()> {
614        let prev = self.last_slot.replace(info);
615        match prev {
616            Some(prev) if !prev.module_present && info.module_present => {
617                self.notifications
618                    .push(Notification::HotPlug(HotPlug::CamPresent));
619                self.reset_module_state();
620                // Re-drive the same reset/init path `Driver::init` uses, so
621                // the newly-inserted module gets a clean resource-manager
622                // handshake (no duplicated handshake logic).
623                let actions = self.stack.handle(Event::Host(HostRequest::Init));
624                self.run(actions)?;
625            }
626            Some(prev) if prev.module_present && !info.module_present => {
627                self.notifications
628                    .push(Notification::HotPlug(HotPlug::CamRemoved));
629                self.reset_module_state();
630            }
631            _ => {}
632        }
633        Ok(())
634    }
635
636    /// Reset per-module protocol + card-inference state after a CAM
637    /// insert/remove edge: a fresh [`CiStack`] (so a re-insert re-handshakes
638    /// cleanly instead of reusing stale session numbers) and cleared Part B
639    /// baselines (so the next module's `ca_info`/`ca_pmt_reply` establishes
640    /// its own fresh baseline rather than diffing against the departed
641    /// module's), AND the managed CAS-layer state (#763 Task 6 fix): a stale
642    /// `services`/`descramble_pids`/`emm_pids` set must not survive a
643    /// departed or freshly-inserted module — the host must re-provision from
644    /// scratch (the next `add_service` then correctly picks `Only` again).
645    fn reset_module_state(&mut self) {
646        self.stack = CiStack::new();
647        self.next_timer = None;
648        self.last_caids = None;
649        self.last_descrambling_ok = None;
650        self.last_mmi_card_present = None;
651        self.managed.clear();
652    }
653
654    /// Best-effort app-layer card-presence inference (Part B, #726): EN 50221
655    /// CI slots are module-level only — there is no card-detect line (verified
656    /// against real DD ddbridge / cxd2099 driver behaviour) — so this derives
657    /// card insert/remove/change from signals the module already sends for
658    /// other reasons. Returns any inferred [`Notification`]s (0 or 1); `note`
659    /// itself is pushed by the caller.
660    fn infer_card(&mut self, note: &Notification) -> Vec<Notification> {
661        match note {
662            Notification::CaInfo { ca_system_ids } => {
663                let new_set: BTreeSet<u16> = ca_system_ids.iter().copied().collect();
664                let mut out = Vec::new();
665                if let Some(prev) = &self.last_caids {
666                    if prev.is_empty() && !new_set.is_empty() {
667                        out.push(Notification::HotPlug(HotPlug::CardInserted));
668                    } else if !prev.is_empty() && new_set.is_empty() {
669                        out.push(Notification::HotPlug(HotPlug::CardRemoved));
670                    } else if !prev.is_empty() && !new_set.is_empty() && *prev != new_set {
671                        out.push(Notification::HotPlug(HotPlug::CardChanged));
672                    }
673                }
674                // #763 Task 4: feed the CAM's advertised CAIDs to the managed
675                // CAS-layer state so `emm_pids` recomputes against the
676                // already-stored CAT map (if `set_cat` ran first).
677                self.managed.set_cam_caids(new_set.clone());
678                self.last_caids = Some(new_set);
679                out
680            }
681            Notification::CaPmtReply {
682                program_number,
683                ca_enable,
684                descrambling_ok,
685            } => {
686                let mut out = Vec::new();
687                if let Some(prev) = self.last_descrambling_ok {
688                    if !prev && *descrambling_ok {
689                        out.push(Notification::HotPlug(HotPlug::CardInserted));
690                    } else if prev && !*descrambling_ok {
691                        out.push(Notification::HotPlug(HotPlug::CardRemoved));
692                    }
693                }
694                self.last_descrambling_ok = Some(*descrambling_ok);
695                // #763 Task 5: diff this reply's programme-level status
696                // against the last one recorded for `program_number` and
697                // surface the edge-triggered `Notification::Entitlement`.
698                if let Some((v, ok)) =
699                    self.managed
700                        .record_reply(*program_number, *ca_enable, *descrambling_ok)
701                {
702                    out.push(Notification::Entitlement {
703                        program_number: *program_number,
704                        ca_enable: v,
705                        descrambling_ok: ok,
706                    });
707                }
708                out
709            }
710            Notification::Mmi(ev) => {
711                let present = Self::mmi_text(ev).and_then(|text| {
712                    let lower = text.to_lowercase();
713                    if MMI_CARD_ABSENT_KEYWORDS.iter().any(|k| lower.contains(k)) {
714                        Some(false)
715                    } else if MMI_CARD_PRESENT_KEYWORDS.iter().any(|k| lower.contains(k)) {
716                        Some(true)
717                    } else {
718                        None
719                    }
720                });
721                match present {
722                    // Edge only: the same state repeated is not news.
723                    Some(p) if self.last_mmi_card_present != Some(p) => {
724                        self.last_mmi_card_present = Some(p);
725                        vec![Notification::HotPlug(if p {
726                            HotPlug::CardInserted
727                        } else {
728                            HotPlug::CardRemoved
729                        })]
730                    }
731                    _ => Vec::new(),
732                }
733            }
734            _ => Vec::new(),
735        }
736    }
737
738    /// The free-text an [`MmiEvent`] carries, for the keyword heuristic above
739    /// (title/subtitle/bottom/choices for a menu/list, the prompt for an
740    /// enquiry; a `Close` carries no text).
741    fn mmi_text(ev: &MmiEvent) -> Option<String> {
742        match ev {
743            MmiEvent::Menu(m) | MmiEvent::List(m) => {
744                let mut s = format!("{} {} {}", m.title, m.subtitle, m.bottom);
745                for choice in &m.choices {
746                    s.push(' ');
747                    s.push_str(choice);
748                }
749                Some(s)
750            }
751            MmiEvent::Enquiry { prompt, .. } => Some(prompt.clone()),
752            MmiEvent::Close => None,
753        }
754    }
755}
756
757#[cfg(test)]
758pub(crate) mod tests {
759    use super::*;
760    use crate::device::{DeviceOp, MockCaDevice};
761    use crate::event::{HostControlEvent, HotPlug, Notification};
762    use broadcast_common::Serialize;
763    use dvb_ci::tpdu::tags;
764
765    pub(crate) fn ser<S: Serialize>(s: &S) -> Vec<u8> {
766        let mut b = vec![0u8; s.serialized_len()];
767        match s.serialize_into(&mut b) {
768            Ok(n) => b.truncate(n),
769            Err(_) => b.clear(),
770        }
771        b
772    }
773
774    /// A manually-advanced fake clock for [`Driver::with_clock`] (r10-W-21):
775    /// `pump`'s elapsed-time measurement is now real wall-clock time, so a
776    /// test that wants to simulate "N of simulated time passed" in one
777    /// `pump` call (rather than actually sleeping) advances this by that
778    /// same `N` immediately before the call.
779    #[derive(Clone)]
780    pub(crate) struct TestClock(std::rc::Rc<std::cell::Cell<Instant>>);
781
782    impl TestClock {
783        pub(crate) fn new() -> Self {
784            Self(std::rc::Rc::new(std::cell::Cell::new(Instant::now())))
785        }
786
787        /// Advance the simulated clock by `d`.
788        pub(crate) fn advance(&self, d: Duration) {
789            self.0.set(self.0.get() + d);
790        }
791
792        /// The `Fn() -> Instant` closure to pass to [`Driver::with_clock`].
793        pub(crate) fn as_fn(&self) -> impl Fn() -> Instant + 'static {
794            let cell = self.0.clone();
795            move || cell.get()
796        }
797    }
798
799    /// Wrap an SPDU as a module→host `T_Data_Last` R_TPDU (+ trailing T_SB,
800    /// data_available clear) on transport connection `tcid`.
801    fn r_data(tcid: u8, spdu: &[u8]) -> Vec<u8> {
802        use dvb_ci::tpdu::{SbValue, tags as tpdu_tags};
803        let mut v = vec![tpdu_tags::DATA_LAST, (1 + spdu.len()) as u8, tcid];
804        v.extend_from_slice(spdu);
805        v.extend_from_slice(&[tpdu_tags::SB, 0x02, tcid, SbValue::new(false).0]);
806        v
807    }
808
809    /// Wrap an APDU for delivery on `session_nb` (session_number prefix), then as
810    /// a module→host R_TPDU on tcid 1.
811    pub(crate) fn r_apdu(session_nb: u16, apdu: &[u8]) -> Vec<u8> {
812        use dvb_ci::spdu::SessionNumber;
813        let mut spdu = ser(&SessionNumber { session_nb });
814        spdu.extend_from_slice(apdu);
815        r_data(1, &spdu)
816    }
817
818    /// A standalone module→host `T_SB` (data_available clear) ack — flushes one
819    /// queued host write per turn (#337).
820    pub(crate) fn sb() -> Vec<u8> {
821        use dvb_ci::tpdu::{SbValue, tags as tpdu_tags};
822        vec![tpdu_tags::SB, 0x02, 0x01, SbValue::new(false).0]
823    }
824
825    /// Feed one scripted module frame into the mock and pump it, then pump a
826    /// handful of SB acks so any queued host writes flush.
827    pub(crate) fn feed(d: &mut Driver<MockCaDevice>, frame: Vec<u8>) {
828        d.device_mut().inbound.push_back(frame);
829        d.pump(Duration::from_millis(10)).unwrap();
830        for _ in 0..8 {
831            d.device_mut().inbound.push_back(sb());
832            d.pump(Duration::from_millis(10)).unwrap();
833        }
834    }
835
836    /// Drive the EN 50221 handshake through the `Driver` until host_control and
837    /// the other module-provided sessions are open (mirrors the stack-level
838    /// `stack_with_ca_session`, but exercises the real driver I/O path).
839    pub(crate) fn driver_with_sessions() -> Driver<MockCaDevice> {
840        use dvb_ci::objects::resource_manager::Profile;
841        use dvb_ci::resource::{
842            APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, HOST_CONTROL, MMI,
843            RESOURCE_MANAGER,
844        };
845        use dvb_ci::spdu::{CreateSessionResponse, OpenSessionRequest, SessionStatus};
846
847        let mut d = Driver::new(MockCaDevice::new([]));
848        d.init().unwrap();
849        // module accepts the transport connection
850        feed(&mut d, vec![tags::C_T_C_REPLY, 0x01, 0x01]);
851        // module opens the host's resource_manager → RM session 1
852        feed(
853            &mut d,
854            r_data(
855                1,
856                &ser(&OpenSessionRequest {
857                    resource: RESOURCE_MANAGER,
858                }),
859            ),
860        );
861        // module's profile → host: CamReady + profile_change + create_session for
862        // each module-provided resource.
863        feed(
864            &mut d,
865            r_apdu(
866                1,
867                &ser(&Profile {
868                    resources: vec![
869                        APPLICATION_INFORMATION,
870                        CONDITIONAL_ACCESS_SUPPORT,
871                        MMI,
872                        HOST_CONTROL,
873                    ],
874                }),
875            ),
876        );
877        // module accepts each create_session (session nbs 2..=5 in registration order)
878        for (nb, res) in [
879            (2u16, APPLICATION_INFORMATION),
880            (3, CONDITIONAL_ACCESS_SUPPORT),
881            (4, MMI),
882            (5, HOST_CONTROL),
883        ] {
884            feed(
885                &mut d,
886                r_data(
887                    1,
888                    &ser(&CreateSessionResponse {
889                        status: SessionStatus::Ok,
890                        resource: res,
891                        session_nb: nb,
892                    }),
893                ),
894            );
895        }
896        d
897    }
898
899    // Session numbers the module allocates in `driver_with_sessions`, in
900    // registration order: RM=1, app_info=2, conditional_access=3, mmi=4,
901    // host_control=5. (Asserted by `handshake_opens_expected_sessions`.)
902    const RM_SESSION: u16 = 1;
903    pub(crate) const CA_SESSION: u16 = 3;
904    const MMI_SESSION: u16 = 4;
905    const HOST_CONTROL_SESSION: u16 = 5;
906
907    #[test]
908    fn host_control_tune_apdu_surfaces_notification_via_driver() {
909        use dvb_ci::objects::host_control::Tune;
910
911        let mut d = driver_with_sessions();
912        let hc_nb = HOST_CONTROL_SESSION;
913        d.take_notifications(); // drop handshake notifications
914
915        // Module (CAM) sends a Tune request on its host_control session.
916        let tune = Tune {
917            network_id: 0x1122,
918            original_network_id: 0x3344,
919            transport_stream_id: 0x5566,
920            service_id: 0x7788,
921        };
922        feed(&mut d, r_apdu(hc_nb, &ser(&tune)));
923
924        // The runtime surfaces the decoded HostControl(Tune) notification.
925        let notes = d.take_notifications();
926        assert!(
927            notes.contains(&Notification::HostControl(HostControlEvent::Tune {
928                network_id: 0x1122,
929                original_network_id: 0x3344,
930                transport_stream_id: 0x5566,
931                service_id: 0x7788,
932            })),
933            "expected HostControl(Tune) notification, got {notes:?}"
934        );
935    }
936
937    /// A padded `tune` from the CAM is refused by dvb-ci's strict parser; the
938    /// runtime must surface that as `Notification::Error`, not drop it.
939    #[test]
940    fn padded_tune_from_the_cam_surfaces_an_error_notification() {
941        let mut d = driver_with_sessions();
942        d.take_notifications();
943        // Tune: 9F 84 00, length 9 (one byte over the fixed 8), then 9 bytes.
944        let padded = [
945            0x9F, 0x84, 0x00, 0x09, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0xEE,
946        ];
947        feed(&mut d, r_apdu(HOST_CONTROL_SESSION, &padded));
948        let notes = d.take_notifications();
949        assert!(
950            notes.iter().any(
951                |n| matches!(n, Notification::Error { detail } if detail.contains("trailing bytes"))
952            ),
953            "expected an Error notification naming the trailing bytes, got {notes:?}"
954        );
955        assert!(
956            !notes
957                .iter()
958                .any(|n| matches!(n, Notification::HostControl(_))),
959            "a refused tune must not also surface as a HostControl event: {notes:?}"
960        );
961    }
962
963    /// A malformed APDU on other resources is reported too (MMI `enq` with a
964    /// truncated body here), not silently ignored.
965    #[test]
966    fn malformed_mmi_apdu_from_the_cam_surfaces_an_error_notification() {
967        let mut d = driver_with_sessions();
968        d.take_notifications();
969        // enq (9F 88 07) with a 1-byte body: shorter than its fixed prefix.
970        feed(&mut d, r_apdu(MMI_SESSION, &[0x9F, 0x88, 0x07, 0x01, 0x00]));
971        let notes = d.take_notifications();
972        assert!(
973            notes
974                .iter()
975                .any(|n| matches!(n, Notification::Error { .. })),
976            "expected an Error notification, got {notes:?}"
977        );
978    }
979
980    #[test]
981    fn profile_reply_advertises_host_control() {
982        use broadcast_common::Parse;
983        use dvb_ci::objects::resource_manager::{Profile, ProfileEnq};
984        use dvb_ci::resource::{HOST_CONTROL, RESOURCE_MANAGER};
985
986        let mut d = Driver::new(MockCaDevice::new([]));
987        d.init().unwrap();
988        feed(&mut d, vec![tags::C_T_C_REPLY, 0x01, 0x01]);
989        // Open RM, then the module enquires the host profile.
990        feed(
991            &mut d,
992            r_data(
993                1,
994                &ser(&dvb_ci::spdu::OpenSessionRequest {
995                    resource: RESOURCE_MANAGER,
996                }),
997            ),
998        );
999        // Module → profile_enq on the RM session → host replies with its profile.
1000        feed(&mut d, r_apdu(RM_SESSION, &ser(&ProfileEnq)));
1001
1002        // Find the host's `profile` reply (tag 9F 80 11) in the written frames and
1003        // confirm it lists HOST_CONTROL.
1004        let want = dvb_ci::tag::PROFILE.to_bytes();
1005        let found = d.device().ops.iter().any(|op| {
1006            if let DeviceOp::Write(w) = op
1007                && let Some(pos) = w.windows(3).position(|x| x == want)
1008                && let Ok(p) = Profile::parse(&w[pos..])
1009            {
1010                return p.resources.contains(&HOST_CONTROL);
1011            }
1012            false
1013        });
1014        assert!(found, "profile reply must advertise HOST_CONTROL");
1015    }
1016
1017    #[test]
1018    fn mmi_menu_answ_and_answ_are_byte_exact_on_the_mmi_session() {
1019        use dvb_ci::objects::mmi_high::{Answ, AnswId, MenuAnsw};
1020
1021        let mut d = driver_with_sessions();
1022        let mmi_nb = MMI_SESSION;
1023
1024        // menu_answ(choice_ref = 2): the driver method must put the exact dvb-ci
1025        // MenuAnsw serialization on the wire, on the MMI session.
1026        d.mmi_menu_answer(2).unwrap();
1027        d.device_mut().inbound.push_back(sb());
1028        d.pump(Duration::from_millis(10)).unwrap();
1029        assert_apdu_on_session(&d, mmi_nb, &ser(&MenuAnsw { choice_ref: 2 }));
1030
1031        // answ(answer, "1234"): byte-exact Answ serialization on the MMI session.
1032        d.mmi_enquiry_answer(b"1234").unwrap();
1033        d.device_mut().inbound.push_back(sb());
1034        d.pump(Duration::from_millis(10)).unwrap();
1035        assert_apdu_on_session(
1036            &d,
1037            mmi_nb,
1038            &ser(&Answ {
1039                answ_id: AnswId::Answer,
1040                text_chars: b"1234",
1041            }),
1042        );
1043    }
1044
1045    /// Assert some host write carries `session_number(session_nb)` immediately
1046    /// followed by the exact `apdu` bytes (byte-exact APDU on the right session).
1047    fn assert_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) {
1048        use dvb_ci::spdu::SessionNumber;
1049        let mut want = ser(&SessionNumber { session_nb });
1050        want.extend_from_slice(apdu);
1051        let hit = d.device().ops.iter().any(|op| match op {
1052            DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
1053            _ => false,
1054        });
1055        assert!(
1056            hit,
1057            "expected APDU {apdu:02X?} on session {session_nb} (session-prefixed {want:02X?}) in writes"
1058        );
1059    }
1060
1061    /// How many host writes carry `session_number(session_nb)` immediately
1062    /// followed by the exact `apdu` bytes — used to distinguish an initial
1063    /// send from a later re-send (#763 Task 5's re-query timer).
1064    fn count_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) -> usize {
1065        use dvb_ci::spdu::SessionNumber;
1066        let mut want = ser(&SessionNumber { session_nb });
1067        want.extend_from_slice(apdu);
1068        d.device()
1069            .ops
1070            .iter()
1071            .filter(|op| match op {
1072                DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
1073                _ => false,
1074            })
1075            .count()
1076    }
1077
1078    #[test]
1079    fn init_drives_reset_slotinfo_and_create_tc_to_device() {
1080        let mut d = Driver::new(MockCaDevice::new([]));
1081        d.init().unwrap();
1082        let ops = &d.device().ops;
1083        assert_eq!(ops[0], DeviceOp::Reset);
1084        assert_eq!(ops[1], DeviceOp::SlotInfo);
1085        assert!(matches!(&ops[2], DeviceOp::Write(w) if w[0] == tags::CREATE_T_C));
1086    }
1087
1088    #[test]
1089    fn reads_reply_then_polls_on_pump() {
1090        // Script the module accepting the connection.
1091        let dev = MockCaDevice::new([vec![tags::C_T_C_REPLY, 0x01, 0x01]]);
1092        let clock = TestClock::new();
1093        let mut d = Driver::new(dev).with_clock(clock.as_fn());
1094        d.init().unwrap();
1095        // first pump reads the C_T_C_Reply (activates the connection)
1096        assert!(d.pump(Duration::from_millis(100)).unwrap());
1097        // r10-W-21: `pump` now measures real (simulated, via TestClock)
1098        // elapsed time rather than trusting the `timeout` argument, so the
1099        // test advances the clock by the same amount `timeout` used to
1100        // stand in for.
1101        clock.advance(Duration::from_millis(100));
1102        // next pump has nothing to read → ticks → emits a poll write
1103        assert!(!d.pump(Duration::from_millis(100)).unwrap());
1104        let last = d.device().ops.last().unwrap();
1105        assert!(matches!(last, DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)));
1106    }
1107
1108    /// r10-W-21: `pump`'s stack-clock advance must reflect real (here,
1109    /// simulated) elapsed time, not the `timeout` argument on its own —
1110    /// pre-fix, calling `pump(1s)` twice back-to-back with almost no real
1111    /// time between them still advanced the stack's clock by a full second
1112    /// each time, exactly as if a full second really had passed.
1113    #[test]
1114    fn pump_advances_the_stack_clock_by_real_elapsed_time_not_by_timeout() {
1115        let dev = MockCaDevice::new([vec![tags::C_T_C_REPLY, 0x01, 0x01]]);
1116        let clock = TestClock::new();
1117        let mut d = Driver::new(dev).with_clock(clock.as_fn());
1118        d.init().unwrap();
1119        assert!(d.pump(Duration::from_secs(1)).unwrap());
1120
1121        // Only 1ms of (simulated) real time actually passed, even though
1122        // `timeout` claims a full second — nowhere near the 100ms poll
1123        // cadence, so no poll write should go out.
1124        clock.advance(Duration::from_millis(1));
1125        assert!(!d.pump(Duration::from_secs(1)).unwrap());
1126        assert!(
1127            d.device().ops.last().is_none_or(|op| !matches!(
1128                op,
1129                DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)
1130            )),
1131            "must not poll after only 1ms of real elapsed time, regardless of the 1s `timeout` argument"
1132        );
1133
1134        // Now really cross the poll interval.
1135        clock.advance(Duration::from_millis(100));
1136        assert!(!d.pump(Duration::from_secs(1)).unwrap());
1137        let last = d.device().ops.last().unwrap();
1138        assert!(matches!(last, DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)));
1139    }
1140
1141    // --- #726: CAM + card hot-plug notifications ---
1142
1143    #[test]
1144    fn cam_insert_edge_emits_cam_present_once_and_redrives_handshake() {
1145        let mut dev = MockCaDevice::new([]);
1146        dev.slot = SlotInfo {
1147            num: 0,
1148            module_ready: false,
1149            module_present: false,
1150        };
1151        let mut d = Driver::new(dev);
1152        d.init().unwrap();
1153        // The first-ever slot observation only establishes the baseline
1154        // (absent) — it must not itself claim a hot-plug edge.
1155        let notes = d.take_notifications();
1156        assert!(
1157            !notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
1158            "baseline observation must not fire CamPresent, got {notes:?}"
1159        );
1160        let resets_before = d
1161            .device()
1162            .ops
1163            .iter()
1164            .filter(|o| **o == DeviceOp::Reset)
1165            .count();
1166
1167        // Module physically inserted and ready.
1168        d.device_mut().slot = SlotInfo {
1169            num: 0,
1170            module_ready: true,
1171            module_present: true,
1172        };
1173        d.pump(Duration::from_millis(10)).unwrap();
1174
1175        let notes = d.take_notifications();
1176        let cam_present_count = notes
1177            .iter()
1178            .filter(|n| **n == Notification::HotPlug(HotPlug::CamPresent))
1179            .count();
1180        assert_eq!(
1181            cam_present_count, 1,
1182            "expected exactly one CamPresent, got {notes:?}"
1183        );
1184        // Handshake re-driven: a fresh Reset, and the last write is CREATE_T_C.
1185        let resets_after = d
1186            .device()
1187            .ops
1188            .iter()
1189            .filter(|o| **o == DeviceOp::Reset)
1190            .count();
1191        assert_eq!(
1192            resets_after,
1193            resets_before + 1,
1194            "expected one fresh Reset on re-insert"
1195        );
1196        assert!(
1197            matches!(d.device().ops.last(), Some(DeviceOp::Write(w)) if w[0] == tags::CREATE_T_C),
1198            "expected the handshake re-driven (CREATE_T_C written), got {:?}",
1199            d.device().ops.last()
1200        );
1201    }
1202
1203    #[test]
1204    fn cam_remove_edge_emits_cam_removed_and_re_insert_re_handshakes() {
1205        let mut d = driver_with_sessions();
1206        d.take_notifications();
1207
1208        // Module physically removed.
1209        d.device_mut().slot.module_present = false;
1210        d.pump(Duration::from_millis(10)).unwrap();
1211        let notes = d.take_notifications();
1212        assert!(
1213            notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
1214            "expected CamRemoved, got {notes:?}"
1215        );
1216
1217        // Session state was torn down: the MMI session from
1218        // `driver_with_sessions` no longer exists on the fresh stack, so an
1219        // answer to it now errors instead of silently going nowhere.
1220        d.mmi_menu_answer(0).unwrap();
1221        let notes = d.take_notifications();
1222        assert!(
1223            notes
1224                .iter()
1225                .any(|n| matches!(n, Notification::Error { .. })),
1226            "expected no open MMI session after teardown, got {notes:?}"
1227        );
1228
1229        // Re-insert: a fresh handshake starts (Reset + CamPresent).
1230        let resets_before = d
1231            .device()
1232            .ops
1233            .iter()
1234            .filter(|o| **o == DeviceOp::Reset)
1235            .count();
1236        d.device_mut().slot.module_present = true;
1237        d.device_mut().slot.module_ready = true;
1238        d.pump(Duration::from_millis(10)).unwrap();
1239        let notes = d.take_notifications();
1240        assert!(
1241            notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
1242            "expected CamPresent on re-insert, got {notes:?}"
1243        );
1244        let resets_after = d
1245            .device()
1246            .ops
1247            .iter()
1248            .filter(|o| **o == DeviceOp::Reset)
1249            .count();
1250        assert_eq!(resets_after, resets_before + 1, "expected a fresh Reset");
1251    }
1252
1253    #[test]
1254    fn slot_status_unchanged_across_polls_emits_no_hotplug_notifications() {
1255        let mut d = Driver::new(MockCaDevice::new([]));
1256        d.init().unwrap();
1257        d.take_notifications();
1258
1259        for _ in 0..5 {
1260            d.pump(Duration::from_millis(10)).unwrap();
1261        }
1262        let notes = d.take_notifications();
1263        assert!(
1264            !notes.iter().any(|n| matches!(
1265                n,
1266                Notification::HotPlug(HotPlug::CamPresent | HotPlug::CamRemoved)
1267            )),
1268            "unchanged slot status must not emit hot-plug notifications, got {notes:?}"
1269        );
1270    }
1271
1272    #[test]
1273    fn ca_info_caid_set_change_infers_card_inserted_then_changed() {
1274        use dvb_ci::objects::ca_info::CaInfo;
1275
1276        let mut d = driver_with_sessions();
1277        d.take_notifications();
1278
1279        // First ca_info: no CAIDs (baseline only, no notification).
1280        feed(
1281            &mut d,
1282            r_apdu(
1283                CA_SESSION,
1284                &ser(&CaInfo {
1285                    ca_system_ids: vec![],
1286                }),
1287            ),
1288        );
1289        let notes = d.take_notifications();
1290        assert!(
1291            !notes.iter().any(|n| matches!(
1292                n,
1293                Notification::HotPlug(
1294                    HotPlug::CardInserted | HotPlug::CardChanged | HotPlug::CardRemoved
1295                )
1296            )),
1297            "first ca_info must only establish the baseline, got {notes:?}"
1298        );
1299
1300        // CAID set becomes populated: card inserted.
1301        feed(
1302            &mut d,
1303            r_apdu(
1304                CA_SESSION,
1305                &ser(&CaInfo {
1306                    ca_system_ids: vec![0x0B00],
1307                }),
1308            ),
1309        );
1310        let notes = d.take_notifications();
1311        assert!(
1312            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1313            "expected CardInserted, got {notes:?}"
1314        );
1315
1316        // CAID set changes to a different non-empty set: card changed.
1317        feed(
1318            &mut d,
1319            r_apdu(
1320                CA_SESSION,
1321                &ser(&CaInfo {
1322                    ca_system_ids: vec![0x1800],
1323                }),
1324            ),
1325        );
1326        let notes = d.take_notifications();
1327        assert!(
1328            notes.contains(&Notification::HotPlug(HotPlug::CardChanged)),
1329            "expected CardChanged, got {notes:?}"
1330        );
1331    }
1332
1333    #[test]
1334    fn ca_pmt_reply_descrambling_transition_infers_card_present_then_removed() {
1335        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1336
1337        fn reply(ca_enable: Option<CaEnable>) -> CaPmtReply {
1338            CaPmtReply {
1339                program_number: 1,
1340                version_number: 1,
1341                current_next_indicator: true,
1342                ca_enable,
1343                streams: vec![],
1344            }
1345        }
1346
1347        let mut d = driver_with_sessions();
1348        d.take_notifications();
1349
1350        // Baseline: descrambling not (yet) possible.
1351        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1352        let notes = d.take_notifications();
1353        assert!(
1354            !notes.iter().any(|n| matches!(
1355                n,
1356                Notification::HotPlug(HotPlug::CardInserted | HotPlug::CardRemoved)
1357            )),
1358            "first ca_pmt_reply must only establish the baseline, got {notes:?}"
1359        );
1360
1361        // false -> true: card-present inference.
1362        feed(
1363            &mut d,
1364            r_apdu(CA_SESSION, &ser(&reply(Some(CaEnable::Possible)))),
1365        );
1366        let notes = d.take_notifications();
1367        assert!(
1368            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1369            "expected CardInserted, got {notes:?}"
1370        );
1371
1372        // true -> false: card removed.
1373        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1374        let notes = d.take_notifications();
1375        assert!(
1376            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1377            "expected CardRemoved, got {notes:?}"
1378        );
1379    }
1380
1381    #[test]
1382    fn ca_pmt_reply_surfaces_typed_ca_enable() {
1383        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1384
1385        let mut d = driver_with_sessions();
1386        d.take_notifications();
1387
1388        // `CA_enable` = 0x03 (possible under conditions, technical dialogue) —
1389        // EN 50221 §8.4.3.5 Table 26.
1390        feed(
1391            &mut d,
1392            r_apdu(
1393                CA_SESSION,
1394                &ser(&CaPmtReply {
1395                    program_number: 7,
1396                    version_number: 1,
1397                    current_next_indicator: true,
1398                    ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1399                    streams: vec![],
1400                }),
1401            ),
1402        );
1403        let notes = d.take_notifications();
1404        assert!(
1405            notes.contains(&Notification::CaPmtReply {
1406                program_number: 7,
1407                ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1408                descrambling_ok: true,
1409            }),
1410            "expected typed ca_enable on CaPmtReply, got {notes:?}"
1411        );
1412    }
1413
1414    #[test]
1415    fn ca_pmt_reply_flag_clear_surfaces_none() {
1416        use dvb_ci::objects::ca_pmt_reply::CaPmtReply;
1417
1418        let mut d = driver_with_sessions();
1419        d.take_notifications();
1420
1421        // Programme `CA_enable_flag` clear -> no programme-level status given
1422        // — EN 50221 §8.4.3.5 Table 26.
1423        feed(
1424            &mut d,
1425            r_apdu(
1426                CA_SESSION,
1427                &ser(&CaPmtReply {
1428                    program_number: 7,
1429                    version_number: 1,
1430                    current_next_indicator: true,
1431                    ca_enable: None,
1432                    streams: vec![],
1433                }),
1434            ),
1435        );
1436        let notes = d.take_notifications();
1437        assert!(
1438            notes.contains(&Notification::CaPmtReply {
1439                program_number: 7,
1440                ca_enable: None,
1441                descrambling_ok: false,
1442            }),
1443            "expected ca_enable None on flag-clear CaPmtReply, got {notes:?}"
1444        );
1445    }
1446
1447    #[test]
1448    fn mmi_no_card_text_infers_card_removed() {
1449        use dvb_ci::objects::mmi_high::Enq;
1450
1451        let mut d = driver_with_sessions();
1452        d.take_notifications();
1453
1454        feed(
1455            &mut d,
1456            r_apdu(
1457                MMI_SESSION,
1458                &ser(&Enq {
1459                    blind_answer: false,
1460                    answer_text_length: 0,
1461                    text_chars: b"NO CARD detected - please insert your smart card",
1462                }),
1463            ),
1464        );
1465
1466        let notes = d.take_notifications();
1467        assert!(
1468            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1469            "expected CardRemoved inferred from MMI 'no card' text, got {notes:?}"
1470        );
1471    }
1472
1473    /// r10-O-13: the MMI keyword heuristic reports a card *transition*, not
1474    /// every menu that repeats the same text.
1475    #[test]
1476    fn mmi_card_keyword_heuristic_is_edge_triggered() {
1477        use dvb_ci::objects::mmi_high::Enq;
1478
1479        let enq = |text: &'static [u8]| {
1480            r_apdu(
1481                MMI_SESSION,
1482                &ser(&Enq {
1483                    blind_answer: false,
1484                    answer_text_length: 0,
1485                    text_chars: text,
1486                }),
1487            )
1488        };
1489        let mut d = driver_with_sessions();
1490        d.take_notifications();
1491        let count = |notes: &[Notification], hp: HotPlug| {
1492            notes
1493                .iter()
1494                .filter(|n| **n == Notification::HotPlug(hp))
1495                .count()
1496        };
1497
1498        // The same "no card" prompt three times: one CardRemoved, not three.
1499        for _ in 0..3 {
1500            feed(&mut d, enq(b"No card - please insert your smart card"));
1501        }
1502        let notes = d.take_notifications();
1503        assert_eq!(count(&notes, HotPlug::CardRemoved), 1, "{notes:?}");
1504        assert_eq!(count(&notes, HotPlug::CardInserted), 0);
1505
1506        // A present-card prompt is an edge: one CardInserted, then silence.
1507        for _ in 0..2 {
1508            feed(&mut d, enq(b"Your entitlement is valid"));
1509        }
1510        let notes = d.take_notifications();
1511        assert_eq!(count(&notes, HotPlug::CardInserted), 1, "{notes:?}");
1512        assert_eq!(count(&notes, HotPlug::CardRemoved), 0);
1513
1514        // Back to absent: a fresh edge, reported once.
1515        feed(&mut d, enq(b"Card removed"));
1516        feed(&mut d, enq(b"Card removed"));
1517        let notes = d.take_notifications();
1518        assert_eq!(count(&notes, HotPlug::CardRemoved), 1, "{notes:?}");
1519    }
1520
1521    #[test]
1522    fn pump_hotplug_delivers_cam_present_via_closure_exactly_once() {
1523        let mut dev = MockCaDevice::new([]);
1524        dev.slot = SlotInfo {
1525            num: 0,
1526            module_ready: false,
1527            module_present: false,
1528        };
1529        let mut d = Driver::new(dev);
1530        d.init().unwrap();
1531        d.take_notifications(); // drop the baseline observation
1532
1533        // Module physically inserted and ready.
1534        d.device_mut().slot = SlotInfo {
1535            num: 0,
1536            module_ready: true,
1537            module_present: true,
1538        };
1539
1540        let mut seen = Vec::new();
1541        d.pump_hotplug(Duration::from_millis(10), |hp| seen.push(hp))
1542            .unwrap();
1543
1544        assert_eq!(
1545            seen,
1546            vec![HotPlug::CamPresent],
1547            "expected the closure to receive HotPlug::CamPresent exactly once, got {seen:?}"
1548        );
1549    }
1550
1551    // --- #763 Task 3: ManagedCa + add_service ---
1552
1553    /// A `CA_descriptor` TLV (ISO/IEC 13818-1 §2.6.16): tag `0x09`, len `4`,
1554    /// `CA_system_id`(2), `reserved(3)`/`CA_PID`(13).
1555    pub(crate) fn ca_descriptor(ca_system_id: u16, pid: u16) -> [u8; 6] {
1556        [
1557            0x09,
1558            0x04,
1559            (ca_system_id >> 8) as u8,
1560            ca_system_id as u8,
1561            0xE0 | ((pid >> 8) as u8 & 0x1F),
1562            pid as u8,
1563        ]
1564    }
1565
1566    /// A synthetic scrambled-service PMT: programme-level `CA_descriptor`
1567    /// (`CA_system_id` `0x0500` = Viaccess, a real assigned value per the
1568    /// TSDuck CA-system registry consumed by `dvb_si::descriptors::ca::ca_system_name`),
1569    /// one scrambled H.264 video ES (own `CA_descriptor`), and one clear AAC
1570    /// audio ES.
1571    ///
1572    /// **Provenance:** no committed capture in this repo's fixture corpus
1573    /// carries a scrambled PMT — `fixtures/dvb-si/tnt-5w-12732v-isi6-10s.ts`'s
1574    /// five PMTs (verified via `cargo run -p dvb-tools -- dump ... --json`)
1575    /// are all clear/FTA services, and no CA-descriptor-bearing capture exists
1576    /// under `private/fixtures/` either. This hand-rolls the wire bytes per
1577    /// ISO/IEC 13818-1 §2.4.4.8's PMT syntax instead, mirroring the exact
1578    /// precedent already established by `dvb-ci/src/builder.rs`'s
1579    /// `build_test_pmt()` (a hand-rolled buffer "that mirrors a real
1580    /// CA-protected service") — real `CA_system_id`/`stream_type` values, real
1581    /// CRC, just not sourced from an off-air capture.
1582    pub(crate) fn build_ca_pmt_fixture(program_number: u16) -> Vec<u8> {
1583        const VIACCESS: u16 = 0x0500;
1584        let prog_ca = ca_descriptor(VIACCESS, 0x0064);
1585        let es0_ca = ca_descriptor(VIACCESS, 0x0065);
1586
1587        let mut body = Vec::new();
1588        body.push(0x02); // table_id (PMT)
1589        body.push(0); // section_length placeholder (fixed up below)
1590        body.push(0);
1591        body.extend_from_slice(&program_number.to_be_bytes());
1592        body.push(0xC3); // reserved(2)='11' | version(5)=1 | current_next=1
1593        body.push(0x00); // section_number
1594        body.push(0x00); // last_section_number
1595        body.push(0xE0 | 0x01); // reserved(3) | PCR_PID(13) = 0x0100
1596        body.push(0x00);
1597        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1598        body.push(prog_ca.len() as u8);
1599        body.extend_from_slice(&prog_ca);
1600        // ES0: H.264 video, pid 0x0100, scrambled (own CA_descriptor).
1601        body.push(0x1B);
1602        body.push(0xE0 | 0x01);
1603        body.push(0x00);
1604        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1605        body.push(es0_ca.len() as u8);
1606        body.extend_from_slice(&es0_ca);
1607        // ES1: AAC ADTS audio, pid 0x0101, clear.
1608        body.push(0x0F);
1609        body.push(0xE0 | 0x01);
1610        body.push(0x01);
1611        body.push(0xF0);
1612        body.push(0x00);
1613
1614        let section_length = body.len() - 3 + 4;
1615        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1616        body[2] = section_length as u8;
1617        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1618        body.extend_from_slice(&crc.to_be_bytes());
1619        body
1620    }
1621
1622    /// Same layout as [`build_ca_pmt_fixture`] but with the PCR carried on its
1623    /// own **dedicated** `PCR_PID` (`0x00FF`) — distinct from every ES PID
1624    /// (`0x0100`/`0x0101`) and CA PID (`0x0064`/`0x0065`) — a legitimate DVB
1625    /// config (ISO/IEC 13818-1 §2.4.4.8) that `build_ca_pmt_fixture`'s
1626    /// `PCR_PID == video ES PID` masks: the #763 final-review regression
1627    /// fixture for `required_pids`/`feed_ts` PCR routing.
1628    pub(crate) fn build_ca_pmt_fixture_dedicated_pcr(program_number: u16) -> Vec<u8> {
1629        const VIACCESS: u16 = 0x0500;
1630        let prog_ca = ca_descriptor(VIACCESS, 0x0064);
1631        let es0_ca = ca_descriptor(VIACCESS, 0x0065);
1632
1633        let mut body = Vec::new();
1634        body.push(0x02); // table_id (PMT)
1635        body.push(0); // section_length placeholder (fixed up below)
1636        body.push(0);
1637        body.extend_from_slice(&program_number.to_be_bytes());
1638        body.push(0xC3); // reserved(2)='11' | version(5)=1 | current_next=1
1639        body.push(0x00); // section_number
1640        body.push(0x00); // last_section_number
1641        body.push(0xE0); // reserved(3) | PCR_PID(13) high byte = 0x00FF >> 8
1642        body.push(0xFF); // PCR_PID low byte — dedicated, outside the ES/CA set
1643        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1644        body.push(prog_ca.len() as u8);
1645        body.extend_from_slice(&prog_ca);
1646        // ES0: H.264 video, pid 0x0100, scrambled (own CA_descriptor).
1647        body.push(0x1B);
1648        body.push(0xE0 | 0x01);
1649        body.push(0x00);
1650        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1651        body.push(es0_ca.len() as u8);
1652        body.extend_from_slice(&es0_ca);
1653        // ES1: AAC ADTS audio, pid 0x0101, clear.
1654        body.push(0x0F);
1655        body.push(0xE0 | 0x01);
1656        body.push(0x01);
1657        body.push(0xF0);
1658        body.push(0x00);
1659
1660        let section_length = body.len() - 3 + 4;
1661        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1662        body[2] = section_length as u8;
1663        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1664        body.extend_from_slice(&crc.to_be_bytes());
1665        body
1666    }
1667
1668    /// Same layout as [`build_ca_pmt_fixture`] but with no `CA_descriptor`
1669    /// anywhere (an ordinary clear/FTA service) — the negative-control PMT for
1670    /// [`CaError::NoCaDescriptor`].
1671    pub(crate) fn build_clear_pmt_fixture(program_number: u16) -> Vec<u8> {
1672        let mut body = Vec::new();
1673        body.push(0x02);
1674        body.push(0);
1675        body.push(0);
1676        body.extend_from_slice(&program_number.to_be_bytes());
1677        body.push(0xC3);
1678        body.push(0x00);
1679        body.push(0x00);
1680        body.push(0xE0 | 0x01);
1681        body.push(0x00);
1682        body.push(0xF0); // program_info_length = 0
1683        body.push(0x00);
1684        // ES0: H.264 video, pid 0x0100, clear.
1685        body.push(0x1B);
1686        body.push(0xE0 | 0x01);
1687        body.push(0x00);
1688        body.push(0xF0);
1689        body.push(0x00);
1690
1691        let section_length = body.len() - 3 + 4;
1692        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1693        body[2] = section_length as u8;
1694        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1695        body.extend_from_slice(&crc.to_be_bytes());
1696        body
1697    }
1698
1699    #[test]
1700    fn add_service_builds_and_sends_ca_pmt_matching_builder_oracle() {
1701        use broadcast_common::Parse;
1702
1703        let mut d = driver_with_sessions();
1704        d.take_notifications();
1705
1706        let pmt_bytes = build_ca_pmt_fixture(1546);
1707        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1708
1709        d.add_service(&pmt).unwrap();
1710        d.device_mut().inbound.push_back(sb());
1711        d.pump(Duration::from_millis(10)).unwrap();
1712
1713        // Oracle: the same PMT built directly via dvb_ci::builder::build_ca_pmt
1714        // with `Only` (first-ever service on an empty managed set) +
1715        // `ok_descrambling`.
1716        let expected =
1717            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
1718        assert_apdu_on_session(&d, CA_SESSION, &expected);
1719
1720        // The service was recorded with its ES/CA PIDs.
1721        let svc = d
1722            .managed_ca()
1723            .services()
1724            .get(&1546)
1725            .expect("program_number 1546 must be tracked after add_service");
1726        assert_eq!(svc.es_pids, vec![0x0100, 0x0101]);
1727        assert_eq!(svc.ca_pids, vec![0x0064, 0x0065]);
1728        assert_eq!(svc.cmd, CaPmtCmdId::OkDescrambling);
1729        assert_eq!(svc.last_ca_enable, None);
1730    }
1731
1732    #[test]
1733    fn add_service_rejects_pmt_without_ca_descriptor() {
1734        use broadcast_common::Parse;
1735
1736        let mut d = driver_with_sessions();
1737        let pmt_bytes = build_clear_pmt_fixture(999);
1738        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1739
1740        let err = d.add_service(&pmt).unwrap_err();
1741        assert!(
1742            matches!(
1743                err,
1744                CaError::NoCaDescriptor {
1745                    program_number: 999
1746                }
1747            ),
1748            "expected NoCaDescriptor{{program_number: 999}}, got {err:?}"
1749        );
1750        assert!(
1751            d.managed_ca().services().is_empty(),
1752            "a rejected PMT must not be recorded"
1753        );
1754    }
1755
1756    /// r10-W-20: the re-query timer must surface a corrupt stored PMT as an
1757    /// `Err` from the public `pump` entry point — never a panic — and the
1758    /// failure must be sticky: a corrupt (or CAM-supplied) service must not
1759    /// silently swallow the healthy services' resend, and must keep
1760    /// reporting on every later tick. A service with truncated stored
1761    /// `pmt_raw` (corruption of the caller's own state after a successful
1762    /// add — the only corrupt shape the stored bytes can hold, since
1763    /// `add_service` re-serializes them) joins a healthy service added
1764    /// through the public `add_service`; when the 5s timer fires, the
1765    /// healthy pair still goes out, the corrupt one's re-parse failure is
1766    /// recorded, and `pump` returns it as `Err` — twice in a row.
1767    #[test]
1768    fn requery_tick_serialize_failure_errors_from_pump_instead_of_panicking() {
1769        use broadcast_common::Parse;
1770
1771        let clock = TestClock::new();
1772        let mut d = driver_with_sessions().with_clock(clock.as_fn());
1773
1774        // A healthy service through the public path (feeds its CaInfo like
1775        // `requery_timer_resends_ca_pmt_then_reply_change_emits_one_entitlement`).
1776        let good_bytes = build_ca_pmt_fixture(1);
1777        let good = PmtSection::parse(&good_bytes).unwrap();
1778        assert_eq!(good.program_number, 1, "fixture precondition");
1779        d.add_service(&good).unwrap();
1780
1781        // The corrupt service, recorded via the same internals
1782        // `add_service` uses (its raw bytes cannot be written by the
1783        // serializer, so they never reach the stored set through the
1784        // public path).
1785        let pmt_bytes = build_ca_pmt_fixture(1546);
1786        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1787        let built = d
1788            .build_ca_pmt_filtered(
1789                &pmt,
1790                CaPmtListManagement::Update,
1791                CaPmtCmdId::OkDescrambling,
1792            )
1793            .try_to_bytes()
1794            .expect("fixture precondition: the good PMT encodes");
1795        let corrupt_raw = pmt_bytes[..pmt_bytes.len() - 6].to_vec();
1796        d.managed.record(
1797            1546,
1798            managed::service_of(&pmt, CaPmtCmdId::OkDescrambling, built, corrupt_raw),
1799        );
1800        d.take_notifications();
1801        // Drain every queued host write with module T_SBs (the transport
1802        // flushes one write per module turn, #337; `feed`'s tail leaves
1803        // handshake writes and `add_service` added one more).
1804        for _ in 0..16 {
1805            d.device_mut().inbound.push_back(sb());
1806            d.pump(Duration::from_millis(10)).unwrap();
1807        }
1808        d.set_requery_interval(Duration::from_secs(5));
1809
1810        // Advance past the interval and pump idle: the timer fires, the
1811        // healthy service's re-query pair goes out, and the corrupt one
1812        // fails its re-parse — an Err out of `pump`, not a panic.
1813        clock.advance(Duration::from_secs(5));
1814        let err = d
1815            .pump(Duration::ZERO)
1816            .expect_err("re-query of a corrupt stored PMT must error, not panic");
1817        assert!(
1818            matches!(
1819                err.get_ref(),
1820                Some(e) if e.downcast_ref::<CaError>()
1821                    .is_some_and(|ca| matches!(ca, CaError::PmtParse(_)))
1822            ),
1823            "expected CaError::PmtParse from the corrupt stored bytes, got {err:?}"
1824        );
1825        assert!(
1826            err.to_string().contains("stored PMT re-parse failed"),
1827            "error must name the re-parse failure, got: {err}"
1828        );
1829        // Sticky-but-not-swallowing: the failure was returned after the
1830        // healthy pair went out this tick; drain it, and the NEXT fire
1831        // re-detects the same corrupt stored bytes (never silently quiet).
1832        assert!(d.pump(Duration::ZERO).is_ok());
1833        clock.advance(Duration::from_secs(5));
1834        let err2 = d
1835            .pump(Duration::ZERO)
1836            .expect_err("the corrupt service must fail every re-query");
1837        assert!(
1838            err2.to_string().contains("stored PMT re-parse failed"),
1839            "failure must persist, got: {err2}"
1840        );
1841    }
1842
1843    /// The constructor-level corrupt PMT: a 5000-byte programme CA loop
1844    /// (CAID `0x0B00`), past dvb-ci's 4095-byte `program_info_length` cap.
1845    /// dvb-si's parse only 12-bit-checks lengths against the physical
1846    /// buffer, so this shape exists only via `PmtSection::new` — standing
1847    /// in for a corrupt reassembly reaching `add_service` as a value.
1848    fn corrupt_ca_section_ctor() -> PmtSection<'static> {
1849        const LOOP: usize = 5000;
1850        let mut prog_info: Vec<u8> = Vec::with_capacity(LOOP);
1851        for _ in 0..LOOP / 4 {
1852            // CA_descriptor: CAID 0x0B00, CA_PID 0x0100 (4-byte body).
1853            prog_info.extend_from_slice(&[0x09u8, 0x02, 0x0B, 0x00]);
1854        }
1855        assert_eq!(prog_info.len(), LOOP);
1856        PmtSection::new(
1857            1,
1858            1,
1859            true,
1860            0,
1861            0,
1862            0x0200,
1863            dvb_si::descriptors::DescriptorLoop::new(Box::leak(prog_info.into_boxed_slice())),
1864            Vec::new(),
1865        )
1866    }
1867
1868    /// r10-W-20: `add_service` must reject a PMT whose CA loop cannot be
1869    /// encoded into a `ca_pmt` (over-range `program_info_length`) as
1870    /// `CaError::Serialize` from the public entry point — pre-fix this
1871    /// reached `.to_bytes()` and panicked inside the dvb-ci serializer.
1872    #[test]
1873    fn add_service_serialize_failure_returns_err_instead_of_panicking() {
1874        let mut d = driver_with_sessions();
1875        d.managed.set_cam_caids([0x0B00u16].into_iter().collect());
1876        let pmt = corrupt_ca_section_ctor();
1877        let err = d
1878            .add_service(&pmt)
1879            .expect_err("a ca_pmt with no valid encoding must error, not panic");
1880        assert!(
1881            matches!(err, CaError::Serialize(_)),
1882            "expected CaError::Serialize, got {err:?}"
1883        );
1884        assert!(
1885            d.managed_ca().services().is_empty(),
1886            "a rejected PMT must not be recorded"
1887        );
1888    }
1889
1890    #[test]
1891    fn add_service_second_call_uses_add_list_management() {
1892        use broadcast_common::Parse;
1893
1894        let mut d = driver_with_sessions();
1895        d.take_notifications();
1896
1897        let pmt1_bytes = build_ca_pmt_fixture(1546);
1898        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1899        d.add_service(&pmt1).unwrap();
1900        d.device_mut().inbound.push_back(sb());
1901        d.pump(Duration::from_millis(10)).unwrap();
1902
1903        let pmt2_bytes = build_ca_pmt_fixture(1547);
1904        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1905        d.add_service(&pmt2).unwrap();
1906        d.device_mut().inbound.push_back(sb());
1907        d.pump(Duration::from_millis(10)).unwrap();
1908
1909        // Second service joins an already-active set → `Add`, not `Only`.
1910        let expected2 =
1911            build_ca_pmt(&pmt2, CaPmtListManagement::Add, CaPmtCmdId::OkDescrambling).to_bytes();
1912        assert_apdu_on_session(&d, CA_SESSION, &expected2);
1913
1914        assert_eq!(d.managed_ca().services().len(), 2);
1915    }
1916
1917    // --- #1067: managed add_service/requery must CAID-filter like the raw
1918    // `descramble` path already does ---
1919
1920    /// Same layout as [`build_ca_pmt_fixture`] but with TWO programme-level
1921    /// `CA_descriptor`s (Viaccess `0x0500` and Nagravision `0x1800` — both
1922    /// real assigned values per the TSDuck CA-system registry, mirroring
1923    /// `dvb_ci::builder`'s own two-CAID `build_test_pmt` fixture) and no
1924    /// ES-level CA — the #1067 regression fixture: a CAM that advertises
1925    /// only one of the two CAIDs must have the other filtered out of the
1926    /// sent `ca_pmt`.
1927    fn build_ca_pmt_fixture_two_caids(program_number: u16) -> Vec<u8> {
1928        const VIACCESS: u16 = 0x0500;
1929        const NAGRA: u16 = 0x1800;
1930        let prog_ca_a = ca_descriptor(VIACCESS, 0x0064);
1931        let prog_ca_b = ca_descriptor(NAGRA, 0x0066);
1932        let mut prog_ca = Vec::new();
1933        prog_ca.extend_from_slice(&prog_ca_a);
1934        prog_ca.extend_from_slice(&prog_ca_b);
1935
1936        let mut body = Vec::new();
1937        body.push(0x02); // table_id (PMT)
1938        body.push(0);
1939        body.push(0);
1940        body.extend_from_slice(&program_number.to_be_bytes());
1941        body.push(0xC3);
1942        body.push(0x00);
1943        body.push(0x00);
1944        body.push(0xE0 | 0x01); // PCR_PID = 0x0100
1945        body.push(0x00);
1946        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1947        body.push(prog_ca.len() as u8);
1948        body.extend_from_slice(&prog_ca);
1949        // ES0: clear AAC audio, pid 0x0100 — no ES-level CA, so this
1950        // fixture isolates the programme-level filter.
1951        body.push(0x0F);
1952        body.push(0xE0 | 0x01);
1953        body.push(0x00);
1954        body.push(0xF0);
1955        body.push(0x00);
1956
1957        let section_length = body.len() - 3 + 4;
1958        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1959        body[2] = section_length as u8;
1960        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1961        body.extend_from_slice(&crc.to_be_bytes());
1962        body
1963    }
1964
1965    #[test]
1966    fn add_service_filters_ca_descriptors_to_cam_advertised_caids() {
1967        use broadcast_common::Parse;
1968        use dvb_ci::objects::ca_info::CaInfo;
1969
1970        let mut d = driver_with_sessions();
1971        d.take_notifications();
1972
1973        // CAM advertises ONLY Viaccess (0x0500); the PMT carries both
1974        // Viaccess and Nagravision (0x1800) programme-level CA_descriptors.
1975        feed(
1976            &mut d,
1977            r_apdu(
1978                CA_SESSION,
1979                &ser(&CaInfo {
1980                    ca_system_ids: vec![0x0500],
1981                }),
1982            ),
1983        );
1984        d.take_notifications();
1985
1986        let pmt_bytes = build_ca_pmt_fixture_two_caids(1560);
1987        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1988
1989        // Oracle: `dvb_ci::builder::build_ca_pmt_for_caids` — the exact
1990        // filter the raw `descramble` path already applies
1991        // (`CiStack::build_ca_pmt_bytes`) — vs. the unfiltered `build_ca_pmt`
1992        // a pre-fix `add_service` sent.
1993        let expected_filtered = dvb_ci::builder::build_ca_pmt_for_caids(
1994            &pmt,
1995            &[0x0500],
1996            CaPmtListManagement::Only,
1997            CaPmtCmdId::OkDescrambling,
1998        )
1999        .to_bytes();
2000        let unfiltered =
2001            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
2002        assert_ne!(
2003            expected_filtered, unfiltered,
2004            "precondition: the fixture's two CAIDs must make filtered != unfiltered"
2005        );
2006        let sends_filtered_before = count_apdu_on_session(&d, CA_SESSION, &expected_filtered);
2007        let sends_unfiltered_before = count_apdu_on_session(&d, CA_SESSION, &unfiltered);
2008
2009        d.add_service(&pmt).unwrap();
2010        d.device_mut().inbound.push_back(sb());
2011        d.pump(Duration::from_millis(10)).unwrap();
2012
2013        assert_eq!(
2014            count_apdu_on_session(&d, CA_SESSION, &expected_filtered),
2015            sends_filtered_before + 1,
2016            "add_service must send the CAID-filtered ca_pmt"
2017        );
2018        assert_eq!(
2019            count_apdu_on_session(&d, CA_SESSION, &unfiltered),
2020            sends_unfiltered_before,
2021            "add_service must NOT send the unfiltered ca_pmt once the CAM's CAIDs are known"
2022        );
2023    }
2024
2025    #[test]
2026    fn requery_timer_filters_ca_descriptors_to_cam_advertised_caids() {
2027        use broadcast_common::Parse;
2028        use dvb_ci::objects::ca_info::CaInfo;
2029
2030        let clock = TestClock::new();
2031        let mut d = driver_with_sessions().with_clock(clock.as_fn());
2032        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
2033        // enable it explicitly to exercise the timer.
2034        d.set_requery_interval(Duration::from_secs(10));
2035        d.take_notifications();
2036
2037        let pmt_bytes = build_ca_pmt_fixture_two_caids(1561);
2038        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2039        // add_service before any ca_info is known: unfiltered, per the
2040        // documented fallback (no CAID info to filter against yet).
2041        d.add_service(&pmt).unwrap();
2042        d.device_mut().inbound.push_back(sb());
2043        clock.advance(Duration::from_millis(10));
2044        d.pump(Duration::from_millis(10)).unwrap();
2045
2046        // The CAM's ca_info now arrives, advertising only Viaccess (0x0500).
2047        feed(
2048            &mut d,
2049            r_apdu(
2050                CA_SESSION,
2051                &ser(&CaInfo {
2052                    ca_system_ids: vec![0x0500],
2053                }),
2054            ),
2055        );
2056        d.take_notifications();
2057
2058        // #1032: the timer resends a `list_management = update` PAIR —
2059        // `query` then `ok_descrambling` — both CAID-filtered (#1067).
2060        let expected_query_filtered = dvb_ci::builder::build_ca_pmt_for_caids(
2061            &pmt,
2062            &[0x0500],
2063            CaPmtListManagement::Update,
2064            CaPmtCmdId::Query,
2065        )
2066        .to_bytes();
2067        let expected_ok_filtered = dvb_ci::builder::build_ca_pmt_for_caids(
2068            &pmt,
2069            &[0x0500],
2070            CaPmtListManagement::Update,
2071            CaPmtCmdId::OkDescrambling,
2072        )
2073        .to_bytes();
2074        let unfiltered_query =
2075            build_ca_pmt(&pmt, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
2076        let unfiltered_ok = build_ca_pmt(
2077            &pmt,
2078            CaPmtListManagement::Update,
2079            CaPmtCmdId::OkDescrambling,
2080        )
2081        .to_bytes();
2082        assert_ne!(
2083            expected_query_filtered, unfiltered_query,
2084            "precondition: the fixture's two CAIDs must make filtered != unfiltered (query)"
2085        );
2086        assert_ne!(
2087            expected_ok_filtered, unfiltered_ok,
2088            "precondition: the fixture's two CAIDs must make filtered != unfiltered (ok_descrambling)"
2089        );
2090        let sends_query_before = count_apdu_on_session(&d, CA_SESSION, &expected_query_filtered);
2091        let sends_ok_before = count_apdu_on_session(&d, CA_SESSION, &expected_ok_filtered);
2092        let sends_unfiltered_query_before =
2093            count_apdu_on_session(&d, CA_SESSION, &unfiltered_query);
2094        let sends_unfiltered_ok_before = count_apdu_on_session(&d, CA_SESSION, &unfiltered_ok);
2095
2096        clock.advance(Duration::from_secs(11));
2097        d.pump(Duration::from_secs(11)).unwrap();
2098        feed(&mut d, sb());
2099
2100        assert_eq!(
2101            count_apdu_on_session(&d, CA_SESSION, &expected_query_filtered),
2102            sends_query_before + 1,
2103            "the re-query `query` half must be CAID-filtered to the CAM's advertised CAIDs"
2104        );
2105        assert_eq!(
2106            count_apdu_on_session(&d, CA_SESSION, &expected_ok_filtered),
2107            sends_ok_before + 1,
2108            "the re-query `ok_descrambling` half must be CAID-filtered to the CAM's advertised CAIDs"
2109        );
2110        assert_eq!(
2111            count_apdu_on_session(&d, CA_SESSION, &unfiltered_query),
2112            sends_unfiltered_query_before,
2113            "the re-query resend must NOT be the unfiltered `query` ca_pmt"
2114        );
2115        assert_eq!(
2116            count_apdu_on_session(&d, CA_SESSION, &unfiltered_ok),
2117            sends_unfiltered_ok_before,
2118            "the re-query resend must NOT be the unfiltered `ok_descrambling` ca_pmt"
2119        );
2120    }
2121
2122    // --- #763 Task 4: set_cat + emm_pids/descramble_pids ---
2123
2124    /// A hand-built CAT section (ISO/IEC 13818-1 §2.4.4.5): table_id 0x01, a
2125    /// flat descriptor loop of `CA_descriptor`s (EN 300 468 §6.2.16, tag
2126    /// 0x09; the `ca_descriptor` helper above builds the same TLV used for
2127    /// PMTs). No off-air CAT capture exists in this repo's fixture corpus
2128    /// (verified: none of the committed `.ts` captures carry PID 0x0001),
2129    /// mirroring the same hand-rolled-fixture precedent as
2130    /// `build_ca_pmt_fixture` and `dvb_si::tables::cat`'s own unit tests.
2131    pub(crate) fn build_cat_fixture(descriptors: &[u8]) -> Vec<u8> {
2132        const EXTENSION_HEADER_LEN: u16 = 5;
2133        const CRC_LEN: u16 = 4;
2134        let section_length = EXTENSION_HEADER_LEN + descriptors.len() as u16 + CRC_LEN;
2135        let mut v = Vec::new();
2136        v.push(0x01); // table_id (CAT)
2137        v.push(0xB0 | ((section_length >> 8) as u8 & 0x0F));
2138        v.push((section_length & 0xFF) as u8);
2139        v.extend_from_slice(&[0xFF, 0xFF]); // table_id_extension (reserved for CAT)
2140        v.push(0xC1); // reserved(2)='11' | version(5)=0 | current_next=1
2141        v.push(0x00); // section_number
2142        v.push(0x00); // last_section_number
2143        v.extend_from_slice(descriptors);
2144        let crc = broadcast_common::crc32_mpeg2::compute(&v);
2145        v.extend_from_slice(&crc.to_be_bytes());
2146        v
2147    }
2148
2149    #[test]
2150    fn set_cat_computes_emm_pids_as_cat_inter_ca_info_caids() {
2151        use broadcast_common::Parse;
2152        use dvb_ci::objects::ca_info::CaInfo;
2153        use dvb_si::tables::cat::CatSection;
2154
2155        let mut d = driver_with_sessions();
2156        d.take_notifications();
2157
2158        // ca_info arrives first: the CAM advertises CAIDs 0x0648, 0x0100.
2159        feed(
2160            &mut d,
2161            r_apdu(
2162                CA_SESSION,
2163                &ser(&CaInfo {
2164                    ca_system_ids: vec![0x0648, 0x0100],
2165                }),
2166            ),
2167        );
2168        d.take_notifications();
2169
2170        // CAT maps 0x0648 -> 0x1FF0 (advertised) and 0x0500 -> 0x1FF1 (not
2171        // advertised by this CAM).
2172        let mut descriptors = Vec::new();
2173        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
2174        descriptors.extend_from_slice(&ca_descriptor(0x0500, 0x1FF1));
2175        let cat_bytes = build_cat_fixture(&descriptors);
2176        let cat = CatSection::parse(&cat_bytes).unwrap();
2177
2178        d.set_cat(&cat).unwrap();
2179
2180        assert_eq!(
2181            d.emm_pids(),
2182            &[0x1FF0],
2183            "0x0500 -> 0x1FF1 must be excluded: the CAM never advertised CAID 0x0500"
2184        );
2185    }
2186
2187    #[test]
2188    fn set_cat_before_ca_info_is_not_an_error_and_recomputes_once_ca_info_arrives() {
2189        use broadcast_common::Parse;
2190        use dvb_ci::objects::ca_info::CaInfo;
2191        use dvb_si::tables::cat::CatSection;
2192
2193        let mut d = driver_with_sessions();
2194        d.take_notifications();
2195
2196        let mut descriptors = Vec::new();
2197        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
2198        descriptors.extend_from_slice(&ca_descriptor(0x0500, 0x1FF1));
2199        let cat_bytes = build_cat_fixture(&descriptors);
2200        let cat = CatSection::parse(&cat_bytes).unwrap();
2201
2202        // set_cat with no ca_info observed yet: not an error, emm_pids stays
2203        // empty (nothing to intersect against).
2204        d.set_cat(&cat).unwrap();
2205        assert!(
2206            d.emm_pids().is_empty(),
2207            "emm_pids must be empty before any ca_info arrives, got {:?}",
2208            d.emm_pids()
2209        );
2210
2211        // ca_info now arrives: emm_pids recomputes against the CAT stored
2212        // earlier, without a second set_cat call.
2213        feed(
2214            &mut d,
2215            r_apdu(
2216                CA_SESSION,
2217                &ser(&CaInfo {
2218                    ca_system_ids: vec![0x0648, 0x0100],
2219                }),
2220            ),
2221        );
2222        d.take_notifications();
2223
2224        assert_eq!(
2225            d.emm_pids(),
2226            &[0x1FF0],
2227            "emm_pids must recompute once ca_info arrives, using the CAT stored by the earlier set_cat"
2228        );
2229    }
2230
2231    /// Task 4 review fix (MEDIUM): `recompute_emm_pids` must dedup like its
2232    /// sibling `recompute_service_pids` does — two CAT `CA_descriptor`s
2233    /// (distinct `CA_system_id`s, both CAM-advertised) that happen to share
2234    /// one `EMM_PID` (a real multi-CAS-on-one-EMM-PID broadcast setup) must
2235    /// list that PID exactly once, not twice.
2236    #[test]
2237    fn set_cat_emm_pids_dedups_when_two_caids_share_one_emm_pid() {
2238        use broadcast_common::Parse;
2239        use dvb_ci::objects::ca_info::CaInfo;
2240        use dvb_si::tables::cat::CatSection;
2241
2242        let mut d = driver_with_sessions();
2243        d.take_notifications();
2244
2245        // CAM advertises both CAIDs.
2246        feed(
2247            &mut d,
2248            r_apdu(
2249                CA_SESSION,
2250                &ser(&CaInfo {
2251                    ca_system_ids: vec![0x0648, 0x0100],
2252                }),
2253            ),
2254        );
2255        d.take_notifications();
2256
2257        // CAT maps BOTH CAIDs to the SAME EMM PID.
2258        let mut descriptors = Vec::new();
2259        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
2260        descriptors.extend_from_slice(&ca_descriptor(0x0100, 0x1FF0));
2261        let cat_bytes = build_cat_fixture(&descriptors);
2262        let cat = CatSection::parse(&cat_bytes).unwrap();
2263
2264        d.set_cat(&cat).unwrap();
2265
2266        assert_eq!(
2267            d.emm_pids(),
2268            &[0x1FF0],
2269            "0x1FF0 must appear exactly once even though two CAM-advertised CAIDs map to it, got {:?}",
2270            d.emm_pids()
2271        );
2272    }
2273
2274    /// Same layout as [`build_ca_pmt_fixture`] but with a distinct PCR/ES PID
2275    /// set, so a second added service proves `descramble_pids` is a real
2276    /// union rather than one programme's PIDs happening to repeat.
2277    fn build_ca_pmt_fixture_distinct_pids(program_number: u16) -> Vec<u8> {
2278        const VIACCESS: u16 = 0x0500;
2279        let prog_ca = ca_descriptor(VIACCESS, 0x0074);
2280        let es0_ca = ca_descriptor(VIACCESS, 0x0075);
2281
2282        let mut body = Vec::new();
2283        body.push(0x02); // table_id (PMT)
2284        body.push(0);
2285        body.push(0);
2286        body.extend_from_slice(&program_number.to_be_bytes());
2287        body.push(0xC3);
2288        body.push(0x00);
2289        body.push(0x00);
2290        body.push(0xE0 | 0x02); // PCR_PID = 0x0200
2291        body.push(0x00);
2292        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
2293        body.push(prog_ca.len() as u8);
2294        body.extend_from_slice(&prog_ca);
2295        // ES0: H.264 video, pid 0x0200, scrambled.
2296        body.push(0x1B);
2297        body.push(0xE0 | 0x02);
2298        body.push(0x00);
2299        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
2300        body.push(es0_ca.len() as u8);
2301        body.extend_from_slice(&es0_ca);
2302        // ES1: AAC ADTS audio, pid 0x0201, clear.
2303        body.push(0x0F);
2304        body.push(0xE0 | 0x02);
2305        body.push(0x01);
2306        body.push(0xF0);
2307        body.push(0x00);
2308
2309        let section_length = body.len() - 3 + 4;
2310        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
2311        body[2] = section_length as u8;
2312        let crc = broadcast_common::crc32_mpeg2::compute(&body);
2313        body.extend_from_slice(&crc.to_be_bytes());
2314        body
2315    }
2316
2317    #[test]
2318    fn descramble_pids_is_the_union_of_active_services_es_pids() {
2319        use broadcast_common::Parse;
2320
2321        let mut d = driver_with_sessions();
2322        d.take_notifications();
2323
2324        assert!(
2325            d.descramble_pids().is_empty(),
2326            "no service added yet: descramble_pids must be empty"
2327        );
2328
2329        let pmt1_bytes = build_ca_pmt_fixture(1546);
2330        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
2331        d.add_service(&pmt1).unwrap();
2332        d.device_mut().inbound.push_back(sb());
2333        d.pump(Duration::from_millis(10)).unwrap();
2334
2335        assert_eq!(d.descramble_pids(), &[0x0100, 0x0101]);
2336
2337        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
2338        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
2339        d.add_service(&pmt2).unwrap();
2340        d.device_mut().inbound.push_back(sb());
2341        d.pump(Duration::from_millis(10)).unwrap();
2342
2343        // Union of both programmes' ES PIDs, sorted.
2344        assert_eq!(
2345            d.descramble_pids(),
2346            &[0x0100, 0x0101, 0x0200, 0x0201],
2347            "descramble_pids must be the union across both added services"
2348        );
2349    }
2350
2351    // --- #763 Task 5: re-query timer + edge-triggered Entitlement ---
2352
2353    /// Build a `ca_pmt_reply` (EN 50221 §8.4.3.5, Table 26) for `program_number`
2354    /// carrying programme-level `ca_enable` (`None` = `CA_enable_flag` clear).
2355    pub(crate) fn ca_pmt_reply_for(
2356        program_number: u16,
2357        ca_enable: Option<dvb_ci::objects::ca_pmt_reply::CaEnable>,
2358    ) -> dvb_ci::objects::ca_pmt_reply::CaPmtReply {
2359        dvb_ci::objects::ca_pmt_reply::CaPmtReply {
2360            program_number,
2361            version_number: 1,
2362            current_next_indicator: true,
2363            ca_enable,
2364            streams: vec![],
2365        }
2366    }
2367
2368    #[test]
2369    fn requery_timer_resends_ca_pmt_then_reply_change_emits_one_entitlement() {
2370        use broadcast_common::Parse;
2371        use dvb_ci::objects::ca_pmt_reply::CaEnable;
2372
2373        let clock = TestClock::new();
2374        let mut d = driver_with_sessions().with_clock(clock.as_fn());
2375        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
2376        // enable it explicitly to exercise the timer.
2377        d.set_requery_interval(Duration::from_secs(10));
2378        d.take_notifications();
2379
2380        let pmt_bytes = build_ca_pmt_fixture(1546);
2381        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2382        d.add_service(&pmt).unwrap();
2383        d.device_mut().inbound.push_back(sb());
2384        clock.advance(Duration::from_millis(10));
2385        d.pump(Duration::from_millis(10)).unwrap();
2386        d.take_notifications();
2387
2388        // The initial `add_service` send is `ok_descrambling` — assert it
2389        // happened, so the test proves the resend below (a distinct `query`
2390        // cmd_id) is a genuinely different wire message, not the same bytes.
2391        let expected_initial_ca_pmt =
2392            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
2393        assert_apdu_on_session(&d, CA_SESSION, &expected_initial_ca_pmt);
2394
2395        // #1032: the re-query timer resends a `list_management = update`
2396        // PAIR — `query` (to solicit a fresh `ca_pmt_reply`) immediately
2397        // followed by `ok_descrambling` (so descrambling is never left
2398        // barred, EN 50221 §8.4.3.5, whether or not the CAM answers the
2399        // query) — never the pre-fix `only`/`first` + `query` alone.
2400        let expected_query =
2401            build_ca_pmt(&pmt, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
2402        let expected_ok_descrambling = build_ca_pmt(
2403            &pmt,
2404            CaPmtListManagement::Update,
2405            CaPmtCmdId::OkDescrambling,
2406        )
2407        .to_bytes();
2408        let query_sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_query);
2409        let ok_sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_ok_descrambling);
2410
2411        let mut all_notes = Vec::new();
2412
2413        // Reply 1: not entitled (baseline — first-ever reply for this
2414        // program; `descrambling_ok` is derived: `NotPossibleNoEntitlement`
2415        // is not in the "possible" set, so `false`).
2416        feed(
2417            &mut d,
2418            r_apdu(
2419                CA_SESSION,
2420                &ser(&ca_pmt_reply_for(
2421                    1546,
2422                    Some(CaEnable::NotPossibleNoEntitlement),
2423                )),
2424            ),
2425        );
2426        all_notes.extend(d.take_notifications());
2427
2428        // Advance the clock past the 10s re-query interval: a single pump
2429        // ticks the stack with elapsed = 11s (nothing readable this turn),
2430        // which the #763 Task 5 re-query timer picks up and queues the
2431        // tracked service's `query`+`ok_descrambling` pair for resend (EN
2432        // 50221 §8.4.3.4 Table 25). EN 50221's link is half-duplex — this
2433        // tick's own keep-alive poll already claimed the turn, so the pair
2434        // is written across the module's next two `T_SB`s (the #337
2435        // one-write-per-turn rule), same as any other queued host write in
2436        // this test suite.
2437        clock.advance(Duration::from_secs(11));
2438        d.pump(Duration::from_secs(11)).unwrap();
2439        all_notes.extend(d.take_notifications());
2440        feed(&mut d, sb());
2441
2442        assert_eq!(
2443            count_apdu_on_session(&d, CA_SESSION, &expected_query),
2444            query_sends_before + 1,
2445            "expected the re-query timer to resend the `query` half exactly once"
2446        );
2447        assert_eq!(
2448            count_apdu_on_session(&d, CA_SESSION, &expected_ok_descrambling),
2449            ok_sends_before + 1,
2450            "expected the re-query timer to resend the `ok_descrambling` half exactly once"
2451        );
2452
2453        // Reply 2: the CAM's re-evaluated answer to the re-query says
2454        // descrambling is now possible.
2455        feed(
2456            &mut d,
2457            r_apdu(
2458                CA_SESSION,
2459                &ser(&ca_pmt_reply_for(1546, Some(CaEnable::Possible))),
2460            ),
2461        );
2462        all_notes.extend(d.take_notifications());
2463
2464        let hits = all_notes
2465            .iter()
2466            .filter(|n| {
2467                matches!(
2468                    n,
2469                    Notification::Entitlement {
2470                        program_number: 1546,
2471                        ca_enable: CaEnable::Possible,
2472                        descrambling_ok: true,
2473                    }
2474                )
2475            })
2476            .count();
2477        assert_eq!(
2478            hits, 1,
2479            "expected exactly one Entitlement{{program_number:1546, ca_enable:Possible, descrambling_ok:true}}, got {all_notes:?}"
2480        );
2481    }
2482
2483    #[test]
2484    fn requery_timer_unchanged_reply_across_two_requeries_emits_no_entitlement() {
2485        use broadcast_common::Parse;
2486        use dvb_ci::objects::ca_pmt_reply::CaEnable;
2487
2488        let mut d = driver_with_sessions();
2489        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
2490        // enable it explicitly so the loop below exercises real re-queries.
2491        d.set_requery_interval(Duration::from_secs(10));
2492        d.take_notifications();
2493
2494        let pmt_bytes = build_ca_pmt_fixture(1547);
2495        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2496        d.add_service(&pmt).unwrap();
2497        d.device_mut().inbound.push_back(sb());
2498        d.pump(Duration::from_millis(10)).unwrap();
2499        d.take_notifications();
2500
2501        // Baseline reply: descrambling possible. First-ever reply — this
2502        // establishes the baseline and DOES emit once (per the transition
2503        // rule); drop it so the loop below only asserts on the re-queries.
2504        feed(
2505            &mut d,
2506            r_apdu(
2507                CA_SESSION,
2508                &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
2509            ),
2510        );
2511        d.take_notifications();
2512
2513        // Two re-queries, the CAM replying with the SAME unchanged status
2514        // both times: no Entitlement either time (negative control).
2515        for _ in 0..2 {
2516            d.pump(Duration::from_secs(11)).unwrap();
2517            d.take_notifications();
2518            feed(
2519                &mut d,
2520                r_apdu(
2521                    CA_SESSION,
2522                    &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
2523                ),
2524            );
2525            let notes = d.take_notifications();
2526            assert!(
2527                !notes
2528                    .iter()
2529                    .any(|n| matches!(n, Notification::Entitlement { .. })),
2530                "unchanged status across a re-query must not emit Entitlement, got {notes:?}"
2531            );
2532        }
2533    }
2534
2535    #[test]
2536    fn requery_reply_withdrawn_to_none_emits_no_entitlement() {
2537        use broadcast_common::Parse;
2538        use dvb_ci::objects::ca_pmt_reply::CaEnable;
2539
2540        let mut d = driver_with_sessions();
2541        d.take_notifications();
2542
2543        let pmt_bytes = build_ca_pmt_fixture(1548);
2544        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2545        d.add_service(&pmt).unwrap();
2546        d.device_mut().inbound.push_back(sb());
2547        d.pump(Duration::from_millis(10)).unwrap();
2548        d.take_notifications();
2549
2550        // Baseline: descrambling possible (drop the baseline Entitlement).
2551        feed(
2552            &mut d,
2553            r_apdu(
2554                CA_SESSION,
2555                &ser(&ca_pmt_reply_for(1548, Some(CaEnable::Possible))),
2556            ),
2557        );
2558        d.take_notifications();
2559
2560        // Programme `CA_enable_flag` now clear (`None`) — status withdrawn.
2561        // Per the transition rule this NEVER emits Entitlement (#726 HotPlug
2562        // covers the coarse withdrawal signal instead).
2563        feed(
2564            &mut d,
2565            r_apdu(CA_SESSION, &ser(&ca_pmt_reply_for(1548, None))),
2566        );
2567        let notes = d.take_notifications();
2568        assert!(
2569            !notes
2570                .iter()
2571                .any(|n| matches!(n, Notification::Entitlement { .. })),
2572            "ca_enable transitioning to None must not emit Entitlement, got {notes:?}"
2573        );
2574    }
2575
2576    #[test]
2577    fn requery_disabled_by_default_sends_nothing() {
2578        use broadcast_common::Parse;
2579
2580        // #1032: no `set_requery_interval` call at all — `REQUERY_DEFAULT`
2581        // (`Duration::ZERO`) must leave re-query off by default (the live
2582        // AlphaCrypt `stack.rs` notes never answers `query`, so the `query`
2583        // half is not hardware-verified).
2584        let mut d = driver_with_sessions();
2585        d.take_notifications();
2586
2587        let pmt_bytes = build_ca_pmt_fixture(1549);
2588        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2589        d.add_service(&pmt).unwrap();
2590        d.device_mut().inbound.push_back(sb());
2591        d.pump(Duration::from_millis(10)).unwrap();
2592
2593        // Count the `update`-variant bytes — the ones the timer would
2594        // resend if enabled — not the `only` bytes `add_service` sent.
2595        let expected_query =
2596            build_ca_pmt(&pmt, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
2597        let expected_ok_descrambling = build_ca_pmt(
2598            &pmt,
2599            CaPmtListManagement::Update,
2600            CaPmtCmdId::OkDescrambling,
2601        )
2602        .to_bytes();
2603        let query_sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_query);
2604        let ok_sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_ok_descrambling);
2605
2606        // Even a very long tick must not trigger a re-query by default.
2607        d.pump(Duration::from_secs(1_000_000)).unwrap();
2608        feed(&mut d, sb());
2609        feed(&mut d, sb());
2610        assert_eq!(
2611            count_apdu_on_session(&d, CA_SESSION, &expected_query),
2612            query_sends_before,
2613            "the default (Duration::ZERO) must never send a re-query `query`"
2614        );
2615        assert_eq!(
2616            count_apdu_on_session(&d, CA_SESSION, &expected_ok_descrambling),
2617            ok_sends_before,
2618            "the default (Duration::ZERO) must never send a re-query `ok_descrambling`"
2619        );
2620    }
2621
2622    #[test]
2623    fn set_requery_interval_zero_disables_resend_after_being_enabled() {
2624        use broadcast_common::Parse;
2625
2626        let mut d = driver_with_sessions();
2627        // Enable, then explicitly disable again — proves `Duration::ZERO`
2628        // works as an override, not merely as an unexercised default.
2629        d.set_requery_interval(Duration::from_secs(5));
2630        d.set_requery_interval(Duration::ZERO);
2631        d.take_notifications();
2632
2633        let pmt_bytes = build_ca_pmt_fixture(1549);
2634        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2635        d.add_service(&pmt).unwrap();
2636        d.device_mut().inbound.push_back(sb());
2637        d.pump(Duration::from_millis(10)).unwrap();
2638
2639        // Count the `update`-variant bytes — the ones the timer would resend
2640        // if it fired — not the `only` bytes `add_service` sent (both are
2641        // `cmd_id = ok_descrambling`; they differ only in `list_management`).
2642        let expected_ca_pmt = build_ca_pmt(
2643            &pmt,
2644            CaPmtListManagement::Update,
2645            CaPmtCmdId::OkDescrambling,
2646        )
2647        .to_bytes();
2648        let sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
2649
2650        // Even a very long tick must not trigger a re-query once disabled.
2651        d.pump(Duration::from_secs(1000)).unwrap();
2652        feed(&mut d, sb());
2653        feed(&mut d, sb());
2654
2655        let sends_after = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
2656        assert_eq!(
2657            sends_after, sends_before,
2658            "Duration::ZERO must disable the re-query resend"
2659        );
2660    }
2661
2662    #[test]
2663    fn requery_timer_resends_every_active_service_not_just_one() {
2664        use broadcast_common::Parse;
2665
2666        let clock = TestClock::new();
2667        let mut d = driver_with_sessions().with_clock(clock.as_fn());
2668        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
2669        // enable it explicitly to exercise the timer.
2670        d.set_requery_interval(Duration::from_secs(10));
2671        d.take_notifications();
2672
2673        // Two services on the managed set: 1546 (`Only`, first-ever) and
2674        // 1547 (`Add`, joining the active set).
2675        let pmt1_bytes = build_ca_pmt_fixture(1546);
2676        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
2677        d.add_service(&pmt1).unwrap();
2678        d.device_mut().inbound.push_back(sb());
2679        clock.advance(Duration::from_millis(10));
2680        d.pump(Duration::from_millis(10)).unwrap();
2681
2682        let pmt2_bytes = build_ca_pmt_fixture(1547);
2683        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
2684        d.add_service(&pmt2).unwrap();
2685        d.device_mut().inbound.push_back(sb());
2686        clock.advance(Duration::from_millis(10));
2687        d.pump(Duration::from_millis(10)).unwrap();
2688        d.take_notifications();
2689
2690        // The `update`-variant PAIR the re-query timer rebuilds for each
2691        // service (#1032: `list_management = update`, `query` then
2692        // `ok_descrambling`, for every active service — `update` acts only
2693        // at programme level per EN 50221 §8.4.3.4, so unlike the pre-fix
2694        // `first`/`more`/`last` scheme it needs no position bookkeeping).
2695        let expected1_query =
2696            build_ca_pmt(&pmt1, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
2697        let expected1_ok = build_ca_pmt(
2698            &pmt1,
2699            CaPmtListManagement::Update,
2700            CaPmtCmdId::OkDescrambling,
2701        )
2702        .to_bytes();
2703        let expected2_query =
2704            build_ca_pmt(&pmt2, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
2705        let expected2_ok = build_ca_pmt(
2706            &pmt2,
2707            CaPmtListManagement::Update,
2708            CaPmtCmdId::OkDescrambling,
2709        )
2710        .to_bytes();
2711        let sends_before1_query = count_apdu_on_session(&d, CA_SESSION, &expected1_query);
2712        let sends_before1_ok = count_apdu_on_session(&d, CA_SESSION, &expected1_ok);
2713        let sends_before2_query = count_apdu_on_session(&d, CA_SESSION, &expected2_query);
2714        let sends_before2_ok = count_apdu_on_session(&d, CA_SESSION, &expected2_ok);
2715
2716        // Advance the clock past the 10s re-query interval: this queues
2717        // BOTH services' `query`+`ok_descrambling` pairs (`requery_tick`
2718        // iterates the whole active set), but EN 50221's half-duplex link
2719        // (the #337 one-write-per-turn rule) only lets one out per turn —
2720        // feed enough `T_SB` acks to flush all four queued writes.
2721        clock.advance(Duration::from_secs(11));
2722        d.pump(Duration::from_secs(11)).unwrap();
2723        feed(&mut d, sb());
2724        feed(&mut d, sb());
2725
2726        assert_eq!(
2727            count_apdu_on_session(&d, CA_SESSION, &expected1_query),
2728            sends_before1_query + 1,
2729            "expected service 1546's `query` resent exactly once on the shared tick"
2730        );
2731        assert_eq!(
2732            count_apdu_on_session(&d, CA_SESSION, &expected1_ok),
2733            sends_before1_ok + 1,
2734            "expected service 1546's `ok_descrambling` resent exactly once on the shared tick"
2735        );
2736        assert_eq!(
2737            count_apdu_on_session(&d, CA_SESSION, &expected2_query),
2738            sends_before2_query + 1,
2739            "expected service 1547's `query` resent exactly once on the shared tick"
2740        );
2741        assert_eq!(
2742            count_apdu_on_session(&d, CA_SESSION, &expected2_ok),
2743            sends_before2_ok + 1,
2744            "expected service 1547's `ok_descrambling` resent exactly once on the shared tick"
2745        );
2746    }
2747
2748    // --- #765 (retained under #1032): re-query must reflect the CURRENT
2749    // active set, rebuilt fresh each tick, not a stale/frozen list ---
2750
2751    #[test]
2752    fn requery_after_remove_resends_only_the_surviving_service() {
2753        use broadcast_common::Parse;
2754
2755        let clock = TestClock::new();
2756        let mut d = driver_with_sessions().with_clock(clock.as_fn());
2757        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
2758        // enable it explicitly to exercise the timer.
2759        d.set_requery_interval(Duration::from_secs(10));
2760        d.take_notifications();
2761
2762        let pmt1_bytes = build_ca_pmt_fixture(1546);
2763        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
2764        d.add_service(&pmt1).unwrap();
2765        d.device_mut().inbound.push_back(sb());
2766        clock.advance(Duration::from_millis(10));
2767        d.pump(Duration::from_millis(10)).unwrap();
2768
2769        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
2770        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
2771        d.add_service(&pmt2).unwrap();
2772        d.device_mut().inbound.push_back(sb());
2773        clock.advance(Duration::from_millis(10));
2774        d.pump(Duration::from_millis(10)).unwrap();
2775
2776        // Remove 1546: 1547 is now the SOLE survivor.
2777        d.remove_service(1546).unwrap();
2778        d.device_mut().inbound.push_back(sb());
2779        clock.advance(Duration::from_millis(10));
2780        d.pump(Duration::from_millis(10)).unwrap();
2781        d.take_notifications();
2782
2783        // The #765 bite (still relevant under #1032's `update`-based
2784        // resend): the timer must rebuild its re-query set from the CURRENT
2785        // active set on every tick, not a list frozen at `add_service` time
2786        // — so 1546 (removed) must never be resent, and 1547 (surviving)
2787        // must be resent as the `query`+`ok_descrambling` pair.
2788        let expected_survivor_query =
2789            build_ca_pmt(&pmt2, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
2790        let expected_survivor_ok = build_ca_pmt(
2791            &pmt2,
2792            CaPmtListManagement::Update,
2793            CaPmtCmdId::OkDescrambling,
2794        )
2795        .to_bytes();
2796        let expected_removed_query =
2797            build_ca_pmt(&pmt1, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
2798        let expected_removed_ok = build_ca_pmt(
2799            &pmt1,
2800            CaPmtListManagement::Update,
2801            CaPmtCmdId::OkDescrambling,
2802        )
2803        .to_bytes();
2804        let sends_survivor_query_before =
2805            count_apdu_on_session(&d, CA_SESSION, &expected_survivor_query);
2806        let sends_survivor_ok_before = count_apdu_on_session(&d, CA_SESSION, &expected_survivor_ok);
2807        let sends_removed_query_before =
2808            count_apdu_on_session(&d, CA_SESSION, &expected_removed_query);
2809        let sends_removed_ok_before = count_apdu_on_session(&d, CA_SESSION, &expected_removed_ok);
2810
2811        clock.advance(Duration::from_secs(11));
2812        d.pump(Duration::from_secs(11)).unwrap();
2813        feed(&mut d, sb());
2814
2815        assert_eq!(
2816            count_apdu_on_session(&d, CA_SESSION, &expected_survivor_query),
2817            sends_survivor_query_before + 1,
2818            "the surviving service's `query` must be re-sent"
2819        );
2820        assert_eq!(
2821            count_apdu_on_session(&d, CA_SESSION, &expected_survivor_ok),
2822            sends_survivor_ok_before + 1,
2823            "the surviving service's `ok_descrambling` must be re-sent"
2824        );
2825        assert_eq!(
2826            count_apdu_on_session(&d, CA_SESSION, &expected_removed_query),
2827            sends_removed_query_before,
2828            "the removed service's `query` must NOT be re-sent"
2829        );
2830        assert_eq!(
2831            count_apdu_on_session(&d, CA_SESSION, &expected_removed_ok),
2832            sends_removed_ok_before,
2833            "the removed service's `ok_descrambling` must NOT be re-sent"
2834        );
2835    }
2836
2837    // --- #763 Task 6: remove_service + clear managed state on CAM hot-plug ---
2838
2839    #[test]
2840    fn remove_service_sends_update_not_selected_and_drops_from_managed_state() {
2841        use broadcast_common::Parse;
2842
2843        let mut d = driver_with_sessions();
2844        d.take_notifications();
2845
2846        // 1546 (`Only`, distinct PIDs 0x100/0x101) and 1547 (`Add`, distinct
2847        // PIDs 0x200/0x201) — distinct PID sets so removing 1546 is
2848        // observably different from removing 1547.
2849        let pmt1_bytes = build_ca_pmt_fixture(1546);
2850        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
2851        d.add_service(&pmt1).unwrap();
2852        d.device_mut().inbound.push_back(sb());
2853        d.pump(Duration::from_millis(10)).unwrap();
2854
2855        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
2856        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
2857        d.add_service(&pmt2).unwrap();
2858        d.device_mut().inbound.push_back(sb());
2859        d.pump(Duration::from_millis(10)).unwrap();
2860
2861        d.remove_service(1546).unwrap();
2862        d.device_mut().inbound.push_back(sb());
2863        d.pump(Duration::from_millis(10)).unwrap();
2864
2865        // Oracle: the same PMT re-built directly via
2866        // dvb_ci::builder::build_ca_pmt with `Update`/`NotSelected` (EN 50221
2867        // §8.4.3.4 Table 25) — the exact bytes `remove_program` sends.
2868        let expected =
2869            build_ca_pmt(&pmt1, CaPmtListManagement::Update, CaPmtCmdId::NotSelected).to_bytes();
2870        assert_apdu_on_session(&d, CA_SESSION, &expected);
2871
2872        assert_eq!(
2873            d.descramble_pids(),
2874            &[0x0200, 0x0201],
2875            "1546's ES PIDs must be gone; 1547's must remain"
2876        );
2877        assert!(
2878            d.managed_ca().services().get(&1546).is_none(),
2879            "1546 must no longer be tracked"
2880        );
2881        assert!(
2882            d.managed_ca().services().get(&1547).is_some(),
2883            "1547 must remain tracked"
2884        );
2885    }
2886
2887    #[test]
2888    fn remove_service_of_untracked_program_is_a_no_op() {
2889        let mut d = driver_with_sessions();
2890        d.take_notifications();
2891
2892        let ops_before = d.device().ops.len();
2893        d.remove_service(0xFFFF).unwrap();
2894        assert_eq!(
2895            d.device().ops.len(),
2896            ops_before,
2897            "removing an untracked program must not send anything to the device"
2898        );
2899        assert!(
2900            d.managed_ca().services().is_empty(),
2901            "removing an untracked program must not disturb the (empty) managed set"
2902        );
2903    }
2904
2905    #[test]
2906    fn cam_removed_edge_clears_managed_state() {
2907        use broadcast_common::Parse;
2908        use dvb_ci::objects::ca_info::CaInfo;
2909        use dvb_si::tables::cat::CatSection;
2910
2911        let mut d = driver_with_sessions();
2912        d.take_notifications();
2913
2914        let pmt_bytes = build_ca_pmt_fixture(1546);
2915        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2916        d.add_service(&pmt).unwrap();
2917        d.device_mut().inbound.push_back(sb());
2918        d.pump(Duration::from_millis(10)).unwrap();
2919
2920        // Populate emm_pids too, via ca_info + set_cat, so the test proves
2921        // the fix clears more than just `services`.
2922        feed(
2923            &mut d,
2924            r_apdu(
2925                CA_SESSION,
2926                &ser(&CaInfo {
2927                    ca_system_ids: vec![0x0648],
2928                }),
2929            ),
2930        );
2931        d.take_notifications();
2932        let mut descriptors = Vec::new();
2933        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
2934        let cat_bytes = build_cat_fixture(&descriptors);
2935        let cat = CatSection::parse(&cat_bytes).unwrap();
2936        d.set_cat(&cat).unwrap();
2937
2938        assert!(
2939            !d.managed_ca().services().is_empty(),
2940            "precondition: a service is tracked"
2941        );
2942        assert!(
2943            !d.descramble_pids().is_empty(),
2944            "precondition: descramble_pids populated"
2945        );
2946        assert!(!d.emm_pids().is_empty(), "precondition: emm_pids populated");
2947
2948        // Module physically removed: a CamRemoved hot-plug edge.
2949        d.device_mut().slot.module_present = false;
2950        d.pump(Duration::from_millis(10)).unwrap();
2951        let notes = d.take_notifications();
2952        assert!(
2953            notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
2954            "expected CamRemoved, got {notes:?}"
2955        );
2956
2957        assert!(
2958            d.managed_ca().services().is_empty(),
2959            "services must be cleared on CamRemoved"
2960        );
2961        assert!(
2962            d.descramble_pids().is_empty(),
2963            "descramble_pids must be cleared on CamRemoved"
2964        );
2965        assert!(
2966            d.emm_pids().is_empty(),
2967            "emm_pids must be cleared on CamRemoved"
2968        );
2969    }
2970}