Skip to main content

dvb_ci_runtime/
driver.rs

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