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