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