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    /// (ETSI EN 300 468 §6.2.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` (ETSI EN 300 468 §6.2.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.5) to the managed
404    /// CAS-layer state: extracts its `CA_descriptor`s (EN 300 468 §6.2.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.8 — 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        // Open RM, then the module enquires the host profile.
1125        feed(
1126            &mut d,
1127            r_data(
1128                1,
1129                &ser(&dvb_ci::spdu::OpenSessionRequest {
1130                    length_form: dvb_ci::length::LengthForm::Minimal,
1131                    resource: RESOURCE_MANAGER,
1132                }),
1133            ),
1134        );
1135        // Module → profile_enq on the RM session → host replies with its profile.
1136        feed(&mut d, r_apdu(RM_SESSION, &ser(&ProfileEnq::default())));
1137
1138        // Find the host's `profile` reply (tag 9F 80 11) in the written frames and
1139        // confirm it lists HOST_CONTROL.
1140        let want = dvb_ci::tag::PROFILE.to_bytes();
1141        let found = d.device().ops.iter().any(|op| {
1142            if let DeviceOp::Write(w) = op
1143                && let Some(pos) = w.windows(3).position(|x| x == want)
1144                && let Ok(p) = Profile::parse(&w[pos..])
1145            {
1146                return p.resources.contains(&HOST_CONTROL);
1147            }
1148            false
1149        });
1150        assert!(found, "profile reply must advertise HOST_CONTROL");
1151    }
1152
1153    #[test]
1154    fn mmi_menu_answ_and_answ_are_byte_exact_on_the_mmi_session() {
1155        use dvb_ci::objects::mmi_high::{Answ, AnswId, MenuAnsw};
1156
1157        let mut d = driver_with_sessions();
1158        let mmi_nb = MMI_SESSION;
1159
1160        // menu_answ(choice_ref = 2): the driver method must put the exact dvb-ci
1161        // MenuAnsw serialization on the wire, on the MMI session.
1162        d.mmi_menu_answer(2).unwrap();
1163        d.device_mut().inbound.push_back(sb());
1164        d.pump(Duration::from_millis(10)).unwrap();
1165        assert_apdu_on_session(
1166            &d,
1167            mmi_nb,
1168            &ser(&MenuAnsw {
1169                length_form: dvb_ci::length::LengthForm::Minimal,
1170                choice_ref: 2,
1171            }),
1172        );
1173
1174        // answ(answer, "1234"): byte-exact Answ serialization on the MMI session.
1175        d.mmi_enquiry_answer(b"1234").unwrap();
1176        d.device_mut().inbound.push_back(sb());
1177        d.pump(Duration::from_millis(10)).unwrap();
1178        assert_apdu_on_session(
1179            &d,
1180            mmi_nb,
1181            &ser(&Answ {
1182                length_form: dvb_ci::length::LengthForm::Minimal,
1183                answ_id: AnswId::Answer,
1184                text_chars: b"1234",
1185            }),
1186        );
1187    }
1188
1189    /// Assert some host write carries `session_number(session_nb)` immediately
1190    /// followed by the exact `apdu` bytes (byte-exact APDU on the right session).
1191    fn assert_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) {
1192        use dvb_ci::spdu::SessionNumber;
1193        let mut want = ser(&SessionNumber {
1194            length_form: dvb_ci::length::LengthForm::Minimal,
1195            session_nb,
1196        });
1197        want.extend_from_slice(apdu);
1198        let hit = d.device().ops.iter().any(|op| match op {
1199            DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
1200            _ => false,
1201        });
1202        assert!(
1203            hit,
1204            "expected APDU {apdu:02X?} on session {session_nb} (session-prefixed {want:02X?}) in writes"
1205        );
1206    }
1207
1208    /// How many host writes carry `session_number(session_nb)` immediately
1209    /// followed by the exact `apdu` bytes — used to distinguish an initial
1210    /// send from a later re-send (#763 Task 5's re-query timer).
1211    fn count_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) -> usize {
1212        use dvb_ci::spdu::SessionNumber;
1213        let mut want = ser(&SessionNumber {
1214            length_form: dvb_ci::length::LengthForm::Minimal,
1215            session_nb,
1216        });
1217        want.extend_from_slice(apdu);
1218        d.device()
1219            .ops
1220            .iter()
1221            .filter(|op| match op {
1222                DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
1223                _ => false,
1224            })
1225            .count()
1226    }
1227
1228    /// A [`MockCaDevice`] that reports a non-zero [`CaDevice::reset_settle`],
1229    /// like the Linux device (r10-O-10).
1230    struct SettleDevice {
1231        inner: MockCaDevice,
1232        settle: Duration,
1233    }
1234
1235    impl CaDevice for SettleDevice {
1236        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1237            self.inner.read(buf)
1238        }
1239        fn write(&mut self, buf: &[u8]) -> io::Result<()> {
1240            self.inner.write(buf)
1241        }
1242        fn reset(&mut self) -> io::Result<()> {
1243            self.inner.reset()
1244        }
1245        fn reset_settle(&self) -> Duration {
1246            self.settle
1247        }
1248        fn slot_info(&mut self) -> io::Result<SlotInfo> {
1249            self.inner.slot_info()
1250        }
1251        fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
1252            self.inner.poll(timeout)
1253        }
1254    }
1255
1256    const SETTLE: Duration = Duration::from_secs(3);
1257
1258    fn settling_driver(clock: &TestClock) -> Driver<SettleDevice> {
1259        Driver::new(SettleDevice {
1260            inner: MockCaDevice::new([]),
1261            settle: SETTLE,
1262        })
1263        .with_clock(clock.as_fn())
1264    }
1265
1266    fn create_tc_writes(d: &Driver<SettleDevice>) -> usize {
1267        d.device()
1268            .inner
1269            .ops
1270            .iter()
1271            .filter(|o| matches!(o, DeviceOp::Write(w) if w[0] == tags::CREATE_T_C))
1272            .count()
1273    }
1274
1275    /// A [`SettleDevice`] whose `write` is refused with `EINVAL`
1276    /// (`InvalidInput`) a set number of times, like the kernel's
1277    /// `dvb_ca_en50221_io_write` while the slot is not yet RUNNING.
1278    struct NotRunningDevice {
1279        inner: SettleDevice,
1280        refusals_left: u32,
1281        refused: u32,
1282    }
1283
1284    impl CaDevice for NotRunningDevice {
1285        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1286            self.inner.read(buf)
1287        }
1288        fn write(&mut self, buf: &[u8]) -> io::Result<()> {
1289            if self.refusals_left > 0 {
1290                self.refusals_left -= 1;
1291                self.refused += 1;
1292                return Err(io::Error::from(io::ErrorKind::InvalidInput));
1293            }
1294            self.inner.write(buf)
1295        }
1296        fn reset(&mut self) -> io::Result<()> {
1297            self.inner.reset()
1298        }
1299        fn reset_settle(&self) -> Duration {
1300            self.inner.reset_settle()
1301        }
1302        fn slot_info(&mut self) -> io::Result<SlotInfo> {
1303            self.inner.slot_info()
1304        }
1305        fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
1306            self.inner.poll(timeout)
1307        }
1308    }
1309
1310    fn not_running_driver(clock: &TestClock, refusals: u32) -> Driver<NotRunningDevice> {
1311        Driver::new(NotRunningDevice {
1312            inner: SettleDevice {
1313                inner: MockCaDevice::new([]),
1314                settle: SETTLE,
1315            },
1316            refusals_left: refusals,
1317            refused: 0,
1318        })
1319        .with_clock(clock.as_fn())
1320    }
1321
1322    fn create_tc_count(d: &Driver<NotRunningDevice>) -> usize {
1323        d.device()
1324            .inner
1325            .inner
1326            .ops
1327            .iter()
1328            .filter(|o| matches!(o, DeviceOp::Write(w) if w[0] == tags::CREATE_T_C))
1329            .count()
1330    }
1331
1332    /// Real hardware (DD CXD2099 + AlphaCrypt, 4th re-open in ~12 min): the
1333    /// first post-settle write came back `EINVAL` because the CAM had not
1334    /// reached the kernel's RUNNING state yet, and `pump` returned it. It must
1335    /// be retried until the slot accepts it.
1336    #[test]
1337    fn post_reset_einval_is_retried_until_the_slot_is_running() {
1338        let clock = TestClock::new();
1339        let mut d = not_running_driver(&clock, 3);
1340        d.init().unwrap();
1341        clock.advance(SETTLE + Duration::from_millis(100));
1342        for _ in 0..10 {
1343            d.pump(Duration::from_millis(10))
1344                .expect("a not-yet-RUNNING slot (EINVAL) must be retried, not reported");
1345            if create_tc_count(&d) == 1 {
1346                break;
1347            }
1348            clock.advance(Duration::from_millis(300));
1349        }
1350        assert_eq!(d.device().refused, 3, "the three refusals were consumed");
1351        assert_eq!(create_tc_count(&d), 1, "Create_T_C delivered exactly once");
1352    }
1353
1354    /// The retry window is bounded: a slot that never comes up is reported
1355    /// (with the original `InvalidInput`), not retried forever.
1356    #[test]
1357    fn post_reset_einval_is_reported_once_the_grace_window_has_passed() {
1358        let clock = TestClock::new();
1359        let mut d = not_running_driver(&clock, u32::MAX);
1360        let start = clock.0.get();
1361        d.init().unwrap();
1362        clock.advance(SETTLE + Duration::from_millis(100));
1363        let mut failure = None;
1364        for _ in 0..80 {
1365            match d.pump(Duration::from_millis(10)) {
1366                Ok(_) => clock.advance(Duration::from_millis(300)),
1367                Err(e) => {
1368                    failure = Some(e);
1369                    break;
1370                }
1371            }
1372        }
1373        let e = failure.expect("a slot that never runs must eventually be reported");
1374        assert_eq!(e.kind(), io::ErrorKind::InvalidInput);
1375        assert!(
1376            clock.0.get() >= start + SETTLE + SLOT_READY_GRACE,
1377            "reported before the grace window ended"
1378        );
1379        assert_eq!(create_tc_count(&d), 0);
1380    }
1381
1382    /// The re-query timer armed before the handshake completes must not put
1383    /// anything on the wire: with no service added there is nothing to
1384    /// re-query, and no `ca_pmt` APDU (tag `9F 80 32`) is ever written however
1385    /// long the pumping goes before the CAM answers.
1386    #[test]
1387    fn requery_armed_before_the_handshake_sends_nothing() {
1388        let clock = TestClock::new();
1389        let mut d = Driver::new(MockCaDevice::new([])).with_clock(clock.as_fn());
1390        d.set_requery_interval(Duration::from_secs(10));
1391        d.init().unwrap();
1392        for _ in 0..40 {
1393            clock.advance(Duration::from_secs(1));
1394            d.pump(Duration::from_millis(100)).unwrap();
1395        }
1396        let ca_pmt_tag = [0x9F, 0x80, 0x32];
1397        let ca_pmt_writes = d
1398            .device()
1399            .ops
1400            .iter()
1401            .filter(|op| matches!(op, DeviceOp::Write(w) if w.windows(3).any(|x| x == ca_pmt_tag)))
1402            .count();
1403        assert_eq!(
1404            ca_pmt_writes, 0,
1405            "re-query wrote a ca_pmt before any service"
1406        );
1407    }
1408
1409    /// r10-O-10 (a): a reset no longer blocks — `init` and `pump` return
1410    /// at once while the slot settles (the pre-fix `reset` slept 3 s).
1411    #[test]
1412    fn reset_does_not_block_pump() {
1413        let clock = TestClock::new();
1414        let mut d = settling_driver(&clock);
1415        let t = std::time::Instant::now();
1416        d.init().unwrap();
1417        d.pump(Duration::from_millis(10)).unwrap();
1418        assert!(t.elapsed() < Duration::from_secs(1), "reset/pump blocked");
1419        assert_eq!(d.device().inner.ops[0], DeviceOp::Reset);
1420    }
1421
1422    /// r10-O-10 (b): no transport traffic before the deadline; the normal
1423    /// sequence (exactly one Create_T_C, same bytes as a zero-settle
1424    /// device) after it.
1425    #[test]
1426    fn no_transport_traffic_until_settle_elapsed() {
1427        let clock = TestClock::new();
1428        let mut d = settling_driver(&clock);
1429        d.device_mut()
1430            .inner
1431            .inbound
1432            .push_back(vec![tags::C_T_C_REPLY, 0x01, 0x01]);
1433        d.init().unwrap();
1434        for _ in 0..5 {
1435            clock.advance(Duration::from_millis(500));
1436            d.pump(Duration::from_millis(10)).unwrap();
1437        }
1438        // t = 2.5 s < 3 s: still quiet, and the module frame is unread.
1439        assert_eq!(create_tc_writes(&d), 0);
1440        assert_eq!(
1441            d.device().inner.inbound.len(),
1442            1,
1443            "frame read while settling"
1444        );
1445
1446        clock.advance(Duration::from_millis(600));
1447        d.pump(Duration::from_millis(10)).unwrap();
1448        assert_eq!(create_tc_writes(&d), 1);
1449
1450        let mut z = Driver::new(MockCaDevice::new([]));
1451        z.init().unwrap();
1452        assert_eq!(
1453            d.device().inner.written()[..3],
1454            z.device().written()[..3],
1455            "post-settle Create_T_C differs from the zero-settle sequence"
1456        );
1457        // And the pump then proceeds normally: the queued reply was consumed
1458        // by that same post-settle pump.
1459        assert!(d.device().inner.inbound.is_empty());
1460    }
1461
1462    /// r10-O-10 (c): a second reset during settling restarts the deadline
1463    /// and drops the stale queued Create_T_C (still exactly one afterwards).
1464    #[test]
1465    fn second_reset_restarts_settle() {
1466        let clock = TestClock::new();
1467        let mut d = settling_driver(&clock);
1468        d.init().unwrap();
1469        clock.advance(Duration::from_secs(2));
1470        d.pump(Duration::from_millis(10)).unwrap();
1471        d.init().unwrap(); // second reset at t = 2 s -> deadline 5 s
1472        clock.advance(Duration::from_secs(2)); // t = 4 s: past the first deadline
1473        d.pump(Duration::from_millis(10)).unwrap();
1474        assert_eq!(create_tc_writes(&d), 0, "first deadline wrongly honoured");
1475        clock.advance(Duration::from_millis(1500)); // t = 5.5 s
1476        d.pump(Duration::from_millis(10)).unwrap();
1477        assert_eq!(create_tc_writes(&d), 1);
1478    }
1479
1480    /// r10-O-10 (d): slot sampling (hot-plug detection) keeps working while
1481    /// the transport is held back.
1482    #[test]
1483    fn slot_sampling_continues_while_settling() {
1484        let clock = TestClock::new();
1485        let mut d = settling_driver(&clock);
1486        d.init().unwrap();
1487        d.take_notifications();
1488        d.device_mut().inner.slot.module_present = false;
1489        clock.advance(Duration::from_millis(500));
1490        d.pump(Duration::from_millis(10)).unwrap();
1491        let notes = d.take_notifications();
1492        assert!(
1493            notes
1494                .iter()
1495                .any(|n| matches!(n, Notification::HotPlug(HotPlug::CamRemoved))),
1496            "{notes:?}"
1497        );
1498        assert_eq!(create_tc_writes(&d), 0);
1499    }
1500
1501    #[test]
1502    fn init_drives_reset_slotinfo_and_create_tc_to_device() {
1503        let mut d = Driver::new(MockCaDevice::new([]));
1504        d.init().unwrap();
1505        let ops = &d.device().ops;
1506        assert_eq!(ops[0], DeviceOp::Reset);
1507        assert_eq!(ops[1], DeviceOp::SlotInfo);
1508        assert!(matches!(&ops[2], DeviceOp::Write(w) if w[0] == tags::CREATE_T_C));
1509    }
1510
1511    #[test]
1512    fn reads_reply_then_polls_on_pump() {
1513        // Script the module accepting the connection.
1514        let dev = MockCaDevice::new([vec![tags::C_T_C_REPLY, 0x01, 0x01]]);
1515        let clock = TestClock::new();
1516        let mut d = Driver::new(dev).with_clock(clock.as_fn());
1517        d.init().unwrap();
1518        // first pump reads the C_T_C_Reply (activates the connection)
1519        assert!(d.pump(Duration::from_millis(100)).unwrap());
1520        // r10-W-21: `pump` now measures real (simulated, via TestClock)
1521        // elapsed time rather than trusting the `timeout` argument, so the
1522        // test advances the clock by the same amount `timeout` used to
1523        // stand in for.
1524        clock.advance(Duration::from_millis(100));
1525        // next pump has nothing to read → ticks → emits a poll write
1526        assert!(!d.pump(Duration::from_millis(100)).unwrap());
1527        let last = d.device().ops.last().unwrap();
1528        assert!(matches!(last, DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)));
1529    }
1530
1531    /// r10-W-21: `pump`'s stack-clock advance must reflect real (here,
1532    /// simulated) elapsed time, not the `timeout` argument on its own —
1533    /// pre-fix, calling `pump(1s)` twice back-to-back with almost no real
1534    /// time between them still advanced the stack's clock by a full second
1535    /// each time, exactly as if a full second really had passed.
1536    #[test]
1537    fn pump_advances_the_stack_clock_by_real_elapsed_time_not_by_timeout() {
1538        let dev = MockCaDevice::new([vec![tags::C_T_C_REPLY, 0x01, 0x01]]);
1539        let clock = TestClock::new();
1540        let mut d = Driver::new(dev).with_clock(clock.as_fn());
1541        d.init().unwrap();
1542        assert!(d.pump(Duration::from_secs(1)).unwrap());
1543
1544        // Only 1ms of (simulated) real time actually passed, even though
1545        // `timeout` claims a full second — nowhere near the 100ms poll
1546        // cadence, so no poll write should go out.
1547        clock.advance(Duration::from_millis(1));
1548        assert!(!d.pump(Duration::from_secs(1)).unwrap());
1549        assert!(
1550            d.device().ops.last().is_none_or(|op| !matches!(
1551                op,
1552                DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)
1553            )),
1554            "must not poll after only 1ms of real elapsed time, regardless of the 1s `timeout` argument"
1555        );
1556
1557        // Now really cross the poll interval.
1558        clock.advance(Duration::from_millis(100));
1559        assert!(!d.pump(Duration::from_secs(1)).unwrap());
1560        let last = d.device().ops.last().unwrap();
1561        assert!(matches!(last, DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)));
1562    }
1563
1564    // --- #726: CAM + card hot-plug notifications ---
1565
1566    #[test]
1567    fn cam_insert_edge_emits_cam_present_once_and_redrives_handshake() {
1568        let mut dev = MockCaDevice::new([]);
1569        dev.slot = SlotInfo {
1570            num: 0,
1571            module_ready: false,
1572            module_present: false,
1573        };
1574        let mut d = Driver::new(dev);
1575        d.init().unwrap();
1576        // The first-ever slot observation only establishes the baseline
1577        // (absent) — it must not itself claim a hot-plug edge.
1578        let notes = d.take_notifications();
1579        assert!(
1580            !notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
1581            "baseline observation must not fire CamPresent, got {notes:?}"
1582        );
1583        let resets_before = d
1584            .device()
1585            .ops
1586            .iter()
1587            .filter(|o| **o == DeviceOp::Reset)
1588            .count();
1589
1590        // Module physically inserted and ready.
1591        d.device_mut().slot = SlotInfo {
1592            num: 0,
1593            module_ready: true,
1594            module_present: true,
1595        };
1596        d.pump(Duration::from_millis(10)).unwrap();
1597
1598        let notes = d.take_notifications();
1599        let cam_present_count = notes
1600            .iter()
1601            .filter(|n| **n == Notification::HotPlug(HotPlug::CamPresent))
1602            .count();
1603        assert_eq!(
1604            cam_present_count, 1,
1605            "expected exactly one CamPresent, got {notes:?}"
1606        );
1607        // Handshake re-driven: a fresh Reset, and the last write is CREATE_T_C.
1608        let resets_after = d
1609            .device()
1610            .ops
1611            .iter()
1612            .filter(|o| **o == DeviceOp::Reset)
1613            .count();
1614        assert_eq!(
1615            resets_after,
1616            resets_before + 1,
1617            "expected one fresh Reset on re-insert"
1618        );
1619        assert!(
1620            matches!(d.device().ops.last(), Some(DeviceOp::Write(w)) if w[0] == tags::CREATE_T_C),
1621            "expected the handshake re-driven (CREATE_T_C written), got {:?}",
1622            d.device().ops.last()
1623        );
1624    }
1625
1626    #[test]
1627    fn cam_remove_edge_emits_cam_removed_and_re_insert_re_handshakes() {
1628        let mut d = driver_with_sessions();
1629        d.take_notifications();
1630
1631        // Module physically removed.
1632        d.device_mut().slot.module_present = false;
1633        d.pump(Duration::from_millis(10)).unwrap();
1634        let notes = d.take_notifications();
1635        assert!(
1636            notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
1637            "expected CamRemoved, got {notes:?}"
1638        );
1639
1640        // Session state was torn down: the MMI session from
1641        // `driver_with_sessions` no longer exists on the fresh stack, so an
1642        // answer to it now errors instead of silently going nowhere.
1643        d.mmi_menu_answer(0).unwrap();
1644        let notes = d.take_notifications();
1645        assert!(
1646            notes
1647                .iter()
1648                .any(|n| matches!(n, Notification::Error { .. })),
1649            "expected no open MMI session after teardown, got {notes:?}"
1650        );
1651
1652        // Re-insert: a fresh handshake starts (Reset + CamPresent).
1653        let resets_before = d
1654            .device()
1655            .ops
1656            .iter()
1657            .filter(|o| **o == DeviceOp::Reset)
1658            .count();
1659        d.device_mut().slot.module_present = true;
1660        d.device_mut().slot.module_ready = true;
1661        d.pump(Duration::from_millis(10)).unwrap();
1662        let notes = d.take_notifications();
1663        assert!(
1664            notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
1665            "expected CamPresent on re-insert, got {notes:?}"
1666        );
1667        let resets_after = d
1668            .device()
1669            .ops
1670            .iter()
1671            .filter(|o| **o == DeviceOp::Reset)
1672            .count();
1673        assert_eq!(resets_after, resets_before + 1, "expected a fresh Reset");
1674    }
1675
1676    #[test]
1677    fn slot_status_unchanged_across_polls_emits_no_hotplug_notifications() {
1678        let mut d = Driver::new(MockCaDevice::new([]));
1679        d.init().unwrap();
1680        d.take_notifications();
1681
1682        for _ in 0..5 {
1683            d.pump(Duration::from_millis(10)).unwrap();
1684        }
1685        let notes = d.take_notifications();
1686        assert!(
1687            !notes.iter().any(|n| matches!(
1688                n,
1689                Notification::HotPlug(HotPlug::CamPresent | HotPlug::CamRemoved)
1690            )),
1691            "unchanged slot status must not emit hot-plug notifications, got {notes:?}"
1692        );
1693    }
1694
1695    #[test]
1696    fn ca_info_caid_set_change_infers_card_inserted_then_changed() {
1697        use dvb_ci::objects::ca_info::CaInfo;
1698
1699        let mut d = driver_with_sessions();
1700        d.take_notifications();
1701
1702        // First ca_info: no CAIDs (baseline only, no notification).
1703        feed(
1704            &mut d,
1705            r_apdu(
1706                CA_SESSION,
1707                &ser(&CaInfo {
1708                    length_form: dvb_ci::length::LengthForm::Minimal,
1709                    ca_system_ids: vec![],
1710                }),
1711            ),
1712        );
1713        let notes = d.take_notifications();
1714        assert!(
1715            !notes.iter().any(|n| matches!(
1716                n,
1717                Notification::HotPlug(
1718                    HotPlug::CardInserted | HotPlug::CardChanged | HotPlug::CardRemoved
1719                )
1720            )),
1721            "first ca_info must only establish the baseline, got {notes:?}"
1722        );
1723
1724        // CAID set becomes populated: card inserted.
1725        feed(
1726            &mut d,
1727            r_apdu(
1728                CA_SESSION,
1729                &ser(&CaInfo {
1730                    length_form: dvb_ci::length::LengthForm::Minimal,
1731                    ca_system_ids: vec![0x0B00],
1732                }),
1733            ),
1734        );
1735        let notes = d.take_notifications();
1736        assert!(
1737            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1738            "expected CardInserted, got {notes:?}"
1739        );
1740
1741        // CAID set changes to a different non-empty set: card changed.
1742        feed(
1743            &mut d,
1744            r_apdu(
1745                CA_SESSION,
1746                &ser(&CaInfo {
1747                    length_form: dvb_ci::length::LengthForm::Minimal,
1748                    ca_system_ids: vec![0x1800],
1749                }),
1750            ),
1751        );
1752        let notes = d.take_notifications();
1753        assert!(
1754            notes.contains(&Notification::HotPlug(HotPlug::CardChanged)),
1755            "expected CardChanged, got {notes:?}"
1756        );
1757    }
1758
1759    #[test]
1760    fn ca_pmt_reply_descrambling_transition_infers_card_present_then_removed() {
1761        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1762
1763        fn reply(ca_enable: Option<CaEnable>) -> CaPmtReply {
1764            CaPmtReply {
1765                reserved_ca_enable: Default::default(),
1766                reserved_version: Default::default(),
1767                length_form: dvb_ci::length::LengthForm::Minimal,
1768                program_number: 1,
1769                version_number: 1,
1770                current_next_indicator: true,
1771                ca_enable,
1772                streams: vec![],
1773            }
1774        }
1775
1776        let mut d = driver_with_sessions();
1777        d.take_notifications();
1778
1779        // Baseline: descrambling not (yet) possible.
1780        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1781        let notes = d.take_notifications();
1782        assert!(
1783            !notes.iter().any(|n| matches!(
1784                n,
1785                Notification::HotPlug(HotPlug::CardInserted | HotPlug::CardRemoved)
1786            )),
1787            "first ca_pmt_reply must only establish the baseline, got {notes:?}"
1788        );
1789
1790        // false -> true: card-present inference.
1791        feed(
1792            &mut d,
1793            r_apdu(CA_SESSION, &ser(&reply(Some(CaEnable::Possible)))),
1794        );
1795        let notes = d.take_notifications();
1796        assert!(
1797            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1798            "expected CardInserted, got {notes:?}"
1799        );
1800
1801        // true -> false: card removed.
1802        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1803        let notes = d.take_notifications();
1804        assert!(
1805            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1806            "expected CardRemoved, got {notes:?}"
1807        );
1808    }
1809
1810    #[test]
1811    fn ca_pmt_reply_surfaces_typed_ca_enable() {
1812        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1813
1814        let mut d = driver_with_sessions();
1815        d.take_notifications();
1816
1817        // `CA_enable` = 0x03 (possible under conditions, technical dialogue) —
1818        // EN 50221 §8.4.3.5 Table 26.
1819        feed(
1820            &mut d,
1821            r_apdu(
1822                CA_SESSION,
1823                &ser(&CaPmtReply {
1824                    reserved_ca_enable: Default::default(),
1825                    reserved_version: Default::default(),
1826                    length_form: dvb_ci::length::LengthForm::Minimal,
1827                    program_number: 7,
1828                    version_number: 1,
1829                    current_next_indicator: true,
1830                    ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1831                    streams: vec![],
1832                }),
1833            ),
1834        );
1835        let notes = d.take_notifications();
1836        assert!(
1837            notes.contains(&Notification::CaPmtReply {
1838                program_number: 7,
1839                ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1840                descrambling_ok: true,
1841            }),
1842            "expected typed ca_enable on CaPmtReply, got {notes:?}"
1843        );
1844    }
1845
1846    #[test]
1847    fn ca_pmt_reply_flag_clear_surfaces_none() {
1848        use dvb_ci::objects::ca_pmt_reply::CaPmtReply;
1849
1850        let mut d = driver_with_sessions();
1851        d.take_notifications();
1852
1853        // Programme `CA_enable_flag` clear -> no programme-level status given
1854        // — EN 50221 §8.4.3.5 Table 26.
1855        feed(
1856            &mut d,
1857            r_apdu(
1858                CA_SESSION,
1859                &ser(&CaPmtReply {
1860                    reserved_ca_enable: Default::default(),
1861                    reserved_version: Default::default(),
1862                    length_form: dvb_ci::length::LengthForm::Minimal,
1863                    program_number: 7,
1864                    version_number: 1,
1865                    current_next_indicator: true,
1866                    ca_enable: None,
1867                    streams: vec![],
1868                }),
1869            ),
1870        );
1871        let notes = d.take_notifications();
1872        assert!(
1873            notes.contains(&Notification::CaPmtReply {
1874                program_number: 7,
1875                ca_enable: None,
1876                descrambling_ok: false,
1877            }),
1878            "expected ca_enable None on flag-clear CaPmtReply, got {notes:?}"
1879        );
1880    }
1881
1882    #[test]
1883    fn mmi_no_card_text_infers_card_removed() {
1884        use dvb_ci::objects::mmi_high::Enq;
1885
1886        let mut d = driver_with_sessions();
1887        d.take_notifications();
1888
1889        feed(
1890            &mut d,
1891            r_apdu(
1892                MMI_SESSION,
1893                &ser(&Enq {
1894                    reserved: Default::default(),
1895                    length_form: dvb_ci::length::LengthForm::Minimal,
1896                    blind_answer: false,
1897                    answer_text_length: 0,
1898                    text_chars: b"NO CARD detected - please insert your smart card",
1899                }),
1900            ),
1901        );
1902
1903        let notes = d.take_notifications();
1904        assert!(
1905            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1906            "expected CardRemoved inferred from MMI 'no card' text, got {notes:?}"
1907        );
1908    }
1909
1910    /// r10-O-13: the MMI keyword heuristic reports a card *transition*, not
1911    /// every menu that repeats the same text.
1912    #[test]
1913    fn mmi_card_keyword_heuristic_is_edge_triggered() {
1914        use dvb_ci::objects::mmi_high::Enq;
1915
1916        let enq = |text: &'static [u8]| {
1917            r_apdu(
1918                MMI_SESSION,
1919                &ser(&Enq {
1920                    reserved: Default::default(),
1921                    length_form: dvb_ci::length::LengthForm::Minimal,
1922                    blind_answer: false,
1923                    answer_text_length: 0,
1924                    text_chars: text,
1925                }),
1926            )
1927        };
1928        let mut d = driver_with_sessions();
1929        d.take_notifications();
1930        let count = |notes: &[Notification], hp: HotPlug| {
1931            notes
1932                .iter()
1933                .filter(|n| **n == Notification::HotPlug(hp))
1934                .count()
1935        };
1936
1937        // The same "no card" prompt three times: one CardRemoved, not three.
1938        for _ in 0..3 {
1939            feed(&mut d, enq(b"No card - please insert your smart card"));
1940        }
1941        let notes = d.take_notifications();
1942        assert_eq!(count(&notes, HotPlug::CardRemoved), 1, "{notes:?}");
1943        assert_eq!(count(&notes, HotPlug::CardInserted), 0);
1944
1945        // A present-card prompt is an edge: one CardInserted, then silence.
1946        for _ in 0..2 {
1947            feed(&mut d, enq(b"Your entitlement is valid"));
1948        }
1949        let notes = d.take_notifications();
1950        assert_eq!(count(&notes, HotPlug::CardInserted), 1, "{notes:?}");
1951        assert_eq!(count(&notes, HotPlug::CardRemoved), 0);
1952
1953        // Back to absent: a fresh edge, reported once.
1954        feed(&mut d, enq(b"Card removed"));
1955        feed(&mut d, enq(b"Card removed"));
1956        let notes = d.take_notifications();
1957        assert_eq!(count(&notes, HotPlug::CardRemoved), 1, "{notes:?}");
1958    }
1959
1960    #[test]
1961    fn pump_hotplug_delivers_cam_present_via_closure_exactly_once() {
1962        let mut dev = MockCaDevice::new([]);
1963        dev.slot = SlotInfo {
1964            num: 0,
1965            module_ready: false,
1966            module_present: false,
1967        };
1968        let mut d = Driver::new(dev);
1969        d.init().unwrap();
1970        d.take_notifications(); // drop the baseline observation
1971
1972        // Module physically inserted and ready.
1973        d.device_mut().slot = SlotInfo {
1974            num: 0,
1975            module_ready: true,
1976            module_present: true,
1977        };
1978
1979        let mut seen = Vec::new();
1980        d.pump_hotplug(Duration::from_millis(10), |hp| seen.push(hp))
1981            .unwrap();
1982
1983        assert_eq!(
1984            seen,
1985            vec![HotPlug::CamPresent],
1986            "expected the closure to receive HotPlug::CamPresent exactly once, got {seen:?}"
1987        );
1988    }
1989
1990    // --- #763 Task 3: ManagedCa + add_service ---
1991
1992    /// A `CA_descriptor` TLV (ISO/IEC 13818-1 §2.6.16): tag `0x09`, len `4`,
1993    /// `CA_system_id`(2), `reserved(3)`/`CA_PID`(13).
1994    pub(crate) fn ca_descriptor(ca_system_id: u16, pid: Pid) -> [u8; 6] {
1995        [
1996            0x09,
1997            0x04,
1998            (ca_system_id >> 8) as u8,
1999            ca_system_id as u8,
2000            0xE0 | ((pid.value() >> 8) as u8 & 0x1F),
2001            pid.value() as u8,
2002        ]
2003    }
2004
2005    /// A synthetic scrambled-service PMT: programme-level `CA_descriptor`
2006    /// (`CA_system_id` `0x0500` = Viaccess, a real assigned value per the
2007    /// TSDuck CA-system registry consumed by `dvb_si::descriptors::ca::ca_system_name`),
2008    /// one scrambled H.264 video ES (own `CA_descriptor`), and one clear AAC
2009    /// audio ES.
2010    ///
2011    /// **Provenance:** no committed capture in this repo's fixture corpus
2012    /// carries a scrambled PMT — `fixtures/dvb-si/tnt-5w-12732v-isi6-10s.ts`'s
2013    /// five PMTs (verified via `cargo run -p dvb-tools -- dump ... --json`)
2014    /// are all clear/FTA services, and no CA-descriptor-bearing capture exists
2015    /// under `private/fixtures/` either. This hand-rolls the wire bytes per
2016    /// ISO/IEC 13818-1 §2.4.4.8's PMT syntax instead, mirroring the exact
2017    /// precedent already established by `dvb-ci/src/builder.rs`'s
2018    /// `build_test_pmt()` (a hand-rolled buffer "that mirrors a real
2019    /// CA-protected service") — real `CA_system_id`/`stream_type` values, real
2020    /// CRC, just not sourced from an off-air capture.
2021    pub(crate) fn build_ca_pmt_fixture(program_number: u16) -> Vec<u8> {
2022        const VIACCESS: u16 = 0x0500;
2023        let prog_ca = ca_descriptor(VIACCESS, Pid::new(0x0064));
2024        let es0_ca = ca_descriptor(VIACCESS, Pid::new(0x0065));
2025
2026        let mut body = Vec::new();
2027        body.push(0x02); // table_id (PMT)
2028        body.push(0); // section_length placeholder (fixed up below)
2029        body.push(0);
2030        body.extend_from_slice(&program_number.to_be_bytes());
2031        body.push(0xC3); // reserved(2)='11' | version(5)=1 | current_next=1
2032        body.push(0x00); // section_number
2033        body.push(0x00); // last_section_number
2034        body.push(0xE0 | 0x01); // reserved(3) | PCR_PID(13) = 0x0100
2035        body.push(0x00);
2036        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
2037        body.push(prog_ca.len() as u8);
2038        body.extend_from_slice(&prog_ca);
2039        // ES0: H.264 video, pid 0x0100, scrambled (own CA_descriptor).
2040        body.push(0x1B);
2041        body.push(0xE0 | 0x01);
2042        body.push(0x00);
2043        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
2044        body.push(es0_ca.len() as u8);
2045        body.extend_from_slice(&es0_ca);
2046        // ES1: AAC ADTS audio, pid 0x0101, clear.
2047        body.push(0x0F);
2048        body.push(0xE0 | 0x01);
2049        body.push(0x01);
2050        body.push(0xF0);
2051        body.push(0x00);
2052
2053        let section_length = body.len() - 3 + 4;
2054        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
2055        body[2] = section_length as u8;
2056        let crc = broadcast_common::crc32_mpeg2::compute(&body);
2057        body.extend_from_slice(&crc.to_be_bytes());
2058        body
2059    }
2060
2061    /// Same layout as [`build_ca_pmt_fixture`] but with the PCR carried on its
2062    /// own **dedicated** `PCR_PID` (`0x00FF`) — distinct from every ES PID
2063    /// (`0x0100`/`0x0101`) and CA PID (`0x0064`/`0x0065`) — a legitimate DVB
2064    /// config (ISO/IEC 13818-1 §2.4.4.8) that `build_ca_pmt_fixture`'s
2065    /// `PCR_PID == video ES PID` masks: the #763 final-review regression
2066    /// fixture for `required_pids`/`feed_ts` PCR routing.
2067    pub(crate) fn build_ca_pmt_fixture_dedicated_pcr(program_number: u16) -> Vec<u8> {
2068        const VIACCESS: u16 = 0x0500;
2069        let prog_ca = ca_descriptor(VIACCESS, Pid::new(0x0064));
2070        let es0_ca = ca_descriptor(VIACCESS, Pid::new(0x0065));
2071
2072        let mut body = Vec::new();
2073        body.push(0x02); // table_id (PMT)
2074        body.push(0); // section_length placeholder (fixed up below)
2075        body.push(0);
2076        body.extend_from_slice(&program_number.to_be_bytes());
2077        body.push(0xC3); // reserved(2)='11' | version(5)=1 | current_next=1
2078        body.push(0x00); // section_number
2079        body.push(0x00); // last_section_number
2080        body.push(0xE0); // reserved(3) | PCR_PID(13) high byte = 0x00FF >> 8
2081        body.push(0xFF); // PCR_PID low byte — dedicated, outside the ES/CA set
2082        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
2083        body.push(prog_ca.len() as u8);
2084        body.extend_from_slice(&prog_ca);
2085        // ES0: H.264 video, pid 0x0100, scrambled (own CA_descriptor).
2086        body.push(0x1B);
2087        body.push(0xE0 | 0x01);
2088        body.push(0x00);
2089        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
2090        body.push(es0_ca.len() as u8);
2091        body.extend_from_slice(&es0_ca);
2092        // ES1: AAC ADTS audio, pid 0x0101, clear.
2093        body.push(0x0F);
2094        body.push(0xE0 | 0x01);
2095        body.push(0x01);
2096        body.push(0xF0);
2097        body.push(0x00);
2098
2099        let section_length = body.len() - 3 + 4;
2100        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
2101        body[2] = section_length as u8;
2102        let crc = broadcast_common::crc32_mpeg2::compute(&body);
2103        body.extend_from_slice(&crc.to_be_bytes());
2104        body
2105    }
2106
2107    /// Same layout as [`build_ca_pmt_fixture`] but with no `CA_descriptor`
2108    /// anywhere (an ordinary clear/FTA service) — the negative-control PMT for
2109    /// [`CaError::NoCaDescriptor`].
2110    pub(crate) fn build_clear_pmt_fixture(program_number: u16) -> Vec<u8> {
2111        let mut body = Vec::new();
2112        body.push(0x02);
2113        body.push(0);
2114        body.push(0);
2115        body.extend_from_slice(&program_number.to_be_bytes());
2116        body.push(0xC3);
2117        body.push(0x00);
2118        body.push(0x00);
2119        body.push(0xE0 | 0x01);
2120        body.push(0x00);
2121        body.push(0xF0); // program_info_length = 0
2122        body.push(0x00);
2123        // ES0: H.264 video, pid 0x0100, clear.
2124        body.push(0x1B);
2125        body.push(0xE0 | 0x01);
2126        body.push(0x00);
2127        body.push(0xF0);
2128        body.push(0x00);
2129
2130        let section_length = body.len() - 3 + 4;
2131        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
2132        body[2] = section_length as u8;
2133        let crc = broadcast_common::crc32_mpeg2::compute(&body);
2134        body.extend_from_slice(&crc.to_be_bytes());
2135        body
2136    }
2137
2138    #[test]
2139    fn add_service_builds_and_sends_ca_pmt_matching_builder_oracle() {
2140        use broadcast_common::Parse;
2141
2142        let mut d = driver_with_sessions();
2143        d.take_notifications();
2144
2145        let pmt_bytes = build_ca_pmt_fixture(1546);
2146        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2147
2148        d.add_service(&pmt).unwrap();
2149        d.device_mut().inbound.push_back(sb());
2150        d.pump(Duration::from_millis(10)).unwrap();
2151
2152        // Oracle: the same PMT built directly via dvb_ci::builder::build_ca_pmt
2153        // with `Only` (first-ever service on an empty managed set) +
2154        // `ok_descrambling`.
2155        let expected =
2156            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
2157        assert_apdu_on_session(&d, CA_SESSION, &expected);
2158
2159        // The service was recorded with its ES/CA PIDs.
2160        let svc = d
2161            .managed_ca()
2162            .services()
2163            .get(&1546)
2164            .expect("program_number 1546 must be tracked after add_service");
2165        assert_eq!(svc.es_pids, vec![Pid::new(0x0100), Pid::new(0x0101)]);
2166        assert_eq!(svc.ca_pids, vec![Pid::new(0x0064), Pid::new(0x0065)]);
2167        assert_eq!(svc.cmd, CaPmtCmdId::OkDescrambling);
2168        assert_eq!(svc.last_ca_enable, None);
2169    }
2170
2171    #[test]
2172    fn add_service_rejects_pmt_without_ca_descriptor() {
2173        use broadcast_common::Parse;
2174
2175        let mut d = driver_with_sessions();
2176        let pmt_bytes = build_clear_pmt_fixture(999);
2177        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2178
2179        let err = d.add_service(&pmt).unwrap_err();
2180        assert!(
2181            matches!(
2182                err,
2183                CaError::NoCaDescriptor {
2184                    program_number: 999
2185                }
2186            ),
2187            "expected NoCaDescriptor{{program_number: 999}}, got {err:?}"
2188        );
2189        assert!(
2190            d.managed_ca().services().is_empty(),
2191            "a rejected PMT must not be recorded"
2192        );
2193    }
2194
2195    /// r10-W-20: the re-query timer must surface a corrupt stored PMT as an
2196    /// `Err` from the public `pump` entry point — never a panic — and the
2197    /// failure must be sticky: a corrupt (or CAM-supplied) service must not
2198    /// silently swallow the healthy services' resend, and must keep
2199    /// reporting on every later tick. A service with truncated stored
2200    /// `pmt_raw` (corruption of the caller's own state after a successful
2201    /// add — the only corrupt shape the stored bytes can hold, since
2202    /// `add_service` re-serializes them) joins a healthy service added
2203    /// through the public `add_service`; when the 5s timer fires, the
2204    /// healthy pair still goes out, the corrupt one's re-parse failure is
2205    /// recorded, and `pump` returns it as `Err` — twice in a row.
2206    #[test]
2207    fn requery_tick_serialize_failure_errors_from_pump_instead_of_panicking() {
2208        use broadcast_common::Parse;
2209
2210        let clock = TestClock::new();
2211        let mut d = driver_with_sessions().with_clock(clock.as_fn());
2212
2213        // A healthy service through the public path (feeds its CaInfo like
2214        // `requery_timer_resends_ca_pmt_then_reply_change_emits_one_entitlement`).
2215        let good_bytes = build_ca_pmt_fixture(1);
2216        let good = PmtSection::parse(&good_bytes).unwrap();
2217        assert_eq!(good.program_number, 1, "fixture precondition");
2218        d.add_service(&good).unwrap();
2219
2220        // The corrupt service, recorded via the same internals
2221        // `add_service` uses (its raw bytes cannot be written by the
2222        // serializer, so they never reach the stored set through the
2223        // public path).
2224        let pmt_bytes = build_ca_pmt_fixture(1546);
2225        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2226        let built = d
2227            .build_ca_pmt_filtered(
2228                &pmt,
2229                CaPmtListManagement::Update,
2230                CaPmtCmdId::OkDescrambling,
2231            )
2232            .try_to_bytes()
2233            .expect("fixture precondition: the good PMT encodes");
2234        let corrupt_raw = pmt_bytes[..pmt_bytes.len() - 6].to_vec();
2235        d.managed.record(
2236            1546,
2237            managed::service_of(&pmt, CaPmtCmdId::OkDescrambling, built, corrupt_raw),
2238        );
2239        d.take_notifications();
2240        // Drain every queued host write with module T_SBs (the transport
2241        // flushes one write per module turn, #337; `feed`'s tail leaves
2242        // handshake writes and `add_service` added one more).
2243        for _ in 0..16 {
2244            d.device_mut().inbound.push_back(sb());
2245            d.pump(Duration::from_millis(10)).unwrap();
2246        }
2247        d.set_requery_interval(Duration::from_secs(5));
2248
2249        // Advance past the interval and pump idle: the timer fires, the
2250        // healthy service's re-query pair goes out, and the corrupt one
2251        // fails its re-parse — an Err out of `pump`, not a panic.
2252        clock.advance(Duration::from_secs(5));
2253        let err = d
2254            .pump(Duration::ZERO)
2255            .expect_err("re-query of a corrupt stored PMT must error, not panic");
2256        assert!(
2257            matches!(
2258                err.get_ref(),
2259                Some(e) if e.downcast_ref::<CaError>()
2260                    .is_some_and(|ca| matches!(ca, CaError::PmtParse(_)))
2261            ),
2262            "expected CaError::PmtParse from the corrupt stored bytes, got {err:?}"
2263        );
2264        assert!(
2265            err.to_string().contains("stored PMT re-parse failed"),
2266            "error must name the re-parse failure, got: {err}"
2267        );
2268        // Sticky-but-not-swallowing: the failure was returned after the
2269        // healthy pair went out this tick; drain it, and the NEXT fire
2270        // re-detects the same corrupt stored bytes (never silently quiet).
2271        assert!(d.pump(Duration::ZERO).is_ok());
2272        clock.advance(Duration::from_secs(5));
2273        let err2 = d
2274            .pump(Duration::ZERO)
2275            .expect_err("the corrupt service must fail every re-query");
2276        assert!(
2277            err2.to_string().contains("stored PMT re-parse failed"),
2278            "failure must persist, got: {err2}"
2279        );
2280    }
2281
2282    /// The constructor-level corrupt PMT: a 5000-byte programme CA loop
2283    /// (CAID `0x0B00`), past dvb-ci's 4095-byte `program_info_length` cap.
2284    /// dvb-si's parse only 12-bit-checks lengths against the physical
2285    /// buffer, so this shape exists only via `PmtSection::new` — standing
2286    /// in for a corrupt reassembly reaching `add_service` as a value.
2287    fn corrupt_ca_section_ctor() -> PmtSection<'static> {
2288        const LOOP: usize = 5000;
2289        let mut prog_info: Vec<u8> = Vec::with_capacity(LOOP);
2290        for _ in 0..LOOP / 4 {
2291            // CA_descriptor: CAID 0x0B00, CA_PID 0x0100 (4-byte body).
2292            prog_info.extend_from_slice(&[0x09u8, 0x02, 0x0B, 0x00]);
2293        }
2294        assert_eq!(prog_info.len(), LOOP);
2295        PmtSection::new(
2296            1,
2297            1,
2298            true,
2299            0,
2300            0,
2301            Pid::new(0x0200),
2302            dvb_si::descriptors::DescriptorLoop::new(Box::leak(prog_info.into_boxed_slice())),
2303            Vec::new(),
2304        )
2305    }
2306
2307    /// r10-W-20: `add_service` must reject a PMT whose CA loop cannot be
2308    /// encoded into a `ca_pmt` (over-range `program_info_length`) as
2309    /// `CaError::Serialize` from the public entry point — pre-fix this
2310    /// reached `.to_bytes()` and panicked inside the dvb-ci serializer.
2311    #[test]
2312    fn add_service_serialize_failure_returns_err_instead_of_panicking() {
2313        let mut d = driver_with_sessions();
2314        d.managed.set_cam_caids([0x0B00u16].into_iter().collect());
2315        let pmt = corrupt_ca_section_ctor();
2316        let err = d
2317            .add_service(&pmt)
2318            .expect_err("a ca_pmt with no valid encoding must error, not panic");
2319        assert!(
2320            matches!(err, CaError::Serialize(_)),
2321            "expected CaError::Serialize, got {err:?}"
2322        );
2323        assert!(
2324            d.managed_ca().services().is_empty(),
2325            "a rejected PMT must not be recorded"
2326        );
2327    }
2328
2329    #[test]
2330    fn add_service_second_call_uses_add_list_management() {
2331        use broadcast_common::Parse;
2332
2333        let mut d = driver_with_sessions();
2334        d.take_notifications();
2335
2336        let pmt1_bytes = build_ca_pmt_fixture(1546);
2337        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
2338        d.add_service(&pmt1).unwrap();
2339        d.device_mut().inbound.push_back(sb());
2340        d.pump(Duration::from_millis(10)).unwrap();
2341
2342        let pmt2_bytes = build_ca_pmt_fixture(1547);
2343        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
2344        d.add_service(&pmt2).unwrap();
2345        d.device_mut().inbound.push_back(sb());
2346        d.pump(Duration::from_millis(10)).unwrap();
2347
2348        // Second service joins an already-active set → `Add`, not `Only`.
2349        let expected2 =
2350            build_ca_pmt(&pmt2, CaPmtListManagement::Add, CaPmtCmdId::OkDescrambling).to_bytes();
2351        assert_apdu_on_session(&d, CA_SESSION, &expected2);
2352
2353        assert_eq!(d.managed_ca().services().len(), 2);
2354    }
2355
2356    // --- #1067: managed add_service/requery must CAID-filter like the raw
2357    // `descramble` path already does ---
2358
2359    /// Same layout as [`build_ca_pmt_fixture`] but with TWO programme-level
2360    /// `CA_descriptor`s (Viaccess `0x0500` and Nagravision `0x1800` — both
2361    /// real assigned values per the TSDuck CA-system registry, mirroring
2362    /// `dvb_ci::builder`'s own two-CAID `build_test_pmt` fixture) and no
2363    /// ES-level CA — the #1067 regression fixture: a CAM that advertises
2364    /// only one of the two CAIDs must have the other filtered out of the
2365    /// sent `ca_pmt`.
2366    fn build_ca_pmt_fixture_two_caids(program_number: u16) -> Vec<u8> {
2367        const VIACCESS: u16 = 0x0500;
2368        const NAGRA: u16 = 0x1800;
2369        let prog_ca_a = ca_descriptor(VIACCESS, Pid::new(0x0064));
2370        let prog_ca_b = ca_descriptor(NAGRA, Pid::new(0x0066));
2371        let mut prog_ca = Vec::new();
2372        prog_ca.extend_from_slice(&prog_ca_a);
2373        prog_ca.extend_from_slice(&prog_ca_b);
2374
2375        let mut body = Vec::new();
2376        body.push(0x02); // table_id (PMT)
2377        body.push(0);
2378        body.push(0);
2379        body.extend_from_slice(&program_number.to_be_bytes());
2380        body.push(0xC3);
2381        body.push(0x00);
2382        body.push(0x00);
2383        body.push(0xE0 | 0x01); // PCR_PID = 0x0100
2384        body.push(0x00);
2385        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
2386        body.push(prog_ca.len() as u8);
2387        body.extend_from_slice(&prog_ca);
2388        // ES0: clear AAC audio, pid 0x0100 — no ES-level CA, so this
2389        // fixture isolates the programme-level filter.
2390        body.push(0x0F);
2391        body.push(0xE0 | 0x01);
2392        body.push(0x00);
2393        body.push(0xF0);
2394        body.push(0x00);
2395
2396        let section_length = body.len() - 3 + 4;
2397        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
2398        body[2] = section_length as u8;
2399        let crc = broadcast_common::crc32_mpeg2::compute(&body);
2400        body.extend_from_slice(&crc.to_be_bytes());
2401        body
2402    }
2403
2404    #[test]
2405    fn add_service_filters_ca_descriptors_to_cam_advertised_caids() {
2406        use broadcast_common::Parse;
2407        use dvb_ci::objects::ca_info::CaInfo;
2408
2409        let mut d = driver_with_sessions();
2410        d.take_notifications();
2411
2412        // CAM advertises ONLY Viaccess (0x0500); the PMT carries both
2413        // Viaccess and Nagravision (0x1800) programme-level CA_descriptors.
2414        feed(
2415            &mut d,
2416            r_apdu(
2417                CA_SESSION,
2418                &ser(&CaInfo {
2419                    length_form: dvb_ci::length::LengthForm::Minimal,
2420                    ca_system_ids: vec![0x0500],
2421                }),
2422            ),
2423        );
2424        d.take_notifications();
2425
2426        let pmt_bytes = build_ca_pmt_fixture_two_caids(1560);
2427        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2428
2429        // Oracle: `dvb_ci::builder::build_ca_pmt_for_caids` — the exact
2430        // filter the raw `descramble` path already applies
2431        // (`CiStack::build_ca_pmt_bytes`) — vs. the unfiltered `build_ca_pmt`
2432        // a pre-fix `add_service` sent.
2433        let expected_filtered = dvb_ci::builder::build_ca_pmt_for_caids(
2434            &pmt,
2435            &[0x0500],
2436            CaPmtListManagement::Only,
2437            CaPmtCmdId::OkDescrambling,
2438        )
2439        .to_bytes();
2440        let unfiltered =
2441            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
2442        assert_ne!(
2443            expected_filtered, unfiltered,
2444            "precondition: the fixture's two CAIDs must make filtered != unfiltered"
2445        );
2446        let sends_filtered_before = count_apdu_on_session(&d, CA_SESSION, &expected_filtered);
2447        let sends_unfiltered_before = count_apdu_on_session(&d, CA_SESSION, &unfiltered);
2448
2449        d.add_service(&pmt).unwrap();
2450        d.device_mut().inbound.push_back(sb());
2451        d.pump(Duration::from_millis(10)).unwrap();
2452
2453        assert_eq!(
2454            count_apdu_on_session(&d, CA_SESSION, &expected_filtered),
2455            sends_filtered_before + 1,
2456            "add_service must send the CAID-filtered ca_pmt"
2457        );
2458        assert_eq!(
2459            count_apdu_on_session(&d, CA_SESSION, &unfiltered),
2460            sends_unfiltered_before,
2461            "add_service must NOT send the unfiltered ca_pmt once the CAM's CAIDs are known"
2462        );
2463    }
2464
2465    #[test]
2466    fn requery_timer_filters_ca_descriptors_to_cam_advertised_caids() {
2467        use broadcast_common::Parse;
2468        use dvb_ci::objects::ca_info::CaInfo;
2469
2470        let clock = TestClock::new();
2471        let mut d = driver_with_sessions().with_clock(clock.as_fn());
2472        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
2473        // enable it explicitly to exercise the timer.
2474        d.set_requery_interval(Duration::from_secs(10));
2475        d.take_notifications();
2476
2477        let pmt_bytes = build_ca_pmt_fixture_two_caids(1561);
2478        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2479        // add_service before any ca_info is known: unfiltered, per the
2480        // documented fallback (no CAID info to filter against yet).
2481        d.add_service(&pmt).unwrap();
2482        d.device_mut().inbound.push_back(sb());
2483        clock.advance(Duration::from_millis(10));
2484        d.pump(Duration::from_millis(10)).unwrap();
2485
2486        // The CAM's ca_info now arrives, advertising only Viaccess (0x0500).
2487        feed(
2488            &mut d,
2489            r_apdu(
2490                CA_SESSION,
2491                &ser(&CaInfo {
2492                    length_form: dvb_ci::length::LengthForm::Minimal,
2493                    ca_system_ids: vec![0x0500],
2494                }),
2495            ),
2496        );
2497        d.take_notifications();
2498
2499        // #1032: the timer resends a `list_management = update` PAIR —
2500        // `query` then `ok_descrambling` — both CAID-filtered (#1067).
2501        let expected_query_filtered = dvb_ci::builder::build_ca_pmt_for_caids(
2502            &pmt,
2503            &[0x0500],
2504            CaPmtListManagement::Update,
2505            CaPmtCmdId::Query,
2506        )
2507        .to_bytes();
2508        let expected_ok_filtered = dvb_ci::builder::build_ca_pmt_for_caids(
2509            &pmt,
2510            &[0x0500],
2511            CaPmtListManagement::Update,
2512            CaPmtCmdId::OkDescrambling,
2513        )
2514        .to_bytes();
2515        let unfiltered_query =
2516            build_ca_pmt(&pmt, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
2517        let unfiltered_ok = build_ca_pmt(
2518            &pmt,
2519            CaPmtListManagement::Update,
2520            CaPmtCmdId::OkDescrambling,
2521        )
2522        .to_bytes();
2523        assert_ne!(
2524            expected_query_filtered, unfiltered_query,
2525            "precondition: the fixture's two CAIDs must make filtered != unfiltered (query)"
2526        );
2527        assert_ne!(
2528            expected_ok_filtered, unfiltered_ok,
2529            "precondition: the fixture's two CAIDs must make filtered != unfiltered (ok_descrambling)"
2530        );
2531        let sends_query_before = count_apdu_on_session(&d, CA_SESSION, &expected_query_filtered);
2532        let sends_ok_before = count_apdu_on_session(&d, CA_SESSION, &expected_ok_filtered);
2533        let sends_unfiltered_query_before =
2534            count_apdu_on_session(&d, CA_SESSION, &unfiltered_query);
2535        let sends_unfiltered_ok_before = count_apdu_on_session(&d, CA_SESSION, &unfiltered_ok);
2536
2537        clock.advance(Duration::from_secs(11));
2538        d.pump(Duration::from_secs(11)).unwrap();
2539        feed(&mut d, sb());
2540
2541        assert_eq!(
2542            count_apdu_on_session(&d, CA_SESSION, &expected_query_filtered),
2543            sends_query_before + 1,
2544            "the re-query `query` half must be CAID-filtered to the CAM's advertised CAIDs"
2545        );
2546        assert_eq!(
2547            count_apdu_on_session(&d, CA_SESSION, &expected_ok_filtered),
2548            sends_ok_before + 1,
2549            "the re-query `ok_descrambling` half must be CAID-filtered to the CAM's advertised CAIDs"
2550        );
2551        assert_eq!(
2552            count_apdu_on_session(&d, CA_SESSION, &unfiltered_query),
2553            sends_unfiltered_query_before,
2554            "the re-query resend must NOT be the unfiltered `query` ca_pmt"
2555        );
2556        assert_eq!(
2557            count_apdu_on_session(&d, CA_SESSION, &unfiltered_ok),
2558            sends_unfiltered_ok_before,
2559            "the re-query resend must NOT be the unfiltered `ok_descrambling` ca_pmt"
2560        );
2561    }
2562
2563    // --- #763 Task 4: set_cat + emm_pids/descramble_pids ---
2564
2565    /// A hand-built CAT section (ISO/IEC 13818-1 §2.4.4.5): table_id 0x01, a
2566    /// flat descriptor loop of `CA_descriptor`s (EN 300 468 §6.2.16, tag
2567    /// 0x09; the `ca_descriptor` helper above builds the same TLV used for
2568    /// PMTs). No off-air CAT capture exists in this repo's fixture corpus
2569    /// (verified: none of the committed `.ts` captures carry PID 0x0001),
2570    /// mirroring the same hand-rolled-fixture precedent as
2571    /// `build_ca_pmt_fixture` and `dvb_si::tables::cat`'s own unit tests.
2572    pub(crate) fn build_cat_fixture(descriptors: &[u8]) -> Vec<u8> {
2573        const EXTENSION_HEADER_LEN: u16 = 5;
2574        const CRC_LEN: u16 = 4;
2575        let section_length = EXTENSION_HEADER_LEN + descriptors.len() as u16 + CRC_LEN;
2576        let mut v = Vec::new();
2577        v.push(0x01); // table_id (CAT)
2578        v.push(0xB0 | ((section_length >> 8) as u8 & 0x0F));
2579        v.push((section_length & 0xFF) as u8);
2580        v.extend_from_slice(&[0xFF, 0xFF]); // table_id_extension (reserved for CAT)
2581        v.push(0xC1); // reserved(2)='11' | version(5)=0 | current_next=1
2582        v.push(0x00); // section_number
2583        v.push(0x00); // last_section_number
2584        v.extend_from_slice(descriptors);
2585        let crc = broadcast_common::crc32_mpeg2::compute(&v);
2586        v.extend_from_slice(&crc.to_be_bytes());
2587        v
2588    }
2589
2590    #[test]
2591    fn set_cat_computes_emm_pids_as_cat_inter_ca_info_caids() {
2592        use broadcast_common::Parse;
2593        use dvb_ci::objects::ca_info::CaInfo;
2594        use dvb_si::tables::cat::CatSection;
2595
2596        let mut d = driver_with_sessions();
2597        d.take_notifications();
2598
2599        // ca_info arrives first: the CAM advertises CAIDs 0x0648, 0x0100.
2600        feed(
2601            &mut d,
2602            r_apdu(
2603                CA_SESSION,
2604                &ser(&CaInfo {
2605                    length_form: dvb_ci::length::LengthForm::Minimal,
2606                    ca_system_ids: vec![0x0648, 0x0100],
2607                }),
2608            ),
2609        );
2610        d.take_notifications();
2611
2612        // CAT maps 0x0648 -> 0x1FF0 (advertised) and 0x0500 -> 0x1FF1 (not
2613        // advertised by this CAM).
2614        let mut descriptors = Vec::new();
2615        descriptors.extend_from_slice(&ca_descriptor(0x0648, Pid::new(0x1FF0)));
2616        descriptors.extend_from_slice(&ca_descriptor(0x0500, Pid::new(0x1FF1)));
2617        let cat_bytes = build_cat_fixture(&descriptors);
2618        let cat = CatSection::parse(&cat_bytes).unwrap();
2619
2620        d.set_cat(&cat).unwrap();
2621
2622        assert_eq!(
2623            d.emm_pids(),
2624            &[Pid::new(0x1FF0)],
2625            "0x0500 -> 0x1FF1 must be excluded: the CAM never advertised CAID 0x0500"
2626        );
2627    }
2628
2629    #[test]
2630    fn set_cat_before_ca_info_is_not_an_error_and_recomputes_once_ca_info_arrives() {
2631        use broadcast_common::Parse;
2632        use dvb_ci::objects::ca_info::CaInfo;
2633        use dvb_si::tables::cat::CatSection;
2634
2635        let mut d = driver_with_sessions();
2636        d.take_notifications();
2637
2638        let mut descriptors = Vec::new();
2639        descriptors.extend_from_slice(&ca_descriptor(0x0648, Pid::new(0x1FF0)));
2640        descriptors.extend_from_slice(&ca_descriptor(0x0500, Pid::new(0x1FF1)));
2641        let cat_bytes = build_cat_fixture(&descriptors);
2642        let cat = CatSection::parse(&cat_bytes).unwrap();
2643
2644        // set_cat with no ca_info observed yet: not an error, emm_pids stays
2645        // empty (nothing to intersect against).
2646        d.set_cat(&cat).unwrap();
2647        assert!(
2648            d.emm_pids().is_empty(),
2649            "emm_pids must be empty before any ca_info arrives, got {:?}",
2650            d.emm_pids()
2651        );
2652
2653        // ca_info now arrives: emm_pids recomputes against the CAT stored
2654        // earlier, without a second set_cat call.
2655        feed(
2656            &mut d,
2657            r_apdu(
2658                CA_SESSION,
2659                &ser(&CaInfo {
2660                    length_form: dvb_ci::length::LengthForm::Minimal,
2661                    ca_system_ids: vec![0x0648, 0x0100],
2662                }),
2663            ),
2664        );
2665        d.take_notifications();
2666
2667        assert_eq!(
2668            d.emm_pids(),
2669            &[Pid::new(0x1FF0)],
2670            "emm_pids must recompute once ca_info arrives, using the CAT stored by the earlier set_cat"
2671        );
2672    }
2673
2674    /// Task 4 review fix (MEDIUM): `recompute_emm_pids` must dedup like its
2675    /// sibling `recompute_service_pids` does — two CAT `CA_descriptor`s
2676    /// (distinct `CA_system_id`s, both CAM-advertised) that happen to share
2677    /// one `EMM_PID` (a real multi-CAS-on-one-EMM-PID broadcast setup) must
2678    /// list that PID exactly once, not twice.
2679    #[test]
2680    fn set_cat_emm_pids_dedups_when_two_caids_share_one_emm_pid() {
2681        use broadcast_common::Parse;
2682        use dvb_ci::objects::ca_info::CaInfo;
2683        use dvb_si::tables::cat::CatSection;
2684
2685        let mut d = driver_with_sessions();
2686        d.take_notifications();
2687
2688        // CAM advertises both CAIDs.
2689        feed(
2690            &mut d,
2691            r_apdu(
2692                CA_SESSION,
2693                &ser(&CaInfo {
2694                    length_form: dvb_ci::length::LengthForm::Minimal,
2695                    ca_system_ids: vec![0x0648, 0x0100],
2696                }),
2697            ),
2698        );
2699        d.take_notifications();
2700
2701        // CAT maps BOTH CAIDs to the SAME EMM PID.
2702        let mut descriptors = Vec::new();
2703        descriptors.extend_from_slice(&ca_descriptor(0x0648, Pid::new(0x1FF0)));
2704        descriptors.extend_from_slice(&ca_descriptor(0x0100, Pid::new(0x1FF0)));
2705        let cat_bytes = build_cat_fixture(&descriptors);
2706        let cat = CatSection::parse(&cat_bytes).unwrap();
2707
2708        d.set_cat(&cat).unwrap();
2709
2710        assert_eq!(
2711            d.emm_pids(),
2712            &[Pid::new(0x1FF0)],
2713            "0x1FF0 must appear exactly once even though two CAM-advertised CAIDs map to it, got {:?}",
2714            d.emm_pids()
2715        );
2716    }
2717
2718    /// Same layout as [`build_ca_pmt_fixture`] but with a distinct PCR/ES PID
2719    /// set, so a second added service proves `descramble_pids` is a real
2720    /// union rather than one programme's PIDs happening to repeat.
2721    fn build_ca_pmt_fixture_distinct_pids(program_number: u16) -> Vec<u8> {
2722        const VIACCESS: u16 = 0x0500;
2723        let prog_ca = ca_descriptor(VIACCESS, Pid::new(0x0074));
2724        let es0_ca = ca_descriptor(VIACCESS, Pid::new(0x0075));
2725
2726        let mut body = Vec::new();
2727        body.push(0x02); // table_id (PMT)
2728        body.push(0);
2729        body.push(0);
2730        body.extend_from_slice(&program_number.to_be_bytes());
2731        body.push(0xC3);
2732        body.push(0x00);
2733        body.push(0x00);
2734        body.push(0xE0 | 0x02); // PCR_PID = 0x0200
2735        body.push(0x00);
2736        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
2737        body.push(prog_ca.len() as u8);
2738        body.extend_from_slice(&prog_ca);
2739        // ES0: H.264 video, pid 0x0200, scrambled.
2740        body.push(0x1B);
2741        body.push(0xE0 | 0x02);
2742        body.push(0x00);
2743        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
2744        body.push(es0_ca.len() as u8);
2745        body.extend_from_slice(&es0_ca);
2746        // ES1: AAC ADTS audio, pid 0x0201, clear.
2747        body.push(0x0F);
2748        body.push(0xE0 | 0x02);
2749        body.push(0x01);
2750        body.push(0xF0);
2751        body.push(0x00);
2752
2753        let section_length = body.len() - 3 + 4;
2754        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
2755        body[2] = section_length as u8;
2756        let crc = broadcast_common::crc32_mpeg2::compute(&body);
2757        body.extend_from_slice(&crc.to_be_bytes());
2758        body
2759    }
2760
2761    #[test]
2762    fn descramble_pids_is_the_union_of_active_services_es_pids() {
2763        use broadcast_common::Parse;
2764
2765        let mut d = driver_with_sessions();
2766        d.take_notifications();
2767
2768        assert!(
2769            d.descramble_pids().is_empty(),
2770            "no service added yet: descramble_pids must be empty"
2771        );
2772
2773        let pmt1_bytes = build_ca_pmt_fixture(1546);
2774        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
2775        d.add_service(&pmt1).unwrap();
2776        d.device_mut().inbound.push_back(sb());
2777        d.pump(Duration::from_millis(10)).unwrap();
2778
2779        assert_eq!(d.descramble_pids(), &[Pid::new(0x0100), Pid::new(0x0101)]);
2780
2781        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
2782        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
2783        d.add_service(&pmt2).unwrap();
2784        d.device_mut().inbound.push_back(sb());
2785        d.pump(Duration::from_millis(10)).unwrap();
2786
2787        // Union of both programmes' ES PIDs, sorted.
2788        assert_eq!(
2789            d.descramble_pids(),
2790            &[
2791                Pid::new(0x0100),
2792                Pid::new(0x0101),
2793                Pid::new(0x0200),
2794                Pid::new(0x0201)
2795            ],
2796            "descramble_pids must be the union across both added services"
2797        );
2798    }
2799
2800    // --- #763 Task 5: re-query timer + edge-triggered Entitlement ---
2801
2802    /// Build a `ca_pmt_reply` (EN 50221 §8.4.3.5, Table 26) for `program_number`
2803    /// carrying programme-level `ca_enable` (`None` = `CA_enable_flag` clear).
2804    pub(crate) fn ca_pmt_reply_for(
2805        program_number: u16,
2806        ca_enable: Option<dvb_ci::objects::ca_pmt_reply::CaEnable>,
2807    ) -> dvb_ci::objects::ca_pmt_reply::CaPmtReply {
2808        dvb_ci::objects::ca_pmt_reply::CaPmtReply {
2809            length_form: dvb_ci::length::LengthForm::Minimal,
2810            program_number,
2811            version_number: 1,
2812            current_next_indicator: true,
2813            ca_enable,
2814            streams: vec![],
2815            reserved_version: Default::default(),
2816            reserved_ca_enable: Default::default(),
2817        }
2818    }
2819
2820    #[test]
2821    fn requery_timer_resends_ca_pmt_then_reply_change_emits_one_entitlement() {
2822        use broadcast_common::Parse;
2823        use dvb_ci::objects::ca_pmt_reply::CaEnable;
2824
2825        let clock = TestClock::new();
2826        let mut d = driver_with_sessions().with_clock(clock.as_fn());
2827        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
2828        // enable it explicitly to exercise the timer.
2829        d.set_requery_interval(Duration::from_secs(10));
2830        d.take_notifications();
2831
2832        let pmt_bytes = build_ca_pmt_fixture(1546);
2833        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2834        d.add_service(&pmt).unwrap();
2835        d.device_mut().inbound.push_back(sb());
2836        clock.advance(Duration::from_millis(10));
2837        d.pump(Duration::from_millis(10)).unwrap();
2838        d.take_notifications();
2839
2840        // The initial `add_service` send is `ok_descrambling` — assert it
2841        // happened, so the test proves the resend below (a distinct `query`
2842        // cmd_id) is a genuinely different wire message, not the same bytes.
2843        let expected_initial_ca_pmt =
2844            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
2845        assert_apdu_on_session(&d, CA_SESSION, &expected_initial_ca_pmt);
2846
2847        // #1032: the re-query timer resends a `list_management = update`
2848        // PAIR — `query` (to solicit a fresh `ca_pmt_reply`) immediately
2849        // followed by `ok_descrambling` (so descrambling is never left
2850        // barred, EN 50221 §8.4.3.5, whether or not the CAM answers the
2851        // query) — never the pre-fix `only`/`first` + `query` alone.
2852        let expected_query =
2853            build_ca_pmt(&pmt, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
2854        let expected_ok_descrambling = build_ca_pmt(
2855            &pmt,
2856            CaPmtListManagement::Update,
2857            CaPmtCmdId::OkDescrambling,
2858        )
2859        .to_bytes();
2860        let query_sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_query);
2861        let ok_sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_ok_descrambling);
2862
2863        let mut all_notes = Vec::new();
2864
2865        // Reply 1: not entitled (baseline — first-ever reply for this
2866        // program; `descrambling_ok` is derived: `NotPossibleNoEntitlement`
2867        // is not in the "possible" set, so `false`).
2868        feed(
2869            &mut d,
2870            r_apdu(
2871                CA_SESSION,
2872                &ser(&ca_pmt_reply_for(
2873                    1546,
2874                    Some(CaEnable::NotPossibleNoEntitlement),
2875                )),
2876            ),
2877        );
2878        all_notes.extend(d.take_notifications());
2879
2880        // Advance the clock past the 10s re-query interval: a single pump
2881        // ticks the stack with elapsed = 11s (nothing readable this turn),
2882        // which the #763 Task 5 re-query timer picks up and queues the
2883        // tracked service's `query`+`ok_descrambling` pair for resend (EN
2884        // 50221 §8.4.3.4 Table 25). EN 50221's link is half-duplex — this
2885        // tick's own keep-alive poll already claimed the turn, so the pair
2886        // is written across the module's next two `T_SB`s (the #337
2887        // one-write-per-turn rule), same as any other queued host write in
2888        // this test suite.
2889        clock.advance(Duration::from_secs(11));
2890        d.pump(Duration::from_secs(11)).unwrap();
2891        all_notes.extend(d.take_notifications());
2892        feed(&mut d, sb());
2893
2894        assert_eq!(
2895            count_apdu_on_session(&d, CA_SESSION, &expected_query),
2896            query_sends_before + 1,
2897            "expected the re-query timer to resend the `query` half exactly once"
2898        );
2899        assert_eq!(
2900            count_apdu_on_session(&d, CA_SESSION, &expected_ok_descrambling),
2901            ok_sends_before + 1,
2902            "expected the re-query timer to resend the `ok_descrambling` half exactly once"
2903        );
2904
2905        // Reply 2: the CAM's re-evaluated answer to the re-query says
2906        // descrambling is now possible.
2907        feed(
2908            &mut d,
2909            r_apdu(
2910                CA_SESSION,
2911                &ser(&ca_pmt_reply_for(1546, Some(CaEnable::Possible))),
2912            ),
2913        );
2914        all_notes.extend(d.take_notifications());
2915
2916        let hits = all_notes
2917            .iter()
2918            .filter(|n| {
2919                matches!(
2920                    n,
2921                    Notification::Entitlement {
2922                        program_number: 1546,
2923                        ca_enable: CaEnable::Possible,
2924                        descrambling_ok: true,
2925                    }
2926                )
2927            })
2928            .count();
2929        assert_eq!(
2930            hits, 1,
2931            "expected exactly one Entitlement{{program_number:1546, ca_enable:Possible, descrambling_ok:true}}, got {all_notes:?}"
2932        );
2933    }
2934
2935    #[test]
2936    fn requery_timer_unchanged_reply_across_two_requeries_emits_no_entitlement() {
2937        use broadcast_common::Parse;
2938        use dvb_ci::objects::ca_pmt_reply::CaEnable;
2939
2940        let mut d = driver_with_sessions();
2941        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
2942        // enable it explicitly so the loop below exercises real re-queries.
2943        d.set_requery_interval(Duration::from_secs(10));
2944        d.take_notifications();
2945
2946        let pmt_bytes = build_ca_pmt_fixture(1547);
2947        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2948        d.add_service(&pmt).unwrap();
2949        d.device_mut().inbound.push_back(sb());
2950        d.pump(Duration::from_millis(10)).unwrap();
2951        d.take_notifications();
2952
2953        // Baseline reply: descrambling possible. First-ever reply — this
2954        // establishes the baseline and DOES emit once (per the transition
2955        // rule); drop it so the loop below only asserts on the re-queries.
2956        feed(
2957            &mut d,
2958            r_apdu(
2959                CA_SESSION,
2960                &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
2961            ),
2962        );
2963        d.take_notifications();
2964
2965        // Two re-queries, the CAM replying with the SAME unchanged status
2966        // both times: no Entitlement either time (negative control).
2967        for _ in 0..2 {
2968            d.pump(Duration::from_secs(11)).unwrap();
2969            d.take_notifications();
2970            feed(
2971                &mut d,
2972                r_apdu(
2973                    CA_SESSION,
2974                    &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
2975                ),
2976            );
2977            let notes = d.take_notifications();
2978            assert!(
2979                !notes
2980                    .iter()
2981                    .any(|n| matches!(n, Notification::Entitlement { .. })),
2982                "unchanged status across a re-query must not emit Entitlement, got {notes:?}"
2983            );
2984        }
2985    }
2986
2987    #[test]
2988    fn requery_reply_withdrawn_to_none_emits_no_entitlement() {
2989        use broadcast_common::Parse;
2990        use dvb_ci::objects::ca_pmt_reply::CaEnable;
2991
2992        let mut d = driver_with_sessions();
2993        d.take_notifications();
2994
2995        let pmt_bytes = build_ca_pmt_fixture(1548);
2996        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2997        d.add_service(&pmt).unwrap();
2998        d.device_mut().inbound.push_back(sb());
2999        d.pump(Duration::from_millis(10)).unwrap();
3000        d.take_notifications();
3001
3002        // Baseline: descrambling possible (drop the baseline Entitlement).
3003        feed(
3004            &mut d,
3005            r_apdu(
3006                CA_SESSION,
3007                &ser(&ca_pmt_reply_for(1548, Some(CaEnable::Possible))),
3008            ),
3009        );
3010        d.take_notifications();
3011
3012        // Programme `CA_enable_flag` now clear (`None`) — status withdrawn.
3013        // Per the transition rule this NEVER emits Entitlement (#726 HotPlug
3014        // covers the coarse withdrawal signal instead).
3015        feed(
3016            &mut d,
3017            r_apdu(CA_SESSION, &ser(&ca_pmt_reply_for(1548, None))),
3018        );
3019        let notes = d.take_notifications();
3020        assert!(
3021            !notes
3022                .iter()
3023                .any(|n| matches!(n, Notification::Entitlement { .. })),
3024            "ca_enable transitioning to None must not emit Entitlement, got {notes:?}"
3025        );
3026    }
3027
3028    #[test]
3029    fn requery_disabled_by_default_sends_nothing() {
3030        use broadcast_common::Parse;
3031
3032        // #1032: no `set_requery_interval` call at all — `REQUERY_DEFAULT`
3033        // (`Duration::ZERO`) must leave re-query off by default (it stays opt-in
3034        // although the `query` + `ok_descrambling` pair is hardware-verified).
3035        let mut d = driver_with_sessions();
3036        d.take_notifications();
3037
3038        let pmt_bytes = build_ca_pmt_fixture(1549);
3039        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
3040        d.add_service(&pmt).unwrap();
3041        d.device_mut().inbound.push_back(sb());
3042        d.pump(Duration::from_millis(10)).unwrap();
3043
3044        // Count the `update`-variant bytes — the ones the timer would
3045        // resend if enabled — not the `only` bytes `add_service` sent.
3046        let expected_query =
3047            build_ca_pmt(&pmt, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
3048        let expected_ok_descrambling = build_ca_pmt(
3049            &pmt,
3050            CaPmtListManagement::Update,
3051            CaPmtCmdId::OkDescrambling,
3052        )
3053        .to_bytes();
3054        let query_sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_query);
3055        let ok_sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_ok_descrambling);
3056
3057        // Even a very long tick must not trigger a re-query by default.
3058        d.pump(Duration::from_secs(1_000_000)).unwrap();
3059        feed(&mut d, sb());
3060        feed(&mut d, sb());
3061        assert_eq!(
3062            count_apdu_on_session(&d, CA_SESSION, &expected_query),
3063            query_sends_before,
3064            "the default (Duration::ZERO) must never send a re-query `query`"
3065        );
3066        assert_eq!(
3067            count_apdu_on_session(&d, CA_SESSION, &expected_ok_descrambling),
3068            ok_sends_before,
3069            "the default (Duration::ZERO) must never send a re-query `ok_descrambling`"
3070        );
3071    }
3072
3073    #[test]
3074    fn set_requery_interval_zero_disables_resend_after_being_enabled() {
3075        use broadcast_common::Parse;
3076
3077        let mut d = driver_with_sessions();
3078        // Enable, then explicitly disable again — proves `Duration::ZERO`
3079        // works as an override, not merely as an unexercised default.
3080        d.set_requery_interval(Duration::from_secs(5));
3081        d.set_requery_interval(Duration::ZERO);
3082        d.take_notifications();
3083
3084        let pmt_bytes = build_ca_pmt_fixture(1549);
3085        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
3086        d.add_service(&pmt).unwrap();
3087        d.device_mut().inbound.push_back(sb());
3088        d.pump(Duration::from_millis(10)).unwrap();
3089
3090        // Count the `update`-variant bytes — the ones the timer would resend
3091        // if it fired — not the `only` bytes `add_service` sent (both are
3092        // `cmd_id = ok_descrambling`; they differ only in `list_management`).
3093        let expected_ca_pmt = build_ca_pmt(
3094            &pmt,
3095            CaPmtListManagement::Update,
3096            CaPmtCmdId::OkDescrambling,
3097        )
3098        .to_bytes();
3099        let sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
3100
3101        // Even a very long tick must not trigger a re-query once disabled.
3102        d.pump(Duration::from_secs(1000)).unwrap();
3103        feed(&mut d, sb());
3104        feed(&mut d, sb());
3105
3106        let sends_after = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
3107        assert_eq!(
3108            sends_after, sends_before,
3109            "Duration::ZERO must disable the re-query resend"
3110        );
3111    }
3112
3113    #[test]
3114    fn requery_timer_resends_every_active_service_not_just_one() {
3115        use broadcast_common::Parse;
3116
3117        let clock = TestClock::new();
3118        let mut d = driver_with_sessions().with_clock(clock.as_fn());
3119        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
3120        // enable it explicitly to exercise the timer.
3121        d.set_requery_interval(Duration::from_secs(10));
3122        d.take_notifications();
3123
3124        // Two services on the managed set: 1546 (`Only`, first-ever) and
3125        // 1547 (`Add`, joining the active set).
3126        let pmt1_bytes = build_ca_pmt_fixture(1546);
3127        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
3128        d.add_service(&pmt1).unwrap();
3129        d.device_mut().inbound.push_back(sb());
3130        clock.advance(Duration::from_millis(10));
3131        d.pump(Duration::from_millis(10)).unwrap();
3132
3133        let pmt2_bytes = build_ca_pmt_fixture(1547);
3134        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
3135        d.add_service(&pmt2).unwrap();
3136        d.device_mut().inbound.push_back(sb());
3137        clock.advance(Duration::from_millis(10));
3138        d.pump(Duration::from_millis(10)).unwrap();
3139        d.take_notifications();
3140
3141        // The `update`-variant PAIR the re-query timer rebuilds for each
3142        // service (#1032: `list_management = update`, `query` then
3143        // `ok_descrambling`, for every active service — `update` acts only
3144        // at programme level per EN 50221 §8.4.3.4, so unlike the pre-fix
3145        // `first`/`more`/`last` scheme it needs no position bookkeeping).
3146        let expected1_query =
3147            build_ca_pmt(&pmt1, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
3148        let expected1_ok = build_ca_pmt(
3149            &pmt1,
3150            CaPmtListManagement::Update,
3151            CaPmtCmdId::OkDescrambling,
3152        )
3153        .to_bytes();
3154        let expected2_query =
3155            build_ca_pmt(&pmt2, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
3156        let expected2_ok = build_ca_pmt(
3157            &pmt2,
3158            CaPmtListManagement::Update,
3159            CaPmtCmdId::OkDescrambling,
3160        )
3161        .to_bytes();
3162        let sends_before1_query = count_apdu_on_session(&d, CA_SESSION, &expected1_query);
3163        let sends_before1_ok = count_apdu_on_session(&d, CA_SESSION, &expected1_ok);
3164        let sends_before2_query = count_apdu_on_session(&d, CA_SESSION, &expected2_query);
3165        let sends_before2_ok = count_apdu_on_session(&d, CA_SESSION, &expected2_ok);
3166
3167        // Advance the clock past the 10s re-query interval: this queues
3168        // BOTH services' `query`+`ok_descrambling` pairs (`requery_tick`
3169        // iterates the whole active set), but EN 50221's half-duplex link
3170        // (the #337 one-write-per-turn rule) only lets one out per turn —
3171        // feed enough `T_SB` acks to flush all four queued writes.
3172        clock.advance(Duration::from_secs(11));
3173        d.pump(Duration::from_secs(11)).unwrap();
3174        feed(&mut d, sb());
3175        feed(&mut d, sb());
3176
3177        assert_eq!(
3178            count_apdu_on_session(&d, CA_SESSION, &expected1_query),
3179            sends_before1_query + 1,
3180            "expected service 1546's `query` resent exactly once on the shared tick"
3181        );
3182        assert_eq!(
3183            count_apdu_on_session(&d, CA_SESSION, &expected1_ok),
3184            sends_before1_ok + 1,
3185            "expected service 1546's `ok_descrambling` resent exactly once on the shared tick"
3186        );
3187        assert_eq!(
3188            count_apdu_on_session(&d, CA_SESSION, &expected2_query),
3189            sends_before2_query + 1,
3190            "expected service 1547's `query` resent exactly once on the shared tick"
3191        );
3192        assert_eq!(
3193            count_apdu_on_session(&d, CA_SESSION, &expected2_ok),
3194            sends_before2_ok + 1,
3195            "expected service 1547's `ok_descrambling` resent exactly once on the shared tick"
3196        );
3197    }
3198
3199    // --- #765 (retained under #1032): re-query must reflect the CURRENT
3200    // active set, rebuilt fresh each tick, not a stale/frozen list ---
3201
3202    #[test]
3203    fn requery_after_remove_resends_only_the_surviving_service() {
3204        use broadcast_common::Parse;
3205
3206        let clock = TestClock::new();
3207        let mut d = driver_with_sessions().with_clock(clock.as_fn());
3208        // #1032: re-query is opt-in (REQUERY_DEFAULT = Duration::ZERO) —
3209        // enable it explicitly to exercise the timer.
3210        d.set_requery_interval(Duration::from_secs(10));
3211        d.take_notifications();
3212
3213        let pmt1_bytes = build_ca_pmt_fixture(1546);
3214        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
3215        d.add_service(&pmt1).unwrap();
3216        d.device_mut().inbound.push_back(sb());
3217        clock.advance(Duration::from_millis(10));
3218        d.pump(Duration::from_millis(10)).unwrap();
3219
3220        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
3221        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
3222        d.add_service(&pmt2).unwrap();
3223        d.device_mut().inbound.push_back(sb());
3224        clock.advance(Duration::from_millis(10));
3225        d.pump(Duration::from_millis(10)).unwrap();
3226
3227        // Remove 1546: 1547 is now the SOLE survivor.
3228        d.remove_service(1546).unwrap();
3229        d.device_mut().inbound.push_back(sb());
3230        clock.advance(Duration::from_millis(10));
3231        d.pump(Duration::from_millis(10)).unwrap();
3232        d.take_notifications();
3233
3234        // The #765 bite (still relevant under #1032's `update`-based
3235        // resend): the timer must rebuild its re-query set from the CURRENT
3236        // active set on every tick, not a list frozen at `add_service` time
3237        // — so 1546 (removed) must never be resent, and 1547 (surviving)
3238        // must be resent as the `query`+`ok_descrambling` pair.
3239        let expected_survivor_query =
3240            build_ca_pmt(&pmt2, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
3241        let expected_survivor_ok = build_ca_pmt(
3242            &pmt2,
3243            CaPmtListManagement::Update,
3244            CaPmtCmdId::OkDescrambling,
3245        )
3246        .to_bytes();
3247        let expected_removed_query =
3248            build_ca_pmt(&pmt1, CaPmtListManagement::Update, CaPmtCmdId::Query).to_bytes();
3249        let expected_removed_ok = build_ca_pmt(
3250            &pmt1,
3251            CaPmtListManagement::Update,
3252            CaPmtCmdId::OkDescrambling,
3253        )
3254        .to_bytes();
3255        let sends_survivor_query_before =
3256            count_apdu_on_session(&d, CA_SESSION, &expected_survivor_query);
3257        let sends_survivor_ok_before = count_apdu_on_session(&d, CA_SESSION, &expected_survivor_ok);
3258        let sends_removed_query_before =
3259            count_apdu_on_session(&d, CA_SESSION, &expected_removed_query);
3260        let sends_removed_ok_before = count_apdu_on_session(&d, CA_SESSION, &expected_removed_ok);
3261
3262        clock.advance(Duration::from_secs(11));
3263        d.pump(Duration::from_secs(11)).unwrap();
3264        feed(&mut d, sb());
3265
3266        assert_eq!(
3267            count_apdu_on_session(&d, CA_SESSION, &expected_survivor_query),
3268            sends_survivor_query_before + 1,
3269            "the surviving service's `query` must be re-sent"
3270        );
3271        assert_eq!(
3272            count_apdu_on_session(&d, CA_SESSION, &expected_survivor_ok),
3273            sends_survivor_ok_before + 1,
3274            "the surviving service's `ok_descrambling` must be re-sent"
3275        );
3276        assert_eq!(
3277            count_apdu_on_session(&d, CA_SESSION, &expected_removed_query),
3278            sends_removed_query_before,
3279            "the removed service's `query` must NOT be re-sent"
3280        );
3281        assert_eq!(
3282            count_apdu_on_session(&d, CA_SESSION, &expected_removed_ok),
3283            sends_removed_ok_before,
3284            "the removed service's `ok_descrambling` must NOT be re-sent"
3285        );
3286    }
3287
3288    // --- #763 Task 6: remove_service + clear managed state on CAM hot-plug ---
3289
3290    #[test]
3291    fn remove_service_sends_update_not_selected_and_drops_from_managed_state() {
3292        use broadcast_common::Parse;
3293
3294        let mut d = driver_with_sessions();
3295        d.take_notifications();
3296
3297        // 1546 (`Only`, distinct PIDs 0x100/0x101) and 1547 (`Add`, distinct
3298        // PIDs 0x200/0x201) — distinct PID sets so removing 1546 is
3299        // observably different from removing 1547.
3300        let pmt1_bytes = build_ca_pmt_fixture(1546);
3301        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
3302        d.add_service(&pmt1).unwrap();
3303        d.device_mut().inbound.push_back(sb());
3304        d.pump(Duration::from_millis(10)).unwrap();
3305
3306        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
3307        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
3308        d.add_service(&pmt2).unwrap();
3309        d.device_mut().inbound.push_back(sb());
3310        d.pump(Duration::from_millis(10)).unwrap();
3311
3312        d.remove_service(1546).unwrap();
3313        d.device_mut().inbound.push_back(sb());
3314        d.pump(Duration::from_millis(10)).unwrap();
3315
3316        // Oracle: the same PMT re-built directly via
3317        // dvb_ci::builder::build_ca_pmt with `Update`/`NotSelected` (EN 50221
3318        // §8.4.3.4 Table 25) — the exact bytes `remove_program` sends.
3319        let expected =
3320            build_ca_pmt(&pmt1, CaPmtListManagement::Update, CaPmtCmdId::NotSelected).to_bytes();
3321        assert_apdu_on_session(&d, CA_SESSION, &expected);
3322
3323        assert_eq!(
3324            d.descramble_pids(),
3325            &[Pid::new(0x0200), Pid::new(0x0201)],
3326            "1546's ES PIDs must be gone; 1547's must remain"
3327        );
3328        assert!(
3329            d.managed_ca().services().get(&1546).is_none(),
3330            "1546 must no longer be tracked"
3331        );
3332        assert!(
3333            d.managed_ca().services().get(&1547).is_some(),
3334            "1547 must remain tracked"
3335        );
3336    }
3337
3338    #[test]
3339    fn remove_service_of_untracked_program_is_a_no_op() {
3340        let mut d = driver_with_sessions();
3341        d.take_notifications();
3342
3343        let ops_before = d.device().ops.len();
3344        d.remove_service(0xFFFF).unwrap();
3345        assert_eq!(
3346            d.device().ops.len(),
3347            ops_before,
3348            "removing an untracked program must not send anything to the device"
3349        );
3350        assert!(
3351            d.managed_ca().services().is_empty(),
3352            "removing an untracked program must not disturb the (empty) managed set"
3353        );
3354    }
3355
3356    #[test]
3357    fn cam_removed_edge_clears_managed_state() {
3358        use broadcast_common::Parse;
3359        use dvb_ci::objects::ca_info::CaInfo;
3360        use dvb_si::tables::cat::CatSection;
3361
3362        let mut d = driver_with_sessions();
3363        d.take_notifications();
3364
3365        let pmt_bytes = build_ca_pmt_fixture(1546);
3366        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
3367        d.add_service(&pmt).unwrap();
3368        d.device_mut().inbound.push_back(sb());
3369        d.pump(Duration::from_millis(10)).unwrap();
3370
3371        // Populate emm_pids too, via ca_info + set_cat, so the test proves
3372        // the fix clears more than just `services`.
3373        feed(
3374            &mut d,
3375            r_apdu(
3376                CA_SESSION,
3377                &ser(&CaInfo {
3378                    length_form: dvb_ci::length::LengthForm::Minimal,
3379                    ca_system_ids: vec![0x0648],
3380                }),
3381            ),
3382        );
3383        d.take_notifications();
3384        let mut descriptors = Vec::new();
3385        descriptors.extend_from_slice(&ca_descriptor(0x0648, Pid::new(0x1FF0)));
3386        let cat_bytes = build_cat_fixture(&descriptors);
3387        let cat = CatSection::parse(&cat_bytes).unwrap();
3388        d.set_cat(&cat).unwrap();
3389
3390        assert!(
3391            !d.managed_ca().services().is_empty(),
3392            "precondition: a service is tracked"
3393        );
3394        assert!(
3395            !d.descramble_pids().is_empty(),
3396            "precondition: descramble_pids populated"
3397        );
3398        assert!(!d.emm_pids().is_empty(), "precondition: emm_pids populated");
3399
3400        // Module physically removed: a CamRemoved hot-plug edge.
3401        d.device_mut().slot.module_present = false;
3402        d.pump(Duration::from_millis(10)).unwrap();
3403        let notes = d.take_notifications();
3404        assert!(
3405            notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
3406            "expected CamRemoved, got {notes:?}"
3407        );
3408
3409        assert!(
3410            d.managed_ca().services().is_empty(),
3411            "services must be cleared on CamRemoved"
3412        );
3413        assert!(
3414            d.descramble_pids().is_empty(),
3415            "descramble_pids must be cleared on CamRemoved"
3416        );
3417        assert!(
3418            d.emm_pids().is_empty(),
3419            "emm_pids must be cleared on CamRemoved"
3420        );
3421    }
3422
3423    #[test]
3424    fn update_service_not_managed_returns_not_managed_and_sends_nothing() {
3425        use broadcast_common::Parse;
3426
3427        let mut d = driver_with_sessions();
3428        d.take_notifications();
3429
3430        let pmt_bytes = build_clear_pmt_fixture(9999);
3431        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
3432
3433        let before = d.device().ops.len();
3434        let err = d
3435            .update_service(&pmt)
3436            .expect_err("unknown program must error");
3437        assert!(
3438            matches!(
3439                err,
3440                CaError::NotManaged {
3441                    program_number: 9999
3442                }
3443            ),
3444            "got {err:?}"
3445        );
3446        assert_eq!(d.device().ops.len(), before, "must send nothing");
3447        assert!(d.managed_ca().services().is_empty());
3448    }
3449
3450    #[test]
3451    fn update_service_no_ca_descriptor_leaves_record_unchanged() {
3452        use broadcast_common::Parse;
3453
3454        let mut d = driver_with_sessions();
3455        d.take_notifications();
3456
3457        let pmt_bytes = build_ca_pmt_fixture(1546);
3458        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
3459        d.add_service(&pmt).unwrap();
3460
3461        let svc_before = d.managed_ca().services().get(&1546).unwrap().clone();
3462
3463        let clear_bytes = build_clear_pmt_fixture(1546);
3464        let clear_pmt = PmtSection::parse(&clear_bytes).unwrap();
3465        let err = d
3466            .update_service(&clear_pmt)
3467            .expect_err("CA-free PMT must error");
3468        assert!(
3469            matches!(
3470                err,
3471                CaError::NoCaDescriptor {
3472                    program_number: 1546
3473                }
3474            ),
3475            "got {err:?}"
3476        );
3477
3478        let svc_after = d.managed_ca().services().get(&1546).unwrap();
3479        assert_eq!(svc_before, *svc_after, "record must be unchanged");
3480    }
3481
3482    /// #1389 wire-level: A and B are added; A's PMT then changes its ECM PIDs
3483    /// (`0x0064`/`0x0065` -> `0x0074`/`0x0075`, same programme number). Exactly
3484    /// one new `ca_pmt` goes out: `list_management = update`, `cmd_id =
3485    /// ok_descrambling`, carrying the NEW PIDs and none of the old; B's record
3486    /// is untouched; A's record is the NEW one (its PIDs, built bytes and raw PMT).
3487    #[test]
3488    fn update_service_sends_one_update_ca_pmt_with_the_new_pids_and_replaces_only_that_record() {
3489        use broadcast_common::Parse;
3490
3491        let mut d = driver_with_sessions();
3492        d.take_notifications();
3493
3494        let a_bytes = build_ca_pmt_fixture(1546);
3495        let a = PmtSection::parse(&a_bytes).unwrap();
3496        d.add_service(&a).unwrap();
3497        d.device_mut().inbound.push_back(sb());
3498        d.pump(Duration::from_millis(10)).unwrap();
3499
3500        let b_bytes = build_ca_pmt_fixture_dedicated_pcr(1547);
3501        let b = PmtSection::parse(&b_bytes).unwrap();
3502        d.add_service(&b).unwrap();
3503        d.device_mut().inbound.push_back(sb());
3504        d.pump(Duration::from_millis(10)).unwrap();
3505
3506        let a_old_record = d.managed_ca().services().get(&1546).unwrap().clone();
3507        let b_record_before = d.managed_ca().services().get(&1547).unwrap().clone();
3508        assert_eq!(
3509            a_old_record.ca_pids,
3510            vec![Pid::new(0x0064), Pid::new(0x0065)],
3511            "fixture sanity: A starts on the 0x0064/0x0065 ECM PIDs"
3512        );
3513
3514        // Same programme number, different ECM PIDs.
3515        let a2_bytes = build_ca_pmt_fixture_distinct_pids(1546);
3516        let a2 = PmtSection::parse(&a2_bytes).unwrap();
3517        let update =
3518            build_ca_pmt(&a2, CaPmtListManagement::Update, CaPmtCmdId::OkDescrambling).to_bytes();
3519        let writes_before = d.device().ops.len();
3520
3521        d.update_service(&a2).unwrap();
3522        d.device_mut().inbound.push_back(sb());
3523        d.pump(Duration::from_millis(10)).unwrap();
3524
3525        // Exactly ONE update ca_pmt, on the CA session, and nothing else carrying a ca_pmt.
3526        assert_eq!(count_apdu_on_session(&d, CA_SESSION, &update), 1);
3527        let new_ca_pmt_writes = d.device().ops[writes_before..]
3528            .iter()
3529            .filter(|op| matches!(op, DeviceOp::Write(w) if w.windows(3).any(|x| x == [0x9F, 0x80, 0x32])))
3530            .count();
3531        assert_eq!(new_ca_pmt_writes, 1, "exactly one ca_pmt APDU may be sent");
3532
3533        // The update carries the new ECM PIDs and none of the old ones.
3534        let new_prog_ca = ca_descriptor(0x0500, Pid::new(0x0074));
3535        let old_prog_ca = ca_descriptor(0x0500, Pid::new(0x0064));
3536        assert!(update.windows(new_prog_ca.len()).any(|x| x == new_prog_ca));
3537        assert!(!update.windows(old_prog_ca.len()).any(|x| x == old_prog_ca));
3538
3539        // A's record is replaced: new PIDs, new built bytes, new raw PMT.
3540        let a_new_record = d.managed_ca().services().get(&1546).unwrap();
3541        assert_eq!(
3542            a_new_record.ca_pids,
3543            vec![Pid::new(0x0074), Pid::new(0x0075)]
3544        );
3545        assert_eq!(a_new_record.built_ca_pmt, update);
3546        assert_ne!(a_new_record.built_ca_pmt, a_old_record.built_ca_pmt);
3547        assert_ne!(a_new_record.pmt_raw, a_old_record.pmt_raw);
3548        assert_eq!(
3549            a_new_record.pmt_raw, a2_bytes,
3550            "raw PMT must be the new section"
3551        );
3552
3553        // B is untouched, and so is the size of the managed set.
3554        assert_eq!(
3555            *d.managed_ca().services().get(&1547).unwrap(),
3556            b_record_before
3557        );
3558        assert_eq!(d.managed_ca().services().len(), 2);
3559    }
3560
3561    /// #1389: after an update, `remove_service` re-drives the removal from the
3562    /// NEW raw PMT (distinct PIDs, so the old PMT would produce different bytes).
3563    #[test]
3564    fn remove_service_after_update_service_uses_the_new_pmt() {
3565        use broadcast_common::Parse;
3566
3567        let mut d = driver_with_sessions();
3568        d.take_notifications();
3569
3570        let a_bytes = build_ca_pmt_fixture(1546);
3571        let a = PmtSection::parse(&a_bytes).unwrap();
3572        d.add_service(&a).unwrap();
3573        d.device_mut().inbound.push_back(sb());
3574        d.pump(Duration::from_millis(10)).unwrap();
3575        let a2_bytes = build_ca_pmt_fixture_distinct_pids(1546);
3576        let a2 = PmtSection::parse(&a2_bytes).unwrap();
3577        d.update_service(&a2).unwrap();
3578        d.device_mut().inbound.push_back(sb());
3579        d.pump(Duration::from_millis(10)).unwrap();
3580
3581        d.remove_service(1546).unwrap();
3582        d.device_mut().inbound.push_back(sb());
3583        d.pump(Duration::from_millis(10)).unwrap();
3584
3585        let removal_new =
3586            build_ca_pmt(&a2, CaPmtListManagement::Update, CaPmtCmdId::NotSelected).to_bytes();
3587        let removal_old =
3588            build_ca_pmt(&a, CaPmtListManagement::Update, CaPmtCmdId::NotSelected).to_bytes();
3589        assert_ne!(
3590            removal_new, removal_old,
3591            "fixture sanity: the two PMTs differ"
3592        );
3593        assert_eq!(count_apdu_on_session(&d, CA_SESSION, &removal_new), 1);
3594        assert_eq!(count_apdu_on_session(&d, CA_SESSION, &removal_old), 0);
3595        assert!(d.managed_ca().services().is_empty());
3596    }
3597
3598    /// #1389: the update `ca_pmt` is CAID-filtered exactly like `add_service`'s
3599    /// (#1067): a CAM advertising only Viaccess must not be sent the Nagra descriptor.
3600    #[test]
3601    fn update_service_filters_caids_like_add_service() {
3602        use broadcast_common::Parse;
3603
3604        let mut d = driver_with_sessions();
3605        d.take_notifications();
3606
3607        let two_caids = build_ca_pmt_fixture_two_caids(1546);
3608        let pmt = PmtSection::parse(&two_caids).unwrap();
3609        d.add_service(&pmt).unwrap();
3610        let added = d
3611            .managed_ca()
3612            .services()
3613            .get(&1546)
3614            .unwrap()
3615            .built_ca_pmt
3616            .clone();
3617
3618        d.update_service(&pmt).unwrap();
3619        let updated = d
3620            .managed_ca()
3621            .services()
3622            .get(&1546)
3623            .unwrap()
3624            .built_ca_pmt
3625            .clone();
3626
3627        // Same filtering as add_service: identical bytes except the list_management byte.
3628        assert_eq!(added.len(), updated.len());
3629        let diff: Vec<usize> = (0..added.len())
3630            .filter(|&i| added[i] != updated[i])
3631            .collect();
3632        assert_eq!(
3633            diff.len(),
3634            1,
3635            "only list_management may differ, got offsets {diff:?}"
3636        );
3637        assert_eq!(updated[diff[0]], 0x05, "list_management must be update");
3638    }
3639
3640    #[test]
3641    fn update_service_serialize_failure_leaves_record_unchanged() {
3642        let mut d = driver_with_sessions();
3643        // add a managed service for program_number 1 first
3644        let pmt_bytes = build_ca_pmt_fixture(1);
3645        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
3646        d.add_service(&pmt).unwrap();
3647
3648        let svc_before = d.managed_ca().services().get(&1).unwrap().clone();
3649
3650        let corrupt = corrupt_ca_section_ctor();
3651        let err = d
3652            .update_service(&corrupt)
3653            .expect_err("unencodable PMT must error");
3654        assert!(
3655            matches!(err, CaError::Serialize(_)),
3656            "expected CaError::Serialize, got {err:?}"
3657        );
3658
3659        let svc_after = d.managed_ca().services().get(&1).unwrap();
3660        assert_eq!(svc_before, *svc_after, "record must be unchanged");
3661    }
3662}