# Codex Notes ## Current Analysis: Options Static TG Validation ### Findings - `bridge_master.py` attempts to validate `TS1_STATIC` and `TS2_STATIC` options before parsing them, but the current validation is ineffective. - The whitespace removal calls use `re.sub(...)` without assigning the result, so whitespace remains in the option value. - The pattern `"![\d\,]"` matches a literal exclamation mark followed by a digit or comma. It does not mean "any character except digit or comma". - Invalid values such as `TS1=91,A92` can reach `int(tg)` and raise `ValueError`. - That exception is caught by the broad `except Exception` around the whole options block, so the options update can abort partway through. ### Assumptions - Static TG option strings are intended to contain only decimal TG numbers separated by commas. - Simple whitespace in static TG option strings is acceptable and should be normalized away, even though it is not strictly correct option syntax. - Malformed static TG tokens should be rejected cleanly without creating new bridge state for that token. - Valid fields in the same options string should still be applied when another field is invalid, because there is no good feedback mechanism for rejected options. - TS1 and TS2 static TG options should be treated independently. - The existing broad exception handler is not intended as the normal validation path for malformed user options. ### Unresolved Questions - Should an invalid new options value leave the previous `CONFIG['SYSTEMS'][...] ['TS1_STATIC']` / `['TS2_STATIC']` value unchanged? - Resolved: in a mixed list like `91,A92`, valid token `91` should still be applied while invalid token `A92` is skipped. ### Protocol-Sensitive Areas - Static TG configuration affects bridge membership and therefore routing decisions for HBP packets. - TS1 and TS2 static TG handling must remain symmetrical unless there is an intentional FreeDMR-specific reason for divergence. - Static TG validation must not change packet bytes. It only controls whether bridge state is created or reset. - Control/local targets such as `6`, `7`, `8`, `9`, `9990`, and `9991..9999` should not accidentally become static routes. ### Inferred Invariants - Static TG option parsing should normalize transport/config text before mutating bridge state. - A malformed static TG option should not prevent other valid option fields from applying. - Static TGs must pass the shared static TG validator before either `make_static_tg()` or `reset_static_tg()` is called. - Config and runtime option paths should use the same validation rules for TS1 and TS2. - TS1 and TS2 static option lists should be parsed independently so a malformed TS1 token does not block valid TS2 changes, and vice versa. ### Resolution - Confirmed intended behavior: static TG options are parsed at token level. - Simple whitespace is normalized away. - Invalid, prohibited or out-of-range tokens are skipped. - Valid tokens in the same TS1 or TS2 list still apply. - TS1 and TS2 are independent; invalid tokens on one slot do not block valid tokens on the other slot. ## Current Analysis: Invalid Default Reflector Persisted After Rejection ### Findings - `options_config()` rejects invalid non-zero `DEFAULT_REFLECTOR` / `DIAL` values for bridge creation when `valid_dial_a_tg_reflector()` returns false. - After logging that the default dial-a-TG is prohibited, the function still writes the raw option value into `CONFIG['SYSTEMS'][_system]['DEFAULT_REFLECTOR']`. - This means an invalid value such as `DIAL=1000000` can be "ignored" for bridge state but retained in system configuration. - Other code, such as disconnected voice announcement logic, reads `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` directly and may treat any positive value as linked/default reflector state. ### Assumptions - If a default reflector option is rejected, no default reflector should remain set for that master during the current session. - Rejected options should not leave `CONFIG` saying a reflector is configured when no corresponding reflector bridge was created. - If an invalid default reflector is supplied while an old valid default reflector is active, the old default should be disabled rather than preserved. - The runtime config should reflect the effective routing state, not merely the last raw option value seen. ### Unresolved Questions - Should this behavior be visible to clients via an error/status path, or is the existing log message sufficient? - Should rejected `DEFAULT_REFLECTOR` values preserve the old timer/static TG changes from the same options string, or should the entire options update be rejected atomically? ### Protocol-Sensitive Areas - Default reflector state is dial-a-TG reflector state on TS2 and affects where a repeater appears linked after startup or options reload. - Voice announcements use TG9 TS2 prompts and may report misleading reflector state if configuration and bridge state diverge. - A rejected default reflector must not create or retune reflector bridge state. - The FreeDMR dial-a-TG policy cap remains `999999`; higher values are not valid link targets. ### Inferred Invariants - A rejected default reflector must be stored as effective `DEFAULT_REFLECTOR = 0`. - `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` should match the active or intended default reflector policy state used by bridge creation. - Default reflector validation should be applied before both bridge mutation and config mutation. - RF dial-a-TG and configured default reflector paths should use the same policy bounds. ### Resolution - Confirmed intended behavior: an invalid default reflector option means no default reflector should be set for the current session. - Production handling should reset TS2 TG9 reflector state for the affected master and persist effective `DEFAULT_REFLECTOR = 0`. - Tests should assert both absence of the invalid reflector bridge and deactivation of any previously active default reflector. ## Current Analysis: Invalid Startup Default Reflector Remains In Config ### Findings - `make_default_reflectors()` uses `valid_dial_a_tg_reflector()` before creating a startup default reflector bridge. - When `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` is invalid, prohibited, or above the FreeDMR dial-a-TG policy cap, no bridge is created. - Invalid startup default reflectors should be logged because otherwise the config-file value is silently ignored. - The invalid configured value remains in `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']`. - Other code paths, including disconnected voice announcement logic, read `DEFAULT_REFLECTOR` directly and treat positive values as configured/default reflector state. ### Assumptions - Startup configuration should follow the same effective-state rule as options reload: invalid default reflector means no default reflector for the current session. - If startup rejects a default reflector, the effective runtime `DEFAULT_REFLECTOR` should become `0`. - The server should not announce or expose an invalid positive default reflector value after rejecting it for bridge creation. ### Unresolved Questions - Should invalid startup `DEFAULT_REFLECTOR` values be rewritten only in runtime `CONFIG`, or should any persisted/generated config output also normalize them? - Should startup log invalid default reflectors at debug level, warning level, or leave existing logging style unchanged? ### Protocol-Sensitive Areas - Default reflectors connect TS2 TG9 reflector state at startup. - System-wide defaults are intended for sparing use and ideally should not be used; they remain available for scenarios that require them. - Client-requested settings should be treated as preferential over system-wide defaults. - A positive `DEFAULT_REFLECTOR` value can affect voice announcements and API or reporting output even when bridge state was not created. - Invalid startup defaults must not create, activate, or retune reflector bridge state. ### Resolution - Invalid positive startup default reflectors are logged at warning level and still do not create reflector bridge state. - Confirmed intended behavior: invalid startup default reflectors normalize the in-memory effective `DEFAULT_REFLECTOR` to `0` for the current session. This does not write back to the config file. ### Inferred Invariants - Runtime `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` should describe the effective default reflector state for the session. - Startup and live options should apply the same dial-a-TG default reflector policy. - Rejecting an invalid startup default reflector should leave no TS2 TG9 default reflector active for that system. ## Current Analysis: Options Numeric Fields Converted Before Validation ### Findings - In `options_config()`, some numeric option fields are converted with `int()` before the later validation block that checks `.isdigit()`. - `OVERRIDE_IDENT_TG` is converted and assigned before the code reaches the validation that logs "`OVERRIDE_IDENT_TG is not an integer`". - `VOICE` and `SINGLE` are also converted before any field-specific numeric validation. - A malformed option such as `IDENTTG=A` or `VOICE=A` can raise `ValueError` inside the broad per-system `except Exception` block. - That broad exception can abort processing of otherwise valid fields in the same options string, which conflicts with the confirmed tolerant-options approach used for static TG tokens. ### Assumptions - Malformed optional numeric fields should not abort processing of otherwise valid fields in the same options string. - Invalid numeric option fields should be ignored individually and logged. - Valid fields such as `DIAL`, `TIMER`, `TS1`, or `TS2` should still apply when an unrelated optional numeric field is malformed. - The broad exception handler should remain a last-resort guard, not the normal validation path. ### Unresolved Questions - Should invalid `VOICE`, `SINGLE`, and `OVERRIDE_IDENT_TG` each preserve their previous effective value? - Should invalid values for these fields be logged at debug level, matching existing option validation, or warning level? - Should boolean-like fields accept only `0` and `1`, or continue accepting any integer where Python truthiness determines the effective boolean? ### Protocol-Sensitive Areas - `OVERRIDE_IDENT_TG` controls where voice ident packets are sent. - Voice ident traffic uses generated DMR packets and should not be redirected to an invalid TG because of malformed options. - Options parsing should not partially abort before dial-a-TG/default/static routing changes are considered. ### Inferred Invariants - Options fields should be normalized and validated before mutating runtime config. - Invalid independent option fields should not block valid independent option fields. - Numeric option parsing should avoid `ValueError` as normal control flow. ### Resolution - Client session options now validate numeric fields before mutating runtime config. - Invalid `VOICE`, `SINGLE`, and `OVERRIDE_IDENT_TG` values are ignored independently and do not block valid fields in the same options string. - `VOICE` and `SINGLE` accept only `0` and `1`. - Empty `DIAL` / `DEFAULT_REFLECTOR` is parsed as `0`, meaning no default reflector. ## Current Analysis: Voice Ident Override TG Range Check ### Findings - `ident()` checks `OVERRIDE_IDENT_TG` before sending generated voice ident packets. - The upper-bound expression is malformed: `int(CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG'] < 16777215)`. - The comparison happens before `int(...)`, so string config values such as `"9"` can be compared directly to integer `16777215`, raising `TypeError`. - Numeric config values avoid the type error, but the expression becomes `int(True)` or `int(False)`, so the second half of the range check is only `1` or `0`. - `bytes_3(CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG'])` is then called with the raw config value rather than the parsed integer. ### Assumptions - `OVERRIDE_IDENT_TG` is intended to direct voice ident packets to a configured TG instead of all-call. - Valid override TGs should be positive and less than the DMR all-call value `16777215`. - Empty string, `False`, or `0` should mean no override and should fall back to all-call. - Invalid override values should not crash `ident()`; they should fall back to all-call and ideally be logged. - Local/control TGs should be rejected for ident override. ### Unresolved Questions - Should invalid startup `OVERRIDE_IDENT_TG` be normalized in runtime config, or only ignored at ident send time? - Resolved: invalid ident override values should be logged. ### Protocol-Sensitive Areas - Voice ident sends generated DMR packets and chooses the destination TG for those packets. - All-call `16777215` is the fallback destination when no override is active. - This path must not mutate packet bytes outside the intended generated voice packet destination. ### Inferred Invariants - Voice ident destination selection should parse and validate the override TG before packet generation. - Invalid override TG values should not prevent voice ident from running. - Empty or disabled override values should use all-call. ### Resolution - `OVERRIDE_IDENT_TG` is parsed before use. - Valid positive TGs below all-call are used as the generated voice ident destination. - Empty string, `False`, and `0` use all-call. - Malformed, out-of-range, or local/control TG values are logged and use all-call. ## Current Analysis: Invalid TIMER Still Used Raw In Static TG Branches ### Findings - `options_config()` now parses `DEFAULT_UA_TIMER` / `TIMER` into `_default_ua_timer` and falls back to the current effective timer when the option is malformed. - Later in the same function, the TS1 and TS2 static TG update branches still use `int(_options['DEFAULT_UA_TIMER'])` instead of `_default_ua_timer`. - A client option string such as `TIMER=A;TS1=91` logs that the timer is invalid, but then can still raise `ValueError` when the static TG branch runs. - That aborts otherwise valid static TG changes in the same options string. ### Assumptions - Invalid `TIMER` should be ignored independently for the session and should use the current effective `DEFAULT_UA_TIMER`. - Valid static TG changes in the same options string should still apply. - The raw `DEFAULT_UA_TIMER` option value should not be converted again after `_default_ua_timer` has been parsed and normalized. ### Unresolved Questions - None for the immediate fix if the previously confirmed tolerant options model applies. ### Protocol-Sensitive Areas - The timer value controls bridge timeout state but should not block static TG routing updates when malformed. - Static TG updates must use the effective parsed timer, not raw client text. ### Inferred Invariants - Once an option field is parsed into an effective value, subsequent bridge mutations should use that effective value. - Invalid independent option fields should not trigger `ValueError` as normal control flow. - Invalid option fields should be logged. ### Resolution - TS1 and TS2 static TG updates now use the parsed effective `_default_ua_timer` rather than the raw option string. - Invalid `TIMER` values are logged and valid static TG updates in the same options string still apply with the current effective timer. ### Clarified Configuration Model - Client-supplied options are session-scoped. They should persist only for the duration of that client session. - FreeDMR has a disconnect/timeout routine that resets session-scoped options to system configured defaults. - Startup config-file defaults and client options are different layers and should not be treated identically. - If a startup config directive has an invalid field and that directive has a defined system fallback default, FreeDMR should use the fallback as if the directive had not been present and log that the startup default setting was invalid. - If a startup config directive is invalid and has no system default fallback, FreeDMR should exit with an error. - For client options, invalid fields should not become persisted configuration; they should apply only if valid for the current session. - Rejected configuration or client-option errors should be logged. Normalizing, ignoring, or falling back silently is not acceptable because many rejected options have no direct user feedback path. ### Startup Fallback Mapping Observed In `config.py` - `DEFAULT_UA_TIMER` has a startup fallback of `10`. - `SINGLE_MODE` has a startup fallback of `True`. - `VOICE_IDENT` has a startup fallback of `True`. - `TS1_STATIC` has a startup fallback of empty string. - `TS2_STATIC` has a startup fallback of empty string. - Empty `TS1_STATIC` and `TS2_STATIC` values are valid and mean no static TGs for that slot. - `OVERRIDE_IDENT_TG` has a startup fallback of `False`. - `DEFAULT_REFLECTOR` is read with `getint()` and no fallback in `config.py`; however the runtime policy now treats invalid positive values as disabled default reflector state for the current session. - Empty string, integer `0`, and boolean false for `DEFAULT_REFLECTOR` are equivalent and mean no default reflector is set. - `OPTIONS` is session-scoped and is reset by the HBP connection lifecycle to `_default_options` when present, or removed otherwise. ## Voice Prompt Packet System Scoping ### Findings - `sendVoicePacket(self, pkt, _source_id, _dest_id, _slot)` indexes `systems[system].STATUS`, but `system` is not a function argument or local variable. - `sendSpeech(self, speech)` also indexes `systems[system].STATUS[2]` without a local `system`. - The call sites pass a router instance as `self`, for example `reactor.callInThread(sendSpeech, self, speech)` and `reactor.callFromThread(sendVoicePacket, systems[system], ...)`. - In imported deterministic tests this can raise `NameError` if those functions execute directly. In the full process it can be worse: module-level `for system in ...` loops may leave a stale global `system`, causing prompt stream state to be written to the wrong router. ### Assumptions To Validate - Generated voice packets should update the status for the router instance being sent on, i.e. `self._system`. - Dial-a-TG voice prompts, disconnected announcements, on-demand files, and idents should all be emitted on TG9 slot 2 unless their existing call path intentionally selects a different destination. - A voice prompt should not mutate stream status for another master just because a global loop variable currently names that system. ### Unresolved Questions - Should invalid prompt packet/state errors be caught and logged inside the thread helpers, or should existing Twisted thread exception logging remain the only failure report? ### Protocol-Sensitive Areas - Voice prompt packet bytes and destination TG must remain unchanged unless the production code intentionally generates them that way. - The fix should be transport/state scoping only; it should not alter dial-a-TG routing decisions or prompt content. ### Inferred Invariants - Helper functions that receive a router instance should derive the system name from `self._system`, not from a module-global loop variable. - Prompt stream lifecycle bookkeeping must be attached to the same system that sends the prompt packet. ### Resolution - `sendVoicePacket()` and `sendSpeech()` now bind `system = self._system` before accessing `systems[system].STATUS`. - Deterministic coverage exercises `sendSpeech()` with a deliberately stale module-level `system` value and verifies stream state and captured prompt packets stay on the router instance's own system. ## Bridge Reset System Replacement ### Findings - `remove_bridge_system(remsystem)` loops `for system in CONFIG['SYSTEMS']` and rebuilds every bridge once per configured system. - When it finds an entry where `bridgesystem['SYSTEM'] == remsystem`, it appends a replacement entry with `'SYSTEM': system`, using the outer loop variable rather than `remsystem`. - Because `bt[bridge]` is overwritten on every outer-loop pass, the final bridge state depends on the last configured system. In a two-master topology, removing `MASTER-A` can leave two `MASTER-B` entries and no `MASTER-A` entry. - Additional deterministic probes show the same identity corruption with three masters and with OpenBridge present: the replacement entry can become `MASTER-C` or even an `OBP-*` system depending on config iteration order. - Neighboring reset helpers do not support this as intentional behavior: `reset_static_tg()` and `reset_all_reflector_system()` preserve the target system identity in replacement entries. ### Assumptions To Validate - Bridge reset for a disconnected or timed-out master should keep that master's bridge entry present but inactive, preserving its slot, TGID, timeout and normal dial-a-TG activation trigger. - Bridge reset should not create duplicate entries for another master. - Bridge reset should not remove or deactivate unrelated systems' bridge entries. ### Unresolved Questions - Should reset entries keep the previous `ON` list exactly as-is, or should they keep the current behavior of setting `ON` to the entry's `TGID`? ### Protocol-Sensitive Areas - This affects bridge state only; packet bytes should not be changed. - Incorrect reset state can change which systems receive later TG traffic and can prevent the reset master from reactivating the intended TG. ### Inferred Invariants - A reset of system `X` must leave bridge entries for system `X` named `X`. - The number of entries for unrelated systems should not change during reset. ### Resolution - `remove_bridge_system()` now rebuilds each bridge once instead of once per configured system. - Replacement entries preserve `SYSTEM` as `remsystem`, set `ACTIVE` false, set `TO_TYPE` to `ON`, reset the timer, and preserve existing `ON`, `OFF`, and `RESET` trigger lists. - Deterministic coverage verifies reset identity preservation for normal bridges and trigger preservation for `#` reflector bridges with an FBP peer present. ## HBP Packet Reset/Reload Guard ### Findings - `routerHBP.dmrd_received()` checks reset/reload state before parsing a packet: `CONFIG['SYSTEMS'][self._system]['_reset'] or CONFIG['SYSTEMS'][_system]['_reloadoptions']`. - `_system` is assigned later in the same function by bridge iteration loops, so Python treats it as a local variable. Referencing it in the guard before assignment raises `UnboundLocalError` when the `_reset` key exists and does not short-circuit safely. - The `return` is outside the `if` block but inside the `try`. If both `_reset` and `_reloadoptions` existed and were false, the function would still return and drop the packet. - The current code only reaches normal packet parsing when a `KeyError` is raised by missing state keys. That makes normal operation depend on absent lifecycle keys rather than their boolean values. ### Assumptions To Validate - HBP packets should be dropped only while the receiving system's `_reset` or `_reloadoptions` flag is true. - The guard should inspect `CONFIG['SYSTEMS'][self._system]` for both flags. - When a packet is dropped due reset/reload, it should be logged once using `_resetlog`, preserving the current intent. - When both flags are false or absent, packet handling should continue normally. ### Unresolved Questions - Should reset and reload use separate one-shot log flags, or is the existing shared `_resetlog` flag sufficient? ### Protocol-Sensitive Areas - This is a packet admission guard. The fix should not mutate packet bytes or alter routing after a packet is admitted. - Incorrectly dropping during false reset/reload state can look like a silent repeater or TG routing failure. ### Inferred Invariants - Lifecycle flags should be treated as optional booleans with false defaults. - A packet should be rejected during reset/reload only when the relevant flag is explicitly true. - Rejected packet admission during reset/reload should remain logged. ### Resolution - The HBP packet admission guard now reads `_reset` and `_reloadoptions` from `CONFIG['SYSTEMS'][self._system]` with false-default `dict.get()`. - The guard returns only when reset or reload is active, and logs the rejection once through `_resetlog`. - Deterministic coverage verifies false lifecycle flags admit packets, while active reset or reload drops packets and logs exactly once. ## HBP Unit Data To OBP Reporting Target ### Findings - `routerHBP.sendDataToOBP()` sends the packet to `systems[_target]` correctly. - When reporting is enabled, it then calls `systems[system]._report.send_bridgeEvent(...)`, but `system` is not a function argument or local variable in `sendDataToOBP()`. - In a deterministic HBP unit-data-to-OBP path with reporting enabled, the packet is captured for the OBP target and then the function raises `NameError: name 'system' is not defined`. - If a module-level `system` happens to exist in the full process, the report event could be attached to the wrong system's report object instead of raising. ### Assumptions To Validate - The TX report for `sendDataToOBP()` should be emitted on the target OBP system's report object, matching the packet destination and the event payload. - Reporting should not be able to raise after a packet send and interrupt the rest of packet handling. - This fix is reporting-only and should not change packet bytes, routing, stream state, or source-quench behavior. ### Unresolved Questions - None for the minimal fix; this appears to be a direct variable-name error. ### Protocol-Sensitive Areas - The packet send path already uses `_target`; only the reporting object should change. - The event payload already names `_target`, so preserving it avoids report semantic changes. ### Inferred Invariants - Reporting side effects should use the same target system as the packet send unless a caller explicitly reports on the source router. - Enabling `CONFIG['REPORTS']['REPORT']` must not change packet routing behavior or raise after a successful send. ### Resolution - `routerHBP.sendDataToOBP()` now reports through `systems[_target]._report`, matching the packet send target and existing event payload. - Deterministic coverage verifies HBP unit data forwarded to OBP captures the packet on the OBP target and records the TX report on that target report object without raising. ### Data Packet Review Scope - Per user direction, continue evaluating data packet processing before moving to voice packet path debugging. ## OBP Data Gateway Metadata Argument Order ### Findings - In `routerOBP.dmrd_received()`, the DATA-GATEWAY forwarding path calls `sendDataToOBP('DATA-GATEWAY', ..., _source_rptr, _ber, _rssi)`. - `routerOBP.sendDataToOBP()` expects positional arguments after `_slot` as `_hops, _source_server, _ber, _rssi, _source_rptr`. - This shifts metadata fields: `_source_rptr` is sent as hops, `_ber` is sent as source server, `_rssi` is sent as BER, RSSI falls back to zero, and source repeater falls back to zero. - A deterministic probe with distinctive metadata captured: `hops=b'\x01\x02\x03\x05'`, `source_server=b'\x12'`, `ber=b'4'`, `source_rptr=b'\x00\x00\x00\x00'` for the DATA-GATEWAY target. ### Assumptions To Validate - DATA-GATEWAY forwarding from OBP should preserve incoming `_hops`, `_source_server`, `_ber`, `_rssi`, and `_source_rptr` exactly as received unless production code intentionally rewrites them. - The DATA-GATEWAY path should match the later OBP-to-OBP forwarding call that already passes `_hops, _source_server, _ber, _rssi`. - Because this is DATA-GATEWAY forwarding, preserving source repeater metadata is preferable to falling back to zeros. ### Unresolved Questions - Should `routerOBP.sendDataToOBP()` calls always use keyword arguments for protocol metadata to prevent this class of positional-order bug? ### Protocol-Sensitive Areas - This affects OBP metadata, not DMR payload bytes. The packet bytes should remain unchanged except for existing slot-bit handling. - Hops, source server, source repeater, BER, and RSSI can affect loop control, observability and downstream data handling. ### Inferred Invariants - Transport metadata forwarding must not be confused with protocol mutation. - DATA-GATEWAY forwarding should not corrupt metadata by positional argument shifts. ### Validation Result - Rejected as a bug. User confirmed DATA-GATEWAY forwarding targets SMS/GPS packet processing by KF1EEL and is protocol v1, not FBP. - Protocol metadata expectations depend on protocol version; DATA-GATEWAY should not be evaluated as though it were an FBP peer. - Do not change this DATA-GATEWAY call based on FBP metadata preservation assumptions. - General protocol invariant confirmed by user: protocol options and argument order must match the protocol version actually in use for that session. ## Unit Data To HBP Reporting Slot ### Findings - Both `routerOBP.sendDataToHBP()` and `routerHBP.sendDataToHBP()` accept a target slot argument named `_d_slot`. - Callers calculate `_tmp_bits` before calling the helper, so the packet bytes are rewritten to the target slot when needed. - The helpers log and report the TX event with a hardcoded slot value of `1`. - Deterministic probes show the packet is captured on slot 2 when SUB_MAP points to slot 2, but the report event says slot 1: `UNIT DATA,DATA,TX,MASTER-B,...,1,1234567`. - This reproduces for both HBP-originated unit data and OBP-originated unit data forwarded to an HBP master via `SUB_MAP`. ### Assumptions To Validate - Unit data TX reports should identify the actual target HBP slot `_d_slot`. - The packet bytes should remain unchanged; the existing caller-side slot bit rewrite is already producing the expected target slot. - This is reporting/observability only and should not alter data routing, DATA-GATEWAY behavior, or protocol-version-specific metadata. ### Unresolved Questions - Should the debug log wording also be changed from "on slot 1" to include `_d_slot`, or should only the reporting payload change? ### Protocol-Sensitive Areas - This is independent of OBP protocol version because the affected target is HBP and `_d_slot` is already an explicit HBP target slot. - Reporting should reflect the actual transport target without mutating packet payload bytes. ### Inferred Invariants - If a helper receives `_d_slot`, reporting and logs should not hardcode slot 1. - Data packet tests should distinguish correct packet slot bits from incorrect report metadata. ### Resolution - `routerOBP.sendDataToHBP()` and `routerHBP.sendDataToHBP()` now use `_d_slot` in both debug logging and `UNIT DATA,DATA,TX` report payloads. - Deterministic coverage verifies HBP-originated and OBP-originated unit data forwarded to HBP slot 2 via `SUB_MAP` captures slot 2 packet bits and reports slot 2. ## OBP Unit CSBK Data Stream Classification ### Findings - HBP unit data classification treats `_dtype_vseq == 3` as data when the packet stream differs from the slot's current `RX_STREAM_ID`. - HBP unit data handling does not update the slot `RX_STREAM_ID` in the data branch, so repeated unit CSBK packets with the same stream continue through the data forwarding path. - OBP unit data classification treats `_dtype_vseq == 3` as data only when `_stream_id not in self.STATUS`. - Once the first OBP unit CSBK packet creates `self.STATUS[_stream_id]`, subsequent unit CSBK packets with the same stream do not enter the data path and are not forwarded via `SUB_MAP`. - Deterministic probe: two HBP unit CSBK packets with the same stream both forward to the HBP `SUB_MAP` target; two OBP unit CSBK packets with the same stream forward only the first packet. ### Assumptions To Validate - Repeated unit CSBK packets with the same stream ID should still be treated as data packets and forwarded, unless duplicate control rejects the exact packet. - OBP and HBP should not diverge on this classification solely because OBP uses per-stream `STATUS` and HBP uses per-slot `STATUS`. - If this is intentional first-packet-only OBP behavior for a protocol version, the deterministic harness should document it rather than change it. ### Unresolved Questions - Is `_dtype_vseq == 3` intended to represent only an initial unit CSBK in OBP, or can a valid data transaction contain multiple non-identical CSBK packets with the same stream ID? ### Protocol-Sensitive Areas - This affects data packet admission to the forwarding path, not packet byte mutation. - Duplicate suppression should remain separate from protocol classification: exact duplicate packets may be dropped, but distinct CSBK data packets should not be lost just because their stream already exists if the protocol allows them. ### Inferred Invariants - Data packet classification should be explicit about whether stream state is a first-packet guard or only duplicate/loop-control state. - OBP/HBP behavioral differences need protocol-version justification. ### Deeper Analysis - ETSI TS 102 361-2 describes CSBK preamble use as a way to improve successful delivery to mobile stations that are scanning or using sleep mode for battery saving. - User confirmed a DMR terminal can send more than one CSBK, usually as a preamble to wake battery-operated terminals, but only one is required for the route. - In FreeDMR's OBP path, the first unit CSBK creates per-stream `STATUS` and can be forwarded through DATA-GATEWAY, other OpenBridge/FBP peers, `SUB_MAP`, or hotspot matching. - Subsequent data header/block packets (`_dtype_vseq` 6, 7, 8) are still always classified as unit data for the same stream. Therefore, the OBP first-CSBK gate does not block the actual data payload path. - Forwarding only the first CSBK may be intentional: it is enough to establish routing/wake-up behavior and avoids multiplying preamble bursts across the network. - HBP's different behavior appears partly caused by its per-slot data state: the unit-data branch does not set `RX_STREAM_ID`, so repeated CSBKs continue to satisfy `_stream_id != self.STATUS[_slot]['RX_STREAM_ID']`. ### Validation Result - Do not change this yet. The observed OBP/HBP difference is real, but not yet a confirmed bug. - Treat OBP first-CSBK-only forwarding as likely intentional until a recorded fixture or live test shows a valid data transaction needing multiple distinct CSBKs forwarded over OBP for correct behavior. - Add future fixture coverage before changing this classification logic. ### Follow-Up Verification - Code parsing in `hblink.py` extracts `_stream_id = _data[16:20]` for every DMRD packet before passing it into `dmrd_received()`. - FreeDMR does not appear to model a single transaction-level stream ID for the whole SMS/GPS data exchange. Each DMRD packet carries its own stream ID, and data routing decisions are made per received packet. - OBP unit data handling always classifies `_dtype_vseq` 6, 7 and 8 as data regardless of whether `_stream_id` already exists, so data header/block packets with distinct stream IDs are naturally handled independently. - OBP unit CSBK handling classifies `_dtype_vseq == 3` as data only when that packet's `_stream_id` is not already present in `self.STATUS`. - Deterministic probe: two OBP unit CSBK packets with distinct stream IDs both forward via `SUB_MAP`; two with the same stream ID only forward the first. - This supports the interpretation that repeated CSBK preambles are expected to be distinct packets with distinct stream IDs in this code path, and the existing OBP first-CSBK-per-stream gate is likely a duplicate/preamble suppression mechanism rather than a transaction-level routing bug. - User confirmed the underlying protocol model: data packets are packet-oriented and are not a stream in the same way AMBE2+ audio is a stream. - User also confirmed DMR data is not always unit-to-unit; the standard permits data packets to be sent as group calls to a talkgroup. ## HBP Group-Addressed Data Activates TG Bridge State ### Findings - HBP `routerHBP.dmrd_received()` handles `group` and `vcsbk` packets in the group-call path. - For a new group packet, it logs `_dtype_vseq == 6` as `DATA HEADER`, but then runs the same unknown-TG bridge creation block used for voice-like group calls: `make_single_bridge(_dst_id, self._system, _slot, DEFAULT_UA_TIMER)`. - Deterministic probe: a single HBP group data header to TG123 creates bridge `123` with `MASTER-A` slot 2 active. - If bridge `123` already exists and is active, the same group data header is routed to target systems, so group-addressed data routing through existing TG bridges appears intentional or at least supported. - OBP group data to TG123 does not create a bridge in the same probe. ### Assumptions To Validate - Group-addressed data packets may legitimately route over existing active TG bridge state. - Unknown group-addressed data packets should not necessarily create new user-activated voice/TG bridge state just because the TG is unknown. - If unknown group data is intended to create a bridge, that should be treated as an explicit design decision because it can activate TG state without voice. ### Unresolved Questions - Should group-addressed data headers (`_call_type == 'group'` and `_dtype_vseq == 6`) create user-activated bridges for unknown TGs, or should automatic bridge creation be restricted to voice-like group calls? - Should OBP and HBP differ here, or should both follow the same group-data bridge-creation policy? ### Protocol-Sensitive Areas - Data packets are packet-oriented, so stream-style bridge activation semantics may not be appropriate. - Group data can be a valid TG-addressed packet, but routing it over an existing bridge is different from creating new active bridge state. ### Inferred Invariants - Packet routing and bridge-state activation should be considered separately. - A data packet should not create voice/TG lifecycle state unless that is an explicit FreeDMR policy. ### Validation Result - Leave unchanged for now. User believes this behavior is deliberate. - Likely rationale: allow routing of GPS packets sent as group calls to a local APRS bridge without exposing them to the entire network, because they are local-to-server. - Do not treat HBP unknown group-data bridge creation as a confirmed bug unless a later fixture or operational test disproves this policy. ## Enhanced OBP Missing-Keepalive Gate ### Findings - `kaReporting()` logs enhanced OpenBridge targets with no `_bcka` as not sendable: `not sending to system ... as KeepAlive never seen`. - `routerOBP.to_target()` applies that rule for OBP-originated group/voice traffic: if `ENHANCED_OBP` is true and `_bcka` is missing or older than 60 seconds, the target is skipped. - `routerHBP.to_target()` only skips enhanced OpenBridge targets when `_bcka` exists and is stale; if `_bcka` is missing, HBP-originated group/voice traffic is still forwarded. - `routerOBP.sendDataToOBP()` and `routerHBP.sendDataToOBP()` have the same lenient missing-`_bcka` check, so unit data can be forwarded to enhanced OpenBridge targets that have never sent a keepalive. - Deterministic probes confirmed: HBP unit data -> enhanced OBP without `_bcka` sends one packet; HBP group voice -> enhanced OBP without `_bcka` sends one packet; OBP unit data -> enhanced OBP without `_bcka` sends one packet; OBP group voice -> enhanced OBP without `_bcka` sends no packet. ### Assumptions To Validate - For `ENHANCED_OBP` targets, missing `_bcka` means the peer has not completed the enhanced keepalive requirement and should not receive traffic. - The intended rule should be the same for HBP-originated and OBP-originated traffic, and for voice-like group packets and unit data packets. - Periodic `kaReporting()` warnings are sufficient operational logging for missing/stale enhanced keepalive suppression; per-packet skip logging would be noisy. ### Unresolved Questions - Should the strict missing-or-stale keepalive gate be applied everywhere an enhanced OpenBridge target is selected, or is there a deliberate exception for HBP-originated traffic/data? ### Protocol-Sensitive Areas - Enhanced OBP keepalive state is transport/session state, not DMR payload mutation. - Data packet forwarding must preserve packet bytes except intentional slot or transport metadata rewrites. ### Inferred Invariants - Enhanced OpenBridge targets should not receive traffic until the session has valid recent keepalive state. - The same sendability predicate should be used by voice and data paths unless an explicit protocol-version rule says otherwise. ### Validation Result - User confirmed the strict missing-or-stale `_bcka` gate is intended for enhanced OBP targets. The inconsistency likely came from protocol development and was missed. - Applied the strict gate in `routerHBP.to_target()`, `routerOBP.sendDataToOBP()`, and `routerHBP.sendDataToOBP()` so the data helpers and HBP-originated voice path match the existing OBP-originated voice behavior. - Added deterministic coverage for HBP unit data with missing, stale and recent keepalive; OBP unit data with missing keepalive; and HBP group voice with missing keepalive. ### Remaining Risk - The black-box UDP harness does not yet exercise enhanced OBP keepalive negotiation end to end. Current coverage validates the in-process routing predicate and packet capture behavior. ## Group-Addressed Data Reported As Group Voice ### Findings - HBP `routerHBP.dmrd_received()` recognizes a group data header when `_call_type == 'group'` and `_dtype_vseq == 6`; it logs `*DATA HEADER*` and emits `DATA HEADER,DATA,RX`. - The same HBP group data header then enters the normal group routing path. The target-side `to_target()` methods emit `GROUP VOICE,START,TX` for a new target stream regardless of `_dtype_vseq`. - OBP `routerOBP.dmrd_received()` does not special-case group data header receive reporting; a group data header is reported as `GROUP VOICE,START,RX`. - `stream_trimmer_loop()` later emits `GROUP VOICE,END,RX` and/or `GROUP VOICE,END,TX` for timed-out group data state, because the timeout reporter is keyed on stream/slot state and does not distinguish data headers from voice streams. - Deterministic probes with reporting enabled confirmed: HBP group data to HBP target reports `DATA HEADER,DATA,RX` on the source, but `GROUP VOICE,START,TX` on the target; after timeout the source gets `GROUP VOICE,END,RX` and the target gets `GROUP VOICE,END,TX`. - OBP group data reports `GROUP VOICE,START,RX` on the OBP source and `GROUP VOICE,START,TX` on the target. ### Assumptions To Validate - Group-addressed data routing may be intentional, but report events should not identify data packets as voice calls. - Consumers of bridge reports expect event names and categories to describe the packet class accurately enough to avoid showing phantom voice calls. - If a group data header creates temporary stream/slot state, timeout reporting should either use data-specific end events or avoid voice end events for that data-only state. ### Unresolved Questions - What report event names should be used for group-addressed data TX and OBP RX? Possible names include `GROUP DATA HEADER,DATA,TX/RX` or reusing `DATA HEADER,DATA,TX/RX`. - Should timeout cleanup emit a data-specific end event for group data, or should data headers avoid creating reportable voice lifecycle state? ### Protocol-Sensitive Areas - This is reporting and stream/slot observability, not packet forwarding or payload mutation. - Group-addressed data is valid DMR traffic and may intentionally route over existing TG bridges. ### Inferred Invariants - Routing a packet over a TG bridge does not imply the report event should be `GROUP VOICE`. - Voice lifecycle reports should only describe AMBE voice-like streams, not packet-oriented data headers. ### Validation Result - User agreed this should change, with the caveat that the dashboard may have limitations and will need later compatibility testing. - Added `group_data_event_name()` for report classification of group/vcsbk data events. - HBP and OBP group data headers now report `DATA HEADER,DATA,RX/TX` instead of `GROUP VOICE,START,RX/TX`. - HBP slot state and OBP stream state now track data-only report state so `stream_trimmer_loop()` suppresses `GROUP VOICE,END,RX/TX` for data-only timeouts. - Deterministic coverage verifies HBP and OBP group data reporting does not emit `GROUP VOICE` events, and that normal HBP group voice still emits voice start/end lifecycle reports. ### Remaining Risk - The dashboard is the primary report consumer and may assume the older `GROUP VOICE` event names. Dashboard compatibility remains to be tested outside this codebase. ## HBP VCSBK Data RX Duplicate Reports ### Findings - HBP `routerHBP.dmrd_received()` handles `_call_type == 'vcsbk'` in the group call/data path. - On a new VCSBK stream, the first `_call_type != 'group'` branch logs `*VCSBK*` and emits `OTHER DATA,DATA,RX`. - Immediately afterwards, the dedicated VCSBK block handler emits a second, more specific report for `_dtype_vseq == 7` or `_dtype_vseq == 8`: `VCSBK 1/2 DATA BLOCK,DATA,RX` or `VCSBK 3/4 DATA BLOCK,DATA,RX`. - Deterministic probes confirmed HBP VCSBK dtype 7 and dtype 8 each produce two RX report events on the source system. - The target-side report is already specific (`VCSBK 1/2 DATA BLOCK,DATA,TX` or `VCSBK 3/4 DATA BLOCK,DATA,TX`) because `to_target()` uses `group_data_event_name()`. - OBP-originated VCSBK dtype 7 and dtype 8 now produce only the specific RX event and the specific TX event. ### Assumptions To Validate - `OTHER DATA,DATA,RX` is intended as a fallback for VCSBK/other data types that do not have a more specific report name. - HBP VCSBK dtype 7/8 should emit only the specific VCSBK block RX event, not both a generic and specific data event. - Removing the duplicate generic HBP source report is reporting-only and should not alter routing, packet bytes, or bridge activation behavior. ### Unresolved Questions - Should any other VCSBK dtype values continue to report as `OTHER DATA,DATA,RX` on first receipt? ### Protocol-Sensitive Areas - VCSBK data block classification is protocol/data reporting, not AMBE voice lifecycle. - This change would affect dashboard/report consumers but not DMR payload forwarding. ### Inferred Invariants - A single received packet should normally produce one RX report event for its packet class. - Specific data report names should take precedence over generic fallback names. ### Validation Result - User agreed that specific VCSBK events should take precedence. - HBP `OTHER DATA,DATA,RX` fallback now only emits when `group_data_event_name()` has no specific event name. - Deterministic coverage verifies HBP VCSBK dtype 7 emits the specific `VCSBK 1/2 DATA BLOCK,DATA,RX/TX` events without a generic RX duplicate. - Deterministic coverage also verifies an unknown VCSBK dtype still emits `OTHER DATA,DATA,RX`. ## Unknown VCSBK Reported As Group Voice ### Findings - Unknown HBP VCSBK data types currently emit `OTHER DATA,DATA,RX` on the source, but because `group_data_event_name()` returns `None` for unknown VCSBK values, the same packet is treated as voice-like for target and timeout reporting. - Deterministic probe for HBP `_call_type == 'vcsbk'` and `_dtype_vseq == 5` produced source reports: `OTHER DATA,DATA,RX` followed by `GROUP VOICE,END,RX` on timeout. - The target for the same HBP packet produced `GROUP VOICE,START,TX` followed by `GROUP VOICE,END,TX` on timeout. - OBP-originated unknown VCSBK currently produces only `GROUP VOICE,START,RX` on source and `GROUP VOICE,START,TX` on target, followed by voice timeout events. - This is parallel to the group-data reporting issue, but applies to generic VCSBK fallback data rather than known VCSBK block types. ### Assumptions To Validate - `vcsbk` packets are data/control signalling for report classification, even when `_dtype_vseq` is not one of the known block values currently named by the code. - Unknown VCSBK types should use `OTHER DATA,DATA,RX/TX` instead of any `GROUP VOICE` lifecycle event. - Timeout cleanup should suppress `GROUP VOICE,END,RX/TX` for unknown VCSBK state just as it does for known group/vcsbk data. ### Unresolved Questions - Are there any VCSBK `_dtype_vseq` values that dashboard/report consumers intentionally expect to appear as `GROUP VOICE`? ### Protocol-Sensitive Areas - VCSBK is a signalling/data classification issue; packet bytes and routing should remain unchanged. - Dashboard compatibility remains a risk for report event names. ### Inferred Invariants - Unknown data/control packet types should fall back to generic data reporting, not voice lifecycle reporting. - `group_data_event_name()` currently doubles as both the event-name selector and the data-vs-voice lifecycle classifier; unknown VCSBK exposes that coupling. ### Specification Check - ETSI TS 102 361-1 V2.6.1 separates voice burst format from data/control burst format. Voice bursts carry vocoder payload plus either sync or embedded signalling in the centre field. - The embedded signalling defined for voice bursts is LC, RC, privacy-related signalling, or null embedded message. This is not a CSBK/data continuation Slot Type burst. - Data/control bursts use the general data burst format and contain a Slot Type PDU whose Data Type defines the 196 information bits. - The Data Type table lists CSBK, Data Header, Rate 1/2 Data Continuation, and Rate 3/4 Data Continuation as data/control burst types. - Therefore, the packets currently classified by the HomeBrew bit parser as `vcsbk`/data-sync data types should not be reported as part of a voice stream. Unknown VCSBK data types should fall back to generic data/control reporting, not `GROUP VOICE` lifecycle reporting. ### Validation Result - User approved the fix after the specification check. - Added `is_group_data_control()` to classify group/vcsbk data-control packets independently from their specific report event name. - Added `group_data_report_name()` so known group/vcsbk data uses the specific event name and unknown VCSBK falls back to `OTHER DATA`. - HBP and OBP unknown VCSBK now emit `OTHER DATA,DATA,RX/TX` and suppress `GROUP VOICE` timeout lifecycle events. - Deterministic coverage verifies HBP and OBP unknown VCSBK fallback reporting produces no `GROUP VOICE` events. ## OBP Unit Data Loop-Control Zero-Division ### Findings - `routerOBP.dmrd_received()` unit-data handling creates or updates `self.STATUS[_stream_id]`, increments `packets`, then performs OBP first-source loop-control selection immediately in the same receive path. - If another OBP system has the earlier `1ST` timestamp for the same `_stream_id` and `_dst_id`, the losing source enters the `self._system != fi` branch. - That branch calculates `call_duration = pkt_time - self.STATUS[_stream_id]['START']`, initializes `packet_rate = 0`, but then divides by `call_duration` whenever `packets` exists: `packet_rate = self.STATUS[_stream_id]['packets'] / call_duration`. - Deterministic probe: inject the same OBP unit data packet with the same stream ID into `OBP-1`, then into `OBP-2` without advancing the harness clock. The second injection raises `ZeroDivisionError` at the packet-rate calculation. - The HBP/OBP group loop-control path has a similar unguarded packet-rate calculation, but the immediate two-source group probe did not crash because that path only reaches the calculation after the stream already exists on the receiving OBP source. ### Assumptions To Validate - A zero-duration packet-rate sample should be reported as `0` rather than crashing packet processing. - Loop-control should still ignore the later source and update `LAST` as it does today. - This is a logging/diagnostic calculation; guarding it should not change routing, packet bytes, or first-source selection behavior. ### Unresolved Questions - Should the same duration guard be applied to all packet-rate calculations in loop-control/rate-drop paths, including the OBP group path and HBP group rate-drop path, as a defensive cleanup? ### Protocol-Sensitive Areas - This is loop-control diagnostics for data forwarding, not protocol mutation. - The first-source `1ST` selection remains the source-quench/loop-control behavior under review. ### Inferred Invariants - Packet processing must not fail because multiple packets share the same timestamp. - Diagnostic packet-rate calculations must be optional and guarded. ### Validation Result - User approved the zero-duration guard. - Added `call_duration` truthiness checks before calculating diagnostic `packet_rate` in OBP unit-data and OBP group loop-control ignore paths. - Deterministic coverage reproduces the same-timestamp two-OBP unit-data case and verifies the later source is loop-controlled without raising. ## OBP Group Packet Rate Drop Uses Absolute Start Time ### Findings - `routerOBP.dmrd_received()` group/vcsbk packet-rate protection checks `self.STATUS[_stream_id]['packets'] > 18` and then divides packet count by `self.STATUS[_stream_id]['START']`. - `START` is an absolute timestamp captured from `pkt_time`, not an elapsed duration. - With normal epoch-like times, `packets / START` is effectively zero, so the `> 25` packet-rate threshold will not fire. - HBP `routerHBP.dmrd_received()` has the analogous rate-drop check, but divides by elapsed time: `pkt_time - self.STATUS[_slot]['RX_START']`. - Deterministic probe: 19 OBP group packets over about 0.095 seconds did not call `proxy_BadPeer()`. The equivalent HBP probe logged `RATE DROP` and set the slot `LAST` field. - `hblink.HBSYSTEM.proxy_BadPeer()` iterates `_peers` and emits `PRBL` proxy blacklist packets for connected HBP client/repeater peers. - `hotspot_proxy_v2.py` consumes `PRBL` by looking up the peer in `peerTrack` and blacklisting the tracked client source host. - `routerOBP` is an OpenBridge peer system, not a hotspot proxy client session; it does not have a valid HBP `_peers` set for this purpose and should not use proxy client blacklisting as its OBP/FBP packet-rate response. - If the OBP elapsed-time divisor is corrected while leaving `self.proxy_BadPeer()` in place, the rate-drop path is likely to fail at runtime rather than cleanly dropping/controlling the offending OBP stream. - User clarified that using the proxy as an IP-level block point can be deliberate: the proxy can install blacklist entries into iptables, and a malicious flood from one IP may need to be blocked for both client and OBP traffic. - The remaining concern is therefore not the policy of using the proxy as a blanket block point; it is that `routerOBP` inherits from `OPENBRIDGE`, while `proxy_BadPeer()` is only defined on `HBSYSTEM`, and OBP `dmrd_received()` is not currently passed the source `_sockaddr`. ### Assumptions To Validate - OBP group/vcsbk rate-drop should use elapsed stream duration: `pkt_time - self.STATUS[_stream_id]['START']`. - A zero or near-zero elapsed duration should not crash; if the threshold cannot be evaluated safely, the packet should not be rate-dropped solely by the diagnostic calculation. - The existing OBP enforcement policy of asking the proxy to blacklist a flood source may be intentional and operationally useful. - The current OBP call site still needs validation because it calls an HBP-only helper and does not have the inbound OBP source socket address in `routerOBP.dmrd_received()`. - The intended OBP/FBP response should be either local packet/stream drop only, local drop plus enhanced-OBP source quench, or local drop plus a correctly targeted proxy blacklist request. ### Unresolved Questions - Should the OBP rate-drop response send enhanced-OBP BCSQ source quench when `ENHANCED_OBP` is enabled? - If proxy blacklisting is retained for OBP floods, should the blacklist target be the validated inbound `_sockaddr`, the configured `TARGET_SOCK`, or a separate proxy-control address? - Should the threshold stay at the existing 25 packets/second over more than 18 packets as a crash-protection guard, or should it be configurable later? ### Protocol-Sensitive Areas - This is packet-rate control and bad-peer handling, not packet mutation. - Changing the divisor can make an existing protection effective, which may alter operational behavior under bursty traffic. - `proxy_BadPeer()` currently belongs to the HBP/hotspot proxy control plane. Reusing the proxy as a shared IP block point may be valid, but OBP needs an explicit, correctly targeted path rather than an HBP-only method call. - BCSQ is an enhanced-OBP source-quench mechanism; using it here would be a protocol-visible overload signal, not just local loop protection. ### Inferred Invariants - Packet-rate thresholds must be calculated against elapsed stream duration, not absolute timestamps. - Rate-limit calculations must guard zero elapsed duration. - OBP/FBP overload handling must not depend on HBP client proxy state. ### Status - Deferred by user. - Do not change this path yet. The proxy/IP-blocking intent may be deliberate because the proxy can feed blacklisted IPs into iptables and may be a useful shared block point for malicious client and OBP floods. - Return later with a focused review of whether `routerOBP` can correctly reach that proxy block path as implemented, and whether the rate calculation should be repaired without changing the intended block policy. ## HBP Unit Data To FBP Drops BER/RSSI Metadata ### Findings - `routerHBP.dmrd_received()` extracts `_ber = _data[53:54]` and `_rssi = _data[54:55]` for inbound HBP packets. - HBP group/voice forwarding to OpenBridge passes `_ber` and `_rssi` through `routerHBP.to_target()` into `systems[_target['SYSTEM']].send_system(...)`. - HBP unit-data forwarding uses `routerHBP.sendDataToOBP()`, whose signature accepts `_ber` and `_rssi`, and then passes them into `systems[_target].send_system(...)`. - The two HBP unit-data call sites pass only `_source_rptr` as the positional argument after `_slot`: `self.sendDataToOBP(..., _bits, _slot, _source_rptr)`. - Because of the function signature, that value is bound to `_hops`, while `_ber` and `_rssi` use their default zero values. - `routerHBP.sendDataToOBP()` ignores the `_hops` argument and resets `_source_server` and `_source_rptr` internally, so the current extra positional argument is effectively confusing but harmless. The BER/RSSI loss is the observable issue. - Deterministic probe with inbound HBP unit data carrying `ber=b'B'` and `rssi=b'R'` captured an OBP send call with `ber=b'\x00'` and `rssi=b'\x00'`. ### Assumptions To Validate - For normal FBP/OpenBridge peer forwarding, HBP-originated unit-data packets should preserve inbound BER/RSSI metadata just like HBP-originated group/voice packets do. - Passing BER/RSSI into `send_system()` is protocol-version safe because `OPENBRIDGE.send_system()` decides what fields are encoded based on the target session protocol version. - DATA-GATEWAY remains protocol-version-specific. Passing BER/RSSI is harmless for a v1 DATA-GATEWAY target because v1 packet construction will not encode the FBP metadata fields. ### Unresolved Questions - Should the fix touch both HBP unit-data call sites, including DATA-GATEWAY, or only the normal FBP peer forwarding call? - Should `routerHBP.sendDataToOBP()` drop the unused `_hops` parameter or should we keep the signature stable and use keyword arguments at the call sites? ### Protocol-Sensitive Areas - This is transport/session metadata, not DMR payload mutation. - Protocol option order must continue to match the protocol version actually used by the target OpenBridge session. - DATA-GATEWAY is not FBP by design; changes must not reinterpret it as FBP. ### Inferred Invariants - DMR payload bytes must be preserved unless intentionally rewritten. - HBP BER/RSSI metadata should not be silently zeroed when forwarding to a protocol version that can carry it. - Positional metadata arguments are fragile in these paths; keyword arguments are safer for reviewable fixes. ### Validation Result - User confirmed this is intentional future consistency work that was not completed in the data path. - Severity is low because BER/RSSI metadata is not central to data routing, but preserving it should be done for consistency with the voice/group path. - User confirmed DATA-GATEWAY may or may not be obsolete, but should remain in place for now. - Updated the HBP unit-data DATA-GATEWAY and normal OpenBridge/FBP call sites to pass `_ber` and `_rssi` as keyword arguments, avoiding the previous positional `_source_rptr` confusion. - Added deterministic coverage proving HBP unit data with nonzero BER/RSSI is forwarded to an OpenBridge target with that send metadata preserved while the captured DMRD packet bytes remain metadata-free at the deterministic capture point. ## HBP Group/VCSBK Rate Drop Zero-Division ### Findings - `routerHBP.dmrd_received()` handles group and VCSBK packets on a per-slot stream state. - On a new group/VCSBK stream it sets `self.STATUS[_slot]['RX_START'] = pkt_time`. - Later in the same branch, before duplicate/out-of-order filtering, the packet rate guard checks: `self.STATUS[_slot]['packets'] / (pkt_time - self.STATUS[_slot]['RX_START'])`. - If many packets are processed with the same `pkt_time` as the stream start, the elapsed duration is zero and this branch raises `ZeroDivisionError`. - Deterministic probe: injecting 19 same-timestamp HBP group data headers on the same stream raises `ZeroDivisionError: division by zero`. - The final call-end packet-rate logging path already guards zero elapsed duration before dividing, and the earlier OBP loop-control diagnostic path was already fixed to do the same. ### Assumptions To Validate - The HBP packet-rate guard is intended to protect FreeDMR from excessive packet rate, including accidental loop/flood conditions, not to crash when packets arrive inside one timestamp quantum. - If elapsed duration is zero, rate cannot be evaluated safely; the packet should continue through existing duplicate/drop logic rather than rate-drop solely from an undefined calculation. - The existing HBP rate-drop policy should remain unchanged when elapsed duration is nonzero: more than 18 packets and more than 25 packets/second logs `RATE DROP`, updates `LAST`, and returns. ### Unresolved Questions - Should extremely small nonzero durations be clamped to a minimum interval, or is a simple zero guard sufficient for now? ### Protocol-Sensitive Areas - This is local overload protection, not DMR payload mutation. - HBP traffic has a practical slot-cadence limit, but test harnesses and bursty runtime scheduling can still produce same-timestamp packet processing. ### Inferred Invariants - Packet-rate calculations must never divide by zero. - Existing HBP rate-drop behavior should be preserved for measurable elapsed durations. - Duplicate/out-of-order filtering should still run when rate cannot be computed safely. ### Validation Result - User approved the zero-duration guard. - Added a `call_duration` guard to the HBP group/VCSBK rate-drop calculation. - Added deterministic coverage for 19 same-timestamp HBP group data headers on one stream; the path no longer raises and stream state remains intact. ## OBP Unit Data To FBP v5+ Drops Source Repeater Metadata ### Findings - `routerOBP.sendDataToOBP()` accepts `_source_rptr` and passes it to the target OpenBridge `send_system()` call: `systems[_target].send_system(..., _hops, _ber, _rssi, _source_server, _source_rptr)`. - `routerOBP.dmrd_received()` receives `_source_rptr` as decoded metadata from `OPENBRIDGE.datagramReceived()` only for FBP protocol versions above 4. - `OPENBRIDGE.send_system()` serializes source server for FBP v4 and above, but serializes source repeater only when the target OpenBridge session version is above 4. - The normal OBP-to-OBP unit-data forwarding call for protocol versions greater than 1 passes `_hops`, `_source_server`, `_ber`, and `_rssi`, but omits `_source_rptr`. - Source server is preserved by this path. The observed loss is source repeater metadata for target sessions whose version can carry it. - For target sessions with version 4 or lower, passing `_source_rptr` would not change serialized packets because `OPENBRIDGE.send_system()` does not encode source repeater for those versions. - Deterministic probe: OBP-1 unit data to OBP-2 with `hops=b'\x05'`, source server `7654321`, BER `b'B'`, RSSI `b'R'`, and source repeater `1234567` captured the OBP-2 send metadata as: hops preserved, source server preserved, BER/RSSI preserved, but `source_rptr=b'\x00\x00\x00\x00'`. ### Assumptions To Validate - For normal FBP/OpenBridge peer forwarding, OBP-originated unit-data packets should preserve `_source_rptr` only when the target session protocol version supports it. Passing it into `send_system()` is expected to be safe because `send_system()` already gates serialization by target `VER`. - This is distinct from the DATA-GATEWAY path. DATA-GATEWAY remains a protocol-v1 SMS/GPS path and should not be evaluated as FBP metadata forwarding. - Using keyword arguments for the normal OBP-to-OBP unit-data forwarding call is the smallest safe fix and avoids repeating positional metadata mistakes. ### Unresolved Questions - Should DATA-GATEWAY remain unchanged exactly as currently implemented, despite its positional argument oddity, until a separate protocol-v1 review is done? ### Protocol-Sensitive Areas - Source repeater is transport/session metadata, not DMR payload mutation. - Protocol options and order must match the protocol version in use for the target session. - FBP metadata preservation should not imply DATA-GATEWAY is FBP. - OBP v1 and lower FBP versions do not support source repeater metadata. ### Inferred Invariants - Normal FBP v5+ forwarding should preserve source repeater metadata it receives unless production code intentionally rewrites it. - Source server preservation is already present in this unit-data path. - DATA-GATEWAY protocol-v1 behavior should remain isolated from FBP metadata expectations. ### Validation Result - User confirmed this makes sense for unit data only. - Updated the normal OBP-to-OBP unit-data forwarding call to pass `_source_rptr` through to `routerOBP.sendDataToOBP()`. - Left DATA-GATEWAY unchanged. - Added deterministic coverage proving OBP-originated unit data forwarded to another FBP peer preserves source server, source repeater, hops, BER and RSSI send metadata. ## OpenBridge DMRE Parser Accepts Truncated Packets Into Indexing ### Findings - `OPENBRIDGE.datagramReceived()` handles `DMRE` packets by reading `_packet[55]` to choose the embedded protocol layout. - It then indexes fixed offsets for v5+ (`_packet[72]`, `_packet[73:89]`) or v4 (`_packet[68]`, `_packet[69:85]`) without first checking that the datagram is long enough for that layout. - Deterministic parser-seam probe: injecting `b"DMRE"` into an OpenBridge system raises `IndexError: index out of range` at `_packet[55]`. - A truncated `DMRE` packet long enough to contain byte 55 but shorter than the full v5+ or v4 layout also raises `IndexError` when later fixed offsets are accessed. - Truncated `DMRD` v1 packets do not raise in the same simple probe because the code computes an HMAC over the available bytes and rejects them before indexing decoded fields. The observed crash is specific to `DMRE` parsing. ### Assumptions To Validate - Malformed/truncated OpenBridge/FBP UDP packets should be logged and discarded, not allowed to raise out of `datagramReceived()`. - The correct minimum lengths are the layouts already implied by `OPENBRIDGE.send_system()`: 89 bytes for v5+ `DMRE`, 85 bytes for v4 `DMRE`. - This should be implemented as a parser guard only; it should not change routing, metadata order, HMAC/BLAKE2 verification, or packet mutation for valid packets. ### Unresolved Questions - Should short-packet logs be warning-level like existing HMAC failures, or debug-level to avoid noisy logs under scanning/flood attempts? ### Protocol-Sensitive Areas - This is the raw UDP/parser seam in `hblink.py`, before `bridge_master.py` decoded packet handling. - Length guards must respect the active embedded protocol version. OBP v1, FBP v4, and FBP v5+ have different packet layouts. ### Inferred Invariants - Parser code should validate minimum length before fixed-offset indexing. - Invalid transport packets should be rejected before decoded packet handling. - Valid packet bytes and metadata must remain unchanged. ### Validation Result - User approved adding parser guards. - Added `DMRE` length checks in `OPENBRIDGE.datagramReceived()` before reading byte 55, and before reading the fixed v5+ or v4 metadata offsets. - Short `DMRE` packets are logged and discarded without reaching decoded packet handling. - Added deterministic parser-seam coverage for packets shorter than the version byte and packets truncated after the version byte. ## HBP Master DMRD Parser Accepts Truncated Packets Into Indexing ### Findings - `HBSYSTEM.master_datagramReceived()` handles `DMRD` packets by first slicing `_peer_id = _data[11:15]` and validating that the peer is connected and the source socket matches the tracked peer. - After that peer/socket check passes, it indexes fixed fields: `_seq = _data[4]`, `_bits = _data[15]`, and `_stream_id = _data[16:20]`. - There is no minimum length check before those fixed-offset indexes. - Deterministic parser-seam probe: register peer `1001` on `MASTER-A`, then inject a 15-byte `DMRD` packet containing the matching peer ID at bytes 11..14. The parser raises `IndexError: index out of range` at `_data[15]`. - This is not an arbitrary unauthenticated packet path in the reproduced case; the packet must pass the tracked peer/socket check. ### Assumptions To Validate - Malformed/truncated HBP `DMRD` packets from a connected peer should be logged and discarded, not allowed to raise out of `master_datagramReceived()`. - The minimum safe length for HBP `DMRD` parser indexing is 20 bytes, because the parser reads through `_data[16:20]` and decoded packet handling expects the 53-byte DMRD header/payload plus optional BER/RSSI later. - A conservative 20-byte parser guard is enough to prevent fixed-offset parser exceptions while preserving existing behavior for packets that reach decoded packet handling. ### Unresolved Questions - Should malformed-packet logging be rate-limited later if noisy connected peers send repeated short packets? ### Protocol-Sensitive Areas - This is HBP UDP parser admission, before `bridge_master.py` decoded packet routing. - Rejecting malformed packets must not change valid packet bytes, peer tracking or authentication behavior. ### Inferred Invariants - Parser code should validate minimum length before fixed-offset indexing. - Invalid transport packets from connected peers should be rejected before decoded packet handling. ### Validation Result - User approved the parser guard. - Added a 53-byte minimum length check for HBP `DMRD` packets in both `master_datagramReceived()` and `peer_datagramReceived()`. - Short HBP `DMRD` packets are logged and discarded before peer validation proceeds into fixed-offset packet parsing. - Added deterministic parser-seam coverage for a short `DMRD` packet from a connected/tracked master peer. ## OpenBridge BCST STUN Receiver Hashes The Wrong Bytes ### Findings - `OPENBRIDGE.send_bcst()` sends a Bridge Control STUN packet as: `BCST + HMAC_SHA1(passphrase, BCST)`. - `OPENBRIDGE.datagramReceived()` receives `BCST` by setting `_hash = _packet[4:]`, then calculating `HMAC_SHA1(passphrase, _packet[4:])`. - That means the receiver compares `HMAC(BCST)` with `HMAC(HMAC(BCST))`, so a valid packet generated by `send_bcst()` is rejected. - Deterministic parser-seam probe: build `BCST + HMAC(passphrase, BCST)` and inject it into an enhanced OpenBridge system. The packet logs `BCST invalid STUN` and does not set `_STUN`. - If the current receive check ever did pass, the trace log references `_tgid` and `_stream_id`, but `BCST` does not define those variables in that branch. The practical observed bug is the hash mismatch, which prevents the success branch. ### Assumptions To Validate - `BCST` receive validation should mirror `send_bcst()` and verify `HMAC_SHA1(passphrase, BCST)`. - A valid `BCST` should set the same stun state that the current success branch intends to set. - The success trace log should not reference TGID or stream ID because `BCST` carries only the opcode plus HMAC. ### Unresolved Questions - STUN currently has no observed timeout/clear path. It remains a conceptual temporary quench mechanism and should be reviewed separately before relying on it operationally. ### Protocol-Sensitive Areas - This is enhanced OpenBridge bridge-control traffic, not DMR payload routing. - The hash input must match the sender and must not accidentally change other Bridge Control opcodes (`BCKA`, `BCSQ`, `BCVE`). ### Inferred Invariants - Bridge Control receive validation should hash the same byte sequence the send helper signs. - STUN packets do not carry TGID or stream ID fields. - A valid STUN request should set the `STUN` flag that existing OpenBridge traffic gates already check. ### Validation Result - User confirmed STUN was conceptual but requested making it internally consistent. Intended concept: one server can tell another to temporarily stop sending any FBP traffic. - Updated `BCST` receive validation to verify `HMAC_SHA1(passphrase, BCST)`, matching `send_bcst()`. - Updated the success branch to log without TGID/stream ID fields. - Updated the success branch to set `self._CONFIG['STUN'] = True`, matching the existing send/receive stun gates that check for `STUN` in the global config. - Added deterministic parser-seam coverage proving a valid generated `BCST` sets the global `STUN` flag and no longer writes the unused `_STUN` key. ## OpenBridge BCSQ Target TGID Key Mismatch ### Findings - `hblink.py`, `OPENBRIDGE.datagramReceived()`, `BCSQ` branch stores received source-quench state in the OpenBridge peer system config as: `_config['_bcsq'][_tgid] = _stream_id`. - `bridge_master.py`, `routerOBP.to_target()`, checks that quench map with the source packet destination `_dst_id`: `(_dst_id in _target_system['_bcsq'])` and `(_target_system['_bcsq'][_dst_id] == _stream_id)`. - `bridge_master.py`, `routerHBP.to_target()`, uses inconsistent keys in the equivalent check: `(_dst_id in _target_system['_bcsq'])` but then indexes `_target_system['_bcsq'][_target['TGID']]`. - For same-TG bridge rules, `_dst_id == _target['TGID']`, so the bug is masked. For cross-TG bridge rules, the membership test and lookup can disagree. If `_dst_id` is present but `_target['TGID']` is not, the code can raise `KeyError`; if only `_target['TGID']` is present, source quench is not applied. ### Assumptions - BCSQ is intended to suppress further forwarding of a specific stream to the OpenBridge system that sent the quench. - User clarified that the only FreeDMR talkgroup rewrite is for dial-a-TG. In that case, the HBP-side source is local TG9/TS2 and the OpenBridge target sees the selected reflector TG. - User clarified BCSQ semantics: source quench asks a peer to stop sending any more packets for a given stream ID on a given TG. It is optional; failure to quench is not fatal and mainly costs a small amount of bandwidth/processing. - Therefore, the severity is lower than a routing/drop bug. The important correctness property is that any local BCSQ check must compare the stream ID and TGID in the same namespace as the BCSQ sender intended. ### Unresolved Questions - Should the stream-trimmer cleanup remove BCSQ entries only after the matching local stream expires, or should received BCSQ entries also have an independent expiry? Current cleanup removes entries whose stored stream ID matches a removed local OpenBridge stream. ### Protocol-Sensitive Areas - `BCSQ` packet format is `BCSQ + TGID(3) + stream_id(4) + HMAC`. - BCSQ semantics interact with dial-a-TG TG rewrite logic. The optional quench may be ineffective if FreeDMR stores a quench under one TG namespace and checks it under another. ### Inferred Invariants - Source quench state must be checked with the same TGID namespace it is stored under. - Source quench is an optimization/control hint, not a required condition for correctness of stream routing. - A BCSQ match should drop forwarding only for the matching TGID and stream ID, not all traffic to that OpenBridge target. ### Resolution - User confirmed that for dial-a-TG, the BCSQ TGID should be the reflector TG, not local TG9. - Updated `bridge_master.py`, `routerHBP.to_target()`, so HBP-to-OpenBridge source-quench checks test and index `_target_system['_bcsq']` with `_target['TGID']`. - Added deterministic dial-a-TG coverage proving a TG9/TS2 HBP reflector stream is not forwarded to an FBP target when that target has source-quenched the reflector TG and stream ID. ## OpenBridge STUN Has No Clear Or Expiry Path ### Findings - `hblink.py`, `OPENBRIDGE.datagramReceived()`, `BCST` receive branch sets the global config flag `self._CONFIG['STUN'] = True`. - `hblink.py`, `OPENBRIDGE.send_system()`, `OPENBRIDGE.datagramReceived()` v1 `DMRD` path, and `OPENBRIDGE.datagramReceived()` `DMRE` path all gate traffic with `if 'STUN' in self._CONFIG`. - Repository-wide search finds no code path that removes `STUN`, changes it back to false, or expires it by time. - `BCST` has no duration field in the current packet format: `BCST + HMAC_SHA1(passphrase, BCST)`. - Current behavior after a valid `BCST` is therefore effectively permanent until process restart or external mutation of the in-memory config. ### Assumptions To Validate - User previously described the intended concept as one server asking another to temporarily stop sending any FBP traffic. - User clarified the likely intended design: a sysop/API operation would un-stun the link. In that design, lack of automatic expiry is not necessarily a bug; the missing piece is the operator/API clear path. - Because `BCST` has no duration field, automatic expiry would be a local policy change rather than protocol behavior. - This should remain a global FBP traffic gate, not per-TG or per-stream; BCSQ already covers the per-TG/per-stream source-quench case. ### Unresolved Questions - Should the eventual sysop/API un-stun operation clear global `STUN` or a per-link/per-peer stun state if the feature is later scoped more narrowly? - Should STUN block only outbound FBP sends, or also inbound FBP processing? The current gates block both outbound `send_system()` and inbound DMR packet handling. - Should the flag remain global across all OpenBridge systems, or should it be scoped to the peer that sent `BCST`? The current code uses global `self._CONFIG['STUN']`. ### Protocol-Sensitive Areas - `BCST` is enhanced OpenBridge bridge-control traffic and currently carries no duration or peer identity beyond the authenticated UDP source and configured passphrase. - Changing the traffic gate from a boolean/existence check to a timestamp must preserve the current "block all FBP traffic while active" behavior if that is confirmed. ### Inferred Invariants - STUN is distinct from BCSQ: STUN is all FBP traffic, BCSQ is one stream on one TG. - Valid `BCST` should never mutate DMR payload bytes. - If STUN is operator-cleared, the missing production behavior is an explicit management/API clear operation, not an automatic timer. ## OBP Group Loop-Control Logs Duration As Packet Rate ### Findings - `bridge_master.py`, `routerOBP.dmrd_received()`, group/vcsbk loop-control branch calculates: `call_duration = pkt_time - self.STATUS[_stream_id]['START']`. - It then calculates a guarded `packet_rate`: `packet_rate = self.STATUS[_stream_id]['packets'] / call_duration`. - The debug log string says `PACKET RATE %0.2f/s`, but the argument passed is `call_duration`, not `packet_rate`. - The analogous OBP unit-data loop-control branch directly above passes `packet_rate` correctly. ### Assumptions To Validate - The log is intended to report packet rate, not duration, because the message text says `PACKET RATE` and the code already computes `packet_rate`. - This is a diagnostics-only bug. It does not affect packet routing, mutation, source selection, BCSQ, or rate-drop enforcement. - User clarified that data calls do not have a meaningful packet rate in this sense because each packet is classed as its own stream; packet-rate assertions should use voice stream fixtures, not data packet fixtures. ### Unresolved Questions - No protocol question identified. This is local logging correctness. ### Protocol-Sensitive Areas - None beyond avoiding any change to packet handling behavior. ### Inferred Invariants - Diagnostic logs should report the metric named by the log message. - Fixing the argument should not change any control-flow decisions. ### Resolution - Updated the OBP group/vcsbk loop-control debug log to pass `packet_rate` instead of `call_duration`. - Added deterministic coverage that creates a two-second loop-controlled OBP group voice stream and verifies the log reports the calculated `0.50/s` packet rate, not the two-second duration as `2.00/s`. ## OBP Group Data RX Log Says Call Start ### Findings - `bridge_master.py`, `routerOBP.dmrd_received()`, group/vcsbk new-stream branch computes `_data_control = is_group_data_control(_call_type, _dtype_vseq)` and reports data packets correctly with `DATA HEADER,DATA,RX`, `VCSBK 1/2 DATA BLOCK,DATA,RX`, `VCSBK 3/4 DATA BLOCK,DATA,RX`, or `OTHER DATA,DATA,RX`. - The same branch always emits an info log labelled `*CALL START*` before the report decision, even when `_data_control` is true. - The HBP group path distinguishes group data header logging from voice start: `_dtype_vseq == 6` logs `*DATA HEADER*`, while voice-like group packets log `*CALL START*`. - User clarified that data packets are packet-oriented and should not be treated as voice streams for rate/lifecycle semantics. ### Assumptions To Validate - OBP group data logs should match the already-correct report classification and should not say `*CALL START*` for data-control packets. - This is diagnostics/logging only; reports, routing, packet bytes, stream state and lifecycle suppression are already handled separately. ### Unresolved Questions - Should OBP group data logs include source server/hops metadata exactly like the current `CALL START` log, or should they match the simpler HBP data-header log shape? ### Protocol-Sensitive Areas - This must not change `DATA_STREAM` classification, reporting events, packet forwarding, or LC construction. ### Inferred Invariants - Logs should not label DMR data-control packets as voice call lifecycle events. - OBP and HBP logging should use consistent data-vs-voice terminology where the packet classification is already known. ### Resolution - User confirmed the change should be made, with the caveat that dashboard consumers may be sensitive to live report/socket event text. - Updated the OBP group/vcsbk RX info log to use the already-computed data event label for data-control packets and `CALL START` only for voice-like packets. - Added deterministic coverage proving an OBP group data header logs `*DATA HEADER*` and no longer logs `*CALL START*`; existing report-socket assertions still verify `DATA HEADER,DATA,RX/TX` payloads. ## Raw Parser May Not Classify VCSBK 3/4 Data Blocks ### Findings - `bridge_master.py` has explicit group/vcsbk report handling for `_call_type == 'vcsbk'` and `_dtype_vseq == 8`, labelled `VCSBK 3/4 DATA BLOCK`. - The raw packet parsers in `hblink.py` derive `_call_type` from the DMRD bits with: `elif (_bits & 0x23) == 0x23: _call_type = 'vcsbk'`. - For a data-sync packet with `_dtype_vseq == 8`, the low nibble is `0x8`; with `HBPF_DATA_SYNC` in bits 4..5, `_bits & 0x23` evaluates to `0x20`, not `0x23`. That means the raw parser classifies it as `group`, not `vcsbk`. - The deterministic in-process harness can inject `call_type='vcsbk'` directly, so current VCSBK 3/4 report tests may cover `bridge_master.py` behavior without proving a real UDP/HBP or UDP/OBP packet can reach that branch. - Unit data handling is not affected by this specific observation because unit data is classified first by the unit bit and then handles dtype 8 inside the unit-data branch. ### Assumptions To Validate - If FreeDMR expects group-addressed VCSBK 3/4 data blocks to arrive from real HBP/OBP packets, the raw parser should classify the relevant bit pattern as `vcsbk` so the existing report/routing classification can run. - The parser predicate may have been intentionally written for only certain CSBK or VCSBK bit patterns; changing it without confirming HomeBrew/DMR bit semantics could misclassify normal group traffic. - This is parser classification, not packet mutation. - User raised an operational risk: earlier hackish data-over-OBP/FBP changes may depend on this classification. A naive fix could affect whether data traverses FBP or whether unknown group-addressed data creates local bridge state. ### Unresolved Questions - Is `_dtype_vseq == 8` genuinely reachable for group/vcsbk packets in the HomeBrew/OpenBridge bit layout, or is the `VCSBK 3/4 DATA BLOCK` branch legacy or only relevant to direct/internal calls? - Should the deterministic harness include a raw-datagram parser test for VCSBK 3/4 once the intended bit pattern is confirmed? ### Protocol-Sensitive Areas - This is at the HBP/OBP raw DMRD parser seam in `hblink.py`. - Incorrectly broadening the `vcsbk` predicate could change voice/group/data classification before packets reach `bridge_master.py`. - Existing active bridge forwarding likely still works for both `group` and `vcsbk` because `bridge_master.py` routes both through the group/vcsbk path. The more sensitive behavior is automatic unknown-TG bridge creation, which is currently restricted to `_call_type == 'group'`. ### Inferred Invariants - Parser-level `_call_type` classification should make production-reachable all packet classes that `bridge_master.py` intentionally handles. - In-process direct injection tests should not be mistaken for raw UDP parser coverage when the question is bit-level classification. - Any parser classification change must prove that existing data-over-FBP traversal still works, including the local group-addressed data behavior previously left intentional. ### Reclassification - User clarified that the `unit`/`group` distinction is fundamentally the DMR addressing mode: private/unit-unit bit set, or group/TG bit unset. Data/control subtype is orthogonal to that addressing mode. - Therefore, changing the raw parser to classify group-addressed data/control packets as `vcsbk` would conflate addressing mode with data subtype and could break intentional data-over-FBP behavior. - Leave raw parser `_call_type` classification unchanged. Data/control handling should be derived from `_frame_type` / `_dtype_vseq` helpers while preserving group-vs-unit addressing. ## Group-Addressed Data Continuation Blocks Reported As Voice ### Findings - `bridge_master.py`, `group_data_event_name()`, treats group-addressed `_dtype_vseq == 6` as `DATA HEADER`. - The same helper only treats `_dtype_vseq == 7` and `_dtype_vseq == 8` as data when `_call_type == 'vcsbk'`. - User clarified that `_call_type == 'group'` means group/TG addressing, not voice. A group-addressed packet can still carry DMR data/control subtypes. - Therefore, a group-addressed data continuation block that reaches `bridge_master.py` as `_call_type == 'group'` and `_dtype_vseq == 7` or `8` is currently classified as voice-like by `is_group_data_control()`. - Consequences would match the earlier group-data report bug: RX/TX reports can use `GROUP VOICE`, timeout cleanup can emit voice lifecycle events, and logs can say `CALL START` for a packet-oriented data block. ### Assumptions To Validate - Group-addressed data continuation blocks with dtype 7 or 8 should be classified as data/control for reporting and timeout lifecycle, while preserving `_call_type == 'group'` for TG-addressed routing and bridge creation behavior. - Existing event labels `VCSBK 1/2 DATA BLOCK` and `VCSBK 3/4 DATA BLOCK` may be legacy terminology. If the dashboard expects those names, changing labels could be more disruptive than only broadening when they apply. - This should not change raw parser classification or packet routing. ### Unresolved Questions - Should dtype 7/8 group-addressed data use the existing `VCSBK 1/2 DATA BLOCK` / `VCSBK 3/4 DATA BLOCK` report labels for dashboard compatibility, or should labels be renamed to more generic data continuation terminology later? ### Protocol-Sensitive Areas - This is data-vs-voice reporting/lifecycle classification, not DMR payload mutation. - It must preserve the group-addressing behavior that lets data traverse TG/FBP bridge paths. ### Inferred Invariants - `_call_type == 'group'` should not imply voice. - Data/control classification should be based on `_frame_type` and `_dtype_vseq`, without breaking group-vs-unit addressing semantics. ### Resolution - User approved broadening the helper-level classification. - Updated `group_data_event_name()` so group-addressed dtype 7 and 8 packets use the existing `VCSBK 1/2 DATA BLOCK` and `VCSBK 3/4 DATA BLOCK` data event names. - Raw parser `_call_type` classification remains unchanged; group-vs-unit addressing is preserved. - Added deterministic HBP and OBP coverage for group-addressed dtype 7/8 packets proving they report as data and do not emit `GROUP VOICE` lifecycle events. - Consolidated HBP known VCSBK RX reporting into the helper-driven new-stream branch so specific VCSBK reports are still emitted once, without reintroducing the older generic/specific duplicate report behavior. ## Voice Path: OBP Group Stream Rate Limit Uses Absolute Start Time ### Findings - `bridge_master.py`, `routerOBP.dmrd_received()`, group/vcsbk stream duplicate and rate-control branch, calculates the rate-drop predicate as `self.STATUS[_stream_id]['packets'] / self.STATUS[_stream_id]['START'] > 25`. - `START` is an absolute timestamp captured from `time()`, not elapsed stream duration. In normal operation this divides by a large epoch value, so the calculated rate is effectively zero. - The surrounding code and earlier user clarification say this guard is intended to stop packet floods from overwhelming FreeDMR. As written, it cannot trigger for realistic packet counts. - HBP group voice uses elapsed duration (`pkt_time - RX_START`) for the comparable packet-rate guard. ### Assumptions To Validate - The OBP/FBP guard should measure packets per elapsed stream duration, not packets per absolute timestamp. - The threshold should remain the existing `> 25` for now; changing policy is separate from fixing the calculation. - The existing `packets > 18` warm-up should remain, so short startup bursts are not judged before enough packets have arrived. - The deferred question about whether `proxy_BadPeer()` is the right response to an OBP flood remains deferred; this analysis only covers the broken rate calculation. ### Unresolved Questions - Confirm whether this OBP rate-drop action should still call `proxy_BadPeer()` after the calculation is corrected, or whether that should be revisited later as previously deferred. ### Protocol-Sensitive Areas - This is transport/rate-control behavior, not DMR payload parsing or mutation. - OBP/FBP can carry multiple arbitrary streams, so this guard is only per stream and does not represent an overall link-rate limit. ### Inferred Invariants - Packet-rate protection should be based on elapsed time for the specific stream. - Fixing the denominator should not change TG routing, slot handling, LC rewrite, or packet bytes. ### Resolution - User confirmed proceeding with the narrow denominator fix. - Updated `routerOBP.dmrd_received()` so the group/vcsbk rate-drop check computes `call_duration = pkt_time - self.STATUS[_stream_id]['START']` and divides packet count by elapsed duration. - Kept the existing `> 25` threshold, `packets > 18` warm-up, and `proxy_BadPeer()` action unchanged. - Added deterministic OBP group voice coverage proving the rate-drop path fires for a high-rate stream when elapsed duration is short. ## Voice LC Rewrite Applied To Data-Sync Control Packets ### Findings - `bridge_master.py`, `routerOBP.to_target()`, rewrites embedded LC for any packet where `_dtype_vseq in [1,2,3,4]`. - `bridge_master.py`, `routerHBP.to_target()`, has the same condition in both the OpenBridge-target and HBP-target branches. - These branches are intended for voice bursts B-E, but they do not check `_frame_type`. - A deterministic probe with an HBP-originated `vcsbk` packet using `_frame_type == HBPF_DATA_SYNC` and `_dtype_vseq == 3` captured a forwarded HBP packet whose DMR payload bytes were changed: `000102030405060708090a0b0c0d00c122b4b2131415161718191a1b1c1d1e1f20` instead of the original `000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20`. - That mutation is the embedded-LC rewrite being applied to a data-sync/control packet, not a voice burst. - ETSI TS 102 361-1 distinguishes voice bursts containing embedded signalling from data/control bursts. Search result snippets from the ETSI TS 102 361-1 PDF describe a voice burst containing embedded signalling and separately state that data/control bursts use data SYNC or RC signalling rather than embedded LC. ### Assumptions To Validate - Embedded LC rewrite should only apply to real voice burst frames, not data-sync/control packets. - Voice header and voice terminator rewrite remain governed by `_frame_type == HBPF_DATA_SYNC` plus `_dtype_vseq == HBPF_SLT_VHEAD` or `HBPF_SLT_VTERM`, because those are voice call signalling bursts. - VCSBK/CSBK/data-control packets should preserve their DMR payload bytes while still allowing transport/header fields such as target TG and slot bit to be rewritten by bridge routing when that is already intentional. ### Unresolved Questions - Future work: evaluate carrying source embedded LC to the destination instead of always regenerating it. Embedded LC may carry embedded GPS and talker alias data, which FreeDMR does not currently relay. ### Protocol-Sensitive Areas - This is a packet mutation bug at the boundary between voice LC rewrite and data/control forwarding. - The fix must not disable intended TG/LC rewrite for voice calls that are mapped from one bridge TG to another. - It must preserve the previously discussed data-over-FBP behavior and group-vs- unit addressing classification. ### Inferred Invariants - Data/control payload bytes are not embedded-LC containers and should not be modified by voice LC rewrite code. - Voice LC rewrite should be gated by both frame class and dtype/voice sequence. ### Resolution - User confirmed embedded-LC rewrite should not apply to data-sync/control packets. - `mk_voice.py` shows generated voice uses `HBPF_VOICE_SYNC` for burst A with vseq `0`, and `HBPF_VOICE` for bursts B-F; embedded LC is inserted only for bursts B-E, vseq `1..4`. - Updated all four embedded-LC rewrite branches in `routerOBP.to_target()` and `routerHBP.to_target()` to require `_frame_type == HBPF_VOICE` and `_dtype_vseq in [1,2,3,4]`. - Added deterministic payload-preservation coverage for data-sync/control packets across HBP-to-HBP, HBP-to-FBP, FBP-to-HBP and FBP-to-FBP forwarding. ## Private Unit Voice Timeout Reported As Group Voice ### Findings - `bridge_master.py`, `routerHBP.dmrd_received()`, private AMI voice branch and private dial-a-TG branch update slot RX state (`RX_TYPE`, `RX_TGID`, `RX_TIME`, `RX_STREAM_ID`) for unit/private calls. - Those private branches set `self.STATUS[_slot]['VOICE_STREAM'] = _voice_call`, but `_voice_call` is initialized to `False` and is never set to `True`. - `VOICE_STREAM` is not initialized in `routerHBP.__init__()` and is not read by `stream_trimmer_loop()`. - `stream_trimmer_loop()` emits `GROUP VOICE,END,RX` for any HBP slot whose `RX_TYPE` is not terminator and whose `RX_DATA_STREAM` flag is false. - Therefore a private dial-a-TG/AMI unit voice packet that does not receive a terminator can be timed out as `GROUP VOICE,END,RX`, even though there was no matching `GROUP VOICE,START,RX`. - Deterministic probe: after 100 seconds of idle time, inject a private unit voice call to `235`, advance 6 seconds, and run `stream_trimmer_loop()`. The report event is: `GROUP VOICE,END,RX,MASTER-A,16909060,1001,3120001,1,235,100.00`. - The duration is also wrong for the private call because private branches do not set `RX_START` on new private streams; the timeout report used the slot's old initialization/start value. ### Assumptions To Validate - Dial-a-TG and AMI private unit voice calls should not emit `GROUP VOICE` lifecycle reports. - HBP timeout reporting should emit `GROUP VOICE,END,RX` only for RX state that originated from a group voice stream that would have emitted `GROUP VOICE,START,RX`. - Private unit calls may still mark the RF slot busy until timeout/terminator; the issue is lifecycle reporting and duration classification, not slot occupancy. ### Unresolved Questions - Should private unit voice timeout produce a different report event, or should it simply suppress `GROUP VOICE` timeout reporting? - Should the existing `VOICE_STREAM` key be completed and used as the explicit trimmer gate, or should a clearer `RX_VOICE_STREAM` / `RX_GROUP_VOICE_STREAM` key be introduced? ### Protocol-Sensitive Areas - This is report/lifecycle classification for HBP slot state, not packet mutation. - Dial-a-TG private control behavior and voice prompts should remain unchanged. ### Inferred Invariants - `GROUP VOICE,END,RX` should correspond to an actual group voice RX lifecycle. - Timeout cleanup should not infer group voice from `RX_TYPE != VTERM` alone. ### Resolution - User approved correcting this even if the old behavior was a dashboard workaround. - Added explicit HBP slot state `RX_GROUP_VOICE_STREAM`, initialized false. - Private AMI and dial-a-TG unit voice branches now mark `RX_GROUP_VOICE_STREAM` false when they update slot RX state. - The group/vcsbk branch marks `RX_GROUP_VOICE_STREAM` true only when the packet is not data/control. - `stream_trimmer_loop()` now emits `GROUP VOICE,END,RX` only when `RX_GROUP_VOICE_STREAM` is true, then clears the flag with the timeout cleanup. - Added deterministic coverage proving a private dial-a-TG unit voice timeout no longer emits an unmatched `GROUP VOICE,END,RX` report. ## HBP-To-HBP Target TX State Uses Source RX Stream Predicate ### Findings - `bridge_master.py`, `routerHBP.to_target()`, HBP-target branch, initializes target TX state when `_stream_id != self.STATUS[_slot]['RX_STREAM_ID']`. - That predicate describes whether the source HBP slot sees a new RX stream, not whether the target HBP slot needs a new TX stream. - The analogous `routerOBP.to_target()` HBP-target branch correctly checks the target slot: `_target_status[_target['TS']]['TX_STREAM_ID'] != _stream_id`. - Deterministic reproduction: - MASTER-A sends stream `01020304` on TG123 to MASTER-B. - After `STREAM_TO`, MASTER-C sends stream `01020305` on TG123 to MASTER-B. - After another gap, MASTER-A sends another packet on stream `01020304`. - The forwarded packet to MASTER-B carries stream `01020304`, but MASTER-B's `TX_STREAM_ID` and `TX_RFS` remain `01020305` / MASTER-C. - For a voice burst B-E packet, the forwarded DMR payload is rewritten using MASTER-B's stale `TX_EMB_LC` generated for MASTER-C, not MASTER-A. - This can create stale target lifecycle state, stale TX reports/durations, and wrong embedded LC source information when a target slot has moved to another stream while the original source later resumes the same stream id. ### Validated Assumptions - User clarified this interleaved/resumed-stream scenario should never happen in normal DMR operation. DMR channel access and hang-time mechanisms should prevent an older stream from being resumed after a different stream has interspersed on the same target path. - Stream IDs are generated locally from inbound call data and are not expected to be reused in this interleaved way. ### Unresolved Questions - None for now. Leave this path unchanged unless a real fixture or live trace proves valid stream interleaving occurs. ### Protocol-Sensitive Areas - This affects target-side stream state and LC rewrite metadata for HBP targets. - It does not change routing eligibility or packet source/destination header fields. ### Inferred Invariants - Target TX state should describe the packet currently being sent to that target. - Source RX stream state and target TX stream state are related but independent. - LC rewrite state must be regenerated when the target TX stream id changes. ### Resolution - No runtime change. Treat the deterministic reproduction as an invalid scenario, not a confirmed production bug. - Keep the note as a protocol-sensitive area to revisit only if recorded traffic shows valid resumed/interleaved stream behavior. ## OBP Target Timeout Reports Wrong Direction And TG After Dial-A-TG Rewrite ### Findings - `bridge_master.py`, `routerHBP.to_target()`, OpenBridge-target branch creates target stream state with `TGID: _dst_id`, even though the outbound packet and `GROUP VOICE,START,TX` report use `_target['TGID']`. - `bridge_master.py`, `routerOBP.to_target()`, OpenBridge-target branch does the same for OBP-to-OBP targets. - `stream_trimmer_loop()` handles all OpenBridge stream timeouts as `GROUP VOICE,END,RX` and reports `_stream['TGID']`. - Deterministic reproduction with dial-a-TG: - Private call links reflector `#235`. - HBP source sends group voice on local TS2 TG9, which is forwarded to FBP target TG235. - OBP target reports start as: `GROUP VOICE,START,TX,OBP-1,16909060,1001,3120001,1,235`. - If no terminator arrives and the OBP target stream times out, the report is: `GROUP VOICE,END,RX,OBP-1,16909060,1001,3120001,1,9,0.00`. - The timeout event is wrong in two ways for the target-side stream: direction is `RX` instead of `TX`, and TG is local TG9 instead of the reflector TG235 that was sent to the FBP peer. - Immediate terminator handling in `to_target()` already emits `GROUP VOICE,END,TX` with `_target['TGID']`; the mismatch only affects timeout cleanup when no terminator is observed. ### Validated Assumptions - User clarified that the report side intentionally reports HBP/RF-side TG9 for dial-a-TG reflector traffic so the repeater dashboard shows TG9 active to RF. - The FBP-visible reflector TG is not necessarily the desired TG for this dashboard report path. ### Unresolved Questions - There remains broader inconsistency between immediate terminator TX reports and timeout RX reports for target-side FBP streams, but this is not currently treated as a bug because the dashboard policy is intentional. ### Protocol-Sensitive Areas - This is report/lifecycle state for OpenBridge/FBP target streams, not packet routing or DMR payload mutation. - BCSQ/loop-control logic also reads OpenBridge stream `TGID`; changing that key directly could affect source-quench behavior. A separate report TG field may be safer. ### Inferred Invariants - For dial-a-TG dashboard reporting, RF-side TG9 activity is a first-class observability requirement. ### Resolution - No runtime change. Treat this as intentional reporting policy for dashboard compatibility rather than a confirmed bug. ## Generated Voice First Packet Does Not Mark HBP TX Activity ### Findings - `bridge_master.py`, `sendVoicePacket()`, handles generated voice packets for dial-a-TG prompts, disconnected announcements, on-demand AMBE files and voice idents. - On the first packet of a generated stream, it creates `systems[system].STATUS[_stream_id]` and sets `_slot['TX_TGID']`, but it does not update `_slot['TX_TIME']`, `_slot['TX_TYPE']`, `_slot['TX_STREAM_ID']`, `_slot['TX_START']`, or `_slot['TX_RFS']`. - `_slot['TX_TIME']` is only updated in the `else` branch for subsequent packets. - Deterministic probe after 100 seconds idle: - Before first generated packet, TS2 state was `TX_TYPE=HBPF_SLT_VTERM`, `TX_TIME=1700000000.0`, `TX_STREAM_ID=b'\\x00'`, `TX_TGID=0`. - After first generated packet, state was still `TX_TYPE=HBPF_SLT_VTERM`, `TX_TIME=1700000000.0`, `TX_STREAM_ID=b'\\x00'`, but `TX_TGID=9`. - Because routing and idle checks use `TX_TIME` and `TX_TYPE`, a generated prompt can look idle at stream start. - Second deterministic probe: after sending the first generated prompt packet on MASTER-B TS2 TG9, an inbound group voice packet from MASTER-A to MASTER-B was still routed immediately to MASTER-B, producing overlapping outbound traffic. ### Assumptions To Validate - User clarified that voice prompts should ideally be interruptible. At minimum, after a prompt has finished, a real voice stream should not be immediately blocked as "busy". - Therefore, blindly updating `_slot['TX_TIME']` for generated prompt packets is not an acceptable fix by itself because normal group-hangtime checks may treat the prompt as recent TX activity and block post-prompt routing. - Generated prompts should be treated as low-priority local traffic relative to real RF/network voice. ### Unresolved Questions - Should generated prompts/idents be represented as full HBP TX voice lifecycle state for dashboard/report purposes, or should they only update `TX_TIME` to protect the RF slot without creating extra report events? - What exact interruption trigger should stop a prompt: any RX activity on that HBP system/slot, any TX routing state change on that slot, or only a higher priority group/private voice stream? ### Protocol-Sensitive Areas - This is generated local voice scheduling/state, not inbound packet parsing. - It affects collision avoidance between generated prompts and bridged traffic. ### Inferred Invariants - A locally generated voice prompt must not permanently or hangtime-block subsequent real voice traffic. - Prompt collision avoidance should prefer stopping the prompt over blocking RF or bridge traffic where practical. - Report visibility for generated prompts is separate from collision avoidance. ### Late Entry Sanity Check #### Findings - ETSI TS 102 361-1 describes DMR late entry by carrying LC information in the embedded field of voice bursts; a six-burst voice superframe uses one sync burst, four embedded-signalling bursts, and one null/other embedded burst. - `mk_voice.py` builds generated voice with `bptc.encode_emblc(LC)` and places the LC fragments into bursts B-E. Burst A carries voice sync; burst F carries null embedded LC. - `bridge_master.py` builds `EMB_LC` / `TX_EMB_LC` for target streams and rewrites only `HBPF_VOICE` bursts with vseq 1-4, corresponding to embedded LC fragments B-E. The earlier guard now avoids mutating data-sync/control payloads. - Therefore, if a real voice stream interrupts a generated prompt and is then forwarded through the normal routing path, receivers that missed the original voice header should be able to late-enter once they receive a complete embedded-LC sequence. #### Assumptions - Generated prompts are local low-priority streams and may be cancelled in favor of real RF/network voice. - Interrupting a prompt should resume normal bridge routing for the real stream; it should not synthesize replacement voice frames outside the existing header/embedded-LC machinery. #### Unresolved Questions - Whether prompt cancellation should send a terminator for the abandoned prompt before real traffic starts. This may be cleaner semantically, but it could also delay the higher-priority stream and defeat interruption. - Whether generated prompt lifecycle state should be visible to dashboards as a full TX stream, independent of collision avoidance. #### Protocol-Sensitive Areas - Late-entry behavior depends on preserving or correctly regenerating embedded LC over bursts B-E. Any prompt-interruption fix must not remove the current `EMB_LC` / `TX_EMB_LC` handling. - If the first forwarded real packet is mid-superframe, receiving radios may need to wait until the next complete embedded LC set before identifying the call. This is expected late-entry behavior, not necessarily a bridge bug. #### Inferred Invariants - Real voice streams must remain the source of truth for LC/header state after a prompt is cancelled. - Prompt cancellation should stop local generated packet production; the following real stream should use existing voice header and late-entry LC processing. ### Resolution - Added explicit generated-prompt state to HBP slot status: `TX_PROMPT_ACTIVE`, `TX_PROMPT_CANCEL`, `TX_PROMPT_TOKEN`, `TX_PROMPT_TIME`, `TX_PROMPT_STREAM_ID`, `TX_PROMPT_TGID` and `TX_PROMPT_RFS`. - `sendVoicePacket()` now records prompt activity from the first generated packet without driving normal group-hangtime fields such as `TX_TIME` / `TX_TYPE`. - `sendSpeech()`, `disconnectedVoice()` and `playFileOnRequest()` now use a per-slot prompt token and stop scheduling generated packets once the slot is cancelled or taken over. - HBP real group voice now cancels generated prompt state on the source slot for local RX and on HBP target slots when a real voice stream is about to be routed. Data/control packets do not cancel prompts. - The real stream remains on the existing voice routing path, so voice headers and B-E embedded LC rewrites continue to provide DMR late-entry behavior. - Added deterministic tests for first-packet prompt state, prompt cancellation in the speech loop, and real HBP voice cancelling a prompt while preserving late-entry embedded LC rewrite. ## OBP Finished Voice Stream Depends On Reporting Being Enabled ### Findings - `bridge_master.py`, `routerOBP.dmrd_received()`, has explicit finished-stream handling: if `'_fin' in self.STATUS[_stream_id]`, later packets for the same stream are ignored. - The OBP voice terminator branch sets `self.STATUS[_stream_id]['_fin'] = True` only inside `if CONFIG['REPORTS']['REPORT'] and not DATA_STREAM`. - Deterministic probe: - With `REPORT=False`, inject OBP voice header, terminator, then another voice burst with the same stream ID. The HBP target capture count increases from 2 to 3, and `_fin` is absent. - With `REPORT=True`, the same late packet is ignored; target capture remains at 2, and `_fin` is present. ### Assumptions To Validate - OBP stream lifecycle state should not depend on whether dashboard/live report output is enabled. - A voice terminator should mark the OBP stream finished for local loop-control and late-packet suppression regardless of report configuration. - The report event itself should remain gated by `CONFIG['REPORTS']['REPORT']`. ### Unresolved Questions - Whether data/control packets can ever legitimately use `HBPF_SLT_VTERM` on the OBP group path. Current code treats the branch as "voice terminator", so the minimal fix should preserve the existing `not DATA_STREAM` guard for `_fin`. ### Protocol-Sensitive Areas - This is stream lifecycle/loop-control state, not packet byte mutation. - Changing `_fin` affects whether late packets with the same stream ID are forwarded after a terminator. ### Inferred Invariants - Runtime stream lifecycle must be independent from optional reporting. - OBP finished-stream suppression is intended to be functional behavior, not a dashboard side effect. ### Resolution - Moved `self.STATUS[_stream_id]['_fin'] = True` out of the report-enabled block while keeping it guarded by `not self.STATUS[_stream_id].get('DATA_STREAM')`. - Kept `send_bridgeEvent('GROUP VOICE,END,RX,...')` inside the reporting gate. - Added deterministic coverage that an OBP voice terminator suppresses later packets with the same stream ID when reporting is disabled. ## HBP Voice Terminator Does Not Suppress Late Same-Stream Packets ### Findings - `bridge_master.py`, `routerHBP.dmrd_received()`, handles HBP group voice terminators by logging/reporting call end and resetting `lastSeq` / `lastData`, then later updates slot state with `RX_TYPE = HBPF_SLT_VTERM` and `RX_STREAM_ID = _stream_id`. - Unlike the OBP path, HBP slot state has no finished-stream marker. - A later HBP voice burst with the same stream ID after the terminator is not rejected as finished. Because `RX_STREAM_ID` still matches, it is not treated as a new stream, and because `lastSeq` was reset, duplicate/order checks do not stop it. - Deterministic probe: - Inject HBP voice header, terminator, then another voice burst with the same stream ID. - The HBP target capture count increases from 2 to 3, meaning the post- terminator voice burst is routed. - Source slot `RX_TYPE` becomes the late packet's vseq (`3`) and `lastSeq` becomes `3`, effectively reopening activity for the terminated stream. ### Assumptions To Validate - A DMR voice terminator should close the voice stream for routing purposes. - Late packets with the same stream ID after a valid voice terminator should be suppressed, not routed or allowed to reopen slot activity. - This should apply to voice streams only; group data/control classification should keep its existing behavior. ### Unresolved Questions - Whether there are any HBP implementations that can legitimately send useful late voice bursts after a terminator with the same stream ID. The code's OBP finished-stream guard suggests FreeDMR already treats such packets as invalid on peer-server links. - How long the HBP finished marker should live. A per-slot marker can probably be cleared when the slot's `RX_STREAM_ID` is nulled by the existing 60-second loop-control cleanup. ### Protocol-Sensitive Areas - This is voice stream lifecycle and duplicate/late-packet suppression, not DMR payload mutation. - It affects only packets that arrive after a terminator for the same stream ID. ### Inferred Invariants - A voice terminator is an end-of-stream signal. - HBP and OBP should agree that post-terminator voice for the same stream ID is finished traffic and should not be forwarded. ### Resolution - Added per-slot `RX_FINISHED_STREAM_ID` and `RX_FINISHED_STREAM_LOG` state for HBP systems. - HBP voice terminators now mark the finished stream ID when the stream is not a data/control stream. - HBP group/vcsbk processing now ignores later voice-like packets whose stream ID matches the finished marker, logging once for the suppressed late stream. - The finished marker is cleared when the existing 60-second null-stream cleanup clears `RX_STREAM_ID`, and when a genuine new voice stream begins. - Added deterministic coverage proving a late HBP same-stream voice burst after a terminator is suppressed. ### Live RF Validation Caveat - Some voice-path behavior in FreeDMR may have been intentionally shaped around real repeater/radio behavior, dashboard expectations, or interoperability quirks that are not visible in the deterministic harness. - DMR terminals may interpret the DMR standards loosely or incompletely, especially around data packets. FreeDMR should generally prioritize preserving usable audio flow over rigid protocol purity when those goals conflict. - The HBP finished-stream suppression and generated-prompt interruption changes are protocol-defensible and covered by deterministic tests, but they still require live RF-path testing with a real terminal, repeater and network path. - Be prepared to revert or narrow these changes if live RF testing shows that a repeater, radio or dashboard depends on the previous behavior. ## OBP Target Terminator Leaves Stream Open For Timeout Report ### Findings - `bridge_master.py`, `routerHBP.to_target()` and `routerOBP.to_target()`, create per-stream status entries on OpenBridge targets when forwarding group voice. - When a voice terminator is forwarded to an OpenBridge target, the target path rewrites the terminator LC and, if reporting is enabled, emits `GROUP VOICE,END,TX`. - The OpenBridge target stream is not marked `_fin` on the forwarded terminator. - `stream_trimmer_loop()` later treats that target-side OpenBridge stream as stale and emits `GROUP VOICE,END,RX` for the same stream. - Deterministic probes: - HBP -> OBP: after header + terminator, OBP target reports `GROUP VOICE,START,TX` and `GROUP VOICE,END,TX`; after six seconds, trimmer adds `GROUP VOICE,END,RX` for the same stream. - OBP -> OBP shows the same duplicate `END,RX` timeout after an immediate target `END,TX`. ### Assumptions To Validate - A forwarded voice terminator should close the target-side OpenBridge stream lifecycle. - Once an immediate target `GROUP VOICE,END,TX` has been emitted for a forwarded terminator, cleanup should not later emit a timeout-style `GROUP VOICE,END,RX` for the same target-side stream. - This should be limited to non-data voice streams, matching the existing `DATA_STREAM` guards. ### Validated Assumptions - User confirmed the timer exists to terminate streams where the RF signal was lost and the terminal's terminator was also lost. - Therefore, timeout cleanup should cover missing terminators, not streams where FreeDMR already forwarded a terminator. ### Protocol-Sensitive Areas - This is target-side stream lifecycle/reporting, not packet routing or payload mutation. - It affects HBP -> OBP and OBP -> OBP forwarded voice terminators. ### Inferred Invariants - OpenBridge target streams should be marked finished when FreeDMR forwards a voice terminator to that target. - Timeout cleanup should report missing terminators, not duplicate already-ended streams that had a terminator. ### Resolution - In both OpenBridge target branches, set `_target_status[_stream_id]['_fin'] = True` when forwarding a non-data voice terminator. - Kept existing `GROUP VOICE,END,TX` reporting behavior unchanged. - Added deterministic tests for HBP -> OBP and OBP -> OBP proving the trimmer does not emit a later duplicate `GROUP VOICE,END,RX` after a forwarded terminator. ## OBP Source To OBP Target BCSQ TG Namespace ### Findings - `bridge_master.py`, `routerOBP.to_target()`, OpenBridge target branch checks source-quench state with `_dst_id`: `(_dst_id in _target_system['_bcsq'])`. - The same logical branch in `routerHBP.to_target()` checks `_target['TGID']`, which is the TGID that will be visible to the target after bridge rewrite. - If an OBP source stream is bridged from one TG to a different OBP target TG, a target BCSQ for the rewritten target TG is ignored. - Deterministic probe: - Bridge OBP-1 TG9 to OBP-2 TG235. - Set OBP-2 `_bcsq` to `{235: stream_id}`. - Inject OBP-1 stream on TG9. - Packet still forwards to OBP-2 as TG235, proving the BCSQ check used the wrong TG namespace. ### Validated Assumptions - User clarified that OBP-to-OBP should not rewrite TGID; inbound OBP TG should equal target OBP TG. - The only expected TG rewrite is HBP <-> OBP when dial-a-TG maps local RF TG9 to an OBP/FBP reflector TG. - BCSQ should use the TGID the source server sees and sends on OBP/FBP, not the TG the client/terminal sees on RF. The quench asks the source server to stop sending its traffic. ### Unresolved Questions - Whether FreeDMR should defensively prevent or normalize OBP-to-OBP bridge entries that imply TG rewrite. That is a bridge-configuration validity question rather than a confirmed BCSQ runtime bug. ### Protocol-Sensitive Areas - This is optional source-quench behavior. Failure does not corrupt packet bytes, but it can waste bandwidth and processing by continuing to forward a quenched stream. - The TG namespace is source-server/OBP-visible, not RF/client-visible. ### Inferred Invariants - OBP-source BCSQ matching should use the inbound OBP TGID and stream ID because that is the source server's namespace. - HBP-to-OBP dial-a-TG BCSQ matching should use the OBP/FBP reflector TG, not RF TG9, because that is the TGID visible to the source server on OBP/FBP. ### Resolution - No runtime change. The current OBP-source `_dst_id` BCSQ check is correct for the intended OBP model where OBP-to-OBP TG rewrite does not occur. - Treat the deterministic OBP-to-OBP rewrite probe as an invalid scenario unless bridge validation later decides to explicitly support or reject such entries. ## Dial-A-TG Product Rationale ### Findings - Dial-a-TG exists to let a terminal user access any TG on the FreeDMR network without explicitly programming that TG into the terminal/codeplug. - Codeplug generation and DMR terminal programming can be a major barrier for amateur radio users. Many users are strong RF/electronics engineers but may not be interested in, or comfortable with, computer-based configuration. - FreeDMR's amateur-radio use case differs from DMR's intended commercial use case, so some FreeDMR behavior intentionally optimizes usability and network access rather than strict commercial fleet/radio programming assumptions. ### Assumptions - Dial-a-TG changes should be evaluated against this access/usability goal, not only against narrow protocol or commercial-radio assumptions. ### Protocol-Sensitive Areas - Dial-a-TG intentionally maps local RF control/use of TG9 TS2 to wider-network TG access on FBP/OBP. - This makes TG namespace clarity critical: RF/client-visible TGs and OBP/FBP-visible TGs can intentionally differ. ### Inferred Invariants - Dial-a-TG should reduce terminal programming burden for users. - Maintaining usable audio/network access for amateur users is a first-class design constraint. ## FreeDMR Routing Model ### Findings - FreeDMR can be understood like a PBX: - TGs are conference groups that can be connected to. - DMR IDs are like phone numbers. - Routing is centered on TGs and DMR IDs. - FreeDMR is intended to be timeslot agnostic, unlike some systems. - Timeslots are closer to phone lines: - a simplex hotspot has one usable line; - a repeater or duplex hotspot has two usable lines. ### Assumptions - Routing changes should preserve TG/DMR-ID centric behavior. - Timeslot should be treated as an available access path or capacity dimension, not as the primary identity of a route unless a specific feature explicitly requires slot scoping. ### Protocol-Sensitive Areas - Dial-a-TG control from TS1 affecting TS2 reflector state is consistent with the PBX/line model: the user can use one line to control which conference is connected on another line. - Tests should avoid assuming commercial DMR fleet semantics where timeslot is always part of the primary routing identity. ### Inferred Invariants - TG and DMR ID are the primary routing identities. - Timeslot handling should preserve capacity/access behavior without making FreeDMR unnecessarily slot-bound. ## HBP-Side Tolerance Principle ### Findings - FreeDMR should generally be more permissive on the HBP side because HBP represents direct RF-facing connections to repeaters, duplex hotspots and simplex hotspots. - HBP-connected devices may come from several vendors and may run proprietary or open source implementations with differing interpretations of the protocol. - RF-facing behavior may vary in timing, late-entry recovery, stream continuity, data handling and terminator delivery. ### Assumptions - HBP-side handling should avoid over-strict enforcement unless needed for safety, loop prevention, routing correctness or preventing network harm. - OBP/FBP peer-server handling can be stricter because it is server-to-server and less directly exposed to varied RF terminal/repeater behavior. ### Protocol-Sensitive Areas - Timeout behavior on HBP should remain tolerant of resumed streams after packet gaps unless live testing proves this causes worse failures. - Data packet handling may need extra tolerance because terminal implementations can be loose or incomplete. ### Inferred Invariants - Prefer audio continuity and interoperability at the RF-facing HBP boundary. - Treat strictness on HBP as a deliberate choice requiring evidence. ## Unit-Unit Private Call Policy ### Findings - FreeDMR deliberately does not allow user-to-user unit-unit private calls. - Amateur Radio is an open service, not a private communications system. - A unit-unit private call still occupies an RF timeslot even when other stations cannot hear useful traffic on that slot. - Users who want directed or pseudo-private routing should use a TG instead, including the group-call TG that corresponds to their DMR ID if appropriate. - Unit-unit/private calls are reserved for control purposes, including dial-a-TG and related control flows. ### Assumptions - Future changes should not add general private voice routing unless this policy is explicitly changed. - Unit-call handling should continue to distinguish control-plane private calls from user traffic. ### Protocol-Sensitive Areas - DMR allows unit-unit calls, but FreeDMR policy intentionally restricts them in favor of open group-call behavior. - Test cases involving private calls should treat them as control operations, not as a supported user voice routing path. ### Inferred Invariants - User voice traffic should be routed as group calls/TGs. - Private calls are control-plane only in FreeDMR. ## HBP New Voice Terminator Can Use Stale Data Classification ### Findings - `bridge_master.py`, `routerHBP.dmrd_received()`, computes `_data_control` for each HBP group/vcsbk packet. - The slot's `RX_DATA_STREAM` / `RX_GROUP_VOICE_STREAM` classification is only updated at the end of the group branch, after final terminator handling. - Final terminator handling checks `self.STATUS[_slot].get('RX_DATA_STREAM')` to decide whether to emit `GROUP VOICE,END,RX` and mark `RX_FINISHED_STREAM_ID`. - Deterministic probe: - Inject group data header on MASTER-A TS2, leaving `RX_DATA_STREAM=True`. - Inject a new voice stream where the first observed packet is a voice terminator. - The new stream emits `GROUP VOICE,START,RX`, but the terminator final-action path sees stale `RX_DATA_STREAM=True`, so it does not emit voice END and does not mark `RX_FINISHED_STREAM_ID`. - A later same-stream voice burst is then routed, increasing the target packet count from 2 to 3. ### Assumptions To Validate - A newly observed HBP stream should set its data-vs-voice classification before any final-action handling for that same packet. - If FreeDMR first observes a stream at its terminator, the classification should come from that terminator packet, not from the previous slot occupant. - Even if this is an edge case, stale data classification should not suppress voice lifecycle/finished-stream handling for a new voice stream. ### Unresolved Questions - How often real RF/HBP paths deliver a new stream whose first observed packet is a terminator. It can plausibly occur around packet loss, startup, or missed earlier packets, but may be rare. - Whether strict handling should suppress a terminator-only "start" report. This proposal does not change that existing behavior; it only makes the end/finish classification match the current packet. ### Protocol-Sensitive Areas - This is HBP source slot lifecycle state, not packet mutation. - DMR terminal behavior may be loose; preserving robust audio/lifecycle behavior matters more than assuming perfectly ordered full streams. ### Inferred Invariants - Per-slot packet classification must describe the current stream before the current packet's lifecycle side effects are applied. - Stale data-control state must not leak into a new voice stream. ### Resolution - HBP group/vcsbk new-stream handling now sets `RX_DATA_STREAM = _data_control` and `RX_GROUP_VOICE_STREAM = not _data_control` before terminator final actions can run. - Kept the existing end-of-branch assignment for ongoing packets. - Added deterministic coverage proving a voice terminator observed after a data stream marks the new voice stream finished and suppresses later same-stream bursts. ## HBP Terminator-Only Voice On Idle Slot Skips Finished Handling ### Findings - `bridge_master.py`, `routerHBP.dmrd_received()`, final voice terminator handling is guarded by: `(_frame_type == HBPF_DATA_SYNC) and (_dtype_vseq == HBPF_SLT_VTERM) and (self.STATUS[_slot]['RX_TYPE'] != HBPF_SLT_VTERM)`. - If the first observed packet for a new stream is a voice terminator while the slot was idle, the previous slot `RX_TYPE` is already `HBPF_SLT_VTERM`, so final terminator handling is skipped. - Deterministic probe: - Start from idle MASTER-A TS2. - Inject a new HBP group voice terminator for stream `01020304`. - The packet is forwarded and `GROUP VOICE,START,RX` is emitted, but no `GROUP VOICE,END,RX` is emitted and `RX_FINISHED_STREAM_ID` remains null. - A later same-stream voice burst is then forwarded, increasing target capture count from 1 to 2 and setting `RX_TYPE` to the late voice vseq. ### Assumptions To Validate - If FreeDMR accepts and forwards a terminator-only voice packet as a new voice stream, it should also close that stream locally and suppress later same-stream voice. - The final-action decision should be based on whether the current packet is a voice terminator for the current stream, not only on the previous slot `RX_TYPE`. - This should stay limited to non-data voice streams. ### Unresolved Questions - Whether a terminator-only packet should emit both START and END reports, or whether START should be suppressed when no earlier voice packet was observed. Existing behavior already emits START, so the minimal fix should only add the missing end/finished handling. ### Protocol-Sensitive Areas - This is HBP source lifecycle/late-packet suppression, not packet mutation. - A terminator-only first observation can happen when earlier packets were lost or FreeDMR starts observing mid-stream. ### Inferred Invariants - A voice terminator should close the current stream even if the previous slot state was idle. - Post-terminator same-stream voice should not be routed. ### Resolution - Replaced the final terminator guard's previous-`RX_TYPE` dependency with a current voice-terminator condition that avoids duplicate final handling for an already-finished stream. - Added deterministic coverage for an idle-slot terminator-only HBP voice packet marking the stream finished and suppressing later same-stream voice. ## HBP Timeout Cleanup Does Not Mark Stream Finished ### Findings - `bridge_master.py`, `stream_trimmer_loop()`, HBP RX timeout cleanup sets `_slot['RX_TYPE'] = HBPF_SLT_VTERM` and emits `GROUP VOICE,END,RX` for reportable voice streams. - It does not set `RX_FINISHED_STREAM_ID`. - Because `RX_STREAM_ID` remains the timed-out stream ID until the 60-second null cleanup, a later same-stream voice packet before that cleanup is treated as a continuation rather than a new/finished stream. - Deterministic probe: - Inject HBP group voice stream `01020304`. - Advance six seconds and run `stream_trimmer_loop()`. - Timeout emits `GROUP VOICE,END,RX`, leaves `RX_STREAM_ID=01020304`, but `RX_FINISHED_STREAM_ID` remains null. - A later same-stream voice burst routes to the target, increasing target capture count from 1 to 2 and setting `RX_TYPE` to the late voice vseq. ### Updated Analysis - User clarified that timeout can represent two different failure modes: - RF path loss between terminal and base station, where the terminal's terminator may also be lost. - Network/server/mesh packet path disruption, where an otherwise valid stream may have a long packet gap and later resume with the same stream ID. - In RF-path loss, if the user is still transmitting and comes back into range, the base station may recover via DMR late entry and may or may not assign a new HBP stream ID. This needs live RF testing. - In network-path loss, downstream servers cannot reliably distinguish a truly ended over from a temporarily missing packet flow. A later same-stream packet may be legitimate and should probably be forwarded to preserve audio. ### Revised Assumptions - Explicit terminators and timeout cleanup should not be treated identically. - A real terminator is a strong end-of-stream signal and should mark the stream finished. - A timeout is an observability/liveness fallback for missing terminators, but may be a soft end when the underlying stream could resume after network delay. - FreeDMR should prioritize preserving recovered audio over rigidly enforcing a timeout as a hard stream close when no terminator was seen. ### Unresolved Questions - Live RF testing should determine whether an RF-lost/recovered over uses a new stream ID or resumes the old one. - Black-box/network simulation should test whether a packet gap longer than the HBP timeout can occur in the mesh and then resume with the same stream ID. - Reporting may remain imperfect in this case: a timeout `END,RX` can be emitted and later packets for the same stream may still be forwarded without a fresh `START,RX`. ### Protocol-Sensitive Areas - This is HBP source stream lifecycle/late-packet suppression, not packet mutation. - It directly follows the operational purpose of timeout cleanup as a substitute terminator when the real terminator was lost. ### Inferred Invariants - An explicit terminator closes a stream; a timeout without terminator is less certain and may need to remain recoverable. - Audio continuity after network gaps is more important than making timeout reports look perfectly final. - Timeout reports should be understood as "stream appears lost" rather than definitive proof that the over ended. ### Resolution - No runtime change for now. - Do not mark HBP streams finished on timeout until live RF and network-gap testing proves this will not block legitimate recovered audio. - Keep the existing behavior documented as a deliberate soft-timeout tradeoff: reporting may show an END for a lost stream, but later same-stream packets can still route before the 60-second null cleanup. ### Follow-Up Verification - Rechecked earlier lifecycle fixes against this soft-timeout model. - HBP timeout cleanup still only sets `RX_TYPE = HBPF_SLT_VTERM`, clears data/report classification, and optionally emits the timeout report. It does not set `RX_FINISHED_STREAM_ID`. - Deterministic probe confirmed HBP timeout remains recoverable: - one packet routed before timeout; - timeout report emitted with `RX_FINISHED_STREAM_ID == 0`; - later same-stream packet before 60-second null cleanup still routes. - Explicit terminators remain hard end-of-stream signals: - HBP explicit terminator sets `RX_FINISHED_STREAM_ID` and suppresses later same-stream voice; - forwarded HBP -> OBP / OBP -> OBP terminators set target `_fin`; - OBP source timeout sets `_to`, not `_fin`. - Conclusion: earlier fixes did not convert timeout cleanup into a hard finished state. The hard-close behavior is currently limited to real/forwarded terminators. ## DMRD Sequence Wrap In Voice Duplicate Control ### Findings - `hblink.py` parses the DMRD sequence number as `_seq = _data[4]`, so it is a one-byte value in the range `0..255`. - `bridge_master.py`, `routerHBP.dmrd_received()`, HBP group/vcsbk duplicate control treats sequence numbers as a simple increasing integer: duplicate when `_seq == lastSeq`, out-of-order when `_seq < lastSeq and _seq != 1`, and missed packets when `_seq > lastSeq + 1`. - `bridge_master.py`, `routerOBP.dmrd_received()`, OBP group/vcsbk duplicate control uses the same linear comparisons. - This works until sequence wrap. If the last accepted sequence is `255` and post-wrap packets `0` and `1` are lost, a valid resumed packet with sequence `2` is treated as out-of-order because `2 < 255 and 2 != 1`. - Because the discard path does not advance `lastSeq`, later valid packets `3`, `4`, and so on remain less than `255` and can continue to be discarded. ### Assumptions - DMRD sequence numbers are modulo-256 transport sequencing, not an unbounded stream counter. - Packet `0` is a valid DMRD sequence value, not "no sequence"; the current truthiness checks skip some validation when `_seq == 0`. - Duplicate and out-of-order suppression is intended to drop repeated or stale packets, not to mute a stream after a legitimate sequence wrap with packet loss. ### Unresolved Questions - Whether data/control paths should use the same helper if future duplicate control is added there, while remembering that DMR data packets are packet-oriented rather than continuous voice streams. ### Protocol-Sensitive Areas - This affects packet-control behavior for long voice streams and network gaps, especially near sequence wrap. - HBP should remain tolerant of RF-facing packet loss and vendor differences. - OBP/FBP should also handle modulo sequence wrap because the sequence value is carried in DMRD packet format, even though server-to-server behavior can be stricter in other areas. ### Inferred Invariants - DMRD sequence comparison should be modulo-aware. - A post-wrap packet should be treated as forward progress even if one or more immediately post-wrap packets were lost. - Duplicate detection should include sequence `0`. ### Resolution - Added `dmrd_seq_delta(seq, last_seq)` to calculate sequence progress as `(seq - last_seq) % 256`. - Updated HBP and OBP group/vcsbk packet-control paths to classify delta `0` as duplicate, delta `1` as normal progress, delta `2..127` as forward progress with missed packets, and delta `128..255` as old/out-of-order. - Removed the `_seq > 0` guard from hash duplicate checks so sequence `0` cannot bypass duplicate detection. - Added deterministic tests for HBP and OBP voice streams crossing `254`, `255`, then `2`, and for HBP sequence `0` duplicate suppression. ## HBP/OBP Source Timeout Uses Stream Start Time ### Findings - `bridge_master.py`, `routerHBP.dmrd_received()`, HBP group/vcsbk handling has a source timeout check: `if self.STATUS[_slot]['RX_START'] + 180 < pkt_time`. - `bridge_master.py`, `routerOBP.dmrd_received()`, OBP group/vcsbk handling has the analogous check: `if self.STATUS[_stream_id]['START'] + 180 < pkt_time`. - Both branches log that the source/stream should be ignored and then return before duplicate control, routing, terminator handling, and normal state updates. - Because the comparison is against the stream start time, this is a hard maximum stream duration of 180 seconds. It is not an inactivity timeout. - A still-active voice stream that continues past 180 seconds without a terminator will be dropped even if packets continue arriving at normal cadence. ### Assumptions - User confirmed this is deliberate amateur DMR network behavior. - The 180-second guard is a hard maximum over/stream duration, comparable to a network-side time-out timer. - Terminal time-out timers are also advised to be set to 180 seconds. ### Unresolved Questions - None for now. ### Protocol-Sensitive Areas - This is voice stream lifecycle and loop/source protection, not packet mutation. - HBP paths are RF-facing and may reflect repeater/hotspot time-out timer behavior, so changing this could permit overlong RF streams that deployments currently expect to be cut off. - OBP/FBP can carry arbitrary forwarded streams, so the operational impact may differ between HBP and OBP. ### Inferred Invariants - A hard stream-age cap should be explicit and documented if retained. - The current 180-second source timeout should remain based on original stream start time, not last packet activity. ### Resolution - No runtime change. The `START + 180` behavior is intentional and should not be rewritten as a last-activity timeout. ## Generated Prompt Cancellation Does Not Send Terminator ### Findings - `mk_voice.py`, `pkt_gen()`, builds generated voice prompts as a DMRD stream: three voice headers, AMBE bursts, and a final voice terminator frame using the same generated stream ID. - `bridge_master.py`, `sendSpeech()`, `disconnectedVoice()`, and `playFileOnRequest()` now stop prompt scheduling when `_generatedVoiceCancelled()` becomes true. - Those loops break immediately and do not drain the generator to its final terminator packet. - `_cancelGeneratedVoice()` only marks prompt state as cancelled; it does not send a same-stream terminator for the abandoned prompt. - A real HBP voice stream can therefore interrupt a generated prompt on an HBP target slot after the repeater has received a prompt header/burst but before it receives the generated prompt terminator. ### Assumptions - User confirmed that absence of a terminator for an interrupted prompt is not a major terminal-facing issue. - DMR is designed for lossy environments, and a missing terminator is an expected recoverable condition. - DMR also supports interruption concepts such as priority interruption, so receivers should tolerate an interrupted stream being superseded. - Any future fix must not delay the real RF/network voice that caused the cancellation. ### Unresolved Questions - None for now. ### Protocol-Sensitive Areas - This is generated local HBP TX lifecycle, not inbound DMR packet parsing. - The fix would intentionally create or forward a terminator packet for locally generated voice; it must preserve the stream ID and LC namespace expected by the target repeater/client. - Prompt interruption exists to prioritize live RF/network audio, so any terminator fix must be low latency. ### Inferred Invariants - Real RF/network audio should remain higher priority than locally generated prompts. - Prompt termination should not mutate the real stream that caused the interruption. - Missing generated-prompt terminators are acceptable if interruption preserves live traffic. ### Resolution - No runtime change. Prompt cancellation may abandon the generated prompt without sending its final terminator. ## Voice Ident Cancellation Leaves Prompt Cancel State Set ### Findings - `bridge_master.py`, `sendSpeech()`, `disconnectedVoice()`, and `playFileOnRequest()` wrap generated prompt playback with `_beginGeneratedVoice()` and `_endGeneratedVoice()`. - `bridge_master.py`, `ident()` sends generated packets directly in its own loop and calls `sendVoicePacket()` without `_beginGeneratedVoice()` / `_endGeneratedVoice()` or `_generatedVoiceCancelled()` checks. - `sendVoicePacket()` returns immediately if `_slot['TX_PROMPT_CANCEL']` is true. - Real HBP voice can cancel generated prompt state on a target slot by calling `_cancelGeneratedVoice()`, setting `TX_PROMPT_CANCEL=True` and `TX_PROMPT_ACTIVE=False`. - If that happens while a voice ident is active, the ident loop keeps scheduling packets that `sendVoicePacket()` drops, and no ident cleanup resets `TX_PROMPT_CANCEL`. - A later `ident()` run on the same slot can then have all of its packets dropped immediately because it never begins a new prompt token or clears the old cancel flag. ### Assumptions - Voice ident should follow the same generated-prompt lifecycle as dial-a-TG prompts and on-demand files. - Real RF/network voice should still be able to interrupt voice ident. - A cancelled ident should not permanently suppress later idents. ### Unresolved Questions - None for now. ### Protocol-Sensitive Areas - This is generated local HBP TX scheduling/state, not inbound DMR parsing. - The fix should not alter generated packet bytes, destination selection, or the existing policy that ident only runs when the slot has been idle. ### Inferred Invariants - Every generated voice loop that uses `sendVoicePacket()` should own a prompt token and clear/finish prompt state when it exits. - Interrupting one generated voice ident must not permanently block future generated voice idents. ### Resolution - Updated `ident()` to use `_beginGeneratedVoice()`, `_generatedVoiceCancelled()`, and `_endGeneratedVoice()` around its generated packet loop. - Removed unused `_stream_id` / `_pkt_time` local assignments from the ident loop. - Added deterministic coverage proving a cancelled ident does not leave the slot in a state that blocks a later ident. ## HBP New Voice Stream Keeps Previous Duplicate State ### Findings - `bridge_master.py`, `routerHBP.dmrd_received()`, HBP group/vcsbk handling uses per-slot duplicate-control state: `lastSeq`, `lastData`, `loss`, `packets`, and `crcs`. - When a new stream is detected with `_stream_id != self.STATUS[_slot]['RX_STREAM_ID']`, the code resets `packets`, `loss`, and `crcs`. - It does not reset `lastSeq` or `lastData` at that point. - The duplicate/out-of-order block for the current packet then runs against potentially stale `lastSeq` and `lastData` from the previous stream on the same slot. - If the previous stream ended by explicit terminator, final terminator handling resets `lastSeq` and `lastData`, so the issue is masked. - If the previous stream ended by timeout, source timeout, collision gap, or any path that does not run explicit terminator cleanup, the next stream can be judged against stale sequence/data. - Example from code logic: previous stream leaves `lastSeq=200`; a new stream begins with sequence `1`. `dmrd_seq_delta(1, 200)` is `57`, so the packet is treated as forward progress with missed packets on the old stream rather than the first packet of a new stream. Other stale values can classify the new packet as duplicate or out-of-order. ### Assumptions - Duplicate-control state is stream-scoped, not slot-lifetime-scoped. - A new stream on the same HBP slot should begin with `lastSeq=False` and `lastData=False`, just as OBP initializes per-stream state. - Resetting duplicate-control state on new stream should not affect explicit terminator suppression, which is now handled separately by `RX_FINISHED_STREAM_ID`. ### Unresolved Questions - None for now. ### Protocol-Sensitive Areas - This is HBP RF-facing packet-control state, not packet mutation. - HBP should be tolerant of missing terminators and stream transitions after packet loss. - The fix should not change OBP, where stream state is already per stream. ### Inferred Invariants - Duplicate and sequence state must belong to the current stream. - Missing a terminator must not let stale duplicate-control state from the old stream suppress or misclassify the next HBP stream. ### Resolution - Updated the HBP group/vcsbk new-stream initialization block to reset `lastSeq` and `lastData` along with `packets`, `loss`, and `crcs`. - Added deterministic coverage for a prior HBP stream that times out with `lastSeq=200`; the next stream on the same slot starts with sequence `1`, routes to the target, and records no inherited loss. ## HBP Sequence Gaps on Unreliable Networks ### Findings - The DMRD sequence byte is supplied by the HBP client/repeater/hotspot at packet offset 4 and is passed through `hblink.py` into `routerHBP.dmrd_received()`. - The current modulo-256 check treats deltas 1..127 as forward progress, delta 0 as duplicate, and deltas greater than 127 as stale/out-of-order. - If a network-side gap exceeds half the 8-bit sequence space, the server cannot distinguish "very late old packet" from "forward packet after a long loss". - Because the out-of-order branch returns before updating `lastSeq`, a same-stream resume after a greater-than-127 jump may remain muted until the sequence wraps back near the previous accepted value. ### Assumptions - RF-side loss between the terminal and repeater may not produce a sequence jump at the server if the HBP device only increments the DMRD sequence for packets it actually sends. - Network-side loss between an HBP device and the server can produce large sequence jumps because packets were sent but not received by FreeDMR. - HBP should remain permissive where practical because it represents direct RF paths and unreliable access networks. ### Unresolved Questions - Whether the HBP duplicate-control path should eventually add a long-gap recovery rule for same stream IDs after a quiet interval, rather than treating all greater-than-127 deltas as stale. ### Protocol-Sensitive Areas - The sequence byte is only 8 bits, so long-gap direction is inherently ambiguous without timing, stream lifecycle, or additional protocol context. - Any recovery rule must avoid accepting genuinely old/reordered packets and creating audio or loop-control regressions. ### Inferred Invariants - Small packet loss and sequence wrap must not suppress a voice stream. - A new HBP stream must not inherit sequence state from an old stream. - Very long same-stream gaps require a policy decision: prefer stale-packet rejection or prefer late audio recovery on lossy access networks. ## FBP Sequence Gaps on Unreliable Links ### Findings - OBP/FBP group voice handling uses the same modulo-256 sequence delta policy as HBP: delta 0 is duplicate, deltas 1..127 are forward progress, and deltas greater than 127 are treated as stale/out-of-order. - OBP/FBP duplicate-control state is keyed by stream ID, so it does not have the HBP per-slot stale-new-stream problem. - A same-stream long network outage on an OBP/FBP link can still produce the same recovery issue: the first packets after the outage may be rejected until the sequence wraps back near the last accepted value. - OBP/FBP code updates `LAST` on accepted packets and on some loop/timeout returns. For the duplicate/out-of-order branch, the useful quiet-time signal is the elapsed time since the last accepted/routed packet. ### Assumptions - Some FreeDMR peer-server links may be unreliable because of RF IP, cellular, portable, EMCOMM, or less-developed infrastructure use cases. - Server-to-server links still need stricter loop-control behavior than HBP edges, but rejecting live resumed audio after a long quiet gap is undesirable. - Any FBP recovery rule should apply only to stream media packets after loop and source-selection checks have already accepted this server as the first source for the stream. ### Unresolved Questions - Whether HBP and FBP should share the same long-gap threshold, or FBP should use a slightly higher threshold to reduce risk from delayed/reordered mesh packets. ### Protocol-Sensitive Areas - BCSQ/source-quench correctness depends on stream ID and TG; recovery should not bypass existing first-source/loop-control decisions. - OBP/FBP can carry multiple concurrent streams, so rate and recovery decisions must remain per stream, not global. ### Inferred Invariants - FBP should not discard live recovered audio solely because a lossy link crossed the half-sequence ambiguity point. - FBP long-gap recovery must not weaken source-selection, STUN, ACL, TG filter, or source-quench behavior. ## FBP Trunk-Wide Long-Gap Diagnostics ### Findings - OBP/FBP links can carry many independent streams over one peer connection. - If long-gap recovery is observed on only some streams, the loss may have been inherited from an upstream route taken by those streams. - If long-gap recovery is observed across most or all active streams on the same OBP/FBP peer connection within a short window, that suggests the local upstream link or peer connection is struggling. ### Assumptions - This signal is diagnostic only and must not influence packet admission, source selection, loop control, or source-quench decisions. - Per-stream recovery logging is useful for debugging a stream; trunk-wide aggregate logging is useful for diagnosing infrastructure or upstream link trouble. ### Unresolved Questions - What threshold should define "most/all streams" on a trunk: all active streams, a fixed minimum count, or a ratio such as 80% within a short window. - Whether this should be implemented immediately with long-gap recovery or left as a later observability improvement. ### Protocol-Sensitive Areas - A malicious or delayed packet pattern must not be able to relax loop-control or routing checks by triggering trunk-wide diagnostics. - Trunk-wide diagnostics must be rate-limited to avoid log flooding during real network incidents. ### Inferred Invariants - Long-gap recovery remains per stream. - Trunk-wide detection is warning-only observability. - Diagnostics must never bypass STUN, HMAC/authentication, ACLs, TG filters, loop-control, finished-stream suppression, source timeout, rate limiting, or BCSQ/source-quench behavior. ## OBP Target LC Missing-Key Logging Uses Wrong System Variable ### Findings - `bridge_master.py`, `routerOBP.to_target()`, OpenBridge-target voice rewrite branch catches missing `T_LC` and `EMB_LC` state with `except KeyError`. - Both handlers log with `system` instead of `self._system`. - `system` is not a local variable in `to_target()`. If no module-level `system` binding exists, the exception handler itself can raise `NameError`. If a module-level binding does exist from other loops, the log can report the wrong system. - The surrounding code intends these `KeyError` paths to be non-fatal: one logs and continues processing the terminator, the other logs and skips the packet. ### Assumptions - Missing LC state is abnormal, but the existing handlers show the intended behavior is to avoid crashing the router on this condition. - Changing the log argument from `system` to `self._system` does not alter packet routing, mutation, or normal successful voice handling. ### Unresolved Questions - Whether the terminator missing-`T_LC` path should also skip sending the packet after logging. That is a separate behavior question and should not be bundled with the logging fix. ### Protocol-Sensitive Areas - This is an error-path logging bug in OBP target voice LC rewrite handling. - It should not change LC rewrite behavior when `T_LC` / `EMB_LC` are present. ### Inferred Invariants - Error-path logging must not introduce a new exception while handling malformed or inconsistent stream state. - Logs should identify the router instance handling the packet. ### Resolution - Replaced the non-local `system` log argument with `self._system` in the missing `T_LC` and `EMB_LC` handlers. - Added deterministic OBP-to-OBP coverage that removes target `EMB_LC` state, injects a voice burst, and verifies the warning logs without crashing or forwarding the malformed rewrite. ## Widened Review: Startup, Config, Support Functions ### Findings - `config.py`, `build_config()`, reads `GLOBAL.USE_ACL` with `config.get(..., fallback=True)` instead of `config.getboolean(...)`. Therefore a config value such as `USE_ACL: False` becomes the non-empty string `"False"`, which is truthy in packet ACL checks. - Packet ACL checks are split across both layers: `hblink.py` performs low-level admission checks using `self._CONFIG['GLOBAL']['USE_ACL']`, and `bridge_master.py` checks the same global flag before target forwarding. A truthy string therefore affects both layers consistently, but incorrectly. - `config.py` converts `ALIASES.STALE_DAYS` into seconds as `STALE_TIME`. `bridge_master.py` then schedules alias reloads with `CONFIG['ALIASES']['STALE_TIME'] * 86400`, multiplying by 86400 twice. A one-day alias stale interval therefore schedules periodic reload around 86400 days instead of 1 day. - `bridge_master.py`, `setAlias()`, assigns reloaded alias dictionaries to local variables named `peer_ids`, `subscriber_ids`, `talkgroup_ids`, `local_subscriber_ids`, `server_ids`, and `checksums`. Without a `global` declaration or updating `CONFIG`, the periodic alias reload does not update the module-level alias dictionaries used by logging, reports, and routing helpers. - `hblink.py` router classes also read aliases from the shared config object: `CONFIG['_SUB_IDS']`, `CONFIG['_PEER_IDS']`, `CONFIG['_LOCAL_SUBSCRIBER_IDS']`, and `CONFIG['_SERVER_IDS']`. Therefore an alias reload fix must update both `bridge_master.py` globals and these shared config keys; updating only one side would leave the split layers inconsistent. - `bridge_master.py`, `bridge_reset()`, checks `if 'OPTIONS' in CONFIG['SYSTEMS'][_system]['OPTIONS']:` after reset. If the system has no `OPTIONS` key, which can happen after disconnect when no `_default_options` exists, this raises `KeyError` inside the timed reset loop. - `hblink.py` deliberately deletes `CONFIG['SYSTEMS'][system]['OPTIONS']` when a peer disconnects or times out and no `_default_options` exists. This confirms the missing `OPTIONS` case is part of normal lifecycle behavior, not corrupt state. ### Assumptions To Validate - `GLOBAL.USE_ACL: False` should disable global ACL checks; it should not be treated as enabled because the string is truthy. - `ALIASES.STALE_DAYS` is intended to control alias refresh age in days, while the runtime scheduler should operate on the already-converted seconds value. - Periodic alias reloads are intended to replace the live alias dictionaries used by bridge logging/reporting and router helper methods. - Bridge reset should tolerate sessions with no current `OPTIONS` key and should not stop the reactor/timed loops. ### Unresolved Questions - In `bridge_reset()`, should `_reloadoptions` be set whenever an `OPTIONS` key exists, or only when current/default options actually need re-parsing? ### Protocol-Sensitive Areas - Global ACL parsing affects all packet admission paths. - Alias reload affects observability and may affect local subscriber lookup helpers, but should not mutate packet bytes. - Bridge reset interacts with HBP disconnect/reconnect lifecycle and options parsing; fixes must preserve the confirmed session-only option semantics. ### Inferred Invariants - Boolean config fields must be stored as booleans, not truthy strings. - Time values should have one unit conversion boundary. - Periodic reload functions must update the state actually read by production paths. - Timed maintenance loops must not crash on missing optional session keys. ### Resolution - Updated `config.py` to parse `GLOBAL.USE_ACL` with `getboolean()`. - Updated the alias reload scheduler to use `CONFIG['ALIASES']['STALE_TIME']` directly, because `config.py` already converts `STALE_DAYS` to seconds. - Updated `setAlias()` to assign the reloaded dictionaries to bridge_master module globals and the shared `CONFIG` alias keys consumed by `hblink.py`. - Updated `bridge_reset()` to check for the presence of the `OPTIONS` key before marking `_reloadoptions`. - Added deterministic/config coverage for global ACL parsing, alias stale-time units, cross-layer alias reload state, and reset after missing session `OPTIONS`. ## Post-Fix Widened Review Status ### Findings - A follow-up scan of `config.py`, `bridge_master.py`, `hblink.py`, `API.py`, `utils.py`, and `log.py` did not identify another confirmed bug with the same confidence as the fixed config/startup/support issues. - The current full deterministic suite passes after the widened fixes. ### Assumptions - Remaining broad `except` blocks and legacy split-layer structure should be treated as audit candidates, not bugs, unless a concrete failing path is demonstrated. ### Unresolved Questions - The API layer has not yet had the same level of focused review as packet, config, startup, alias, and reset handling. - Long-gap HBP/FBP same-stream recovery remains a known improvement candidate, not a completed fix. ### Protocol-Sensitive Areas - API/reset/options changes can affect live session control and should be tested carefully if reviewed later. - Long-gap recovery must not bypass loop-control, ACL, STUN, source-quench, or rate-limiting behavior. ### Inferred Invariants - Confirmed bugs should have a demonstrated code path and a narrowly scoped regression test before production changes are made. - Deferred policy or observability work should remain documented separately from fixed correctness bugs. ## UDP Black-Box Harness Expansion ### Findings - The UDP black-box layer can sensibly mirror HBP-observable deterministic behavior: startup/config parsing that affects admission, HBP registration, DMRD routing, packet byte preservation, sequence duplicate/drop behavior, terminator lifecycle behavior, and generated prompt output. - OBP/FBP coverage remains out of scope for this increment because it requires OpenBridge packet/HMAC/protocol-version emulation over UDP. ### Assumptions - UDP tests should cover high-value end-to-end behavior, not every deterministic internal state transition. - State-only behavior such as alias dictionary replacement and bridge-reset flags remains better covered by the deterministic harness unless an external UDP-visible symptom is needed. ### Unresolved Questions - Add OpenBridge/FBP UDP peer emulation later for enhanced metadata, BCSQ/STUN, keepalive, and FBP long-gap behavior. - Add recorded packet fixture replay and jitter/drop controls around realistic cadence later. ### Protocol-Sensitive Areas - The current UDP expansion exercises HBP only; it must not be interpreted as FBP/OpenBridge black-box coverage. - UDP tests assert observable packet fields and bytes, not internal mutable status dictionaries. ### Inferred Invariants - Deterministic tests remain the broad internal regression layer. - UDP tests are the external integration confidence layer and should stay loopback-only, opt-in, and isolated from real network traffic. ### Resolution - Added UDP scenario config knobs for global ACL fields and static TG lists. - Added opt-in UDP coverage for global ACL false parsing through packet admission, HBP data/control payload preservation, voice sequence wrap, duplicate sequence `0` suppression, and post-terminator late packet suppression. - Existing UDP coverage for static routing and local TG9 TS2 dial-a-TG prompt output remains in place. ## UDP/FBP Black-Box Harness Expansion ### Findings - `hblink.py` OpenBridge/FBP v5 packets are observable over UDP as `DMRE` envelopes with the DMR header/body, BER/RSSI, embedded protocol version, timestamp, source server, source repeater, hop count and BLAKE2b hash. - Enhanced OpenBridge forwarding is gated by recent `BCKA` state, so a useful black-box FBP peer must send signed keepalive traffic before expecting forwarded packets. - FBP source-quench is represented by signed `BCSQ` packets keyed by TGID and stream ID; the UDP harness can assert the external effect by verifying no matching `DMRE` leaves FreeDMR for that stream. ### Assumptions - Initial FBP black-box coverage should focus on protocol-v5 enhanced FBP, because that is the current default and carries the richest metadata. - The harness may construct valid FBP transport envelopes and bridge-control packets, but only production code should perform route-driven DMR field rewrites. - FBP peer emulation should stay loopback-only and opt-in with the existing UDP test gate. ### Unresolved Questions - Add explicit black-box coverage for `BCST` STUN once desired externally observable behaviour is selected for a multi-peer topology. - Add older OpenBridge/FBP protocol-version fixtures later so metadata assertions follow the protocol version actually negotiated for the session. - Add recorded packet replay and jitter/drop controls after the synthetic FBP path is stable. ### Protocol-Sensitive Areas - FBP packet signing must match the exact protocol version layout. Version 5 hashes bytes through the hop-count field and includes source repeater metadata; lower versions differ. - FBP inbound network ID is carried in the embedded DMR peer-ID field and must match the configured OpenBridge `NETWORK_ID`. - Enhanced FBP liveness and source-quench are transport/control state, not DMR payload mutation. ### Inferred Invariants - HBP-to-FBP static TG routing clears the slot bit because OpenBridge traffic is carried as TS1 over the FBP transport. - FBP-to-HBP static TG routing rewrites only the target HBP slot/TG fields required by the active bridge target while preserving stream identity. - A peer-requested `BCSQ` for a TG/stream suppresses subsequent outbound FBP packets for that TG/stream without affecting unrelated routing. ### Resolution - Extended `tests/harness/udp_blackbox.py` with FBP constants, packet parsing, v5 `DMRE` packet construction, bridge-control signing, generated OBP config sections, and a `FbpPeer` loopback emulator. - Added opt-in UDP tests for HBP-to-FBP static TG routing, FBP-to-HBP static TG routing, FBP source-quench suppression, and inbound FBP network-ID rejection. - Updated test and architecture documentation to describe the current FBP UDP support and run commands. ## UDP Unreliable-Link Simulation ### Findings - The UDP black-box harness can simulate unreliable links at the fake endpoint send boundary without changing FreeDMR production behaviour. - FreeDMR's current voice packet-control logic treats DMRD sequence numbers as modulo-256, forwards forward progress, and discards delayed out-of-order packets rather than buffering and reordering them. - FBP trunks can carry arbitrary numbers of streams, so impairment scenarios should be able to target one stream without implying that the whole trunk is impaired. ### Assumptions - FreeDMR owns stream ID assignment in the routed/network path; unreliable-link tests should not assume a repeater assigns a replacement stream ID after loss. - FreeDMR intentionally does not implement a jitter buffer. In real-time AMBE stream handling, late or out-of-order packets should generally be discarded rather than reconstructed. - UDP impairment tests model UDP/IP transport behaviour, not RF late entry, AMBE FEC recovery, terminal behaviour or MMDVM jitter buffering. ### Unresolved Questions - Add burst-loss and blackout scenarios to document current timeout/continuation behaviour over longer gaps. - Add a whole-trunk impairment warning scenario later if an observable logging rule is designed. - Add multi-stream FBP impairment where one stream is delayed/reordered while unrelated streams continue normally. ### Protocol-Sensitive Areas - Delayed packets must not override loop-control, STUN, ACLs, source-quench or rate-limiting decisions. - FBP impairment tests must preserve the correct signed protocol envelope for the version under test; the impairment layer schedules transport sends and does not mutate DMR or FBP packet bytes. ### Inferred Invariants - Transport simulation and protocol mutation remain separate. - Reordered packets should be observable as missing/dropped packets at the destination, not as a buffered corrected sequence. - A damaged stream should not poison later streams on the same FBP trunk. ### Resolution - Added `LinkImpairment` to `tests/harness/udp_blackbox.py` with deterministic drop, duplicate, jitter and delay scheduling for fake endpoint sends. - Extended HBP and FBP fake endpoints so `send_stream()` / `send_fbp_stream()` can apply impairment while preserving packet bytes. - Added opt-in UDP tests for delayed out-of-order HBP packets and delayed out-of-order FBP packets. Both assert that sequence `1` arriving after sequence `2` is not replayed; the FBP test also verifies a following stream on the same trunk still routes. ## UDP Real-World Scenario Profiles ### Findings - The UDP black-box suite benefits from reusable scenario profiles because many realistic tests need the same 30 ms voice-over packet sequences and impairment patterns. - Prompt interruption is externally observable over UDP: a generated local TG9 TS2 prompt should not prevent a real HBP voice stream from routing after the prompt has started. - FreeDMR is commonly deployed in Docker with the hotspot proxy enabled, but proxy/firewall behaviour is a separate integration boundary from direct `bridge_master.py` UDP testing. ### Assumptions - Direct UDP tests should remain the current second layer and should not start the hotspot proxy implicitly. - Proxy/firewall tests should become a third opt-in layer so direct protocol regressions remain isolated from packaging/proxy failures. - Any firewall integration test must avoid changing the developer host firewall unless it runs in Docker or uses a fake command runner. ### Unresolved Questions - Add a reliable voice-ident interruption trigger for the subprocess harness. The production ident loop currently runs on a long interval, so a direct generated-prompt interruption test gives faster coverage now. - Decide whether proxy tests should run through Docker Compose, local subprocess proxy mode, or both. - Inspect external proxy/firewall code from the GitLab repo only when needed and with network access explicitly available. ### Protocol-Sensitive Areas - Stream profiles generate DMR packet sequences; route-driven rewrites still belong only to production code. - Multi-stream FBP trunk tests should avoid HBP target-slot contention unless contention is the behaviour under test. - Prompt interruption tests must assert real routed traffic, not just absence of prompt packets, because generated speech may have already queued packets. ### Inferred Invariants - Reusable stream/impairment profiles should be deterministic and named after real deployment failure modes where possible. - A generated prompt or ident must not permanently block real RF-originated voice. - Proxy tests are valuable, but they should be opt-in and isolated from the direct UDP black-box harness. ### Resolution - Added `StreamProfile.voice_over()` for reusable 30 ms voice stream packet sequences with optional header and terminator packets. - Added named `ImpairmentProfiles` for clean links, provider-style reordering, mobile flutter drops and duplicate UDP datagrams. - Added a UDP prompt-interruption test that observes a local TG9 TS2 generated prompt, injects real HBP voice, and verifies the real stream routes to another master. - Added a multi-stream HBP-to-FBP trunk test where one TG stream is reordered and drops its late packet while another clean TG stream continues over the same FBP peer. - Documented a future third Docker/proxy integration layer for packaged deployments and proxy/firewall behaviour. ## UDP Hostile Packet Coverage ### Findings - The UDP black-box harness can exercise malformed and hostile packet paths against a real `bridge_master.py` subprocess while keeping all traffic on loopback. - HBP short `DMRD` datagrams are expected to be ignored without disconnecting the emulated repeater; a following valid packet should still route. - FBP stale timestamp and max-hop enforcement are expected to source-quench the affected TG/stream rather than forwarding to HBP targets. ### Assumptions - Bad FBP hashes should be ignored without a source-quench because the packet did not authenticate. - Short malformed FBP packets should be ignored and should not poison the peer state for later valid traffic. - Source-quench assertions should check the externally visible BCSQ TG/stream fields, not internal `_laststrid` state. ### Unresolved Questions - Add optional subprocess log assertions later for the warning/error messages produced by these hostile packet paths. - Add bad source-server ID, prohibited OpenBridge slot and old protocol-version cases once the next negative-path batch is selected. ### Protocol-Sensitive Areas - FBP hash corruption must mutate only the transport hash, not the DMR payload, so the test isolates authentication handling. - Stale timestamp and max-hop tests use valid hashes and valid network IDs so they specifically exercise post-auth protocol gates. ### Inferred Invariants - Malformed/hostile UDP input must not crash the subprocess. - Rejected FBP packets must not leak traffic to HBP repeaters. - Authenticated but stale/over-hop FBP streams should produce BCSQ for the affected TG/stream. ### Resolution - Added FBP packet-builder options for explicit timestamp and intentionally corrupted hash generation. - Added `FbpPeer.recv_opcode()` to capture bridge-control responses such as BCSQ. - Added opt-in UDP tests for short HBP `DMRD`, short FBP `DMRE`, bad FBP hash, stale FBP timestamp and max-hop FBP handling. ## UDP FBP Bridge-Control Coverage ### Findings - Enhanced OpenBridge/FBP targets require recent authenticated `BCKA` state before HBP-originated traffic is forwarded to them. - `BCSQ` is a stream/TG source-quench control and must authenticate before it mutates suppression state. - `BCST` STUN is a global OpenBridge traffic gate. It blocks FBP send/receive paths but does not imply HBP-to-HBP traffic should stop. ### Assumptions - Invalid bridge-control hashes should be ignored without changing runtime state. - A valid `BCST` is intended to temporarily stop all FBP/OpenBridge traffic until an operator/API path later clears the stun state. - Black-box STUN assertions should isolate FBP effects from ordinary HBP bridge routing, because valid HBP-to-HBP traffic can still be queued. ### Unresolved Questions - No un-STUN API path is currently covered; live operator/API semantics remain deferred. - Older protocol-version and unsupported-version bridge-control cases are still future UDP fixture work. - Subprocess log assertions for rejected bridge-control packets are still optional future coverage. ### Protocol-Sensitive Areas - Bridge-control HMAC input depends on opcode: `BCVE` signs the version byte, while `BCKA`, `BCSQ`, and `BCST` sign their full control bodies. - `BCSQ` is scoped to the TGID and stream ID seen on the FBP/OpenBridge side. - STUN should not override loop-control or reinterpret delayed stream packets. ### Inferred Invariants - No authenticated enhanced keepalive means no enhanced HBP-to-FBP forwarding. - Invalid `BCSQ` does not suppress any later stream. - Valid STUN blocks OpenBridge send/receive while leaving unrelated HBP routing behaviour to the normal bridge rules. ### Resolution - Added corrupt bridge-control hash support to the UDP FBP control builder. - Added an invalid BCSQ helper to the fake FBP peer model. - Added opt-in UDP tests for enhanced keepalive gating, invalid BCSQ rejection, and BCST STUN blocking OpenBridge traffic in both directions. ## UDP FBP Protocol-Version Coverage ### Findings - `BCVE` is the explicit bridge-control version negotiation path. Downgrades, unsupported versions and invalid hashes should not change the configured outbound packet version. - FBP v5 packets carry source repeater metadata. v4 packets use an older layout without that field, but v4 is now treated as historical/deprecation context rather than a protocol target to preserve long term. - v1 remains important and supported as an open OBP interop protocol used by other amateur DMR software. The important v1 bridge-instance path is `bridge.py`, not primarily `bridge_master.py`. - A signed v1 OpenBridge `DMRD` packet received on a v5-configured link is refused before normal packet routing and FreeDMR responds with `BCVE`. - The UDP harness can build both v4 and v5 envelopes while keeping the inner DMR payload bytes generated by the same `PacketSpec`. It can also build signed v1 OBP packets for refusal tests. - `PROTO_VER` is read into `CONFIG['SYSTEMS'][...]['VER']`; historical v4 behavior is now characterization coverage only. Going forward, expected protocol support is v1 OBP where appropriate and v5 FBP. - UDP expected-failure coverage now confirms two remaining protocol-version issues: unsupported embedded `DMRE` version 6 is not rejected before routing, and the v4 send layout currently carries the module default version byte instead of the configured `PROTO_VER` value. - Recorded packet fixture replay is now available for hex-encoded UDP payloads. Replay preserves packet bytes and keeps protocol mutation inside FreeDMR. - Subprocess log capture is available for black-box warning/error assertions. - True voice-ident black-box interruption remains blocked by the fixed 914 second production ident loop interval unless a test hook or long-running mode is introduced. ### Assumptions - The generated black-box config uses protocol v5 by default, so failed BCVE negotiation should leave outbound packets as v5. - Accepting an inbound v4 packet is current behavior, but it is not a desired long-term compatibility contract. - Refusing v1 on a configured v5 link is the intended behavior because the generated test config sets `PROTO_VER` to the current FBP version. This does not contradict support for v1 through bridge instances. - Protocol options and metadata layout should be asserted against the protocol version carried by the packet or negotiated for the session. ### Unresolved Questions - Decide whether unsupported embedded `DMRE` versions should be rejected at the parser seam before routing. The expected-failure test documents the current leak. - Decide whether the v4 send branch should write the configured protocol version byte rather than the module-level `VER` constant. The expected-failure test documents the current mismatch; this may become moot if v4 is removed. - Future v1 interop testing should inspect `bridge.py`, Docker startup files and the GitLab wiki for bridge-instance behavior. - Add a production-supported fast trigger for voice-ident subprocess tests, or keep real ident coverage in deterministic tests and live/manual testing. ### Protocol-Sensitive Areas - v5 hash input includes the source repeater field and hop byte. - v4 hash input omits source repeater and places the hop byte immediately after source server metadata. - `BCVE` signs only the one-byte version payload, not the opcode. - v1 OBP packets use the older `DMRD` envelope and HMAC over the 53-byte packet body; a v5-configured receiver refuses them before validating/routing as v1. ### Inferred Invariants - Invalid or rejected `BCVE` messages do not mutate the outbound protocol version. - v4 inbound packets can currently route to HBP using the v4 metadata layout, but this is characterization/deprecation context. - v1 packets on a v5-configured link produce a `BCVE` response and do not route to HBP targets. - v1 remains supported where a system is intentionally operating as an OBP bridge instance. - Tests must distinguish bridge-control negotiation from per-packet metadata parsing. - Recorded fixture replay is transport simulation only; fixture loading must not rewrite protocol fields. - Log assertions should supplement packet assertions and should not become the only evidence of routing behavior. ### Resolution - Added a version parameter to the UDP FBP packet builder and fake FBP send helpers. - Added invalid `BCVE` generation to the fake FBP peer model. - Added opt-in UDP tests for BCVE downgrade rejection, unsupported BCVE rejection, invalid BCVE rejection and inbound v4 packet routing. - Added signed v1 OBP packet generation and an opt-in UDP test that verifies v1 traffic on a v5-configured link is rejected with BCVE and does not leak to HBP. - Added a UDP test documenting current v4 downgrade to the older outbound layout as characterization/deprecation context. - Added expected-failure UDP tests for unsupported embedded FBP packet version rejection and configured-v4 version-byte consistency. - Added recorded packet fixture replay support and a UDP fixture replay test. - Added burst-loss and duplicate-UDP profile coverage for HBP streams. - Added subprocess log capture and log assertions for malformed short FBP packets and bad FBP hashes. ## 2026-05-23 - `bridge.py` Backport Scope ### Findings - `bridge.py` carries the older conference-bridge voice path and does not implement `bridge_master.py` dial-a-TG, data-gateway, generated-prompt or configuration-option handling. - The shared HBP/OBP group voice packet-control path in `bridge.py` still used simple integer comparisons for the one-byte DMRD sequence value, so valid modulo-256 forward progress after wrap could be rejected as out-of-order. - `bridge.py` HBP slot state retained `lastSeq` and `lastData` across new streams on the same slot. - `bridge.py` OBP terminator handling only marked `_fin` when live reporting was enabled, so late same-stream packets could avoid the finished-stream guard when reports were disabled. - `bridge.py` HBP terminator handling logged/report-ended streams but did not have the finished-stream suppression already added to `bridge_master.py`. ### Assumptions - Only behavior already present in `bridge.py` should be corrected: group voice stream routing, sequence tracking and stream lifecycle. - `bridge.py` should not gain dial-a-TG, group data, DATA-GATEWAY, prompt, ident, static-TG, default-reflector or broader FBP negotiation features as part of this backport. - The same modulo-256 sequence policy used by `bridge_master.py` applies to `bridge.py` HBP and OBP voice packets because both receive the same DMRD sequence byte. ### Unresolved Questions - A dedicated `bridge.py` runtime harness could be added later if bridge instances need the same UDP-level coverage as `bridge_master.py`. - `bridge.py` still has older reporting and bridge-rule behavior that was not reviewed in this pass. ### Protocol-Sensitive Areas - DMRD sequence numbers are one byte and wrap at 255 to 0. - A modulo delta greater than 127 is treated as old/out-of-order rather than forward progress; this preserves the loop-control safety posture discussed for `bridge_master.py`. - Voice terminator state must suppress late same-stream packets without overriding loop-control or adding a jitter buffer. ### Inferred Invariants - A new HBP stream must start with fresh duplicate/sequence state. - OBP finished-stream suppression must not depend on whether the live report socket is enabled. - `bridge.py` backports should stay limited to bug fixes for behavior the file already implements. ### Resolution - Added `dmrd_seq_delta()` to `bridge.py` and used it for existing HBP/OBP duplicate, out-of-order and missed-packet checks. - Reset HBP duplicate/sequence state on new streams and after voice terminators. - Marked OBP streams finished on terminator regardless of reporting state and reset per-stream sequence tracking. - Added HBP finished-stream state to suppress late same-stream packets after a terminator. - Added a lightweight `tests/test_bridge_backports.py` check for the shared modulo helper and documented the narrower bridge backport coverage. ## 2026-05-23 - `API.py` Initial Review ### Findings - `API.py`, `FD_APIUserDefinedContext.validateKey()`, writes `dmrid` and every `peerid` to stdout with `print()`. That bypasses FreeDMR logging and can leak API authentication activity into daemon stdout or supervisor logs. - `API.py`, `FD_APIUserDefinedContext.getoptions()`, reads `CONFIG['SYSTEMS'][system]['OPTIONS']` directly. `hblink.py` deliberately deletes that key when an HBP session disconnects and no default options are configured, so an authenticated API `getoptions()` can raise `KeyError` instead of returning a stable "no current options" value. - The former `API.py`, `FD_API.getconfig()` and `FD_API.getbridges()`, declared `_returns=Unicode()` but returned the live `CONFIG` and `BRIDGES` dictionaries. Those dictionaries contain bytes and nested structures, so the declared Spyne return type did not match the actual value. - `bridge_master.py`, `kill_server()`, reads `CONFIG['GLOBAL']['_KILL_SERVER']` directly, but the key is only set by the signal handler or `API.py` `killserver()`. No startup default is visible in `config.py`, so the timed kill-server loop can raise `KeyError` before any API or signal sets the flag. - The former `bridge_master.py`, `config_API()`, accepted `_config` but installed `FD_APIUserDefinedContext(CONFIG, _bridges)` using the module global. In normal runtime this was probably the same object, but the function ignored its parameter and was harder to test in isolation. - The former `api_client.py` used Twisted XML-RPC against port 7080, while `bridge_master.py` started the Spyne HTTP/JSON API on TCP port 8000. The sample client did not match the API server configured by FreeDMR. ### Assumptions - The API is an optional management surface and should not affect packet routing unless an authenticated method mutates live session/config state. - User-level API auth is intended to use the options `KEY` associated with the connected HBP peer/repeater. - Although legacy HBlink can host multiple peers on one master, FreeDMR's intended deployment model is one HBP peer per master. Config defaults and samples set `MAX_PEERS: 1`, and the proxy maps each external peer ID to its own backend destination port/master instance. - System-level API auth is intended to use `GLOBAL.SYSTEM_API_KEY`, loaded or generated at startup. - API `getoptions()` should be safe to call even when a peer has disconnected or has no current session options. ### Unresolved Questions - If config/bridge inspection is reintroduced, it should use bounded, JSON-safe snapshots or a separate worker path so it cannot delay voice processing in the reactor. ### Protocol-Sensitive Areas - API-provided `OPTIONS` feeds the same parser as HBP `RPTO` options and can change dial-a-TG, static TG, timer, voice-ident and announcement-language behavior. - API reset toggles `_reset`, which is consumed by `bridge_reset()` and packet admission guards. - API killserver toggles `_KILL_SERVER`, which is consumed by the Twisted timed shutdown loop. ### Inferred Invariants - API methods should not throw internal exceptions for normal disconnected or no-options session states. - API authentication details should use FreeDMR logging, not raw stdout. - Runtime control flags should have false defaults before timed loops read them. - Public API response declarations should match the objects returned. ### User-Confirmed API Semantics - `getoptions()` should return a clear response when no live options are available. - API `setoptions()` should receive the full `OPTIONS` string; the API should not silently add or preserve `KEY=...`. - User-level `reset(dmrid, key)` was intended to act on the matching HBP session associated with the supplied DMR ID. Because FreeDMR expects one peer per master, the current system-level `_reset` action may be the correct implementation once `validateKey()` has proven the peer belongs to that master. - User confirmed nobody is likely using the experimental Spyne API and replacing it now is preferred because Spyne dependency handling is awkward. - The API must not delay live voice processing; request handlers should avoid blocking work and expensive live-state serialization. ### Resolution - Replaced the Spyne API layer with a small Twisted HTTP/JSON resource in `API.py`. - Removed Spyne imports from `bridge_master.py` and removed Spyne from `requirements.txt`. - Kept API operations to small in-memory control-plane mutations: version, health, reset, options get/set, system kill and resetall. - Removed live `getconfig()`/`getbridges()` API endpoints to avoid potentially expensive serialization in the voice process reactor. - Added a small request-body limit so API calls cannot submit large JSON bodies that would delay the reactor. - Added a safe `_KILL_SERVER` default and changed the shutdown loop to read the flag with `.get()`. - Updated the sample API client to use HTTP/JSON on port 8000. ## 2026-05-23 - Support Module Review, Batch 1 ### Findings - `AMI.py` appears to be live when `ALLSTAR.ENABLED` is true: `bridge_master.py` creates `AMIOBJ` and dial-a-TG AllStar control calls `AMIOBJ.send_command()`. - `bridge_master.py` logs the configured AllStar password when setting up AMI. If AllStar is current, this is a credential exposure bug. - `AMI.py`, `AMIClient.lineReceived()`, prints every AMI response line directly to stdout instead of using the FreeDMR logger. This can leak operational details and makes service logging inconsistent. - `AMI.py` stores command and credentials on the nested `AMIClient` class object before connecting. Closely spaced `send_command()` calls could overwrite each other's command state before the TCP client sends it. - `AMI.py`, `AMI.closeConnection()`, references `self.transport`, but the outer `AMI` object is not the protocol instance and does not own that attribute. - `utils.py`, `try_download()`, disables TLS certificate verification for alias downloads with `ssl._create_unverified_context()`. This trades compatibility for integrity risk on configured HTTPS alias/checksum URLs. - `const.py` defines `ID_MAX = 16776415`, while `bridge_master.py` now uses `DMR_ID_MAX = 16777215` for dial-a-TG validation. Because `config.py` uses `const.ID_MAX` for ACL building, max-ID handling may be inconsistent. - `read_ambe.py`, `readAMBE.readfiles()`, returns `False` on missing voice pack files, but startup later iterates `words.keys()`. A missing configured audio language can fail startup with a secondary attribute error rather than a clear "missing voice pack" error. - `mk_voice.py`, `pkt_gen()`, still uses a random stream ID for generated voice prompts. Collision probability is low, but generated prompts share the live stream namespace. ### Assumptions - AllStar/AMI support is optional but still intended to work when enabled. - Alias downloads are operational convenience data, but stale or modified alias data can affect dashboard/reporting clarity and possibly server ID metadata. - Audio packs may be provided by deployment packaging even when not obvious from minimal test fixtures. ### Unresolved Questions - Was disabled TLS verification for alias downloads intentional because of old CA or embedded-platform compatibility problems? - Is `const.ID_MAX = 16776415` intentional, or should shared ACL validation use the DMR 24-bit maximum `16777215`? - Should missing configured voice prompt languages be a clear fatal startup error, or should FreeDMR fall back to a known shipped language? ### User-Confirmed Status - AllStar/AMI support is current enough to tidy up. ### Protocol-Sensitive Areas - AllStar control is driven from dial-a-TG private-call handling and must not block or delay voice packet processing. - Alias and server ID files are read at startup and may influence reporting, validation and operational visibility. - DMR ID and TG range constants must match the 24-bit field constraints unless a narrower range is deliberately reserved by FreeDMR policy. - Voice prompt packet generation shares packet timing, TG/slot routing and stream lifecycle semantics with live DMRD traffic. ### Inferred Invariants - Runtime logs must not expose configured passphrases or management credentials. - Optional external control paths should use FreeDMR logging and reactor-safe Twisted patterns. - Startup configuration failures should be explicit enough for sysops to fix without packet-level debugging. ## 2026-05-23 - Auxiliary Script Review, Batch 2 ### Findings - `app_template.py` and `blank_app.py` compile and look like HBlink example scaffolding rather than current FreeDMR packet-routing services. - `bridge_all.py` and `bridge_all_master.py` compile, but their ACL checks use `TG1_ACL` and `TG2_ACL`. Current config parsing creates `TGID_TS1_ACL` and `TGID_TS2_ACL`, so these scripts appear stale if they are still run. - `bridge_all.py` and `bridge_all_master.py` use the older sequence-loss check that was already noted as rollover-sensitive in code comments. - `report_receiver.py` and `report_sql.py` are external reporting clients and use `pickle.loads()` on reporting socket payloads. This is acceptable only if the report source is trusted and local/private. - `report_sql.py` inserts report events by formatting SQL strings directly. Event fields originate from the reporting socket and should be parameterized if this client is current. - `playback.py`, `playback_file.py` and `play_ambe.py` compile, but they use blocking `sleep()` calls in Twisted callbacks or packet receive paths. These are suitable for lab/playback utilities, not live voice-process services. - `docker-configs/supervisord.conf` starts `playback.py` as a supervised process by default in the proxy image, so the blocking-playback concern may matter in packaged deployments if that program is not intentionally enabled. - `hotspot_proxy_v2.py` is current enough to matter: `hblink.py` has explicit `PRIN` and `PRBL` handling and sample HBP config enables `PROXY_CONTROL`. - `hotspot_proxy_v2.py` installs a SIGTERM handler named `sigt()` that only prints `oooh` and does not stop the reactor. Under supervisor/container shutdown, the proxy may not terminate cleanly on SIGTERM. - `hotspot_proxy_v2.py` falls back to an internal default configuration if any required `[PROXY]` option is missing or invalid. That can start a proxy on default ports after a config typo instead of failing closed. - `hdstack/hotspot_proxy_v2.py` is a separate older proxy copy with materially different behavior and one visible typo path (`_data` in DMRA handling), but it may be legacy or a special HDStack deployment copy. - Several Dockerfiles still build from GitHub while `Dockerfile-ci` copies the local tree. Some images therefore may not include local checkout changes when built from this repository directory. - `docker-configs/Dockerfile-hbmonv2` creates user `hbmon` but runs `chown -R radio: /opt/HBMonv2`; if this Dockerfile is current, that user name mismatch can fail image build. ### Assumptions - The auxiliary HBlink example scripts are less important than `bridge_master.py`, `hblink.py`, `bridge.py`, `config.py`, proxy and Docker packaging. - The reporting socket is normally intended for trusted dashboards/clients, but it may still be exposed on configured TCP interfaces. - The top-level `hotspot_proxy_v2.py` is the main packaged proxy; the `hdstack` copy may exist for a special multi-instance deployment. ### Unresolved Questions - Should Dockerfiles that clone from GitHub be kept, or should maintained Docker builds copy the local source tree like `Dockerfile-ci`? ### User-Confirmed Status - `bridge_all.py` and `bridge_all_master.py` are legacy/experimental; leave them for now. - `playback.py`, `playback_file.py` and `play_ambe.py` are a combination of lab tools, experimental code and legacy code; they are low priority. - `report_receiver.py` and `report_sql.py` are lightly used/current: they feed `https://freedmr-lh.gb7fr.org.uk/` in one deployment. - `hdstack/hotspot_proxy_v2.py` is experimental capacity work; leave detailed review for later. ### Follow-Up Findings For Current Reporting Clients - `report_sql.py`, `send_mysql()`, performs reconnect attempts and MySQL writes directly in the Twisted reactor path. If the database stalls or reconnects slowly, the reporting client can stop processing its TCP report stream. - `report_sql.py`, `send_mysql()`, uses string formatting to build the `insert into feed` SQL statement. Even on a trusted report stream, quotes or commas in fields can break inserts; parameterized SQL would be safer and simpler. - `report_sql.py`, `send_mysql()`, closes the cursor only on error, not after successful inserts. - `report_sql.py`, `reportClientFactory.buildProtocol()`, returns `self.proto(db, reactor)` using module globals rather than `self.db` and `self.reactor`. It works in the current `__main__` path but makes the factory unnecessarily fragile. - `report_receiver.py` CLI flags such as `--events 0`, `--config 0` and `--bridges 0` are parsed as strings, so `"0"` is truthy and enables the output. These should be real booleans or checked against `"1"`. - Both report clients use `pickle.loads()` for config/bridge snapshots. User has confirmed current use is a known deployment, so this should be documented as a trusted-report-socket assumption rather than changed blindly. ### Protocol-Sensitive Areas - Proxy session mapping is part of the intended one-HBP-peer-per-master architecture and must preserve HBP registration/control packet ordering. - Proxy `PRIN` and `PRBL` packets affect client IP awareness and dynamic firewall/source-quench behavior. - Playback utilities synthesize or replay DMRD voice frames and can disturb timing assumptions if run in the same reactor as live services. - Reporting clients consume the same live-report stream used by dashboards; field shape changes can break external consumers. ### Inferred Invariants - Long-running daemons under Docker/supervisor should exit cleanly on SIGTERM. - Packaged default process lists should avoid optional lab tools unless the deployment intentionally enables them. - Config parse failures for network-facing daemons should fail closed rather than silently opening default listeners. ## 2026-05-23 - Packaging, Config and Documentation Review, Batch 3 ### Findings - `rules_SAMPLE.py` is an empty static-routing skeleton and compiles. - `systemd-scripts/freedmrrepeater.service` appears stale: it uses `/opt/FreeDMR` and `./config/hblink.cfg`, while current sample configs and Docker entrypoints run from `/opt/freedmr` with `freedmr.cfg`. - `pyvenv.cfg` is tracked at repository root. This makes the source checkout look like a Python virtual environment and can confuse tooling or developers. - Current FreeDMR/Docker config samples mostly use current ACL names `TGID_TS1_ACL` and `TGID_TS2_ACL`. - `FreeDMR-SAMPLE.cfg` and `FreeDMR-SAMPLE-commented.cfg` still show `PROTO_VER: 2`, while Docker config documents `PROTO_VER: 5` for FreeDMR FBP and `PROTO_VER: 1` for OBP/external software. - `hblink-SAMPLE.cfg` is intentionally HBlink-flavored and lacks current FreeDMR-only defaults such as API, dial-a-TG option defaults and proxy control. - `hdstack/*.cfg`, `loro.cfg` and `playback_file.cfg` appear special-purpose configs rather than main FreeDMR defaults. - `docker-configs/Dockerfile-noproxy`, `Dockerfile-proxy` and `Dockerfile-hdstack` clone from remote GitHub rather than copying the local tree, so building them from this checkout may not include current local changes. `Dockerfile-ci` does copy the local source tree. - `docker-configs/entrypoint-proxy` accepts `BRIDGE_SERVER=1` and runs `bridge.py -c freedmr.cfg -r rules.py`; `bridge.py` supports `-r`, so this entrypoint matches the maintained bridge path. - `docs/api.md`, `docs/testing.md` and `docs/test-harness-design.md` reflect the new HTTP/JSON API and current deterministic/UDP harness split. ### Assumptions - Docker is the preferred general deployment path. - `Dockerfile-ci` is the maintained local-build path for this working tree. - `hblink-SAMPLE.cfg` remains useful as an upstream-style reference and should not be forced into FreeDMR master semantics unless the project wants that. ### Unresolved Questions - Should the non-CI Dockerfiles be kept as remote-clone recipes, or updated to copy local source for reviewable builds? - Should `FreeDMR-SAMPLE*.cfg` be updated from `PROTO_VER: 2` to the current documented FreeDMR default of `5`, while retaining notes that OBP/external bridges use `1`? ### User-Confirmed Status - `report_receiver.py` and `report_sql.py` are kind-of current: they are used in one deployment to feed `https://freedmr-lh.gb7fr.org.uk/`. - `hdstack/hotspot_proxy_v2.py` is experimental capacity work; defer until the capacity design is discussed. - Disabled TLS verification in alias downloads is probably intentional; leave behavior unchanged for now. - Docker containers are the recommended deployment path because they reduce support overhead; the systemd unit may exist but is not the main focus. - Root `pyvenv.cfg` probably should not be tracked. ### Protocol-Sensitive Areas - `PROTO_VER` controls OBP/FBP packet option ordering and available fields; test fixtures must match the negotiated/configured protocol version. - The service manager and container entrypoints define which components are actually live in packaged deployments, including proxy and optional playback. ### Inferred Invariants - Maintained sample configs should not encourage obsolete FBP protocol versions unless backward compatibility is being intentionally demonstrated. - Runtime packaging should start the same code and config layout that users are expected to operate. ### Resolution - Tidied current AllStar/AMI support without changing dial-a-TG packet logic: `bridge_master.py` now redacts the configured AMI password in startup logs, `AMI.py` logs AMI responses through the module logger instead of printing raw lines, and AMI command/auth state is held on protocol instances rather than shared on the protocol class. - Changed `AMI.closeConnection()` to disconnect the Twisted connector it owns instead of referencing a nonexistent outer `transport` attribute. - Updated `report_sql.py` so report events schedule database writes with `reactor.callInThread()`, use a lock around the shared DB connection, execute the existing feed insert with parameterized SQL, close cursors in `finally`, and build clients with the factory's `self.db` / `self.reactor`. - Updated `report_receiver.py` so CLI flags such as `--events 0`, `--config 0`, `--bridges 0`, and `--stats 0` parse as false. - Removed tracked root `pyvenv.cfg`. - Added `tests/test_auxiliary_tools.py` for AMI protocol state, report receiver flag parsing and report SQL parameterized insert behavior. - Updated `docs/testing.md` with the auxiliary utility coverage. ## 2026-05-23 - Proxy And Shared Constant Review ### Findings - `hotspot_proxy_v2.py` parsed environment booleans with Python `bool()`. Because `bool("0")` is true, Docker settings such as `FDPROXY_IPV6=0` enabled the option instead of disabling it. - `hotspot_proxy_v2.py` installed a SIGTERM handler that only printed `oooh`. Under Docker/supervisor shutdown, the proxy could ignore the normal termination signal until forcibly stopped. - `hotspot_proxy_v2.py` falls back to an internal default config when `[PROXY]` is absent or invalid. This looked risky in isolation, but the current Docker config files do not include a `[PROXY]` section, so changing this would alter deployment behavior and needs a packaging decision. - `const.ID_MAX` remains `16776415` while other current code treats the DMR 24-bit all-call value `16777215` as the upper reserved boundary. The narrower shared ACL max may be intentional reservation or a historical typo; user was not sure, so it is left unchanged. ### Assumptions - The top-level `hotspot_proxy_v2.py` is the current packaged proxy. - Docker environment variables named with `=0` are intended to disable the feature. - The proxy's default internal configuration is currently part of Docker startup behavior when no `[PROXY]` section is supplied. ### Unresolved Questions - Should proxy config fallback remain the default Docker behavior, or should Docker configs grow an explicit `[PROXY]` section before the fallback is made stricter? - Is `const.ID_MAX = 16776415` a deliberate FreeDMR policy limit, or should ACL validation use the full 24-bit DMR maximum/reserved all-call boundary? ### Protocol-Sensitive Areas - Proxy `PRIN` and `PRBL` are part of client-source awareness and dynamic blocklisting; changes here affect HBP client admission and abuse handling. - ACL ID maxima affect which DMR IDs and TGs can be expressed in config, even when packet parsing itself can carry the full 24-bit field. ### Inferred Invariants - `FDPROXY_*` environment booleans should parse like config booleans, not Python truthiness of non-empty strings. - Containerized long-running daemons should handle SIGTERM as graceful shutdown. ### Resolution - Added `bool_from_env()` in `hotspot_proxy_v2.py` and applied it to `FDPROXY_IPV6`, `FDPROXY_STATS`, `FDPROXY_CLIENTINFO`, and the commented debug override path. - Changed the proxy SIGTERM handler to use the same graceful shutdown handler as SIGINT. - Added auxiliary test coverage for proxy environment boolean parsing.