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                && let Some(pos) = w.windows(3).position(|x| x == want)
796                && let Ok(p) = Profile::parse(&w[pos..])
797            {
798                return p.resources.contains(&HOST_CONTROL);
799            }
800            false
801        });
802        assert!(found, "profile reply must advertise HOST_CONTROL");
803    }
804
805    #[test]
806    fn mmi_menu_answ_and_answ_are_byte_exact_on_the_mmi_session() {
807        use dvb_ci::objects::mmi_high::{Answ, AnswId, MenuAnsw};
808
809        let mut d = driver_with_sessions();
810        let mmi_nb = MMI_SESSION;
811
812        // menu_answ(choice_ref = 2): the driver method must put the exact dvb-ci
813        // MenuAnsw serialization on the wire, on the MMI session.
814        d.mmi_menu_answer(2).unwrap();
815        d.device_mut().inbound.push_back(sb());
816        d.pump(Duration::from_millis(10)).unwrap();
817        assert_apdu_on_session(&d, mmi_nb, &ser(&MenuAnsw { choice_ref: 2 }));
818
819        // answ(answer, "1234"): byte-exact Answ serialization on the MMI session.
820        d.mmi_enquiry_answer(b"1234").unwrap();
821        d.device_mut().inbound.push_back(sb());
822        d.pump(Duration::from_millis(10)).unwrap();
823        assert_apdu_on_session(
824            &d,
825            mmi_nb,
826            &ser(&Answ {
827                answ_id: AnswId::Answer,
828                text_chars: b"1234",
829            }),
830        );
831    }
832
833    /// Assert some host write carries `session_number(session_nb)` immediately
834    /// followed by the exact `apdu` bytes (byte-exact APDU on the right session).
835    fn assert_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) {
836        use dvb_ci::spdu::SessionNumber;
837        let mut want = ser(&SessionNumber { session_nb });
838        want.extend_from_slice(apdu);
839        let hit = d.device().ops.iter().any(|op| match op {
840            DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
841            _ => false,
842        });
843        assert!(
844            hit,
845            "expected APDU {apdu:02X?} on session {session_nb} (session-prefixed {want:02X?}) in writes"
846        );
847    }
848
849    /// How many host writes carry `session_number(session_nb)` immediately
850    /// followed by the exact `apdu` bytes — used to distinguish an initial
851    /// send from a later re-send (#763 Task 5's re-query timer).
852    fn count_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) -> usize {
853        use dvb_ci::spdu::SessionNumber;
854        let mut want = ser(&SessionNumber { session_nb });
855        want.extend_from_slice(apdu);
856        d.device()
857            .ops
858            .iter()
859            .filter(|op| match op {
860                DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
861                _ => false,
862            })
863            .count()
864    }
865
866    #[test]
867    fn init_drives_reset_slotinfo_and_create_tc_to_device() {
868        let mut d = Driver::new(MockCaDevice::new([]));
869        d.init().unwrap();
870        let ops = &d.device().ops;
871        assert_eq!(ops[0], DeviceOp::Reset);
872        assert_eq!(ops[1], DeviceOp::SlotInfo);
873        assert!(matches!(&ops[2], DeviceOp::Write(w) if w[0] == tags::CREATE_T_C));
874    }
875
876    #[test]
877    fn reads_reply_then_polls_on_pump() {
878        // Script the module accepting the connection.
879        let dev = MockCaDevice::new([vec![tags::C_T_C_REPLY, 0x01, 0x01]]);
880        let mut d = Driver::new(dev);
881        d.init().unwrap();
882        // first pump reads the C_T_C_Reply (activates the connection)
883        assert!(d.pump(Duration::from_millis(100)).unwrap());
884        // next pump has nothing to read → ticks → emits a poll write
885        assert!(!d.pump(Duration::from_millis(100)).unwrap());
886        let last = d.device().ops.last().unwrap();
887        assert!(matches!(last, DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)));
888    }
889
890    // --- #726: CAM + card hot-plug notifications ---
891
892    #[test]
893    fn cam_insert_edge_emits_cam_present_once_and_redrives_handshake() {
894        let mut dev = MockCaDevice::new([]);
895        dev.slot = SlotInfo {
896            num: 0,
897            module_ready: false,
898            module_present: false,
899        };
900        let mut d = Driver::new(dev);
901        d.init().unwrap();
902        // The first-ever slot observation only establishes the baseline
903        // (absent) — it must not itself claim a hot-plug edge.
904        let notes = d.take_notifications();
905        assert!(
906            !notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
907            "baseline observation must not fire CamPresent, got {notes:?}"
908        );
909        let resets_before = d
910            .device()
911            .ops
912            .iter()
913            .filter(|o| **o == DeviceOp::Reset)
914            .count();
915
916        // Module physically inserted and ready.
917        d.device_mut().slot = SlotInfo {
918            num: 0,
919            module_ready: true,
920            module_present: true,
921        };
922        d.pump(Duration::from_millis(10)).unwrap();
923
924        let notes = d.take_notifications();
925        let cam_present_count = notes
926            .iter()
927            .filter(|n| **n == Notification::HotPlug(HotPlug::CamPresent))
928            .count();
929        assert_eq!(
930            cam_present_count, 1,
931            "expected exactly one CamPresent, got {notes:?}"
932        );
933        // Handshake re-driven: a fresh Reset, and the last write is CREATE_T_C.
934        let resets_after = d
935            .device()
936            .ops
937            .iter()
938            .filter(|o| **o == DeviceOp::Reset)
939            .count();
940        assert_eq!(
941            resets_after,
942            resets_before + 1,
943            "expected one fresh Reset on re-insert"
944        );
945        assert!(
946            matches!(d.device().ops.last(), Some(DeviceOp::Write(w)) if w[0] == tags::CREATE_T_C),
947            "expected the handshake re-driven (CREATE_T_C written), got {:?}",
948            d.device().ops.last()
949        );
950    }
951
952    #[test]
953    fn cam_remove_edge_emits_cam_removed_and_re_insert_re_handshakes() {
954        let mut d = driver_with_sessions();
955        d.take_notifications();
956
957        // Module physically removed.
958        d.device_mut().slot.module_present = false;
959        d.pump(Duration::from_millis(10)).unwrap();
960        let notes = d.take_notifications();
961        assert!(
962            notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
963            "expected CamRemoved, got {notes:?}"
964        );
965
966        // Session state was torn down: the MMI session from
967        // `driver_with_sessions` no longer exists on the fresh stack, so an
968        // answer to it now errors instead of silently going nowhere.
969        d.mmi_menu_answer(0).unwrap();
970        let notes = d.take_notifications();
971        assert!(
972            notes
973                .iter()
974                .any(|n| matches!(n, Notification::Error { .. })),
975            "expected no open MMI session after teardown, got {notes:?}"
976        );
977
978        // Re-insert: a fresh handshake starts (Reset + CamPresent).
979        let resets_before = d
980            .device()
981            .ops
982            .iter()
983            .filter(|o| **o == DeviceOp::Reset)
984            .count();
985        d.device_mut().slot.module_present = true;
986        d.device_mut().slot.module_ready = true;
987        d.pump(Duration::from_millis(10)).unwrap();
988        let notes = d.take_notifications();
989        assert!(
990            notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
991            "expected CamPresent on re-insert, got {notes:?}"
992        );
993        let resets_after = d
994            .device()
995            .ops
996            .iter()
997            .filter(|o| **o == DeviceOp::Reset)
998            .count();
999        assert_eq!(resets_after, resets_before + 1, "expected a fresh Reset");
1000    }
1001
1002    #[test]
1003    fn slot_status_unchanged_across_polls_emits_no_hotplug_notifications() {
1004        let mut d = Driver::new(MockCaDevice::new([]));
1005        d.init().unwrap();
1006        d.take_notifications();
1007
1008        for _ in 0..5 {
1009            d.pump(Duration::from_millis(10)).unwrap();
1010        }
1011        let notes = d.take_notifications();
1012        assert!(
1013            !notes.iter().any(|n| matches!(
1014                n,
1015                Notification::HotPlug(HotPlug::CamPresent | HotPlug::CamRemoved)
1016            )),
1017            "unchanged slot status must not emit hot-plug notifications, got {notes:?}"
1018        );
1019    }
1020
1021    #[test]
1022    fn ca_info_caid_set_change_infers_card_inserted_then_changed() {
1023        use dvb_ci::objects::ca_info::CaInfo;
1024
1025        let mut d = driver_with_sessions();
1026        d.take_notifications();
1027
1028        // First ca_info: no CAIDs (baseline only, no notification).
1029        feed(
1030            &mut d,
1031            r_apdu(
1032                CA_SESSION,
1033                &ser(&CaInfo {
1034                    ca_system_ids: vec![],
1035                }),
1036            ),
1037        );
1038        let notes = d.take_notifications();
1039        assert!(
1040            !notes.iter().any(|n| matches!(
1041                n,
1042                Notification::HotPlug(
1043                    HotPlug::CardInserted | HotPlug::CardChanged | HotPlug::CardRemoved
1044                )
1045            )),
1046            "first ca_info must only establish the baseline, got {notes:?}"
1047        );
1048
1049        // CAID set becomes populated: card inserted.
1050        feed(
1051            &mut d,
1052            r_apdu(
1053                CA_SESSION,
1054                &ser(&CaInfo {
1055                    ca_system_ids: vec![0x0B00],
1056                }),
1057            ),
1058        );
1059        let notes = d.take_notifications();
1060        assert!(
1061            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1062            "expected CardInserted, got {notes:?}"
1063        );
1064
1065        // CAID set changes to a different non-empty set: card changed.
1066        feed(
1067            &mut d,
1068            r_apdu(
1069                CA_SESSION,
1070                &ser(&CaInfo {
1071                    ca_system_ids: vec![0x1800],
1072                }),
1073            ),
1074        );
1075        let notes = d.take_notifications();
1076        assert!(
1077            notes.contains(&Notification::HotPlug(HotPlug::CardChanged)),
1078            "expected CardChanged, got {notes:?}"
1079        );
1080    }
1081
1082    #[test]
1083    fn ca_pmt_reply_descrambling_transition_infers_card_present_then_removed() {
1084        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1085
1086        fn reply(ca_enable: Option<CaEnable>) -> CaPmtReply {
1087            CaPmtReply {
1088                program_number: 1,
1089                version_number: 1,
1090                current_next_indicator: true,
1091                ca_enable,
1092                streams: vec![],
1093            }
1094        }
1095
1096        let mut d = driver_with_sessions();
1097        d.take_notifications();
1098
1099        // Baseline: descrambling not (yet) possible.
1100        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1101        let notes = d.take_notifications();
1102        assert!(
1103            !notes.iter().any(|n| matches!(
1104                n,
1105                Notification::HotPlug(HotPlug::CardInserted | HotPlug::CardRemoved)
1106            )),
1107            "first ca_pmt_reply must only establish the baseline, got {notes:?}"
1108        );
1109
1110        // false -> true: card-present inference.
1111        feed(
1112            &mut d,
1113            r_apdu(CA_SESSION, &ser(&reply(Some(CaEnable::Possible)))),
1114        );
1115        let notes = d.take_notifications();
1116        assert!(
1117            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1118            "expected CardInserted, got {notes:?}"
1119        );
1120
1121        // true -> false: card removed.
1122        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1123        let notes = d.take_notifications();
1124        assert!(
1125            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1126            "expected CardRemoved, got {notes:?}"
1127        );
1128    }
1129
1130    #[test]
1131    fn ca_pmt_reply_surfaces_typed_ca_enable() {
1132        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1133
1134        let mut d = driver_with_sessions();
1135        d.take_notifications();
1136
1137        // `CA_enable` = 0x03 (possible under conditions, technical dialogue) —
1138        // EN 50221 §8.4.3.5 Table 26.
1139        feed(
1140            &mut d,
1141            r_apdu(
1142                CA_SESSION,
1143                &ser(&CaPmtReply {
1144                    program_number: 7,
1145                    version_number: 1,
1146                    current_next_indicator: true,
1147                    ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1148                    streams: vec![],
1149                }),
1150            ),
1151        );
1152        let notes = d.take_notifications();
1153        assert!(
1154            notes.contains(&Notification::CaPmtReply {
1155                program_number: 7,
1156                ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1157                descrambling_ok: true,
1158            }),
1159            "expected typed ca_enable on CaPmtReply, got {notes:?}"
1160        );
1161    }
1162
1163    #[test]
1164    fn ca_pmt_reply_flag_clear_surfaces_none() {
1165        use dvb_ci::objects::ca_pmt_reply::CaPmtReply;
1166
1167        let mut d = driver_with_sessions();
1168        d.take_notifications();
1169
1170        // Programme `CA_enable_flag` clear -> no programme-level status given
1171        // — EN 50221 §8.4.3.5 Table 26.
1172        feed(
1173            &mut d,
1174            r_apdu(
1175                CA_SESSION,
1176                &ser(&CaPmtReply {
1177                    program_number: 7,
1178                    version_number: 1,
1179                    current_next_indicator: true,
1180                    ca_enable: None,
1181                    streams: vec![],
1182                }),
1183            ),
1184        );
1185        let notes = d.take_notifications();
1186        assert!(
1187            notes.contains(&Notification::CaPmtReply {
1188                program_number: 7,
1189                ca_enable: None,
1190                descrambling_ok: false,
1191            }),
1192            "expected ca_enable None on flag-clear CaPmtReply, got {notes:?}"
1193        );
1194    }
1195
1196    #[test]
1197    fn mmi_no_card_text_infers_card_removed() {
1198        use dvb_ci::objects::mmi_high::Enq;
1199
1200        let mut d = driver_with_sessions();
1201        d.take_notifications();
1202
1203        feed(
1204            &mut d,
1205            r_apdu(
1206                MMI_SESSION,
1207                &ser(&Enq {
1208                    blind_answer: false,
1209                    answer_text_length: 0,
1210                    text_chars: b"NO CARD detected - please insert your smart card",
1211                }),
1212            ),
1213        );
1214
1215        let notes = d.take_notifications();
1216        assert!(
1217            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1218            "expected CardRemoved inferred from MMI 'no card' text, got {notes:?}"
1219        );
1220    }
1221
1222    #[test]
1223    fn pump_hotplug_delivers_cam_present_via_closure_exactly_once() {
1224        let mut dev = MockCaDevice::new([]);
1225        dev.slot = SlotInfo {
1226            num: 0,
1227            module_ready: false,
1228            module_present: false,
1229        };
1230        let mut d = Driver::new(dev);
1231        d.init().unwrap();
1232        d.take_notifications(); // drop the baseline observation
1233
1234        // Module physically inserted and ready.
1235        d.device_mut().slot = SlotInfo {
1236            num: 0,
1237            module_ready: true,
1238            module_present: true,
1239        };
1240
1241        let mut seen = Vec::new();
1242        d.pump_hotplug(Duration::from_millis(10), |hp| seen.push(hp))
1243            .unwrap();
1244
1245        assert_eq!(
1246            seen,
1247            vec![HotPlug::CamPresent],
1248            "expected the closure to receive HotPlug::CamPresent exactly once, got {seen:?}"
1249        );
1250    }
1251
1252    // --- #763 Task 3: ManagedCa + add_service ---
1253
1254    /// A `CA_descriptor` TLV (ISO/IEC 13818-1 §2.6.16): tag `0x09`, len `4`,
1255    /// `CA_system_id`(2), `reserved(3)`/`CA_PID`(13).
1256    pub(crate) fn ca_descriptor(ca_system_id: u16, pid: u16) -> [u8; 6] {
1257        [
1258            0x09,
1259            0x04,
1260            (ca_system_id >> 8) as u8,
1261            ca_system_id as u8,
1262            0xE0 | ((pid >> 8) as u8 & 0x1F),
1263            pid as u8,
1264        ]
1265    }
1266
1267    /// A synthetic scrambled-service PMT: programme-level `CA_descriptor`
1268    /// (`CA_system_id` `0x0500` = Viaccess, a real assigned value per the
1269    /// TSDuck CA-system registry consumed by `dvb_si::descriptors::ca::ca_system_name`),
1270    /// one scrambled H.264 video ES (own `CA_descriptor`), and one clear AAC
1271    /// audio ES.
1272    ///
1273    /// **Provenance:** no committed capture in this repo's fixture corpus
1274    /// carries a scrambled PMT — `fixtures/dvb-si/tnt-5w-12732v-isi6-10s.ts`'s
1275    /// five PMTs (verified via `cargo run -p dvb-tools -- dump ... --json`)
1276    /// are all clear/FTA services, and no CA-descriptor-bearing capture exists
1277    /// under `private/fixtures/` either. This hand-rolls the wire bytes per
1278    /// ISO/IEC 13818-1 §2.4.4.8's PMT syntax instead, mirroring the exact
1279    /// precedent already established by `dvb-ci/src/builder.rs`'s
1280    /// `build_test_pmt()` (a hand-rolled buffer "that mirrors a real
1281    /// CA-protected service") — real `CA_system_id`/`stream_type` values, real
1282    /// CRC, just not sourced from an off-air capture.
1283    pub(crate) fn build_ca_pmt_fixture(program_number: u16) -> Vec<u8> {
1284        const VIACCESS: u16 = 0x0500;
1285        let prog_ca = ca_descriptor(VIACCESS, 0x0064);
1286        let es0_ca = ca_descriptor(VIACCESS, 0x0065);
1287
1288        let mut body = Vec::new();
1289        body.push(0x02); // table_id (PMT)
1290        body.push(0); // section_length placeholder (fixed up below)
1291        body.push(0);
1292        body.extend_from_slice(&program_number.to_be_bytes());
1293        body.push(0xC3); // reserved(2)='11' | version(5)=1 | current_next=1
1294        body.push(0x00); // section_number
1295        body.push(0x00); // last_section_number
1296        body.push(0xE0 | 0x01); // reserved(3) | PCR_PID(13) = 0x0100
1297        body.push(0x00);
1298        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1299        body.push(prog_ca.len() as u8);
1300        body.extend_from_slice(&prog_ca);
1301        // ES0: H.264 video, pid 0x0100, scrambled (own CA_descriptor).
1302        body.push(0x1B);
1303        body.push(0xE0 | 0x01);
1304        body.push(0x00);
1305        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1306        body.push(es0_ca.len() as u8);
1307        body.extend_from_slice(&es0_ca);
1308        // ES1: AAC ADTS audio, pid 0x0101, clear.
1309        body.push(0x0F);
1310        body.push(0xE0 | 0x01);
1311        body.push(0x01);
1312        body.push(0xF0);
1313        body.push(0x00);
1314
1315        let section_length = body.len() - 3 + 4;
1316        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1317        body[2] = section_length as u8;
1318        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1319        body.extend_from_slice(&crc.to_be_bytes());
1320        body
1321    }
1322
1323    /// Same layout as [`build_ca_pmt_fixture`] but with the PCR carried on its
1324    /// own **dedicated** `PCR_PID` (`0x00FF`) — distinct from every ES PID
1325    /// (`0x0100`/`0x0101`) and CA PID (`0x0064`/`0x0065`) — a legitimate DVB
1326    /// config (ISO/IEC 13818-1 §2.4.4.8) that `build_ca_pmt_fixture`'s
1327    /// `PCR_PID == video ES PID` masks: the #763 final-review regression
1328    /// fixture for `required_pids`/`feed_ts` PCR routing.
1329    pub(crate) fn build_ca_pmt_fixture_dedicated_pcr(program_number: u16) -> Vec<u8> {
1330        const VIACCESS: u16 = 0x0500;
1331        let prog_ca = ca_descriptor(VIACCESS, 0x0064);
1332        let es0_ca = ca_descriptor(VIACCESS, 0x0065);
1333
1334        let mut body = Vec::new();
1335        body.push(0x02); // table_id (PMT)
1336        body.push(0); // section_length placeholder (fixed up below)
1337        body.push(0);
1338        body.extend_from_slice(&program_number.to_be_bytes());
1339        body.push(0xC3); // reserved(2)='11' | version(5)=1 | current_next=1
1340        body.push(0x00); // section_number
1341        body.push(0x00); // last_section_number
1342        body.push(0xE0); // reserved(3) | PCR_PID(13) high byte = 0x00FF >> 8
1343        body.push(0xFF); // PCR_PID low byte — dedicated, outside the ES/CA set
1344        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1345        body.push(prog_ca.len() as u8);
1346        body.extend_from_slice(&prog_ca);
1347        // ES0: H.264 video, pid 0x0100, scrambled (own CA_descriptor).
1348        body.push(0x1B);
1349        body.push(0xE0 | 0x01);
1350        body.push(0x00);
1351        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1352        body.push(es0_ca.len() as u8);
1353        body.extend_from_slice(&es0_ca);
1354        // ES1: AAC ADTS audio, pid 0x0101, clear.
1355        body.push(0x0F);
1356        body.push(0xE0 | 0x01);
1357        body.push(0x01);
1358        body.push(0xF0);
1359        body.push(0x00);
1360
1361        let section_length = body.len() - 3 + 4;
1362        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1363        body[2] = section_length as u8;
1364        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1365        body.extend_from_slice(&crc.to_be_bytes());
1366        body
1367    }
1368
1369    /// Same layout as [`build_ca_pmt_fixture`] but with no `CA_descriptor`
1370    /// anywhere (an ordinary clear/FTA service) — the negative-control PMT for
1371    /// [`CaError::NoCaDescriptor`].
1372    pub(crate) fn build_clear_pmt_fixture(program_number: u16) -> Vec<u8> {
1373        let mut body = Vec::new();
1374        body.push(0x02);
1375        body.push(0);
1376        body.push(0);
1377        body.extend_from_slice(&program_number.to_be_bytes());
1378        body.push(0xC3);
1379        body.push(0x00);
1380        body.push(0x00);
1381        body.push(0xE0 | 0x01);
1382        body.push(0x00);
1383        body.push(0xF0); // program_info_length = 0
1384        body.push(0x00);
1385        // ES0: H.264 video, pid 0x0100, clear.
1386        body.push(0x1B);
1387        body.push(0xE0 | 0x01);
1388        body.push(0x00);
1389        body.push(0xF0);
1390        body.push(0x00);
1391
1392        let section_length = body.len() - 3 + 4;
1393        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1394        body[2] = section_length as u8;
1395        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1396        body.extend_from_slice(&crc.to_be_bytes());
1397        body
1398    }
1399
1400    #[test]
1401    fn add_service_builds_and_sends_ca_pmt_matching_builder_oracle() {
1402        use broadcast_common::Parse;
1403
1404        let mut d = driver_with_sessions();
1405        d.take_notifications();
1406
1407        let pmt_bytes = build_ca_pmt_fixture(1546);
1408        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1409
1410        d.add_service(&pmt).unwrap();
1411        d.device_mut().inbound.push_back(sb());
1412        d.pump(Duration::from_millis(10)).unwrap();
1413
1414        // Oracle: the same PMT built directly via dvb_ci::builder::build_ca_pmt
1415        // with `Only` (first-ever service on an empty managed set) +
1416        // `ok_descrambling`.
1417        let expected =
1418            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
1419        assert_apdu_on_session(&d, CA_SESSION, &expected);
1420
1421        // The service was recorded with its ES/CA PIDs.
1422        let svc = d
1423            .managed_ca()
1424            .services()
1425            .get(&1546)
1426            .expect("program_number 1546 must be tracked after add_service");
1427        assert_eq!(svc.es_pids, vec![0x0100, 0x0101]);
1428        assert_eq!(svc.ca_pids, vec![0x0064, 0x0065]);
1429        assert_eq!(svc.cmd, CaPmtCmdId::OkDescrambling);
1430        assert_eq!(svc.last_ca_enable, None);
1431    }
1432
1433    #[test]
1434    fn add_service_rejects_pmt_without_ca_descriptor() {
1435        use broadcast_common::Parse;
1436
1437        let mut d = driver_with_sessions();
1438        let pmt_bytes = build_clear_pmt_fixture(999);
1439        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1440
1441        let err = d.add_service(&pmt).unwrap_err();
1442        assert!(
1443            matches!(
1444                err,
1445                CaError::NoCaDescriptor {
1446                    program_number: 999
1447                }
1448            ),
1449            "expected NoCaDescriptor{{program_number: 999}}, got {err:?}"
1450        );
1451        assert!(
1452            d.managed_ca().services().is_empty(),
1453            "a rejected PMT must not be recorded"
1454        );
1455    }
1456
1457    #[test]
1458    fn add_service_second_call_uses_add_list_management() {
1459        use broadcast_common::Parse;
1460
1461        let mut d = driver_with_sessions();
1462        d.take_notifications();
1463
1464        let pmt1_bytes = build_ca_pmt_fixture(1546);
1465        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1466        d.add_service(&pmt1).unwrap();
1467        d.device_mut().inbound.push_back(sb());
1468        d.pump(Duration::from_millis(10)).unwrap();
1469
1470        let pmt2_bytes = build_ca_pmt_fixture(1547);
1471        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1472        d.add_service(&pmt2).unwrap();
1473        d.device_mut().inbound.push_back(sb());
1474        d.pump(Duration::from_millis(10)).unwrap();
1475
1476        // Second service joins an already-active set → `Add`, not `Only`.
1477        let expected2 =
1478            build_ca_pmt(&pmt2, CaPmtListManagement::Add, CaPmtCmdId::OkDescrambling).to_bytes();
1479        assert_apdu_on_session(&d, CA_SESSION, &expected2);
1480
1481        assert_eq!(d.managed_ca().services().len(), 2);
1482    }
1483
1484    // --- #763 Task 4: set_cat + emm_pids/descramble_pids ---
1485
1486    /// A hand-built CAT section (ISO/IEC 13818-1 §2.4.4.5): table_id 0x01, a
1487    /// flat descriptor loop of `CA_descriptor`s (EN 300 468 §6.2.16, tag
1488    /// 0x09; the `ca_descriptor` helper above builds the same TLV used for
1489    /// PMTs). No off-air CAT capture exists in this repo's fixture corpus
1490    /// (verified: none of the committed `.ts` captures carry PID 0x0001),
1491    /// mirroring the same hand-rolled-fixture precedent as
1492    /// `build_ca_pmt_fixture` and `dvb_si::tables::cat`'s own unit tests.
1493    pub(crate) fn build_cat_fixture(descriptors: &[u8]) -> Vec<u8> {
1494        const EXTENSION_HEADER_LEN: u16 = 5;
1495        const CRC_LEN: u16 = 4;
1496        let section_length = EXTENSION_HEADER_LEN + descriptors.len() as u16 + CRC_LEN;
1497        let mut v = Vec::new();
1498        v.push(0x01); // table_id (CAT)
1499        v.push(0xB0 | ((section_length >> 8) as u8 & 0x0F));
1500        v.push((section_length & 0xFF) as u8);
1501        v.extend_from_slice(&[0xFF, 0xFF]); // table_id_extension (reserved for CAT)
1502        v.push(0xC1); // reserved(2)='11' | version(5)=0 | current_next=1
1503        v.push(0x00); // section_number
1504        v.push(0x00); // last_section_number
1505        v.extend_from_slice(descriptors);
1506        let crc = broadcast_common::crc32_mpeg2::compute(&v);
1507        v.extend_from_slice(&crc.to_be_bytes());
1508        v
1509    }
1510
1511    #[test]
1512    fn set_cat_computes_emm_pids_as_cat_inter_ca_info_caids() {
1513        use broadcast_common::Parse;
1514        use dvb_ci::objects::ca_info::CaInfo;
1515        use dvb_si::tables::cat::CatSection;
1516
1517        let mut d = driver_with_sessions();
1518        d.take_notifications();
1519
1520        // ca_info arrives first: the CAM advertises CAIDs 0x0648, 0x0100.
1521        feed(
1522            &mut d,
1523            r_apdu(
1524                CA_SESSION,
1525                &ser(&CaInfo {
1526                    ca_system_ids: vec![0x0648, 0x0100],
1527                }),
1528            ),
1529        );
1530        d.take_notifications();
1531
1532        // CAT maps 0x0648 -> 0x1FF0 (advertised) and 0x0500 -> 0x1FF1 (not
1533        // advertised by this CAM).
1534        let mut descriptors = Vec::new();
1535        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
1536        descriptors.extend_from_slice(&ca_descriptor(0x0500, 0x1FF1));
1537        let cat_bytes = build_cat_fixture(&descriptors);
1538        let cat = CatSection::parse(&cat_bytes).unwrap();
1539
1540        d.set_cat(&cat).unwrap();
1541
1542        assert_eq!(
1543            d.emm_pids(),
1544            &[0x1FF0],
1545            "0x0500 -> 0x1FF1 must be excluded: the CAM never advertised CAID 0x0500"
1546        );
1547    }
1548
1549    #[test]
1550    fn set_cat_before_ca_info_is_not_an_error_and_recomputes_once_ca_info_arrives() {
1551        use broadcast_common::Parse;
1552        use dvb_ci::objects::ca_info::CaInfo;
1553        use dvb_si::tables::cat::CatSection;
1554
1555        let mut d = driver_with_sessions();
1556        d.take_notifications();
1557
1558        let mut descriptors = Vec::new();
1559        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
1560        descriptors.extend_from_slice(&ca_descriptor(0x0500, 0x1FF1));
1561        let cat_bytes = build_cat_fixture(&descriptors);
1562        let cat = CatSection::parse(&cat_bytes).unwrap();
1563
1564        // set_cat with no ca_info observed yet: not an error, emm_pids stays
1565        // empty (nothing to intersect against).
1566        d.set_cat(&cat).unwrap();
1567        assert!(
1568            d.emm_pids().is_empty(),
1569            "emm_pids must be empty before any ca_info arrives, got {:?}",
1570            d.emm_pids()
1571        );
1572
1573        // ca_info now arrives: emm_pids recomputes against the CAT stored
1574        // earlier, without a second set_cat call.
1575        feed(
1576            &mut d,
1577            r_apdu(
1578                CA_SESSION,
1579                &ser(&CaInfo {
1580                    ca_system_ids: vec![0x0648, 0x0100],
1581                }),
1582            ),
1583        );
1584        d.take_notifications();
1585
1586        assert_eq!(
1587            d.emm_pids(),
1588            &[0x1FF0],
1589            "emm_pids must recompute once ca_info arrives, using the CAT stored by the earlier set_cat"
1590        );
1591    }
1592
1593    /// Task 4 review fix (MEDIUM): `recompute_emm_pids` must dedup like its
1594    /// sibling `recompute_service_pids` does — two CAT `CA_descriptor`s
1595    /// (distinct `CA_system_id`s, both CAM-advertised) that happen to share
1596    /// one `EMM_PID` (a real multi-CAS-on-one-EMM-PID broadcast setup) must
1597    /// list that PID exactly once, not twice.
1598    #[test]
1599    fn set_cat_emm_pids_dedups_when_two_caids_share_one_emm_pid() {
1600        use broadcast_common::Parse;
1601        use dvb_ci::objects::ca_info::CaInfo;
1602        use dvb_si::tables::cat::CatSection;
1603
1604        let mut d = driver_with_sessions();
1605        d.take_notifications();
1606
1607        // CAM advertises both CAIDs.
1608        feed(
1609            &mut d,
1610            r_apdu(
1611                CA_SESSION,
1612                &ser(&CaInfo {
1613                    ca_system_ids: vec![0x0648, 0x0100],
1614                }),
1615            ),
1616        );
1617        d.take_notifications();
1618
1619        // CAT maps BOTH CAIDs to the SAME EMM PID.
1620        let mut descriptors = Vec::new();
1621        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
1622        descriptors.extend_from_slice(&ca_descriptor(0x0100, 0x1FF0));
1623        let cat_bytes = build_cat_fixture(&descriptors);
1624        let cat = CatSection::parse(&cat_bytes).unwrap();
1625
1626        d.set_cat(&cat).unwrap();
1627
1628        assert_eq!(
1629            d.emm_pids(),
1630            &[0x1FF0],
1631            "0x1FF0 must appear exactly once even though two CAM-advertised CAIDs map to it, got {:?}",
1632            d.emm_pids()
1633        );
1634    }
1635
1636    /// Same layout as [`build_ca_pmt_fixture`] but with a distinct PCR/ES PID
1637    /// set, so a second added service proves `descramble_pids` is a real
1638    /// union rather than one programme's PIDs happening to repeat.
1639    fn build_ca_pmt_fixture_distinct_pids(program_number: u16) -> Vec<u8> {
1640        const VIACCESS: u16 = 0x0500;
1641        let prog_ca = ca_descriptor(VIACCESS, 0x0074);
1642        let es0_ca = ca_descriptor(VIACCESS, 0x0075);
1643
1644        let mut body = Vec::new();
1645        body.push(0x02); // table_id (PMT)
1646        body.push(0);
1647        body.push(0);
1648        body.extend_from_slice(&program_number.to_be_bytes());
1649        body.push(0xC3);
1650        body.push(0x00);
1651        body.push(0x00);
1652        body.push(0xE0 | 0x02); // PCR_PID = 0x0200
1653        body.push(0x00);
1654        body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1655        body.push(prog_ca.len() as u8);
1656        body.extend_from_slice(&prog_ca);
1657        // ES0: H.264 video, pid 0x0200, scrambled.
1658        body.push(0x1B);
1659        body.push(0xE0 | 0x02);
1660        body.push(0x00);
1661        body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1662        body.push(es0_ca.len() as u8);
1663        body.extend_from_slice(&es0_ca);
1664        // ES1: AAC ADTS audio, pid 0x0201, clear.
1665        body.push(0x0F);
1666        body.push(0xE0 | 0x02);
1667        body.push(0x01);
1668        body.push(0xF0);
1669        body.push(0x00);
1670
1671        let section_length = body.len() - 3 + 4;
1672        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1673        body[2] = section_length as u8;
1674        let crc = broadcast_common::crc32_mpeg2::compute(&body);
1675        body.extend_from_slice(&crc.to_be_bytes());
1676        body
1677    }
1678
1679    #[test]
1680    fn descramble_pids_is_the_union_of_active_services_es_pids() {
1681        use broadcast_common::Parse;
1682
1683        let mut d = driver_with_sessions();
1684        d.take_notifications();
1685
1686        assert!(
1687            d.descramble_pids().is_empty(),
1688            "no service added yet: descramble_pids must be empty"
1689        );
1690
1691        let pmt1_bytes = build_ca_pmt_fixture(1546);
1692        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1693        d.add_service(&pmt1).unwrap();
1694        d.device_mut().inbound.push_back(sb());
1695        d.pump(Duration::from_millis(10)).unwrap();
1696
1697        assert_eq!(d.descramble_pids(), &[0x0100, 0x0101]);
1698
1699        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
1700        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1701        d.add_service(&pmt2).unwrap();
1702        d.device_mut().inbound.push_back(sb());
1703        d.pump(Duration::from_millis(10)).unwrap();
1704
1705        // Union of both programmes' ES PIDs, sorted.
1706        assert_eq!(
1707            d.descramble_pids(),
1708            &[0x0100, 0x0101, 0x0200, 0x0201],
1709            "descramble_pids must be the union across both added services"
1710        );
1711    }
1712
1713    // --- #763 Task 5: re-query timer + edge-triggered Entitlement ---
1714
1715    /// Build a `ca_pmt_reply` (EN 50221 §8.4.3.5, Table 26) for `program_number`
1716    /// carrying programme-level `ca_enable` (`None` = `CA_enable_flag` clear).
1717    pub(crate) fn ca_pmt_reply_for(
1718        program_number: u16,
1719        ca_enable: Option<dvb_ci::objects::ca_pmt_reply::CaEnable>,
1720    ) -> dvb_ci::objects::ca_pmt_reply::CaPmtReply {
1721        dvb_ci::objects::ca_pmt_reply::CaPmtReply {
1722            program_number,
1723            version_number: 1,
1724            current_next_indicator: true,
1725            ca_enable,
1726            streams: vec![],
1727        }
1728    }
1729
1730    #[test]
1731    fn requery_timer_resends_ca_pmt_then_reply_change_emits_one_entitlement() {
1732        use broadcast_common::Parse;
1733        use dvb_ci::objects::ca_pmt_reply::CaEnable;
1734
1735        let mut d = driver_with_sessions();
1736        d.take_notifications();
1737
1738        let pmt_bytes = build_ca_pmt_fixture(1546);
1739        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1740        d.add_service(&pmt).unwrap();
1741        d.device_mut().inbound.push_back(sb());
1742        d.pump(Duration::from_millis(10)).unwrap();
1743        d.take_notifications();
1744
1745        // The initial `add_service` send is `ok_descrambling` — assert it
1746        // happened, so the test proves the resend below (a distinct `query`
1747        // cmd_id) is a genuinely different wire message, not the same bytes.
1748        let expected_initial_ca_pmt =
1749            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
1750        assert_apdu_on_session(&d, CA_SESSION, &expected_initial_ca_pmt);
1751
1752        // The re-query timer resends the `query`-variant bytes (EN 50221
1753        // §8.4.3.5: only `query`/`ok_mmi` solicit a `ca_pmt_reply` from a
1754        // conformant CAM — `ok_descrambling` does not).
1755        let expected_ca_pmt =
1756            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::Query).to_bytes();
1757        let sends_before_requery = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1758
1759        let mut all_notes = Vec::new();
1760
1761        // Reply 1: not entitled (baseline — first-ever reply for this
1762        // program; `descrambling_ok` is derived: `NotPossibleNoEntitlement`
1763        // is not in the "possible" set, so `false`).
1764        feed(
1765            &mut d,
1766            r_apdu(
1767                CA_SESSION,
1768                &ser(&ca_pmt_reply_for(
1769                    1546,
1770                    Some(CaEnable::NotPossibleNoEntitlement),
1771                )),
1772            ),
1773        );
1774        all_notes.extend(d.take_notifications());
1775
1776        // Advance the clock past the default 10s re-query interval: a single
1777        // pump ticks the stack with elapsed = 11s (nothing readable this
1778        // turn), which the #763 Task 5 re-query timer picks up and queues
1779        // the tracked service's exact ca_pmt for resend (EN 50221 §8.4.3.4
1780        // Table 25). EN 50221's link is half-duplex — this tick's own
1781        // keep-alive poll already claimed the turn, so the resend is
1782        // written on the module's next `T_SB` (the #337 one-write-per-turn
1783        // rule), same as any other queued host write in this test suite.
1784        d.pump(Duration::from_secs(11)).unwrap();
1785        all_notes.extend(d.take_notifications());
1786        d.device_mut().inbound.push_back(sb());
1787        d.pump(Duration::from_millis(10)).unwrap();
1788
1789        let sends_after_requery = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1790        assert_eq!(
1791            sends_after_requery,
1792            sends_before_requery + 1,
1793            "expected the re-query timer to resend the exact ca_pmt exactly once"
1794        );
1795
1796        // Reply 2: the CAM's re-evaluated answer to the re-query says
1797        // descrambling is now possible.
1798        feed(
1799            &mut d,
1800            r_apdu(
1801                CA_SESSION,
1802                &ser(&ca_pmt_reply_for(1546, Some(CaEnable::Possible))),
1803            ),
1804        );
1805        all_notes.extend(d.take_notifications());
1806
1807        let hits = all_notes
1808            .iter()
1809            .filter(|n| {
1810                matches!(
1811                    n,
1812                    Notification::Entitlement {
1813                        program_number: 1546,
1814                        ca_enable: CaEnable::Possible,
1815                        descrambling_ok: true,
1816                    }
1817                )
1818            })
1819            .count();
1820        assert_eq!(
1821            hits, 1,
1822            "expected exactly one Entitlement{{program_number:1546, ca_enable:Possible, descrambling_ok:true}}, got {all_notes:?}"
1823        );
1824    }
1825
1826    #[test]
1827    fn requery_timer_unchanged_reply_across_two_requeries_emits_no_entitlement() {
1828        use broadcast_common::Parse;
1829        use dvb_ci::objects::ca_pmt_reply::CaEnable;
1830
1831        let mut d = driver_with_sessions();
1832        d.take_notifications();
1833
1834        let pmt_bytes = build_ca_pmt_fixture(1547);
1835        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1836        d.add_service(&pmt).unwrap();
1837        d.device_mut().inbound.push_back(sb());
1838        d.pump(Duration::from_millis(10)).unwrap();
1839        d.take_notifications();
1840
1841        // Baseline reply: descrambling possible. First-ever reply — this
1842        // establishes the baseline and DOES emit once (per the transition
1843        // rule); drop it so the loop below only asserts on the re-queries.
1844        feed(
1845            &mut d,
1846            r_apdu(
1847                CA_SESSION,
1848                &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
1849            ),
1850        );
1851        d.take_notifications();
1852
1853        // Two re-queries, the CAM replying with the SAME unchanged status
1854        // both times: no Entitlement either time (negative control).
1855        for _ in 0..2 {
1856            d.pump(Duration::from_secs(11)).unwrap();
1857            d.take_notifications();
1858            feed(
1859                &mut d,
1860                r_apdu(
1861                    CA_SESSION,
1862                    &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
1863                ),
1864            );
1865            let notes = d.take_notifications();
1866            assert!(
1867                !notes
1868                    .iter()
1869                    .any(|n| matches!(n, Notification::Entitlement { .. })),
1870                "unchanged status across a re-query must not emit Entitlement, got {notes:?}"
1871            );
1872        }
1873    }
1874
1875    #[test]
1876    fn requery_reply_withdrawn_to_none_emits_no_entitlement() {
1877        use broadcast_common::Parse;
1878        use dvb_ci::objects::ca_pmt_reply::CaEnable;
1879
1880        let mut d = driver_with_sessions();
1881        d.take_notifications();
1882
1883        let pmt_bytes = build_ca_pmt_fixture(1548);
1884        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1885        d.add_service(&pmt).unwrap();
1886        d.device_mut().inbound.push_back(sb());
1887        d.pump(Duration::from_millis(10)).unwrap();
1888        d.take_notifications();
1889
1890        // Baseline: descrambling possible (drop the baseline Entitlement).
1891        feed(
1892            &mut d,
1893            r_apdu(
1894                CA_SESSION,
1895                &ser(&ca_pmt_reply_for(1548, Some(CaEnable::Possible))),
1896            ),
1897        );
1898        d.take_notifications();
1899
1900        // Programme `CA_enable_flag` now clear (`None`) — status withdrawn.
1901        // Per the transition rule this NEVER emits Entitlement (#726 HotPlug
1902        // covers the coarse withdrawal signal instead).
1903        feed(
1904            &mut d,
1905            r_apdu(CA_SESSION, &ser(&ca_pmt_reply_for(1548, None))),
1906        );
1907        let notes = d.take_notifications();
1908        assert!(
1909            !notes
1910                .iter()
1911                .any(|n| matches!(n, Notification::Entitlement { .. })),
1912            "ca_enable transitioning to None must not emit Entitlement, got {notes:?}"
1913        );
1914    }
1915
1916    #[test]
1917    fn set_requery_interval_zero_disables_resend() {
1918        use broadcast_common::Parse;
1919
1920        let mut d = driver_with_sessions();
1921        d.set_requery_interval(Duration::ZERO);
1922        d.take_notifications();
1923
1924        let pmt_bytes = build_ca_pmt_fixture(1549);
1925        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1926        d.add_service(&pmt).unwrap();
1927        d.device_mut().inbound.push_back(sb());
1928        d.pump(Duration::from_millis(10)).unwrap();
1929
1930        // Count the `query`-variant bytes — the ones the timer would resend
1931        // if it fired — not the `ok_descrambling` bytes `add_service` sent.
1932        let expected_ca_pmt =
1933            build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::Query).to_bytes();
1934        let sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1935
1936        // Even a very long tick must not trigger a re-query once disabled.
1937        d.pump(Duration::from_secs(1000)).unwrap();
1938
1939        let sends_after = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1940        assert_eq!(
1941            sends_after, sends_before,
1942            "Duration::ZERO must disable the re-query resend"
1943        );
1944    }
1945
1946    #[test]
1947    fn requery_timer_resends_every_active_service_not_just_one() {
1948        use broadcast_common::Parse;
1949
1950        let mut d = driver_with_sessions();
1951        d.take_notifications();
1952
1953        // Two services on the managed set: 1546 (`Only`, first-ever) and
1954        // 1547 (`Add`, joining the active set).
1955        let pmt1_bytes = build_ca_pmt_fixture(1546);
1956        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1957        d.add_service(&pmt1).unwrap();
1958        d.device_mut().inbound.push_back(sb());
1959        d.pump(Duration::from_millis(10)).unwrap();
1960
1961        let pmt2_bytes = build_ca_pmt_fixture(1547);
1962        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1963        d.add_service(&pmt2).unwrap();
1964        d.device_mut().inbound.push_back(sb());
1965        d.pump(Duration::from_millis(10)).unwrap();
1966        d.take_notifications();
1967
1968        // The `query`-variant bytes the re-query timer rebuilds for each
1969        // service — #765: list_management reflects each service's POSITION
1970        // in the current active set (lowest program_number = First, highest
1971        // = Last), not whatever it got at `add_service` time.
1972        let expected1 =
1973            build_ca_pmt(&pmt1, CaPmtListManagement::First, CaPmtCmdId::Query).to_bytes();
1974        let expected2 =
1975            build_ca_pmt(&pmt2, CaPmtListManagement::Last, CaPmtCmdId::Query).to_bytes();
1976        let sends_before1 = count_apdu_on_session(&d, CA_SESSION, &expected1);
1977        let sends_before2 = count_apdu_on_session(&d, CA_SESSION, &expected2);
1978
1979        // Advance the clock past the default 10s re-query interval: this
1980        // queues BOTH services' resends (`requery_tick` iterates the whole
1981        // active set), but EN 50221's half-duplex link (the #337
1982        // one-write-per-turn rule) only lets one out per turn — feed enough
1983        // `T_SB` acks to flush both queued writes, mirroring `feed`'s own
1984        // multi-turn drain loop.
1985        d.pump(Duration::from_secs(11)).unwrap();
1986        feed(&mut d, sb());
1987
1988        let sends_after1 = count_apdu_on_session(&d, CA_SESSION, &expected1);
1989        let sends_after2 = count_apdu_on_session(&d, CA_SESSION, &expected2);
1990        assert_eq!(
1991            sends_after1,
1992            sends_before1 + 1,
1993            "expected service 1546's query ca_pmt resent exactly once on the shared tick"
1994        );
1995        assert_eq!(
1996            sends_after2,
1997            sends_before2 + 1,
1998            "expected service 1547's query ca_pmt resent exactly once on the shared tick"
1999        );
2000    }
2001
2002    // --- #765: re-query must reflect the CURRENT active set, not the
2003    // list_management frozen at each service's add_service time ---
2004
2005    #[test]
2006    fn requery_after_remove_uses_only_for_sole_survivor() {
2007        use broadcast_common::Parse;
2008
2009        let mut d = driver_with_sessions();
2010        d.take_notifications();
2011
2012        // add_service(1546) -> Only (first-ever); add_service(1547) -> Add
2013        // (joining the active set).
2014        let pmt1_bytes = build_ca_pmt_fixture(1546);
2015        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
2016        d.add_service(&pmt1).unwrap();
2017        d.device_mut().inbound.push_back(sb());
2018        d.pump(Duration::from_millis(10)).unwrap();
2019
2020        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
2021        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
2022        d.add_service(&pmt2).unwrap();
2023        d.device_mut().inbound.push_back(sb());
2024        d.pump(Duration::from_millis(10)).unwrap();
2025
2026        // Remove 1546: 1547 is now the SOLE survivor.
2027        d.remove_service(1546).unwrap();
2028        d.device_mut().inbound.push_back(sb());
2029        d.pump(Duration::from_millis(10)).unwrap();
2030        d.take_notifications();
2031
2032        // The #765 bite: the resent ca_pmt for the sole survivor must use
2033        // `Only` — reflecting the CURRENT (post-remove) active set — not
2034        // `Add`, the list_management 1547 was frozen with back when 1546
2035        // was still active at its own add_service time. A pre-fix
2036        // frozen-bytes scheme resends `Add` here and fails this assertion.
2037        let expected_only =
2038            build_ca_pmt(&pmt2, CaPmtListManagement::Only, CaPmtCmdId::Query).to_bytes();
2039        let expected_stale_add =
2040            build_ca_pmt(&pmt2, CaPmtListManagement::Add, CaPmtCmdId::Query).to_bytes();
2041        let sends_only_before = count_apdu_on_session(&d, CA_SESSION, &expected_only);
2042        let sends_add_before = count_apdu_on_session(&d, CA_SESSION, &expected_stale_add);
2043
2044        d.pump(Duration::from_secs(11)).unwrap();
2045        feed(&mut d, sb());
2046
2047        assert_eq!(
2048            count_apdu_on_session(&d, CA_SESSION, &expected_only),
2049            sends_only_before + 1,
2050            "sole-survivor re-query must resend with list_management = Only"
2051        );
2052        assert_eq!(
2053            count_apdu_on_session(&d, CA_SESSION, &expected_stale_add),
2054            sends_add_before,
2055            "sole-survivor re-query must NOT resend the stale Add list_management"
2056        );
2057    }
2058
2059    #[test]
2060    fn requery_two_services_uses_first_then_last() {
2061        use broadcast_common::Parse;
2062
2063        let mut d = driver_with_sessions();
2064        d.take_notifications();
2065
2066        // Two active services, kept both active across the re-query tick —
2067        // program_number order is 1546 < 1547 (services is a BTreeMap).
2068        let pmt1_bytes = build_ca_pmt_fixture(1546);
2069        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
2070        d.add_service(&pmt1).unwrap();
2071        d.device_mut().inbound.push_back(sb());
2072        d.pump(Duration::from_millis(10)).unwrap();
2073
2074        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
2075        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
2076        d.add_service(&pmt2).unwrap();
2077        d.device_mut().inbound.push_back(sb());
2078        d.pump(Duration::from_millis(10)).unwrap();
2079        d.take_notifications();
2080
2081        // Bite: a frozen-per-service scheme would resend 1546 with `Only`
2082        // (its own add_service-time list_management) and 1547 with `Add`
2083        // (its own) — neither of which is `First`/`Last`.
2084        let expected_first =
2085            build_ca_pmt(&pmt1, CaPmtListManagement::First, CaPmtCmdId::Query).to_bytes();
2086        let expected_last =
2087            build_ca_pmt(&pmt2, CaPmtListManagement::Last, CaPmtCmdId::Query).to_bytes();
2088        let sends_first_before = count_apdu_on_session(&d, CA_SESSION, &expected_first);
2089        let sends_last_before = count_apdu_on_session(&d, CA_SESSION, &expected_last);
2090
2091        d.pump(Duration::from_secs(11)).unwrap();
2092        feed(&mut d, sb());
2093
2094        assert_eq!(
2095            count_apdu_on_session(&d, CA_SESSION, &expected_first),
2096            sends_first_before + 1,
2097            "the lowest-program_number active service must re-query with First"
2098        );
2099        assert_eq!(
2100            count_apdu_on_session(&d, CA_SESSION, &expected_last),
2101            sends_last_before + 1,
2102            "the highest-program_number active service must re-query with Last"
2103        );
2104    }
2105
2106    // --- #763 Task 6: remove_service + clear managed state on CAM hot-plug ---
2107
2108    #[test]
2109    fn remove_service_sends_update_not_selected_and_drops_from_managed_state() {
2110        use broadcast_common::Parse;
2111
2112        let mut d = driver_with_sessions();
2113        d.take_notifications();
2114
2115        // 1546 (`Only`, distinct PIDs 0x100/0x101) and 1547 (`Add`, distinct
2116        // PIDs 0x200/0x201) — distinct PID sets so removing 1546 is
2117        // observably different from removing 1547.
2118        let pmt1_bytes = build_ca_pmt_fixture(1546);
2119        let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
2120        d.add_service(&pmt1).unwrap();
2121        d.device_mut().inbound.push_back(sb());
2122        d.pump(Duration::from_millis(10)).unwrap();
2123
2124        let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
2125        let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
2126        d.add_service(&pmt2).unwrap();
2127        d.device_mut().inbound.push_back(sb());
2128        d.pump(Duration::from_millis(10)).unwrap();
2129
2130        d.remove_service(1546).unwrap();
2131        d.device_mut().inbound.push_back(sb());
2132        d.pump(Duration::from_millis(10)).unwrap();
2133
2134        // Oracle: the same PMT re-built directly via
2135        // dvb_ci::builder::build_ca_pmt with `Update`/`NotSelected` (EN 50221
2136        // §8.4.3.4 Table 25) — the exact bytes `remove_program` sends.
2137        let expected =
2138            build_ca_pmt(&pmt1, CaPmtListManagement::Update, CaPmtCmdId::NotSelected).to_bytes();
2139        assert_apdu_on_session(&d, CA_SESSION, &expected);
2140
2141        assert_eq!(
2142            d.descramble_pids(),
2143            &[0x0200, 0x0201],
2144            "1546's ES PIDs must be gone; 1547's must remain"
2145        );
2146        assert!(
2147            d.managed_ca().services().get(&1546).is_none(),
2148            "1546 must no longer be tracked"
2149        );
2150        assert!(
2151            d.managed_ca().services().get(&1547).is_some(),
2152            "1547 must remain tracked"
2153        );
2154    }
2155
2156    #[test]
2157    fn remove_service_of_untracked_program_is_a_no_op() {
2158        let mut d = driver_with_sessions();
2159        d.take_notifications();
2160
2161        let ops_before = d.device().ops.len();
2162        d.remove_service(0xFFFF).unwrap();
2163        assert_eq!(
2164            d.device().ops.len(),
2165            ops_before,
2166            "removing an untracked program must not send anything to the device"
2167        );
2168        assert!(
2169            d.managed_ca().services().is_empty(),
2170            "removing an untracked program must not disturb the (empty) managed set"
2171        );
2172    }
2173
2174    #[test]
2175    fn cam_removed_edge_clears_managed_state() {
2176        use broadcast_common::Parse;
2177        use dvb_ci::objects::ca_info::CaInfo;
2178        use dvb_si::tables::cat::CatSection;
2179
2180        let mut d = driver_with_sessions();
2181        d.take_notifications();
2182
2183        let pmt_bytes = build_ca_pmt_fixture(1546);
2184        let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2185        d.add_service(&pmt).unwrap();
2186        d.device_mut().inbound.push_back(sb());
2187        d.pump(Duration::from_millis(10)).unwrap();
2188
2189        // Populate emm_pids too, via ca_info + set_cat, so the test proves
2190        // the fix clears more than just `services`.
2191        feed(
2192            &mut d,
2193            r_apdu(
2194                CA_SESSION,
2195                &ser(&CaInfo {
2196                    ca_system_ids: vec![0x0648],
2197                }),
2198            ),
2199        );
2200        d.take_notifications();
2201        let mut descriptors = Vec::new();
2202        descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
2203        let cat_bytes = build_cat_fixture(&descriptors);
2204        let cat = CatSection::parse(&cat_bytes).unwrap();
2205        d.set_cat(&cat).unwrap();
2206
2207        assert!(
2208            !d.managed_ca().services().is_empty(),
2209            "precondition: a service is tracked"
2210        );
2211        assert!(
2212            !d.descramble_pids().is_empty(),
2213            "precondition: descramble_pids populated"
2214        );
2215        assert!(!d.emm_pids().is_empty(), "precondition: emm_pids populated");
2216
2217        // Module physically removed: a CamRemoved hot-plug edge.
2218        d.device_mut().slot.module_present = false;
2219        d.pump(Duration::from_millis(10)).unwrap();
2220        let notes = d.take_notifications();
2221        assert!(
2222            notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
2223            "expected CamRemoved, got {notes:?}"
2224        );
2225
2226        assert!(
2227            d.managed_ca().services().is_empty(),
2228            "services must be cleared on CamRemoved"
2229        );
2230        assert!(
2231            d.descramble_pids().is_empty(),
2232            "descramble_pids must be cleared on CamRemoved"
2233        );
2234        assert!(
2235            d.emm_pids().is_empty(),
2236            "emm_pids must be cleared on CamRemoved"
2237        );
2238    }
2239}