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