dvb_ci_runtime/event.rs
1//! The sans-IO event/action model.
2//!
3//! The protocol core is pure: it consumes [`Event`]s and produces [`Action`]s,
4//! with no device, threads, or clock of its own. The driver loop executes the
5//! actions against a [`CaDevice`](crate::CaDevice) and feeds events back. This
6//! keeps every state machine deterministic and testable without
7//! hardware — a test (or a differential comparison against an external
8//! reference) drives a sequence of events and asserts the emitted action
9//! sequence.
10
11use mpeg_ts::pid::Pid;
12use std::time::Duration;
13
14use dvb_ci::objects::ca_pmt_reply::CaEnable;
15use dvb_ci::resource::ResourceId;
16
17/// An input to the protocol core.
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum Event<'a> {
21 /// One link-layer frame was read from the device.
22 Readable(&'a [u8]),
23 /// Logical time advanced by `elapsed` since the last tick (drives poll
24 /// cadence and resource timers without a real clock in the core).
25 Tick {
26 /// Time since the previous tick.
27 elapsed: Duration,
28 },
29 /// A request from the host application.
30 Host(HostRequest<'a>),
31}
32
33/// A request the host application makes of the stack.
34///
35/// `Debug` redacts the `MmiEnquiryAnswer` text — it is whatever the user typed
36/// at a (possibly blind) enquiry, i.e. often a PIN (audit #1142).
37#[derive(Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum HostRequest<'a> {
40 /// Bring the interface up: reset the slot and open the transport connection.
41 Init,
42 /// Send a serialized `ca_pmt` APDU body to the CAM's conditional-access
43 /// resource (descrambling request).
44 SendCaPmt(&'a [u8]),
45 /// Answer an MMI `menu`/`list` by 1-based `choice_ref` (0 = "back"/cancel),
46 /// sent as `menu_answ` to the module.
47 MmiMenuAnswer(u8),
48 /// Answer an MMI `enquiry` with the user's input text (EN 300 468 Annex A
49 /// bytes), sent as `answ` (`answ_id = answer`).
50 MmiEnquiryAnswer(&'a [u8]),
51 /// Abort the current MMI enquiry (`answ` with `answ_id = cancel`).
52 MmiCancel,
53 /// Ask the module to open its MMI menu (`enter_menu` on the
54 /// application_information session) — e.g. to read card / entitlement info.
55 EnterMenu,
56 /// Descramble the services in a PMT section (raw `dvb-si` PMT bytes). The
57 /// stack filters the PMT's `CA_descriptor`s to the CAM's advertised CAIDs
58 /// (from its `ca_info`) and sends a `ca_pmt` with `list_management = only`,
59 /// `cmd_id = ok_descrambling`. The reply outcome surfaces as
60 /// [`Notification::CaPmtReply`].
61 Descramble(&'a [u8]),
62 /// Descramble a **set** of programmes in one CA-PMT list (`first`/`more`/
63 /// `last`, all `ok_descrambling`), replacing any prior set. Each element is a
64 /// raw PMT section.
65 DescramblePrograms(&'a [&'a [u8]]),
66 /// Add one programme to the descrambled set (`list_management = add`).
67 AddProgram(&'a [u8]),
68 /// Remove one programme from the descrambled set (`list_management = update`,
69 /// `cmd_id = not_selected`).
70 RemoveProgram(&'a [u8]),
71 /// Tear the interface down (close sessions + transport connection).
72 Shutdown,
73}
74
75impl core::fmt::Debug for HostRequest<'_> {
76 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
77 match self {
78 Self::Init => f.write_str("Init"),
79 Self::SendCaPmt(b) => f.debug_tuple("SendCaPmt").field(b).finish(),
80 Self::MmiMenuAnswer(n) => f.debug_tuple("MmiMenuAnswer").field(n).finish(),
81 Self::MmiEnquiryAnswer(b) => f
82 .debug_tuple("MmiEnquiryAnswer")
83 .field(&format_args!("<{} bytes redacted>", b.len()))
84 .finish(),
85 Self::MmiCancel => f.write_str("MmiCancel"),
86 Self::EnterMenu => f.write_str("EnterMenu"),
87 Self::Descramble(b) => f.debug_tuple("Descramble").field(b).finish(),
88 Self::DescramblePrograms(p) => f.debug_tuple("DescramblePrograms").field(p).finish(),
89 Self::AddProgram(b) => f.debug_tuple("AddProgram").field(b).finish(),
90 Self::RemoveProgram(b) => f.debug_tuple("RemoveProgram").field(b).finish(),
91 Self::Shutdown => f.write_str("Shutdown"),
92 }
93 }
94}
95
96/// An output the driver loop must perform.
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum Action {
100 /// Write one link-layer frame to the device.
101 Write(Vec<u8>),
102 /// Issue the `CA_RESET` ioctl.
103 Reset,
104 /// Issue the `CA_GET_SLOT_INFO` ioctl.
105 QuerySlot,
106 /// Arm the poll/timer to fire after `after` (coalesced: the latest wins).
107 SetTimer {
108 /// Delay before the next [`Event::Tick`] should be delivered.
109 after: Duration,
110 },
111 /// Surface a host-facing [`Notification`].
112 Notify(Notification),
113}
114
115/// A host-facing event surfaced by the stack (the useful outputs of a CI
116/// session — what an application reacts to).
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[non_exhaustive]
119pub enum Notification {
120 /// The module is present and the resource-manager handshake completed.
121 CamReady,
122 /// `application_information` was received.
123 ApplicationInfo {
124 /// `application_type` (0x01 = CA).
125 application_type: u8,
126 /// `application_manufacturer`.
127 manufacturer: u16,
128 /// `manufacturer_code`.
129 code: u16,
130 /// The decoded menu string.
131 menu: String,
132 },
133 /// `ca_info` was received — the CA system ids the module supports.
134 CaInfo {
135 /// `CA_system_id` values the CAM can descramble.
136 ca_system_ids: Vec<u16>,
137 },
138 /// A `ca_pmt_reply` was received for a prior CA_PMT.
139 CaPmtReply {
140 /// `program_number` the reply pertains to.
141 program_number: u16,
142 /// Programme-level `CA_enable` (EN 50221 §8.4.3.5 Table 26). `None`
143 /// iff the programme `CA_enable_flag` bit was clear (no
144 /// programme-level status given) — plumbed straight through from the
145 /// `dvb_ci` `CaPmtReply` object's own `Option<CaEnable>`, never
146 /// collapsed to a sentinel.
147 ca_enable: Option<CaEnable>,
148 /// Whether descrambling is (or was) possible, derived from
149 /// `ca_enable`. Kept for back-compat with the pre-#763 boolean-only
150 /// surface.
151 descrambling_ok: bool,
152 },
153 /// A per-programme entitlement status transition (#763). Edge-triggered:
154 /// fires once per `program_number` only when the programme-level
155 /// `CA_enable` status (EN 50221 §8.4.3.5, Table 26) changes versus the
156 /// last observed `ca_pmt_reply` for that programme. The transition is
157 /// detected by the periodic re-query (`Driver::set_requery_interval`),
158 /// which re-sends the active `ca_pmt`s with `ca_pmt_cmd_id = query` so
159 /// the CAM re-evaluates and replies. Complements the coarse #726
160 /// `HotPlug` module/card layer with the fine-grained per-service layer.
161 /// (Programme-level status only; the ES-level `CA_enable` entries are
162 /// not evaluated for this event.)
163 Entitlement {
164 /// `program_number` the status pertains to.
165 program_number: u16,
166 /// Programme-level `CA_enable` status.
167 ca_enable: CaEnable,
168 /// Whether descrambling is possible per the current status.
169 descrambling_ok: bool,
170 },
171 /// An MMI menu/enquiry the host should display.
172 Mmi(MmiEvent),
173 /// A `host_control` request the CAM made of the host (EN 50221 §8.5.1). The
174 /// host acts on it out-of-band (retune / PID replace); the runtime only
175 /// surfaces the decoded request.
176 HostControl(HostControlEvent),
177 /// A session for `resource` was opened.
178 SessionOpened {
179 /// The resource the session serves.
180 resource: ResourceId,
181 },
182 /// A session closed.
183 SessionClosed {
184 /// The `session_nb` that closed.
185 session_nb: u16,
186 },
187 /// A protocol error surfaced by the stack (non-fatal; informational).
188 Error {
189 /// Human-readable detail.
190 detail: String,
191 },
192 /// A CAM/card hot-plug transition (#726). See [`HotPlug`].
193 HotPlug(HotPlug),
194}
195
196impl Notification {
197 /// This notification's [`HotPlug`] transition, if it is one — a cheap
198 /// classifier for poll-mode consumers that only care about hot-plug
199 /// edges.
200 #[must_use]
201 pub fn hotplug(&self) -> Option<HotPlug> {
202 if let Notification::HotPlug(h) = self {
203 Some(*h)
204 } else {
205 None
206 }
207 }
208}
209
210/// A CAM/card hot-plug transition. `Cam*` are real DVB-CA slot-status edges;
211/// `Card*` are best-effort EN 50221 app-layer inference (no card-detect line).
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213#[non_exhaustive]
214pub enum HotPlug {
215 /// The module transitioned absent → present (DVB-CA slot status:
216 /// `CA_CI_MODULE_PRESENT` went from clear to set — Linux uapi
217 /// `linux/dvb/ca.h` `ca_slot_info.flags`; `CA_CI_MODULE_READY` is **not**
218 /// required, the driver keys the edge off `module_present` alone). A real
219 /// hardware signal from [`SlotInfo`](crate::device::SlotInfo), surfaced
220 /// once on the edge (not on every poll); the driver re-drives the handshake (a fresh
221 /// [`Init`](crate::event::HostRequest::Init)) so the newly-inserted module
222 /// gets a clean resource-manager session.
223 CamPresent,
224 /// The module transitioned present → absent (DVB-CA slot status:
225 /// `CA_CI_MODULE_PRESENT` clear). A real hardware signal; the driver tears
226 /// down all session/handshake state so a later re-insert re-handshakes
227 /// cleanly rather than reusing stale session numbers.
228 CamRemoved,
229 /// A smart card was inferred to have been inserted into the module.
230 ///
231 /// **Best-effort app-layer inference** — EN 50221 slots are module-level
232 /// only; there is no card-detect line (verified against DD ddbridge /
233 /// cxd2099 driver behaviour). Raised from one of: an `ca_info` CAID-set
234 /// transition from empty to non-empty, or a `ca_pmt_reply`
235 /// `descrambling_ok` transition from `false` to `true`. Some CAMs instead
236 /// give a strong signal by resetting the module on card change, which
237 /// surfaces as [`CamPresent`](HotPlug::CamPresent) rather than this
238 /// variant.
239 CardInserted,
240 /// A smart card was inferred to have been removed from the module.
241 ///
242 /// **Best-effort app-layer inference** (see [`CardInserted`](HotPlug::CardInserted)
243 /// for why no hardware signal exists). Raised from one of: an `ca_info`
244 /// CAID-set transition from non-empty to empty, a `ca_pmt_reply`
245 /// `descrambling_ok` transition from `true` to `false`, or MMI menu/list/
246 /// enquiry text matching a "no card" style keyword.
247 CardRemoved,
248 /// The inserted smart card was inferred to have changed (swapped without
249 /// an intervening removal the runtime observed).
250 ///
251 /// **Best-effort app-layer inference** (see [`CardInserted`](HotPlug::CardInserted)
252 /// for why no hardware signal exists). Raised when a later `ca_info`
253 /// reports a different non-empty CAID set than the last one seen for this
254 /// module.
255 CardChanged,
256}
257
258impl HotPlug {
259 /// Stable lowercase spec-ish token for this transition (#204 label
260 /// convention).
261 #[must_use]
262 pub fn name(&self) -> &'static str {
263 match self {
264 Self::CamPresent => "cam-present",
265 Self::CamRemoved => "cam-removed",
266 Self::CardInserted => "card-inserted",
267 Self::CardRemoved => "card-removed",
268 Self::CardChanged => "card-changed",
269 }
270 }
271}
272
273broadcast_common::impl_spec_display!(HotPlug);
274
275/// A decoded high-level MMI `menu()` / `list()` ready for display (§8.6.5,
276/// Tables 49/51). The three header lines and the choice list are kept separate
277/// so a UI can render them directly — a title bar, two sub-lines, and a list of
278/// selectable rows — without re-parsing.
279#[derive(Debug, Clone, PartialEq, Eq, Default)]
280pub struct MmiMenu {
281 /// Title line.
282 pub title: String,
283 /// Sub-title line (often a section heading).
284 pub subtitle: String,
285 /// Bottom line (often a hint such as "Select item and press OK").
286 pub bottom: String,
287 /// The selectable choices, in wire order. Answer the Nth (1-based) with
288 /// [`Driver::mmi_menu_answer`](crate::Driver::mmi_menu_answer)`(N)`; `0`
289 /// cancels / goes back.
290 pub choices: Vec<String>,
291}
292
293/// MMI (man-machine interface) host events.
294#[derive(Debug, Clone, PartialEq, Eq)]
295#[non_exhaustive]
296pub enum MmiEvent {
297 /// A `menu()` to display — the user picks one choice. Answer via
298 /// [`Driver::mmi_menu_answer`](crate::Driver::mmi_menu_answer).
299 Menu(MmiMenu),
300 /// A `list()` to display — informational (e.g. an entitlement listing); the
301 /// host typically dismisses it with
302 /// [`Driver::mmi_menu_answer`](crate::Driver::mmi_menu_answer)`(0)`.
303 List(MmiMenu),
304 /// An `enquiry` (text prompt) expecting an answer. Reply via
305 /// [`Driver::mmi_enquiry_answer`](crate::Driver::mmi_enquiry_answer) or
306 /// [`Driver::mmi_cancel`](crate::Driver::mmi_cancel).
307 Enquiry {
308 /// Prompt text.
309 prompt: String,
310 /// Whether the answer should be hidden (e.g. PIN).
311 blind: bool,
312 /// Expected answer length.
313 answer_len: u8,
314 },
315 /// The module closed the MMI dialog.
316 Close,
317}
318
319/// A `host_control` request the CAM makes of the host (ETSI EN 50221 §8.5.1,
320/// Tables 27-30). The runtime surfaces the decoded request; the host performs
321/// the retune / PID replacement itself (out of band) — there is no re-tune
322/// logic in the stack.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324#[non_exhaustive]
325pub enum HostControlEvent {
326 /// `tune()` (Table 27): retune to the identified service.
327 Tune {
328 /// `network_id`.
329 network_id: u16,
330 /// `original_network_id`.
331 original_network_id: u16,
332 /// `transport_stream_id`.
333 transport_stream_id: u16,
334 /// `service_id`.
335 service_id: u16,
336 },
337 /// `replace()` (Table 28): temporarily replace one component PID with
338 /// another from the same multiplex.
339 Replace {
340 /// `replacement_ref` — matched later by a Clear Replace.
341 replacement_ref: u8,
342 /// 13-bit `replaced_PID`.
343 replaced_pid: Pid,
344 /// 13-bit `replacement_PID`.
345 replacement_pid: Pid,
346 },
347 /// `clear_replace()` (Table 29): undo all Replace operations sharing this
348 /// `replacement_ref`.
349 ClearReplace {
350 /// `replacement_ref` shared with one or more prior Replace requests.
351 replacement_ref: u8,
352 },
353 /// `ask_release()` (Table 30): the CAM asks the host to release any
354 /// replacements it holds (header-only request).
355 AskRelease,
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn host_request_debug_does_not_print_an_enquiry_answer() {
364 let dbg = format!("{:?}", HostRequest::MmiEnquiryAnswer(b"9876"));
365 assert_eq!(dbg, "MmiEnquiryAnswer(<4 bytes redacted>)");
366 }
367
368 #[test]
369 fn notification_entitlement_construction() {
370 let note = Notification::Entitlement {
371 program_number: 1234,
372 ca_enable: CaEnable::Possible,
373 descrambling_ok: true,
374 };
375 match note {
376 Notification::Entitlement {
377 program_number,
378 ca_enable,
379 descrambling_ok,
380 } => {
381 assert_eq!(program_number, 1234);
382 assert_eq!(ca_enable, CaEnable::Possible);
383 assert!(descrambling_ok);
384 }
385 _ => panic!("expected Entitlement variant"),
386 }
387 }
388}