1use 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
21const MMI_CARD_ABSENT_KEYWORDS: &[&str] = &[
26 "no card",
27 "insert card",
28 "insert smart card",
29 "card removed",
30 "please insert",
31];
32
33const MMI_CARD_PRESENT_KEYWORDS: &[&str] = &["entitlement", "card valid", "subscription active"];
38
39pub struct Driver<D: CaDevice> {
41 device: D,
42 stack: CiStack,
43 notifications: Vec<Notification>,
44 next_timer: Option<Duration>,
46 buf: Vec<u8>,
48 last_slot: Option<SlotInfo>,
55 last_caids: Option<BTreeSet<u16>>,
58 last_descrambling_ok: Option<bool>,
61 managed: ManagedCa,
64}
65
66impl<D: CaDevice> Driver<D> {
67 #[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 pub fn managed_ca(&self) -> &ManagedCa {
86 &self.managed
87 }
88
89 pub fn device(&self) -> &D {
91 &self.device
92 }
93
94 pub fn device_mut(&mut self) -> &mut D {
97 &mut self.device
98 }
99
100 pub fn next_timer(&self) -> Option<Duration> {
102 self.next_timer
103 }
104
105 pub fn take_notifications(&mut self) -> Vec<Notification> {
107 core::mem::take(&mut self.notifications)
108 }
109
110 pub fn init(&mut self) -> io::Result<()> {
112 let actions = self.stack.handle(Event::Host(HostRequest::Init));
113 self.run(actions)
114 }
115
116 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 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 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 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 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 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 let requery_bytes = build_ca_pmt(pmt, list_management, CaPmtCmdId::Query).to_bytes();
201 let mut pmt_raw = vec![0u8; pmt.serialized_len()];
206 let n = pmt
207 .serialize_into(&mut pmt_raw)
208 .expect("PmtSection::serialize_into on a freshly-sized buffer cannot fail");
209 pmt_raw.truncate(n);
210 self.send_ca_pmt(&built_bytes)?;
211 self.managed.record(
212 pmt.program_number,
213 managed::service_of(pmt, cmd_id, built_bytes, requery_bytes, pmt_raw),
214 );
215 Ok(())
216 }
217
218 pub fn remove_service(&mut self, program_number: u16) -> Result<(), CaError> {
232 let raw = self
233 .managed
234 .services()
235 .get(&program_number)
236 .map(|s| s.pmt_raw.clone());
237 let Some(raw) = raw else {
238 return Ok(());
239 };
240 self.remove_program(&raw)?;
241 self.managed.remove(program_number);
242 Ok(())
243 }
244
245 pub fn set_requery_interval(&mut self, interval: Duration) {
255 self.managed.set_requery_interval(interval);
256 }
257
258 pub fn set_cat(&mut self, cat: &CatSection<'_>) -> Result<(), CaError> {
273 let entries = cat.ca_descriptors().map_err(CaError::Cat)?;
274 self.managed.set_cat(&entries);
275 Ok(())
276 }
277
278 #[must_use]
282 pub fn emm_pids(&self) -> &[u16] {
283 self.managed.emm_pids()
284 }
285
286 #[must_use]
289 pub fn descramble_pids(&self) -> &[u16] {
290 self.managed.descramble_pids()
291 }
292
293 #[must_use]
298 pub fn ca_pids(&self) -> &[u16] {
299 self.managed.ca_pids()
300 }
301
302 #[must_use]
310 pub fn required_pids(&self) -> Vec<u16> {
311 self.managed.required_pids()
312 }
313
314 pub fn mmi_menu_answer(&mut self, choice_ref: u8) -> io::Result<()> {
316 let actions = self
317 .stack
318 .handle(Event::Host(HostRequest::MmiMenuAnswer(choice_ref)));
319 self.run(actions)
320 }
321
322 pub fn mmi_enquiry_answer(&mut self, text: &[u8]) -> io::Result<()> {
324 let actions = self
325 .stack
326 .handle(Event::Host(HostRequest::MmiEnquiryAnswer(text)));
327 self.run(actions)
328 }
329
330 pub fn mmi_cancel(&mut self) -> io::Result<()> {
332 let actions = self.stack.handle(Event::Host(HostRequest::MmiCancel));
333 self.run(actions)
334 }
335
336 pub fn enter_menu(&mut self) -> io::Result<()> {
339 let actions = self.stack.handle(Event::Host(HostRequest::EnterMenu));
340 self.run(actions)
341 }
342
343 pub fn pump(&mut self, timeout: Duration) -> io::Result<bool> {
352 self.run(vec![Action::QuerySlot])?;
353 if self.device.poll(timeout)? {
354 let n = self.device.read(&mut self.buf)?;
355 if n > 0 {
356 let frame = self.buf[..n].to_vec();
357 let actions = self.stack.handle(Event::Readable(&frame));
358 self.run(actions)?;
359 return Ok(true);
360 }
361 }
362 let actions = self.stack.handle(Event::Tick { elapsed: timeout });
363 self.run(actions)?;
364 self.requery_tick(timeout)?;
365 Ok(false)
366 }
367
368 fn requery_tick(&mut self, elapsed: Duration) -> io::Result<()> {
379 if !self.managed.tick(elapsed) {
380 return Ok(());
381 }
382 let ca_pmts: Vec<Vec<u8>> = self
383 .managed
384 .services()
385 .values()
386 .map(|s| s.requery_ca_pmt.clone())
387 .collect();
388 for ca_pmt in ca_pmts {
389 self.send_ca_pmt(&ca_pmt)?;
390 }
391 Ok(())
392 }
393
394 pub fn pump_with<F: FnMut(&Notification)>(
403 &mut self,
404 timeout: Duration,
405 mut handler: F,
406 ) -> io::Result<bool> {
407 let progressed = self.pump(timeout)?;
408 for n in self.take_notifications() {
409 handler(&n);
410 }
411 Ok(progressed)
412 }
413
414 pub fn pump_hotplug<F: FnMut(HotPlug)>(
418 &mut self,
419 timeout: Duration,
420 mut handler: F,
421 ) -> io::Result<bool> {
422 self.pump_with(timeout, |n| {
423 if let Some(h) = n.hotplug() {
424 handler(h);
425 }
426 })
427 }
428
429 fn run(&mut self, actions: Vec<Action>) -> io::Result<()> {
431 for action in actions {
432 match action {
433 Action::Write(bytes) => self.device.write(&bytes)?,
434 Action::Reset => self.device.reset()?,
435 Action::QuerySlot => {
436 let info = self.device.slot_info()?;
437 self.handle_slot_info(info)?;
438 }
439 Action::SetTimer { after } => self.next_timer = Some(after),
440 Action::Notify(n) => {
441 let inferred = self.infer_card(&n);
442 self.notifications.push(n);
443 self.notifications.extend(inferred);
444 }
445 }
446 }
447 Ok(())
448 }
449
450 fn handle_slot_info(&mut self, info: SlotInfo) -> io::Result<()> {
457 let prev = self.last_slot.replace(info);
458 match prev {
459 Some(prev) if !prev.module_present && info.module_present => {
460 self.notifications
461 .push(Notification::HotPlug(HotPlug::CamPresent));
462 self.reset_module_state();
463 let actions = self.stack.handle(Event::Host(HostRequest::Init));
467 self.run(actions)?;
468 }
469 Some(prev) if prev.module_present && !info.module_present => {
470 self.notifications
471 .push(Notification::HotPlug(HotPlug::CamRemoved));
472 self.reset_module_state();
473 }
474 _ => {}
475 }
476 Ok(())
477 }
478
479 fn reset_module_state(&mut self) {
489 self.stack = CiStack::new();
490 self.next_timer = None;
491 self.last_caids = None;
492 self.last_descrambling_ok = None;
493 self.managed.clear();
494 }
495
496 fn infer_card(&mut self, note: &Notification) -> Vec<Notification> {
503 match note {
504 Notification::CaInfo { ca_system_ids } => {
505 let new_set: BTreeSet<u16> = ca_system_ids.iter().copied().collect();
506 let mut out = Vec::new();
507 if let Some(prev) = &self.last_caids {
508 if prev.is_empty() && !new_set.is_empty() {
509 out.push(Notification::HotPlug(HotPlug::CardInserted));
510 } else if !prev.is_empty() && new_set.is_empty() {
511 out.push(Notification::HotPlug(HotPlug::CardRemoved));
512 } else if !prev.is_empty() && !new_set.is_empty() && *prev != new_set {
513 out.push(Notification::HotPlug(HotPlug::CardChanged));
514 }
515 }
516 self.managed.set_cam_caids(new_set.clone());
520 self.last_caids = Some(new_set);
521 out
522 }
523 Notification::CaPmtReply {
524 program_number,
525 ca_enable,
526 descrambling_ok,
527 } => {
528 let mut out = Vec::new();
529 if let Some(prev) = self.last_descrambling_ok {
530 if !prev && *descrambling_ok {
531 out.push(Notification::HotPlug(HotPlug::CardInserted));
532 } else if prev && !*descrambling_ok {
533 out.push(Notification::HotPlug(HotPlug::CardRemoved));
534 }
535 }
536 self.last_descrambling_ok = Some(*descrambling_ok);
537 if let Some((v, ok)) =
541 self.managed
542 .record_reply(*program_number, *ca_enable, *descrambling_ok)
543 {
544 out.push(Notification::Entitlement {
545 program_number: *program_number,
546 ca_enable: v,
547 descrambling_ok: ok,
548 });
549 }
550 out
551 }
552 Notification::Mmi(ev) => match Self::mmi_text(ev) {
553 Some(text) => {
554 let lower = text.to_lowercase();
555 if MMI_CARD_ABSENT_KEYWORDS.iter().any(|k| lower.contains(k)) {
556 vec![Notification::HotPlug(HotPlug::CardRemoved)]
557 } else if MMI_CARD_PRESENT_KEYWORDS.iter().any(|k| lower.contains(k)) {
558 vec![Notification::HotPlug(HotPlug::CardInserted)]
559 } else {
560 Vec::new()
561 }
562 }
563 None => Vec::new(),
564 },
565 _ => Vec::new(),
566 }
567 }
568
569 fn mmi_text(ev: &MmiEvent) -> Option<String> {
573 match ev {
574 MmiEvent::Menu(m) | MmiEvent::List(m) => {
575 let mut s = format!("{} {} {}", m.title, m.subtitle, m.bottom);
576 for choice in &m.choices {
577 s.push(' ');
578 s.push_str(choice);
579 }
580 Some(s)
581 }
582 MmiEvent::Enquiry { prompt, .. } => Some(prompt.clone()),
583 MmiEvent::Close => None,
584 }
585 }
586}
587
588#[cfg(test)]
589pub(crate) mod tests {
590 use super::*;
591 use crate::device::{DeviceOp, MockCaDevice};
592 use crate::event::{HostControlEvent, HotPlug, Notification};
593 use broadcast_common::Serialize;
594 use dvb_ci::tpdu::tags;
595
596 pub(crate) fn ser<S: Serialize>(s: &S) -> Vec<u8> {
597 let mut b = vec![0u8; s.serialized_len()];
598 match s.serialize_into(&mut b) {
599 Ok(n) => b.truncate(n),
600 Err(_) => b.clear(),
601 }
602 b
603 }
604
605 fn r_data(tcid: u8, spdu: &[u8]) -> Vec<u8> {
608 use dvb_ci::tpdu::{SbValue, tags as tpdu_tags};
609 let mut v = vec![tpdu_tags::DATA_LAST, (1 + spdu.len()) as u8, tcid];
610 v.extend_from_slice(spdu);
611 v.extend_from_slice(&[tpdu_tags::SB, 0x02, tcid, SbValue::new(false).0]);
612 v
613 }
614
615 pub(crate) fn r_apdu(session_nb: u16, apdu: &[u8]) -> Vec<u8> {
618 use dvb_ci::spdu::SessionNumber;
619 let mut spdu = ser(&SessionNumber { session_nb });
620 spdu.extend_from_slice(apdu);
621 r_data(1, &spdu)
622 }
623
624 pub(crate) fn sb() -> Vec<u8> {
627 use dvb_ci::tpdu::{SbValue, tags as tpdu_tags};
628 vec![tpdu_tags::SB, 0x02, 0x01, SbValue::new(false).0]
629 }
630
631 pub(crate) fn feed(d: &mut Driver<MockCaDevice>, frame: Vec<u8>) {
634 d.device_mut().inbound.push_back(frame);
635 d.pump(Duration::from_millis(10)).unwrap();
636 for _ in 0..8 {
637 d.device_mut().inbound.push_back(sb());
638 d.pump(Duration::from_millis(10)).unwrap();
639 }
640 }
641
642 pub(crate) fn driver_with_sessions() -> Driver<MockCaDevice> {
646 use dvb_ci::objects::resource_manager::Profile;
647 use dvb_ci::resource::{
648 APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, HOST_CONTROL, MMI,
649 RESOURCE_MANAGER,
650 };
651 use dvb_ci::spdu::{CreateSessionResponse, OpenSessionRequest, SessionStatus};
652
653 let mut d = Driver::new(MockCaDevice::new([]));
654 d.init().unwrap();
655 feed(&mut d, vec![tags::C_T_C_REPLY, 0x01, 0x01]);
657 feed(
659 &mut d,
660 r_data(
661 1,
662 &ser(&OpenSessionRequest {
663 resource: RESOURCE_MANAGER,
664 }),
665 ),
666 );
667 feed(
670 &mut d,
671 r_apdu(
672 1,
673 &ser(&Profile {
674 resources: vec![
675 APPLICATION_INFORMATION,
676 CONDITIONAL_ACCESS_SUPPORT,
677 MMI,
678 HOST_CONTROL,
679 ],
680 }),
681 ),
682 );
683 for (nb, res) in [
685 (2u16, APPLICATION_INFORMATION),
686 (3, CONDITIONAL_ACCESS_SUPPORT),
687 (4, MMI),
688 (5, HOST_CONTROL),
689 ] {
690 feed(
691 &mut d,
692 r_data(
693 1,
694 &ser(&CreateSessionResponse {
695 status: SessionStatus::Ok,
696 resource: res,
697 session_nb: nb,
698 }),
699 ),
700 );
701 }
702 d
703 }
704
705 const RM_SESSION: u16 = 1;
709 pub(crate) const CA_SESSION: u16 = 3;
710 const MMI_SESSION: u16 = 4;
711 const HOST_CONTROL_SESSION: u16 = 5;
712
713 #[test]
714 fn host_control_tune_apdu_surfaces_notification_via_driver() {
715 use dvb_ci::objects::host_control::Tune;
716
717 let mut d = driver_with_sessions();
718 let hc_nb = HOST_CONTROL_SESSION;
719 d.take_notifications(); let tune = Tune {
723 network_id: 0x1122,
724 original_network_id: 0x3344,
725 transport_stream_id: 0x5566,
726 service_id: 0x7788,
727 };
728 feed(&mut d, r_apdu(hc_nb, &ser(&tune)));
729
730 let notes = d.take_notifications();
732 assert!(
733 notes.contains(&Notification::HostControl(HostControlEvent::Tune {
734 network_id: 0x1122,
735 original_network_id: 0x3344,
736 transport_stream_id: 0x5566,
737 service_id: 0x7788,
738 })),
739 "expected HostControl(Tune) notification, got {notes:?}"
740 );
741 }
742
743 #[test]
744 fn profile_reply_advertises_host_control() {
745 use broadcast_common::Parse;
746 use dvb_ci::objects::resource_manager::{Profile, ProfileEnq};
747 use dvb_ci::resource::{HOST_CONTROL, RESOURCE_MANAGER};
748
749 let mut d = Driver::new(MockCaDevice::new([]));
750 d.init().unwrap();
751 feed(&mut d, vec![tags::C_T_C_REPLY, 0x01, 0x01]);
752 feed(
754 &mut d,
755 r_data(
756 1,
757 &ser(&dvb_ci::spdu::OpenSessionRequest {
758 resource: RESOURCE_MANAGER,
759 }),
760 ),
761 );
762 feed(&mut d, r_apdu(RM_SESSION, &ser(&ProfileEnq)));
764
765 let want = dvb_ci::tag::PROFILE.to_bytes();
768 let found = d.device().ops.iter().any(|op| {
769 if let DeviceOp::Write(w) = op {
770 if let Some(pos) = w.windows(3).position(|x| x == want) {
771 if let Ok(p) = Profile::parse(&w[pos..]) {
772 return p.resources.contains(&HOST_CONTROL);
773 }
774 }
775 }
776 false
777 });
778 assert!(found, "profile reply must advertise HOST_CONTROL");
779 }
780
781 #[test]
782 fn mmi_menu_answ_and_answ_are_byte_exact_on_the_mmi_session() {
783 use dvb_ci::objects::mmi_high::{Answ, AnswId, MenuAnsw};
784
785 let mut d = driver_with_sessions();
786 let mmi_nb = MMI_SESSION;
787
788 d.mmi_menu_answer(2).unwrap();
791 d.device_mut().inbound.push_back(sb());
792 d.pump(Duration::from_millis(10)).unwrap();
793 assert_apdu_on_session(&d, mmi_nb, &ser(&MenuAnsw { choice_ref: 2 }));
794
795 d.mmi_enquiry_answer(b"1234").unwrap();
797 d.device_mut().inbound.push_back(sb());
798 d.pump(Duration::from_millis(10)).unwrap();
799 assert_apdu_on_session(
800 &d,
801 mmi_nb,
802 &ser(&Answ {
803 answ_id: AnswId::Answer,
804 text_chars: b"1234",
805 }),
806 );
807 }
808
809 fn assert_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) {
812 use dvb_ci::spdu::SessionNumber;
813 let mut want = ser(&SessionNumber { session_nb });
814 want.extend_from_slice(apdu);
815 let hit = d.device().ops.iter().any(|op| match op {
816 DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
817 _ => false,
818 });
819 assert!(
820 hit,
821 "expected APDU {apdu:02X?} on session {session_nb} (session-prefixed {want:02X?}) in writes"
822 );
823 }
824
825 fn count_apdu_on_session(d: &Driver<MockCaDevice>, session_nb: u16, apdu: &[u8]) -> usize {
829 use dvb_ci::spdu::SessionNumber;
830 let mut want = ser(&SessionNumber { session_nb });
831 want.extend_from_slice(apdu);
832 d.device()
833 .ops
834 .iter()
835 .filter(|op| match op {
836 DeviceOp::Write(w) => w.windows(want.len()).any(|x| x == want.as_slice()),
837 _ => false,
838 })
839 .count()
840 }
841
842 #[test]
843 fn init_drives_reset_slotinfo_and_create_tc_to_device() {
844 let mut d = Driver::new(MockCaDevice::new([]));
845 d.init().unwrap();
846 let ops = &d.device().ops;
847 assert_eq!(ops[0], DeviceOp::Reset);
848 assert_eq!(ops[1], DeviceOp::SlotInfo);
849 assert!(matches!(&ops[2], DeviceOp::Write(w) if w[0] == tags::CREATE_T_C));
850 }
851
852 #[test]
853 fn reads_reply_then_polls_on_pump() {
854 let dev = MockCaDevice::new([vec![tags::C_T_C_REPLY, 0x01, 0x01]]);
856 let mut d = Driver::new(dev);
857 d.init().unwrap();
858 assert!(d.pump(Duration::from_millis(100)).unwrap());
860 assert!(!d.pump(Duration::from_millis(100)).unwrap());
862 let last = d.device().ops.last().unwrap();
863 assert!(matches!(last, DeviceOp::Write(w) if w.first() == Some(&tags::DATA_LAST)));
864 }
865
866 #[test]
869 fn cam_insert_edge_emits_cam_present_once_and_redrives_handshake() {
870 let mut dev = MockCaDevice::new([]);
871 dev.slot = SlotInfo {
872 num: 0,
873 module_ready: false,
874 module_present: false,
875 };
876 let mut d = Driver::new(dev);
877 d.init().unwrap();
878 let notes = d.take_notifications();
881 assert!(
882 !notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
883 "baseline observation must not fire CamPresent, got {notes:?}"
884 );
885 let resets_before = d
886 .device()
887 .ops
888 .iter()
889 .filter(|o| **o == DeviceOp::Reset)
890 .count();
891
892 d.device_mut().slot = SlotInfo {
894 num: 0,
895 module_ready: true,
896 module_present: true,
897 };
898 d.pump(Duration::from_millis(10)).unwrap();
899
900 let notes = d.take_notifications();
901 let cam_present_count = notes
902 .iter()
903 .filter(|n| **n == Notification::HotPlug(HotPlug::CamPresent))
904 .count();
905 assert_eq!(
906 cam_present_count, 1,
907 "expected exactly one CamPresent, got {notes:?}"
908 );
909 let resets_after = d
911 .device()
912 .ops
913 .iter()
914 .filter(|o| **o == DeviceOp::Reset)
915 .count();
916 assert_eq!(
917 resets_after,
918 resets_before + 1,
919 "expected one fresh Reset on re-insert"
920 );
921 assert!(
922 matches!(d.device().ops.last(), Some(DeviceOp::Write(w)) if w[0] == tags::CREATE_T_C),
923 "expected the handshake re-driven (CREATE_T_C written), got {:?}",
924 d.device().ops.last()
925 );
926 }
927
928 #[test]
929 fn cam_remove_edge_emits_cam_removed_and_re_insert_re_handshakes() {
930 let mut d = driver_with_sessions();
931 d.take_notifications();
932
933 d.device_mut().slot.module_present = false;
935 d.pump(Duration::from_millis(10)).unwrap();
936 let notes = d.take_notifications();
937 assert!(
938 notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
939 "expected CamRemoved, got {notes:?}"
940 );
941
942 d.mmi_menu_answer(0).unwrap();
946 let notes = d.take_notifications();
947 assert!(
948 notes
949 .iter()
950 .any(|n| matches!(n, Notification::Error { .. })),
951 "expected no open MMI session after teardown, got {notes:?}"
952 );
953
954 let resets_before = d
956 .device()
957 .ops
958 .iter()
959 .filter(|o| **o == DeviceOp::Reset)
960 .count();
961 d.device_mut().slot.module_present = true;
962 d.device_mut().slot.module_ready = true;
963 d.pump(Duration::from_millis(10)).unwrap();
964 let notes = d.take_notifications();
965 assert!(
966 notes.contains(&Notification::HotPlug(HotPlug::CamPresent)),
967 "expected CamPresent on re-insert, got {notes:?}"
968 );
969 let resets_after = d
970 .device()
971 .ops
972 .iter()
973 .filter(|o| **o == DeviceOp::Reset)
974 .count();
975 assert_eq!(resets_after, resets_before + 1, "expected a fresh Reset");
976 }
977
978 #[test]
979 fn slot_status_unchanged_across_polls_emits_no_hotplug_notifications() {
980 let mut d = Driver::new(MockCaDevice::new([]));
981 d.init().unwrap();
982 d.take_notifications();
983
984 for _ in 0..5 {
985 d.pump(Duration::from_millis(10)).unwrap();
986 }
987 let notes = d.take_notifications();
988 assert!(
989 !notes.iter().any(|n| matches!(
990 n,
991 Notification::HotPlug(HotPlug::CamPresent | HotPlug::CamRemoved)
992 )),
993 "unchanged slot status must not emit hot-plug notifications, got {notes:?}"
994 );
995 }
996
997 #[test]
998 fn ca_info_caid_set_change_infers_card_inserted_then_changed() {
999 use dvb_ci::objects::ca_info::CaInfo;
1000
1001 let mut d = driver_with_sessions();
1002 d.take_notifications();
1003
1004 feed(
1006 &mut d,
1007 r_apdu(
1008 CA_SESSION,
1009 &ser(&CaInfo {
1010 ca_system_ids: vec![],
1011 }),
1012 ),
1013 );
1014 let notes = d.take_notifications();
1015 assert!(
1016 !notes.iter().any(|n| matches!(
1017 n,
1018 Notification::HotPlug(
1019 HotPlug::CardInserted | HotPlug::CardChanged | HotPlug::CardRemoved
1020 )
1021 )),
1022 "first ca_info must only establish the baseline, got {notes:?}"
1023 );
1024
1025 feed(
1027 &mut d,
1028 r_apdu(
1029 CA_SESSION,
1030 &ser(&CaInfo {
1031 ca_system_ids: vec![0x0B00],
1032 }),
1033 ),
1034 );
1035 let notes = d.take_notifications();
1036 assert!(
1037 notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1038 "expected CardInserted, got {notes:?}"
1039 );
1040
1041 feed(
1043 &mut d,
1044 r_apdu(
1045 CA_SESSION,
1046 &ser(&CaInfo {
1047 ca_system_ids: vec![0x1800],
1048 }),
1049 ),
1050 );
1051 let notes = d.take_notifications();
1052 assert!(
1053 notes.contains(&Notification::HotPlug(HotPlug::CardChanged)),
1054 "expected CardChanged, got {notes:?}"
1055 );
1056 }
1057
1058 #[test]
1059 fn ca_pmt_reply_descrambling_transition_infers_card_present_then_removed() {
1060 use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1061
1062 fn reply(ca_enable: Option<CaEnable>) -> CaPmtReply {
1063 CaPmtReply {
1064 program_number: 1,
1065 version_number: 1,
1066 current_next_indicator: true,
1067 ca_enable,
1068 streams: vec![],
1069 }
1070 }
1071
1072 let mut d = driver_with_sessions();
1073 d.take_notifications();
1074
1075 feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1077 let notes = d.take_notifications();
1078 assert!(
1079 !notes.iter().any(|n| matches!(
1080 n,
1081 Notification::HotPlug(HotPlug::CardInserted | HotPlug::CardRemoved)
1082 )),
1083 "first ca_pmt_reply must only establish the baseline, got {notes:?}"
1084 );
1085
1086 feed(
1088 &mut d,
1089 r_apdu(CA_SESSION, &ser(&reply(Some(CaEnable::Possible)))),
1090 );
1091 let notes = d.take_notifications();
1092 assert!(
1093 notes.contains(&Notification::HotPlug(HotPlug::CardInserted)),
1094 "expected CardInserted, got {notes:?}"
1095 );
1096
1097 feed(&mut d, r_apdu(CA_SESSION, &ser(&reply(None))));
1099 let notes = d.take_notifications();
1100 assert!(
1101 notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1102 "expected CardRemoved, got {notes:?}"
1103 );
1104 }
1105
1106 #[test]
1107 fn ca_pmt_reply_surfaces_typed_ca_enable() {
1108 use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
1109
1110 let mut d = driver_with_sessions();
1111 d.take_notifications();
1112
1113 feed(
1116 &mut d,
1117 r_apdu(
1118 CA_SESSION,
1119 &ser(&CaPmtReply {
1120 program_number: 7,
1121 version_number: 1,
1122 current_next_indicator: true,
1123 ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1124 streams: vec![],
1125 }),
1126 ),
1127 );
1128 let notes = d.take_notifications();
1129 assert!(
1130 notes.contains(&Notification::CaPmtReply {
1131 program_number: 7,
1132 ca_enable: Some(CaEnable::PossibleTechnicalDialogue),
1133 descrambling_ok: true,
1134 }),
1135 "expected typed ca_enable on CaPmtReply, got {notes:?}"
1136 );
1137 }
1138
1139 #[test]
1140 fn ca_pmt_reply_flag_clear_surfaces_none() {
1141 use dvb_ci::objects::ca_pmt_reply::CaPmtReply;
1142
1143 let mut d = driver_with_sessions();
1144 d.take_notifications();
1145
1146 feed(
1149 &mut d,
1150 r_apdu(
1151 CA_SESSION,
1152 &ser(&CaPmtReply {
1153 program_number: 7,
1154 version_number: 1,
1155 current_next_indicator: true,
1156 ca_enable: None,
1157 streams: vec![],
1158 }),
1159 ),
1160 );
1161 let notes = d.take_notifications();
1162 assert!(
1163 notes.contains(&Notification::CaPmtReply {
1164 program_number: 7,
1165 ca_enable: None,
1166 descrambling_ok: false,
1167 }),
1168 "expected ca_enable None on flag-clear CaPmtReply, got {notes:?}"
1169 );
1170 }
1171
1172 #[test]
1173 fn mmi_no_card_text_infers_card_removed() {
1174 use dvb_ci::objects::mmi_high::Enq;
1175
1176 let mut d = driver_with_sessions();
1177 d.take_notifications();
1178
1179 feed(
1180 &mut d,
1181 r_apdu(
1182 MMI_SESSION,
1183 &ser(&Enq {
1184 blind_answer: false,
1185 answer_text_length: 0,
1186 text_chars: b"NO CARD detected - please insert your smart card",
1187 }),
1188 ),
1189 );
1190
1191 let notes = d.take_notifications();
1192 assert!(
1193 notes.contains(&Notification::HotPlug(HotPlug::CardRemoved)),
1194 "expected CardRemoved inferred from MMI 'no card' text, got {notes:?}"
1195 );
1196 }
1197
1198 #[test]
1199 fn pump_hotplug_delivers_cam_present_via_closure_exactly_once() {
1200 let mut dev = MockCaDevice::new([]);
1201 dev.slot = SlotInfo {
1202 num: 0,
1203 module_ready: false,
1204 module_present: false,
1205 };
1206 let mut d = Driver::new(dev);
1207 d.init().unwrap();
1208 d.take_notifications(); d.device_mut().slot = SlotInfo {
1212 num: 0,
1213 module_ready: true,
1214 module_present: true,
1215 };
1216
1217 let mut seen = Vec::new();
1218 d.pump_hotplug(Duration::from_millis(10), |hp| seen.push(hp))
1219 .unwrap();
1220
1221 assert_eq!(
1222 seen,
1223 vec![HotPlug::CamPresent],
1224 "expected the closure to receive HotPlug::CamPresent exactly once, got {seen:?}"
1225 );
1226 }
1227
1228 pub(crate) fn ca_descriptor(ca_system_id: u16, pid: u16) -> [u8; 6] {
1233 [
1234 0x09,
1235 0x04,
1236 (ca_system_id >> 8) as u8,
1237 ca_system_id as u8,
1238 0xE0 | ((pid >> 8) as u8 & 0x1F),
1239 pid as u8,
1240 ]
1241 }
1242
1243 pub(crate) fn build_ca_pmt_fixture(program_number: u16) -> Vec<u8> {
1260 const VIACCESS: u16 = 0x0500;
1261 let prog_ca = ca_descriptor(VIACCESS, 0x0064);
1262 let es0_ca = ca_descriptor(VIACCESS, 0x0065);
1263
1264 let mut body = Vec::new();
1265 body.push(0x02); body.push(0); body.push(0);
1268 body.extend_from_slice(&program_number.to_be_bytes());
1269 body.push(0xC3); body.push(0x00); body.push(0x00); body.push(0xE0 | 0x01); body.push(0x00);
1274 body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1275 body.push(prog_ca.len() as u8);
1276 body.extend_from_slice(&prog_ca);
1277 body.push(0x1B);
1279 body.push(0xE0 | 0x01);
1280 body.push(0x00);
1281 body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1282 body.push(es0_ca.len() as u8);
1283 body.extend_from_slice(&es0_ca);
1284 body.push(0x0F);
1286 body.push(0xE0 | 0x01);
1287 body.push(0x01);
1288 body.push(0xF0);
1289 body.push(0x00);
1290
1291 let section_length = body.len() - 3 + 4;
1292 body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1293 body[2] = section_length as u8;
1294 let crc = broadcast_common::crc32_mpeg2::compute(&body);
1295 body.extend_from_slice(&crc.to_be_bytes());
1296 body
1297 }
1298
1299 pub(crate) fn build_ca_pmt_fixture_dedicated_pcr(program_number: u16) -> Vec<u8> {
1306 const VIACCESS: u16 = 0x0500;
1307 let prog_ca = ca_descriptor(VIACCESS, 0x0064);
1308 let es0_ca = ca_descriptor(VIACCESS, 0x0065);
1309
1310 let mut body = Vec::new();
1311 body.push(0x02); body.push(0); body.push(0);
1314 body.extend_from_slice(&program_number.to_be_bytes());
1315 body.push(0xC3); body.push(0x00); body.push(0x00); body.push(0xE0); body.push(0xFF); body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1321 body.push(prog_ca.len() as u8);
1322 body.extend_from_slice(&prog_ca);
1323 body.push(0x1B);
1325 body.push(0xE0 | 0x01);
1326 body.push(0x00);
1327 body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1328 body.push(es0_ca.len() as u8);
1329 body.extend_from_slice(&es0_ca);
1330 body.push(0x0F);
1332 body.push(0xE0 | 0x01);
1333 body.push(0x01);
1334 body.push(0xF0);
1335 body.push(0x00);
1336
1337 let section_length = body.len() - 3 + 4;
1338 body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1339 body[2] = section_length as u8;
1340 let crc = broadcast_common::crc32_mpeg2::compute(&body);
1341 body.extend_from_slice(&crc.to_be_bytes());
1342 body
1343 }
1344
1345 pub(crate) fn build_clear_pmt_fixture(program_number: u16) -> Vec<u8> {
1349 let mut body = Vec::new();
1350 body.push(0x02);
1351 body.push(0);
1352 body.push(0);
1353 body.extend_from_slice(&program_number.to_be_bytes());
1354 body.push(0xC3);
1355 body.push(0x00);
1356 body.push(0x00);
1357 body.push(0xE0 | 0x01);
1358 body.push(0x00);
1359 body.push(0xF0); body.push(0x00);
1361 body.push(0x1B);
1363 body.push(0xE0 | 0x01);
1364 body.push(0x00);
1365 body.push(0xF0);
1366 body.push(0x00);
1367
1368 let section_length = body.len() - 3 + 4;
1369 body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1370 body[2] = section_length as u8;
1371 let crc = broadcast_common::crc32_mpeg2::compute(&body);
1372 body.extend_from_slice(&crc.to_be_bytes());
1373 body
1374 }
1375
1376 #[test]
1377 fn add_service_builds_and_sends_ca_pmt_matching_builder_oracle() {
1378 use broadcast_common::Parse;
1379
1380 let mut d = driver_with_sessions();
1381 d.take_notifications();
1382
1383 let pmt_bytes = build_ca_pmt_fixture(1546);
1384 let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1385
1386 d.add_service(&pmt).unwrap();
1387 d.device_mut().inbound.push_back(sb());
1388 d.pump(Duration::from_millis(10)).unwrap();
1389
1390 let expected =
1394 build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
1395 assert_apdu_on_session(&d, CA_SESSION, &expected);
1396
1397 let svc = d
1399 .managed_ca()
1400 .services()
1401 .get(&1546)
1402 .expect("program_number 1546 must be tracked after add_service");
1403 assert_eq!(svc.es_pids, vec![0x0100, 0x0101]);
1404 assert_eq!(svc.ca_pids, vec![0x0064, 0x0065]);
1405 assert_eq!(svc.cmd, CaPmtCmdId::OkDescrambling);
1406 assert_eq!(svc.last_ca_enable, None);
1407 }
1408
1409 #[test]
1410 fn add_service_rejects_pmt_without_ca_descriptor() {
1411 use broadcast_common::Parse;
1412
1413 let mut d = driver_with_sessions();
1414 let pmt_bytes = build_clear_pmt_fixture(999);
1415 let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1416
1417 let err = d.add_service(&pmt).unwrap_err();
1418 assert!(
1419 matches!(
1420 err,
1421 CaError::NoCaDescriptor {
1422 program_number: 999
1423 }
1424 ),
1425 "expected NoCaDescriptor{{program_number: 999}}, got {err:?}"
1426 );
1427 assert!(
1428 d.managed_ca().services().is_empty(),
1429 "a rejected PMT must not be recorded"
1430 );
1431 }
1432
1433 #[test]
1434 fn add_service_second_call_uses_add_list_management() {
1435 use broadcast_common::Parse;
1436
1437 let mut d = driver_with_sessions();
1438 d.take_notifications();
1439
1440 let pmt1_bytes = build_ca_pmt_fixture(1546);
1441 let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1442 d.add_service(&pmt1).unwrap();
1443 d.device_mut().inbound.push_back(sb());
1444 d.pump(Duration::from_millis(10)).unwrap();
1445
1446 let pmt2_bytes = build_ca_pmt_fixture(1547);
1447 let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1448 d.add_service(&pmt2).unwrap();
1449 d.device_mut().inbound.push_back(sb());
1450 d.pump(Duration::from_millis(10)).unwrap();
1451
1452 let expected2 =
1454 build_ca_pmt(&pmt2, CaPmtListManagement::Add, CaPmtCmdId::OkDescrambling).to_bytes();
1455 assert_apdu_on_session(&d, CA_SESSION, &expected2);
1456
1457 assert_eq!(d.managed_ca().services().len(), 2);
1458 }
1459
1460 pub(crate) fn build_cat_fixture(descriptors: &[u8]) -> Vec<u8> {
1470 const EXTENSION_HEADER_LEN: u16 = 5;
1471 const CRC_LEN: u16 = 4;
1472 let section_length = EXTENSION_HEADER_LEN + descriptors.len() as u16 + CRC_LEN;
1473 let mut v = Vec::new();
1474 v.push(0x01); v.push(0xB0 | ((section_length >> 8) as u8 & 0x0F));
1476 v.push((section_length & 0xFF) as u8);
1477 v.extend_from_slice(&[0xFF, 0xFF]); v.push(0xC1); v.push(0x00); v.push(0x00); v.extend_from_slice(descriptors);
1482 let crc = broadcast_common::crc32_mpeg2::compute(&v);
1483 v.extend_from_slice(&crc.to_be_bytes());
1484 v
1485 }
1486
1487 #[test]
1488 fn set_cat_computes_emm_pids_as_cat_inter_ca_info_caids() {
1489 use broadcast_common::Parse;
1490 use dvb_ci::objects::ca_info::CaInfo;
1491 use dvb_si::tables::cat::CatSection;
1492
1493 let mut d = driver_with_sessions();
1494 d.take_notifications();
1495
1496 feed(
1498 &mut d,
1499 r_apdu(
1500 CA_SESSION,
1501 &ser(&CaInfo {
1502 ca_system_ids: vec![0x0648, 0x0100],
1503 }),
1504 ),
1505 );
1506 d.take_notifications();
1507
1508 let mut descriptors = Vec::new();
1511 descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
1512 descriptors.extend_from_slice(&ca_descriptor(0x0500, 0x1FF1));
1513 let cat_bytes = build_cat_fixture(&descriptors);
1514 let cat = CatSection::parse(&cat_bytes).unwrap();
1515
1516 d.set_cat(&cat).unwrap();
1517
1518 assert_eq!(
1519 d.emm_pids(),
1520 &[0x1FF0],
1521 "0x0500 -> 0x1FF1 must be excluded: the CAM never advertised CAID 0x0500"
1522 );
1523 }
1524
1525 #[test]
1526 fn set_cat_before_ca_info_is_not_an_error_and_recomputes_once_ca_info_arrives() {
1527 use broadcast_common::Parse;
1528 use dvb_ci::objects::ca_info::CaInfo;
1529 use dvb_si::tables::cat::CatSection;
1530
1531 let mut d = driver_with_sessions();
1532 d.take_notifications();
1533
1534 let mut descriptors = Vec::new();
1535 descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
1536 descriptors.extend_from_slice(&ca_descriptor(0x0500, 0x1FF1));
1537 let cat_bytes = build_cat_fixture(&descriptors);
1538 let cat = CatSection::parse(&cat_bytes).unwrap();
1539
1540 d.set_cat(&cat).unwrap();
1543 assert!(
1544 d.emm_pids().is_empty(),
1545 "emm_pids must be empty before any ca_info arrives, got {:?}",
1546 d.emm_pids()
1547 );
1548
1549 feed(
1552 &mut d,
1553 r_apdu(
1554 CA_SESSION,
1555 &ser(&CaInfo {
1556 ca_system_ids: vec![0x0648, 0x0100],
1557 }),
1558 ),
1559 );
1560 d.take_notifications();
1561
1562 assert_eq!(
1563 d.emm_pids(),
1564 &[0x1FF0],
1565 "emm_pids must recompute once ca_info arrives, using the CAT stored by the earlier set_cat"
1566 );
1567 }
1568
1569 #[test]
1575 fn set_cat_emm_pids_dedups_when_two_caids_share_one_emm_pid() {
1576 use broadcast_common::Parse;
1577 use dvb_ci::objects::ca_info::CaInfo;
1578 use dvb_si::tables::cat::CatSection;
1579
1580 let mut d = driver_with_sessions();
1581 d.take_notifications();
1582
1583 feed(
1585 &mut d,
1586 r_apdu(
1587 CA_SESSION,
1588 &ser(&CaInfo {
1589 ca_system_ids: vec![0x0648, 0x0100],
1590 }),
1591 ),
1592 );
1593 d.take_notifications();
1594
1595 let mut descriptors = Vec::new();
1597 descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
1598 descriptors.extend_from_slice(&ca_descriptor(0x0100, 0x1FF0));
1599 let cat_bytes = build_cat_fixture(&descriptors);
1600 let cat = CatSection::parse(&cat_bytes).unwrap();
1601
1602 d.set_cat(&cat).unwrap();
1603
1604 assert_eq!(
1605 d.emm_pids(),
1606 &[0x1FF0],
1607 "0x1FF0 must appear exactly once even though two CAM-advertised CAIDs map to it, got {:?}",
1608 d.emm_pids()
1609 );
1610 }
1611
1612 fn build_ca_pmt_fixture_distinct_pids(program_number: u16) -> Vec<u8> {
1616 const VIACCESS: u16 = 0x0500;
1617 let prog_ca = ca_descriptor(VIACCESS, 0x0074);
1618 let es0_ca = ca_descriptor(VIACCESS, 0x0075);
1619
1620 let mut body = Vec::new();
1621 body.push(0x02); body.push(0);
1623 body.push(0);
1624 body.extend_from_slice(&program_number.to_be_bytes());
1625 body.push(0xC3);
1626 body.push(0x00);
1627 body.push(0x00);
1628 body.push(0xE0 | 0x02); body.push(0x00);
1630 body.push(0xF0 | ((prog_ca.len() >> 8) as u8 & 0x0F));
1631 body.push(prog_ca.len() as u8);
1632 body.extend_from_slice(&prog_ca);
1633 body.push(0x1B);
1635 body.push(0xE0 | 0x02);
1636 body.push(0x00);
1637 body.push(0xF0 | ((es0_ca.len() >> 8) as u8 & 0x0F));
1638 body.push(es0_ca.len() as u8);
1639 body.extend_from_slice(&es0_ca);
1640 body.push(0x0F);
1642 body.push(0xE0 | 0x02);
1643 body.push(0x01);
1644 body.push(0xF0);
1645 body.push(0x00);
1646
1647 let section_length = body.len() - 3 + 4;
1648 body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
1649 body[2] = section_length as u8;
1650 let crc = broadcast_common::crc32_mpeg2::compute(&body);
1651 body.extend_from_slice(&crc.to_be_bytes());
1652 body
1653 }
1654
1655 #[test]
1656 fn descramble_pids_is_the_union_of_active_services_es_pids() {
1657 use broadcast_common::Parse;
1658
1659 let mut d = driver_with_sessions();
1660 d.take_notifications();
1661
1662 assert!(
1663 d.descramble_pids().is_empty(),
1664 "no service added yet: descramble_pids must be empty"
1665 );
1666
1667 let pmt1_bytes = build_ca_pmt_fixture(1546);
1668 let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1669 d.add_service(&pmt1).unwrap();
1670 d.device_mut().inbound.push_back(sb());
1671 d.pump(Duration::from_millis(10)).unwrap();
1672
1673 assert_eq!(d.descramble_pids(), &[0x0100, 0x0101]);
1674
1675 let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
1676 let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1677 d.add_service(&pmt2).unwrap();
1678 d.device_mut().inbound.push_back(sb());
1679 d.pump(Duration::from_millis(10)).unwrap();
1680
1681 assert_eq!(
1683 d.descramble_pids(),
1684 &[0x0100, 0x0101, 0x0200, 0x0201],
1685 "descramble_pids must be the union across both added services"
1686 );
1687 }
1688
1689 pub(crate) fn ca_pmt_reply_for(
1694 program_number: u16,
1695 ca_enable: Option<dvb_ci::objects::ca_pmt_reply::CaEnable>,
1696 ) -> dvb_ci::objects::ca_pmt_reply::CaPmtReply {
1697 dvb_ci::objects::ca_pmt_reply::CaPmtReply {
1698 program_number,
1699 version_number: 1,
1700 current_next_indicator: true,
1701 ca_enable,
1702 streams: vec![],
1703 }
1704 }
1705
1706 #[test]
1707 fn requery_timer_resends_ca_pmt_then_reply_change_emits_one_entitlement() {
1708 use broadcast_common::Parse;
1709 use dvb_ci::objects::ca_pmt_reply::CaEnable;
1710
1711 let mut d = driver_with_sessions();
1712 d.take_notifications();
1713
1714 let pmt_bytes = build_ca_pmt_fixture(1546);
1715 let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1716 d.add_service(&pmt).unwrap();
1717 d.device_mut().inbound.push_back(sb());
1718 d.pump(Duration::from_millis(10)).unwrap();
1719 d.take_notifications();
1720
1721 let expected_initial_ca_pmt =
1725 build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling).to_bytes();
1726 assert_apdu_on_session(&d, CA_SESSION, &expected_initial_ca_pmt);
1727
1728 let expected_ca_pmt =
1732 build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::Query).to_bytes();
1733 let sends_before_requery = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1734
1735 let mut all_notes = Vec::new();
1736
1737 feed(
1741 &mut d,
1742 r_apdu(
1743 CA_SESSION,
1744 &ser(&ca_pmt_reply_for(
1745 1546,
1746 Some(CaEnable::NotPossibleNoEntitlement),
1747 )),
1748 ),
1749 );
1750 all_notes.extend(d.take_notifications());
1751
1752 d.pump(Duration::from_secs(11)).unwrap();
1761 all_notes.extend(d.take_notifications());
1762 d.device_mut().inbound.push_back(sb());
1763 d.pump(Duration::from_millis(10)).unwrap();
1764
1765 let sends_after_requery = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1766 assert_eq!(
1767 sends_after_requery,
1768 sends_before_requery + 1,
1769 "expected the re-query timer to resend the exact ca_pmt exactly once"
1770 );
1771
1772 feed(
1775 &mut d,
1776 r_apdu(
1777 CA_SESSION,
1778 &ser(&ca_pmt_reply_for(1546, Some(CaEnable::Possible))),
1779 ),
1780 );
1781 all_notes.extend(d.take_notifications());
1782
1783 let hits = all_notes
1784 .iter()
1785 .filter(|n| {
1786 matches!(
1787 n,
1788 Notification::Entitlement {
1789 program_number: 1546,
1790 ca_enable: CaEnable::Possible,
1791 descrambling_ok: true,
1792 }
1793 )
1794 })
1795 .count();
1796 assert_eq!(
1797 hits, 1,
1798 "expected exactly one Entitlement{{program_number:1546, ca_enable:Possible, descrambling_ok:true}}, got {all_notes:?}"
1799 );
1800 }
1801
1802 #[test]
1803 fn requery_timer_unchanged_reply_across_two_requeries_emits_no_entitlement() {
1804 use broadcast_common::Parse;
1805 use dvb_ci::objects::ca_pmt_reply::CaEnable;
1806
1807 let mut d = driver_with_sessions();
1808 d.take_notifications();
1809
1810 let pmt_bytes = build_ca_pmt_fixture(1547);
1811 let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1812 d.add_service(&pmt).unwrap();
1813 d.device_mut().inbound.push_back(sb());
1814 d.pump(Duration::from_millis(10)).unwrap();
1815 d.take_notifications();
1816
1817 feed(
1821 &mut d,
1822 r_apdu(
1823 CA_SESSION,
1824 &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
1825 ),
1826 );
1827 d.take_notifications();
1828
1829 for _ in 0..2 {
1832 d.pump(Duration::from_secs(11)).unwrap();
1833 d.take_notifications();
1834 feed(
1835 &mut d,
1836 r_apdu(
1837 CA_SESSION,
1838 &ser(&ca_pmt_reply_for(1547, Some(CaEnable::Possible))),
1839 ),
1840 );
1841 let notes = d.take_notifications();
1842 assert!(
1843 !notes
1844 .iter()
1845 .any(|n| matches!(n, Notification::Entitlement { .. })),
1846 "unchanged status across a re-query must not emit Entitlement, got {notes:?}"
1847 );
1848 }
1849 }
1850
1851 #[test]
1852 fn requery_reply_withdrawn_to_none_emits_no_entitlement() {
1853 use broadcast_common::Parse;
1854 use dvb_ci::objects::ca_pmt_reply::CaEnable;
1855
1856 let mut d = driver_with_sessions();
1857 d.take_notifications();
1858
1859 let pmt_bytes = build_ca_pmt_fixture(1548);
1860 let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1861 d.add_service(&pmt).unwrap();
1862 d.device_mut().inbound.push_back(sb());
1863 d.pump(Duration::from_millis(10)).unwrap();
1864 d.take_notifications();
1865
1866 feed(
1868 &mut d,
1869 r_apdu(
1870 CA_SESSION,
1871 &ser(&ca_pmt_reply_for(1548, Some(CaEnable::Possible))),
1872 ),
1873 );
1874 d.take_notifications();
1875
1876 feed(
1880 &mut d,
1881 r_apdu(CA_SESSION, &ser(&ca_pmt_reply_for(1548, None))),
1882 );
1883 let notes = d.take_notifications();
1884 assert!(
1885 !notes
1886 .iter()
1887 .any(|n| matches!(n, Notification::Entitlement { .. })),
1888 "ca_enable transitioning to None must not emit Entitlement, got {notes:?}"
1889 );
1890 }
1891
1892 #[test]
1893 fn set_requery_interval_zero_disables_resend() {
1894 use broadcast_common::Parse;
1895
1896 let mut d = driver_with_sessions();
1897 d.set_requery_interval(Duration::ZERO);
1898 d.take_notifications();
1899
1900 let pmt_bytes = build_ca_pmt_fixture(1549);
1901 let pmt = PmtSection::parse(&pmt_bytes).unwrap();
1902 d.add_service(&pmt).unwrap();
1903 d.device_mut().inbound.push_back(sb());
1904 d.pump(Duration::from_millis(10)).unwrap();
1905
1906 let expected_ca_pmt =
1909 build_ca_pmt(&pmt, CaPmtListManagement::Only, CaPmtCmdId::Query).to_bytes();
1910 let sends_before = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1911
1912 d.pump(Duration::from_secs(1000)).unwrap();
1914
1915 let sends_after = count_apdu_on_session(&d, CA_SESSION, &expected_ca_pmt);
1916 assert_eq!(
1917 sends_after, sends_before,
1918 "Duration::ZERO must disable the re-query resend"
1919 );
1920 }
1921
1922 #[test]
1923 fn requery_timer_resends_every_active_service_not_just_one() {
1924 use broadcast_common::Parse;
1925
1926 let mut d = driver_with_sessions();
1927 d.take_notifications();
1928
1929 let pmt1_bytes = build_ca_pmt_fixture(1546);
1932 let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1933 d.add_service(&pmt1).unwrap();
1934 d.device_mut().inbound.push_back(sb());
1935 d.pump(Duration::from_millis(10)).unwrap();
1936
1937 let pmt2_bytes = build_ca_pmt_fixture(1547);
1938 let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1939 d.add_service(&pmt2).unwrap();
1940 d.device_mut().inbound.push_back(sb());
1941 d.pump(Duration::from_millis(10)).unwrap();
1942 d.take_notifications();
1943
1944 let expected1 =
1947 build_ca_pmt(&pmt1, CaPmtListManagement::Only, CaPmtCmdId::Query).to_bytes();
1948 let expected2 = build_ca_pmt(&pmt2, CaPmtListManagement::Add, CaPmtCmdId::Query).to_bytes();
1949 let sends_before1 = count_apdu_on_session(&d, CA_SESSION, &expected1);
1950 let sends_before2 = count_apdu_on_session(&d, CA_SESSION, &expected2);
1951
1952 d.pump(Duration::from_secs(11)).unwrap();
1959 feed(&mut d, sb());
1960
1961 let sends_after1 = count_apdu_on_session(&d, CA_SESSION, &expected1);
1962 let sends_after2 = count_apdu_on_session(&d, CA_SESSION, &expected2);
1963 assert_eq!(
1964 sends_after1,
1965 sends_before1 + 1,
1966 "expected service 1546's query ca_pmt resent exactly once on the shared tick"
1967 );
1968 assert_eq!(
1969 sends_after2,
1970 sends_before2 + 1,
1971 "expected service 1547's query ca_pmt resent exactly once on the shared tick"
1972 );
1973 }
1974
1975 #[test]
1978 fn remove_service_sends_update_not_selected_and_drops_from_managed_state() {
1979 use broadcast_common::Parse;
1980
1981 let mut d = driver_with_sessions();
1982 d.take_notifications();
1983
1984 let pmt1_bytes = build_ca_pmt_fixture(1546);
1988 let pmt1 = PmtSection::parse(&pmt1_bytes).unwrap();
1989 d.add_service(&pmt1).unwrap();
1990 d.device_mut().inbound.push_back(sb());
1991 d.pump(Duration::from_millis(10)).unwrap();
1992
1993 let pmt2_bytes = build_ca_pmt_fixture_distinct_pids(1547);
1994 let pmt2 = PmtSection::parse(&pmt2_bytes).unwrap();
1995 d.add_service(&pmt2).unwrap();
1996 d.device_mut().inbound.push_back(sb());
1997 d.pump(Duration::from_millis(10)).unwrap();
1998
1999 d.remove_service(1546).unwrap();
2000 d.device_mut().inbound.push_back(sb());
2001 d.pump(Duration::from_millis(10)).unwrap();
2002
2003 let expected =
2007 build_ca_pmt(&pmt1, CaPmtListManagement::Update, CaPmtCmdId::NotSelected).to_bytes();
2008 assert_apdu_on_session(&d, CA_SESSION, &expected);
2009
2010 assert_eq!(
2011 d.descramble_pids(),
2012 &[0x0200, 0x0201],
2013 "1546's ES PIDs must be gone; 1547's must remain"
2014 );
2015 assert!(
2016 d.managed_ca().services().get(&1546).is_none(),
2017 "1546 must no longer be tracked"
2018 );
2019 assert!(
2020 d.managed_ca().services().get(&1547).is_some(),
2021 "1547 must remain tracked"
2022 );
2023 }
2024
2025 #[test]
2026 fn remove_service_of_untracked_program_is_a_no_op() {
2027 let mut d = driver_with_sessions();
2028 d.take_notifications();
2029
2030 let ops_before = d.device().ops.len();
2031 d.remove_service(0xFFFF).unwrap();
2032 assert_eq!(
2033 d.device().ops.len(),
2034 ops_before,
2035 "removing an untracked program must not send anything to the device"
2036 );
2037 assert!(
2038 d.managed_ca().services().is_empty(),
2039 "removing an untracked program must not disturb the (empty) managed set"
2040 );
2041 }
2042
2043 #[test]
2044 fn cam_removed_edge_clears_managed_state() {
2045 use broadcast_common::Parse;
2046 use dvb_ci::objects::ca_info::CaInfo;
2047 use dvb_si::tables::cat::CatSection;
2048
2049 let mut d = driver_with_sessions();
2050 d.take_notifications();
2051
2052 let pmt_bytes = build_ca_pmt_fixture(1546);
2053 let pmt = PmtSection::parse(&pmt_bytes).unwrap();
2054 d.add_service(&pmt).unwrap();
2055 d.device_mut().inbound.push_back(sb());
2056 d.pump(Duration::from_millis(10)).unwrap();
2057
2058 feed(
2061 &mut d,
2062 r_apdu(
2063 CA_SESSION,
2064 &ser(&CaInfo {
2065 ca_system_ids: vec![0x0648],
2066 }),
2067 ),
2068 );
2069 d.take_notifications();
2070 let mut descriptors = Vec::new();
2071 descriptors.extend_from_slice(&ca_descriptor(0x0648, 0x1FF0));
2072 let cat_bytes = build_cat_fixture(&descriptors);
2073 let cat = CatSection::parse(&cat_bytes).unwrap();
2074 d.set_cat(&cat).unwrap();
2075
2076 assert!(
2077 !d.managed_ca().services().is_empty(),
2078 "precondition: a service is tracked"
2079 );
2080 assert!(
2081 !d.descramble_pids().is_empty(),
2082 "precondition: descramble_pids populated"
2083 );
2084 assert!(!d.emm_pids().is_empty(), "precondition: emm_pids populated");
2085
2086 d.device_mut().slot.module_present = false;
2088 d.pump(Duration::from_millis(10)).unwrap();
2089 let notes = d.take_notifications();
2090 assert!(
2091 notes.contains(&Notification::HotPlug(HotPlug::CamRemoved)),
2092 "expected CamRemoved, got {notes:?}"
2093 );
2094
2095 assert!(
2096 d.managed_ca().services().is_empty(),
2097 "services must be cleared on CamRemoved"
2098 );
2099 assert!(
2100 d.descramble_pids().is_empty(),
2101 "descramble_pids must be cleared on CamRemoved"
2102 );
2103 assert!(
2104 d.emm_pids().is_empty(),
2105 "emm_pids must be cleared on CamRemoved"
2106 );
2107 }
2108}