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;
9
10use broadcast_common::Serialize;
11use dvb_ci::builder::build_ca_pmt;
12use dvb_ci::objects::ca_pmt::{CaPmtCmdId, CaPmtListManagement};
13use dvb_si::tables::cat::CatSection;
14use dvb_si::tables::pmt::PmtSection;
15
16use crate::device::{CaDevice, SlotInfo};
17use crate::event::{Action, Event, HostRequest, HotPlug, MmiEvent, Notification};
18use crate::managed::{self, CaError, ManagedCa};
19use crate::stack::CiStack;
20
21/// Substrings (case-insensitive) in MMI menu/list/enquiry text that
22/// heuristically indicate the smart card is absent. **Best-effort**: EN 50221
23/// defines no card-detect signal, so this is free-text sniffing of real CAM
24/// MMI copy, not a spec-defined mechanism.
25const MMI_CARD_ABSENT_KEYWORDS: &[&str] = &[
26    "no card",
27    "insert card",
28    "insert smart card",
29    "card removed",
30    "please insert",
31];
32
33/// Substrings (case-insensitive) in MMI menu/list/enquiry text that
34/// heuristically indicate a valid smart card is present (entitlements
35/// readable). **Best-effort**, same caveat as
36/// [`MMI_CARD_ABSENT_KEYWORDS`].
37const MMI_CARD_PRESENT_KEYWORDS: &[&str] = &["entitlement", "card valid", "subscription active"];
38
39/// Drives a [`CaDevice`] with the [`CiStack`].
40pub struct Driver<D: CaDevice> {
41    device: D,
42    stack: CiStack,
43    notifications: Vec<Notification>,
44    /// Delay the stack last asked to be polled after (`None` = none pending).
45    next_timer: Option<Duration>,
46    /// Read buffer for one link-layer frame.
47    buf: Vec<u8>,
48    /// Last observed slot status (Part A hot-plug edge detection, #726).
49    /// `None` means no [`SlotInfo`] has been observed yet — the first
50    /// observation only establishes the baseline; it never itself fires
51    /// [`Notification::HotPlug`] carrying [`HotPlug::CamPresent`]/
52    /// [`CamRemoved`](HotPlug::CamRemoved), so `Driver::init` on an
53    /// already-inserted module doesn't spuriously re-drive its own handshake.
54    last_slot: Option<SlotInfo>,
55    /// Last `ca_info` CAID set seen for the current module (Part B card
56    /// inference, best-effort). `None` = not seen yet (baseline only).
57    last_caids: Option<BTreeSet<u16>>,
58    /// Last `ca_pmt_reply` `descrambling_ok` seen for the current module
59    /// (Part B card inference, best-effort). `None` = not seen yet.
60    last_descrambling_ok: Option<bool>,
61    /// The slot's managed CAS-layer state (#763 Layer 1) — active services
62    /// built via [`add_service`](Self::add_service).
63    managed: ManagedCa,
64}
65
66impl<D: CaDevice> Driver<D> {
67    /// New driver over `device`, single transport connection.
68    #[must_use]
69    pub fn new(device: D) -> Self {
70        Self {
71            device,
72            stack: CiStack::new(),
73            notifications: Vec::new(),
74            next_timer: None,
75            buf: vec![0u8; 4096],
76            last_slot: None,
77            last_caids: None,
78            last_descrambling_ok: None,
79            managed: ManagedCa::new(),
80        }
81    }
82
83    /// The slot's managed CAS-layer state (#763 Layer 1) — the active
84    /// service set built via [`add_service`](Self::add_service).
85    pub fn managed_ca(&self) -> &ManagedCa {
86        &self.managed
87    }
88
89    /// Borrow the underlying device (e.g. to inspect a mock's recorded ops).
90    pub fn device(&self) -> &D {
91        &self.device
92    }
93
94    /// Mutably borrow the underlying device (e.g. to script a mock's inbound
95    /// frames between pumps).
96    pub fn device_mut(&mut self) -> &mut D {
97        &mut self.device
98    }
99
100    /// The poll delay the stack most recently requested, if any.
101    pub fn next_timer(&self) -> Option<Duration> {
102        self.next_timer
103    }
104
105    /// Drain the notifications collected so far.
106    pub fn take_notifications(&mut self) -> Vec<Notification> {
107        core::mem::take(&mut self.notifications)
108    }
109
110    /// Bring the interface up (reset + open the transport connection).
111    pub fn init(&mut self) -> io::Result<()> {
112        let actions = self.stack.handle(Event::Host(HostRequest::Init));
113        self.run(actions)
114    }
115
116    /// Request the module descramble the services in `ca_pmt` (a serialized
117    /// `ca_pmt` APDU body, e.g. from `dvb_ci::build_ca_pmt`).
118    pub fn send_ca_pmt(&mut self, ca_pmt: &[u8]) -> io::Result<()> {
119        let actions = self
120            .stack
121            .handle(Event::Host(HostRequest::SendCaPmt(ca_pmt)));
122        self.run(actions)
123    }
124
125    /// Descramble the services in a PMT section: the stack filters the PMT's
126    /// `CA_descriptor`s to the CAM's advertised CAIDs and sends a `ca_pmt`
127    /// (`list_management = only`, `cmd_id = ok_descrambling`). The outcome
128    /// surfaces as [`Notification::CaPmtReply`]. Call after the CAM is ready and
129    /// its `ca_info` has been received (otherwise no CAID filter is applied).
130    pub fn descramble(&mut self, pmt_section: &[u8]) -> io::Result<()> {
131        let actions = self
132            .stack
133            .handle(Event::Host(HostRequest::Descramble(pmt_section)));
134        self.run(actions)
135    }
136
137    /// Descramble a set of programmes in one CA-PMT list (`first`/`more`/`last`),
138    /// replacing any previously selected set. Each element is a raw PMT section.
139    pub fn descramble_programs(&mut self, pmt_sections: &[&[u8]]) -> io::Result<()> {
140        let actions = self
141            .stack
142            .handle(Event::Host(HostRequest::DescramblePrograms(pmt_sections)));
143        self.run(actions)
144    }
145
146    /// Add one programme to the descrambled set (`list_management = add`) without
147    /// re-listing the others — for a capacity manager adding a viewer's service.
148    pub fn add_program(&mut self, pmt_section: &[u8]) -> io::Result<()> {
149        let actions = self
150            .stack
151            .handle(Event::Host(HostRequest::AddProgram(pmt_section)));
152        self.run(actions)
153    }
154
155    /// Remove one programme from the descrambled set (`list_management = update`,
156    /// `cmd_id = not_selected`) — tells the CAM to stop descrambling it.
157    pub fn remove_program(&mut self, pmt_section: &[u8]) -> io::Result<()> {
158        let actions = self
159            .stack
160            .handle(Event::Host(HostRequest::RemoveProgram(pmt_section)));
161        self.run(actions)
162    }
163
164    /// Build + send the `ca_pmt` for `pmt` (via
165    /// [`dvb_ci::builder::build_ca_pmt`], ETSI EN 50221 §8.4.3.4 Table 25) and
166    /// track it in the slot's managed active-service set (#763 Layer 1).
167    /// Additive alongside the raw [`send_ca_pmt`](Self::send_ca_pmt) and the
168    /// existing multi-programme API
169    /// ([`descramble_programs`](Self::descramble_programs)/
170    /// [`add_program`](Self::add_program)).
171    ///
172    /// `list_management` (EN 50221 Table 25) is auto-selected from the tracked
173    /// set: `Only` when this is the first service added to an empty managed
174    /// set, `Add` when joining an already-active set. (Contrast the raw
175    /// [`add_program`](Self::add_program), which always sends `Add` and leaves
176    /// list-management sequencing to the caller.)
177    ///
178    /// # Errors
179    /// [`CaError::NoCaDescriptor`] if `pmt` carries no `CA_descriptor`
180    /// (ETSI EN 300 468 §6.2.16, tag `0x09`) at programme or
181    /// elementary-stream level — there would be nothing for the CAM to
182    /// descramble. [`CaError::Io`] if sending the built `ca_pmt` fails.
183    pub fn add_service(&mut self, pmt: &PmtSection<'_>) -> Result<(), CaError> {
184        if !managed::pmt_has_ca(pmt) {
185            return Err(CaError::NoCaDescriptor {
186                program_number: pmt.program_number,
187            });
188        }
189        let list_management = if self.managed.is_empty() {
190            CaPmtListManagement::Only
191        } else {
192            CaPmtListManagement::Add
193        };
194        let cmd_id = CaPmtCmdId::OkDescrambling;
195        let built = build_ca_pmt(pmt, list_management, cmd_id);
196        let built_bytes = built.to_bytes();
197        // Also build the `query`-variant bytes (same list_management) for the
198        // Task 5 re-query timer to resend — `ok_descrambling` solicits no
199        // reply (EN 50221 §8.4.3.5), so only `query` is fit for that purpose.
200        let requery_bytes = build_ca_pmt(pmt, list_management, CaPmtCmdId::Query).to_bytes();
201        // `PmtSection` has no raw-bytes accessor — re-serialize (byte-identical
202        // round-trip, a project invariant) to recover owned PMT bytes so
203        // `remove_service` (#763 Task 6) can later re-drive `remove_program`,
204        // which needs the raw section.
205        let mut pmt_raw = vec![0u8; pmt.serialized_len()];
206        let n = pmt
207            .serialize_into(&mut pmt_raw)
208            .expect("PmtSection::serialize_into on a freshly-sized buffer cannot fail");
209        pmt_raw.truncate(n);
210        self.send_ca_pmt(&built_bytes)?;
211        self.managed.record(
212            pmt.program_number,
213            managed::service_of(pmt, cmd_id, built_bytes, requery_bytes, pmt_raw),
214        );
215        Ok(())
216    }
217
218    /// Stop descrambling a previously-added service (#763 Task 6): sends the
219    /// removal `ca_pmt` (`list_management = update`, `cmd_id = not_selected`,
220    /// EN 50221 §8.4.3.4 Table 25) via the existing
221    /// [`remove_program`](Self::remove_program) path — re-driving it with the
222    /// raw PMT bytes stashed at [`add_service`](Self::add_service) time — then
223    /// drops the service from the managed set.
224    ///
225    /// Removing a `program_number` that isn't currently tracked (never
226    /// `add_service`'d, or already removed) is a **no-op**, not an error:
227    /// [`CaError`] has no not-found arm, and `remove_service` is idempotent.
228    ///
229    /// # Errors
230    /// [`CaError::Io`] if sending the removal `ca_pmt` fails.
231    pub fn remove_service(&mut self, program_number: u16) -> Result<(), CaError> {
232        let raw = self
233            .managed
234            .services()
235            .get(&program_number)
236            .map(|s| s.pmt_raw.clone());
237        let Some(raw) = raw else {
238            return Ok(());
239        };
240        self.remove_program(&raw)?;
241        self.managed.remove(program_number);
242        Ok(())
243    }
244
245    /// Set the entitlement re-query cadence (#763 Task 5): every
246    /// `interval`, the driver re-sends each actively-managed service's
247    /// `ca_pmt` (EN 50221 §8.4.3.4 Table 25, `cmd_id = query` — not the
248    /// `ok_descrambling` variant originally sent to start descrambling; per
249    /// §8.4.3.5, `ok_descrambling` solicits no reply) so the CAM re-evaluates
250    /// and replies, surfacing as [`Notification::CaPmtReply`] and — on a
251    /// status change — [`Notification::Entitlement`]. `Duration::ZERO`
252    /// disables re-query. Defaults to [`managed::REQUERY_DEFAULT`] (10s) at
253    /// construction.
254    pub fn set_requery_interval(&mut self, interval: Duration) {
255        self.managed.set_requery_interval(interval);
256    }
257
258    /// Feed a freshly-parsed CAT (ISO/IEC 13818-1 §2.4.4.5) to the managed
259    /// CAS-layer state: extracts its `CA_descriptor`s (EN 300 468 §6.2.16,
260    /// CAID → EMM PID) and recomputes [`emm_pids`](Self::emm_pids) against
261    /// the CAM's advertised CAIDs (last `Notification::CaInfo`, captured
262    /// automatically as it arrives — see [`pump`](Self::pump)).
263    ///
264    /// Calling this before any `ca_info` has been observed is **not** an
265    /// error: [`emm_pids`](Self::emm_pids) stays empty until the CAM
266    /// advertises its CAIDs, then recomputes against the CAT stored here —
267    /// `set_cat` need not be re-called once `ca_info` arrives.
268    ///
269    /// # Errors
270    /// [`CaError::Cat`] if the CAT's descriptor loop carries a truncated
271    /// `CA_descriptor`.
272    pub fn set_cat(&mut self, cat: &CatSection<'_>) -> Result<(), CaError> {
273        let entries = cat.ca_descriptors().map_err(CaError::Cat)?;
274        self.managed.set_cat(&entries);
275        Ok(())
276    }
277
278    /// The EMM PIDs to route into `ci0` — the last [`set_cat`](Self::set_cat)'s
279    /// CAID → EMM-PID map intersected with the CAM's advertised CAIDs (#763
280    /// Task 4).
281    #[must_use]
282    pub fn emm_pids(&self) -> &[u16] {
283        self.managed.emm_pids()
284    }
285
286    /// The PIDs to route into `ci0` for descrambling — the union of every
287    /// actively-managed service's elementary-stream PIDs (#763 Task 4).
288    #[must_use]
289    pub fn descramble_pids(&self) -> &[u16] {
290        self.managed.descramble_pids()
291    }
292
293    /// The union of every actively-managed service's `CA_PID`s (ECM PIDs —
294    /// ISO/IEC 13818-1 §2.6.16 `CA_descriptor` `CA_PID`, programme + ES level
295    /// combined) — the control-word channel, without which the module has ES
296    /// to descramble but no control words to do it with (#763 Task 7).
297    #[must_use]
298    pub fn ca_pids(&self) -> &[u16] {
299        self.managed.ca_pids()
300    }
301
302    /// `descramble_pids() ∪ ca_pids() ∪ emm_pids() ∪ PCR` — every PID class
303    /// this slot needs on `ci0` (ES to descramble ∪ ECM for control words ∪
304    /// EMM for entitlements ∪ each active service's PCR PID — ISO/IEC
305    /// 13818-1 §2.4.4.8 — so the descrambled TS keeps its clock reference
306    /// even when the PCR rides a dedicated PID). #763 Task 7's turnkey
307    /// [`CaDescrambler`](crate::descrambler::CaDescrambler) filters its
308    /// `feed_ts` input to exactly this set.
309    #[must_use]
310    pub fn required_pids(&self) -> Vec<u16> {
311        self.managed.required_pids()
312    }
313
314    /// Answer an MMI menu/list by 1-based `choice_ref` (0 = back/cancel).
315    pub fn mmi_menu_answer(&mut self, choice_ref: u8) -> io::Result<()> {
316        let actions = self
317            .stack
318            .handle(Event::Host(HostRequest::MmiMenuAnswer(choice_ref)));
319        self.run(actions)
320    }
321
322    /// Answer an MMI enquiry with the user's input (EN 300 468 Annex A bytes).
323    pub fn mmi_enquiry_answer(&mut self, text: &[u8]) -> io::Result<()> {
324        let actions = self
325            .stack
326            .handle(Event::Host(HostRequest::MmiEnquiryAnswer(text)));
327        self.run(actions)
328    }
329
330    /// Abort the current MMI dialogue (`answ` with `answ_id = cancel`).
331    pub fn mmi_cancel(&mut self) -> io::Result<()> {
332        let actions = self.stack.handle(Event::Host(HostRequest::MmiCancel));
333        self.run(actions)
334    }
335
336    /// Ask the module to open its MMI menu (`enter_menu`) — e.g. to read card /
337    /// entitlement info from the module's own menus.
338    pub fn enter_menu(&mut self) -> io::Result<()> {
339        let actions = self.stack.handle(Event::Host(HostRequest::EnterMenu));
340        self.run(actions)
341    }
342
343    /// One pump step: if the device is readable within `timeout`, read a frame
344    /// and feed it; otherwise advance the stack's timers by `timeout` (driving
345    /// the poll cadence). Returns whether a frame was processed.
346    ///
347    /// Also samples [`SlotInfo`] once per call (the DVB-CA slot has no
348    /// interrupt/event of its own; `CA_GET_SLOT_INFO` is a poll) so a hot-plug
349    /// edge is caught between reads — see [`Notification::HotPlug`] carrying
350    /// [`HotPlug::CamPresent`]/[`CamRemoved`](HotPlug::CamRemoved) (#726).
351    pub fn pump(&mut self, timeout: Duration) -> io::Result<bool> {
352        self.run(vec![Action::QuerySlot])?;
353        if self.device.poll(timeout)? {
354            let n = self.device.read(&mut self.buf)?;
355            if n > 0 {
356                let frame = self.buf[..n].to_vec();
357                let actions = self.stack.handle(Event::Readable(&frame));
358                self.run(actions)?;
359                return Ok(true);
360            }
361        }
362        let actions = self.stack.handle(Event::Tick { elapsed: timeout });
363        self.run(actions)?;
364        self.requery_tick(timeout)?;
365        Ok(false)
366    }
367
368    /// Advance the #763 Task 5 entitlement re-query cadence by `elapsed`
369    /// ([`ManagedCa::tick`](crate::managed::ManagedCa::tick), mirroring
370    /// `resource.rs`'s `DateTime::tick` accumulate-then-fire pattern). When
371    /// the interval elapses, re-send every actively-managed service's
372    /// `query`-variant `ca_pmt` (`ManagedService::requery_ca_pmt`) via the
373    /// same [`send_ca_pmt`](Self::send_ca_pmt) path
374    /// [`add_service`](Self::add_service) uses (EN 50221 §8.4.3.4 Table 25) —
375    /// `cmd_id = query`, not the `ok_descrambling` bytes originally sent, is
376    /// required for a conformant CAM to re-evaluate and reply (§8.4.3.5:
377    /// `ok_descrambling` solicits no reply).
378    fn requery_tick(&mut self, elapsed: Duration) -> io::Result<()> {
379        if !self.managed.tick(elapsed) {
380            return Ok(());
381        }
382        let ca_pmts: Vec<Vec<u8>> = self
383            .managed
384            .services()
385            .values()
386            .map(|s| s.requery_ca_pmt.clone())
387            .collect();
388        for ca_pmt in ca_pmts {
389            self.send_ca_pmt(&ca_pmt)?;
390        }
391        Ok(())
392    }
393
394    /// Pump once ([`pump`](Self::pump)), then invoke `handler` for each
395    /// [`Notification`] produced this cycle (drain-and-dispatch via
396    /// [`take_notifications`](Self::take_notifications)). Returns the same
397    /// bool as `pump`. The closure is per-call — nothing is stored, so there
398    /// are no lifetime constraints beyond the call itself. This crate is
399    /// sync/sans-IO (no channels/async runtime), so a closure callback is the
400    /// idiomatic push-style alternative to poll-draining `take_notifications`
401    /// yourself.
402    pub fn pump_with<F: FnMut(&Notification)>(
403        &mut self,
404        timeout: Duration,
405        mut handler: F,
406    ) -> io::Result<bool> {
407        let progressed = self.pump(timeout)?;
408        for n in self.take_notifications() {
409            handler(&n);
410        }
411        Ok(progressed)
412    }
413
414    /// Convenience over [`pump_with`](Self::pump_with): invoke `handler` only
415    /// for [`HotPlug`] transitions, ignoring every other [`Notification`]
416    /// produced this cycle.
417    pub fn pump_hotplug<F: FnMut(HotPlug)>(
418        &mut self,
419        timeout: Duration,
420        mut handler: F,
421    ) -> io::Result<bool> {
422        self.pump_with(timeout, |n| {
423            if let Some(h) = n.hotplug() {
424                handler(h);
425            }
426        })
427    }
428
429    /// Execute the stack's actions against the device.
430    fn run(&mut self, actions: Vec<Action>) -> io::Result<()> {
431        for action in actions {
432            match action {
433                Action::Write(bytes) => self.device.write(&bytes)?,
434                Action::Reset => self.device.reset()?,
435                Action::QuerySlot => {
436                    let info = self.device.slot_info()?;
437                    self.handle_slot_info(info)?;
438                }
439                Action::SetTimer { after } => self.next_timer = Some(after),
440                Action::Notify(n) => {
441                    let inferred = self.infer_card(&n);
442                    self.notifications.push(n);
443                    self.notifications.extend(inferred);
444                }
445            }
446        }
447        Ok(())
448    }
449
450    /// Compare a freshly-queried [`SlotInfo`] against the last one observed
451    /// and react to a `module_present` edge (Part A, #726): the *first*
452    /// observation ever (`self.last_slot == None`) only establishes the
453    /// baseline — it must not fire a notification, or `Driver::init` against
454    /// an already-inserted module would spuriously report a hot-plug and
455    /// recurse into re-driving its own in-progress handshake.
456    fn handle_slot_info(&mut self, info: SlotInfo) -> io::Result<()> {
457        let prev = self.last_slot.replace(info);
458        match prev {
459            Some(prev) if !prev.module_present && info.module_present => {
460                self.notifications
461                    .push(Notification::HotPlug(HotPlug::CamPresent));
462                self.reset_module_state();
463                // Re-drive the same reset/init path `Driver::init` uses, so
464                // the newly-inserted module gets a clean resource-manager
465                // handshake (no duplicated handshake logic).
466                let actions = self.stack.handle(Event::Host(HostRequest::Init));
467                self.run(actions)?;
468            }
469            Some(prev) if prev.module_present && !info.module_present => {
470                self.notifications
471                    .push(Notification::HotPlug(HotPlug::CamRemoved));
472                self.reset_module_state();
473            }
474            _ => {}
475        }
476        Ok(())
477    }
478
479    /// Reset per-module protocol + card-inference state after a CAM
480    /// insert/remove edge: a fresh [`CiStack`] (so a re-insert re-handshakes
481    /// cleanly instead of reusing stale session numbers) and cleared Part B
482    /// baselines (so the next module's `ca_info`/`ca_pmt_reply` establishes
483    /// its own fresh baseline rather than diffing against the departed
484    /// module's), AND the managed CAS-layer state (#763 Task 6 fix): a stale
485    /// `services`/`descramble_pids`/`emm_pids` set must not survive a
486    /// departed or freshly-inserted module — the host must re-provision from
487    /// scratch (the next `add_service` then correctly picks `Only` again).
488    fn reset_module_state(&mut self) {
489        self.stack = CiStack::new();
490        self.next_timer = None;
491        self.last_caids = None;
492        self.last_descrambling_ok = None;
493        self.managed.clear();
494    }
495
496    /// Best-effort app-layer card-presence inference (Part B, #726): EN 50221
497    /// CI slots are module-level only — there is no card-detect line (verified
498    /// against real DD ddbridge / cxd2099 driver behaviour) — so this derives
499    /// card insert/remove/change from signals the module already sends for
500    /// other reasons. Returns any inferred [`Notification`]s (0 or 1); `note`
501    /// itself is pushed by the caller.
502    fn infer_card(&mut self, note: &Notification) -> Vec<Notification> {
503        match note {
504            Notification::CaInfo { ca_system_ids } => {
505                let new_set: BTreeSet<u16> = ca_system_ids.iter().copied().collect();
506                let mut out = Vec::new();
507                if let Some(prev) = &self.last_caids {
508                    if prev.is_empty() && !new_set.is_empty() {
509                        out.push(Notification::HotPlug(HotPlug::CardInserted));
510                    } else if !prev.is_empty() && new_set.is_empty() {
511                        out.push(Notification::HotPlug(HotPlug::CardRemoved));
512                    } else if !prev.is_empty() && !new_set.is_empty() && *prev != new_set {
513                        out.push(Notification::HotPlug(HotPlug::CardChanged));
514                    }
515                }
516                // #763 Task 4: feed the CAM's advertised CAIDs to the managed
517                // CAS-layer state so `emm_pids` recomputes against the
518                // already-stored CAT map (if `set_cat` ran first).
519                self.managed.set_cam_caids(new_set.clone());
520                self.last_caids = Some(new_set);
521                out
522            }
523            Notification::CaPmtReply {
524                program_number,
525                ca_enable,
526                descrambling_ok,
527            } => {
528                let mut out = Vec::new();
529                if let Some(prev) = self.last_descrambling_ok {
530                    if !prev && *descrambling_ok {
531                        out.push(Notification::HotPlug(HotPlug::CardInserted));
532                    } else if prev && !*descrambling_ok {
533                        out.push(Notification::HotPlug(HotPlug::CardRemoved));
534                    }
535                }
536                self.last_descrambling_ok = Some(*descrambling_ok);
537                // #763 Task 5: diff this reply's programme-level status
538                // against the last one recorded for `program_number` and
539                // surface the edge-triggered `Notification::Entitlement`.
540                if let Some((v, ok)) =
541                    self.managed
542                        .record_reply(*program_number, *ca_enable, *descrambling_ok)
543                {
544                    out.push(Notification::Entitlement {
545                        program_number: *program_number,
546                        ca_enable: v,
547                        descrambling_ok: ok,
548                    });
549                }
550                out
551            }
552            Notification::Mmi(ev) => match Self::mmi_text(ev) {
553                Some(text) => {
554                    let lower = text.to_lowercase();
555                    if MMI_CARD_ABSENT_KEYWORDS.iter().any(|k| lower.contains(k)) {
556                        vec![Notification::HotPlug(HotPlug::CardRemoved)]
557                    } else if MMI_CARD_PRESENT_KEYWORDS.iter().any(|k| lower.contains(k)) {
558                        vec![Notification::HotPlug(HotPlug::CardInserted)]
559                    } else {
560                        Vec::new()
561                    }
562                }
563                None => Vec::new(),
564            },
565            _ => Vec::new(),
566        }
567    }
568
569    /// The free-text an [`MmiEvent`] carries, for the keyword heuristic above
570    /// (title/subtitle/bottom/choices for a menu/list, the prompt for an
571    /// enquiry; a `Close` carries no text).
572    fn mmi_text(ev: &MmiEvent) -> Option<String> {
573        match ev {
574            MmiEvent::Menu(m) | MmiEvent::List(m) => {
575                let mut s = format!("{} {} {}", m.title, m.subtitle, m.bottom);
576                for choice in &m.choices {
577                    s.push(' ');
578                    s.push_str(choice);
579                }
580                Some(s)
581            }
582            MmiEvent::Enquiry { prompt, .. } => Some(prompt.clone()),
583            MmiEvent::Close => None,
584        }
585    }
586}
587
588#[cfg(test)]
589pub(crate) mod tests {
590    use super::*;
591    use crate::device::{DeviceOp, MockCaDevice};
592    use crate::event::{HostControlEvent, HotPlug, Notification};
593    use broadcast_common::Serialize;
594    use dvb_ci::tpdu::tags;
595
596    pub(crate) fn ser<S: Serialize>(s: &S) -> Vec<u8> {
597        let mut b = vec![0u8; s.serialized_len()];
598        match s.serialize_into(&mut b) {
599            Ok(n) => b.truncate(n),
600            Err(_) => b.clear(),
601        }
602        b
603    }
604
605    /// Wrap an SPDU as a module→host `T_Data_Last` R_TPDU (+ trailing T_SB,
606    /// data_available clear) on transport connection `tcid`.
607    fn r_data(tcid: u8, spdu: &[u8]) -> Vec<u8> {
608        use dvb_ci::tpdu::{SbValue, tags as tpdu_tags};
609        let mut v = vec![tpdu_tags::DATA_LAST, (1 + spdu.len()) as u8, tcid];
610        v.extend_from_slice(spdu);
611        v.extend_from_slice(&[tpdu_tags::SB, 0x02, tcid, SbValue::new(false).0]);
612        v
613    }
614
615    /// Wrap an APDU for delivery on `session_nb` (session_number prefix), then as
616    /// a module→host R_TPDU on tcid 1.
617    pub(crate) fn r_apdu(session_nb: u16, apdu: &[u8]) -> Vec<u8> {
618        use dvb_ci::spdu::SessionNumber;
619        let mut spdu = ser(&SessionNumber { session_nb });
620        spdu.extend_from_slice(apdu);
621        r_data(1, &spdu)
622    }
623
624    /// A standalone module→host `T_SB` (data_available clear) ack — flushes one
625    /// queued host write per turn (#337).
626    pub(crate) fn sb() -> Vec<u8> {
627        use dvb_ci::tpdu::{SbValue, tags as tpdu_tags};
628        vec![tpdu_tags::SB, 0x02, 0x01, SbValue::new(false).0]
629    }
630
631    /// Feed one scripted module frame into the mock and pump it, then pump a
632    /// handful of SB acks so any queued host writes flush.
633    pub(crate) fn feed(d: &mut Driver<MockCaDevice>, frame: Vec<u8>) {
634        d.device_mut().inbound.push_back(frame);
635        d.pump(Duration::from_millis(10)).unwrap();
636        for _ in 0..8 {
637            d.device_mut().inbound.push_back(sb());
638            d.pump(Duration::from_millis(10)).unwrap();
639        }
640    }
641
642    /// Drive the EN 50221 handshake through the `Driver` until host_control and
643    /// the other module-provided sessions are open (mirrors the stack-level
644    /// `stack_with_ca_session`, but exercises the real driver I/O path).
645    pub(crate) fn driver_with_sessions() -> Driver<MockCaDevice> {
646        use dvb_ci::objects::resource_manager::Profile;
647        use dvb_ci::resource::{
648            APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, HOST_CONTROL, MMI,
649            RESOURCE_MANAGER,
650        };
651        use dvb_ci::spdu::{CreateSessionResponse, OpenSessionRequest, SessionStatus};
652
653        let mut d = Driver::new(MockCaDevice::new([]));
654        d.init().unwrap();
655        // module accepts the transport connection
656        feed(&mut d, vec![tags::C_T_C_REPLY, 0x01, 0x01]);
657        // module opens the host's resource_manager → RM session 1
658        feed(
659            &mut d,
660            r_data(
661                1,
662                &ser(&OpenSessionRequest {
663                    resource: RESOURCE_MANAGER,
664                }),
665            ),
666        );
667        // module's profile → host: CamReady + profile_change + create_session for
668        // each module-provided resource.
669        feed(
670            &mut d,
671            r_apdu(
672                1,
673                &ser(&Profile {
674                    resources: vec![
675                        APPLICATION_INFORMATION,
676                        CONDITIONAL_ACCESS_SUPPORT,
677                        MMI,
678                        HOST_CONTROL,
679                    ],
680                }),
681            ),
682        );
683        // module accepts each create_session (session nbs 2..=5 in registration order)
684        for (nb, res) in [
685            (2u16, APPLICATION_INFORMATION),
686            (3, CONDITIONAL_ACCESS_SUPPORT),
687            (4, MMI),
688            (5, HOST_CONTROL),
689        ] {
690            feed(
691                &mut d,
692                r_data(
693                    1,
694                    &ser(&CreateSessionResponse {
695                        status: SessionStatus::Ok,
696                        resource: res,
697                        session_nb: nb,
698                    }),
699                ),
700            );
701        }
702        d
703    }
704
705    // Session numbers the module allocates in `driver_with_sessions`, in
706    // registration order: RM=1, app_info=2, conditional_access=3, mmi=4,
707    // host_control=5. (Asserted by `handshake_opens_expected_sessions`.)
708    const RM_SESSION: u16 = 1;
709    pub(crate) const CA_SESSION: u16 = 3;
710    const MMI_SESSION: u16 = 4;
711    const HOST_CONTROL_SESSION: u16 = 5;
712
713    #[test]
714    fn host_control_tune_apdu_surfaces_notification_via_driver() {
715        use dvb_ci::objects::host_control::Tune;
716
717        let mut d = driver_with_sessions();
718        let hc_nb = HOST_CONTROL_SESSION;
719        d.take_notifications(); // drop handshake notifications
720
721        // Module (CAM) sends a Tune request on its host_control session.
722        let tune = Tune {
723            network_id: 0x1122,
724            original_network_id: 0x3344,
725            transport_stream_id: 0x5566,
726            service_id: 0x7788,
727        };
728        feed(&mut d, r_apdu(hc_nb, &ser(&tune)));
729
730        // The runtime surfaces the decoded HostControl(Tune) notification.
731        let notes = d.take_notifications();
732        assert!(
733            notes.contains(&Notification::HostControl(HostControlEvent::Tune {
734                network_id: 0x1122,
735                original_network_id: 0x3344,
736                transport_stream_id: 0x5566,
737                service_id: 0x7788,
738            })),
739            "expected HostControl(Tune) notification, got {notes:?}"
740        );
741    }
742
743    #[test]
744    fn profile_reply_advertises_host_control() {
745        use broadcast_common::Parse;
746        use dvb_ci::objects::resource_manager::{Profile, ProfileEnq};
747        use dvb_ci::resource::{HOST_CONTROL, RESOURCE_MANAGER};
748
749        let mut d = Driver::new(MockCaDevice::new([]));
750        d.init().unwrap();
751        feed(&mut d, vec![tags::C_T_C_REPLY, 0x01, 0x01]);
752        // Open RM, then the module enquires the host profile.
753        feed(
754            &mut d,
755            r_data(
756                1,
757                &ser(&dvb_ci::spdu::OpenSessionRequest {
758                    resource: RESOURCE_MANAGER,
759                }),
760            ),
761        );
762        // Module → profile_enq on the RM session → host replies with its profile.
763        feed(&mut d, r_apdu(RM_SESSION, &ser(&ProfileEnq)));
764
765        // Find the host's `profile` reply (tag 9F 80 11) in the written frames and
766        // confirm it lists HOST_CONTROL.
767        let want = dvb_ci::tag::PROFILE.to_bytes();
768        let found = d.device().ops.iter().any(|op| {
769            if let DeviceOp::Write(w) = op {
770                if let Some(pos) = w.windows(3).position(|x| x == want) {
771                    if let Ok(p) = Profile::parse(&w[pos..]) {
772                        return p.resources.contains(&HOST_CONTROL);
773                    }
774                }
775            }
776            false
777        });
778        assert!(found, "profile reply must advertise HOST_CONTROL");
779    }
780
781    #[test]
782    fn mmi_menu_answ_and_answ_are_byte_exact_on_the_mmi_session() {
783        use dvb_ci::objects::mmi_high::{Answ, AnswId, MenuAnsw};
784
785        let mut d = driver_with_sessions();
786        let mmi_nb = MMI_SESSION;
787
788        // menu_answ(choice_ref = 2): the driver method must put the exact dvb-ci
789        // MenuAnsw serialization on the wire, on the MMI session.
790        d.mmi_menu_answer(2).unwrap();
791        d.device_mut().inbound.push_back(sb());
792        d.pump(Duration::from_millis(10)).unwrap();
793        assert_apdu_on_session(&d, mmi_nb, &ser(&MenuAnsw { choice_ref: 2 }));
794
795        // answ(answer, "1234"): byte-exact Answ serialization on the MMI session.
796        d.mmi_enquiry_answer(b"1234").unwrap();
797        d.device_mut().inbound.push_back(sb());
798        d.pump(Duration::from_millis(10)).unwrap();
799        assert_apdu_on_session(
800            &d,
801            mmi_nb,
802            &ser(&Answ {
803                answ_id: AnswId::Answer,
804                text_chars: b"1234",
805            }),
806        );
807    }
808
809    /// Assert some host write carries `session_number(session_nb)` immediately
810    /// followed by the exact `apdu` bytes (byte-exact APDU on the right session).
811    fn assert_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) {
812        use dvb_ci::spdu::SessionNumber;
813        let mut want = ser(&SessionNumber { session_nb });
814        want.extend_from_slice(apdu);
815        let hit = d.device().ops.iter().any(|op| match op {
816            DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
817            _ => false,
818        });
819        assert!(
820            hit,
821            "expected APDU {apdu:02X?} on session {session_nb} (session-prefixed {want:02X?}) in writes"
822        );
823    }
824
825    /// How many host writes carry `session_number(session_nb)` immediately
826    /// followed by the exact `apdu` bytes — used to distinguish an initial
827    /// send from a later re-send (#763 Task 5's re-query timer).
828    fn count_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) -> usize {
829        use dvb_ci::spdu::SessionNumber;
830        let mut want = ser(&SessionNumber { session_nb });
831        want.extend_from_slice(apdu);
832        d.device()
833            .ops
834            .iter()
835            .filter(|op| match op {
836                DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
837                _ => false,
838            })
839            .count()
840    }
841
842    #[test]
843    fn init_drives_reset_slotinfo_and_create_tc_to_device() {
844        let mut d = Driver::new(MockCaDevice::new([]));
845        d.init().unwrap();
846        let ops = &d.device().ops;
847        assert_eq!(ops[0], DeviceOp::Reset);
848        assert_eq!(ops[1], DeviceOp::SlotInfo);
849        assert!(matches!(&ops[2], DeviceOp::Write(w) if w[0] == tags::CREATE_T_C));
850    }
851
852    #[test]
853    fn reads_reply_then_polls_on_pump() {
854        // Script the module accepting the connection.
855        let dev = MockCaDevice::new([vec![tags::C_T_C_REPLY, 0x01, 0x01]]);
856        let mut d = Driver::new(dev);
857        d.init().unwrap();
858        // first pump reads the C_T_C_Reply (activates the connection)
859        assert!(d.pump(Duration::from_millis(100)).unwrap());
860        // next pump has nothing to read → ticks → emits a poll write
861        assert!(!d.pump(Duration::from_millis(100)).unwrap());
862        let last = d.device().ops.last().unwrap();
863        assert!(matches!(last, DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)));
864    }
865
866    // --- #726: CAM + card hot-plug notifications ---
867
868    #[test]
869    fn cam_insert_edge_emits_cam_present_once_and_redrives_handshake() {
870        let mut dev = MockCaDevice::new([]);
871        dev.slot = SlotInfo {
872            num: 0,
873            module_ready: false,
874            module_present: false,
875        };
876        let mut d = Driver::new(dev);
877        d.init().unwrap();
878        // The first-ever slot observation only establishes the baseline
879        // (absent) — it must not itself claim a hot-plug edge.
880        let notes = d.take_notifications();
881        assert!(
882            !notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
883            "baseline observation must not fire CamPresent, got {notes:?}"
884        );
885        let resets_before = d
886            .device()
887            .ops
888            .iter()
889            .filter(|o| **o == DeviceOp::Reset)
890            .count();
891
892        // Module physically inserted and ready.
893        d.device_mut().slot = SlotInfo {
894            num: 0,
895            module_ready: true,
896            module_present: true,
897        };
898        d.pump(Duration::from_millis(10)).unwrap();
899
900        let notes = d.take_notifications();
901        let cam_present_count = notes
902            .iter()
903            .filter(|n| **n == Notification::HotPlug(HotPlug::CamPresent))
904            .count();
905        assert_eq!(
906            cam_present_count, 1,
907            "expected exactly one CamPresent, got {notes:?}"
908        );
909        // Handshake re-driven: a fresh Reset, and the last write is CREATE_T_C.
910        let resets_after = d
911            .device()
912            .ops
913            .iter()
914            .filter(|o| **o == DeviceOp::Reset)
915            .count();
916        assert_eq!(
917            resets_after,
918            resets_before + 1,
919            "expected one fresh Reset on re-insert"
920        );
921        assert!(
922            matches!(d.device().ops.last(), Some(DeviceOp::Write(w)) if w[0] == tags::CREATE_T_C),
923            "expected the handshake re-driven (CREATE_T_C written), got {:?}",
924            d.device().ops.last()
925        );
926    }
927
928    #[test]
929    fn cam_remove_edge_emits_cam_removed_and_re_insert_re_handshakes() {
930        let mut d = driver_with_sessions();
931        d.take_notifications();
932
933        // Module physically removed.
934        d.device_mut().slot.module_present = false;
935        d.pump(Duration::from_millis(10)).unwrap();
936        let notes = d.take_notifications();
937        assert!(
938            notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
939            "expected CamRemoved, got {notes:?}"
940        );
941
942        // Session state was torn down: the MMI session from
943        // `driver_with_sessions` no longer exists on the fresh stack, so an
944        // answer to it now errors instead of silently going nowhere.
945        d.mmi_menu_answer(0).unwrap();
946        let notes = d.take_notifications();
947        assert!(
948            notes
949                .iter()
950                .any(|n| matches!(n, Notification::Error { .. })),
951            "expected no open MMI session after teardown, got {notes:?}"
952        );
953
954        // Re-insert: a fresh handshake starts (Reset + CamPresent).
955        let resets_before = d
956            .device()
957            .ops
958            .iter()
959            .filter(|o| **o == DeviceOp::Reset)
960            .count();
961        d.device_mut().slot.module_present = true;
962        d.device_mut().slot.module_ready = true;
963        d.pump(Duration::from_millis(10)).unwrap();
964        let notes = d.take_notifications();
965        assert!(
966            notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
967            "expected CamPresent on re-insert, got {notes:?}"
968        );
969        let resets_after = d
970            .device()
971            .ops
972            .iter()
973            .filter(|o| **o == DeviceOp::Reset)
974            .count();
975        assert_eq!(resets_after, resets_before + 1, "expected a fresh Reset");
976    }
977
978    #[test]
979    fn slot_status_unchanged_across_polls_emits_no_hotplug_notifications() {
980        let mut d = Driver::new(MockCaDevice::new([]));
981        d.init().unwrap();
982        d.take_notifications();
983
984        for _ in 0..5 {
985            d.pump(Duration::from_millis(10)).unwrap();
986        }
987        let notes = d.take_notifications();
988        assert!(
989            !notes.iter().any(|n| matches!(
990                n,
991                Notification::HotPlug(HotPlug::CamPresent | HotPlug::CamRemoved)
992            )),
993            "unchanged slot status must not emit hot-plug notifications, got {notes:?}"
994        );
995    }
996
997    #[test]
998    fn ca_info_caid_set_change_infers_card_inserted_then_changed() {
999        use dvb_ci::objects::ca_info::CaInfo;
1000
1001        let mut d = driver_with_sessions();
1002        d.take_notifications();
1003
1004        // First ca_info: no CAIDs (baseline only, no notification).
1005        feed(
1006            &mut d,
1007            r_apdu(
1008                CA_SESSION,
1009                &ser(&CaInfo {
1010                    ca_system_ids: vec![],
1011                }),
1012            ),
1013        );
1014        let notes = d.take_notifications();
1015        assert!(
1016            !notes.iter().any(|n| matches!(
1017                n,
1018                Notification::HotPlug(
1019                    HotPlug::CardInserted | HotPlug::CardChanged | HotPlug::CardRemoved
1020                )
1021            )),
1022            "first ca_info must only establish the baseline, got {notes:?}"
1023        );
1024
1025        // CAID set becomes populated: card inserted.
1026        feed(
1027            &mut d,
1028            r_apdu(
1029                CA_SESSION,
1030                &ser(&CaInfo {
1031                    ca_system_ids: vec![0x0B00],
1032                }),
1033            ),
1034        );
1035        let notes = d.take_notifications();
1036        assert!(
1037            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1038            "expected CardInserted, got {notes:?}"
1039        );
1040
1041        // CAID set changes to a different non-empty set: card changed.
1042        feed(
1043            &mut d,
1044            r_apdu(
1045                CA_SESSION,
1046                &ser(&CaInfo {
1047                    ca_system_ids: vec![0x1800],
1048                }),
1049            ),
1050        );
1051        let notes = d.take_notifications();
1052        assert!(
1053            notes.contains(&Notification::HotPlug(HotPlug::CardChanged)),
1054            "expected CardChanged, got {notes:?}"
1055        );
1056    }
1057
1058    #[test]
1059    fn ca_pmt_reply_descrambling_transition_infers_card_present_then_removed() {
1060        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1061
1062        fn reply(ca_enable: Option<CaEnable>) -> CaPmtReply {
1063            CaPmtReply {
1064                program_number: 1,
1065                version_number: 1,
1066                current_next_indicator: true,
1067                ca_enable,
1068                streams: vec![],
1069            }
1070        }
1071
1072        let mut d = driver_with_sessions();
1073        d.take_notifications();
1074
1075        // Baseline: descrambling not (yet) possible.
1076        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1077        let notes = d.take_notifications();
1078        assert!(
1079            !notes.iter().any(|n| matches!(
1080                n,
1081                Notification::HotPlug(HotPlug::CardInserted | HotPlug::CardRemoved)
1082            )),
1083            "first ca_pmt_reply must only establish the baseline, got {notes:?}"
1084        );
1085
1086        // false -> true: card-present inference.
1087        feed(
1088            &mut d,
1089            r_apdu(CA_SESSION, &ser(&reply(Some(CaEnable::Possible)))),
1090        );
1091        let notes = d.take_notifications();
1092        assert!(
1093            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1094            "expected CardInserted, got {notes:?}"
1095        );
1096
1097        // true -> false: card removed.
1098        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1099        let notes = d.take_notifications();
1100        assert!(
1101            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1102            "expected CardRemoved, got {notes:?}"
1103        );
1104    }
1105
1106    #[test]
1107    fn ca_pmt_reply_surfaces_typed_ca_enable() {
1108        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1109
1110        let mut d = driver_with_sessions();
1111        d.take_notifications();
1112
1113        // `CA_enable` = 0x03 (possible under conditions, technical dialogue) —
1114        // EN 50221 §8.4.3.5 Table 26.
1115        feed(
1116            &mut d,
1117            r_apdu(
1118                CA_SESSION,
1119                &ser(&CaPmtReply {
1120                    program_number: 7,
1121                    version_number: 1,
1122                    current_next_indicator: true,
1123                    ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1124                    streams: vec![],
1125                }),
1126            ),
1127        );
1128        let notes = d.take_notifications();
1129        assert!(
1130            notes.contains(&Notification::CaPmtReply {
1131                program_number: 7,
1132                ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1133                descrambling_ok: true,
1134            }),
1135            "expected typed ca_enable on CaPmtReply, got {notes:?}"
1136        );
1137    }
1138
1139    #[test]
1140    fn ca_pmt_reply_flag_clear_surfaces_none() {
1141        use dvb_ci::objects::ca_pmt_reply::CaPmtReply;
1142
1143        let mut d = driver_with_sessions();
1144        d.take_notifications();
1145
1146        // Programme `CA_enable_flag` clear -> no programme-level status given
1147        // — EN 50221 §8.4.3.5 Table 26.
1148        feed(
1149            &mut d,
1150            r_apdu(
1151                CA_SESSION,
1152                &ser(&CaPmtReply {
1153                    program_number: 7,
1154                    version_number: 1,
1155                    current_next_indicator: true,
1156                    ca_enable: None,
1157                    streams: vec![],
1158                }),
1159            ),
1160        );
1161        let notes = d.take_notifications();
1162        assert!(
1163            notes.contains(&Notification::CaPmtReply {
1164                program_number: 7,
1165                ca_enable: None,
1166                descrambling_ok: false,
1167            }),
1168            "expected ca_enable None on flag-clear CaPmtReply, got {notes:?}"
1169        );
1170    }
1171
1172    #[test]
1173    fn mmi_no_card_text_infers_card_removed() {
1174        use dvb_ci::objects::mmi_high::Enq;
1175
1176        let mut d = driver_with_sessions();
1177        d.take_notifications();
1178
1179        feed(
1180            &mut d,
1181            r_apdu(
1182                MMI_SESSION,
1183                &ser(&Enq {
1184                    blind_answer: false,
1185                    answer_text_length: 0,
1186                    text_chars: b"NO CARD detected - please insert your smart card",
1187                }),
1188            ),
1189        );
1190
1191        let notes = d.take_notifications();
1192        assert!(
1193            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1194            "expected CardRemoved inferred from MMI 'no card' text, got {notes:?}"
1195        );
1196    }
1197
1198    #[test]
1199    fn pump_hotplug_delivers_cam_present_via_closure_exactly_once() {
1200        let mut dev = MockCaDevice::new([]);
1201        dev.slot = SlotInfo {
1202            num: 0,
1203            module_ready: false,
1204            module_present: false,
1205        };
1206        let mut d = Driver::new(dev);
1207        d.init().unwrap();
1208        d.take_notifications(); // drop the baseline observation
1209
1210        // Module physically inserted and ready.
1211        d.device_mut().slot = SlotInfo {
1212            num: 0,
1213            module_ready: true,
1214            module_present: true,
1215        };
1216
1217        let mut seen = Vec::new();
1218        d.pump_hotplug(Duration::from_millis(10), |hp| seen.push(hp))
1219            .unwrap();
1220
1221        assert_eq!(
1222            seen,
1223            vec![HotPlug::CamPresent],
1224            "expected the closure to receive HotPlug::CamPresent exactly once, got {seen:?}"
1225        );
1226    }
1227
1228    // --- #763 Task 3: ManagedCa + add_service ---
1229
1230    /// A `CA_descriptor` TLV (ISO/IEC 13818-1 §2.6.16): tag `0x09`, len `4`,
1231    /// `CA_system_id`(2), `reserved(3)`/`CA_PID`(13).
1232    pub(crate) fn ca_descriptor(ca_system_id: u16, pid: u16) -> [u8; 6] {
1233        [
1234            0x09,
1235            0x04,
1236            (ca_system_id >> 8) as u8,
1237            ca_system_id as u8,
1238            0xE0 | ((pid >> 8) as u8 & 0x1F),
1239            pid as u8,
1240        ]
1241    }
1242
1243    /// A synthetic scrambled-service PMT: programme-level `CA_descriptor`
1244    /// (`CA_system_id` `0x0500` = Viaccess, a real assigned value per the
1245    /// TSDuck CA-system registry consumed by `dvb_si::descriptors::ca::ca_system_name`),
1246    /// one scrambled H.264 video ES (own `CA_descriptor`), and one clear AAC
1247    /// audio ES.
1248    ///
1249    /// **Provenance:** no committed capture in this repo's fixture corpus
1250    /// carries a scrambled PMT — `fixtures/dvb-si/tnt-5w-12732v-isi6-10s.ts`'s
1251    /// five PMTs (verified via `cargo run -p dvb-tools -- dump ... --json`)
1252    /// are all clear/FTA services, and no CA-descriptor-bearing capture exists
1253    /// under `private/fixtures/` either. This hand-rolls the wire bytes per
1254    /// ISO/IEC 13818-1 §2.4.4.8's PMT syntax instead, mirroring the exact
1255    /// precedent already established by `dvb-ci/src/builder.rs`'s
1256    /// `build_test_pmt()` (a hand-rolled buffer "that mirrors a real
1257    /// CA-protected service") — real `CA_system_id`/`stream_type` values, real
1258    /// CRC, just not sourced from an off-air capture.
1259    pub(crate) fn build_ca_pmt_fixture(program_number: u16) -> Vec<u8> {
1260        const VIACCESS: u16 = 0x0500;
1261        let prog_ca = ca_descriptor(VIACCESS, 0x0064);
1262        let es0_ca = ca_descriptor(VIACCESS, 0x0065);
1263
1264        let mut body = Vec::new();
1265        body.push(0x02); // table_id (PMT)
1266        body.push(0); // section_length placeholder (fixed up below)
1267        body.push(0);
1268        body.extend_from_slice(&program_number.to_be_bytes());
1269        body.push(0xC3); // reserved(2)='11' | version(5)=1 | current_next=1
1270        body.push(0x00); // section_number
1271        body.push(0x00); // last_section_number
1272        body.push(0xE0 | 0x01); // reserved(3) | PCR_PID(13) = 0x0100
1273        body.push(0x00);
1274        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1275        body.push(prog_ca.len() as u8);
1276        body.extend_from_slice(&prog_ca);
1277        // ES0: H.264 video, pid 0x0100, scrambled (own CA_descriptor).
1278        body.push(0x1B);
1279        body.push(0xE0 | 0x01);
1280        body.push(0x00);
1281        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1282        body.push(es0_ca.len() as u8);
1283        body.extend_from_slice(&es0_ca);
1284        // ES1: AAC ADTS audio, pid 0x0101, clear.
1285        body.push(0x0F);
1286        body.push(0xE0 | 0x01);
1287        body.push(0x01);
1288        body.push(0xF0);
1289        body.push(0x00);
1290
1291        let section_length = body.len() - 3 + 4;
1292        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1293        body[2] = section_length as u8;
1294        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1295        body.extend_from_slice(&crc.to_be_bytes());
1296        body
1297    }
1298
1299    /// Same layout as [`build_ca_pmt_fixture`] but with the PCR carried on its
1300    /// own **dedicated** `PCR_PID` (`0x00FF`) — distinct from every ES PID
1301    /// (`0x0100`/`0x0101`) and CA PID (`0x0064`/`0x0065`) — a legitimate DVB
1302    /// config (ISO/IEC 13818-1 §2.4.4.8) that `build_ca_pmt_fixture`'s
1303    /// `PCR_PID == video ES PID` masks: the #763 final-review regression
1304    /// fixture for `required_pids`/`feed_ts` PCR routing.
1305    pub(crate) fn build_ca_pmt_fixture_dedicated_pcr(program_number: u16) -> Vec<u8> {
1306        const VIACCESS: u16 = 0x0500;
1307        let prog_ca = ca_descriptor(VIACCESS, 0x0064);
1308        let es0_ca = ca_descriptor(VIACCESS, 0x0065);
1309
1310        let mut body = Vec::new();
1311        body.push(0x02); // table_id (PMT)
1312        body.push(0); // section_length placeholder (fixed up below)
1313        body.push(0);
1314        body.extend_from_slice(&program_number.to_be_bytes());
1315        body.push(0xC3); // reserved(2)='11' | version(5)=1 | current_next=1
1316        body.push(0x00); // section_number
1317        body.push(0x00); // last_section_number
1318        body.push(0xE0); // reserved(3) | PCR_PID(13) high byte = 0x00FF >> 8
1319        body.push(0xFF); // PCR_PID low byte — dedicated, outside the ES/CA set
1320        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1321        body.push(prog_ca.len() as u8);
1322        body.extend_from_slice(&prog_ca);
1323        // ES0: H.264 video, pid 0x0100, scrambled (own CA_descriptor).
1324        body.push(0x1B);
1325        body.push(0xE0 | 0x01);
1326        body.push(0x00);
1327        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1328        body.push(es0_ca.len() as u8);
1329        body.extend_from_slice(&es0_ca);
1330        // ES1: AAC ADTS audio, pid 0x0101, clear.
1331        body.push(0x0F);
1332        body.push(0xE0 | 0x01);
1333        body.push(0x01);
1334        body.push(0xF0);
1335        body.push(0x00);
1336
1337        let section_length = body.len() - 3 + 4;
1338        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1339        body[2] = section_length as u8;
1340        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1341        body.extend_from_slice(&crc.to_be_bytes());
1342        body
1343    }
1344
1345    /// Same layout as [`build_ca_pmt_fixture`] but with no `CA_descriptor`
1346    /// anywhere (an ordinary clear/FTA service) — the negative-control PMT for
1347    /// [`CaError::NoCaDescriptor`].
1348    pub(crate) fn build_clear_pmt_fixture(program_number: u16) -> Vec<u8> {
1349        let mut body = Vec::new();
1350        body.push(0x02);
1351        body.push(0);
1352        body.push(0);
1353        body.extend_from_slice(&program_number.to_be_bytes());
1354        body.push(0xC3);
1355        body.push(0x00);
1356        body.push(0x00);
1357        body.push(0xE0 | 0x01);
1358        body.push(0x00);
1359        body.push(0xF0); // program_info_length = 0
1360        body.push(0x00);
1361        // ES0: H.264 video, pid 0x0100, clear.
1362        body.push(0x1B);
1363        body.push(0xE0 | 0x01);
1364        body.push(0x00);
1365        body.push(0xF0);
1366        body.push(0x00);
1367
1368        let section_length = body.len() - 3 + 4;
1369        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1370        body[2] = section_length as u8;
1371        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1372        body.extend_from_slice(&crc.to_be_bytes());
1373        body
1374    }
1375
1376    #[test]
1377    fn add_service_builds_and_sends_ca_pmt_matching_builder_oracle() {
1378        use broadcast_common::Parse;
1379
1380        let mut d = driver_with_sessions();
1381        d.take_notifications();
1382
1383        let pmt_bytes = build_ca_pmt_fixture(1546);
1384        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1385
1386        d.add_service(&pmt).unwrap();
1387        d.device_mut().inbound.push_back(sb());
1388        d.pump(Duration::from_millis(10)).unwrap();
1389
1390        // Oracle: the same PMT built directly via dvb_ci::builder::build_ca_pmt
1391        // with `Only` (first-ever service on an empty managed set) +
1392        // `ok_descrambling`.
1393        let expected =
1394            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
1395        assert_apdu_on_session(&d, CA_SESSION, &expected);
1396
1397        // The service was recorded with its ES/CA PIDs.
1398        let svc = d
1399            .managed_ca()
1400            .services()
1401            .get(&1546)
1402            .expect("program_number 1546 must be tracked after add_service");
1403        assert_eq!(svc.es_pids, vec![0x0100, 0x0101]);
1404        assert_eq!(svc.ca_pids, vec![0x0064, 0x0065]);
1405        assert_eq!(svc.cmd, CaPmtCmdId::OkDescrambling);
1406        assert_eq!(svc.last_ca_enable, None);
1407    }
1408
1409    #[test]
1410    fn add_service_rejects_pmt_without_ca_descriptor() {
1411        use broadcast_common::Parse;
1412
1413        let mut d = driver_with_sessions();
1414        let pmt_bytes = build_clear_pmt_fixture(999);
1415        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1416
1417        let err = d.add_service(&pmt).unwrap_err();
1418        assert!(
1419            matches!(
1420                err,
1421                CaError::NoCaDescriptor {
1422                    program_number: 999
1423                }
1424            ),
1425            "expected NoCaDescriptor{{program_number: 999}}, got {err:?}"
1426        );
1427        assert!(
1428            d.managed_ca().services().is_empty(),
1429            "a rejected PMT must not be recorded"
1430        );
1431    }
1432
1433    #[test]
1434    fn add_service_second_call_uses_add_list_management() {
1435        use broadcast_common::Parse;
1436
1437        let mut d = driver_with_sessions();
1438        d.take_notifications();
1439
1440        let pmt1_bytes = build_ca_pmt_fixture(1546);
1441        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1442        d.add_service(&pmt1).unwrap();
1443        d.device_mut().inbound.push_back(sb());
1444        d.pump(Duration::from_millis(10)).unwrap();
1445
1446        let pmt2_bytes = build_ca_pmt_fixture(1547);
1447        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1448        d.add_service(&pmt2).unwrap();
1449        d.device_mut().inbound.push_back(sb());
1450        d.pump(Duration::from_millis(10)).unwrap();
1451
1452        // Second service joins an already-active set → `Add`, not `Only`.
1453        let expected2 =
1454            build_ca_pmt(&pmt2, CaPmtListManagement::Add, CaPmtCmdId::OkDescrambling).to_bytes();
1455        assert_apdu_on_session(&d, CA_SESSION, &expected2);
1456
1457        assert_eq!(d.managed_ca().services().len(), 2);
1458    }
1459
1460    // --- #763 Task 4: set_cat + emm_pids/descramble_pids ---
1461
1462    /// A hand-built CAT section (ISO/IEC 13818-1 §2.4.4.5): table_id 0x01, a
1463    /// flat descriptor loop of `CA_descriptor`s (EN 300 468 §6.2.16, tag
1464    /// 0x09; the `ca_descriptor` helper above builds the same TLV used for
1465    /// PMTs). No off-air CAT capture exists in this repo's fixture corpus
1466    /// (verified: none of the committed `.ts` captures carry PID 0x0001),
1467    /// mirroring the same hand-rolled-fixture precedent as
1468    /// `build_ca_pmt_fixture` and `dvb_si::tables::cat`'s own unit tests.
1469    pub(crate) fn build_cat_fixture(descriptors: &[u8]) -> Vec<u8> {
1470        const EXTENSION_HEADER_LEN: u16 = 5;
1471        const CRC_LEN: u16 = 4;
1472        let section_length = EXTENSION_HEADER_LEN + descriptors.len() as u16 + CRC_LEN;
1473        let mut v = Vec::new();
1474        v.push(0x01); // table_id (CAT)
1475        v.push(0xB0 | ((section_length >> 8) as u8 & 0x0F));
1476        v.push((section_length & 0xFF) as u8);
1477        v.extend_from_slice(&[0xFF, 0xFF]); // table_id_extension (reserved for CAT)
1478        v.push(0xC1); // reserved(2)='11' | version(5)=0 | current_next=1
1479        v.push(0x00); // section_number
1480        v.push(0x00); // last_section_number
1481        v.extend_from_slice(descriptors);
1482        let crc = broadcast_common::crc32_mpeg2::compute(&v);
1483        v.extend_from_slice(&crc.to_be_bytes());
1484        v
1485    }
1486
1487    #[test]
1488    fn set_cat_computes_emm_pids_as_cat_inter_ca_info_caids() {
1489        use broadcast_common::Parse;
1490        use dvb_ci::objects::ca_info::CaInfo;
1491        use dvb_si::tables::cat::CatSection;
1492
1493        let mut d = driver_with_sessions();
1494        d.take_notifications();
1495
1496        // ca_info arrives first: the CAM advertises CAIDs 0x0648, 0x0100.
1497        feed(
1498            &mut d,
1499            r_apdu(
1500                CA_SESSION,
1501                &ser(&CaInfo {
1502                    ca_system_ids: vec![0x0648, 0x0100],
1503                }),
1504            ),
1505        );
1506        d.take_notifications();
1507
1508        // CAT maps 0x0648 -> 0x1FF0 (advertised) and 0x0500 -> 0x1FF1 (not
1509        // advertised by this CAM).
1510        let mut descriptors = Vec::new();
1511        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
1512        descriptors.extend_from_slice(&ca_descriptor(0x0500, 0x1FF1));
1513        let cat_bytes = build_cat_fixture(&descriptors);
1514        let cat = CatSection::parse(&cat_bytes).unwrap();
1515
1516        d.set_cat(&cat).unwrap();
1517
1518        assert_eq!(
1519            d.emm_pids(),
1520            &[0x1FF0],
1521            "0x0500 -> 0x1FF1 must be excluded: the CAM never advertised CAID 0x0500"
1522        );
1523    }
1524
1525    #[test]
1526    fn set_cat_before_ca_info_is_not_an_error_and_recomputes_once_ca_info_arrives() {
1527        use broadcast_common::Parse;
1528        use dvb_ci::objects::ca_info::CaInfo;
1529        use dvb_si::tables::cat::CatSection;
1530
1531        let mut d = driver_with_sessions();
1532        d.take_notifications();
1533
1534        let mut descriptors = Vec::new();
1535        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
1536        descriptors.extend_from_slice(&ca_descriptor(0x0500, 0x1FF1));
1537        let cat_bytes = build_cat_fixture(&descriptors);
1538        let cat = CatSection::parse(&cat_bytes).unwrap();
1539
1540        // set_cat with no ca_info observed yet: not an error, emm_pids stays
1541        // empty (nothing to intersect against).
1542        d.set_cat(&cat).unwrap();
1543        assert!(
1544            d.emm_pids().is_empty(),
1545            "emm_pids must be empty before any ca_info arrives, got {:?}",
1546            d.emm_pids()
1547        );
1548
1549        // ca_info now arrives: emm_pids recomputes against the CAT stored
1550        // earlier, without a second set_cat call.
1551        feed(
1552            &mut d,
1553            r_apdu(
1554                CA_SESSION,
1555                &ser(&CaInfo {
1556                    ca_system_ids: vec![0x0648, 0x0100],
1557                }),
1558            ),
1559        );
1560        d.take_notifications();
1561
1562        assert_eq!(
1563            d.emm_pids(),
1564            &[0x1FF0],
1565            "emm_pids must recompute once ca_info arrives, using the CAT stored by the earlier set_cat"
1566        );
1567    }
1568
1569    /// Task 4 review fix (MEDIUM): `recompute_emm_pids` must dedup like its
1570    /// sibling `recompute_service_pids` does — two CAT `CA_descriptor`s
1571    /// (distinct `CA_system_id`s, both CAM-advertised) that happen to share
1572    /// one `EMM_PID` (a real multi-CAS-on-one-EMM-PID broadcast setup) must
1573    /// list that PID exactly once, not twice.
1574    #[test]
1575    fn set_cat_emm_pids_dedups_when_two_caids_share_one_emm_pid() {
1576        use broadcast_common::Parse;
1577        use dvb_ci::objects::ca_info::CaInfo;
1578        use dvb_si::tables::cat::CatSection;
1579
1580        let mut d = driver_with_sessions();
1581        d.take_notifications();
1582
1583        // CAM advertises both CAIDs.
1584        feed(
1585            &mut d,
1586            r_apdu(
1587                CA_SESSION,
1588                &ser(&CaInfo {
1589                    ca_system_ids: vec![0x0648, 0x0100],
1590                }),
1591            ),
1592        );
1593        d.take_notifications();
1594
1595        // CAT maps BOTH CAIDs to the SAME EMM PID.
1596        let mut descriptors = Vec::new();
1597        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
1598        descriptors.extend_from_slice(&ca_descriptor(0x0100, 0x1FF0));
1599        let cat_bytes = build_cat_fixture(&descriptors);
1600        let cat = CatSection::parse(&cat_bytes).unwrap();
1601
1602        d.set_cat(&cat).unwrap();
1603
1604        assert_eq!(
1605            d.emm_pids(),
1606            &[0x1FF0],
1607            "0x1FF0 must appear exactly once even though two CAM-advertised CAIDs map to it, got {:?}",
1608            d.emm_pids()
1609        );
1610    }
1611
1612    /// Same layout as [`build_ca_pmt_fixture`] but with a distinct PCR/ES PID
1613    /// set, so a second added service proves `descramble_pids` is a real
1614    /// union rather than one programme's PIDs happening to repeat.
1615    fn build_ca_pmt_fixture_distinct_pids(program_number: u16) -> Vec<u8> {
1616        const VIACCESS: u16 = 0x0500;
1617        let prog_ca = ca_descriptor(VIACCESS, 0x0074);
1618        let es0_ca = ca_descriptor(VIACCESS, 0x0075);
1619
1620        let mut body = Vec::new();
1621        body.push(0x02); // table_id (PMT)
1622        body.push(0);
1623        body.push(0);
1624        body.extend_from_slice(&program_number.to_be_bytes());
1625        body.push(0xC3);
1626        body.push(0x00);
1627        body.push(0x00);
1628        body.push(0xE0 | 0x02); // PCR_PID = 0x0200
1629        body.push(0x00);
1630        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1631        body.push(prog_ca.len() as u8);
1632        body.extend_from_slice(&prog_ca);
1633        // ES0: H.264 video, pid 0x0200, scrambled.
1634        body.push(0x1B);
1635        body.push(0xE0 | 0x02);
1636        body.push(0x00);
1637        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1638        body.push(es0_ca.len() as u8);
1639        body.extend_from_slice(&es0_ca);
1640        // ES1: AAC ADTS audio, pid 0x0201, clear.
1641        body.push(0x0F);
1642        body.push(0xE0 | 0x02);
1643        body.push(0x01);
1644        body.push(0xF0);
1645        body.push(0x00);
1646
1647        let section_length = body.len() - 3 + 4;
1648        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1649        body[2] = section_length as u8;
1650        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1651        body.extend_from_slice(&crc.to_be_bytes());
1652        body
1653    }
1654
1655    #[test]
1656    fn descramble_pids_is_the_union_of_active_services_es_pids() {
1657        use broadcast_common::Parse;
1658
1659        let mut d = driver_with_sessions();
1660        d.take_notifications();
1661
1662        assert!(
1663            d.descramble_pids().is_empty(),
1664            "no service added yet: descramble_pids must be empty"
1665        );
1666
1667        let pmt1_bytes = build_ca_pmt_fixture(1546);
1668        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1669        d.add_service(&pmt1).unwrap();
1670        d.device_mut().inbound.push_back(sb());
1671        d.pump(Duration::from_millis(10)).unwrap();
1672
1673        assert_eq!(d.descramble_pids(), &[0x0100, 0x0101]);
1674
1675        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
1676        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1677        d.add_service(&pmt2).unwrap();
1678        d.device_mut().inbound.push_back(sb());
1679        d.pump(Duration::from_millis(10)).unwrap();
1680
1681        // Union of both programmes' ES PIDs, sorted.
1682        assert_eq!(
1683            d.descramble_pids(),
1684            &[0x0100, 0x0101, 0x0200, 0x0201],
1685            "descramble_pids must be the union across both added services"
1686        );
1687    }
1688
1689    // --- #763 Task 5: re-query timer + edge-triggered Entitlement ---
1690
1691    /// Build a `ca_pmt_reply` (EN 50221 §8.4.3.5, Table 26) for `program_number`
1692    /// carrying programme-level `ca_enable` (`None` = `CA_enable_flag` clear).
1693    pub(crate) fn ca_pmt_reply_for(
1694        program_number: u16,
1695        ca_enable: Option<dvb_ci::objects::ca_pmt_reply::CaEnable>,
1696    ) -> dvb_ci::objects::ca_pmt_reply::CaPmtReply {
1697        dvb_ci::objects::ca_pmt_reply::CaPmtReply {
1698            program_number,
1699            version_number: 1,
1700            current_next_indicator: true,
1701            ca_enable,
1702            streams: vec![],
1703        }
1704    }
1705
1706    #[test]
1707    fn requery_timer_resends_ca_pmt_then_reply_change_emits_one_entitlement() {
1708        use broadcast_common::Parse;
1709        use dvb_ci::objects::ca_pmt_reply::CaEnable;
1710
1711        let mut d = driver_with_sessions();
1712        d.take_notifications();
1713
1714        let pmt_bytes = build_ca_pmt_fixture(1546);
1715        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1716        d.add_service(&pmt).unwrap();
1717        d.device_mut().inbound.push_back(sb());
1718        d.pump(Duration::from_millis(10)).unwrap();
1719        d.take_notifications();
1720
1721        // The initial `add_service` send is `ok_descrambling` — assert it
1722        // happened, so the test proves the resend below (a distinct `query`
1723        // cmd_id) is a genuinely different wire message, not the same bytes.
1724        let expected_initial_ca_pmt =
1725            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
1726        assert_apdu_on_session(&d, CA_SESSION, &expected_initial_ca_pmt);
1727
1728        // The re-query timer resends the `query`-variant bytes (EN 50221
1729        // §8.4.3.5: only `query`/`ok_mmi` solicit a `ca_pmt_reply` from a
1730        // conformant CAM — `ok_descrambling` does not).
1731        let expected_ca_pmt =
1732            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::Query).to_bytes();
1733        let sends_before_requery = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1734
1735        let mut all_notes = Vec::new();
1736
1737        // Reply 1: not entitled (baseline — first-ever reply for this
1738        // program; `descrambling_ok` is derived: `NotPossibleNoEntitlement`
1739        // is not in the "possible" set, so `false`).
1740        feed(
1741            &mut d,
1742            r_apdu(
1743                CA_SESSION,
1744                &ser(&ca_pmt_reply_for(
1745                    1546,
1746                    Some(CaEnable::NotPossibleNoEntitlement),
1747                )),
1748            ),
1749        );
1750        all_notes.extend(d.take_notifications());
1751
1752        // Advance the clock past the default 10s re-query interval: a single
1753        // pump ticks the stack with elapsed = 11s (nothing readable this
1754        // turn), which the #763 Task 5 re-query timer picks up and queues
1755        // the tracked service's exact ca_pmt for resend (EN 50221 §8.4.3.4
1756        // Table 25). EN 50221's link is half-duplex — this tick's own
1757        // keep-alive poll already claimed the turn, so the resend is
1758        // written on the module's next `T_SB` (the #337 one-write-per-turn
1759        // rule), same as any other queued host write in this test suite.
1760        d.pump(Duration::from_secs(11)).unwrap();
1761        all_notes.extend(d.take_notifications());
1762        d.device_mut().inbound.push_back(sb());
1763        d.pump(Duration::from_millis(10)).unwrap();
1764
1765        let sends_after_requery = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1766        assert_eq!(
1767            sends_after_requery,
1768            sends_before_requery + 1,
1769            "expected the re-query timer to resend the exact ca_pmt exactly once"
1770        );
1771
1772        // Reply 2: the CAM's re-evaluated answer to the re-query says
1773        // descrambling is now possible.
1774        feed(
1775            &mut d,
1776            r_apdu(
1777                CA_SESSION,
1778                &ser(&ca_pmt_reply_for(1546, Some(CaEnable::Possible))),
1779            ),
1780        );
1781        all_notes.extend(d.take_notifications());
1782
1783        let hits = all_notes
1784            .iter()
1785            .filter(|n| {
1786                matches!(
1787                    n,
1788                    Notification::Entitlement {
1789                        program_number: 1546,
1790                        ca_enable: CaEnable::Possible,
1791                        descrambling_ok: true,
1792                    }
1793                )
1794            })
1795            .count();
1796        assert_eq!(
1797            hits, 1,
1798            "expected exactly one Entitlement{{program_number:1546, ca_enable:Possible, descrambling_ok:true}}, got {all_notes:?}"
1799        );
1800    }
1801
1802    #[test]
1803    fn requery_timer_unchanged_reply_across_two_requeries_emits_no_entitlement() {
1804        use broadcast_common::Parse;
1805        use dvb_ci::objects::ca_pmt_reply::CaEnable;
1806
1807        let mut d = driver_with_sessions();
1808        d.take_notifications();
1809
1810        let pmt_bytes = build_ca_pmt_fixture(1547);
1811        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1812        d.add_service(&pmt).unwrap();
1813        d.device_mut().inbound.push_back(sb());
1814        d.pump(Duration::from_millis(10)).unwrap();
1815        d.take_notifications();
1816
1817        // Baseline reply: descrambling possible. First-ever reply — this
1818        // establishes the baseline and DOES emit once (per the transition
1819        // rule); drop it so the loop below only asserts on the re-queries.
1820        feed(
1821            &mut d,
1822            r_apdu(
1823                CA_SESSION,
1824                &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
1825            ),
1826        );
1827        d.take_notifications();
1828
1829        // Two re-queries, the CAM replying with the SAME unchanged status
1830        // both times: no Entitlement either time (negative control).
1831        for _ in 0..2 {
1832            d.pump(Duration::from_secs(11)).unwrap();
1833            d.take_notifications();
1834            feed(
1835                &mut d,
1836                r_apdu(
1837                    CA_SESSION,
1838                    &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
1839                ),
1840            );
1841            let notes = d.take_notifications();
1842            assert!(
1843                !notes
1844                    .iter()
1845                    .any(|n| matches!(n, Notification::Entitlement { .. })),
1846                "unchanged status across a re-query must not emit Entitlement, got {notes:?}"
1847            );
1848        }
1849    }
1850
1851    #[test]
1852    fn requery_reply_withdrawn_to_none_emits_no_entitlement() {
1853        use broadcast_common::Parse;
1854        use dvb_ci::objects::ca_pmt_reply::CaEnable;
1855
1856        let mut d = driver_with_sessions();
1857        d.take_notifications();
1858
1859        let pmt_bytes = build_ca_pmt_fixture(1548);
1860        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1861        d.add_service(&pmt).unwrap();
1862        d.device_mut().inbound.push_back(sb());
1863        d.pump(Duration::from_millis(10)).unwrap();
1864        d.take_notifications();
1865
1866        // Baseline: descrambling possible (drop the baseline Entitlement).
1867        feed(
1868            &mut d,
1869            r_apdu(
1870                CA_SESSION,
1871                &ser(&ca_pmt_reply_for(1548, Some(CaEnable::Possible))),
1872            ),
1873        );
1874        d.take_notifications();
1875
1876        // Programme `CA_enable_flag` now clear (`None`) — status withdrawn.
1877        // Per the transition rule this NEVER emits Entitlement (#726 HotPlug
1878        // covers the coarse withdrawal signal instead).
1879        feed(
1880            &mut d,
1881            r_apdu(CA_SESSION, &ser(&ca_pmt_reply_for(1548, None))),
1882        );
1883        let notes = d.take_notifications();
1884        assert!(
1885            !notes
1886                .iter()
1887                .any(|n| matches!(n, Notification::Entitlement { .. })),
1888            "ca_enable transitioning to None must not emit Entitlement, got {notes:?}"
1889        );
1890    }
1891
1892    #[test]
1893    fn set_requery_interval_zero_disables_resend() {
1894        use broadcast_common::Parse;
1895
1896        let mut d = driver_with_sessions();
1897        d.set_requery_interval(Duration::ZERO);
1898        d.take_notifications();
1899
1900        let pmt_bytes = build_ca_pmt_fixture(1549);
1901        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1902        d.add_service(&pmt).unwrap();
1903        d.device_mut().inbound.push_back(sb());
1904        d.pump(Duration::from_millis(10)).unwrap();
1905
1906        // Count the `query`-variant bytes — the ones the timer would resend
1907        // if it fired — not the `ok_descrambling` bytes `add_service` sent.
1908        let expected_ca_pmt =
1909            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::Query).to_bytes();
1910        let sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1911
1912        // Even a very long tick must not trigger a re-query once disabled.
1913        d.pump(Duration::from_secs(1000)).unwrap();
1914
1915        let sends_after = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1916        assert_eq!(
1917            sends_after, sends_before,
1918            "Duration::ZERO must disable the re-query resend"
1919        );
1920    }
1921
1922    #[test]
1923    fn requery_timer_resends_every_active_service_not_just_one() {
1924        use broadcast_common::Parse;
1925
1926        let mut d = driver_with_sessions();
1927        d.take_notifications();
1928
1929        // Two services on the managed set: 1546 (`Only`, first-ever) and
1930        // 1547 (`Add`, joining the active set).
1931        let pmt1_bytes = build_ca_pmt_fixture(1546);
1932        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1933        d.add_service(&pmt1).unwrap();
1934        d.device_mut().inbound.push_back(sb());
1935        d.pump(Duration::from_millis(10)).unwrap();
1936
1937        let pmt2_bytes = build_ca_pmt_fixture(1547);
1938        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1939        d.add_service(&pmt2).unwrap();
1940        d.device_mut().inbound.push_back(sb());
1941        d.pump(Duration::from_millis(10)).unwrap();
1942        d.take_notifications();
1943
1944        // The `query`-variant bytes the re-query timer resends for each
1945        // service — same `list_management` each got at `add_service` time.
1946        let expected1 =
1947            build_ca_pmt(&pmt1, CaPmtListManagement::Only, CaPmtCmdId::Query).to_bytes();
1948        let expected2 = build_ca_pmt(&pmt2, CaPmtListManagement::Add, CaPmtCmdId::Query).to_bytes();
1949        let sends_before1 = count_apdu_on_session(&d, CA_SESSION, &expected1);
1950        let sends_before2 = count_apdu_on_session(&d, CA_SESSION, &expected2);
1951
1952        // Advance the clock past the default 10s re-query interval: this
1953        // queues BOTH services' resends (`requery_tick` iterates the whole
1954        // active set), but EN 50221's half-duplex link (the #337
1955        // one-write-per-turn rule) only lets one out per turn — feed enough
1956        // `T_SB` acks to flush both queued writes, mirroring `feed`'s own
1957        // multi-turn drain loop.
1958        d.pump(Duration::from_secs(11)).unwrap();
1959        feed(&mut d, sb());
1960
1961        let sends_after1 = count_apdu_on_session(&d, CA_SESSION, &expected1);
1962        let sends_after2 = count_apdu_on_session(&d, CA_SESSION, &expected2);
1963        assert_eq!(
1964            sends_after1,
1965            sends_before1 + 1,
1966            "expected service 1546's query ca_pmt resent exactly once on the shared tick"
1967        );
1968        assert_eq!(
1969            sends_after2,
1970            sends_before2 + 1,
1971            "expected service 1547's query ca_pmt resent exactly once on the shared tick"
1972        );
1973    }
1974
1975    // --- #763 Task 6: remove_service + clear managed state on CAM hot-plug ---
1976
1977    #[test]
1978    fn remove_service_sends_update_not_selected_and_drops_from_managed_state() {
1979        use broadcast_common::Parse;
1980
1981        let mut d = driver_with_sessions();
1982        d.take_notifications();
1983
1984        // 1546 (`Only`, distinct PIDs 0x100/0x101) and 1547 (`Add`, distinct
1985        // PIDs 0x200/0x201) — distinct PID sets so removing 1546 is
1986        // observably different from removing 1547.
1987        let pmt1_bytes = build_ca_pmt_fixture(1546);
1988        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1989        d.add_service(&pmt1).unwrap();
1990        d.device_mut().inbound.push_back(sb());
1991        d.pump(Duration::from_millis(10)).unwrap();
1992
1993        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
1994        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1995        d.add_service(&pmt2).unwrap();
1996        d.device_mut().inbound.push_back(sb());
1997        d.pump(Duration::from_millis(10)).unwrap();
1998
1999        d.remove_service(1546).unwrap();
2000        d.device_mut().inbound.push_back(sb());
2001        d.pump(Duration::from_millis(10)).unwrap();
2002
2003        // Oracle: the same PMT re-built directly via
2004        // dvb_ci::builder::build_ca_pmt with `Update`/`NotSelected` (EN 50221
2005        // §8.4.3.4 Table 25) — the exact bytes `remove_program` sends.
2006        let expected =
2007            build_ca_pmt(&pmt1, CaPmtListManagement::Update, CaPmtCmdId::NotSelected).to_bytes();
2008        assert_apdu_on_session(&d, CA_SESSION, &expected);
2009
2010        assert_eq!(
2011            d.descramble_pids(),
2012            &[0x0200, 0x0201],
2013            "1546's ES PIDs must be gone; 1547's must remain"
2014        );
2015        assert!(
2016            d.managed_ca().services().get(&1546).is_none(),
2017            "1546 must no longer be tracked"
2018        );
2019        assert!(
2020            d.managed_ca().services().get(&1547).is_some(),
2021            "1547 must remain tracked"
2022        );
2023    }
2024
2025    #[test]
2026    fn remove_service_of_untracked_program_is_a_no_op() {
2027        let mut d = driver_with_sessions();
2028        d.take_notifications();
2029
2030        let ops_before = d.device().ops.len();
2031        d.remove_service(0xFFFF).unwrap();
2032        assert_eq!(
2033            d.device().ops.len(),
2034            ops_before,
2035            "removing an untracked program must not send anything to the device"
2036        );
2037        assert!(
2038            d.managed_ca().services().is_empty(),
2039            "removing an untracked program must not disturb the (empty) managed set"
2040        );
2041    }
2042
2043    #[test]
2044    fn cam_removed_edge_clears_managed_state() {
2045        use broadcast_common::Parse;
2046        use dvb_ci::objects::ca_info::CaInfo;
2047        use dvb_si::tables::cat::CatSection;
2048
2049        let mut d = driver_with_sessions();
2050        d.take_notifications();
2051
2052        let pmt_bytes = build_ca_pmt_fixture(1546);
2053        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2054        d.add_service(&pmt).unwrap();
2055        d.device_mut().inbound.push_back(sb());
2056        d.pump(Duration::from_millis(10)).unwrap();
2057
2058        // Populate emm_pids too, via ca_info + set_cat, so the test proves
2059        // the fix clears more than just `services`.
2060        feed(
2061            &mut d,
2062            r_apdu(
2063                CA_SESSION,
2064                &ser(&CaInfo {
2065                    ca_system_ids: vec![0x0648],
2066                }),
2067            ),
2068        );
2069        d.take_notifications();
2070        let mut descriptors = Vec::new();
2071        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
2072        let cat_bytes = build_cat_fixture(&descriptors);
2073        let cat = CatSection::parse(&cat_bytes).unwrap();
2074        d.set_cat(&cat).unwrap();
2075
2076        assert!(
2077            !d.managed_ca().services().is_empty(),
2078            "precondition: a service is tracked"
2079        );
2080        assert!(
2081            !d.descramble_pids().is_empty(),
2082            "precondition: descramble_pids populated"
2083        );
2084        assert!(!d.emm_pids().is_empty(), "precondition: emm_pids populated");
2085
2086        // Module physically removed: a CamRemoved hot-plug edge.
2087        d.device_mut().slot.module_present = false;
2088        d.pump(Duration::from_millis(10)).unwrap();
2089        let notes = d.take_notifications();
2090        assert!(
2091            notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
2092            "expected CamRemoved, got {notes:?}"
2093        );
2094
2095        assert!(
2096            d.managed_ca().services().is_empty(),
2097            "services must be cleared on CamRemoved"
2098        );
2099        assert!(
2100            d.descramble_pids().is_empty(),
2101            "descramble_pids must be cleared on CamRemoved"
2102        );
2103        assert!(
2104            d.emm_pids().is_empty(),
2105            "emm_pids must be cleared on CamRemoved"
2106        );
2107    }
2108}