Skip to main content

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}