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 crate::device::{CaDevice, SlotInfo};
11use crate::event::{Action, Event, HostRequest, HotPlug, MmiEvent, Notification};
12use crate::stack::CiStack;
13
14/// Substrings (case-insensitive) in MMI menu/list/enquiry text that
15/// heuristically indicate the smart card is absent. **Best-effort**: EN 50221
16/// defines no card-detect signal, so this is free-text sniffing of real CAM
17/// MMI copy, not a spec-defined mechanism.
18const MMI_CARD_ABSENT_KEYWORDS: &[&str] = &[
19    "no card",
20    "insert card",
21    "insert smart card",
22    "card removed",
23    "please insert",
24];
25
26/// Substrings (case-insensitive) in MMI menu/list/enquiry text that
27/// heuristically indicate a valid smart card is present (entitlements
28/// readable). **Best-effort**, same caveat as
29/// [`MMI_CARD_ABSENT_KEYWORDS`].
30const MMI_CARD_PRESENT_KEYWORDS: &[&str] = &["entitlement", "card valid", "subscription active"];
31
32/// Drives a [`CaDevice`] with the [`CiStack`].
33pub struct Driver<D: CaDevice> {
34    device: D,
35    stack: CiStack,
36    notifications: Vec<Notification>,
37    /// Delay the stack last asked to be polled after (`None` = none pending).
38    next_timer: Option<Duration>,
39    /// Read buffer for one link-layer frame.
40    buf: Vec<u8>,
41    /// Last observed slot status (Part A hot-plug edge detection, #726).
42    /// `None` means no [`SlotInfo`] has been observed yet — the first
43    /// observation only establishes the baseline; it never itself fires
44    /// [`Notification::HotPlug`] carrying [`HotPlug::CamPresent`]/
45    /// [`CamRemoved`](HotPlug::CamRemoved), so `Driver::init` on an
46    /// already-inserted module doesn't spuriously re-drive its own handshake.
47    last_slot: Option<SlotInfo>,
48    /// Last `ca_info` CAID set seen for the current module (Part B card
49    /// inference, best-effort). `None` = not seen yet (baseline only).
50    last_caids: Option<BTreeSet<u16>>,
51    /// Last `ca_pmt_reply` `descrambling_ok` seen for the current module
52    /// (Part B card inference, best-effort). `None` = not seen yet.
53    last_descrambling_ok: Option<bool>,
54}
55
56impl<D: CaDevice> Driver<D> {
57    /// New driver over `device`, single transport connection.
58    #[must_use]
59    pub fn new(device: D) -> Self {
60        Self {
61            device,
62            stack: CiStack::new(),
63            notifications: Vec::new(),
64            next_timer: None,
65            buf: vec![0u8; 4096],
66            last_slot: None,
67            last_caids: None,
68            last_descrambling_ok: None,
69        }
70    }
71
72    /// Borrow the underlying device (e.g. to inspect a mock's recorded ops).
73    pub fn device(&self) -> &D {
74        &self.device
75    }
76
77    /// Mutably borrow the underlying device (e.g. to script a mock's inbound
78    /// frames between pumps).
79    pub fn device_mut(&mut self) -> &mut D {
80        &mut self.device
81    }
82
83    /// The poll delay the stack most recently requested, if any.
84    pub fn next_timer(&self) -> Option<Duration> {
85        self.next_timer
86    }
87
88    /// Drain the notifications collected so far.
89    pub fn take_notifications(&mut self) -> Vec<Notification> {
90        core::mem::take(&mut self.notifications)
91    }
92
93    /// Bring the interface up (reset + open the transport connection).
94    pub fn init(&mut self) -> io::Result<()> {
95        let actions = self.stack.handle(Event::Host(HostRequest::Init));
96        self.run(actions)
97    }
98
99    /// Request the module descramble the services in `ca_pmt` (a serialized
100    /// `ca_pmt` APDU body, e.g. from `dvb_ci::build_ca_pmt`).
101    pub fn send_ca_pmt(&mut self, ca_pmt: &[u8]) -> io::Result<()> {
102        let actions = self
103            .stack
104            .handle(Event::Host(HostRequest::SendCaPmt(ca_pmt)));
105        self.run(actions)
106    }
107
108    /// Descramble the services in a PMT section: the stack filters the PMT's
109    /// `CA_descriptor`s to the CAM's advertised CAIDs and sends a `ca_pmt`
110    /// (`list_management = only`, `cmd_id = ok_descrambling`). The outcome
111    /// surfaces as [`Notification::CaPmtReply`]. Call after the CAM is ready and
112    /// its `ca_info` has been received (otherwise no CAID filter is applied).
113    pub fn descramble(&mut self, pmt_section: &[u8]) -> io::Result<()> {
114        let actions = self
115            .stack
116            .handle(Event::Host(HostRequest::Descramble(pmt_section)));
117        self.run(actions)
118    }
119
120    /// Descramble a set of programmes in one CA-PMT list (`first`/`more`/`last`),
121    /// replacing any previously selected set. Each element is a raw PMT section.
122    pub fn descramble_programs(&mut self, pmt_sections: &[&[u8]]) -> io::Result<()> {
123        let actions = self
124            .stack
125            .handle(Event::Host(HostRequest::DescramblePrograms(pmt_sections)));
126        self.run(actions)
127    }
128
129    /// Add one programme to the descrambled set (`list_management = add`) without
130    /// re-listing the others — for a capacity manager adding a viewer's service.
131    pub fn add_program(&mut self, pmt_section: &[u8]) -> io::Result<()> {
132        let actions = self
133            .stack
134            .handle(Event::Host(HostRequest::AddProgram(pmt_section)));
135        self.run(actions)
136    }
137
138    /// Remove one programme from the descrambled set (`list_management = update`,
139    /// `cmd_id = not_selected`) — tells the CAM to stop descrambling it.
140    pub fn remove_program(&mut self, pmt_section: &[u8]) -> io::Result<()> {
141        let actions = self
142            .stack
143            .handle(Event::Host(HostRequest::RemoveProgram(pmt_section)));
144        self.run(actions)
145    }
146
147    /// Answer an MMI menu/list by 1-based `choice_ref` (0 = back/cancel).
148    pub fn mmi_menu_answer(&mut self, choice_ref: u8) -> io::Result<()> {
149        let actions = self
150            .stack
151            .handle(Event::Host(HostRequest::MmiMenuAnswer(choice_ref)));
152        self.run(actions)
153    }
154
155    /// Answer an MMI enquiry with the user's input (EN 300 468 Annex A bytes).
156    pub fn mmi_enquiry_answer(&mut self, text: &[u8]) -> io::Result<()> {
157        let actions = self
158            .stack
159            .handle(Event::Host(HostRequest::MmiEnquiryAnswer(text)));
160        self.run(actions)
161    }
162
163    /// Abort the current MMI dialogue (`answ` with `answ_id = cancel`).
164    pub fn mmi_cancel(&mut self) -> io::Result<()> {
165        let actions = self.stack.handle(Event::Host(HostRequest::MmiCancel));
166        self.run(actions)
167    }
168
169    /// Ask the module to open its MMI menu (`enter_menu`) — e.g. to read card /
170    /// entitlement info from the module's own menus.
171    pub fn enter_menu(&mut self) -> io::Result<()> {
172        let actions = self.stack.handle(Event::Host(HostRequest::EnterMenu));
173        self.run(actions)
174    }
175
176    /// One pump step: if the device is readable within `timeout`, read a frame
177    /// and feed it; otherwise advance the stack's timers by `timeout` (driving
178    /// the poll cadence). Returns whether a frame was processed.
179    ///
180    /// Also samples [`SlotInfo`] once per call (the DVB-CA slot has no
181    /// interrupt/event of its own; `CA_GET_SLOT_INFO` is a poll) so a hot-plug
182    /// edge is caught between reads — see [`Notification::HotPlug`] carrying
183    /// [`HotPlug::CamPresent`]/[`CamRemoved`](HotPlug::CamRemoved) (#726).
184    pub fn pump(&mut self, timeout: Duration) -> io::Result<bool> {
185        self.run(vec![Action::QuerySlot])?;
186        if self.device.poll(timeout)? {
187            let n = self.device.read(&mut self.buf)?;
188            if n > 0 {
189                let frame = self.buf[..n].to_vec();
190                let actions = self.stack.handle(Event::Readable(&frame));
191                self.run(actions)?;
192                return Ok(true);
193            }
194        }
195        let actions = self.stack.handle(Event::Tick { elapsed: timeout });
196        self.run(actions)?;
197        Ok(false)
198    }
199
200    /// Pump once ([`pump`](Self::pump)), then invoke `handler` for each
201    /// [`Notification`] produced this cycle (drain-and-dispatch via
202    /// [`take_notifications`](Self::take_notifications)). Returns the same
203    /// bool as `pump`. The closure is per-call — nothing is stored, so there
204    /// are no lifetime constraints beyond the call itself. This crate is
205    /// sync/sans-IO (no channels/async runtime), so a closure callback is the
206    /// idiomatic push-style alternative to poll-draining `take_notifications`
207    /// yourself.
208    pub fn pump_with<F: FnMut(&Notification)>(
209        &mut self,
210        timeout: Duration,
211        mut handler: F,
212    ) -> io::Result<bool> {
213        let progressed = self.pump(timeout)?;
214        for n in self.take_notifications() {
215            handler(&n);
216        }
217        Ok(progressed)
218    }
219
220    /// Convenience over [`pump_with`](Self::pump_with): invoke `handler` only
221    /// for [`HotPlug`] transitions, ignoring every other [`Notification`]
222    /// produced this cycle.
223    pub fn pump_hotplug<F: FnMut(HotPlug)>(
224        &mut self,
225        timeout: Duration,
226        mut handler: F,
227    ) -> io::Result<bool> {
228        self.pump_with(timeout, |n| {
229            if let Some(h) = n.hotplug() {
230                handler(h);
231            }
232        })
233    }
234
235    /// Execute the stack's actions against the device.
236    fn run(&mut self, actions: Vec<Action>) -> io::Result<()> {
237        for action in actions {
238            match action {
239                Action::Write(bytes) => self.device.write(&bytes)?,
240                Action::Reset => self.device.reset()?,
241                Action::QuerySlot => {
242                    let info = self.device.slot_info()?;
243                    self.handle_slot_info(info)?;
244                }
245                Action::SetTimer { after } => self.next_timer = Some(after),
246                Action::Notify(n) => {
247                    let inferred = self.infer_card(&n);
248                    self.notifications.push(n);
249                    self.notifications.extend(inferred);
250                }
251            }
252        }
253        Ok(())
254    }
255
256    /// Compare a freshly-queried [`SlotInfo`] against the last one observed
257    /// and react to a `module_present` edge (Part A, #726): the *first*
258    /// observation ever (`self.last_slot == None`) only establishes the
259    /// baseline — it must not fire a notification, or `Driver::init` against
260    /// an already-inserted module would spuriously report a hot-plug and
261    /// recurse into re-driving its own in-progress handshake.
262    fn handle_slot_info(&mut self, info: SlotInfo) -> io::Result<()> {
263        let prev = self.last_slot.replace(info);
264        match prev {
265            Some(prev) if !prev.module_present && info.module_present => {
266                self.notifications
267                    .push(Notification::HotPlug(HotPlug::CamPresent));
268                self.reset_module_state();
269                // Re-drive the same reset/init path `Driver::init` uses, so
270                // the newly-inserted module gets a clean resource-manager
271                // handshake (no duplicated handshake logic).
272                let actions = self.stack.handle(Event::Host(HostRequest::Init));
273                self.run(actions)?;
274            }
275            Some(prev) if prev.module_present && !info.module_present => {
276                self.notifications
277                    .push(Notification::HotPlug(HotPlug::CamRemoved));
278                self.reset_module_state();
279            }
280            _ => {}
281        }
282        Ok(())
283    }
284
285    /// Reset per-module protocol + card-inference state after a CAM
286    /// insert/remove edge: a fresh [`CiStack`] (so a re-insert re-handshakes
287    /// cleanly instead of reusing stale session numbers) and cleared Part B
288    /// baselines (so the next module's `ca_info`/`ca_pmt_reply` establishes
289    /// its own fresh baseline rather than diffing against the departed
290    /// module's).
291    fn reset_module_state(&mut self) {
292        self.stack = CiStack::new();
293        self.next_timer = None;
294        self.last_caids = None;
295        self.last_descrambling_ok = None;
296    }
297
298    /// Best-effort app-layer card-presence inference (Part B, #726): EN 50221
299    /// CI slots are module-level only — there is no card-detect line (verified
300    /// against real DD ddbridge / cxd2099 driver behaviour) — so this derives
301    /// card insert/remove/change from signals the module already sends for
302    /// other reasons. Returns any inferred [`Notification`]s (0 or 1); `note`
303    /// itself is pushed by the caller.
304    fn infer_card(&mut self, note: &Notification) -> Vec<Notification> {
305        match note {
306            Notification::CaInfo { ca_system_ids } => {
307                let new_set: BTreeSet<u16> = ca_system_ids.iter().copied().collect();
308                let mut out = Vec::new();
309                if let Some(prev) = &self.last_caids {
310                    if prev.is_empty() && !new_set.is_empty() {
311                        out.push(Notification::HotPlug(HotPlug::CardInserted));
312                    } else if !prev.is_empty() && new_set.is_empty() {
313                        out.push(Notification::HotPlug(HotPlug::CardRemoved));
314                    } else if !prev.is_empty() && !new_set.is_empty() && *prev != new_set {
315                        out.push(Notification::HotPlug(HotPlug::CardChanged));
316                    }
317                }
318                self.last_caids = Some(new_set);
319                out
320            }
321            Notification::CaPmtReply {
322                descrambling_ok, ..
323            } => {
324                let mut out = Vec::new();
325                if let Some(prev) = self.last_descrambling_ok {
326                    if !prev && *descrambling_ok {
327                        out.push(Notification::HotPlug(HotPlug::CardInserted));
328                    } else if prev && !*descrambling_ok {
329                        out.push(Notification::HotPlug(HotPlug::CardRemoved));
330                    }
331                }
332                self.last_descrambling_ok = Some(*descrambling_ok);
333                out
334            }
335            Notification::Mmi(ev) => match Self::mmi_text(ev) {
336                Some(text) => {
337                    let lower = text.to_lowercase();
338                    if MMI_CARD_ABSENT_KEYWORDS.iter().any(|k| lower.contains(k)) {
339                        vec![Notification::HotPlug(HotPlug::CardRemoved)]
340                    } else if MMI_CARD_PRESENT_KEYWORDS.iter().any(|k| lower.contains(k)) {
341                        vec![Notification::HotPlug(HotPlug::CardInserted)]
342                    } else {
343                        Vec::new()
344                    }
345                }
346                None => Vec::new(),
347            },
348            _ => Vec::new(),
349        }
350    }
351
352    /// The free-text an [`MmiEvent`] carries, for the keyword heuristic above
353    /// (title/subtitle/bottom/choices for a menu/list, the prompt for an
354    /// enquiry; a `Close` carries no text).
355    fn mmi_text(ev: &MmiEvent) -> Option<String> {
356        match ev {
357            MmiEvent::Menu(m) | MmiEvent::List(m) => {
358                let mut s = format!("{} {} {}", m.title, m.subtitle, m.bottom);
359                for choice in &m.choices {
360                    s.push(' ');
361                    s.push_str(choice);
362                }
363                Some(s)
364            }
365            MmiEvent::Enquiry { prompt, .. } => Some(prompt.clone()),
366            MmiEvent::Close => None,
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::device::{DeviceOp, MockCaDevice};
375    use crate::event::{HostControlEvent, HotPlug, Notification};
376    use broadcast_common::Serialize;
377    use dvb_ci::tpdu::tags;
378
379    fn ser<S: Serialize>(s: &S) -> Vec<u8> {
380        let mut b = vec![0u8; s.serialized_len()];
381        match s.serialize_into(&mut b) {
382            Ok(n) => b.truncate(n),
383            Err(_) => b.clear(),
384        }
385        b
386    }
387
388    /// Wrap an SPDU as a module→host `T_Data_Last` R_TPDU (+ trailing T_SB,
389    /// data_available clear) on transport connection `tcid`.
390    fn r_data(tcid: u8, spdu: &[u8]) -> Vec<u8> {
391        use dvb_ci::tpdu::{SbValue, tags as tpdu_tags};
392        let mut v = vec![tpdu_tags::DATA_LAST, (1 + spdu.len()) as u8, tcid];
393        v.extend_from_slice(spdu);
394        v.extend_from_slice(&[tpdu_tags::SB, 0x02, tcid, SbValue::new(false).0]);
395        v
396    }
397
398    /// Wrap an APDU for delivery on `session_nb` (session_number prefix), then as
399    /// a module→host R_TPDU on tcid 1.
400    fn r_apdu(session_nb: u16, apdu: &[u8]) -> Vec<u8> {
401        use dvb_ci::spdu::SessionNumber;
402        let mut spdu = ser(&SessionNumber { session_nb });
403        spdu.extend_from_slice(apdu);
404        r_data(1, &spdu)
405    }
406
407    /// A standalone module→host `T_SB` (data_available clear) ack — flushes one
408    /// queued host write per turn (#337).
409    fn sb() -> Vec<u8> {
410        use dvb_ci::tpdu::{SbValue, tags as tpdu_tags};
411        vec![tpdu_tags::SB, 0x02, 0x01, SbValue::new(false).0]
412    }
413
414    /// Feed one scripted module frame into the mock and pump it, then pump a
415    /// handful of SB acks so any queued host writes flush.
416    fn feed(d: &mut Driver<MockCaDevice>, frame: Vec<u8>) {
417        d.device_mut().inbound.push_back(frame);
418        d.pump(Duration::from_millis(10)).unwrap();
419        for _ in 0..8 {
420            d.device_mut().inbound.push_back(sb());
421            d.pump(Duration::from_millis(10)).unwrap();
422        }
423    }
424
425    /// Drive the EN 50221 handshake through the `Driver` until host_control and
426    /// the other module-provided sessions are open (mirrors the stack-level
427    /// `stack_with_ca_session`, but exercises the real driver I/O path).
428    fn driver_with_sessions() -> Driver<MockCaDevice> {
429        use dvb_ci::objects::resource_manager::Profile;
430        use dvb_ci::resource::{
431            APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, HOST_CONTROL, MMI,
432            RESOURCE_MANAGER,
433        };
434        use dvb_ci::spdu::{CreateSessionResponse, OpenSessionRequest, SessionStatus};
435
436        let mut d = Driver::new(MockCaDevice::new([]));
437        d.init().unwrap();
438        // module accepts the transport connection
439        feed(&mut d, vec![tags::C_T_C_REPLY, 0x01, 0x01]);
440        // module opens the host's resource_manager → RM session 1
441        feed(
442            &mut d,
443            r_data(
444                1,
445                &ser(&OpenSessionRequest {
446                    resource: RESOURCE_MANAGER,
447                }),
448            ),
449        );
450        // module's profile → host: CamReady + profile_change + create_session for
451        // each module-provided resource.
452        feed(
453            &mut d,
454            r_apdu(
455                1,
456                &ser(&Profile {
457                    resources: vec![
458                        APPLICATION_INFORMATION,
459                        CONDITIONAL_ACCESS_SUPPORT,
460                        MMI,
461                        HOST_CONTROL,
462                    ],
463                }),
464            ),
465        );
466        // module accepts each create_session (session nbs 2..=5 in registration order)
467        for (nb, res) in [
468            (2u16, APPLICATION_INFORMATION),
469            (3, CONDITIONAL_ACCESS_SUPPORT),
470            (4, MMI),
471            (5, HOST_CONTROL),
472        ] {
473            feed(
474                &mut d,
475                r_data(
476                    1,
477                    &ser(&CreateSessionResponse {
478                        status: SessionStatus::Ok,
479                        resource: res,
480                        session_nb: nb,
481                    }),
482                ),
483            );
484        }
485        d
486    }
487
488    // Session numbers the module allocates in `driver_with_sessions`, in
489    // registration order: RM=1, app_info=2, conditional_access=3, mmi=4,
490    // host_control=5. (Asserted by `handshake_opens_expected_sessions`.)
491    const RM_SESSION: u16 = 1;
492    const CA_SESSION: u16 = 3;
493    const MMI_SESSION: u16 = 4;
494    const HOST_CONTROL_SESSION: u16 = 5;
495
496    #[test]
497    fn host_control_tune_apdu_surfaces_notification_via_driver() {
498        use dvb_ci::objects::host_control::Tune;
499
500        let mut d = driver_with_sessions();
501        let hc_nb = HOST_CONTROL_SESSION;
502        d.take_notifications(); // drop handshake notifications
503
504        // Module (CAM) sends a Tune request on its host_control session.
505        let tune = Tune {
506            network_id: 0x1122,
507            original_network_id: 0x3344,
508            transport_stream_id: 0x5566,
509            service_id: 0x7788,
510        };
511        feed(&mut d, r_apdu(hc_nb, &ser(&tune)));
512
513        // The runtime surfaces the decoded HostControl(Tune) notification.
514        let notes = d.take_notifications();
515        assert!(
516            notes.contains(&Notification::HostControl(HostControlEvent::Tune {
517                network_id: 0x1122,
518                original_network_id: 0x3344,
519                transport_stream_id: 0x5566,
520                service_id: 0x7788,
521            })),
522            "expected HostControl(Tune) notification, got {notes:?}"
523        );
524    }
525
526    #[test]
527    fn profile_reply_advertises_host_control() {
528        use broadcast_common::Parse;
529        use dvb_ci::objects::resource_manager::{Profile, ProfileEnq};
530        use dvb_ci::resource::{HOST_CONTROL, RESOURCE_MANAGER};
531
532        let mut d = Driver::new(MockCaDevice::new([]));
533        d.init().unwrap();
534        feed(&mut d, vec![tags::C_T_C_REPLY, 0x01, 0x01]);
535        // Open RM, then the module enquires the host profile.
536        feed(
537            &mut d,
538            r_data(
539                1,
540                &ser(&dvb_ci::spdu::OpenSessionRequest {
541                    resource: RESOURCE_MANAGER,
542                }),
543            ),
544        );
545        // Module → profile_enq on the RM session → host replies with its profile.
546        feed(&mut d, r_apdu(RM_SESSION, &ser(&ProfileEnq)));
547
548        // Find the host's `profile` reply (tag 9F 80 11) in the written frames and
549        // confirm it lists HOST_CONTROL.
550        let want = dvb_ci::tag::PROFILE.to_bytes();
551        let found = d.device().ops.iter().any(|op| {
552            if let DeviceOp::Write(w) = op {
553                if let Some(pos) = w.windows(3).position(|x| x == want) {
554                    if let Ok(p) = Profile::parse(&w[pos..]) {
555                        return p.resources.contains(&HOST_CONTROL);
556                    }
557                }
558            }
559            false
560        });
561        assert!(found, "profile reply must advertise HOST_CONTROL");
562    }
563
564    #[test]
565    fn mmi_menu_answ_and_answ_are_byte_exact_on_the_mmi_session() {
566        use dvb_ci::objects::mmi_high::{Answ, AnswId, MenuAnsw};
567
568        let mut d = driver_with_sessions();
569        let mmi_nb = MMI_SESSION;
570
571        // menu_answ(choice_ref = 2): the driver method must put the exact dvb-ci
572        // MenuAnsw serialization on the wire, on the MMI session.
573        d.mmi_menu_answer(2).unwrap();
574        d.device_mut().inbound.push_back(sb());
575        d.pump(Duration::from_millis(10)).unwrap();
576        assert_apdu_on_session(&d, mmi_nb, &ser(&MenuAnsw { choice_ref: 2 }));
577
578        // answ(answer, "1234"): byte-exact Answ serialization on the MMI session.
579        d.mmi_enquiry_answer(b"1234").unwrap();
580        d.device_mut().inbound.push_back(sb());
581        d.pump(Duration::from_millis(10)).unwrap();
582        assert_apdu_on_session(
583            &d,
584            mmi_nb,
585            &ser(&Answ {
586                answ_id: AnswId::Answer,
587                text_chars: b"1234",
588            }),
589        );
590    }
591
592    /// Assert some host write carries `session_number(session_nb)` immediately
593    /// followed by the exact `apdu` bytes (byte-exact APDU on the right session).
594    fn assert_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) {
595        use dvb_ci::spdu::SessionNumber;
596        let mut want = ser(&SessionNumber { session_nb });
597        want.extend_from_slice(apdu);
598        let hit = d.device().ops.iter().any(|op| match op {
599            DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
600            _ => false,
601        });
602        assert!(
603            hit,
604            "expected APDU {apdu:02X?} on session {session_nb} (session-prefixed {want:02X?}) in writes"
605        );
606    }
607
608    #[test]
609    fn init_drives_reset_slotinfo_and_create_tc_to_device() {
610        let mut d = Driver::new(MockCaDevice::new([]));
611        d.init().unwrap();
612        let ops = &d.device().ops;
613        assert_eq!(ops[0], DeviceOp::Reset);
614        assert_eq!(ops[1], DeviceOp::SlotInfo);
615        assert!(matches!(&ops[2], DeviceOp::Write(w) if w[0] == tags::CREATE_T_C));
616    }
617
618    #[test]
619    fn reads_reply_then_polls_on_pump() {
620        // Script the module accepting the connection.
621        let dev = MockCaDevice::new([vec![tags::C_T_C_REPLY, 0x01, 0x01]]);
622        let mut d = Driver::new(dev);
623        d.init().unwrap();
624        // first pump reads the C_T_C_Reply (activates the connection)
625        assert!(d.pump(Duration::from_millis(100)).unwrap());
626        // next pump has nothing to read → ticks → emits a poll write
627        assert!(!d.pump(Duration::from_millis(100)).unwrap());
628        let last = d.device().ops.last().unwrap();
629        assert!(matches!(last, DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)));
630    }
631
632    // --- #726: CAM + card hot-plug notifications ---
633
634    #[test]
635    fn cam_insert_edge_emits_cam_present_once_and_redrives_handshake() {
636        let mut dev = MockCaDevice::new([]);
637        dev.slot = SlotInfo {
638            num: 0,
639            module_ready: false,
640            module_present: false,
641        };
642        let mut d = Driver::new(dev);
643        d.init().unwrap();
644        // The first-ever slot observation only establishes the baseline
645        // (absent) — it must not itself claim a hot-plug edge.
646        let notes = d.take_notifications();
647        assert!(
648            !notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
649            "baseline observation must not fire CamPresent, got {notes:?}"
650        );
651        let resets_before = d
652            .device()
653            .ops
654            .iter()
655            .filter(|o| **o == DeviceOp::Reset)
656            .count();
657
658        // Module physically inserted and ready.
659        d.device_mut().slot = SlotInfo {
660            num: 0,
661            module_ready: true,
662            module_present: true,
663        };
664        d.pump(Duration::from_millis(10)).unwrap();
665
666        let notes = d.take_notifications();
667        let cam_present_count = notes
668            .iter()
669            .filter(|n| **n == Notification::HotPlug(HotPlug::CamPresent))
670            .count();
671        assert_eq!(
672            cam_present_count, 1,
673            "expected exactly one CamPresent, got {notes:?}"
674        );
675        // Handshake re-driven: a fresh Reset, and the last write is CREATE_T_C.
676        let resets_after = d
677            .device()
678            .ops
679            .iter()
680            .filter(|o| **o == DeviceOp::Reset)
681            .count();
682        assert_eq!(
683            resets_after,
684            resets_before + 1,
685            "expected one fresh Reset on re-insert"
686        );
687        assert!(
688            matches!(d.device().ops.last(), Some(DeviceOp::Write(w)) if w[0] == tags::CREATE_T_C),
689            "expected the handshake re-driven (CREATE_T_C written), got {:?}",
690            d.device().ops.last()
691        );
692    }
693
694    #[test]
695    fn cam_remove_edge_emits_cam_removed_and_re_insert_re_handshakes() {
696        let mut d = driver_with_sessions();
697        d.take_notifications();
698
699        // Module physically removed.
700        d.device_mut().slot.module_present = false;
701        d.pump(Duration::from_millis(10)).unwrap();
702        let notes = d.take_notifications();
703        assert!(
704            notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
705            "expected CamRemoved, got {notes:?}"
706        );
707
708        // Session state was torn down: the MMI session from
709        // `driver_with_sessions` no longer exists on the fresh stack, so an
710        // answer to it now errors instead of silently going nowhere.
711        d.mmi_menu_answer(0).unwrap();
712        let notes = d.take_notifications();
713        assert!(
714            notes
715                .iter()
716                .any(|n| matches!(n, Notification::Error { .. })),
717            "expected no open MMI session after teardown, got {notes:?}"
718        );
719
720        // Re-insert: a fresh handshake starts (Reset + CamPresent).
721        let resets_before = d
722            .device()
723            .ops
724            .iter()
725            .filter(|o| **o == DeviceOp::Reset)
726            .count();
727        d.device_mut().slot.module_present = true;
728        d.device_mut().slot.module_ready = true;
729        d.pump(Duration::from_millis(10)).unwrap();
730        let notes = d.take_notifications();
731        assert!(
732            notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
733            "expected CamPresent on re-insert, got {notes:?}"
734        );
735        let resets_after = d
736            .device()
737            .ops
738            .iter()
739            .filter(|o| **o == DeviceOp::Reset)
740            .count();
741        assert_eq!(resets_after, resets_before + 1, "expected a fresh Reset");
742    }
743
744    #[test]
745    fn slot_status_unchanged_across_polls_emits_no_hotplug_notifications() {
746        let mut d = Driver::new(MockCaDevice::new([]));
747        d.init().unwrap();
748        d.take_notifications();
749
750        for _ in 0..5 {
751            d.pump(Duration::from_millis(10)).unwrap();
752        }
753        let notes = d.take_notifications();
754        assert!(
755            !notes.iter().any(|n| matches!(
756                n,
757                Notification::HotPlug(HotPlug::CamPresent | HotPlug::CamRemoved)
758            )),
759            "unchanged slot status must not emit hot-plug notifications, got {notes:?}"
760        );
761    }
762
763    #[test]
764    fn ca_info_caid_set_change_infers_card_inserted_then_changed() {
765        use dvb_ci::objects::ca_info::CaInfo;
766
767        let mut d = driver_with_sessions();
768        d.take_notifications();
769
770        // First ca_info: no CAIDs (baseline only, no notification).
771        feed(
772            &mut d,
773            r_apdu(
774                CA_SESSION,
775                &ser(&CaInfo {
776                    ca_system_ids: vec![],
777                }),
778            ),
779        );
780        let notes = d.take_notifications();
781        assert!(
782            !notes.iter().any(|n| matches!(
783                n,
784                Notification::HotPlug(
785                    HotPlug::CardInserted | HotPlug::CardChanged | HotPlug::CardRemoved
786                )
787            )),
788            "first ca_info must only establish the baseline, got {notes:?}"
789        );
790
791        // CAID set becomes populated: card inserted.
792        feed(
793            &mut d,
794            r_apdu(
795                CA_SESSION,
796                &ser(&CaInfo {
797                    ca_system_ids: vec![0x0B00],
798                }),
799            ),
800        );
801        let notes = d.take_notifications();
802        assert!(
803            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
804            "expected CardInserted, got {notes:?}"
805        );
806
807        // CAID set changes to a different non-empty set: card changed.
808        feed(
809            &mut d,
810            r_apdu(
811                CA_SESSION,
812                &ser(&CaInfo {
813                    ca_system_ids: vec![0x1800],
814                }),
815            ),
816        );
817        let notes = d.take_notifications();
818        assert!(
819            notes.contains(&Notification::HotPlug(HotPlug::CardChanged)),
820            "expected CardChanged, got {notes:?}"
821        );
822    }
823
824    #[test]
825    fn ca_pmt_reply_descrambling_transition_infers_card_present_then_removed() {
826        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
827
828        fn reply(ca_enable: Option<CaEnable>) -> CaPmtReply {
829            CaPmtReply {
830                program_number: 1,
831                version_number: 1,
832                current_next_indicator: true,
833                ca_enable,
834                streams: vec![],
835            }
836        }
837
838        let mut d = driver_with_sessions();
839        d.take_notifications();
840
841        // Baseline: descrambling not (yet) possible.
842        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
843        let notes = d.take_notifications();
844        assert!(
845            !notes.iter().any(|n| matches!(
846                n,
847                Notification::HotPlug(HotPlug::CardInserted | HotPlug::CardRemoved)
848            )),
849            "first ca_pmt_reply must only establish the baseline, got {notes:?}"
850        );
851
852        // false -> true: card-present inference.
853        feed(
854            &mut d,
855            r_apdu(CA_SESSION, &ser(&reply(Some(CaEnable::Possible)))),
856        );
857        let notes = d.take_notifications();
858        assert!(
859            notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
860            "expected CardInserted, got {notes:?}"
861        );
862
863        // true -> false: card removed.
864        feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
865        let notes = d.take_notifications();
866        assert!(
867            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
868            "expected CardRemoved, got {notes:?}"
869        );
870    }
871
872    #[test]
873    fn mmi_no_card_text_infers_card_removed() {
874        use dvb_ci::objects::mmi_high::Enq;
875
876        let mut d = driver_with_sessions();
877        d.take_notifications();
878
879        feed(
880            &mut d,
881            r_apdu(
882                MMI_SESSION,
883                &ser(&Enq {
884                    blind_answer: false,
885                    answer_text_length: 0,
886                    text_chars: b"NO CARD detected - please insert your smart card",
887                }),
888            ),
889        );
890
891        let notes = d.take_notifications();
892        assert!(
893            notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
894            "expected CardRemoved inferred from MMI 'no card' text, got {notes:?}"
895        );
896    }
897
898    #[test]
899    fn pump_hotplug_delivers_cam_present_via_closure_exactly_once() {
900        let mut dev = MockCaDevice::new([]);
901        dev.slot = SlotInfo {
902            num: 0,
903            module_ready: false,
904            module_present: false,
905        };
906        let mut d = Driver::new(dev);
907        d.init().unwrap();
908        d.take_notifications(); // drop the baseline observation
909
910        // Module physically inserted and ready.
911        d.device_mut().slot = SlotInfo {
912            num: 0,
913            module_ready: true,
914            module_present: true,
915        };
916
917        let mut seen = Vec::new();
918        d.pump_hotplug(Duration::from_millis(10), |hp| seen.push(hp))
919            .unwrap();
920
921        assert_eq!(
922            seen,
923            vec![HotPlug::CamPresent],
924            "expected the closure to receive HotPlug::CamPresent exactly once, got {seen:?}"
925        );
926    }
927}