Skip to main content

dvb_conformance/
lib.rs

1//! ETSI TR 101 290 v1.4.1 transport-stream conformance monitor.
2//!
3//! Implements the **first-priority** (Table 5.0a, indicators 1.1–1.6),
4//! **second-priority** (Table 5.0b, indicators 2.1–2.3b, 2.5–2.6), and
5//! **third-priority** (Table 5.0c, indicators 3.1, 3.2, 3.4, 3.5, 3.6, 3.7,
6//! 3.8) indicator sets — see `docs/tr_101_290.md` for the full spec
7//! transcription and the crate-coverage mapping.
8//!
9//! Not computable under this crate's architecture (needs the ISO/IEC 13818-1
10//! **T-STD buffer model** or hardware arrival timing): 2.4 `PCR_accuracy_error`,
11//! 3.3 `Buffer_error`, 3.9 `Empty_buffer_error`, 3.10 `Data_delay_error`.
12//!
13//! Feasible but deferred: the 25 ms minimum-gap dimension shared by 3.1.a /
14//! 3.2 / 3.5.a / 3.6.a / 3.7 / 3.8 (needs per-`(table_id, section_number)`
15//! tracking to avoid false positives on dense multi-section tables); the
16//! `_other` repetition sub-clauses 3.1.b / 3.5.b / 3.6.b (need TR 101 211
17//! interval rules); the EIT P/F pairing check 3.6.c.
18//!
19//! # Caller-supplied time
20//!
21//! [`ConformanceMonitor::feed`] takes a [`core::time::Duration`] timestamp
22//! alongside each TS packet. All presence/absence timeout checks (1.3.a, 1.5.a,
23//! 1.6, 2.3a, 2.3b, 2.5, 3.2) are evaluated against this clock. The caller
24//! must ensure that timestamps are **monotonic non-decreasing** across calls;
25//! the monitor does not enforce this but non-monotonic timestamps will produce
26//! spurious events.
27//!
28//! # References
29//!
30//! - ETSI TR 101 290 v1.4.1 (2020-06), §5.2.1, Table 5.0a
31//! - ETSI TR 101 290 v1.4.1 (2020-06), §5.2.2, Table 5.0b
32//! - ETSI TR 101 290 v1.4.1 (2020-06), §5.2.3, Table 5.0c
33//! - ISO/IEC 13818-1 (MPEG-2 Systems)
34
35#![cfg_attr(not(feature = "std"), no_std)]
36#![cfg_attr(docsrs, feature(doc_cfg))]
37// Runnable examples, embedded so they render on docs.rs and stay in sync with
38// the actual `examples/*.rs` files (shown, not compiled).
39#![doc = "\n# Examples\n"]
40#![doc = "Two runnable examples ship with this crate (`cargo run -p dvb-conformance --example <name>`).\n"]
41#![doc = "\n## `monitor_stream`\n\n```rust,ignore"]
42#![doc = include_str!("../examples/monitor_stream.rs")]
43#![doc = "```\n\n## `priority_breakdown`\n\n```rust,ignore"]
44#![doc = include_str!("../examples/priority_breakdown.rs")]
45#![doc = "```"]
46extern crate alloc;
47
48use alloc::collections::BTreeMap;
49use alloc::format;
50use alloc::string::String;
51use alloc::vec::Vec;
52use core::time::Duration;
53
54use broadcast_common::Parse;
55use dvb_si::tables::pat::{PatSection, TABLE_ID as PAT_TABLE_ID};
56use dvb_si::tables::pmt::PmtSection;
57use mpeg_ts::section::Section;
58use mpeg_ts::ts::{SectionReassembler, TsPacket};
59
60// ── Named PID constants ─────────────────────────────────────────────────────
61
62/// PID 0x0000 — Program Association Table (ISO/IEC 13818-1 §2.4.4.3).
63const PID_PAT: u16 = 0x0000;
64/// PID 0x0001 — Conditional Access Table (ISO/IEC 13818-1 §2.4.4.5).
65const PID_CAT: u16 = 0x0001;
66/// PID 0x0010 — Network Information Table (EN 300 468 §5.2.1).
67const PID_NIT: u16 = 0x0010;
68/// PID 0x0011 — SDT/BAT (EN 300 468 §5.2.2 / §5.2.3).
69const PID_SDT_BAT: u16 = 0x0011;
70/// PID 0x0012 — Event Information Table (EN 300 468 §5.2.4).
71const PID_EIT: u16 = 0x0012;
72/// PID 0x0013 — Running Status Table (EN 300 468 §5.2.8).
73const PID_RST: u16 = 0x0013;
74/// PID 0x0014 — TDT/TOT (EN 300 468 §5.2.5 / §5.2.6).
75const PID_TDT_TOT: u16 = 0x0014;
76/// PID 0x1FFF — Null/padding packets (ISO/IEC 13818-1 §2.4.3.3).
77const PID_NULL: u16 = 0x1FFF;
78
79/// Sync byte value (ISO/IEC 13818-1 §2.4.3.3).
80const SYNC_BYTE: u8 = 0x47;
81
82/// Well-known SI/PSI PIDs on which CRC-checked long-form sections appear.
83const SI_PIDS: [u16; 7] = [
84    PID_PAT,
85    PID_CAT,
86    PID_NIT,
87    PID_SDT_BAT,
88    PID_EIT,
89    PID_RST,
90    PID_TDT_TOT,
91];
92
93/// Reserved-for-future-use PID range (ISO/IEC 13818-1 Table 2-3) — exempt
94/// from Unreferenced_PID (TR 101 290 v1.4.1 Table 5.0c indicator 3.4).
95const RESERVED_PID_MIN: u16 = 0x0002;
96/// Upper (inclusive) bound of the reserved PID range.
97const RESERVED_PID_MAX: u16 = 0x000F;
98
99// ── Default timing constants ────────────────────────────────────────────────
100
101/// TR 101 290 v1.4.1 Table 5.0a note 3 / TS 101 154 §4.1.7 — PAT maximum
102/// interval (0.5 s per Table 5.0a row 1.3.a; TS 101 154 recommends ≤ 100 ms).
103const DEFAULT_PAT_MAX_INTERVAL_MS: u64 = 500;
104
105/// TR 101 290 v1.4.1 Table 5.0a row 1.5.a / note 3 — PMT maximum interval.
106const DEFAULT_PMT_MAX_INTERVAL_MS: u64 = 500;
107
108/// TR 101 290 v1.4.1 §5.2.1 accompanying text (1.6) — PID_error period.
109const DEFAULT_PID_ERROR_PERIOD_SECS: u64 = 5;
110
111/// TR 101 290 v1.4.1 §5.2.1 accompanying text (1.1) — sync acquisition
112/// threshold: five consecutive correct sync bytes.
113const DEFAULT_SYNC_ACQUIRE_PACKETS: u8 = 5;
114
115/// TR 101 290 v1.4.1 §5.2.1 accompanying text (1.1) — sync loss threshold:
116/// two or more consecutive corrupted sync bytes.
117const DEFAULT_SYNC_LOSS_PACKETS: u8 = 2;
118
119/// TR 101 290 v1.4.1 Table 5.0b indicator 2.3a / note 2 — PCR maximum
120/// repetition interval (100 ms; note 2 removed the 40 ms limit).
121const DEFAULT_PCR_REPETITION_LIMIT_MS: u64 = 100;
122
123/// TR 101 290 v1.4.1 Table 5.0b indicator 2.3b — PCR discontinuity indicator
124/// maximum interval (100 ms).
125const DEFAULT_PCR_DISCONTINUITY_LIMIT_MS: u64 = 100;
126
127/// TR 101 290 v1.4.1 Table 5.0b indicator 2.5 / note 3 — PTS maximum
128/// repetition interval (700 ms; not applied to still pictures).
129const DEFAULT_PTS_REPETITION_LIMIT_MS: u64 = 700;
130
131/// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — NIT_actual maximum repetition
132/// interval (10 s; EN 300 468 §5.2.1).
133const DEFAULT_SI_NIT_INTERVAL_SECS: u64 = 10;
134
135/// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — SDT_actual maximum repetition
136/// interval (2 s; EN 300 468 §5.2.2).
137const DEFAULT_SI_SDT_INTERVAL_SECS: u64 = 2;
138
139/// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — EIT P/F actual maximum
140/// repetition interval (2 s; EN 300 468 §5.2.4).
141const DEFAULT_SI_EIT_PF_INTERVAL_SECS: u64 = 2;
142
143/// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — TDT maximum repetition
144/// interval (30 s; EN 300 468 §5.2.5).
145const DEFAULT_SI_TDT_INTERVAL_SECS: u64 = 30;
146
147/// TR 101 290 v1.4.1 Table 5.0c indicator 3.4 / note 1 — Unreferenced_PID
148/// persistence threshold (0,5 s). Transitions shorter than this (e.g. a PID
149/// seen briefly before its PMT arrives) are not errors.
150const DEFAULT_UNREFERENCED_PID_PERIOD_MS: u64 = 500;
151
152// ── PCR / PES constants ─────────────────────────────────────────────────────
153
154/// PCR modulus on the 27 MHz clock: `2^33 × 300` (33-bit base × 300 ticks).
155/// ISO/IEC 13818-1 §2.4.3.5 — PCR wraps modulo this value.
156const PCR_MODULUS_27MHZ: u64 = (1u64 << 33) * 300;
157
158/// 27 MHz clock rate (ticks per second).
159const CLOCK_27MHZ: u64 = 27_000_000;
160
161/// PES start-code prefix byte 0 (ISO/IEC 13818-1 §2.4.3.7 Table 2-18).
162const PES_PREFIX_0: u8 = 0x00;
163/// PES start-code prefix byte 1.
164const PES_PREFIX_1: u8 = 0x00;
165/// PES start-code prefix byte 2.
166const PES_PREFIX_2: u8 = 0x01;
167
168/// Offset of the PES header `marker_bits + flags` byte relative to the PES
169/// packet start (byte 6: `'10' + PES_scrambling_control + …`).
170const PES_FLAGS_OFFSET: usize = 6;
171
172/// Mask for the `PTS_DTS_flags` field within the PES header byte at offset 7
173/// (bits `[7:6]` — `0b10` means PTS present, `0b11` means PTS+DTS).
174const PES_PTS_DTS_FLAGS_MASK: u8 = 0b1100_0000;
175
176/// Value indicating PTS is present in `PTS_DTS_flags` (bit 7 set).
177const PES_PTS_PRESENT: u8 = 0b1000_0000;
178
179/// CAT `table_id` value (ISO/IEC 13818-1 §2.4.4.5).
180const CAT_TABLE_ID: u8 = dvb_si::table_id::TableId::Cat as u8;
181
182/// NIT_actual `table_id` (EN 300 468 §5.2.1, table_id 0x40).
183const NIT_ACTUAL_TABLE_ID: u8 = dvb_si::table_id::TableId::NetworkInformationActual as u8;
184
185/// SDT_actual `table_id` (EN 300 468 §5.2.2, table_id 0x42).
186const SDT_ACTUAL_TABLE_ID: u8 = dvb_si::table_id::TableId::ServiceDescriptionActual as u8;
187
188/// EIT P/F actual `table_id` (EN 300 468 §5.2.4, table_id 0x4E).
189const EIT_PF_ACTUAL_TABLE_ID: u8 = dvb_si::table_id::TableId::EventInformationPfActual as u8;
190
191/// TDT `table_id` (EN 300 468 §5.2.5, table_id 0x70).
192const TDT_TABLE_ID: u8 = dvb_si::table_id::TableId::TimeAndDate as u8;
193
194/// NIT_other `table_id` (EN 300 468 §5.2.1, table_id 0x41).
195const NIT_OTHER_TABLE_ID: u8 = dvb_si::table_id::TableId::NetworkInformationOther as u8;
196
197/// SDT_other `table_id` (EN 300 468 §5.2.3, table_id 0x46).
198const SDT_OTHER_TABLE_ID: u8 = dvb_si::table_id::TableId::ServiceDescriptionOther as u8;
199
200/// BAT `table_id` (EN 300 468 §5.2.2, table_id 0x4A — allowed alongside SDT
201/// on PID 0x0011).
202const BAT_TABLE_ID: u8 = dvb_si::table_id::TableId::BouquetAssociation as u8;
203
204/// EIT P/F other `table_id` (EN 300 468 §5.2.4, table_id 0x4F).
205const EIT_PF_OTHER_TABLE_ID: u8 = dvb_si::table_id::TableId::EventInformationPfOther as u8;
206
207/// RST `table_id` (EN 300 468 §5.2.8, table_id 0x71).
208const RST_TABLE_ID: u8 = dvb_si::table_id::TableId::RunningStatus as u8;
209
210/// Stuffing table (`ST`) `table_id` — allowed filler on every SI PID covered
211/// by Table 5.0c (EN 300 468 §5.2.9, table_id 0x72).
212const STUFFING_TABLE_ID: u8 = dvb_si::table_id::TableId::Stuffing as u8;
213
214/// TOT `table_id` (EN 300 468 §5.2.6, table_id 0x73 — allowed alongside TDT
215/// on PID 0x0014).
216const TOT_TABLE_ID: u8 = dvb_si::table_id::TableId::TimeOffset as u8;
217
218// ── Public types ─────────────────────────────────────────────────────────────
219
220/// Severity tier per TR 101 290 §5.2 (Tables 5.0a/5.0b/5.0c).
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222#[cfg_attr(feature = "serde", derive(serde::Serialize))]
223#[non_exhaustive]
224pub enum Priority {
225    /// Table 5.0a — necessary for de-codability.
226    First,
227    /// Table 5.0b — recommended for continuous or periodic monitoring.
228    Second,
229    /// Table 5.0c — application-dependant monitoring.
230    Third,
231}
232
233impl Priority {
234    /// Human-readable spec label (TR 101 290 §5.2, Tables 5.0a/5.0b/5.0c).
235    #[must_use]
236    pub fn name(&self) -> &'static str {
237        match self {
238            Self::First => "first priority",
239            Self::Second => "second priority",
240            Self::Third => "third priority",
241        }
242    }
243}
244broadcast_common::impl_spec_display!(Priority);
245
246/// A TR 101 290 measurement indicator.
247///
248/// `#[non_exhaustive]` — additional Priority-3 variants may be added later.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
250#[cfg_attr(feature = "serde", derive(serde::Serialize))]
251#[non_exhaustive]
252pub enum Indicator {
253    // ── Priority 1 (Table 5.0a) ──────────────────────────────────────────
254    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.1 — loss of synchronisation
255    /// with hysteresis.
256    TsSyncLoss,
257    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.2 — sync_byte not equal 0x47.
258    SyncByteError,
259    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.3.a — PAT_error_2.
260    PatError2,
261    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.4 — Continuity_count_error.
262    ContinuityCountError,
263    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.5.a — PMT_error_2.
264    PmtError2,
265    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.6 — PID_error.
266    PidError,
267
268    // ── Priority 2 (Table 5.0b) ──────────────────────────────────────────
269    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.1 — Transport_error.
270    TransportError,
271    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.2 — CRC_error.
272    CrcError,
273    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.3a — PCR_repetition_error.
274    PcrRepetitionError,
275    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.3b —
276    /// PCR_discontinuity_indicator_error.
277    PcrDiscontinuityError,
278    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.5 — PTS_error.
279    PtsError,
280    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.6 — CAT_error.
281    CatError,
282
283    // ── Priority 3 (Table 5.0c) ──────────────────────────────────────────
284    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.1 — NIT_error (bad table_id
285    /// on PID 0x0010 + NIT_actual absence; the 25 ms min-gap dimension of
286    /// 3.1.a is deferred).
287    NitError,
288    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — SI_repetition_error
289    /// (maximum interval dimension; minimum-gap deferred).
290    SiRepetitionError,
291    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.4 — Unreferenced_PID: a PID
292    /// persists longer than the presence threshold without being referenced
293    /// by the PAT/CAT/a PMT or one of the well-known SI PIDs.
294    UnreferencedPid,
295    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.5 — SDT_error (bad table_id
296    /// on PID 0x0011 + SDT_actual absence; the 25 ms min-gap dimension of
297    /// 3.5.a is deferred).
298    SdtError,
299    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.6 — EIT_error (bad table_id
300    /// on PID 0x0012 + EIT P/F actual absence; the 25 ms min-gap dimension of
301    /// 3.6.a and the P/F pairing check 3.6.c are deferred).
302    EitError,
303    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.7 — RST_error (bad table_id
304    /// on PID 0x0013; the 25 ms min-gap dimension is deferred).
305    RstError,
306    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.8 — TDT_error (bad table_id
307    /// on PID 0x0014 + TDT absence; the 25 ms min-gap dimension is deferred).
308    TdtError,
309}
310
311impl Indicator {
312    /// The priority tier this indicator belongs to.
313    #[must_use]
314    pub fn priority(self) -> Priority {
315        match self {
316            Self::TsSyncLoss
317            | Self::SyncByteError
318            | Self::PatError2
319            | Self::ContinuityCountError
320            | Self::PmtError2
321            | Self::PidError => Priority::First,
322            Self::TransportError
323            | Self::CrcError
324            | Self::PcrRepetitionError
325            | Self::PcrDiscontinuityError
326            | Self::PtsError
327            | Self::CatError => Priority::Second,
328            Self::NitError
329            | Self::SiRepetitionError
330            | Self::UnreferencedPid
331            | Self::SdtError
332            | Self::EitError
333            | Self::RstError
334            | Self::TdtError => Priority::Third,
335        }
336    }
337
338    /// Verbatim indicator name from the TR 101 290 tables.
339    #[must_use]
340    pub fn name(self) -> &'static str {
341        match self {
342            Self::TsSyncLoss => "TS_sync_loss",
343            Self::SyncByteError => "Sync_byte_error",
344            Self::PatError2 => "PAT_error_2",
345            Self::ContinuityCountError => "Continuity_count_error",
346            Self::PmtError2 => "PMT_error_2",
347            Self::PidError => "PID_error",
348            Self::TransportError => "Transport_error",
349            Self::CrcError => "CRC_error",
350            Self::PcrRepetitionError => "PCR_repetition_error",
351            Self::PcrDiscontinuityError => "PCR_discontinuity_indicator_error",
352            Self::PtsError => "PTS_error",
353            Self::CatError => "CAT_error",
354            Self::NitError => "NIT_error",
355            Self::SiRepetitionError => "SI_repetition_error",
356            Self::UnreferencedPid => "Unreferenced_PID",
357            Self::SdtError => "SDT_error",
358            Self::EitError => "EIT_error",
359            Self::RstError => "RST_error",
360            Self::TdtError => "TDT_error",
361        }
362    }
363
364    /// Clause citation from the spec.
365    #[must_use]
366    pub fn clause(self) -> &'static str {
367        match self {
368            Self::TsSyncLoss => "TR 101 290 v1.4.1 Table 5.0a indicator 1.1",
369            Self::SyncByteError => "TR 101 290 v1.4.1 Table 5.0a indicator 1.2",
370            Self::PatError2 => "TR 101 290 v1.4.1 Table 5.0a indicator 1.3.a",
371            Self::ContinuityCountError => "TR 101 290 v1.4.1 Table 5.0a indicator 1.4",
372            Self::PmtError2 => "TR 101 290 v1.4.1 Table 5.0a indicator 1.5.a",
373            Self::PidError => "TR 101 290 v1.4.1 Table 5.0a indicator 1.6",
374            Self::TransportError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.1",
375            Self::CrcError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.2",
376            Self::PcrRepetitionError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.3a",
377            Self::PcrDiscontinuityError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.3b",
378            Self::PtsError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.5",
379            Self::CatError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.6",
380            Self::NitError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.1",
381            Self::SiRepetitionError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.2",
382            Self::UnreferencedPid => "TR 101 290 v1.4.1 Table 5.0c indicator 3.4",
383            Self::SdtError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.5",
384            Self::EitError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.6",
385            Self::RstError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.7",
386            Self::TdtError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.8",
387        }
388    }
389}
390broadcast_common::impl_spec_display!(Indicator);
391
392/// One raised conformance error.
393#[derive(Debug, Clone, PartialEq, Eq)]
394#[cfg_attr(feature = "serde", derive(serde::Serialize))]
395#[non_exhaustive]
396pub struct ConformanceEvent {
397    /// The indicator that was raised.
398    pub indicator: Indicator,
399    /// Priority tier of the indicator.
400    pub priority: Priority,
401    /// PID the error concerns, when applicable.
402    pub pid: Option<u16>,
403    /// Caller timestamp of the packet that raised it.
404    pub at: Duration,
405    /// Human-readable specifics (e.g. "expected cc=5, got 7").
406    pub detail: String,
407}
408
409/// Diagnostic counters.
410#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
411#[cfg_attr(feature = "serde", derive(serde::Serialize))]
412#[non_exhaustive]
413pub struct Stats {
414    /// Total TS packets fed.
415    pub packets: u64,
416    /// Total conformance events raised.
417    pub events: u64,
418    /// Whether the monitor is currently in sync.
419    pub in_sync: bool,
420}
421
422/// Configurable hysteresis and timeout parameters.
423#[derive(Debug, Clone)]
424#[non_exhaustive]
425pub struct Config {
426    /// Maximum interval between PAT sections (Table 5.0a 1.3.a / note 3).
427    /// Default: 500 ms.
428    pub pat_max_interval: Duration,
429    /// Maximum interval between PMT sections per program_map_PID (1.5.a).
430    /// Default: 500 ms.
431    pub pmt_max_interval: Duration,
432    /// Period after which a referenced PID is considered absent (1.6).
433    /// Default: 5 s.
434    pub pid_error_period: Duration,
435    /// Consecutive good sync bytes to acquire sync (1.1).
436    /// Default: 5.
437    pub sync_acquire_packets: u8,
438    /// Consecutive bad sync bytes to declare sync loss (1.1).
439    /// Default: 2.
440    pub sync_loss_packets: u8,
441    /// Maximum interval between consecutive PCR values on a single PID
442    /// (Table 5.0b 2.3a / note 2). Default: 100 ms.
443    pub pcr_repetition_limit: Duration,
444    /// Maximum legal PCR delta (in time) without a signalled discontinuity
445    /// (Table 5.0b 2.3b). Default: 100 ms.
446    pub pcr_discontinuity_limit: Duration,
447    /// Maximum interval between consecutive PTS values on an elementary-stream
448    /// PID (Table 5.0b 2.5 / note 3). Default: 700 ms.
449    pub pts_repetition_limit: Duration,
450    /// Maximum repetition interval for NIT_actual sections (Table 5.0c 3.2 /
451    /// EN 300 468 §5.2.1). Default: 10 s.
452    pub si_nit_interval: Duration,
453    /// Maximum repetition interval for SDT_actual sections (Table 5.0c 3.2 /
454    /// EN 300 468 §5.2.2). Default: 2 s.
455    pub si_sdt_interval: Duration,
456    /// Maximum repetition interval for EIT P/F actual sections (Table 5.0c
457    /// 3.2 / EN 300 468 §5.2.4). Default: 2 s.
458    pub si_eit_pf_interval: Duration,
459    /// Maximum repetition interval for TDT sections (Table 5.0c 3.2 /
460    /// EN 300 468 §5.2.5). Default: 30 s.
461    pub si_tdt_interval: Duration,
462    /// Persistence threshold before an unreferenced PID is flagged
463    /// (Table 5.0c 3.4 / note 1). Default: 500 ms.
464    pub unreferenced_pid_period: Duration,
465}
466
467impl Default for Config {
468    fn default() -> Self {
469        Self {
470            pat_max_interval: Duration::from_millis(DEFAULT_PAT_MAX_INTERVAL_MS),
471            pmt_max_interval: Duration::from_millis(DEFAULT_PMT_MAX_INTERVAL_MS),
472            pid_error_period: Duration::from_secs(DEFAULT_PID_ERROR_PERIOD_SECS),
473            sync_acquire_packets: DEFAULT_SYNC_ACQUIRE_PACKETS,
474            sync_loss_packets: DEFAULT_SYNC_LOSS_PACKETS,
475            pcr_repetition_limit: Duration::from_millis(DEFAULT_PCR_REPETITION_LIMIT_MS),
476            pcr_discontinuity_limit: Duration::from_millis(DEFAULT_PCR_DISCONTINUITY_LIMIT_MS),
477            pts_repetition_limit: Duration::from_millis(DEFAULT_PTS_REPETITION_LIMIT_MS),
478            si_nit_interval: Duration::from_secs(DEFAULT_SI_NIT_INTERVAL_SECS),
479            si_sdt_interval: Duration::from_secs(DEFAULT_SI_SDT_INTERVAL_SECS),
480            si_eit_pf_interval: Duration::from_secs(DEFAULT_SI_EIT_PF_INTERVAL_SECS),
481            si_tdt_interval: Duration::from_secs(DEFAULT_SI_TDT_INTERVAL_SECS),
482            unreferenced_pid_period: Duration::from_millis(DEFAULT_UNREFERENCED_PID_PERIOD_MS),
483        }
484    }
485}
486
487// ── Internal per-PID state ───────────────────────────────────────────────────
488
489/// Per-PID continuity-counter tracking state.
490struct CcState {
491    last_cc: u8,
492    had_payload: bool,
493    dup_used: bool,
494    initialised: bool,
495}
496
497/// Timer state for a presence/absence check (shared by 1.3.a, 1.5.a, 1.6).
498struct PresenceTimer {
499    last_seen: Duration,
500    reported: bool,
501}
502
503/// State tracked for each program_map_PID signalled by the PAT.
504struct PmtTracking {
505    timer: PresenceTimer,
506    reassembler: SectionReassembler,
507}
508
509/// State tracked for each elementary-stream PID referenced by a PMT.
510struct EsTracking {
511    timer: PresenceTimer,
512}
513
514/// Per-PID PCR tracking state (indicators 2.3a, 2.3b).
515struct PcrState {
516    last_pcr_27mhz: u64,
517    last_pcr_time: Duration,
518    initialised: bool,
519}
520
521/// Per-PID PTS tracking state (indicator 2.5).
522struct PtsState {
523    last_pts_time: Duration,
524    armed: bool,
525}
526
527/// Per-PID section reassembly state for the well-known SI/PSI PIDs.
528struct SiReassembly {
529    reassembler: SectionReassembler,
530}
531
532/// Timer state for an SI table repetition-interval check (indicator 3.2).
533/// Lazily armed — only starts checking after the first section of that
534/// table_id is seen.
535struct SiRepetitionTimer {
536    last_seen: Duration,
537    reported: bool,
538    armed: bool,
539}
540
541/// Tracking state for a candidate Unreferenced_PID (indicator 3.4): the time
542/// this PID was first observed while NOT part of the referenced set.
543struct UnreferencedPidTracking {
544    first_seen: Duration,
545    reported: bool,
546}
547
548// ── ConformanceMonitor ───────────────────────────────────────────────────────
549
550/// ETSI TR 101 290 transport-stream conformance monitor.
551///
552/// Feed one TS packet at a time via [`feed`](Self::feed); each call returns
553/// the events raised by that packet. The monitor is synchronous and
554/// single-threaded — no interior mutability, no async.
555pub struct ConformanceMonitor {
556    config: Config,
557    events: Vec<ConformanceEvent>,
558    stats: Stats,
559
560    // Sync hysteresis state machine (1.1)
561    in_sync: bool,
562    good_run: u8,
563    bad_run: u8,
564
565    // Per-PID continuity counter (1.4)
566    cc_states: BTreeMap<u16, CcState>,
567
568    // PAT section reassembly + timing (1.3.a)
569    pat_reassembler: SectionReassembler,
570    pat_timer: PresenceTimer,
571
572    // PMT section reassembly + timing per program_map_PID (1.5.a)
573    pmt_trackings: BTreeMap<u16, PmtTracking>,
574
575    // Referenced ES PID timing (1.6)
576    es_trackings: BTreeMap<u16, EsTracking>,
577
578    // Well-known SI/PSI section reassembly + CRC checking (2.2)
579    si_reassemblies: BTreeMap<u16, SiReassembly>,
580
581    // Per-PID PCR tracking (2.3a, 2.3b)
582    pcr_states: BTreeMap<u16, PcrState>,
583
584    // Per-PID PTS tracking (2.5)
585    pts_states: BTreeMap<u16, PtsState>,
586
587    // CAT tracking (2.6)
588    cat_seen: bool,
589    scrambled_without_cat_reported: bool,
590
591    // SI repetition-interval timers keyed by table_id (3.2)
592    si_timers: BTreeMap<u8, SiRepetitionTimer>,
593
594    // Unreferenced_PID candidate tracking (3.4)
595    unreferenced_pid_timers: BTreeMap<u16, UnreferencedPidTracking>,
596}
597
598impl ConformanceMonitor {
599    /// Create a monitor with default configuration.
600    pub fn new() -> Self {
601        Self::with_config(Config::default())
602    }
603
604    /// Create a monitor with the given configuration.
605    pub fn with_config(config: Config) -> Self {
606        let mut si_reassemblies = BTreeMap::new();
607        for &pid in &SI_PIDS {
608            si_reassemblies.insert(
609                pid,
610                SiReassembly {
611                    reassembler: SectionReassembler::default(),
612                },
613            );
614        }
615        Self {
616            config,
617            events: Vec::new(),
618            stats: Stats {
619                packets: 0,
620                events: 0,
621                in_sync: false,
622            },
623            in_sync: false,
624            good_run: 0,
625            bad_run: 0,
626            cc_states: BTreeMap::new(),
627            pat_reassembler: SectionReassembler::default(),
628            pat_timer: PresenceTimer {
629                last_seen: Duration::ZERO,
630                reported: false,
631            },
632            pmt_trackings: BTreeMap::new(),
633            es_trackings: BTreeMap::new(),
634            si_reassemblies,
635            pcr_states: BTreeMap::new(),
636            pts_states: BTreeMap::new(),
637            cat_seen: false,
638            scrambled_without_cat_reported: false,
639            si_timers: BTreeMap::new(),
640            unreferenced_pid_timers: BTreeMap::new(),
641        }
642    }
643
644    /// Feed ONE TS packet (any length; 188 expected) with its caller-supplied
645    /// arrival time `t`.
646    ///
647    /// `t` must be monotonic non-decreasing across calls (documented but not
648    /// enforced). Returns the events raised by this packet.
649    pub fn feed(&mut self, ts_packet: &[u8], t: Duration) -> &[ConformanceEvent] {
650        self.events.clear();
651        self.stats.packets += 1;
652
653        // ── Step 2: Sync byte check (1.2) ─────────────────────────────────
654        let sync_ok = !ts_packet.is_empty() && ts_packet[0] == SYNC_BYTE;
655        if !sync_ok {
656            self.emit(Indicator::SyncByteError, None, t, "sync_byte != 0x47");
657        }
658
659        // ── Step 3: Sync hysteresis state machine (1.1) ──────────────────
660        if sync_ok {
661            self.good_run = self.good_run.saturating_add(1);
662            self.bad_run = 0;
663            if !self.in_sync && self.good_run >= self.config.sync_acquire_packets {
664                self.in_sync = true;
665            }
666        } else {
667            self.bad_run = self.bad_run.saturating_add(1);
668            self.good_run = 0;
669            if self.in_sync && self.bad_run >= self.config.sync_loss_packets {
670                self.in_sync = false;
671                self.emit(
672                    Indicator::TsSyncLoss,
673                    None,
674                    t,
675                    "sync lost after hysteresis threshold",
676                );
677            }
678        }
679
680        // Per the doc: "If indicator 1.1 is activated then all other
681        // indicators are invalid." While not in sync, suppress all other
682        // indicators.
683        if !self.in_sync {
684            return &self.events;
685        }
686
687        // ── Step 4: Parse TS packet ───────────────────────────────────────
688        let packet = match TsPacket::parse(ts_packet) {
689            Ok(p) => p,
690            Err(_) => return &self.events,
691        };
692        let header = &packet.header;
693        let pid = header.pid;
694
695        // ── 2.1 Transport_error (Table 5.0b indicator 2.1) ──────────────
696        if header.tei {
697            self.emit(
698                Indicator::TransportError,
699                Some(pid),
700                t,
701                format!("transport_error_indicator set on PID 0x{pid:04X}"),
702            );
703        }
704
705        // ── Step 5: Continuity_count_error (1.4) ─────────────────────────
706        if pid != PID_NULL {
707            self.check_cc(
708                pid,
709                header.continuity_counter,
710                header.has_payload,
711                t,
712                ts_packet,
713            );
714        }
715
716        // ── Step 7: PAT_error_2 — scrambling check (1.3.a) ──────────────
717        if pid == PID_PAT && header.scrambling != 0 {
718            self.emit(
719                Indicator::PatError2,
720                Some(PID_PAT),
721                t,
722                format!(
723                    "scrambling_control_field != 00 on PID 0x0000 (got {})",
724                    header.scrambling
725                ),
726            );
727        }
728
729        // ── Step 8: PMT_error_2 — scrambling check (1.5.a) ──────────────
730        if self.pmt_trackings.contains_key(&pid) && header.scrambling != 0 {
731            self.emit(
732                Indicator::PmtError2,
733                Some(pid),
734                t,
735                format!("scrambling_control_field != 00 on program_map_PID 0x{pid:04X}"),
736            );
737        }
738
739        // ── 2.6 CAT_error — scrambled packet with no CAT (Table 5.0b 2.6)
740        //
741        // At stream start, scrambled packets may arrive before a CAT section
742        // has been acquired; this check fires once in that case. It re-arms
743        // (see `check_cat_table_id`) when a CAT later appears, so the error is
744        // re-detectable after a CAT section is seen.
745        if header.scrambling != 0 && !self.cat_seen && !self.scrambled_without_cat_reported {
746            self.scrambled_without_cat_reported = true;
747            self.emit(
748                Indicator::CatError,
749                Some(pid),
750                t,
751                format!("scrambled packet on PID 0x{pid:04X} but no CAT seen on PID 0x0001"),
752            );
753        }
754
755        // ── Step 6: Section reassembly — PAT ─────────────────────────────
756        if pid == PID_PAT && header.has_payload {
757            if let Some(payload) = packet.payload {
758                self.pat_reassembler.feed(payload, header.pusi);
759            }
760            self.pat_timer.last_seen = t;
761            self.pat_timer.reported = false;
762            while let Some(section_bytes) = self.pat_reassembler.pop_section() {
763                self.check_crc_and_process_pat(&section_bytes, pid, t);
764            }
765        }
766
767        // ── Step 6b: Section reassembly — PMT PIDs ───────────────────────
768        if self.pmt_trackings.contains_key(&pid) && header.has_payload {
769            if let Some(payload) = packet.payload {
770                if let Some(tracking) = self.pmt_trackings.get_mut(&pid) {
771                    tracking.reassembler.feed(payload, header.pusi);
772                }
773            }
774            let sections: Vec<_> = if let Some(tracking) = self.pmt_trackings.get_mut(&pid) {
775                tracking.timer.last_seen = t;
776                tracking.timer.reported = false;
777                core::iter::from_fn(|| tracking.reassembler.pop_section()).collect()
778            } else {
779                Vec::new()
780            };
781            for section_bytes in &sections {
782                self.check_crc_and_process_pmt(section_bytes, pid, t);
783            }
784        }
785
786        // ── Step 6c: Section reassembly — well-known SI/PSI PIDs (2.2) ───
787        // PAT and PMT PIDs are handled above (they have separate reassembly
788        // for P1 logic). Only process the non-PAT, non-PMT SI PIDs here.
789        if pid != PID_PAT
790            && !self.pmt_trackings.contains_key(&pid)
791            && self.si_reassemblies.contains_key(&pid)
792            && header.has_payload
793        {
794            if let Some(payload) = packet.payload {
795                if let Some(si_ra) = self.si_reassemblies.get_mut(&pid) {
796                    si_ra.reassembler.feed(payload, header.pusi);
797                }
798            }
799            let sections: Vec<_> = if let Some(si_ra) = self.si_reassemblies.get_mut(&pid) {
800                core::iter::from_fn(|| si_ra.reassembler.pop_section()).collect()
801            } else {
802                Vec::new()
803            };
804            for section_bytes in &sections {
805                self.check_crc_for_si(section_bytes, pid, t);
806                self.check_cat_table_id(section_bytes, pid, t);
807                self.check_nit_table_id(section_bytes, pid, t);
808                self.check_sdt_table_id(section_bytes, pid, t);
809                self.check_eit_table_id(section_bytes, pid, t);
810                self.check_rst_table_id(section_bytes, pid, t);
811                self.check_tdt_table_id(section_bytes, pid, t);
812                self.update_si_repetition(section_bytes, pid, t);
813            }
814        }
815        // Also CRC-check completed PAT/PMT sections via the si_reassemblies
816        // map (these share the same PID). PAT and PMT already have their own
817        // reassemblers above — the si_reassemblies entries for those PIDs are
818        // not fed again. CRC checking for PAT/PMT is done inside
819        // check_crc_and_process_pat / check_crc_and_process_pmt.
820
821        // ── Step 9: PID_error — update last_seen for referenced PIDs ─────
822        if let Some(tracking) = self.es_trackings.get_mut(&pid) {
823            tracking.timer.last_seen = t;
824            tracking.timer.reported = false;
825        }
826
827        // ── 2.3a / 2.3b: PCR checks (Table 5.0b indicators 2.3a, 2.3b) ──
828        if let Some(Ok(af)) = packet.adaptation_field() {
829            if let Some(pcr) = af.pcr {
830                self.check_pcr(pid, pcr.as_27mhz(), af.discontinuity_indicator, t);
831            }
832        }
833
834        // ── 2.5: PTS check (Table 5.0b indicator 2.5) ───────────────────
835        if header.pusi
836            && header.scrambling == 0
837            && self.es_trackings.contains_key(&pid)
838            && header.has_payload
839        {
840            if let Some(payload) = packet.payload {
841                self.check_pts(pid, payload, t);
842            }
843        }
844
845        // ── 3.4: Unreferenced_PID bookkeeping ───────────────────────────
846        if pid != PID_NULL {
847            self.track_unreferenced_pid(pid, t);
848        }
849
850        // ── Presence-timeout evaluation (1.3.a, 1.5.a, 1.6, 3.4) ────────
851        self.check_presence_timeouts(t);
852
853        &self.events
854    }
855
856    /// Diagnostic counters.
857    pub fn stats(&self) -> Stats {
858        Stats {
859            in_sync: self.in_sync,
860            ..self.stats
861        }
862    }
863
864    // ── Internal helpers ──────────────────────────────────────────────────
865
866    fn emit(
867        &mut self,
868        indicator: Indicator,
869        pid: Option<u16>,
870        at: Duration,
871        detail: impl Into<String>,
872    ) {
873        let event = ConformanceEvent {
874            indicator,
875            priority: indicator.priority(),
876            pid,
877            at,
878            detail: detail.into(),
879        };
880        self.stats.events += 1;
881        self.events.push(event);
882    }
883
884    /// Continuity_count_error (1.4) check.
885    fn check_cc(&mut self, pid: u16, cc: u8, has_payload: bool, t: Duration, raw: &[u8]) {
886        // Check for discontinuity_indicator in the adaptation field BEFORE
887        // mutating cc_states (avoids holding the entry borrow across self.emit).
888        let discontinuity = if raw.len() >= 5 {
889            let b3 = raw[3];
890            let has_adaptation = (b3 & 0x20) != 0;
891            if has_adaptation {
892                let af_len = raw[4] as usize;
893                if af_len > 0 && raw.len() > 5 {
894                    (raw[5] & 0x80) != 0
895                } else {
896                    false
897                }
898            } else {
899                false
900            }
901        } else {
902            false
903        };
904
905        // Compute what we need from the existing state, then decide.
906        let (expected, is_duplicate, should_emit_dup, should_emit_cc) = {
907            let state = self.cc_states.entry(pid).or_insert_with(|| CcState {
908                last_cc: cc,
909                had_payload: has_payload,
910                dup_used: false,
911                initialised: false,
912            });
913
914            if !state.initialised {
915                state.last_cc = cc;
916                state.had_payload = has_payload;
917                state.dup_used = false;
918                state.initialised = true;
919                return;
920            }
921
922            if discontinuity {
923                // Will update state below — just signal no emit.
924                (0u8, false, false, false)
925            } else {
926                let is_duplicate = cc == state.last_cc && has_payload;
927                let mut should_emit_dup = false;
928                let mut should_emit_cc = false;
929
930                if is_duplicate {
931                    if state.dup_used {
932                        should_emit_dup = true;
933                    }
934                } else {
935                    state.dup_used = false;
936                    let expected = if has_payload {
937                        (state.last_cc.wrapping_add(1)) & 0x0F
938                    } else {
939                        state.last_cc
940                    };
941                    if cc != expected {
942                        should_emit_cc = true;
943                    }
944                }
945
946                (
947                    if has_payload {
948                        (state.last_cc.wrapping_add(1)) & 0x0F
949                    } else {
950                        state.last_cc
951                    },
952                    is_duplicate,
953                    should_emit_dup,
954                    should_emit_cc,
955                )
956            }
957        };
958
959        // Now emit events without holding a borrow on cc_states.
960        if should_emit_dup {
961            self.emit(
962                Indicator::ContinuityCountError,
963                Some(pid),
964                t,
965                format!("second consecutive duplicate on PID 0x{pid:04X} (cc={cc})"),
966            );
967        }
968        if should_emit_cc {
969            self.emit(
970                Indicator::ContinuityCountError,
971                Some(pid),
972                t,
973                format!("expected cc={expected}, got {cc} on PID 0x{pid:04X}"),
974            );
975        }
976
977        // Finally, update state.
978        let state = self.cc_states.get_mut(&pid).unwrap();
979        if discontinuity {
980            state.last_cc = cc;
981            state.had_payload = has_payload;
982            state.dup_used = false;
983        } else if is_duplicate {
984            // First duplicate is legal; mark dup_used but do NOT update last_cc.
985            state.dup_used = true;
986        } else {
987            state.dup_used = false;
988            state.last_cc = cc;
989            state.had_payload = has_payload;
990        }
991    }
992
993    /// CRC-check a completed section and, if on PID_PAT, process it.
994    fn check_crc_and_process_pat(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
995        // 2.2: CRC check on PAT section.
996        self.check_crc_for_section(section_bytes, pid, t);
997
998        self.process_pat_section(section_bytes, t);
999    }
1000
1001    /// CRC-check a completed section and, if on a PMT PID, process it.
1002    fn check_crc_and_process_pmt(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1003        // 2.2: CRC check on PMT section.
1004        self.check_crc_for_section(section_bytes, pid, t);
1005
1006        self.process_pmt_section(section_bytes, pid, t);
1007    }
1008
1009    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.2 — CRC_error.
1010    ///
1011    /// On any tracked PID, if a completed long-form section has a CRC
1012    /// mismatch, emit `CrcError`.
1013    fn check_crc_for_section(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1014        let section = match Section::parse(section_bytes) {
1015            Ok(s) => s,
1016            Err(_) => return,
1017        };
1018
1019        // validate_crc returns Ok for short-form sections (no CRC to check).
1020        if let Err(mpeg_ts::error::Error::CrcMismatch { .. }) = section.validate_crc(section_bytes)
1021        {
1022            self.emit(
1023                Indicator::CrcError,
1024                Some(pid),
1025                t,
1026                format!(
1027                    "CRC-32 mismatch on PID 0x{:04X} (table_id 0x{:02X})",
1028                    pid, section.table_id
1029                ),
1030            );
1031        }
1032    }
1033
1034    /// CRC-check for SI PIDs that are not PAT/PMT (handled via si_reassemblies).
1035    fn check_crc_for_si(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1036        self.check_crc_for_section(section_bytes, pid, t);
1037    }
1038
1039    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.6 — CAT_error, condition 1:
1040    /// section with `table_id != 0x01` on PID_CAT.
1041    fn check_cat_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1042        if pid != PID_CAT {
1043            return;
1044        }
1045        let section = match Section::parse(section_bytes) {
1046            Ok(s) => s,
1047            Err(_) => return,
1048        };
1049        if section.table_id == CAT_TABLE_ID {
1050            // Valid CAT section — mark as seen.
1051            self.cat_seen = true;
1052            // Re-arm the "scrambled without CAT" check so that if a CAT was
1053            // previously absent and then appears, the check resets.
1054            self.scrambled_without_cat_reported = false;
1055        } else {
1056            self.emit(
1057                Indicator::CatError,
1058                Some(PID_CAT),
1059                t,
1060                format!(
1061                    "section with table_id 0x{:02X} on PID 0x0001 (expected 0x01 for CAT)",
1062                    section.table_id
1063                ),
1064            );
1065        }
1066    }
1067
1068    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.1 — NIT_error, bad-table_id
1069    /// dimension. `docs/tr_101_290.md` clause 3.1: allowed table_ids on
1070    /// PID 0x0010 are NIT_actual (0x40), NIT_other (0x41), stuffing/ST
1071    /// (0x72). The absence dimension (NIT_actual missing for the
1072    /// EN 300 468 §5.1.4 repetition interval) is raised from the shared 3.2
1073    /// SI timer in `check_presence_timeouts`.
1074    fn check_nit_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1075        if pid != PID_NIT {
1076            return;
1077        }
1078        let section = match Section::parse(section_bytes) {
1079            Ok(s) => s,
1080            Err(_) => return,
1081        };
1082        let allowed = section.table_id == NIT_ACTUAL_TABLE_ID
1083            || section.table_id == NIT_OTHER_TABLE_ID
1084            || section.table_id == STUFFING_TABLE_ID;
1085        if !allowed {
1086            self.emit(
1087                Indicator::NitError,
1088                Some(PID_NIT),
1089                t,
1090                format!(
1091                    "section with table_id 0x{:02X} on PID 0x0010 (expected NIT_actual/NIT_other/ST)",
1092                    section.table_id
1093                ),
1094            );
1095        }
1096    }
1097
1098    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.5 — SDT_error, bad-table_id
1099    /// dimension. `docs/tr_101_290.md` clause 3.5: allowed table_ids on
1100    /// PID 0x0011 are SDT_actual (0x42), SDT_other (0x46), BAT (0x4A),
1101    /// stuffing/ST (0x72). The absence dimension (SDT_actual missing) is
1102    /// raised from the shared 3.2 SI timer in `check_presence_timeouts`.
1103    fn check_sdt_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1104        if pid != PID_SDT_BAT {
1105            return;
1106        }
1107        let section = match Section::parse(section_bytes) {
1108            Ok(s) => s,
1109            Err(_) => return,
1110        };
1111        let allowed = section.table_id == SDT_ACTUAL_TABLE_ID
1112            || section.table_id == SDT_OTHER_TABLE_ID
1113            || section.table_id == BAT_TABLE_ID
1114            || section.table_id == STUFFING_TABLE_ID;
1115        if !allowed {
1116            self.emit(
1117                Indicator::SdtError,
1118                Some(PID_SDT_BAT),
1119                t,
1120                format!(
1121                    "section with table_id 0x{:02X} on PID 0x0011 (expected SDT_actual/SDT_other/BAT/ST)",
1122                    section.table_id
1123                ),
1124            );
1125        }
1126    }
1127
1128    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.6 — EIT_error, bad-table_id
1129    /// dimension. `docs/tr_101_290.md` clause 3.6: allowed table_ids on
1130    /// PID 0x0012 are EIT P/F actual (0x4E), EIT P/F other (0x4F), EIT
1131    /// schedule actual (0x50..=0x5F), EIT schedule other (0x60..=0x6F),
1132    /// stuffing/ST (0x72). The absence dimension (EIT P/F actual missing) is
1133    /// raised from the shared 3.2 SI timer in `check_presence_timeouts`.
1134    fn check_eit_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1135        if pid != PID_EIT {
1136            return;
1137        }
1138        let section = match Section::parse(section_bytes) {
1139            Ok(s) => s,
1140            Err(_) => return,
1141        };
1142        let table_id = section.table_id;
1143        let allowed = table_id == EIT_PF_ACTUAL_TABLE_ID
1144            || table_id == EIT_PF_OTHER_TABLE_ID
1145            || (dvb_si::tables::eit::TABLE_ID_SCHEDULE_ACTUAL_FIRST
1146                ..=dvb_si::tables::eit::TABLE_ID_SCHEDULE_ACTUAL_LAST)
1147                .contains(&table_id)
1148            || (dvb_si::tables::eit::TABLE_ID_SCHEDULE_OTHER_FIRST
1149                ..=dvb_si::tables::eit::TABLE_ID_SCHEDULE_OTHER_LAST)
1150                .contains(&table_id)
1151            || table_id == STUFFING_TABLE_ID;
1152        if !allowed {
1153            self.emit(
1154                Indicator::EitError,
1155                Some(PID_EIT),
1156                t,
1157                format!(
1158                    "section with table_id 0x{table_id:02X} on PID 0x0012 (expected EIT P/F or schedule range or ST)"
1159                ),
1160            );
1161        }
1162    }
1163
1164    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.7 — RST_error, bad-table_id
1165    /// dimension. `docs/tr_101_290.md` clause 3.7: allowed table_ids on
1166    /// PID 0x0013 are RST (0x71), stuffing/ST (0x72). RST has no documented
1167    /// absence threshold in Table 5.0c, so there is no presence dimension.
1168    fn check_rst_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1169        if pid != PID_RST {
1170            return;
1171        }
1172        let section = match Section::parse(section_bytes) {
1173            Ok(s) => s,
1174            Err(_) => return,
1175        };
1176        let allowed = section.table_id == RST_TABLE_ID || section.table_id == STUFFING_TABLE_ID;
1177        if !allowed {
1178            self.emit(
1179                Indicator::RstError,
1180                Some(PID_RST),
1181                t,
1182                format!(
1183                    "section with table_id 0x{:02X} on PID 0x0013 (expected RST/ST)",
1184                    section.table_id
1185                ),
1186            );
1187        }
1188    }
1189
1190    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.8 — TDT_error, bad-table_id
1191    /// dimension. `docs/tr_101_290.md` clause 3.8: allowed table_ids on
1192    /// PID 0x0014 are TDT (0x70), stuffing/ST (0x72), TOT (0x73). The
1193    /// absence dimension (TDT missing) is raised from the shared 3.2 SI
1194    /// timer in `check_presence_timeouts`.
1195    fn check_tdt_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1196        if pid != PID_TDT_TOT {
1197            return;
1198        }
1199        let section = match Section::parse(section_bytes) {
1200            Ok(s) => s,
1201            Err(_) => return,
1202        };
1203        let allowed = section.table_id == TDT_TABLE_ID
1204            || section.table_id == TOT_TABLE_ID
1205            || section.table_id == STUFFING_TABLE_ID;
1206        if !allowed {
1207            self.emit(
1208                Indicator::TdtError,
1209                Some(PID_TDT_TOT),
1210                t,
1211                format!(
1212                    "section with table_id 0x{:02X} on PID 0x0014 (expected TDT/TOT/ST)",
1213                    section.table_id
1214                ),
1215            );
1216        }
1217    }
1218
1219    /// Whether `pid` is (currently) part of the TR 101 290 v1.4.1 Table 5.0c
1220    /// indicator 3.4 referenced set: PAT, CAT, well-known SI PIDs (NIT/
1221    /// SDT-BAT/EIT/RST/TDT-TOT), the null PID, the reserved-for-future-use
1222    /// range, PMT_PIDs referenced by the PAT, and ES/PCR PIDs referenced by
1223    /// a PMT.
1224    ///
1225    /// Not tracked: CAT-referenced EMM PIDs (this monitor does not decode CA
1226    /// descriptors) and PIDs "user defined as private data streams" (the
1227    /// spec's own carve-out — not distinguishable from the wire alone). Both
1228    /// are documented limitations; see the crate `//!` and README.
1229    fn is_referenced_or_reserved_pid(&self, pid: u16) -> bool {
1230        pid == PID_PAT
1231            || pid == PID_CAT
1232            || pid == PID_NIT
1233            || pid == PID_SDT_BAT
1234            || pid == PID_EIT
1235            || pid == PID_RST
1236            || pid == PID_TDT_TOT
1237            || pid == PID_NULL
1238            || (RESERVED_PID_MIN..=RESERVED_PID_MAX).contains(&pid)
1239            || self.pmt_trackings.contains_key(&pid)
1240            || self.es_trackings.contains_key(&pid)
1241    }
1242
1243    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.4 — Unreferenced_PID
1244    /// bookkeeping. Records the first-seen time of a PID that is not
1245    /// (currently) part of the referenced set. If the PID is already
1246    /// referenced, drops any stale tracking entry. Absence-timeout
1247    /// evaluation happens in `check_presence_timeouts`; a PID that later
1248    /// becomes referenced (see `process_pat_section` / `process_pmt_section`)
1249    /// has its entry removed there.
1250    fn track_unreferenced_pid(&mut self, pid: u16, t: Duration) {
1251        if self.is_referenced_or_reserved_pid(pid) {
1252            self.unreferenced_pid_timers.remove(&pid);
1253            return;
1254        }
1255        self.unreferenced_pid_timers
1256            .entry(pid)
1257            .or_insert_with(|| UnreferencedPidTracking {
1258                first_seen: t,
1259                reported: false,
1260            });
1261    }
1262
1263    /// Process a completed section on PID_PAT.
1264    fn process_pat_section(&mut self, section_bytes: &[u8], t: Duration) {
1265        let section = match Section::parse(section_bytes) {
1266            Ok(s) => s,
1267            Err(_) => return,
1268        };
1269
1270        // 1.3.a: section with table_id other than 0x00 found on PID 0x0000.
1271        if section.table_id != PAT_TABLE_ID {
1272            self.emit(
1273                Indicator::PatError2,
1274                Some(PID_PAT),
1275                t,
1276                format!(
1277                    "section with table_id 0x{:02X} on PID 0x0000 (expected 0x00)",
1278                    section.table_id
1279                ),
1280            );
1281            return;
1282        }
1283
1284        // Parse the PAT proper.
1285        let pat = match PatSection::parse(section_bytes) {
1286            Ok(p) => p,
1287            Err(_) => return,
1288        };
1289
1290        // Discover program_map_PIDs and start tracking them.
1291        for entry in pat.programmes() {
1292            let pmt_pid = entry.pid;
1293            self.pmt_trackings
1294                .entry(pmt_pid)
1295                .or_insert_with(|| PmtTracking {
1296                    timer: PresenceTimer {
1297                        last_seen: t,
1298                        reported: false,
1299                    },
1300                    reassembler: SectionReassembler::default(),
1301                });
1302            // 3.4: a program_map_PID is now referenced — it is no longer an
1303            // Unreferenced_PID candidate even if its own packets have not
1304            // been observed yet.
1305            self.unreferenced_pid_timers.remove(&pmt_pid);
1306        }
1307    }
1308
1309    /// Process a completed section on a program_map_PID.
1310    fn process_pmt_section(&mut self, section_bytes: &[u8], _pid: u16, t: Duration) {
1311        let section = match Section::parse(section_bytes) {
1312            Ok(s) => s,
1313            Err(_) => return,
1314        };
1315
1316        // 1.5.a only checks presence and scrambling of table_id 0x02 sections.
1317        // If table_id is not 0x02, skip — we don't emit PMT_error_2 for a
1318        // wrong table_id on a program_map_PID (that's not in the spec for
1319        // 1.5.a).
1320        let pmt_table_id: u8 = dvb_si::tables::pmt::TABLE_ID;
1321        if section.table_id != pmt_table_id {
1322            return;
1323        }
1324
1325        // Parse the PMT proper.
1326        let pmt = match PmtSection::parse(section_bytes) {
1327            Ok(p) => p,
1328            Err(_) => return,
1329        };
1330
1331        // Collect new ES PIDs to add.
1332        let mut new_es_pids: Vec<u16> = Vec::new();
1333        if pmt.pcr_pid != PID_NULL && !self.es_trackings.contains_key(&pmt.pcr_pid) {
1334            new_es_pids.push(pmt.pcr_pid);
1335        }
1336        for stream in &pmt.streams {
1337            let es_pid = stream.elementary_pid;
1338            if !self.es_trackings.contains_key(&es_pid) {
1339                new_es_pids.push(es_pid);
1340            }
1341        }
1342
1343        for es_pid in new_es_pids {
1344            self.es_trackings.insert(
1345                es_pid,
1346                EsTracking {
1347                    timer: PresenceTimer {
1348                        last_seen: t,
1349                        reported: false,
1350                    },
1351                },
1352            );
1353            // 3.4: an ES/PCR PID is now referenced by a PMT — no longer an
1354            // Unreferenced_PID candidate.
1355            self.unreferenced_pid_timers.remove(&es_pid);
1356        }
1357    }
1358
1359    /// TR 101 290 v1.4.1 Table 5.0b indicators 2.3a / 2.3b — PCR checks.
1360    fn check_pcr(&mut self, pid: u16, pcr_27mhz: u64, discontinuity: bool, t: Duration) {
1361        let state = self.pcr_states.entry(pid).or_insert_with(|| PcrState {
1362            last_pcr_27mhz: 0,
1363            last_pcr_time: Duration::ZERO,
1364            initialised: false,
1365        });
1366
1367        if !state.initialised {
1368            state.last_pcr_27mhz = pcr_27mhz;
1369            state.last_pcr_time = t;
1370            state.initialised = true;
1371            return;
1372        }
1373
1374        // Snapshot state for decision-making before any emit.
1375        let last_pcr_time = state.last_pcr_time;
1376        let last_pcr_27mhz = state.last_pcr_27mhz;
1377
1378        // 2.3a: PCR_repetition_error — interval between consecutive PCR
1379        // values exceeds the configured limit.
1380        let rep_interval = t.saturating_sub(last_pcr_time);
1381        let should_emit_rep = rep_interval > self.config.pcr_repetition_limit;
1382
1383        // 2.3b: PCR_discontinuity_indicator_error — PCR delta exceeds 100 ms
1384        // without a signalled discontinuity.
1385        let delta =
1386            (pcr_27mhz.wrapping_add(PCR_MODULUS_27MHZ) - last_pcr_27mhz) % PCR_MODULUS_27MHZ;
1387        let delta_ms = delta * 1000 / CLOCK_27MHZ;
1388        let limit_ms = self.config.pcr_discontinuity_limit.as_millis() as u64;
1389        let should_emit_disc = delta_ms > limit_ms && !discontinuity;
1390
1391        // Emit outside the HashMap borrow.
1392        if should_emit_rep {
1393            self.emit(
1394                Indicator::PcrRepetitionError,
1395                Some(pid),
1396                t,
1397                format!(
1398                    "PCR interval {} ms exceeds limit {} ms on PID 0x{:04X}",
1399                    rep_interval.as_millis(),
1400                    self.config.pcr_repetition_limit.as_millis(),
1401                    pid
1402                ),
1403            );
1404        }
1405        if should_emit_disc {
1406            self.emit(
1407                Indicator::PcrDiscontinuityError,
1408                Some(pid),
1409                t,
1410                format!(
1411                    "PCR delta {delta_ms} ms exceeds limit {limit_ms} ms on PID 0x{pid:04X} without discontinuity_indicator"
1412                ),
1413            );
1414        }
1415
1416        // Update state.
1417        let state = self.pcr_states.get_mut(&pid).unwrap();
1418        state.last_pcr_27mhz = pcr_27mhz;
1419        state.last_pcr_time = t;
1420    }
1421
1422    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.5 — PTS_error.
1423    ///
1424    /// Peeks the PES header on an elementary-stream PID for PTS_DTS_flags.
1425    /// Only checks PIDs that have been "armed" by seeing at least one PTS.
1426    fn check_pts(&mut self, pid: u16, payload: &[u8], t: Duration) {
1427        // PES start-code prefix: 00 00 01.
1428        if payload.len() < PES_FLAGS_OFFSET + 2 {
1429            return;
1430        }
1431        if payload[0] != PES_PREFIX_0 || payload[1] != PES_PREFIX_1 || payload[2] != PES_PREFIX_2 {
1432            return;
1433        }
1434
1435        // Byte 6: `'10' + flags` — the top two bits must be `10`.
1436        let flags_byte = payload[PES_FLAGS_OFFSET];
1437        if (flags_byte >> 6) != 0b10 {
1438            return;
1439        }
1440
1441        // Byte 7: PTS_DTS_flags in bits `[7:6]`.
1442        let pts_dts_flags = payload[PES_FLAGS_OFFSET + 1] & PES_PTS_DTS_FLAGS_MASK;
1443        let pts_present = (pts_dts_flags & PES_PTS_PRESENT) != 0;
1444        if !pts_present {
1445            return;
1446        }
1447
1448        let state = self.pts_states.entry(pid).or_insert_with(|| PtsState {
1449            last_pts_time: Duration::ZERO,
1450            armed: false,
1451        });
1452
1453        if !state.armed {
1454            // First PTS on this PID — arm the check, no error yet.
1455            state.last_pts_time = t;
1456            state.armed = true;
1457            return;
1458        }
1459
1460        // Snapshot state for decision-making before any emit.
1461        let last_pts_time = state.last_pts_time;
1462        let pts_interval = t.saturating_sub(last_pts_time);
1463        let should_emit = pts_interval > self.config.pts_repetition_limit;
1464
1465        if should_emit {
1466            self.emit(
1467                Indicator::PtsError,
1468                Some(pid),
1469                t,
1470                format!(
1471                    "PTS interval {} ms exceeds limit {} ms on PID 0x{:04X}",
1472                    pts_interval.as_millis(),
1473                    self.config.pts_repetition_limit.as_millis(),
1474                    pid
1475                ),
1476            );
1477        }
1478
1479        // Update state.
1480        let state = self.pts_states.get_mut(&pid).unwrap();
1481        state.last_pts_time = t;
1482    }
1483
1484    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — update SI repetition timer
1485    /// when a completed section on a well-known SI PID matches one of the four
1486    /// tracked table_ids.
1487    fn update_si_repetition(&mut self, section_bytes: &[u8], _pid: u16, t: Duration) {
1488        let table_id = match Section::parse(section_bytes) {
1489            Ok(s) => s.table_id,
1490            Err(_) => return,
1491        };
1492
1493        let is_tracked = table_id == NIT_ACTUAL_TABLE_ID
1494            || table_id == SDT_ACTUAL_TABLE_ID
1495            || table_id == EIT_PF_ACTUAL_TABLE_ID
1496            || table_id == TDT_TABLE_ID;
1497
1498        if !is_tracked {
1499            return;
1500        }
1501
1502        let timer = self
1503            .si_timers
1504            .entry(table_id)
1505            .or_insert_with(|| SiRepetitionTimer {
1506                last_seen: Duration::ZERO,
1507                reported: false,
1508                armed: false,
1509            });
1510
1511        timer.last_seen = t;
1512        timer.reported = false;
1513        timer.armed = true;
1514    }
1515
1516    /// Evaluate all presence/absence timeouts against the current time `t`.
1517    fn check_presence_timeouts(&mut self, t: Duration) {
1518        // 1.3.a: PAT presence timeout
1519        if t.saturating_sub(self.pat_timer.last_seen) > self.config.pat_max_interval
1520            && !self.pat_timer.reported
1521        {
1522            self.pat_timer.reported = true;
1523            self.emit(
1524                Indicator::PatError2,
1525                Some(PID_PAT),
1526                t,
1527                format!(
1528                    "no PAT section within {} ms",
1529                    self.config.pat_max_interval.as_millis()
1530                ),
1531            );
1532        }
1533
1534        // 1.5.a: PMT presence timeout per program_map_PID
1535        // Collect PIDs that need events, then emit outside the iteration.
1536        let pmt_timeouts: Vec<(u16, u64)> = self
1537            .pmt_trackings
1538            .iter()
1539            .filter_map(|(&pid, tracking)| {
1540                if t.saturating_sub(tracking.timer.last_seen) > self.config.pmt_max_interval
1541                    && !tracking.timer.reported
1542                {
1543                    Some((pid, self.config.pmt_max_interval.as_millis() as u64))
1544                } else {
1545                    None
1546                }
1547            })
1548            .collect();
1549        for (pid, interval_ms) in pmt_timeouts {
1550            if let Some(tracking) = self.pmt_trackings.get_mut(&pid) {
1551                tracking.timer.reported = true;
1552            }
1553            self.emit(
1554                Indicator::PmtError2,
1555                Some(pid),
1556                t,
1557                format!("no PMT section on program_map_PID 0x{pid:04X} within {interval_ms} ms"),
1558            );
1559        }
1560
1561        // 1.6: PID_error — referenced PID absence
1562        let pid_timeouts: Vec<(u16, u64)> = self
1563            .es_trackings
1564            .iter()
1565            .filter_map(|(&pid, tracking)| {
1566                if t.saturating_sub(tracking.timer.last_seen) > self.config.pid_error_period
1567                    && !tracking.timer.reported
1568                {
1569                    Some((pid, self.config.pid_error_period.as_secs()))
1570                } else {
1571                    None
1572                }
1573            })
1574            .collect();
1575        for (pid, period_secs) in pid_timeouts {
1576            if let Some(tracking) = self.es_trackings.get_mut(&pid) {
1577                tracking.timer.reported = true;
1578            }
1579            self.emit(
1580                Indicator::PidError,
1581                Some(pid),
1582                t,
1583                format!("referenced PID 0x{pid:04X} absent for > {period_secs} s"),
1584            );
1585        }
1586
1587        // 3.2: SI_repetition_error — maximum interval for tracked SI tables.
1588        // Collect table_ids that need events, then emit outside the iteration.
1589        let si_timeouts: Vec<(u8, u64, u16, u64)> = self
1590            .si_timers
1591            .iter()
1592            .filter_map(|(&table_id, timer)| {
1593                if !timer.armed || timer.reported {
1594                    return None;
1595                }
1596                let (limit, pid) = match table_id {
1597                    NIT_ACTUAL_TABLE_ID => (self.config.si_nit_interval, PID_NIT),
1598                    SDT_ACTUAL_TABLE_ID => (self.config.si_sdt_interval, PID_SDT_BAT),
1599                    EIT_PF_ACTUAL_TABLE_ID => (self.config.si_eit_pf_interval, PID_EIT),
1600                    TDT_TABLE_ID => (self.config.si_tdt_interval, PID_TDT_TOT),
1601                    _ => return None,
1602                };
1603                let interval = t.saturating_sub(timer.last_seen);
1604                if interval > limit {
1605                    Some((
1606                        table_id,
1607                        interval.as_millis() as u64,
1608                        pid,
1609                        limit.as_millis() as u64,
1610                    ))
1611                } else {
1612                    None
1613                }
1614            })
1615            .collect();
1616        for (table_id, interval_ms, pid, limit_ms) in si_timeouts {
1617            if let Some(timer) = self.si_timers.get_mut(&table_id) {
1618                timer.reported = true;
1619            }
1620            // The four `_actual` table_ids share this ONE lazily-armed
1621            // timer: an absence past the interval is simultaneously
1622            // SI_repetition_error (3.2, the general repetition-rate
1623            // indicator, emitted unconditionally below) AND the presence
1624            // dimension of the corresponding table-specific indicator
1625            // (NIT_error 3.1 / SDT_error 3.5 / EIT_error 3.6 / TDT_error 3.8)
1626            // — the same underlying absence, per the spec's historical
1627            // combined indicator vs. the split `_actual` variants (see
1628            // docs/tr_101_290.md notes 2-4).
1629            let (table_name, group_indicator) = match table_id {
1630                NIT_ACTUAL_TABLE_ID => ("NIT_actual", Some(Indicator::NitError)),
1631                SDT_ACTUAL_TABLE_ID => ("SDT_actual", Some(Indicator::SdtError)),
1632                EIT_PF_ACTUAL_TABLE_ID => ("EIT_P/F_actual", Some(Indicator::EitError)),
1633                TDT_TABLE_ID => ("TDT", Some(Indicator::TdtError)),
1634                _ => ("unknown", None),
1635            };
1636            self.emit(
1637                Indicator::SiRepetitionError,
1638                Some(pid),
1639                t,
1640                format!("{table_name} repetition interval {interval_ms} ms exceeds {limit_ms} ms"),
1641            );
1642            if let Some(indicator) = group_indicator {
1643                self.emit(
1644                    indicator,
1645                    Some(pid),
1646                    t,
1647                    format!("no {table_name} section on PID 0x{pid:04X} within {limit_ms} ms"),
1648                );
1649            }
1650        }
1651
1652        // 3.4: Unreferenced_PID — a PID persisting beyond the presence
1653        // threshold without being referenced by the PAT/CAT/a PMT or one of
1654        // the well-known SI PIDs.
1655        let unref_timeouts: Vec<(u16, u64)> = self
1656            .unreferenced_pid_timers
1657            .iter()
1658            .filter_map(|(&pid, timer)| {
1659                if timer.reported {
1660                    return None;
1661                }
1662                let elapsed = t.saturating_sub(timer.first_seen);
1663                if elapsed > self.config.unreferenced_pid_period {
1664                    Some((pid, self.config.unreferenced_pid_period.as_millis() as u64))
1665                } else {
1666                    None
1667                }
1668            })
1669            .collect();
1670        for (pid, period_ms) in unref_timeouts {
1671            if let Some(timer) = self.unreferenced_pid_timers.get_mut(&pid) {
1672                timer.reported = true;
1673            }
1674            self.emit(
1675                Indicator::UnreferencedPid,
1676                Some(pid),
1677                t,
1678                format!(
1679                    "PID 0x{pid:04X} present for > {period_ms} ms without being referenced by PAT/CAT/a PMT or a well-known SI PID"
1680                ),
1681            );
1682        }
1683    }
1684}
1685
1686impl Default for ConformanceMonitor {
1687    fn default() -> Self {
1688        Self::new()
1689    }
1690}
1691
1692#[cfg(test)]
1693mod tests;