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