You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
FreeDMR/docs/codex-notes.md

4281 lines
202 KiB

# 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.

Powered by TurnKey Linux.