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