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//! **third-priority** (Table 5.0c, indicators 3.1–3.10) indicator sets —
6//! see `docs/tr_101_290.md` for the full spec transcription and the
7//! crate-coverage mapping.
8//!
9//! ## T-STD buffer model (indicators 3.3, 3.9, 3.10)
10//!
11//! A partial ISO/IEC 13818-1 T-STD buffer model (see `src/tstd.rs`) drives
12//! the buffer-model indicators:
13//!
14//! - **3.3 `BufferError`**: TBsys overflow detection (512-byte buffer at
15//!   1 Mbit/s drain, fed at PSI section completion). TBn overflow is deferred
16//!   — it requires the coded bitrate `Rxn` from descriptors.
17//! - **3.9 `EmptyBufferError`**: TBn (per-PID) and TBsys (global) empty at
18//!   least once per second. MBn empty check is deferred.
19//! - **3.10 `Data_delay_error`**: Data delay > 1 s through TBn and TBsys.
20//!   Still-picture 60 s threshold is tracked but not yet differentiated.
21//! - **2.4 `PcrAccuracyError`**: not implemented — requires hardware arrival
22//!   timestamps with ±500 ns resolution. The variant exists for documentation
23//!   completeness only.
24//!
25//! MBn/EBn/Bn/Bsys buffer modelling is deferred — it requires codec-level
26//! buffer sizes from descriptors (multiplex_buffer_descriptor,
27//! smoothing_buffer_descriptor) not yet parsed by the monitor.
28//!
29//! # Caller-supplied time
30//!
31//! [`ConformanceMonitor::feed`] takes a [`core::time::Duration`] timestamp
32//! alongside each TS packet. All presence/absence timeout checks (1.3.a, 1.5.a,
33//! 1.6, 2.3a, 2.3b, 2.5, 3.2) are evaluated against this clock. The caller
34//! must ensure that timestamps are **monotonic non-decreasing** across calls;
35//! the monitor does not enforce this but non-monotonic timestamps will produce
36//! spurious events.
37//!
38//! # References
39//!
40//! - ETSI TR 101 290 v1.4.1 (2020-06), §5.2.1, Table 5.0a
41//! - ETSI TR 101 290 v1.4.1 (2020-06), §5.2.2, Table 5.0b
42//! - ETSI TR 101 290 v1.4.1 (2020-06), §5.2.3, Table 5.0c
43//! - ISO/IEC 13818-1 (MPEG-2 Systems)
44
45#![cfg_attr(not(feature = "std"), no_std)]
46#![cfg_attr(docsrs, feature(doc_cfg))]
47// Runnable examples, embedded so they render on docs.rs and stay in sync with
48// the actual `examples/*.rs` files (shown, not compiled).
49#![doc = "\n# Examples\n"]
50#![doc = "Two runnable examples ship with this crate (`cargo run -p dvb-conformance --example <name>`).\n"]
51#![doc = "\n## `monitor_stream`\n\n```rust,ignore"]
52#![doc = include_str!("../examples/monitor_stream.rs")]
53#![doc = "```\n\n## `priority_breakdown`\n\n```rust,ignore"]
54#![doc = include_str!("../examples/priority_breakdown.rs")]
55#![doc = "```"]
56extern crate alloc;
57
58mod tstd;
59
60use alloc::collections::BTreeMap;
61use alloc::format;
62use alloc::string::String;
63use alloc::vec::Vec;
64use core::time::Duration;
65
66use tstd::TstdModel;
67
68use broadcast_common::Parse;
69use dvb_si::tables::eit::EitSection;
70use dvb_si::tables::pat::{PatSection, TABLE_ID as PAT_TABLE_ID};
71use dvb_si::tables::pmt::PmtSection;
72use mpeg_ts::section::Section;
73use mpeg_ts::ts::{SectionReassembler, TsPacket};
74
75// ── Named PID constants ─────────────────────────────────────────────────────
76
77/// PID 0x0000 — Program Association Table (ISO/IEC 13818-1 §2.4.4.3).
78const PID_PAT: u16 = 0x0000;
79/// PID 0x0001 — Conditional Access Table (ISO/IEC 13818-1 §2.4.4.5).
80const PID_CAT: u16 = 0x0001;
81/// PID 0x0010 — Network Information Table (EN 300 468 §5.2.1).
82const PID_NIT: u16 = 0x0010;
83/// PID 0x0011 — SDT/BAT (EN 300 468 §5.2.2 / §5.2.3).
84const PID_SDT_BAT: u16 = 0x0011;
85/// PID 0x0012 — Event Information Table (EN 300 468 §5.2.4).
86const PID_EIT: u16 = 0x0012;
87/// PID 0x0013 — Running Status Table (EN 300 468 §5.2.8).
88const PID_RST: u16 = 0x0013;
89/// PID 0x0014 — TDT/TOT (EN 300 468 §5.2.5 / §5.2.6).
90const PID_TDT_TOT: u16 = 0x0014;
91/// PID 0x1FFF — Null/padding packets (ISO/IEC 13818-1 §2.4.3.3).
92const PID_NULL: u16 = 0x1FFF;
93
94/// Sync byte value (ISO/IEC 13818-1 §2.4.3.3).
95const SYNC_BYTE: u8 = 0x47;
96
97/// Well-known SI/PSI PIDs on which CRC-checked long-form sections appear.
98const SI_PIDS: [u16; 7] = [
99    PID_PAT,
100    PID_CAT,
101    PID_NIT,
102    PID_SDT_BAT,
103    PID_EIT,
104    PID_RST,
105    PID_TDT_TOT,
106];
107
108/// Reserved-for-future-use PID range (ISO/IEC 13818-1 Table 2-3) — exempt
109/// from Unreferenced_PID (TR 101 290 v1.4.1 Table 5.0c indicator 3.4).
110const RESERVED_PID_MIN: u16 = 0x0002;
111/// Upper (inclusive) bound of the reserved PID range.
112const RESERVED_PID_MAX: u16 = 0x000F;
113
114// ── Default timing constants ────────────────────────────────────────────────
115
116/// TR 101 290 v1.4.1 Table 5.0a note 3 / TS 101 154 §4.1.7 — PAT maximum
117/// interval (0.5 s per Table 5.0a row 1.3.a; TS 101 154 recommends ≤ 100 ms).
118const DEFAULT_PAT_MAX_INTERVAL_MS: u64 = 500;
119
120/// TR 101 290 v1.4.1 Table 5.0a row 1.5.a / note 3 — PMT maximum interval.
121const DEFAULT_PMT_MAX_INTERVAL_MS: u64 = 500;
122
123/// TR 101 290 v1.4.1 §5.2.1 accompanying text (1.6) — PID_error period.
124const DEFAULT_PID_ERROR_PERIOD_SECS: u64 = 5;
125
126/// TR 101 290 v1.4.1 §5.2.1 accompanying text (1.1) — sync acquisition
127/// threshold: five consecutive correct sync bytes.
128const DEFAULT_SYNC_ACQUIRE_PACKETS: u8 = 5;
129
130/// TR 101 290 v1.4.1 §5.2.1 accompanying text (1.1) — sync loss threshold:
131/// two or more consecutive corrupted sync bytes.
132const DEFAULT_SYNC_LOSS_PACKETS: u8 = 2;
133
134/// TR 101 290 v1.4.1 Table 5.0b indicator 2.3a / note 2 — PCR maximum
135/// repetition interval (100 ms; note 2 removed the 40 ms limit).
136const DEFAULT_PCR_REPETITION_LIMIT_MS: u64 = 100;
137
138/// TR 101 290 v1.4.1 Table 5.0b indicator 2.3b — PCR discontinuity indicator
139/// maximum interval (100 ms).
140const DEFAULT_PCR_DISCONTINUITY_LIMIT_MS: u64 = 100;
141
142/// TR 101 290 v1.4.1 Table 5.0b indicator 2.5 / note 3 — PTS maximum
143/// repetition interval (700 ms; not applied to still pictures).
144const DEFAULT_PTS_REPETITION_LIMIT_MS: u64 = 700;
145
146/// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — NIT_actual maximum repetition
147/// interval (10 s; EN 300 468 §5.2.1).
148const DEFAULT_SI_NIT_INTERVAL_SECS: u64 = 10;
149
150/// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — SDT_actual maximum repetition
151/// interval (2 s; EN 300 468 §5.2.2).
152const DEFAULT_SI_SDT_INTERVAL_SECS: u64 = 2;
153
154/// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — EIT P/F actual maximum
155/// repetition interval (2 s; EN 300 468 §5.2.4).
156const DEFAULT_SI_EIT_PF_INTERVAL_SECS: u64 = 2;
157
158/// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — TDT maximum repetition
159/// interval (30 s; EN 300 468 §5.2.5).
160const DEFAULT_SI_TDT_INTERVAL_SECS: u64 = 30;
161
162/// TR 101 290 v1.4.1 Table 5.0c indicator 3.4 / note 1 — Unreferenced_PID
163/// persistence threshold (0,5 s). Transitions shorter than this (e.g. a PID
164/// seen briefly before its PMT arrives) are not errors.
165const DEFAULT_UNREFERENCED_PID_PERIOD_MS: u64 = 500;
166
167// ── 25 ms minimum-gap constants (Table 5.0c rows 3.1.a / 3.2 / 3.5.a / 3.6.a / 3.7 / 3.8)
168
169/// TR 101 290 v1.4.1 Table 5.0c — minimum interval between sections of the
170/// same `(table_id, section_number)` on the same PID (25 ms).
171const DEFAULT_SI_MIN_GAP_MS: u64 = 25;
172
173/// TR 101 290 v1.4.1 Table 5.0c indicator 3.1.b / 3.5.b / 3.6.b — maximum
174/// interval between `_other` sections with the same `section_number` on the
175/// same PID (10 s per ETSI TR 101 211 §4.4).
176const DEFAULT_SI_OTHER_INTERVAL_SECS: u64 = 10;
177
178// ── PCR / PES constants ─────────────────────────────────────────────────────
179
180/// PCR modulus on the 27 MHz clock: `2^33 × 300` (33-bit base × 300 ticks).
181/// ISO/IEC 13818-1 §2.4.3.5 — PCR wraps modulo this value.
182const PCR_MODULUS_27MHZ: u64 = (1u64 << 33) * 300;
183
184/// 27 MHz clock rate (ticks per second).
185const CLOCK_27MHZ: u64 = 27_000_000;
186
187/// PES start-code prefix byte 0 (ISO/IEC 13818-1 §2.4.3.6, Table 2-21).
188const PES_PREFIX_0: u8 = 0x00;
189/// PES start-code prefix byte 1.
190const PES_PREFIX_1: u8 = 0x00;
191/// PES start-code prefix byte 2.
192const PES_PREFIX_2: u8 = 0x01;
193
194/// Offset of the PES header `marker_bits + flags` byte relative to the PES
195/// packet start (byte 6: `'10' + PES_scrambling_control + …`).
196const PES_FLAGS_OFFSET: usize = 6;
197
198/// Mask for the `PTS_DTS_flags` field within the PES header byte at offset 7
199/// (bits `[7:6]` — `0b10` means PTS present, `0b11` means PTS+DTS).
200const PES_PTS_DTS_FLAGS_MASK: u8 = 0b1100_0000;
201
202/// Value indicating PTS is present in `PTS_DTS_flags` (bit 7 set).
203const PES_PTS_PRESENT: u8 = 0b1000_0000;
204
205/// CAT `table_id` value (ISO/IEC 13818-1 §2.4.4.5).
206const CAT_TABLE_ID: u8 = dvb_si::table_id::TableId::Cat as u8;
207
208/// NIT_actual `table_id` (EN 300 468 §5.2.1, table_id 0x40).
209const NIT_ACTUAL_TABLE_ID: u8 = dvb_si::table_id::TableId::NetworkInformationActual as u8;
210
211/// SDT_actual `table_id` (EN 300 468 §5.2.2, table_id 0x42).
212const SDT_ACTUAL_TABLE_ID: u8 = dvb_si::table_id::TableId::ServiceDescriptionActual as u8;
213
214/// EIT P/F actual `table_id` (EN 300 468 §5.2.4, table_id 0x4E).
215const EIT_PF_ACTUAL_TABLE_ID: u8 = dvb_si::table_id::TableId::EventInformationPfActual as u8;
216
217/// TDT `table_id` (EN 300 468 §5.2.5, table_id 0x70).
218const TDT_TABLE_ID: u8 = dvb_si::table_id::TableId::TimeAndDate as u8;
219
220/// NIT_other `table_id` (EN 300 468 §5.2.1, table_id 0x41).
221const NIT_OTHER_TABLE_ID: u8 = dvb_si::table_id::TableId::NetworkInformationOther as u8;
222
223/// SDT_other `table_id` (EN 300 468 §5.2.3, table_id 0x46).
224const SDT_OTHER_TABLE_ID: u8 = dvb_si::table_id::TableId::ServiceDescriptionOther as u8;
225
226/// BAT `table_id` (EN 300 468 §5.2.2, table_id 0x4A — allowed alongside SDT
227/// on PID 0x0011).
228const BAT_TABLE_ID: u8 = dvb_si::table_id::TableId::BouquetAssociation as u8;
229
230/// EIT P/F other `table_id` (EN 300 468 §5.2.4, table_id 0x4F).
231const EIT_PF_OTHER_TABLE_ID: u8 = dvb_si::table_id::TableId::EventInformationPfOther as u8;
232
233/// RST `table_id` (EN 300 468 §5.2.8, table_id 0x71).
234const RST_TABLE_ID: u8 = dvb_si::table_id::TableId::RunningStatus as u8;
235
236/// Stuffing table (`ST`) `table_id` — allowed filler on every SI PID covered
237/// by Table 5.0c (EN 300 468 §5.2.9, table_id 0x72).
238const STUFFING_TABLE_ID: u8 = dvb_si::table_id::TableId::Stuffing as u8;
239
240/// TOT `table_id` (EN 300 468 §5.2.6, table_id 0x73 — allowed alongside TDT
241/// on PID 0x0014).
242const TOT_TABLE_ID: u8 = dvb_si::table_id::TableId::TimeOffset as u8;
243
244// ── Public types ─────────────────────────────────────────────────────────────
245
246/// Severity tier per TR 101 290 §5.2 (Tables 5.0a/5.0b/5.0c).
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248#[cfg_attr(feature = "serde", derive(serde::Serialize))]
249#[non_exhaustive]
250pub enum Priority {
251    /// Table 5.0a — necessary for de-codability.
252    First,
253    /// Table 5.0b — recommended for continuous or periodic monitoring.
254    Second,
255    /// Table 5.0c — application-dependant monitoring.
256    Third,
257}
258
259impl Priority {
260    /// Human-readable spec label (TR 101 290 §5.2, Tables 5.0a/5.0b/5.0c).
261    #[must_use]
262    pub fn name(&self) -> &'static str {
263        match self {
264            Self::First => "first priority",
265            Self::Second => "second priority",
266            Self::Third => "third priority",
267        }
268    }
269}
270broadcast_common::impl_spec_display!(Priority);
271
272/// A TR 101 290 measurement indicator.
273///
274/// `#[non_exhaustive]` — additional Priority-3 variants may be added later.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
276#[cfg_attr(feature = "serde", derive(serde::Serialize))]
277#[non_exhaustive]
278pub enum Indicator {
279    // ── Priority 1 (Table 5.0a) ──────────────────────────────────────────
280    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.1 — loss of synchronisation
281    /// with hysteresis.
282    TsSyncLoss,
283    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.2 — sync_byte not equal 0x47.
284    SyncByteError,
285    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.3.a — PAT_error_2.
286    PatError2,
287    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.4 — Continuity_count_error.
288    ContinuityCountError,
289    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.5.a — PMT_error_2.
290    PmtError2,
291    /// TR 101 290 v1.4.1 Table 5.0a indicator 1.6 — PID_error.
292    PidError,
293
294    // ── Priority 2 (Table 5.0b) ──────────────────────────────────────────
295    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.1 — Transport_error.
296    TransportError,
297    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.2 — CRC_error.
298    CrcError,
299    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.3a — PCR_repetition_error.
300    PcrRepetitionError,
301    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.3b —
302    /// PCR_discontinuity_indicator_error.
303    PcrDiscontinuityError,
304    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.5 — PTS_error.
305    PtsError,
306    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.6 — CAT_error.
307    CatError,
308
309    // ── Priority 3 (Table 5.0c) ──────────────────────────────────────────
310    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.1 — NIT_error (bad table_id
311    /// on PID 0x0010 + NIT_actual absence).
312    NitError,
313    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — SI_repetition_error
314    /// (maximum interval dimension).
315    SiRepetitionError,
316    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.4 — Unreferenced_PID: a PID
317    /// persists longer than the presence threshold without being referenced
318    /// by the PAT/CAT/a PMT or one of the well-known SI PIDs.
319    UnreferencedPid,
320    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.5 — SDT_error (bad table_id
321    /// on PID 0x0011 + SDT_actual absence).
322    SdtError,
323    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.6 — EIT_error (bad table_id
324    /// on PID 0x0012 + EIT P/F actual absence).
325    EitError,
326    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.7 — RST_error (bad table_id
327    /// on PID 0x0013).
328    RstError,
329    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.8 — TDT_error (bad table_id
330    /// on PID 0x0014 + TDT absence).
331    TdtError,
332
333    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.6.c — EIT_PF_error: if either
334    /// section ('0' P or '1' F) of an EIT P/F sub-table is present, both
335    /// should exist.
336    EitPfError,
337
338    /// TR 101 290 v1.4.1 Table 5.0c — NIT_other_error (3.1.b): interval between
339    /// sections with the same `section_number` and `table_id = 0x41` on
340    /// PID 0x0010 longer than 10 s.
341    NitOtherError,
342    /// TR 101 290 v1.4.1 Table 5.0c — SDT_other_error (3.5.b): interval between
343    /// sections with the same `section_number` and `table_id = 0x46` on
344    /// PID 0x0011 longer than 10 s.
345    SdtOtherError,
346    /// TR 101 290 v1.4.1 Table 5.0c — EIT_other_error (3.6.b): interval between
347    /// EIT P/F other sections with the same `section_number` and
348    /// `table_id = 0x4F` on PID 0x0012 longer than 10 s.
349    EitOtherError,
350
351    /// TR 101 290 v1.4.1 Table 5.0c — min-gap violation: two sections of the
352    /// same `(table_id, section_number)` occurred closer together than the
353    /// specified minimum interval (25 ms per Table 5.0c rows 3.1.a, 3.2,
354    /// 3.5.a, 3.6.a, 3.7, 3.8).
355    SiMinGapError,
356
357    // ── Priority 2 (Table 5.0b) — T-STD ──────────────────────────────────
358    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.4 — PCR_accuracy_error.
359    ///
360    /// **Not implemented**: requires hardware arrival timing with ±500 ns
361    /// resolution (ISO/IEC 13818-1 §2.4.2.2). A packet-index-derived arrival
362    /// estimate cannot honestly resolve 500 ns, and a false positive is worse
363    /// than a gap (c.f. the withdrawn `PtsCheck`). This variant exists for
364    /// documentation completeness only and is never emitted by the monitor.
365    PcrAccuracyError,
366
367    // ── Priority 3 (Table 5.0c) — T-STD ──────────────────────────────────
368    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.3 — Buffer_error.
369    ///
370    /// Currently checks `TB_buffering_error` (overflow of transport buffer
371    /// TBn, 512 bytes per ISO/IEC 13818-1 §2.4.2.3) and
372    /// `TBsys_buffering_error` (overflow of TBsys, 512 bytes). The remaining
373    /// sub-checks — `MB_buffering_error`, `EB_buffering_error`,
374    /// `B_buffering_error`, `Bsys_buffering_error` — require codec-dependent
375    /// buffer sizes from descriptors (multiplex_buffer_descriptor,
376    /// smoothing_buffer_descriptor) and are deferred until the monitor
377    /// parses those descriptors.
378    BufferError,
379
380    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.9 — Empty_buffer_error.
381    ///
382    /// Checks that TBn (transport buffer for each elementary stream) and
383    /// TBsys (system-information transport buffer) are empty at least once
384    /// per second. The MBn check (multiplexing buffer, leak method) is
385    /// deferred — it requires the full MBn buffer model with leak-rate
386    /// parameters from descriptors.
387    EmptyBufferError,
388
389    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.10 — Data_delay_error.
390    ///
391    /// Checks that data delay through the T-STD transport buffers (TBn,
392    /// TBsys) does not exceed 1 second (60 s for still-picture video).
393    /// The full end-to-end delay through MBn/EBn/Bn is not modelled
394    /// (deferred — see `Buffer_error` doc).
395    DataDelayError,
396}
397
398impl Indicator {
399    /// The priority tier this indicator belongs to.
400    #[must_use]
401    pub fn priority(self) -> Priority {
402        match self {
403            Self::TsSyncLoss
404            | Self::SyncByteError
405            | Self::PatError2
406            | Self::ContinuityCountError
407            | Self::PmtError2
408            | Self::PidError => Priority::First,
409            Self::TransportError
410            | Self::CrcError
411            | Self::PcrRepetitionError
412            | Self::PcrDiscontinuityError
413            | Self::PtsError
414            | Self::CatError
415            | Self::PcrAccuracyError => Priority::Second,
416            Self::NitError
417            | Self::SiRepetitionError
418            | Self::UnreferencedPid
419            | Self::SdtError
420            | Self::EitError
421            | Self::RstError
422            | Self::TdtError
423            | Self::BufferError
424            | Self::EmptyBufferError
425            | Self::DataDelayError
426            | Self::EitPfError
427            | Self::NitOtherError
428            | Self::SdtOtherError
429            | Self::EitOtherError
430            | Self::SiMinGapError => Priority::Third,
431        }
432    }
433
434    /// Verbatim indicator name from the TR 101 290 tables.
435    #[must_use]
436    pub fn name(self) -> &'static str {
437        match self {
438            Self::TsSyncLoss => "TS_sync_loss",
439            Self::SyncByteError => "Sync_byte_error",
440            Self::PatError2 => "PAT_error_2",
441            Self::ContinuityCountError => "Continuity_count_error",
442            Self::PmtError2 => "PMT_error_2",
443            Self::PidError => "PID_error",
444            Self::TransportError => "Transport_error",
445            Self::CrcError => "CRC_error",
446            Self::PcrRepetitionError => "PCR_repetition_error",
447            Self::PcrDiscontinuityError => "PCR_discontinuity_indicator_error",
448            Self::PtsError => "PTS_error",
449            Self::CatError => "CAT_error",
450            Self::NitError => "NIT_error",
451            Self::SiRepetitionError => "SI_repetition_error",
452            Self::UnreferencedPid => "Unreferenced_PID",
453            Self::SdtError => "SDT_error",
454            Self::EitError => "EIT_error",
455            Self::RstError => "RST_error",
456            Self::TdtError => "TDT_error",
457            Self::PcrAccuracyError => "PCR_accuracy_error",
458            Self::BufferError => "Buffer_error",
459            Self::EmptyBufferError => "Empty_buffer_error",
460            Self::DataDelayError => "Data_delay_error",
461            Self::EitPfError => "EIT_PF_error",
462            Self::NitOtherError => "NIT_other_error",
463            Self::SdtOtherError => "SDT_other_error",
464            Self::EitOtherError => "EIT_other_error",
465            Self::SiMinGapError => "SI_min_gap_error",
466        }
467    }
468
469    /// Clause citation from the spec.
470    #[must_use]
471    pub fn clause(self) -> &'static str {
472        match self {
473            Self::TsSyncLoss => "TR 101 290 v1.4.1 Table 5.0a indicator 1.1",
474            Self::SyncByteError => "TR 101 290 v1.4.1 Table 5.0a indicator 1.2",
475            Self::PatError2 => "TR 101 290 v1.4.1 Table 5.0a indicator 1.3.a",
476            Self::ContinuityCountError => "TR 101 290 v1.4.1 Table 5.0a indicator 1.4",
477            Self::PmtError2 => "TR 101 290 v1.4.1 Table 5.0a indicator 1.5.a",
478            Self::PidError => "TR 101 290 v1.4.1 Table 5.0a indicator 1.6",
479            Self::TransportError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.1",
480            Self::CrcError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.2",
481            Self::PcrRepetitionError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.3a",
482            Self::PcrDiscontinuityError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.3b",
483            Self::PtsError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.5",
484            Self::CatError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.6",
485            Self::NitError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.1",
486            Self::SiRepetitionError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.2",
487            Self::UnreferencedPid => "TR 101 290 v1.4.1 Table 5.0c indicator 3.4",
488            Self::SdtError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.5",
489            Self::EitError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.6",
490            Self::RstError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.7",
491            Self::TdtError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.8",
492            Self::PcrAccuracyError => "TR 101 290 v1.4.1 Table 5.0b indicator 2.4",
493            Self::BufferError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.3",
494            Self::EmptyBufferError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.9",
495            Self::DataDelayError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.10",
496            Self::EitPfError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.6.c",
497            Self::NitOtherError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.1.b",
498            Self::SdtOtherError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.5.b",
499            Self::EitOtherError => "TR 101 290 v1.4.1 Table 5.0c indicator 3.6.b",
500            Self::SiMinGapError => {
501                "TR 101 290 v1.4.1 Table 5.0c (25 ms minimum-gap dimension of 3.1.a / 3.2 / 3.5.a / 3.6.a / 3.7 / 3.8)"
502            }
503        }
504    }
505}
506broadcast_common::impl_spec_display!(Indicator);
507
508/// One raised conformance error.
509#[derive(Debug, Clone, PartialEq, Eq)]
510#[cfg_attr(feature = "serde", derive(serde::Serialize))]
511#[non_exhaustive]
512pub struct ConformanceEvent {
513    /// The indicator that was raised.
514    pub indicator: Indicator,
515    /// Priority tier of the indicator.
516    pub priority: Priority,
517    /// PID the error concerns, when applicable.
518    pub pid: Option<u16>,
519    /// Caller timestamp of the packet that raised it.
520    pub at: Duration,
521    /// Human-readable specifics (e.g. "expected cc=5, got 7").
522    pub detail: String,
523}
524
525/// Diagnostic counters.
526#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
527#[cfg_attr(feature = "serde", derive(serde::Serialize))]
528#[non_exhaustive]
529pub struct Stats {
530    /// Total TS packets fed.
531    pub packets: u64,
532    /// Total conformance events raised.
533    pub events: u64,
534    /// Whether the monitor is currently in sync.
535    pub in_sync: bool,
536}
537
538/// Configurable hysteresis and timeout parameters.
539#[derive(Debug, Clone)]
540#[non_exhaustive]
541pub struct Config {
542    /// Maximum interval between PAT sections (Table 5.0a 1.3.a / note 3).
543    /// Default: 500 ms.
544    pub pat_max_interval: Duration,
545    /// Maximum interval between PMT sections per program_map_PID (1.5.a).
546    /// Default: 500 ms.
547    pub pmt_max_interval: Duration,
548    /// Period after which a referenced PID is considered absent (1.6).
549    /// Default: 5 s.
550    pub pid_error_period: Duration,
551    /// Consecutive good sync bytes to acquire sync (1.1).
552    /// Default: 5.
553    pub sync_acquire_packets: u8,
554    /// Consecutive bad sync bytes to declare sync loss (1.1).
555    /// Default: 2.
556    pub sync_loss_packets: u8,
557    /// Maximum interval between consecutive PCR values on a single PID
558    /// (Table 5.0b 2.3a / note 2). Default: 100 ms.
559    pub pcr_repetition_limit: Duration,
560    /// Maximum legal PCR delta (in time) without a signalled discontinuity
561    /// (Table 5.0b 2.3b). Default: 100 ms.
562    pub pcr_discontinuity_limit: Duration,
563    /// Maximum interval between consecutive PTS values on an elementary-stream
564    /// PID (Table 5.0b 2.5 / note 3). Default: 700 ms.
565    pub pts_repetition_limit: Duration,
566    /// Maximum repetition interval for NIT_actual sections (Table 5.0c 3.2 /
567    /// EN 300 468 §5.2.1). Default: 10 s.
568    pub si_nit_interval: Duration,
569    /// Maximum repetition interval for SDT_actual sections (Table 5.0c 3.2 /
570    /// EN 300 468 §5.2.2). Default: 2 s.
571    pub si_sdt_interval: Duration,
572    /// Maximum repetition interval for EIT P/F actual sections (Table 5.0c
573    /// 3.2 / EN 300 468 §5.2.4). Default: 2 s.
574    pub si_eit_pf_interval: Duration,
575    /// Maximum repetition interval for TDT sections (Table 5.0c 3.2 /
576    /// EN 300 468 §5.2.5). Default: 30 s.
577    pub si_tdt_interval: Duration,
578    /// Persistence threshold before an unreferenced PID is flagged
579    /// (Table 5.0c 3.4 / note 1). Default: 500 ms.
580    pub unreferenced_pid_period: Duration,
581    /// Minimum allowed interval between sections of the same
582    /// `(table_id, section_number)` on the same PID
583    /// (Table 5.0c rows 3.1.a / 3.2 / 3.5.a / 3.6.a / 3.7 / 3.8).
584    /// Default: 25 ms.
585    pub si_min_gap: Duration,
586    /// Maximum interval between `_other` sections with the same
587    /// `section_number` (Table 5.0c indicators 3.1.b / 3.5.b / 3.6.b).
588    /// Default: 10 s per ETSI TR 101 211 §4.4.
589    pub si_other_interval: Duration,
590}
591
592impl Default for Config {
593    fn default() -> Self {
594        Self {
595            pat_max_interval: Duration::from_millis(DEFAULT_PAT_MAX_INTERVAL_MS),
596            pmt_max_interval: Duration::from_millis(DEFAULT_PMT_MAX_INTERVAL_MS),
597            pid_error_period: Duration::from_secs(DEFAULT_PID_ERROR_PERIOD_SECS),
598            sync_acquire_packets: DEFAULT_SYNC_ACQUIRE_PACKETS,
599            sync_loss_packets: DEFAULT_SYNC_LOSS_PACKETS,
600            pcr_repetition_limit: Duration::from_millis(DEFAULT_PCR_REPETITION_LIMIT_MS),
601            pcr_discontinuity_limit: Duration::from_millis(DEFAULT_PCR_DISCONTINUITY_LIMIT_MS),
602            pts_repetition_limit: Duration::from_millis(DEFAULT_PTS_REPETITION_LIMIT_MS),
603            si_nit_interval: Duration::from_secs(DEFAULT_SI_NIT_INTERVAL_SECS),
604            si_sdt_interval: Duration::from_secs(DEFAULT_SI_SDT_INTERVAL_SECS),
605            si_eit_pf_interval: Duration::from_secs(DEFAULT_SI_EIT_PF_INTERVAL_SECS),
606            si_tdt_interval: Duration::from_secs(DEFAULT_SI_TDT_INTERVAL_SECS),
607            unreferenced_pid_period: Duration::from_millis(DEFAULT_UNREFERENCED_PID_PERIOD_MS),
608            si_min_gap: Duration::from_millis(DEFAULT_SI_MIN_GAP_MS),
609            si_other_interval: Duration::from_secs(DEFAULT_SI_OTHER_INTERVAL_SECS),
610        }
611    }
612}
613
614// ── Internal per-PID state ───────────────────────────────────────────────────
615
616/// Per-PID continuity-counter tracking state.
617struct CcState {
618    last_cc: u8,
619    had_payload: bool,
620    dup_used: bool,
621    initialised: bool,
622    /// Raw bytes of the last payload-bearing packet seen on this PID. Used
623    /// with [`broadcast_common::ts_dup::check_duplicate`] to test the
624    /// H.222.0 §2.4.3.3 "legal duplicate" byte-identity rule (that function
625    /// owns locating and exempting the PCR field). Empty until the first
626    /// packet is recorded.
627    last_packet: Vec<u8>,
628}
629
630/// Timer state for a presence/absence check (shared by 1.3.a, 1.5.a, 1.6).
631struct PresenceTimer {
632    last_seen: Duration,
633    reported: bool,
634}
635
636/// State tracked for each program_map_PID signalled by the PAT.
637struct PmtTracking {
638    timer: PresenceTimer,
639    reassembler: SectionReassembler,
640}
641
642/// State tracked for each elementary-stream PID referenced by a PMT.
643struct EsTracking {
644    timer: PresenceTimer,
645}
646
647/// Per-PID PCR tracking state (indicators 2.3a, 2.3b).
648struct PcrState {
649    last_pcr_27mhz: u64,
650    last_pcr_time: Duration,
651    initialised: bool,
652}
653
654/// Per-PID PTS tracking state (indicator 2.5).
655struct PtsState {
656    last_pts_time: Duration,
657    armed: bool,
658}
659
660/// Per-PID section reassembly state for the well-known SI/PSI PIDs.
661struct SiReassembly {
662    reassembler: SectionReassembler,
663}
664
665/// Timer state for an SI table repetition-interval check (indicator 3.2).
666/// Lazily armed — only starts checking after the first section of that
667/// table_id is seen.
668struct SiRepetitionTimer {
669    last_seen: Duration,
670    reported: bool,
671    armed: bool,
672}
673
674/// Tracking state for a candidate Unreferenced_PID (indicator 3.4): the time
675/// this PID was first observed while NOT part of the referenced set.
676struct UnreferencedPidTracking {
677    first_seen: Duration,
678    reported: bool,
679}
680
681/// Per-`(table_id, section_number)` last-seen tracking for the 25 ms
682/// minimum-gap dimension (indicators 3.1.a / 3.2 / 3.5.a / 3.6.a / 3.7 / 3.8).
683/// Keyed on the well-known PID + `(table_id, section_number)` so a dense
684/// multi-section table does not produce false positives.
685#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
686struct MinGapKey {
687    pid: u16,
688    table_id: u8,
689    section_number: u8,
690}
691
692/// State for a single `_other` section_number repetition check (indicators
693/// 3.1.b / 3.5.b / 3.6.b). Lazily armed — only checks after the second
694/// occurrence of the section is seen (so a stream that legitimately carries no
695/// `_other` table is never flagged).
696struct OtherRepetitionState {
697    /// How many times this `(table_id, section_number)` has been seen.
698    occurrences: u64,
699    /// Time of the most recent section.
700    last_seen: Duration,
701    /// Whether the absence has already been reported for this key.
702    reported: bool,
703}
704
705/// Per-sub-table EIT P/F pairing state (indicator 3.6.c).
706/// A sub-table is identified by `(service_id, transport_stream_id,
707/// original_network_id)`.
708#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
709struct EitSubTableKey {
710    service_id: u16,
711    transport_stream_id: u16,
712    original_network_id: u16,
713}
714
715/// Tracking for whether section '0' (present) and/or section '1' (following)
716/// have been seen for a given EIT P/F sub-table.
717struct EitPfPairState {
718    has_section_0: bool,
719    has_section_1: bool,
720    /// True once the pairing error has been emitted for this sub-table.
721    reported: bool,
722    /// Timestamp when the first incomplete observation was made (one section
723    /// seen without the other). The error fires only after the EIT P/F
724    /// repetition interval (2 s) has elapsed since this timestamp, giving the
725    /// partner section time to arrive.
726    first_incomplete_at: Option<Duration>,
727}
728
729// ── ConformanceMonitor ───────────────────────────────────────────────────────
730
731/// ETSI TR 101 290 transport-stream conformance monitor.
732///
733/// Feed one TS packet at a time via [`feed`](Self::feed); each call returns
734/// the events raised by that packet. The monitor is synchronous and
735/// single-threaded — no interior mutability, no async.
736pub struct ConformanceMonitor {
737    config: Config,
738    events: Vec<ConformanceEvent>,
739    stats: Stats,
740
741    // Sync hysteresis state machine (1.1)
742    in_sync: bool,
743    good_run: u8,
744    bad_run: u8,
745
746    // Per-PID continuity counter (1.4)
747    cc_states: BTreeMap<u16, CcState>,
748
749    // PAT section reassembly + timing (1.3.a)
750    pat_reassembler: SectionReassembler,
751    pat_timer: PresenceTimer,
752
753    // PMT section reassembly + timing per program_map_PID (1.5.a)
754    pmt_trackings: BTreeMap<u16, PmtTracking>,
755
756    // Referenced ES PID timing (1.6)
757    es_trackings: BTreeMap<u16, EsTracking>,
758
759    // Well-known SI/PSI section reassembly + CRC checking (2.2)
760    si_reassemblies: BTreeMap<u16, SiReassembly>,
761
762    // Per-PID PCR tracking (2.3a, 2.3b)
763    pcr_states: BTreeMap<u16, PcrState>,
764
765    // Per-PID PTS tracking (2.5)
766    pts_states: BTreeMap<u16, PtsState>,
767
768    // CAT tracking (2.6)
769    cat_seen: bool,
770    scrambled_without_cat_reported: bool,
771
772    // SI repetition-interval timers keyed by table_id (3.2)
773    si_timers: BTreeMap<u8, SiRepetitionTimer>,
774
775    // Unreferenced_PID candidate tracking (3.4)
776    unreferenced_pid_timers: BTreeMap<u16, UnreferencedPidTracking>,
777
778    // Per-`(table_id, section_number)` min-gap tracking
779    // (25 ms minimum-gap dimension of 3.1.a / 3.2 / 3.5.a / 3.6.a / 3.7 / 3.8)
780    min_gap_timers: BTreeMap<MinGapKey, Duration>,
781
782    // Per-section_number `_other` repetition tracking (3.1.b / 3.5.b / 3.6.b)
783    other_repetition_timers: BTreeMap<(u8, u8), OtherRepetitionState>,
784
785    // EIT P/F pairing tracking per sub-table (3.6.c)
786    eit_pf_pairs: BTreeMap<EitSubTableKey, EitPfPairState>,
787
788    // T-STD buffer model (3.3, 3.9, 3.10)
789    tstd: TstdModel,
790}
791
792impl ConformanceMonitor {
793    /// Create a monitor with default configuration.
794    pub fn new() -> Self {
795        Self::with_config(Config::default())
796    }
797
798    /// Create a monitor with the given configuration.
799    pub fn with_config(config: Config) -> Self {
800        let mut si_reassemblies = BTreeMap::new();
801        for &pid in &SI_PIDS {
802            si_reassemblies.insert(
803                pid,
804                SiReassembly {
805                    reassembler: SectionReassembler::default(),
806                },
807            );
808        }
809        Self {
810            config,
811            events: Vec::new(),
812            stats: Stats {
813                packets: 0,
814                events: 0,
815                in_sync: false,
816            },
817            in_sync: false,
818            good_run: 0,
819            bad_run: 0,
820            cc_states: BTreeMap::new(),
821            pat_reassembler: SectionReassembler::default(),
822            pat_timer: PresenceTimer {
823                last_seen: Duration::ZERO,
824                reported: false,
825            },
826            pmt_trackings: BTreeMap::new(),
827            es_trackings: BTreeMap::new(),
828            si_reassemblies,
829            pcr_states: BTreeMap::new(),
830            pts_states: BTreeMap::new(),
831            cat_seen: false,
832            scrambled_without_cat_reported: false,
833            si_timers: BTreeMap::new(),
834            unreferenced_pid_timers: BTreeMap::new(),
835            min_gap_timers: BTreeMap::new(),
836            other_repetition_timers: BTreeMap::new(),
837            eit_pf_pairs: BTreeMap::new(),
838            tstd: TstdModel::new(Duration::ZERO),
839        }
840    }
841
842    /// Feed ONE TS packet (any length; 188 expected) with its caller-supplied
843    /// arrival time `t`.
844    ///
845    /// `t` must be monotonic non-decreasing across calls (documented but not
846    /// enforced). Returns the events raised by this packet.
847    pub fn feed(&mut self, ts_packet: &[u8], t: Duration) -> &[ConformanceEvent] {
848        self.events.clear();
849        self.stats.packets += 1;
850
851        // ── Step 2: Sync byte check (1.2) ─────────────────────────────────
852        let sync_ok = !ts_packet.is_empty() && ts_packet[0] == SYNC_BYTE;
853        if !sync_ok {
854            self.emit(Indicator::SyncByteError, None, t, "sync_byte != 0x47");
855        }
856
857        // ── Step 3: Sync hysteresis state machine (1.1) ──────────────────
858        if sync_ok {
859            self.good_run = self.good_run.saturating_add(1);
860            self.bad_run = 0;
861            if !self.in_sync && self.good_run >= self.config.sync_acquire_packets {
862                self.in_sync = true;
863            }
864        } else {
865            self.bad_run = self.bad_run.saturating_add(1);
866            self.good_run = 0;
867            if self.in_sync && self.bad_run >= self.config.sync_loss_packets {
868                self.in_sync = false;
869                self.emit(
870                    Indicator::TsSyncLoss,
871                    None,
872                    t,
873                    "sync lost after hysteresis threshold",
874                );
875            }
876        }
877
878        // Per the doc: "If indicator 1.1 is activated then all other
879        // indicators are invalid." While not in sync, suppress all other
880        // indicators.
881        if !self.in_sync {
882            return &self.events;
883        }
884
885        // ── Step 4: Parse TS packet ───────────────────────────────────────
886        let packet = match TsPacket::parse(ts_packet) {
887            Ok(p) => p,
888            Err(_) => return &self.events,
889        };
890        let header = &packet.header;
891        let pid = header.pid;
892
893        // ── 2.1 Transport_error (Table 5.0b indicator 2.1) ──────────────
894        if header.tei {
895            self.emit(
896                Indicator::TransportError,
897                Some(pid),
898                t,
899                format!("transport_error_indicator set on PID 0x{pid:04X}"),
900            );
901        }
902
903        // ── Step 5: Continuity_count_error (1.4) ─────────────────────────
904        if pid != PID_NULL {
905            self.check_cc(
906                pid,
907                header.continuity_counter,
908                header.has_payload,
909                t,
910                ts_packet,
911            );
912        }
913
914        // ── Step 7: PAT_error_2 — scrambling check (1.3.a) ──────────────
915        if pid == PID_PAT && header.scrambling != 0 {
916            self.emit(
917                Indicator::PatError2,
918                Some(PID_PAT),
919                t,
920                format!(
921                    "scrambling_control_field != 00 on PID 0x0000 (got {})",
922                    header.scrambling
923                ),
924            );
925        }
926
927        // ── Step 8: PMT_error_2 — scrambling check (1.5.a) ──────────────
928        if self.pmt_trackings.contains_key(&pid) && header.scrambling != 0 {
929            self.emit(
930                Indicator::PmtError2,
931                Some(pid),
932                t,
933                format!("scrambling_control_field != 00 on program_map_PID 0x{pid:04X}"),
934            );
935        }
936
937        // ── 2.6 CAT_error — scrambled packet with no CAT (Table 5.0b 2.6)
938        //
939        // At stream start, scrambled packets may arrive before a CAT section
940        // has been acquired; this check fires once in that case. It re-arms
941        // (see `check_cat_table_id`) when a CAT later appears, so the error is
942        // re-detectable after a CAT section is seen.
943        if header.scrambling != 0 && !self.cat_seen && !self.scrambled_without_cat_reported {
944            self.scrambled_without_cat_reported = true;
945            self.emit(
946                Indicator::CatError,
947                Some(pid),
948                t,
949                format!("scrambled packet on PID 0x{pid:04X} but no CAT seen on PID 0x0001"),
950            );
951        }
952
953        // ── Step 6: Section reassembly — PAT ─────────────────────────────
954        if pid == PID_PAT && header.has_payload {
955            if let Some(payload) = packet.payload {
956                self.pat_reassembler.feed(payload, header.pusi);
957            }
958            self.pat_timer.last_seen = t;
959            self.pat_timer.reported = false;
960            while let Some(section_bytes) = self.pat_reassembler.pop_section() {
961                self.check_crc_and_process_pat(&section_bytes, pid, t);
962            }
963        }
964
965        // ── Step 6b: Section reassembly — PMT PIDs ───────────────────────
966        if self.pmt_trackings.contains_key(&pid) && header.has_payload {
967            if let Some(payload) = packet.payload
968                && let Some(tracking) = self.pmt_trackings.get_mut(&pid)
969            {
970                tracking.reassembler.feed(payload, header.pusi);
971            }
972            let sections: Vec<_> = if let Some(tracking) = self.pmt_trackings.get_mut(&pid) {
973                tracking.timer.last_seen = t;
974                tracking.timer.reported = false;
975                core::iter::from_fn(|| tracking.reassembler.pop_section()).collect()
976            } else {
977                Vec::new()
978            };
979            for section_bytes in &sections {
980                self.check_crc_and_process_pmt(section_bytes, pid, t);
981            }
982        }
983
984        // ── Step 6c: Section reassembly — well-known SI/PSI PIDs (2.2) ───
985        // PAT and PMT PIDs are handled above (they have separate reassembly
986        // for P1 logic). Only process the non-PAT, non-PMT SI PIDs here.
987        if pid != PID_PAT
988            && !self.pmt_trackings.contains_key(&pid)
989            && self.si_reassemblies.contains_key(&pid)
990            && header.has_payload
991        {
992            if let Some(payload) = packet.payload
993                && let Some(si_ra) = self.si_reassemblies.get_mut(&pid)
994            {
995                si_ra.reassembler.feed(payload, header.pusi);
996            }
997            let sections: Vec<_> = if let Some(si_ra) = self.si_reassemblies.get_mut(&pid) {
998                core::iter::from_fn(|| si_ra.reassembler.pop_section()).collect()
999            } else {
1000                Vec::new()
1001            };
1002            for section_bytes in &sections {
1003                self.check_crc_for_si(section_bytes, pid, t);
1004                self.check_cat_table_id(section_bytes, pid, t);
1005                self.check_nit_table_id(section_bytes, pid, t);
1006                self.check_sdt_table_id(section_bytes, pid, t);
1007                self.check_eit_table_id(section_bytes, pid, t);
1008                self.check_rst_table_id(section_bytes, pid, t);
1009                self.check_tdt_table_id(section_bytes, pid, t);
1010                self.update_si_repetition(section_bytes, pid, t);
1011                self.check_si_min_gap(section_bytes, pid, t);
1012                self.check_si_other_section(section_bytes, pid, t);
1013                self.track_eit_pf_pair(section_bytes, t);
1014            }
1015        }
1016        // Also CRC-check completed PAT/PMT sections via the si_reassemblies
1017        // map (these share the same PID). PAT and PMT already have their own
1018        // reassemblers above — the si_reassemblies entries for those PIDs are
1019        // not fed again. CRC checking for PAT/PMT is done inside
1020        // check_crc_and_process_pat / check_crc_and_process_pmt.
1021
1022        // ── Step 9: PID_error — update last_seen for referenced PIDs ─────
1023        if let Some(tracking) = self.es_trackings.get_mut(&pid) {
1024            tracking.timer.last_seen = t;
1025            tracking.timer.reported = false;
1026        }
1027
1028        // ── 2.3a / 2.3b: PCR checks (Table 5.0b indicators 2.3a, 2.3b) ──
1029        if let Some(Ok(af)) = packet.adaptation_field()
1030            && let Some(pcr) = af.pcr
1031        {
1032            self.check_pcr(pid, pcr.as_27mhz(), af.discontinuity_indicator, t);
1033        }
1034
1035        // ── 2.5: PTS check (Table 5.0b indicator 2.5) ───────────────────
1036        if header.pusi
1037            && header.scrambling == 0
1038            && self.es_trackings.contains_key(&pid)
1039            && header.has_payload
1040            && let Some(payload) = packet.payload
1041        {
1042            self.check_pts(pid, payload, t);
1043        }
1044
1045        // ── 3.4: Unreferenced_PID bookkeeping ───────────────────────────
1046        if pid != PID_NULL {
1047            self.track_unreferenced_pid(pid, t);
1048        }
1049
1050        // ── T-STD buffer model (3.3, 3.9, 3.10) ─────────────────────────
1051        // ts_packet is a raw 188-byte (or larger) TS packet.
1052        self.process_tstd(header, ts_packet, t);
1053
1054        // ── Presence-timeout evaluation (1.3.a, 1.5.a, 1.6, 3.4) ────────
1055        self.check_presence_timeouts(t);
1056
1057        // ── EIT P/F pairing evaluation (3.6.c) ──────────────────────────
1058        self.check_eit_pf_pairs(t);
1059
1060        &self.events
1061    }
1062
1063    /// Diagnostic counters.
1064    pub fn stats(&self) -> Stats {
1065        Stats {
1066            in_sync: self.in_sync,
1067            ..self.stats
1068        }
1069    }
1070
1071    // ── Internal helpers ──────────────────────────────────────────────────
1072
1073    fn emit(
1074        &mut self,
1075        indicator: Indicator,
1076        pid: Option<u16>,
1077        at: Duration,
1078        detail: impl Into<String>,
1079    ) {
1080        let event = ConformanceEvent {
1081            indicator,
1082            priority: indicator.priority(),
1083            pid,
1084            at,
1085            detail: detail.into(),
1086        };
1087        self.stats.events += 1;
1088        self.events.push(event);
1089    }
1090
1091    /// Continuity_count_error (1.4) check.
1092    fn check_cc(&mut self, pid: u16, cc: u8, has_payload: bool, t: Duration, raw: &[u8]) {
1093        // Check for discontinuity_indicator in the adaptation field BEFORE
1094        // mutating cc_states (avoids holding the entry borrow across self.emit).
1095        let discontinuity = if raw.len() >= 5 {
1096            let b3 = raw[3];
1097            let has_adaptation = (b3 & 0x20) != 0;
1098            if has_adaptation {
1099                let af_len = raw[4] as usize;
1100                if af_len > 0 && raw.len() > 5 {
1101                    (raw[5] & 0x80) != 0
1102                } else {
1103                    false
1104                }
1105            } else {
1106                false
1107            }
1108        } else {
1109            false
1110        };
1111
1112        // Compute what we need from the existing state, then decide.
1113        let (expected, is_duplicate, should_emit_dup, should_emit_cc) = {
1114            let state = self.cc_states.entry(pid).or_insert_with(|| CcState {
1115                last_cc: cc,
1116                had_payload: has_payload,
1117                dup_used: false,
1118                initialised: false,
1119                last_packet: Vec::new(),
1120            });
1121
1122            if !state.initialised {
1123                state.last_cc = cc;
1124                state.had_payload = has_payload;
1125                state.dup_used = false;
1126                state.initialised = true;
1127                state.last_packet = raw.to_vec();
1128                return;
1129            }
1130
1131            if discontinuity {
1132                // Will update state below — just signal no emit.
1133                (0u8, false, false, false)
1134            } else {
1135                // ISO/IEC 13818-1:2023 §2.4.3.3: a legal duplicate requires
1136                // the same continuity_counter on a payload-bearing packet
1137                // AND byte-identical content (PCR excepted), AND at most
1138                // one such repeat in a row ("two, and only two"). Comparing
1139                // only `cc == state.last_cc` (as this code used to) would
1140                // accept ANY packet repeating the previous CC, silently
1141                // swallowing genuine continuity faults (issue #956).
1142                use broadcast_common::ts_dup::{DuplicateVerdict, check_duplicate};
1143
1144                let verdict = check_duplicate(&state.last_packet, raw, state.dup_used);
1145                let is_duplicate = !matches!(verdict, DuplicateVerdict::NotDuplicate);
1146                let mut should_emit_dup = false;
1147                let mut should_emit_cc = false;
1148
1149                match verdict {
1150                    DuplicateVerdict::Legal => {}
1151                    DuplicateVerdict::IllegalThirdRepeat => {
1152                        should_emit_dup = true;
1153                    }
1154                    DuplicateVerdict::NotDuplicate => {
1155                        state.dup_used = false;
1156                        let expected = if has_payload {
1157                            (state.last_cc.wrapping_add(1)) & 0x0F
1158                        } else {
1159                            state.last_cc
1160                        };
1161                        if cc != expected {
1162                            should_emit_cc = true;
1163                        }
1164                    }
1165                    // `DuplicateVerdict` is `#[non_exhaustive]`; there is no
1166                    // fourth variant today, but a future addition must not
1167                    // silently fall through as "not a duplicate".
1168                    _ => unreachable!("unhandled DuplicateVerdict variant"),
1169                }
1170
1171                (
1172                    if has_payload {
1173                        (state.last_cc.wrapping_add(1)) & 0x0F
1174                    } else {
1175                        state.last_cc
1176                    },
1177                    is_duplicate,
1178                    should_emit_dup,
1179                    should_emit_cc,
1180                )
1181            }
1182        };
1183
1184        // Now emit events without holding a borrow on cc_states.
1185        if should_emit_dup {
1186            self.emit(
1187                Indicator::ContinuityCountError,
1188                Some(pid),
1189                t,
1190                format!("second consecutive duplicate on PID 0x{pid:04X} (cc={cc})"),
1191            );
1192        }
1193        if should_emit_cc {
1194            self.emit(
1195                Indicator::ContinuityCountError,
1196                Some(pid),
1197                t,
1198                format!("expected cc={expected}, got {cc} on PID 0x{pid:04X}"),
1199            );
1200        }
1201
1202        // Finally, update state.
1203        let state = self.cc_states.get_mut(&pid).unwrap();
1204        if discontinuity {
1205            state.last_cc = cc;
1206            state.had_payload = has_payload;
1207            state.dup_used = false;
1208            state.last_packet = raw.to_vec();
1209        } else if is_duplicate {
1210            // A duplicate (legal or an illegal third-plus repeat) is, by
1211            // construction, byte-identical (PCR excepted) to what's already
1212            // stored, so `last_cc`/`last_packet` are left in place — only
1213            // `dup_used` moves, so a further repeat is still recognised as
1214            // (at least) the second one.
1215            state.dup_used = true;
1216        } else {
1217            state.dup_used = false;
1218            state.last_cc = cc;
1219            state.had_payload = has_payload;
1220            state.last_packet = raw.to_vec();
1221        }
1222    }
1223
1224    /// CRC-check a completed section and, if on PID_PAT, process it.
1225    fn check_crc_and_process_pat(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1226        // 2.2: CRC check on PAT section.
1227        self.check_crc_for_section(section_bytes, pid, t);
1228
1229        self.process_pat_section(section_bytes, t);
1230    }
1231
1232    /// CRC-check a completed section and, if on a PMT PID, process it.
1233    fn check_crc_and_process_pmt(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1234        // 2.2: CRC check on PMT section.
1235        self.check_crc_for_section(section_bytes, pid, t);
1236
1237        self.process_pmt_section(section_bytes, pid, t);
1238    }
1239
1240    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.2 — CRC_error.
1241    ///
1242    /// On any tracked PID, if a completed long-form section has a CRC
1243    /// mismatch, emit `CrcError`. Also feeds section bytes into TBsys
1244    /// for T-STD Buffer_error tracking (indicator 3.3).
1245    fn check_crc_for_section(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1246        let section = match Section::parse(section_bytes) {
1247            Ok(s) => s,
1248            Err(_) => return,
1249        };
1250
1251        // Feed section bytes into TBsys for T-STD buffer tracking
1252        // (ISO/IEC 13818-1 §2.4.2.3: PSI sections pass through TBsys
1253        // before entering Bsys). TBsys has 512 bytes capacity and drains
1254        // at 1 Mbit/s. Overflow fires Buffer_error (3.3).
1255        self.feed_tbsys_section_bytes(section_bytes, pid, t);
1256
1257        // validate_crc returns Ok for short-form sections (no CRC to check).
1258        if let Err(mpeg_ts::error::Error::CrcMismatch { .. }) = section.validate_crc(section_bytes)
1259        {
1260            self.emit(
1261                Indicator::CrcError,
1262                Some(pid),
1263                t,
1264                format!(
1265                    "CRC-32 mismatch on PID 0x{:04X} (table_id 0x{:02X})",
1266                    pid, section.table_id
1267                ),
1268            );
1269        }
1270    }
1271
1272    /// Feed PSI section bytes into TBsys and check for overflow.
1273    ///
1274    /// TBsys (ISO/IEC 13818-1 §2.4.2.3): 512-byte transport buffer for system
1275    /// information. Receives PSI section bytes from the well-known SI/PSI PIDs.
1276    /// Drains at 1 Mbit/s (125 000 bytes/s). Overflow fires Buffer_error (3.3).
1277    fn feed_tbsys_section_bytes(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1278        let section_len = section_bytes.len() as u64;
1279
1280        // Drain TBsys to current time using the fixed 1 Mbit/s leak rate.
1281        self.tstd.drain_tb_sys(t);
1282
1283        // Check if adding these section bytes would overflow TBsys.
1284        // TBsys has 512 bytes capacity at 1 Mbit/s drain.
1285        if self.tstd.tb_sys.would_overflow(section_len, t) {
1286            self.emit(
1287                Indicator::BufferError,
1288                Some(pid),
1289                t,
1290                format!(
1291                    "TBsys overflow on PID 0x{pid:04X}: section {section_len} bytes exceeds {} byte capacity at 1 Mbit/s drain",
1292                    tstd::TB_SYS_SIZE,
1293                ),
1294            );
1295        }
1296
1297        // Feed the section bytes into TBsys regardless (the buffer
1298        // model tracks occupancy for empty-interval and delay checks).
1299        let _overflow = self.tstd.tb_sys.feed(section_len, t);
1300
1301        // Indicator 3.9: TBsys empty at least once per second?
1302        if self.tstd.tb_sys.check_empty_interval(t) && !self.tstd.tb_sys_empty_reported {
1303            self.tstd.tb_sys_empty_reported = true;
1304            self.emit(
1305                Indicator::EmptyBufferError,
1306                Some(pid),
1307                t,
1308                format!(
1309                    "TBsys not empty in the last {} s",
1310                    tstd::TB_SYS_EMPTY_INTERVAL_SECS
1311                ),
1312            );
1313        }
1314
1315        // Indicator 3.10: TBsys data delay > 1 s?
1316        if let Some(delay) = self.tstd.tb_sys.delay_secs(t)
1317            && delay > tstd::DATA_DELAY_LIMIT_SECS as f64
1318        {
1319            self.emit(
1320                Indicator::DataDelayError,
1321                Some(pid),
1322                t,
1323                format!(
1324                    "TBsys data delay {delay:.2} s exceeds {} s on PID 0x{pid:04X}",
1325                    tstd::DATA_DELAY_LIMIT_SECS,
1326                ),
1327            );
1328        }
1329    }
1330
1331    /// CRC-check for SI PIDs that are not PAT/PMT (handled via si_reassemblies).
1332    fn check_crc_for_si(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1333        self.check_crc_for_section(section_bytes, pid, t);
1334    }
1335
1336    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.6 — CAT_error, condition 1:
1337    /// section with `table_id != 0x01` on PID_CAT.
1338    fn check_cat_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1339        if pid != PID_CAT {
1340            return;
1341        }
1342        let section = match Section::parse(section_bytes) {
1343            Ok(s) => s,
1344            Err(_) => return,
1345        };
1346        if section.table_id == CAT_TABLE_ID {
1347            // Valid CAT section — mark as seen.
1348            self.cat_seen = true;
1349            // Re-arm the "scrambled without CAT" check so that if a CAT was
1350            // previously absent and then appears, the check resets.
1351            self.scrambled_without_cat_reported = false;
1352        } else {
1353            self.emit(
1354                Indicator::CatError,
1355                Some(PID_CAT),
1356                t,
1357                format!(
1358                    "section with table_id 0x{:02X} on PID 0x0001 (expected 0x01 for CAT)",
1359                    section.table_id
1360                ),
1361            );
1362        }
1363    }
1364
1365    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.1 — NIT_error, bad-table_id
1366    /// dimension. `docs/tr_101_290.md` clause 3.1: allowed table_ids on
1367    /// PID 0x0010 are NIT_actual (0x40), NIT_other (0x41), stuffing/ST
1368    /// (0x72). The absence dimension (NIT_actual missing for the
1369    /// EN 300 468 §5.1.4 repetition interval) is raised from the shared 3.2
1370    /// SI timer in `check_presence_timeouts`.
1371    fn check_nit_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1372        if pid != PID_NIT {
1373            return;
1374        }
1375        let section = match Section::parse(section_bytes) {
1376            Ok(s) => s,
1377            Err(_) => return,
1378        };
1379        let allowed = section.table_id == NIT_ACTUAL_TABLE_ID
1380            || section.table_id == NIT_OTHER_TABLE_ID
1381            || section.table_id == STUFFING_TABLE_ID;
1382        if !allowed {
1383            self.emit(
1384                Indicator::NitError,
1385                Some(PID_NIT),
1386                t,
1387                format!(
1388                    "section with table_id 0x{:02X} on PID 0x0010 (expected NIT_actual/NIT_other/ST)",
1389                    section.table_id
1390                ),
1391            );
1392        }
1393    }
1394
1395    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.5 — SDT_error, bad-table_id
1396    /// dimension. `docs/tr_101_290.md` clause 3.5: allowed table_ids on
1397    /// PID 0x0011 are SDT_actual (0x42), SDT_other (0x46), BAT (0x4A),
1398    /// stuffing/ST (0x72). The absence dimension (SDT_actual missing) is
1399    /// raised from the shared 3.2 SI timer in `check_presence_timeouts`.
1400    fn check_sdt_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1401        if pid != PID_SDT_BAT {
1402            return;
1403        }
1404        let section = match Section::parse(section_bytes) {
1405            Ok(s) => s,
1406            Err(_) => return,
1407        };
1408        let allowed = section.table_id == SDT_ACTUAL_TABLE_ID
1409            || section.table_id == SDT_OTHER_TABLE_ID
1410            || section.table_id == BAT_TABLE_ID
1411            || section.table_id == STUFFING_TABLE_ID;
1412        if !allowed {
1413            self.emit(
1414                Indicator::SdtError,
1415                Some(PID_SDT_BAT),
1416                t,
1417                format!(
1418                    "section with table_id 0x{:02X} on PID 0x0011 (expected SDT_actual/SDT_other/BAT/ST)",
1419                    section.table_id
1420                ),
1421            );
1422        }
1423    }
1424
1425    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.6 — EIT_error, bad-table_id
1426    /// dimension. `docs/tr_101_290.md` clause 3.6: allowed table_ids on
1427    /// PID 0x0012 are EIT P/F actual (0x4E), EIT P/F other (0x4F), EIT
1428    /// schedule actual (0x50..=0x5F), EIT schedule other (0x60..=0x6F),
1429    /// stuffing/ST (0x72). The absence dimension (EIT P/F actual missing) is
1430    /// raised from the shared 3.2 SI timer in `check_presence_timeouts`.
1431    fn check_eit_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1432        if pid != PID_EIT {
1433            return;
1434        }
1435        let section = match Section::parse(section_bytes) {
1436            Ok(s) => s,
1437            Err(_) => return,
1438        };
1439        let table_id = section.table_id;
1440        let allowed = table_id == EIT_PF_ACTUAL_TABLE_ID
1441            || table_id == EIT_PF_OTHER_TABLE_ID
1442            || (dvb_si::tables::eit::TABLE_ID_SCHEDULE_ACTUAL_FIRST
1443                ..=dvb_si::tables::eit::TABLE_ID_SCHEDULE_ACTUAL_LAST)
1444                .contains(&table_id)
1445            || (dvb_si::tables::eit::TABLE_ID_SCHEDULE_OTHER_FIRST
1446                ..=dvb_si::tables::eit::TABLE_ID_SCHEDULE_OTHER_LAST)
1447                .contains(&table_id)
1448            || table_id == STUFFING_TABLE_ID;
1449        if !allowed {
1450            self.emit(
1451                Indicator::EitError,
1452                Some(PID_EIT),
1453                t,
1454                format!(
1455                    "section with table_id 0x{table_id:02X} on PID 0x0012 (expected EIT P/F or schedule range or ST)"
1456                ),
1457            );
1458        }
1459    }
1460
1461    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.7 — RST_error, bad-table_id
1462    /// dimension. `docs/tr_101_290.md` clause 3.7: allowed table_ids on
1463    /// PID 0x0013 are RST (0x71), stuffing/ST (0x72). RST has no documented
1464    /// absence threshold in Table 5.0c, so there is no presence dimension.
1465    fn check_rst_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1466        if pid != PID_RST {
1467            return;
1468        }
1469        let section = match Section::parse(section_bytes) {
1470            Ok(s) => s,
1471            Err(_) => return,
1472        };
1473        let allowed = section.table_id == RST_TABLE_ID || section.table_id == STUFFING_TABLE_ID;
1474        if !allowed {
1475            self.emit(
1476                Indicator::RstError,
1477                Some(PID_RST),
1478                t,
1479                format!(
1480                    "section with table_id 0x{:02X} on PID 0x0013 (expected RST/ST)",
1481                    section.table_id
1482                ),
1483            );
1484        }
1485    }
1486
1487    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.8 — TDT_error, bad-table_id
1488    /// dimension. `docs/tr_101_290.md` clause 3.8: allowed table_ids on
1489    /// PID 0x0014 are TDT (0x70), stuffing/ST (0x72), TOT (0x73). The
1490    /// absence dimension (TDT missing) is raised from the shared 3.2 SI
1491    /// timer in `check_presence_timeouts`.
1492    fn check_tdt_table_id(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1493        if pid != PID_TDT_TOT {
1494            return;
1495        }
1496        let section = match Section::parse(section_bytes) {
1497            Ok(s) => s,
1498            Err(_) => return,
1499        };
1500        let allowed = section.table_id == TDT_TABLE_ID
1501            || section.table_id == TOT_TABLE_ID
1502            || section.table_id == STUFFING_TABLE_ID;
1503        if !allowed {
1504            self.emit(
1505                Indicator::TdtError,
1506                Some(PID_TDT_TOT),
1507                t,
1508                format!(
1509                    "section with table_id 0x{:02X} on PID 0x0014 (expected TDT/TOT/ST)",
1510                    section.table_id
1511                ),
1512            );
1513        }
1514    }
1515
1516    /// Whether `pid` is (currently) part of the TR 101 290 v1.4.1 Table 5.0c
1517    /// indicator 3.4 referenced set: PAT, CAT, well-known SI PIDs (NIT/
1518    /// SDT-BAT/EIT/RST/TDT-TOT), the null PID, the reserved-for-future-use
1519    /// range, PMT_PIDs referenced by the PAT, and ES/PCR PIDs referenced by
1520    /// a PMT.
1521    ///
1522    /// Not tracked: CAT-referenced EMM PIDs (this monitor does not decode CA
1523    /// descriptors) and PIDs "user defined as private data streams" (the
1524    /// spec's own carve-out — not distinguishable from the wire alone). Both
1525    /// are documented limitations; see the crate `//!` and README.
1526    fn is_referenced_or_reserved_pid(&self, pid: u16) -> bool {
1527        pid == PID_PAT
1528            || pid == PID_CAT
1529            || pid == PID_NIT
1530            || pid == PID_SDT_BAT
1531            || pid == PID_EIT
1532            || pid == PID_RST
1533            || pid == PID_TDT_TOT
1534            || pid == PID_NULL
1535            || (RESERVED_PID_MIN..=RESERVED_PID_MAX).contains(&pid)
1536            || self.pmt_trackings.contains_key(&pid)
1537            || self.es_trackings.contains_key(&pid)
1538    }
1539
1540    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.4 — Unreferenced_PID
1541    /// bookkeeping. Records the first-seen time of a PID that is not
1542    /// (currently) part of the referenced set. If the PID is already
1543    /// referenced, drops any stale tracking entry. Absence-timeout
1544    /// evaluation happens in `check_presence_timeouts`; a PID that later
1545    /// becomes referenced (see `process_pat_section` / `process_pmt_section`)
1546    /// has its entry removed there.
1547    fn track_unreferenced_pid(&mut self, pid: u16, t: Duration) {
1548        if self.is_referenced_or_reserved_pid(pid) {
1549            self.unreferenced_pid_timers.remove(&pid);
1550            return;
1551        }
1552        self.unreferenced_pid_timers
1553            .entry(pid)
1554            .or_insert_with(|| UnreferencedPidTracking {
1555                first_seen: t,
1556                reported: false,
1557            });
1558    }
1559
1560    /// Process a completed section on PID_PAT.
1561    fn process_pat_section(&mut self, section_bytes: &[u8], t: Duration) {
1562        let section = match Section::parse(section_bytes) {
1563            Ok(s) => s,
1564            Err(_) => return,
1565        };
1566
1567        // 1.3.a: section with table_id other than 0x00 found on PID 0x0000.
1568        if section.table_id != PAT_TABLE_ID {
1569            self.emit(
1570                Indicator::PatError2,
1571                Some(PID_PAT),
1572                t,
1573                format!(
1574                    "section with table_id 0x{:02X} on PID 0x0000 (expected 0x00)",
1575                    section.table_id
1576                ),
1577            );
1578            return;
1579        }
1580
1581        // Parse the PAT proper.
1582        let pat = match PatSection::parse(section_bytes) {
1583            Ok(p) => p,
1584            Err(_) => return,
1585        };
1586
1587        // Discover program_map_PIDs and start tracking them.
1588        for entry in pat.programmes() {
1589            let pmt_pid = entry.pid;
1590            self.pmt_trackings
1591                .entry(pmt_pid)
1592                .or_insert_with(|| PmtTracking {
1593                    timer: PresenceTimer {
1594                        last_seen: t,
1595                        reported: false,
1596                    },
1597                    reassembler: SectionReassembler::default(),
1598                });
1599            // 3.4: a program_map_PID is now referenced — it is no longer an
1600            // Unreferenced_PID candidate even if its own packets have not
1601            // been observed yet.
1602            self.unreferenced_pid_timers.remove(&pmt_pid);
1603        }
1604    }
1605
1606    /// Process a completed section on a program_map_PID.
1607    fn process_pmt_section(&mut self, section_bytes: &[u8], _pid: u16, t: Duration) {
1608        let section = match Section::parse(section_bytes) {
1609            Ok(s) => s,
1610            Err(_) => return,
1611        };
1612
1613        // 1.5.a only checks presence and scrambling of table_id 0x02 sections.
1614        // If table_id is not 0x02, skip — we don't emit PMT_error_2 for a
1615        // wrong table_id on a program_map_PID (that's not in the spec for
1616        // 1.5.a).
1617        let pmt_table_id: u8 = dvb_si::tables::pmt::TABLE_ID;
1618        if section.table_id != pmt_table_id {
1619            return;
1620        }
1621
1622        // Parse the PMT proper.
1623        let pmt = match PmtSection::parse(section_bytes) {
1624            Ok(p) => p,
1625            Err(_) => return,
1626        };
1627
1628        // Collect new ES PIDs to add.
1629        let mut new_es_pids: Vec<u16> = Vec::new();
1630        if pmt.pcr_pid != PID_NULL && !self.es_trackings.contains_key(&pmt.pcr_pid) {
1631            new_es_pids.push(pmt.pcr_pid);
1632        }
1633        for stream in &pmt.streams {
1634            let es_pid = stream.elementary_pid;
1635            if !self.es_trackings.contains_key(&es_pid) {
1636                new_es_pids.push(es_pid);
1637            }
1638        }
1639
1640        for es_pid in new_es_pids {
1641            self.es_trackings.insert(
1642                es_pid,
1643                EsTracking {
1644                    timer: PresenceTimer {
1645                        last_seen: t,
1646                        reported: false,
1647                    },
1648                },
1649            );
1650            // 3.4: an ES/PCR PID is now referenced by a PMT — no longer an
1651            // Unreferenced_PID candidate.
1652            self.unreferenced_pid_timers.remove(&es_pid);
1653        }
1654    }
1655
1656    /// TR 101 290 v1.4.1 Table 5.0b indicators 2.3a / 2.3b — PCR checks.
1657    fn check_pcr(&mut self, pid: u16, pcr_27mhz: u64, discontinuity: bool, t: Duration) {
1658        let state = self.pcr_states.entry(pid).or_insert_with(|| PcrState {
1659            last_pcr_27mhz: 0,
1660            last_pcr_time: Duration::ZERO,
1661            initialised: false,
1662        });
1663
1664        if !state.initialised {
1665            state.last_pcr_27mhz = pcr_27mhz;
1666            state.last_pcr_time = t;
1667            state.initialised = true;
1668            return;
1669        }
1670
1671        // Snapshot state for decision-making before any emit.
1672        let last_pcr_time = state.last_pcr_time;
1673        let last_pcr_27mhz = state.last_pcr_27mhz;
1674
1675        // 2.3a: PCR_repetition_error — interval between consecutive PCR
1676        // values exceeds the configured limit.
1677        let rep_interval = t.saturating_sub(last_pcr_time);
1678        let should_emit_rep = rep_interval > self.config.pcr_repetition_limit;
1679
1680        // 2.3b: PCR_discontinuity_indicator_error — PCR delta exceeds 100 ms
1681        // without a signalled discontinuity.
1682        let delta =
1683            (pcr_27mhz.wrapping_add(PCR_MODULUS_27MHZ) - last_pcr_27mhz) % PCR_MODULUS_27MHZ;
1684        let delta_ms = delta * 1000 / CLOCK_27MHZ;
1685        let limit_ms = self.config.pcr_discontinuity_limit.as_millis() as u64;
1686        let should_emit_disc = delta_ms > limit_ms && !discontinuity;
1687
1688        // Emit outside the HashMap borrow.
1689        if should_emit_rep {
1690            self.emit(
1691                Indicator::PcrRepetitionError,
1692                Some(pid),
1693                t,
1694                format!(
1695                    "PCR interval {} ms exceeds limit {} ms on PID 0x{:04X}",
1696                    rep_interval.as_millis(),
1697                    self.config.pcr_repetition_limit.as_millis(),
1698                    pid
1699                ),
1700            );
1701        }
1702        if should_emit_disc {
1703            self.emit(
1704                Indicator::PcrDiscontinuityError,
1705                Some(pid),
1706                t,
1707                format!(
1708                    "PCR delta {delta_ms} ms exceeds limit {limit_ms} ms on PID 0x{pid:04X} without discontinuity_indicator"
1709                ),
1710            );
1711        }
1712
1713        // Update state.
1714        let state = self.pcr_states.get_mut(&pid).unwrap();
1715        state.last_pcr_27mhz = pcr_27mhz;
1716        state.last_pcr_time = t;
1717    }
1718
1719    /// TR 101 290 v1.4.1 Table 5.0b indicator 2.5 — PTS_error.
1720    ///
1721    /// Peeks the PES header on an elementary-stream PID for PTS_DTS_flags.
1722    /// Only checks PIDs that have been "armed" by seeing at least one PTS.
1723    fn check_pts(&mut self, pid: u16, payload: &[u8], t: Duration) {
1724        // PES start-code prefix: 00 00 01.
1725        if payload.len() < PES_FLAGS_OFFSET + 2 {
1726            return;
1727        }
1728        if payload[0] != PES_PREFIX_0 || payload[1] != PES_PREFIX_1 || payload[2] != PES_PREFIX_2 {
1729            return;
1730        }
1731
1732        // Byte 6: `'10' + flags` — the top two bits must be `10`.
1733        let flags_byte = payload[PES_FLAGS_OFFSET];
1734        if (flags_byte >> 6) != 0b10 {
1735            return;
1736        }
1737
1738        // Byte 7: PTS_DTS_flags in bits `[7:6]`.
1739        let pts_dts_flags = payload[PES_FLAGS_OFFSET + 1] & PES_PTS_DTS_FLAGS_MASK;
1740        let pts_present = (pts_dts_flags & PES_PTS_PRESENT) != 0;
1741        if !pts_present {
1742            return;
1743        }
1744
1745        let state = self.pts_states.entry(pid).or_insert_with(|| PtsState {
1746            last_pts_time: Duration::ZERO,
1747            armed: false,
1748        });
1749
1750        if !state.armed {
1751            // First PTS on this PID — arm the check, no error yet.
1752            state.last_pts_time = t;
1753            state.armed = true;
1754            return;
1755        }
1756
1757        // Snapshot state for decision-making before any emit.
1758        let last_pts_time = state.last_pts_time;
1759        let pts_interval = t.saturating_sub(last_pts_time);
1760        let should_emit = pts_interval > self.config.pts_repetition_limit;
1761
1762        if should_emit {
1763            self.emit(
1764                Indicator::PtsError,
1765                Some(pid),
1766                t,
1767                format!(
1768                    "PTS interval {} ms exceeds limit {} ms on PID 0x{:04X}",
1769                    pts_interval.as_millis(),
1770                    self.config.pts_repetition_limit.as_millis(),
1771                    pid
1772                ),
1773            );
1774        }
1775
1776        // Update state.
1777        let state = self.pts_states.get_mut(&pid).unwrap();
1778        state.last_pts_time = t;
1779    }
1780
1781    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.2 — update SI repetition timer
1782    /// when a completed section on a well-known SI PID matches one of the four
1783    /// tracked table_ids.
1784    fn update_si_repetition(&mut self, section_bytes: &[u8], _pid: u16, t: Duration) {
1785        let table_id = match Section::parse(section_bytes) {
1786            Ok(s) => s.table_id,
1787            Err(_) => return,
1788        };
1789
1790        let is_tracked = table_id == NIT_ACTUAL_TABLE_ID
1791            || table_id == SDT_ACTUAL_TABLE_ID
1792            || table_id == EIT_PF_ACTUAL_TABLE_ID
1793            || table_id == TDT_TABLE_ID;
1794
1795        if !is_tracked {
1796            return;
1797        }
1798
1799        let timer = self
1800            .si_timers
1801            .entry(table_id)
1802            .or_insert_with(|| SiRepetitionTimer {
1803                last_seen: Duration::ZERO,
1804                reported: false,
1805                armed: false,
1806            });
1807
1808        timer.last_seen = t;
1809        timer.reported = false;
1810        timer.armed = true;
1811    }
1812
1813    /// TR 101 290 v1.4.1 Table 5.0c — 25 ms minimum-gap check shared by
1814    /// 3.1.a / 3.2 / 3.5.a / 3.6.a / 3.7 / 3.8.
1815    ///
1816    /// Tracks last-seen time per `(table_id, section_number)`, not per
1817    /// `table_id` alone, so a dense legitimate multi-section table does not
1818    /// produce false positives. The key also includes `pid` so the same
1819    /// `(table_id, section_number)` pair arriving on different PIDs is not
1820    /// conflated.
1821    ///
1822    /// Called for every completed section on the well-known SI PIDs.
1823    fn check_si_min_gap(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
1824        let table_id = match Section::parse(section_bytes) {
1825            Ok(s) => s.table_id,
1826            Err(_) => return,
1827        };
1828
1829        // Only the tracked table_ids participate in the min-gap check.
1830        let min_gap_table_ids = [
1831            NIT_ACTUAL_TABLE_ID,
1832            SDT_ACTUAL_TABLE_ID,
1833            EIT_PF_ACTUAL_TABLE_ID,
1834            RST_TABLE_ID,
1835            TDT_TABLE_ID,
1836        ];
1837        if !min_gap_table_ids.contains(&table_id) {
1838            return;
1839        }
1840
1841        let section_number = match Section::parse(section_bytes) {
1842            Ok(s) => s.section_number,
1843            Err(_) => return,
1844        };
1845
1846        let key = MinGapKey {
1847            pid,
1848            table_id,
1849            section_number,
1850        };
1851
1852        if let Some(&last_time) = self.min_gap_timers.get(&key) {
1853            let gap = t.saturating_sub(last_time);
1854            if gap < self.config.si_min_gap {
1855                self.emit(
1856                    Indicator::SiMinGapError,
1857                    Some(pid),
1858                    t,
1859                    format!(
1860                        "table_id 0x{table_id:02X} section_number {section_number} on PID 0x{pid:04X}: gap {gap} µs < {limit} ms minimum",
1861                        gap = gap.as_micros(),
1862                        limit = self.config.si_min_gap.as_millis(),
1863                    ),
1864                );
1865            }
1866        }
1867
1868        self.min_gap_timers.insert(key, t);
1869    }
1870
1871    /// TR 101 290 v1.4.1 Table 5.0c indicators 3.1.b / 3.5.b / 3.6.b —
1872    /// `_other` repetition interval check.
1873    ///
1874    /// Only checks once the sub-table's presence is **established** (observed
1875    /// at least twice). A stream that legitimately carries no `_other` table
1876    /// is never flagged.
1877    fn check_other_repetition(&mut self, table_id: u8, section_number: u8, pid: u16, t: Duration) {
1878        // Only the three `_other` table_ids.
1879        if table_id != NIT_OTHER_TABLE_ID
1880            && table_id != SDT_OTHER_TABLE_ID
1881            && table_id != EIT_PF_OTHER_TABLE_ID
1882        {
1883            return;
1884        }
1885
1886        let key = (table_id, section_number);
1887        let entry =
1888            self.other_repetition_timers
1889                .entry(key)
1890                .or_insert_with(|| OtherRepetitionState {
1891                    occurrences: 0,
1892                    last_seen: Duration::ZERO,
1893                    reported: false,
1894                });
1895
1896        entry.occurrences += 1;
1897
1898        // Snapshot what we need before any emit.
1899        let should_report = entry.occurrences >= 2
1900            && !entry.reported
1901            && t.saturating_sub(entry.last_seen) > self.config.si_other_interval;
1902        let last_seen = entry.last_seen;
1903
1904        entry.last_seen = t;
1905
1906        if should_report {
1907            entry.reported = true;
1908
1909            let indicator = match table_id {
1910                NIT_OTHER_TABLE_ID => Indicator::NitOtherError,
1911                SDT_OTHER_TABLE_ID => Indicator::SdtOtherError,
1912                _ => Indicator::EitOtherError,
1913            };
1914            let (table_name, clause) = match table_id {
1915                NIT_OTHER_TABLE_ID => ("NIT_other (0x41)", "3.1.b"),
1916                SDT_OTHER_TABLE_ID => ("SDT_other (0x46)", "3.5.b"),
1917                _ => ("EIT_other (0x4F)", "3.6.b"),
1918            };
1919            let interval = t.saturating_sub(last_seen);
1920            let interval_ms = interval.as_millis();
1921            let limit = self.config.si_other_interval.as_secs();
1922            self.emit(
1923                indicator,
1924                Some(pid),
1925                t,
1926                format!(
1927                    "{table_name} section_number {section_number}: interval {interval_ms} ms exceeds {limit} s ({clause})",
1928                ),
1929            );
1930        }
1931    }
1932
1933    /// TR 101 290 v1.4.1 Table 5.0c indicator 3.6.c — EIT_PF_error: track
1934    /// section-0/section-1 presence per sub-table.
1935    ///
1936    /// A sub-table is keyed by `(service_id, transport_stream_id,
1937    /// original_network_id)`. If either section exists, both should.
1938    fn track_eit_pf_pair(&mut self, section_bytes: &[u8], t: Duration) {
1939        let table_id = match Section::parse(section_bytes) {
1940            Ok(s) => s.table_id,
1941            Err(_) => return,
1942        };
1943
1944        // Only EIT P/F actual
1945        if table_id != EIT_PF_ACTUAL_TABLE_ID {
1946            return;
1947        }
1948
1949        let eit = match EitSection::parse(section_bytes) {
1950            Ok(e) => e,
1951            Err(_) => return,
1952        };
1953
1954        if eit.section_number > 1 {
1955            // Only sections '0' (present) and '1' (following) participate.
1956            return;
1957        }
1958
1959        let key = EitSubTableKey {
1960            service_id: eit.service_id,
1961            transport_stream_id: eit.transport_stream_id,
1962            original_network_id: eit.original_network_id,
1963        };
1964
1965        let entry = self
1966            .eit_pf_pairs
1967            .entry(key)
1968            .or_insert_with(|| EitPfPairState {
1969                has_section_0: false,
1970                has_section_1: false,
1971                reported: false,
1972                first_incomplete_at: None,
1973            });
1974
1975        if eit.section_number == 0 {
1976            entry.has_section_0 = true;
1977        } else {
1978            entry.has_section_1 = true;
1979        }
1980
1981        // If one section is present but the other is not, record the time
1982        // of this first incomplete observation (so the pairing check can
1983        // wait for the partner to arrive).
1984        if entry.first_incomplete_at.is_none() && (entry.has_section_0 != entry.has_section_1) {
1985            entry.first_incomplete_at = Some(t);
1986        }
1987
1988        // If both sections have now arrived, clear the incomplete marker.
1989        if entry.has_section_0 && entry.has_section_1 {
1990            entry.first_incomplete_at = None;
1991        }
1992    }
1993
1994    /// Evaluate EIT P/F pairing and emit EIT_PF_error for any sub-table where
1995    /// one section exists without the other for longer than the EIT P/F
1996    /// repetition interval (2 s per EN 300 468 §5.2.4).
1997    fn check_eit_pf_pairs(&mut self, t: Duration) {
1998        let limit = self.config.si_eit_pf_interval;
1999
2000        let to_report: Vec<EitSubTableKey> = self
2001            .eit_pf_pairs
2002            .iter()
2003            .filter_map(|(&key, state)| {
2004                if state.reported {
2005                    return None;
2006                }
2007                match (state.has_section_0, state.has_section_1) {
2008                    (true, false) | (false, true) => {
2009                        // Only fire if the incomplete pair has persisted
2010                        // longer than the EIT P/F repetition interval.
2011                        if let Some(first_at) = state.first_incomplete_at {
2012                            if t.saturating_sub(first_at) > limit {
2013                                Some(key)
2014                            } else {
2015                                None
2016                            }
2017                        } else {
2018                            None
2019                        }
2020                    }
2021                    _ => None,
2022                }
2023            })
2024            .collect();
2025
2026        for key in to_report {
2027            if let Some(state) = self.eit_pf_pairs.get_mut(&key) {
2028                state.reported = true;
2029            }
2030            self.emit(
2031                Indicator::EitPfError,
2032                Some(PID_EIT),
2033                t,
2034                format!(
2035                    "EIT P/F sub-table (service_id=0x{:04X} ts_id=0x{:04X} onw_id=0x{:04X}): one section present without the other",
2036                    key.service_id, key.transport_stream_id, key.original_network_id,
2037                ),
2038            );
2039        }
2040    }
2041
2042    /// Dispatch `_other` repetition tracking for completed SI sections on
2043    /// the well-known PIDs. Only sections with `table_id` NIT_other (0x41),
2044    /// SDT_other (0x46), or EIT P/F other (0x4F) are tracked.
2045    fn check_si_other_section(&mut self, section_bytes: &[u8], pid: u16, t: Duration) {
2046        let section = match Section::parse(section_bytes) {
2047            Ok(s) => s,
2048            Err(_) => return,
2049        };
2050
2051        let table_id = section.table_id;
2052        if table_id != NIT_OTHER_TABLE_ID
2053            && table_id != SDT_OTHER_TABLE_ID
2054            && table_id != EIT_PF_OTHER_TABLE_ID
2055        {
2056            return;
2057        }
2058
2059        self.check_other_repetition(table_id, section.section_number, pid, t);
2060    }
2061
2062    /// T-STD buffer model processing: run the buffer simulation for one TS
2063    /// packet and evaluate indicators 3.3 (Buffer_error), 3.9
2064    /// (Empty_buffer_error), and 3.10 (Data_delay_error).
2065    ///
2066    /// # Buffer model
2067    ///
2068    /// - **TBn** (per-PID, 512 bytes): each payload PID gets a transport
2069    ///   buffer. The full 188-byte packet (minus sync) enters the buffer
2070    ///   instantaneously at the caller's timestamp `t`. The buffer drains at
2071    ///   the stream's effective bitrate (estimated from accumulated bytes
2072    ///   divided by elapsed wall-clock time, clamped to a floor of
2073    ///   125 kbit/s).
2074    /// - **TBsys** (global, 512 bytes, 1 Mbit/s leak): the PSI/SI PIDs (PAT,
2075    ///   CAT, NIT, SDT/BAT, EIT, RST, TDT/TOT) feed into TBsys. When a
2076    ///   completed section is assembled and passed to the SI/PSI decoder, the
2077    ///   section bytes are removed from TBsys.
2078    ///
2079    /// # Known limitation
2080    ///
2081    /// The effective bitrate estimate from accumulated bytes / wall-clock time
2082    /// is inherently approximate when the caller's timestamps do not match the
2083    /// actual transport rate. The monitor documents this: the caller is
2084    /// responsible for supplying realistic timestamps.
2085    fn process_tstd(&mut self, header: &mpeg_ts::ts::TsHeader, ts_packet: &[u8], t: Duration) {
2086        let pid = header.pid;
2087
2088        // Periodic TBsys drain and empty-interval check. TBsys feeds at
2089        // section-completion, but we must drain it on every packet to
2090        // accurately model the 1 Mbit/s continuous drain rate.
2091        self.tstd.drain_tb_sys(t);
2092
2093        // ── TBsys: PSI/SI PIDs ──────────────────────────────────────────
2094        let is_si_pid = pid == PID_PAT
2095            || pid == PID_CAT
2096            || pid == PID_NIT
2097            || pid == PID_SDT_BAT
2098            || pid == PID_EIT
2099            || pid == PID_RST
2100            || pid == PID_TDT_TOT;
2101
2102        // Count the packet bytes for T-STD accounting.
2103        // The T-STD model uses the full 188-byte TS packet as the arrival
2104        // unit (ISO/IEC 13818-1 §2.4.2.3: the transport buffer receives TS
2105        // packets and strips the TS header + adaptation field before passing
2106        // bytes downstream; but for TBn overflow, the relevant input unit
2107        // is the packet's contribution to buffer occupancy).
2108        let packet_bytes = ts_packet.len() as u64;
2109
2110        if is_si_pid && header.has_payload {
2111            // TBsys is fed at section-completion time (see
2112            // `feed_tbsys_section_bytes` called from `check_crc_for_section`).
2113            // The per-packet path only drains TBsys and does empty/delay
2114            // checks, which are also done at section-completion.
2115        }
2116
2117        // ── TBn: per-PID buffers for all non-SI, non-null PIDs ──────────
2118        if pid == PID_NULL || is_si_pid {
2119            return;
2120        }
2121
2122        // Compute T-STD checks without holding a mutable borrow on self.tstd
2123        // across any self.emit() calls. We collect the events to emit, then
2124        // emit them after releasing the borrow.
2125        struct TstdCheck {
2126            buffer_overflow: bool,
2127            empty_interval: bool,
2128            delay_exceeded: bool,
2129        }
2130
2131        let check = {
2132            let entry = self.tstd.pid_buffers.entry(pid).or_insert_with(|| {
2133                // Initial leak rate conservatively high: the effective
2134                // bitrate will be estimated once we have timing data.
2135                tstd::PidStdState::new(1_000_000u64, t)
2136            });
2137
2138            // Accumulate bytes for effective bitrate estimation.
2139            entry.total_bytes += packet_bytes;
2140
2141            // Estimate the effective transport rate from accumulated bytes
2142            // and elapsed wall-clock time. Wait until we have at least
2143            // 1 ms of history before estimating; use a default of
2144            // ~6 Mbit/s (750 KB/s) until then.
2145            let leak = {
2146                let elapsed_us = t.saturating_sub(entry.first_seen).as_micros() as u64;
2147                if elapsed_us >= 1_000 {
2148                    // bytes per second = total_bytes * 1_000_000 / elapsed_us
2149                    (entry.total_bytes * 1_000_000 / elapsed_us).max(tstd::TB_LEAK_RATE_FLOOR)
2150                } else {
2151                    // Default: generous initial rate (~40 Mbit/s).
2152                    // This prevents false overflows during the first
2153                    // millisecond before we have enough data to estimate
2154                    // the effective bitrate. For a real ~38 Mbit/s stream
2155                    // with 40 µs inter-packet, this is close enough to
2156                    // prevent overflow during the convergence period.
2157                    5_000_000u64
2158                }
2159            };
2160            entry.tb.set_leak_rate(leak);
2161
2162            // Drain the buffer TO the current time (i.e. drain up to the
2163            // arrival instant of this packet, before the packet data enters).
2164            entry.tb.drain_to(t);
2165
2166            let buffer_overflow = {
2167                // Drain, then feed (for occupancy tracking). We do NOT flag
2168                // TBn overflow as BufferError — accurate overflow detection
2169                // requires the coded bitrate Rxn from descriptors
2170                // (multiplex_buffer_descriptor). Without that, the
2171                // estimated leak rate is approximate and produces false
2172                // positives on clean streams. TBn occupancy is still
2173                // tracked for empty-interval and data-delay checks.
2174                entry.tb.drain_to(t);
2175                let _overflow = entry.tb.feed(packet_bytes, t);
2176                false
2177            };
2178            let empty_interval = entry.tb.check_empty_interval(t) && !entry.empty_reported;
2179            let delay_exceeded = if let Some(delay) = entry.tb.delay_secs(t) {
2180                delay > tstd::DATA_DELAY_LIMIT_SECS as f64 && !entry.delay_reported
2181            } else {
2182                false
2183            };
2184
2185            if empty_interval {
2186                entry.empty_reported = true;
2187            }
2188            if delay_exceeded {
2189                entry.delay_reported = true;
2190            }
2191            entry.last_packet_time = t;
2192
2193            TstdCheck {
2194                buffer_overflow,
2195                empty_interval,
2196                delay_exceeded,
2197            }
2198        };
2199
2200        // Emit events outside the borrow.
2201        if check.buffer_overflow {
2202            self.emit(
2203                Indicator::BufferError,
2204                Some(pid),
2205                t,
2206                format!(
2207                    "TBn overflow on PID 0x{pid:04X}: {} byte capacity exceeded",
2208                    tstd::TB_SIZE,
2209                ),
2210            );
2211        }
2212
2213        if check.empty_interval {
2214            self.emit(
2215                Indicator::EmptyBufferError,
2216                Some(pid),
2217                t,
2218                format!(
2219                    "TBn not empty in the last {} s on PID 0x{pid:04X}",
2220                    tstd::TB_EMPTY_INTERVAL_SECS,
2221                ),
2222            );
2223        }
2224
2225        if check.delay_exceeded {
2226            self.emit(
2227                Indicator::DataDelayError,
2228                Some(pid),
2229                t,
2230                format!(
2231                    "TBn data delay exceeds {} s on PID 0x{pid:04X}",
2232                    tstd::DATA_DELAY_LIMIT_SECS,
2233                ),
2234            );
2235        }
2236    }
2237
2238    /// Evaluate all presence/absence timeouts against the current time `t`.
2239    fn check_presence_timeouts(&mut self, t: Duration) {
2240        // 1.3.a: PAT presence timeout
2241        if t.saturating_sub(self.pat_timer.last_seen) > self.config.pat_max_interval
2242            && !self.pat_timer.reported
2243        {
2244            self.pat_timer.reported = true;
2245            self.emit(
2246                Indicator::PatError2,
2247                Some(PID_PAT),
2248                t,
2249                format!(
2250                    "no PAT section within {} ms",
2251                    self.config.pat_max_interval.as_millis()
2252                ),
2253            );
2254        }
2255
2256        // 1.5.a: PMT presence timeout per program_map_PID
2257        // Collect PIDs that need events, then emit outside the iteration.
2258        let pmt_timeouts: Vec<(u16, u64)> = self
2259            .pmt_trackings
2260            .iter()
2261            .filter_map(|(&pid, tracking)| {
2262                if t.saturating_sub(tracking.timer.last_seen) > self.config.pmt_max_interval
2263                    && !tracking.timer.reported
2264                {
2265                    Some((pid, self.config.pmt_max_interval.as_millis() as u64))
2266                } else {
2267                    None
2268                }
2269            })
2270            .collect();
2271        for (pid, interval_ms) in pmt_timeouts {
2272            if let Some(tracking) = self.pmt_trackings.get_mut(&pid) {
2273                tracking.timer.reported = true;
2274            }
2275            self.emit(
2276                Indicator::PmtError2,
2277                Some(pid),
2278                t,
2279                format!("no PMT section on program_map_PID 0x{pid:04X} within {interval_ms} ms"),
2280            );
2281        }
2282
2283        // 1.6: PID_error — referenced PID absence
2284        let pid_timeouts: Vec<(u16, u64)> = self
2285            .es_trackings
2286            .iter()
2287            .filter_map(|(&pid, tracking)| {
2288                if t.saturating_sub(tracking.timer.last_seen) > self.config.pid_error_period
2289                    && !tracking.timer.reported
2290                {
2291                    Some((pid, self.config.pid_error_period.as_secs()))
2292                } else {
2293                    None
2294                }
2295            })
2296            .collect();
2297        for (pid, period_secs) in pid_timeouts {
2298            if let Some(tracking) = self.es_trackings.get_mut(&pid) {
2299                tracking.timer.reported = true;
2300            }
2301            self.emit(
2302                Indicator::PidError,
2303                Some(pid),
2304                t,
2305                format!("referenced PID 0x{pid:04X} absent for > {period_secs} s"),
2306            );
2307        }
2308
2309        // 3.2: SI_repetition_error — maximum interval for tracked SI tables.
2310        // Collect table_ids that need events, then emit outside the iteration.
2311        let si_timeouts: Vec<(u8, u64, u16, u64)> = self
2312            .si_timers
2313            .iter()
2314            .filter_map(|(&table_id, timer)| {
2315                if !timer.armed || timer.reported {
2316                    return None;
2317                }
2318                let (limit, pid) = match table_id {
2319                    NIT_ACTUAL_TABLE_ID => (self.config.si_nit_interval, PID_NIT),
2320                    SDT_ACTUAL_TABLE_ID => (self.config.si_sdt_interval, PID_SDT_BAT),
2321                    EIT_PF_ACTUAL_TABLE_ID => (self.config.si_eit_pf_interval, PID_EIT),
2322                    TDT_TABLE_ID => (self.config.si_tdt_interval, PID_TDT_TOT),
2323                    _ => return None,
2324                };
2325                let interval = t.saturating_sub(timer.last_seen);
2326                if interval > limit {
2327                    Some((
2328                        table_id,
2329                        interval.as_millis() as u64,
2330                        pid,
2331                        limit.as_millis() as u64,
2332                    ))
2333                } else {
2334                    None
2335                }
2336            })
2337            .collect();
2338        for (table_id, interval_ms, pid, limit_ms) in si_timeouts {
2339            if let Some(timer) = self.si_timers.get_mut(&table_id) {
2340                timer.reported = true;
2341            }
2342            // The four `_actual` table_ids share this ONE lazily-armed
2343            // timer: an absence past the interval is simultaneously
2344            // SI_repetition_error (3.2, the general repetition-rate
2345            // indicator, emitted unconditionally below) AND the presence
2346            // dimension of the corresponding table-specific indicator
2347            // (NIT_error 3.1 / SDT_error 3.5 / EIT_error 3.6 / TDT_error 3.8)
2348            // — the same underlying absence, per the spec's historical
2349            // combined indicator vs. the split `_actual` variants (see
2350            // docs/tr_101_290.md notes 2-4).
2351            let (table_name, group_indicator) = match table_id {
2352                NIT_ACTUAL_TABLE_ID => ("NIT_actual", Some(Indicator::NitError)),
2353                SDT_ACTUAL_TABLE_ID => ("SDT_actual", Some(Indicator::SdtError)),
2354                EIT_PF_ACTUAL_TABLE_ID => ("EIT_P/F_actual", Some(Indicator::EitError)),
2355                TDT_TABLE_ID => ("TDT", Some(Indicator::TdtError)),
2356                _ => ("unknown", None),
2357            };
2358            self.emit(
2359                Indicator::SiRepetitionError,
2360                Some(pid),
2361                t,
2362                format!("{table_name} repetition interval {interval_ms} ms exceeds {limit_ms} ms"),
2363            );
2364            if let Some(indicator) = group_indicator {
2365                self.emit(
2366                    indicator,
2367                    Some(pid),
2368                    t,
2369                    format!("no {table_name} section on PID 0x{pid:04X} within {limit_ms} ms"),
2370                );
2371            }
2372        }
2373
2374        // 3.4: Unreferenced_PID — a PID persisting beyond the presence
2375        // threshold without being referenced by the PAT/CAT/a PMT or one of
2376        // the well-known SI PIDs.
2377        let unref_timeouts: Vec<(u16, u64)> = self
2378            .unreferenced_pid_timers
2379            .iter()
2380            .filter_map(|(&pid, timer)| {
2381                if timer.reported {
2382                    return None;
2383                }
2384                let elapsed = t.saturating_sub(timer.first_seen);
2385                if elapsed > self.config.unreferenced_pid_period {
2386                    Some((pid, self.config.unreferenced_pid_period.as_millis() as u64))
2387                } else {
2388                    None
2389                }
2390            })
2391            .collect();
2392        for (pid, period_ms) in unref_timeouts {
2393            if let Some(timer) = self.unreferenced_pid_timers.get_mut(&pid) {
2394                timer.reported = true;
2395            }
2396            self.emit(
2397                Indicator::UnreferencedPid,
2398                Some(pid),
2399                t,
2400                format!(
2401                    "PID 0x{pid:04X} present for > {period_ms} ms without being referenced by PAT/CAT/a PMT or a well-known SI PID"
2402                ),
2403            );
2404        }
2405    }
2406}
2407
2408impl Default for ConformanceMonitor {
2409    fn default() -> Self {
2410        Self::new()
2411    }
2412}
2413
2414#[cfg(test)]
2415mod tests;