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