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