1use 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
14const MMI_CARD_ABSENT_KEYWORDS: &[&str] = &[
19 "no card",
20 "insert card",
21 "insert smart card",
22 "card removed",
23 "please insert",
24];
25
26const MMI_CARD_PRESENT_KEYWORDS: &[&str] = &["entitlement", "card valid", "subscription active"];
31
32pub struct Driver<D: CaDevice> {
34 device: D,
35 stack: CiStack,
36 notifications: Vec<Notification>,
37 next_timer: Option<Duration>,
39 buf: Vec<u8>,
41 last_slot: Option<SlotInfo>,
48 last_caids: Option<BTreeSet<u16>>,
51 last_descrambling_ok: Option<bool>,
54}
55
56impl<D: CaDevice> Driver<D> {
57 #[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 pub fn device(&self) -> &D {
74 &self.device
75 }
76
77 pub fn device_mut(&mut self) -> &mut D {
80 &mut self.device
81 }
82
83 pub fn next_timer(&self) -> Option<Duration> {
85 self.next_timer
86 }
87
88 pub fn take_notifications(&mut self) -> Vec<Notification> {
90 core::mem::take(&mut self.notifications)
91 }
92
93 pub fn init(&mut self) -> io::Result<()> {
95 let actions = self.stack.handle(Event::Host(HostRequest::Init));
96 self.run(actions)
97 }
98
99 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 feed(&mut d, vec![tags::C_T_C_REPLY, 0x01, 0x01]);
440 feed(
442 &mut d,
443 r_data(
444 1,
445 &ser(&OpenSessionRequest {
446 resource: RESOURCE_MANAGER,
447 }),
448 ),
449 );
450 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 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 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(); 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 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 feed(
537 &mut d,
538 r_data(
539 1,
540 &ser(&dvb_ci::spdu::OpenSessionRequest {
541 resource: RESOURCE_MANAGER,
542 }),
543 ),
544 );
545 feed(&mut d, r_apdu(RM_SESSION, &ser(&ProfileEnq)));
547
548 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 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 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 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 let dev = MockCaDevice::new([vec![tags::C_T_C_REPLY, 0x01, 0x01]]);
622 let mut d = Driver::new(dev);
623 d.init().unwrap();
624 assert!(d.pump(Duration::from_millis(100)).unwrap());
626 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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(); 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}