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