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