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