Compare commits
3 Commits
21c8087281
...
c2c7e654cb
| Author | SHA1 | Date |
|---|---|---|
|
|
c2c7e654cb | 3 weeks ago |
|
|
d86623180a | 3 weeks ago |
|
|
562d86f949 | 3 weeks ago |
@ -1,23 +1,26 @@
|
|||||||
|
import json
|
||||||
from twisted.internet import reactor
|
import sys
|
||||||
from twisted.web.xmlrpc import Proxy
|
from urllib import request
|
||||||
|
|
||||||
|
|
||||||
def printValue(value):
|
def post(path, payload):
|
||||||
print(repr(value))
|
body = json.dumps(payload).encode("utf-8")
|
||||||
reactor.stop()
|
req = request.Request(
|
||||||
|
"http://127.0.0.1:8000" + path,
|
||||||
|
data=body,
|
||||||
def printError(error):
|
headers={"content-type": "application/json"},
|
||||||
print("error", error)
|
method="POST",
|
||||||
reactor.stop()
|
)
|
||||||
|
with request.urlopen(req, timeout=3) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
def capitalize(value):
|
|
||||||
print(value)
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 4:
|
||||||
proxy = Proxy(b"http://localhost:7080/xmlrpc")
|
print("usage: api_client.py <dmrid> <key> <options>")
|
||||||
# The callRemote method accepts a method name and an argument list.
|
raise SystemExit(2)
|
||||||
proxy.callRemote("FD_API.reset", '2', '55555').addCallbacks(capitalize, printError)
|
print(post("/api/v1/options/set", {
|
||||||
reactor.run()
|
"dmrid": int(sys.argv[1]),
|
||||||
|
"key": sys.argv[2],
|
||||||
|
"options": sys.argv[3],
|
||||||
|
}))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,113 @@
|
|||||||
|
# FreeDMR API
|
||||||
|
|
||||||
|
FreeDMR includes an experimental HTTP/JSON API for small live control-plane
|
||||||
|
actions. It is intended for local administration and automation, not for public
|
||||||
|
internet exposure.
|
||||||
|
|
||||||
|
Enable it with:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[GLOBAL]
|
||||||
|
ENABLE_API: True
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, the API listens on TCP port `8000`.
|
||||||
|
|
||||||
|
## Safety Notes
|
||||||
|
|
||||||
|
FreeDMR is a live voice routing process. API requests are deliberately limited
|
||||||
|
to small in-memory operations so they do not delay DMR voice packet handling.
|
||||||
|
Request bodies larger than 8192 bytes are rejected.
|
||||||
|
|
||||||
|
Bind or firewall port `8000` appropriately. Do not expose it publicly without a
|
||||||
|
trusted reverse proxy and access controls.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
User-level endpoints require:
|
||||||
|
|
||||||
|
- `dmrid`: the connected HBP peer/repeater DMR ID
|
||||||
|
- `key`: the session options key for that peer
|
||||||
|
|
||||||
|
System-level endpoints require:
|
||||||
|
|
||||||
|
- `systemkey`: the FreeDMR system API key
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/v1/version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/options/get \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"dmrid":1234567,"key":"secret"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
If no live options are present, the response is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok":true,"connected":true,"has_options":false,"options":""}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/options/set \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"dmrid":1234567,"key":"secret","options":"KEY=secret;TS1=91;DIAL=2350"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `options` value must be the complete FreeDMR `OPTIONS` string. The API does
|
||||||
|
not add or preserve `KEY=...` automatically.
|
||||||
|
|
||||||
|
### Reset Peer Session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/reset \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"dmrid":1234567,"key":"secret"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
FreeDMR expects one HBP peer per master instance, so this resets the master
|
||||||
|
instance that owns the authenticated peer session.
|
||||||
|
|
||||||
|
### Reset All Connections
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/system/resetall \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"systemkey":"system-secret"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop FreeDMR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/system/kill \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"systemkey":"system-secret"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responses
|
||||||
|
|
||||||
|
Successful responses include `"ok": true`. Failed responses include
|
||||||
|
`"ok": false` and an `error` string.
|
||||||
|
|
||||||
|
Common errors:
|
||||||
|
|
||||||
|
- `invalid_credentials`
|
||||||
|
- `invalid_json`
|
||||||
|
- `missing_options`
|
||||||
|
- `request_too_large`
|
||||||
|
- `not_found`
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,660 @@
|
|||||||
|
# FreeDMR 2.0 Architecture Decisions
|
||||||
|
|
||||||
|
This file records architectural decisions, requirements, assumptions and open
|
||||||
|
questions driven out during design discussion. It is intended as source material
|
||||||
|
for a later formal FreeDMR 2.0 design document.
|
||||||
|
|
||||||
|
## Project Philosophy
|
||||||
|
|
||||||
|
FreeDMR is open-source, open, intentionally understandable and intentionally
|
||||||
|
simple enough to encourage community implementation, experimentation and
|
||||||
|
operation by radio amateurs.
|
||||||
|
|
||||||
|
HBLink proved that a DMR server could be written in an open, readable way
|
||||||
|
without DMR being gatekept by commercial vendors. FreeDMR takes the next step:
|
||||||
|
it proves that a DMR network can be built this way without central control.
|
||||||
|
Before HBLink and FreeDMR, DMR server software and server-level network
|
||||||
|
membership were typically closed, gatekept or dependent on personal/team
|
||||||
|
approval. FreeDMR exists in part to lower that barrier and give radio amateurs
|
||||||
|
choice and freedom to experiment with global-scale ROIP networking.
|
||||||
|
FreeDMR does not need to gatekeep all private experimentation. The project
|
||||||
|
controls public listing: the process by which servers are shared with Pi-Star
|
||||||
|
and other HBP hotspots as legitimate public access servers. A sysop can run a
|
||||||
|
private server under their own DMR ID and arrange gatewaying with an existing
|
||||||
|
sysop, who effectively vouches for that traffic. Public listing has additional
|
||||||
|
requirements such as connectivity quality, sysop contactability and basic
|
||||||
|
operational expectations.
|
||||||
|
|
||||||
|
The FreeDMR mesh design is influenced by the late Bob Bruninga's APRS ideas,
|
||||||
|
Spanning Tree Protocol and related distributed-network approaches. The project
|
||||||
|
also has a social purpose: bringing together communities and people connected
|
||||||
|
to earlier amateur-radio networking work. FreeDMR is therefore both a technical
|
||||||
|
system and a diplomacy project; design choices must respect operational
|
||||||
|
autonomy, interoperability and trust between independent sysops.
|
||||||
|
|
||||||
|
FreeDMR is successful because it works in the amateur-radio sense: it is best
|
||||||
|
effort, experimental, approachable and deployable on ordinary low-cost systems
|
||||||
|
such as cheap VPS instances and Raspberry Pi-class hardware. It is not intended
|
||||||
|
to be a safety-assured commercial system. FreeDMR 2.0 should improve quality,
|
||||||
|
clarity and scalability without losing the ham-spirit/hacker-philosophy traits
|
||||||
|
that made the network useful and welcoming.
|
||||||
|
|
||||||
|
Design implications:
|
||||||
|
|
||||||
|
- Prefer clear, inspectable protocols over opaque mechanisms.
|
||||||
|
- Keep the implementation understandable by competent sysops and contributors.
|
||||||
|
- Keep the barrier to compatible implementations low where possible.
|
||||||
|
- Preserve low-cost deployment and modest hardware requirements.
|
||||||
|
- Avoid architectural choices that make FreeDMR dependent on heavyweight
|
||||||
|
infrastructure for ordinary single-server operation.
|
||||||
|
- Treat reliability as best-effort resilience appropriate to amateur radio, not
|
||||||
|
as commercial safety assurance.
|
||||||
|
- Preserve server autonomy and local policy.
|
||||||
|
- Avoid unnecessary central control.
|
||||||
|
- Distinguish private operation, vouched/gatewayed traffic and public listing.
|
||||||
|
- Security should protect authenticity and network integrity without hiding
|
||||||
|
amateur-radio traffic.
|
||||||
|
|
||||||
|
## Protected Model
|
||||||
|
|
||||||
|
The protected asset is the FreeDMR operating model, not the old HBLink-derived
|
||||||
|
object structure.
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
|
||||||
|
- packet model and protocol behaviour
|
||||||
|
- dial-a-TG semantics
|
||||||
|
- TG/DMR-ID centric routing
|
||||||
|
- loop control
|
||||||
|
- source quench
|
||||||
|
- mesh behaviour
|
||||||
|
- practical RF/network tolerance learned from live servers and real RF links
|
||||||
|
- "everything everywhere" principle, subject to documented exceptions
|
||||||
|
|
||||||
|
Replace or redesign where useful:
|
||||||
|
|
||||||
|
- configured `MASTER` stanza as primary runtime identity
|
||||||
|
- proxy-mediated client fan-out
|
||||||
|
- global mutable `BRIDGES` structure as authoritative state
|
||||||
|
- custom dashboard/reporting socket protocol
|
||||||
|
- packet-path coupling to dashboard/API/report consumers
|
||||||
|
|
||||||
|
## Layer Model
|
||||||
|
|
||||||
|
FreeDMR 2.0 should be described as layered:
|
||||||
|
|
||||||
|
- **Access layer**: client/server access protocols such as HBP today and
|
||||||
|
possible future non-trunk client protocols. Owns login/auth/options/keepalive,
|
||||||
|
client sessions, slot state and RF-facing TG presentation.
|
||||||
|
- **Subscription layer**: talkgroup conference membership. Owns direct TG
|
||||||
|
subscriptions, dial-a-TG subscriptions, static/default/user-activated
|
||||||
|
subscriptions, expiry and RF-visible TG to conference TG mapping.
|
||||||
|
- **Mesh layer**: inter-server FBP/OBP/trunk-style behaviour. Owns loop control,
|
||||||
|
source quench, hop/version handling and inter-server conference traffic.
|
||||||
|
- **Reporting layer**: local dashboard, API observers, logs, global lastheard
|
||||||
|
export and state snapshots. Reporting is observational and must not steer
|
||||||
|
packet handling.
|
||||||
|
|
||||||
|
## Reactor and Runtime Migration
|
||||||
|
|
||||||
|
Do not replace Twisted as part of the first FreeDMR 2.0 architecture work.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- Keep Twisted's single-threaded reactor as a safety boundary initially.
|
||||||
|
- Extract and test the protocol/routing/subscription core behind deterministic
|
||||||
|
interfaces.
|
||||||
|
- Introduce explicit process/message boundaries only after the state model is
|
||||||
|
clear.
|
||||||
|
- Consider asyncio or another event loop only once Twisted has become a thin
|
||||||
|
transport shell around tested core logic.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- The current packet behaviour is subtle and validated through real RF/network
|
||||||
|
deployment.
|
||||||
|
- Replacing the event loop while also replacing the state model would mix too
|
||||||
|
many sources of behavioural change.
|
||||||
|
- Twisted's single-threaded reactor helps preserve current ordering assumptions
|
||||||
|
while bridge/subscription and reporting boundaries are made explicit.
|
||||||
|
- The first migration target is architectural clarity and scalability, not event
|
||||||
|
loop novelty.
|
||||||
|
|
||||||
|
## Identity Model
|
||||||
|
|
||||||
|
The configured master/listener is not the client identity.
|
||||||
|
|
||||||
|
FreeDMR 2.0 should move toward:
|
||||||
|
|
||||||
|
- listener identity: UDP socket/service instance
|
||||||
|
- client identity: DMR peer/client ID
|
||||||
|
- subscription identity: client ID + slot + RF-visible TG + conference TG
|
||||||
|
- mesh identity: server/peer/network ID
|
||||||
|
|
||||||
|
Server identity hierarchy:
|
||||||
|
|
||||||
|
- FreeDMR server IDs are 4-digit DMR IDs.
|
||||||
|
- Server sub-IDs are 5-digit IDs derived from the server ID space.
|
||||||
|
- Each sysop/server identity may therefore cover up to 10 server sub-IDs for
|
||||||
|
backend components, larger deployments, failover or fault-tolerant layouts.
|
||||||
|
- Identity verification should cover the base server ID and its authorized
|
||||||
|
sub-IDs rather than requiring unrelated credentials for each sub-ID.
|
||||||
|
|
||||||
|
A single master/listener UDP port should serve an arbitrary number of clients
|
||||||
|
directly, replacing the proxy where possible.
|
||||||
|
|
||||||
|
## Talkgroup Subscription Model
|
||||||
|
|
||||||
|
Conceptually, each TG is a conference bridge. Clients subscribe to conference
|
||||||
|
TGs. FreeDMR does not primarily decide where to send user traffic; users choose
|
||||||
|
the traffic they want to hear by subscription.
|
||||||
|
|
||||||
|
Subscriptions can be:
|
||||||
|
|
||||||
|
- direct TG: RF-visible TG equals conference TG
|
||||||
|
- dial-a-TG: RF-visible TG is currently TG9, conference TG is the selected TG
|
||||||
|
- alias/rewrite: RF-visible TG may be any configured TG, conference TG is the
|
||||||
|
FreeDMR network identity
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
TalkgroupSubscription(
|
||||||
|
client_id=2345001,
|
||||||
|
slot=2,
|
||||||
|
conference_tg=4400,
|
||||||
|
rf_tg=9,
|
||||||
|
mode="dial",
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The invariant is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
conference_tg = FreeDMR network/conference identity
|
||||||
|
rf_tg = client-facing RF presentation identity
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes arbitrary TG rewrites possible without making TG9 structurally
|
||||||
|
special.
|
||||||
|
|
||||||
|
## Bridge Table Replacement
|
||||||
|
|
||||||
|
The legacy `BRIDGES` dict should be replaced internally by subscription-oriented
|
||||||
|
state and indexes. The `"#"` reflector naming convention does not need to be
|
||||||
|
preserved internally; it can be a compatibility/export detail.
|
||||||
|
|
||||||
|
Recommended hot-path structures:
|
||||||
|
|
||||||
|
- `dict` / `set` for O(1)-style local lookups
|
||||||
|
- `typing.NamedTuple` keys for readable hash keys
|
||||||
|
- `dataclass(slots=True)` records for mutable subscription/session state
|
||||||
|
- `heapq` for expiry timers using lazy invalidation
|
||||||
|
|
||||||
|
Recommended indexes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
subscriptions_by_conference_tg[conference_tg] -> set[SubscriptionKey]
|
||||||
|
subscription_by_rf[(client_id, slot, rf_tg)] -> SubscriptionKey
|
||||||
|
subscriptions_by_client_slot[(client_id, slot)] -> set[SubscriptionKey]
|
||||||
|
expiry_heap -> (expires_at, generation, SubscriptionKey)
|
||||||
|
```
|
||||||
|
|
||||||
|
Packet handlers should not scan all subscriptions/bridges to find routing
|
||||||
|
targets.
|
||||||
|
|
||||||
|
## Packet Plane vs Control Plane
|
||||||
|
|
||||||
|
The packet plane is delay-sensitive.
|
||||||
|
|
||||||
|
Packet-plane rules:
|
||||||
|
|
||||||
|
- local in-memory hot state only
|
||||||
|
- no external database round trips
|
||||||
|
- no blocking API/dashboard/report calls
|
||||||
|
- no cross-process lock waits
|
||||||
|
- no dependency on reporting consumers being connected
|
||||||
|
|
||||||
|
External stores may be used for:
|
||||||
|
|
||||||
|
- config distribution
|
||||||
|
- API/dashboard state
|
||||||
|
- control-plane coordination
|
||||||
|
- snapshots
|
||||||
|
- global lastheard export
|
||||||
|
- optional clustering/multi-process coordination
|
||||||
|
|
||||||
|
General performance principle:
|
||||||
|
|
||||||
|
- Expensive processing should be considered for offload to separate processes
|
||||||
|
because CPython execution is constrained by the GIL for CPU-bound Python code.
|
||||||
|
- Offload is appropriate for reporting fanout, global export, dashboard
|
||||||
|
aggregation, historical database writes, heavy analytics, expensive
|
||||||
|
transcoding/codec experiments and non-critical maintenance jobs.
|
||||||
|
- Offload boundaries must be asynchronous from the packet path. If an offload
|
||||||
|
worker is slow or unavailable, packet handling must continue with local state.
|
||||||
|
- Do not offload hot-path routing decisions if doing so would add inter-process,
|
||||||
|
network or lock waits to every packet.
|
||||||
|
|
||||||
|
## DMR Data Packet Policy
|
||||||
|
|
||||||
|
FreeDMR must maintain DMR data packet forwarding support.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- FreeDMR should forward supported DMR data packets according to the same
|
||||||
|
conference/subscription and mesh principles as other traffic.
|
||||||
|
- There must be no regression in existing data packet forwarding support.
|
||||||
|
- FreeDMR core should not become an application-level DMR data processor.
|
||||||
|
- GPS, SMS and similar application processing should be implemented by systems
|
||||||
|
connected via FBP or another mesh/access-adjacent interface.
|
||||||
|
- `DATA_GATEWAY` is understood as an earlier expression of this model: an FBP
|
||||||
|
link that carries data-oriented traffic rather than ordinary voice traffic.
|
||||||
|
- Existing `SUB_MAP` behaviour is intentional: data addressed to a DMR ID can be
|
||||||
|
routed toward the last known HBP/client location for that DMR ID.
|
||||||
|
|
||||||
|
Core FreeDMR may inspect/classify data packets only as needed for:
|
||||||
|
|
||||||
|
- packet admission and protocol validation
|
||||||
|
- routing/subscription decisions
|
||||||
|
- loop control and source quench
|
||||||
|
- reporting/logging
|
||||||
|
- preserving packet bytes and metadata across FBP/HBP boundaries
|
||||||
|
- maintaining the subscriber location map needed for data-client routing
|
||||||
|
|
||||||
|
Possible narrow exceptions:
|
||||||
|
|
||||||
|
- dial-a-TG control via DMR SMS
|
||||||
|
- DMR SMS alerts from a server to a sysop
|
||||||
|
|
||||||
|
Any such exceptions must be explicit control-plane features and must not turn
|
||||||
|
FreeDMR core into a general GPS/SMS application processor.
|
||||||
|
|
||||||
|
## Mesh Peer Authentication
|
||||||
|
|
||||||
|
FreeDMR should only accept mesh/FBP traffic from servers that can be validated
|
||||||
|
as legitimate members of the network.
|
||||||
|
|
||||||
|
Core principle:
|
||||||
|
|
||||||
|
- FreeDMR may sign/authenticate traffic and control messages, but should not
|
||||||
|
encrypt amateur-radio traffic or mesh traffic by default.
|
||||||
|
- Amateur radio is public in most jurisdictions and encryption is often not
|
||||||
|
permitted. FreeDMR users may also carry IP backhaul over amateur radio links.
|
||||||
|
- FreeDMR's security model is authenticity, integrity, membership validation and
|
||||||
|
local policy enforcement, not secrecy.
|
||||||
|
- This follows the existing FreeDMR principle, agreed historically by project
|
||||||
|
maintainers, that the network has nothing to hide and should remain cleartext.
|
||||||
|
|
||||||
|
Identity/listing distinction:
|
||||||
|
|
||||||
|
- Signed mesh identity should prove a server/sysop identity or a vouching
|
||||||
|
relationship. It should not automatically imply public listing.
|
||||||
|
- Public listing is a directory/discovery decision for clients and HBP hotspots.
|
||||||
|
- A public access server may need stronger operational requirements than a
|
||||||
|
private or gatewayed server.
|
||||||
|
- Local sysops may still choose whether to carry/vouch for traffic from private
|
||||||
|
servers, even when those servers are not publicly listed.
|
||||||
|
- If an individual 7-digit DMR ID is used as a server identity, traffic may pass
|
||||||
|
when a directly connected/listed sysop chooses to allow and gateway it.
|
||||||
|
- The vouching sysop is accountable to their peers for traffic they forward. If
|
||||||
|
that traffic harms the network, peers may choose to stop peering with the
|
||||||
|
vouching server. This preserves a self-policing social mechanism without
|
||||||
|
requiring central control for all private experimentation.
|
||||||
|
|
||||||
|
Analogue network bridges:
|
||||||
|
|
||||||
|
- Analogue ROIP/network bridges commonly connect as if they are DMR clients via
|
||||||
|
HBP.
|
||||||
|
- FreeDMR permits this and is generally more permissive than many other DMR
|
||||||
|
networks.
|
||||||
|
- FreeDMR works with/supports the DVSwitch community on this. DVSwitch provides
|
||||||
|
a common mechanism by which analogue networks can be bridged into DMR-style
|
||||||
|
access.
|
||||||
|
- These bridges are operationally sensitive: technical limitations can make
|
||||||
|
them effectively listen-only, consuming CPU and bandwidth while adding little
|
||||||
|
value if they do not contribute actual two-way user activity.
|
||||||
|
- Analogue bridges are often implemented using audio mixing/conference style
|
||||||
|
behaviour. This is a poor fit for DMR and similar digital modes, which enforce
|
||||||
|
one audio source at a time and rely on stream, hang-time and contention
|
||||||
|
behaviour rather than mixed audio.
|
||||||
|
- This mismatch comes partly from analogue repeater heritage: analogue systems
|
||||||
|
may maintain a continuous transmit carrier and mix notification sounds such as
|
||||||
|
pips, CWID and courtesy tones into the output audio. Analogue systems also
|
||||||
|
often have little or no strong source identity, whereas DMR traffic carries a
|
||||||
|
DMR ID.
|
||||||
|
- A common failure mode is that a feed from an analogue repeater keeps the DMR
|
||||||
|
stream open between analogue overs, plays courtesy/notification tones and then
|
||||||
|
carries the next analogue user in the same held stream. This can hold the TG
|
||||||
|
open and prevent a digital station from breaking in until the analogue
|
||||||
|
repeater times out and its carrier drops.
|
||||||
|
- Analogue bridges should therefore be subject to local sysop policy, public
|
||||||
|
listing expectations and peer accountability. Permitted does not mean
|
||||||
|
automatically valuable or immune from peering/listing consequences.
|
||||||
|
|
||||||
|
Other digital network bridges:
|
||||||
|
|
||||||
|
- Digital voice networks such as YSF and NXDN are generally a better technical
|
||||||
|
match for DMR than analogue networks because they also use AMBE-family vocoder
|
||||||
|
audio.
|
||||||
|
- AMBE-to-AMBE interworking can be lossless at the codec level and avoids
|
||||||
|
transcoding artifacts.
|
||||||
|
- Transcoding from analogue or unlike codecs can degrade audio quality
|
||||||
|
significantly and should be treated carefully.
|
||||||
|
|
||||||
|
Desired direction:
|
||||||
|
|
||||||
|
- Add PKI-backed mesh peer admission to the Bridge Control (`BCXX`) mechanism.
|
||||||
|
- A peer server presents public identity material signed by a FreeDMR network
|
||||||
|
master key or trusted network CA.
|
||||||
|
- The authenticated identity must bind at least:
|
||||||
|
- server ID
|
||||||
|
- authorized server sub-IDs
|
||||||
|
- public key
|
||||||
|
- validity period
|
||||||
|
- permitted protocol/features where useful
|
||||||
|
- Runtime admission should bind the authenticated server identity to the
|
||||||
|
observed transport endpoint, including IP address.
|
||||||
|
- If the observed IP address changes, the FBP peer must perform a new key
|
||||||
|
exchange/authentication step before its traffic is forwarded.
|
||||||
|
- Network membership should be represented by a signed sysop/server key that is
|
||||||
|
issued when the sysop/server joins the network and revoked when they leave or
|
||||||
|
are compromised. Runtime endpoint/session bindings are renewed separately and
|
||||||
|
do not require re-signing the long-lived membership key.
|
||||||
|
- One successful verification of the signed identity should authorize the
|
||||||
|
covered server ID and declared/authorized sub-IDs for that sysop, subject to
|
||||||
|
local policy and endpoint/session binding.
|
||||||
|
|
||||||
|
Packet-plane rule:
|
||||||
|
|
||||||
|
- Expensive signature/certificate validation happens during control-plane
|
||||||
|
admission or re-admission, not for every DMR packet.
|
||||||
|
- Per-packet mesh traffic should use a cached authenticated peer/session state
|
||||||
|
check keyed by server ID and endpoint.
|
||||||
|
|
||||||
|
Initial conceptual flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FBP peer connects/sends keepalive
|
||||||
|
-> BC auth exchange presents signed server identity/public key
|
||||||
|
-> FreeDMR validates signature against trusted network key
|
||||||
|
-> FreeDMR binds server_id + endpoint + protocol features to peer session
|
||||||
|
-> DMR traffic is accepted only while that authenticated binding is valid
|
||||||
|
```
|
||||||
|
|
||||||
|
Security requirements:
|
||||||
|
|
||||||
|
- Reject unauthenticated FBP traffic by default once this mode is enabled.
|
||||||
|
- Reject traffic where server ID, key identity and source endpoint do not match
|
||||||
|
the authenticated binding.
|
||||||
|
- Expire authenticated bindings and require renewal.
|
||||||
|
- Support soft renewal: when an authenticated binding reaches its renewal
|
||||||
|
timestamp, schedule asynchronous re-authentication while allowing a bounded
|
||||||
|
grace period so in-flight voice is not interrupted purely by renewal timing.
|
||||||
|
- Hard-stop forwarding only for explicit authentication failure, revoked
|
||||||
|
identity/key, endpoint mismatch outside policy, expired grace period, or
|
||||||
|
policy requiring immediate re-authentication.
|
||||||
|
- Log authentication failure reasons clearly without leaking private material.
|
||||||
|
- Provide a controlled transition mode for existing networks while PKI is rolled
|
||||||
|
out.
|
||||||
|
|
||||||
|
Open questions:
|
||||||
|
|
||||||
|
- Whether to use X.509 certificates, raw Ed25519 public keys with signed
|
||||||
|
metadata, or another compact identity format.
|
||||||
|
- How network master keys/CAs are generated, rotated and revoked.
|
||||||
|
- Whether peer authorization policy should live in config, MQTT/control-plane
|
||||||
|
state, or a signed network membership list.
|
||||||
|
- How to handle legitimate dynamic-IP servers without weakening endpoint
|
||||||
|
binding.
|
||||||
|
- What renewal and grace-period defaults best preserve voice continuity without
|
||||||
|
weakening mesh admission.
|
||||||
|
|
||||||
|
### Distributed Key Gossip Option
|
||||||
|
|
||||||
|
FreeDMR may also use a peer-to-peer signed-key dissemination mechanism over the
|
||||||
|
Bridge Control (`BCXX`) out-of-band channel.
|
||||||
|
|
||||||
|
Concept:
|
||||||
|
|
||||||
|
- Each server periodically advertises the signed server public keys/membership
|
||||||
|
documents it knows to its direct FBP peers.
|
||||||
|
- Peers validate the signatures and build a local table of legitimate server
|
||||||
|
identities as knowledge propagates through the mesh.
|
||||||
|
- Each server uses its local signed-key table and local policy to decide whether
|
||||||
|
to route or reject packets that originated from a given source server, even
|
||||||
|
when that source server is not directly connected.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- FreeDMR is a peer network, not hub-and-spoke or master/slave.
|
||||||
|
- Servers are autonomous and independently operated.
|
||||||
|
- Direct FBP peers should not be blindly trusted to make correct routing
|
||||||
|
decisions on behalf of the local server.
|
||||||
|
- Open-source, human-readable code deliberately lowers the barrier to
|
||||||
|
modification, so each server must be able to protect itself from incorrect or
|
||||||
|
malicious upstream forwarding decisions.
|
||||||
|
|
||||||
|
Security requirements for key gossip:
|
||||||
|
|
||||||
|
- Only signed membership documents are accepted; peers cannot create trust by
|
||||||
|
merely repeating a key.
|
||||||
|
- Membership documents need issuer, subject server ID, public key fingerprint,
|
||||||
|
authorized sub-IDs, validity period, serial/version and signature.
|
||||||
|
- Revocation data must propagate by the same or a stronger mechanism.
|
||||||
|
- Each server must enforce local policy after validation. A valid signed key
|
||||||
|
proves membership, not mandatory carriage.
|
||||||
|
- Key gossip must be rate-limited and bounded so it cannot become a BCXX flood
|
||||||
|
or memory-growth vector.
|
||||||
|
- Received membership data must be replay-resistant enough to handle expiry,
|
||||||
|
superseded serials and revoked keys.
|
||||||
|
- The packet path must use cached key/policy state; signature validation and
|
||||||
|
gossip processing are control-plane work.
|
||||||
|
|
||||||
|
This complements direct-peer endpoint authentication. Direct-peer auth proves
|
||||||
|
the connected FBP peer is legitimate for this session; distributed signed-key
|
||||||
|
knowledge lets the local server make autonomous decisions about traffic whose
|
||||||
|
source server is elsewhere in the mesh.
|
||||||
|
|
||||||
|
## Reporting Protocol Decision
|
||||||
|
|
||||||
|
FreeDMR 2.0 should define a structured reporting event protocol and use MQTT as
|
||||||
|
the preferred external live reporting transport.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- MQTT is already familiar in DMR network dashboard/reporting contexts.
|
||||||
|
- BrandMeister uses MQTT, providing a useful precedent for dashboard consumers.
|
||||||
|
- MQTT topics map naturally to server/client/subscription/call state.
|
||||||
|
- Retained messages are useful for current state snapshots.
|
||||||
|
- Last Will and Testament can represent server/reporting disconnects.
|
||||||
|
- MQTT-over-WebSocket allows browser dashboards to subscribe directly when the
|
||||||
|
broker supports it.
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- MQTT publishing must be asynchronous from the packet worker.
|
||||||
|
- Packet routing must continue if the MQTT broker/dashboard is down.
|
||||||
|
- Event generation must be state-change/summary oriented, not per DMR frame.
|
||||||
|
- The event schema is the compatibility contract; internal Python objects are
|
||||||
|
not.
|
||||||
|
- Local live dashboard and central global lastheard remain separate paths.
|
||||||
|
- Voice stability takes precedence over reporting completeness. If the system
|
||||||
|
must choose between dropping/reporting-losing events and delaying packet
|
||||||
|
handling, it must drop or coalesce reporting events.
|
||||||
|
|
||||||
|
Implementation requirement:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packet path -> non-blocking local event queue -> MQTT publisher worker
|
||||||
|
```
|
||||||
|
|
||||||
|
The packet path must not call an MQTT broker synchronously. The local event
|
||||||
|
queue should be bounded. On overflow, the publisher layer should drop or
|
||||||
|
coalesce low-priority events and emit a later reporting-health event rather than
|
||||||
|
blocking packet handling.
|
||||||
|
|
||||||
|
Suggested event priority:
|
||||||
|
|
||||||
|
- retain/coalesce latest state: server/client/slot/subscription state
|
||||||
|
- keep best effort: call start/end summaries
|
||||||
|
- drop first under pressure: high-volume debug/warning/statistical updates
|
||||||
|
|
||||||
|
MQTT publishing should support reconnect with exponential backoff and should
|
||||||
|
refresh retained state after reconnect so a dashboard can recover even if
|
||||||
|
transient events were missed.
|
||||||
|
|
||||||
|
Suggested MQTT namespace:
|
||||||
|
|
||||||
|
```text
|
||||||
|
freedmr/v2/{server_id}/state
|
||||||
|
freedmr/v2/{server_id}/client/{client_id}/state
|
||||||
|
freedmr/v2/{server_id}/client/{client_id}/slot/{slot}/activity
|
||||||
|
freedmr/v2/{server_id}/subscription/{subscription_id}/state
|
||||||
|
freedmr/v2/{server_id}/call/{stream_id}/start
|
||||||
|
freedmr/v2/{server_id}/call/{stream_id}/end
|
||||||
|
freedmr/v2/{server_id}/mesh/{peer_id}/state
|
||||||
|
freedmr/v2/{server_id}/event
|
||||||
|
```
|
||||||
|
|
||||||
|
Use retained messages for current state:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server state
|
||||||
|
client state
|
||||||
|
slot activity
|
||||||
|
subscription state
|
||||||
|
mesh peer state
|
||||||
|
```
|
||||||
|
|
||||||
|
Use non-retained messages for transient events:
|
||||||
|
|
||||||
|
```text
|
||||||
|
call start/end
|
||||||
|
loop-control event
|
||||||
|
source-quench event
|
||||||
|
packet-rate/loss summary
|
||||||
|
warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
Example event:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"event_id": 1849281,
|
||||||
|
"type": "call.started",
|
||||||
|
"timestamp": 1710000000.123,
|
||||||
|
"server_id": 234099,
|
||||||
|
"client_id": 2345001,
|
||||||
|
"slot": 2,
|
||||||
|
"conference_tg": 4400,
|
||||||
|
"rf_tg": 9,
|
||||||
|
"source_id": 2351234,
|
||||||
|
"stream_id": 16909060,
|
||||||
|
"access": "hbp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard delivery options:
|
||||||
|
|
||||||
|
- preferred: dashboard subscribes to MQTT over WebSocket
|
||||||
|
- alternative: local reporting sidecar translates MQTT to SSE/HTTP
|
||||||
|
- control actions should use authenticated HTTP APIs unless a future UI needs
|
||||||
|
bidirectional streaming
|
||||||
|
|
||||||
|
## Local Dashboard vs Global Lastheard
|
||||||
|
|
||||||
|
Each FreeDMR server has its own local live dashboard. The global lastheard
|
||||||
|
service is centrally hosted and non-real-time.
|
||||||
|
|
||||||
|
Local dashboard:
|
||||||
|
|
||||||
|
- consumes local MQTT live state/events
|
||||||
|
- displays current client/repeater traffic
|
||||||
|
- must tolerate reconnects and missed transient events by reloading retained
|
||||||
|
state topics
|
||||||
|
|
||||||
|
Global lastheard:
|
||||||
|
|
||||||
|
- consumes call summaries or batched exports
|
||||||
|
- should not depend on packet-plane or dashboard delivery
|
||||||
|
- should tolerate central outage via spool/retry
|
||||||
|
|
||||||
|
Possible MQTT global feed:
|
||||||
|
|
||||||
|
- Each server publishes local live dashboard topics to a local broker or local
|
||||||
|
reporting service.
|
||||||
|
- Prefer a separate exporter process for the curated global feed. The exporter
|
||||||
|
subscribes to the same local real-time MQTT feed as the dashboard, filters and
|
||||||
|
summarizes what is needed, then publishes to the network MQTT broker or writes
|
||||||
|
to the global collector.
|
||||||
|
- The exporter publishes only summary topics needed for the 30-day database,
|
||||||
|
such as call end summaries, client/server presence, selected mesh health and
|
||||||
|
selected subscription changes.
|
||||||
|
- Raw packet events and high-volume live slot updates should not be exported to
|
||||||
|
the global broker by default.
|
||||||
|
- Central broker, global dashboard or exporter failure must not back up into
|
||||||
|
local packet processing or local dashboard state.
|
||||||
|
|
||||||
|
Preferred flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FreeDMR core -> local MQTT feed -> local dashboard
|
||||||
|
-> global-exporter process -> network MQTT/collector
|
||||||
|
```
|
||||||
|
|
||||||
|
Core publishing invariant:
|
||||||
|
|
||||||
|
- FreeDMR core emits each reporting event once to its configured local MQTT
|
||||||
|
broker/publisher queue.
|
||||||
|
- Fanout to dashboards, exporters, automation and global collectors is handled
|
||||||
|
by the MQTT broker and separate subscriber processes.
|
||||||
|
- Adding more reporting consumers must not increase FreeDMR packet-process work
|
||||||
|
beyond the single local event emission.
|
||||||
|
|
||||||
|
Suggested global MQTT subjects:
|
||||||
|
|
||||||
|
```text
|
||||||
|
freedmr/v2/global/{server_id}/call/end
|
||||||
|
freedmr/v2/global/{server_id}/client/state
|
||||||
|
freedmr/v2/global/{server_id}/server/state
|
||||||
|
freedmr/v2/global/{server_id}/mesh/state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reporting Event Types
|
||||||
|
|
||||||
|
Initial event families:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server.started
|
||||||
|
server.stopping
|
||||||
|
client.connected
|
||||||
|
client.disconnected
|
||||||
|
client.options_changed
|
||||||
|
subscription.activated
|
||||||
|
subscription.deactivated
|
||||||
|
subscription.expired
|
||||||
|
call.started
|
||||||
|
call.ended
|
||||||
|
call.lost
|
||||||
|
mesh.peer_up
|
||||||
|
mesh.peer_down
|
||||||
|
mesh.source_quench
|
||||||
|
loop.detected
|
||||||
|
packet.rate_limited
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Which MQTT broker should be packaged by default: Mosquitto, EMQX, NATS MQTT
|
||||||
|
compatibility, or another option?
|
||||||
|
- Should MQTT be mandatory for FreeDMR 2.0 dashboards, or optional with an
|
||||||
|
embedded/local fallback?
|
||||||
|
- What authentication/authorization model should protect MQTT topics and
|
||||||
|
dashboard control APIs?
|
||||||
|
- What retained-topic expiry policy should be used to prevent stale state?
|
||||||
|
- Should global lastheard consume MQTT directly or use a separate HTTP/queue
|
||||||
|
exporter fed from reporting events?
|
||||||
|
- Should FreeDMR expose a legacy `BRIDGES` compatibility view during migration?
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
# FreeDMR 2 Glossary
|
||||||
|
|
||||||
|
This glossary defines FreeDMR 2 terms. Legacy terms such as `SYSTEM`, `MASTER`, `BRIDGES`, and `#` reflector names describe current implementation details or compatibility views, not the primary FreeDMR 2 model.
|
||||||
|
|
||||||
|
**Access layer**: The part of FreeDMR that accepts client/repeater protocols such as HBP. It owns login, authentication, options, keepalive, access sessions, RF-facing slots, and RF-visible TG presentation.
|
||||||
|
|
||||||
|
**Access session**: A live connection-like relationship between FreeDMR and a client/repeater. It is identified by client DMR ID and transport/session metadata, not by a configured listener stanza.
|
||||||
|
|
||||||
|
**Listener**: A UDP socket/service endpoint that accepts one or more access sessions. A configured `MASTER` is a legacy listener-like concept, not the client identity.
|
||||||
|
|
||||||
|
**Client/repeater**: An HBP hotspot, repeater, gateway, bridge, or future access-side system connected to FreeDMR.
|
||||||
|
|
||||||
|
**Client DMR ID**: The DMR ID used by a client/repeater access session. Future routing should key primarily on client DMR ID, slot, and RF-visible TG.
|
||||||
|
|
||||||
|
**RF-visible TG**: The talkgroup number seen by the RF terminal or access-side device. In dial-a-TG this may be TG9 while the network conference TG is different.
|
||||||
|
|
||||||
|
**Conference TG**: The FreeDMR network talkgroup identity. Conceptually this is the conference bridge to which clients subscribe.
|
||||||
|
|
||||||
|
**Subscription**: Membership of a client slot/RF TG presentation in a conference TG.
|
||||||
|
|
||||||
|
**Static subscription**: A subscription created by configuration or policy and normally present for the session.
|
||||||
|
|
||||||
|
**Dial-a-TG subscription**: A subscription created by dial-a-TG control. It maps an RF-visible TG, traditionally TG9, to a selected conference TG.
|
||||||
|
|
||||||
|
**Default reflector**: A configured or client-requested default dial-a-TG style subscription for a session. Empty string, integer 0, or boolean false means no default reflector.
|
||||||
|
|
||||||
|
**Stream**: A voice call flow identified by stream metadata. AMBE voice is a stream; DMR data packets are packet-oriented and should not be treated as AMBE streams.
|
||||||
|
|
||||||
|
**Source server**: The server that originated or advertised the packet into the mesh, according to the FBP/OBP protocol version in use.
|
||||||
|
|
||||||
|
**Source repeater**: The access-side repeater/client identity carried when the protocol version supports it.
|
||||||
|
|
||||||
|
**Mesh peer**: Another FreeDMR/OpenBridge/FBP peer server connected through the mesh layer.
|
||||||
|
|
||||||
|
**Server ID**: A FreeDMR server identity. Server IDs are treated separately from client DMR IDs.
|
||||||
|
|
||||||
|
**Server sub-ID**: A subordinate server identity authorized under a server/sysop identity, for example backend or fault-tolerant deployments.
|
||||||
|
|
||||||
|
**Bridge-control message**: An out-of-band FBP/OBP control message, such as source quench or STUN.
|
||||||
|
|
||||||
|
**Packet plane**: The delay-sensitive path that receives, parses, routes, mutates where necessary, and sends DMR packets.
|
||||||
|
|
||||||
|
**Control plane**: Authenticated configuration, API, bridge-control, admission, and policy operations.
|
||||||
|
|
||||||
|
**Reporting plane**: Observational events and state export for dashboards, logs, lastheard, monitoring, and operators.
|
||||||
|
|
||||||
|
**Compatibility/export state**: A derived view that presents old FreeDMR/HBLink/dashboard shapes to consumers. It is not authoritative state.
|
||||||
|
|
||||||
|
**HBP**: Homebrew Protocol, used by many hotspots/repeaters on the access side.
|
||||||
|
|
||||||
|
**OBP**: OpenBridge Protocol version 1. It remains important as an open interop path where intentionally configured.
|
||||||
|
|
||||||
|
**FBP**: FreeDMR Bridge Protocol. Any OpenBridge-derived peer protocol version higher than 1 is termed FBP for FreeDMR clarity.
|
||||||
|
|
||||||
|
**DATA-GATEWAY**: A historical/early expression of a data-oriented FBP link. FreeDMR 2 should preserve data forwarding without making the core a GPS/SMS application processor.
|
||||||
|
|
||||||
|
**Source quench / BCSQ**: A bridge-control hint asking a peer to suppress a stream/TG toward us. It is optional and per stream/TG.
|
||||||
|
|
||||||
|
**STUN / BCST**: A broader bridge-control gate intended to stop all FBP traffic from a peer under the current conceptual model.
|
||||||
|
|
||||||
|
**OVCM**: Open Voice Channel Mode. ETSI service option bit 0x04 when explicitly used. HBLink legacy 0x20 is compatibility history, not standards-clean OVCM.
|
||||||
|
|
||||||
|
**Synthetic LC**: A generated Link Control value used when FreeDMR has to create fallback LC information.
|
||||||
|
|
||||||
|
**Real inbound LC**: LC decoded from received traffic. It should be preserved unchanged unless FreeDMR deliberately rewrites it.
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
# FreeDMR 2 System Model
|
||||||
|
|
||||||
|
FreeDMR 2 is a layered system. The layers are design boundaries, not necessarily separate processes at first.
|
||||||
|
|
||||||
|
## Access Layer
|
||||||
|
|
||||||
|
The access layer owns HBP and future client/repeater protocols. It handles login, authentication, options, keepalive, access sessions, RF-facing slot state, and RF-visible TG presentation.
|
||||||
|
|
||||||
|
A configured listener is not the client identity. A single listener should eventually support multiple clients directly, replacing proxy-mediated fan-out where possible.
|
||||||
|
|
||||||
|
## Subscription Layer
|
||||||
|
|
||||||
|
The subscription layer owns talkgroup conference membership. It handles direct TG subscriptions, dial-a-TG subscriptions, static subscriptions, default reflectors, user/API/SMS activated subscriptions, expiry, and RF-visible TG to conference TG mapping.
|
||||||
|
|
||||||
|
Packet routing should consume subscription state. It should not need to know whether a subscription came from static config, dial-a-TG, API, SMS, or a future UI.
|
||||||
|
|
||||||
|
## Mesh Layer
|
||||||
|
|
||||||
|
The mesh layer owns FBP/OBP/trunk-style inter-server traffic. It handles loop control, source quench, hop/version handling, bridge control, source server/repeater metadata, and conference traffic between servers.
|
||||||
|
|
||||||
|
FreeDMR remains a peer network, not hub-and-spoke. Local sysops retain local routing and policy autonomy.
|
||||||
|
|
||||||
|
## Packet/Stream Layer
|
||||||
|
|
||||||
|
The packet/stream layer owns packet parsing, stream lifecycle, sequence handling, terminators, LC/embedded LC handling, data-vs-voice classification, and packet mutation boundaries.
|
||||||
|
|
||||||
|
Raw packet bytes are immutable input until an explicit named rewrite operation occurs.
|
||||||
|
|
||||||
|
## Reporting Layer
|
||||||
|
|
||||||
|
The reporting layer is observational only. It emits state and events to local dashboards, global lastheard exporters, logs, and monitoring consumers.
|
||||||
|
|
||||||
|
Reporting must not steer packet routing.
|
||||||
|
|
||||||
|
## Control/API Layer
|
||||||
|
|
||||||
|
The control/API layer provides explicit authenticated operations for sysop and control-plane actions. It should operate on access sessions, subscriptions, mesh peers, and reporting state without blocking the packet path.
|
||||||
|
|
||||||
|
## Critical Invariant
|
||||||
|
|
||||||
|
Reporting, dashboards, APIs, databases, exporters, and monitoring consumers must not block or steer packet handling.
|
||||||
@ -0,0 +1,251 @@
|
|||||||
|
# FreeDMR 2 State Model
|
||||||
|
|
||||||
|
The FreeDMR 2 state model separates listener identity, client identity, subscription state, stream state, mesh peer state, and reporting/export views. The legacy `BRIDGES` dict is not the authoritative FreeDMR 2 model.
|
||||||
|
|
||||||
|
Recommended hot-path structures:
|
||||||
|
|
||||||
|
- `NamedTuple` or tuple keys for hot dict/set indexes.
|
||||||
|
- `dataclass(slots=True)` for mutable state records.
|
||||||
|
- `heapq` expiry queue with lazy invalidation.
|
||||||
|
- Local in-memory packet-plane state.
|
||||||
|
- No packet-path dependency on external databases or reporting consumers.
|
||||||
|
|
||||||
|
Suggested indexes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
subscriptions_by_conference_tg[conference_tg] -> set[SubscriptionKey]
|
||||||
|
subscription_by_rf[(client_id, slot, rf_tg)] -> SubscriptionKey
|
||||||
|
subscriptions_by_client_slot[(client_id, slot)] -> set[SubscriptionKey]
|
||||||
|
expiry_heap -> (expires_at, generation, SubscriptionKey)
|
||||||
|
```
|
||||||
|
|
||||||
|
## AccessSession
|
||||||
|
|
||||||
|
Purpose: Represents a live client/repeater session.
|
||||||
|
|
||||||
|
Owner: Access layer.
|
||||||
|
|
||||||
|
Key: Client DMR ID plus listener/session endpoint data.
|
||||||
|
|
||||||
|
Mutable fields: Authentication state, options, keepalive time, endpoint, active slots, supported protocol features.
|
||||||
|
|
||||||
|
Expiry/timer behaviour: Keepalive/session timeout expires the session and resets session-scoped options to system defaults.
|
||||||
|
|
||||||
|
Packet-plane rules: Read for packet admission, option interpretation, slot state, and source identity. Writes only minimal hot state such as last packet/keepalive.
|
||||||
|
|
||||||
|
Control-plane rules: API may read and update bounded session options.
|
||||||
|
|
||||||
|
Reporting/export view: Client connected/disconnected/options/state events.
|
||||||
|
|
||||||
|
Compatibility mapping: Current configured `MASTER`/`SYSTEM` session fields and peer status entries.
|
||||||
|
|
||||||
|
## Listener
|
||||||
|
|
||||||
|
Purpose: Owns a UDP socket/service endpoint.
|
||||||
|
|
||||||
|
Owner: Access layer or transport shell.
|
||||||
|
|
||||||
|
Key: Listener name or bind address/port.
|
||||||
|
|
||||||
|
Mutable fields: Socket state, configured admission policy, active access sessions.
|
||||||
|
|
||||||
|
Expiry/timer behaviour: None beyond transport lifecycle.
|
||||||
|
|
||||||
|
Packet-plane rules: Receives and sends packets; should not be treated as client identity.
|
||||||
|
|
||||||
|
Control-plane rules: Configuration and lifecycle only.
|
||||||
|
|
||||||
|
Reporting/export view: Listener up/down and session counts.
|
||||||
|
|
||||||
|
Compatibility mapping: Current `MASTER` stanza.
|
||||||
|
|
||||||
|
## ClientSlotState
|
||||||
|
|
||||||
|
Purpose: Tracks per-client per-slot RF-facing state.
|
||||||
|
|
||||||
|
Owner: Access and subscription layers.
|
||||||
|
|
||||||
|
Key: `(client_id, slot)`.
|
||||||
|
|
||||||
|
Mutable fields: RF-visible TG activity, current stream, hang/timeout state, default reflector, static and dial subscriptions.
|
||||||
|
|
||||||
|
Expiry/timer behaviour: Stream timers, dial/default reflector expiry, session reset on disconnect.
|
||||||
|
|
||||||
|
Packet-plane rules: Read for RF-visible TG mapping and stream admission. Writes current stream and observed activity.
|
||||||
|
|
||||||
|
Control-plane rules: API may activate/deactivate subscriptions and defaults.
|
||||||
|
|
||||||
|
Reporting/export view: Slot activity and active subscription state.
|
||||||
|
|
||||||
|
Compatibility mapping: Existing slot options, dial-a-TG state, timeout fields.
|
||||||
|
|
||||||
|
## TalkgroupSubscription
|
||||||
|
|
||||||
|
Purpose: Represents membership of a client slot/RF TG in a conference TG.
|
||||||
|
|
||||||
|
Owner: Subscription layer.
|
||||||
|
|
||||||
|
Key: Stable `SubscriptionKey`, likely `(client_id, slot, rf_tg, conference_tg, mode)`.
|
||||||
|
|
||||||
|
Mutable fields: Active flag, source, expiry, generation, priority/policy metadata.
|
||||||
|
|
||||||
|
Expiry/timer behaviour: Static subscriptions normally session-bound; dial/default/user subscriptions may expire.
|
||||||
|
|
||||||
|
Packet-plane rules: Read heavily for routing. Writes should be explicit activation/deactivation/expiry only.
|
||||||
|
|
||||||
|
Control-plane rules: API and bridge control may create/remove/update subscriptions subject to policy.
|
||||||
|
|
||||||
|
Reporting/export view: Subscription activated/deactivated/expired events.
|
||||||
|
|
||||||
|
Compatibility mapping: Current `BRIDGES` entries and `#` reflector export names.
|
||||||
|
|
||||||
|
## StreamState
|
||||||
|
|
||||||
|
Purpose: Tracks voice stream lifecycle and packet ordering.
|
||||||
|
|
||||||
|
Owner: Packet/stream layer.
|
||||||
|
|
||||||
|
Key: Stream ID plus source identity and direction namespace where needed.
|
||||||
|
|
||||||
|
Mutable fields: Source ID, destination/conference TG, RF-visible TG when relevant, slot, last sequence, last packet time, LC state, source server/repeater metadata, loop-control state.
|
||||||
|
|
||||||
|
Expiry/timer behaviour: Explicit terminator is strong end; timeout is softer, especially on HBP.
|
||||||
|
|
||||||
|
Packet-plane rules: Read/write in packet path. Must be local and fast.
|
||||||
|
|
||||||
|
Control-plane rules: Normally read-only, except explicit reset/debug operations.
|
||||||
|
|
||||||
|
Reporting/export view: Call started/ended/lost events.
|
||||||
|
|
||||||
|
Compatibility mapping: Current stream tracking dicts and report socket call state.
|
||||||
|
|
||||||
|
## MeshPeerState
|
||||||
|
|
||||||
|
Purpose: Tracks a peer server/link.
|
||||||
|
|
||||||
|
Owner: Mesh layer.
|
||||||
|
|
||||||
|
Key: Peer/server ID and authenticated endpoint/session where available.
|
||||||
|
|
||||||
|
Mutable fields: Protocol version, endpoint, auth state, last seen, stun/quench state, supported metadata layout, send/receive counters.
|
||||||
|
|
||||||
|
Expiry/timer behaviour: Peer keepalive/control timeout; auth renewal timers.
|
||||||
|
|
||||||
|
Packet-plane rules: Read for admission, protocol layout, and source metadata. Writes last-seen counters and cached safety state only.
|
||||||
|
|
||||||
|
Control-plane rules: API/BCXX may stun, clear stun, authenticate, or update policy.
|
||||||
|
|
||||||
|
Reporting/export view: Peer up/down/stun/source-quench events.
|
||||||
|
|
||||||
|
Compatibility mapping: Current OBP/FBP peer entries.
|
||||||
|
|
||||||
|
## BridgeControlState
|
||||||
|
|
||||||
|
Purpose: Holds bridge-control effects such as source quench, STUN, and authentication state.
|
||||||
|
|
||||||
|
Owner: Mesh/control layer.
|
||||||
|
|
||||||
|
Key: Peer ID plus control scope, for example `(peer_id, stream_id, conference_tg)` for BCSQ.
|
||||||
|
|
||||||
|
Mutable fields: Active flag, reason, expiry, generation, authenticated issuer.
|
||||||
|
|
||||||
|
Expiry/timer behaviour: Source quench and soft controls should expire; hard policy blocks may persist until cleared.
|
||||||
|
|
||||||
|
Packet-plane rules: Read for admission/suppression. Writes only when handling bridge-control packets.
|
||||||
|
|
||||||
|
Control-plane rules: API/BCXX may set or clear controls.
|
||||||
|
|
||||||
|
Reporting/export view: Mesh control events.
|
||||||
|
|
||||||
|
Compatibility mapping: Current BCSQ/BCST handling.
|
||||||
|
|
||||||
|
## ReportingState
|
||||||
|
|
||||||
|
Purpose: Tracks reporting pipeline health and retained current state.
|
||||||
|
|
||||||
|
Owner: Reporting layer.
|
||||||
|
|
||||||
|
Key: Event family or retained state identity.
|
||||||
|
|
||||||
|
Mutable fields: Queue depth, dropped counts, publisher connected state, last emitted state.
|
||||||
|
|
||||||
|
Expiry/timer behaviour: Retained state refresh and reconnect backoff.
|
||||||
|
|
||||||
|
Packet-plane rules: Packet path may enqueue non-blocking events only.
|
||||||
|
|
||||||
|
Control-plane rules: API may read reporting health.
|
||||||
|
|
||||||
|
Reporting/export view: Native v2 reporting state.
|
||||||
|
|
||||||
|
Compatibility mapping: Current dashboard socket state, preferably through a sidecar adapter.
|
||||||
|
|
||||||
|
## CompatibilityExportState
|
||||||
|
|
||||||
|
Purpose: Derived legacy-shaped view for old dashboard/API/HBLink-compatible consumers.
|
||||||
|
|
||||||
|
Owner: Compatibility adapter.
|
||||||
|
|
||||||
|
Key: Consumer-specific.
|
||||||
|
|
||||||
|
Mutable fields: Cached translated state.
|
||||||
|
|
||||||
|
Expiry/timer behaviour: Follows source state; may drop stale export entries.
|
||||||
|
|
||||||
|
Packet-plane rules: Must not be read by packet routing.
|
||||||
|
|
||||||
|
Control-plane rules: May expose old-compatible admin views if required.
|
||||||
|
|
||||||
|
Reporting/export view: Legacy compatibility only.
|
||||||
|
|
||||||
|
Compatibility mapping: `BRIDGES`, `SYSTEM`, `MASTER`, and `#` reflector names.
|
||||||
|
|
||||||
|
## Worker Ownership Considerations
|
||||||
|
|
||||||
|
Authoritative packet-plane state must have one owner. Reporting/export state is derived and must not drive routing. External stores may distribute snapshots or control-plane updates, but they are not per-packet routing dependencies.
|
||||||
|
|
||||||
|
Process boundaries must preserve the same state ownership rules as in-process modules.
|
||||||
|
|
||||||
|
AccessSession:
|
||||||
|
|
||||||
|
- Classification: Access/session state with packet-plane admission impact.
|
||||||
|
- Likely owner: Transport/listener process initially.
|
||||||
|
- Future ownership: May be assigned to a routing worker once admitted.
|
||||||
|
|
||||||
|
ClientSlotState:
|
||||||
|
|
||||||
|
- Classification: Packet-plane authoritative state.
|
||||||
|
- Likely owner: Single routing owner for that client/slot.
|
||||||
|
|
||||||
|
TalkgroupSubscription:
|
||||||
|
|
||||||
|
- Classification: Packet-plane authoritative state.
|
||||||
|
- Likely owner: Single routing/subscription owner.
|
||||||
|
|
||||||
|
StreamState:
|
||||||
|
|
||||||
|
- Classification: Packet-plane authoritative state.
|
||||||
|
- Likely owner: Single stream owner; all packets for a given stream should be handled by one owner.
|
||||||
|
|
||||||
|
MeshPeerState:
|
||||||
|
|
||||||
|
- Classification: Split transport/session state and routing policy state.
|
||||||
|
- Likely owner: Transport/session owner for socket/auth/session facts; routing policy owner for cached packet decisions.
|
||||||
|
- Rule: Authenticated peer/session state must be cached locally for packet decisions.
|
||||||
|
|
||||||
|
BridgeControlState:
|
||||||
|
|
||||||
|
- Classification: Control-plane input with packet-plane effect.
|
||||||
|
- Likely owner: Relevant packet/routing owner for active BCSQ/STUN effects.
|
||||||
|
- Rule: BCSQ/STUN state used by the packet path must be local to the relevant packet owner.
|
||||||
|
|
||||||
|
ReportingState:
|
||||||
|
|
||||||
|
- Classification: Reporting/export snapshot and event state.
|
||||||
|
- Likely owner: Reporting worker.
|
||||||
|
- Rule: Not authoritative for packet routing.
|
||||||
|
|
||||||
|
CompatibilityExportState:
|
||||||
|
|
||||||
|
- Classification: Derived compatibility state.
|
||||||
|
- Likely owner: Compatibility adapter/export worker.
|
||||||
|
- Rule: Never authoritative.
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
# FreeDMR 2 Subscription Model
|
||||||
|
|
||||||
|
The subscription model is the centrepiece of FreeDMR 2.
|
||||||
|
|
||||||
|
Conceptually, each TG is a conference bridge. Client systems subscribe to conference TGs. FreeDMR routes traffic according to active subscriptions, not according to the legacy shape of the `BRIDGES` dict.
|
||||||
|
|
||||||
|
Definitions:
|
||||||
|
|
||||||
|
- `conference_tg`: FreeDMR network/conference identity.
|
||||||
|
- `rf_tg`: Client-facing RF presentation identity.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
Direct TG:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rf_tg == conference_tg
|
||||||
|
```
|
||||||
|
|
||||||
|
Dial-a-TG:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rf_tg == 9
|
||||||
|
conference_tg == selected reflector/TG
|
||||||
|
```
|
||||||
|
|
||||||
|
Alias/rewrite:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rf_tg may differ from conference_tg by policy/configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
Example subscription:
|
||||||
|
|
||||||
|
```python
|
||||||
|
TalkgroupSubscription(
|
||||||
|
client_id=2345001,
|
||||||
|
slot=2,
|
||||||
|
rf_tg=9,
|
||||||
|
conference_tg=4400,
|
||||||
|
mode="dial",
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing Invariant
|
||||||
|
|
||||||
|
Packet routing should not need to know whether a subscription came from static config, default reflector, dial-a-TG, API, SMS control, or a future UI action. Those are subscription sources, not routing modes.
|
||||||
|
|
||||||
|
## Dial-a-TG Rationale
|
||||||
|
|
||||||
|
Dial-a-TG exists so terminal users can access arbitrary FreeDMR TGs without programming every TG into the terminal/codeplug. It is an amateur-radio usability feature and should be evaluated against that goal, not only against commercial DMR fleet assumptions.
|
||||||
|
|
||||||
|
Control of dial-a-TG from TS1 as well as TS2 is intentional. If TS2 is blocked by unwanted traffic, a user can transmit private-call control on TS1 to disconnect or change the TS2 reflector/TG state.
|
||||||
|
|
||||||
|
Voice prompts should remain RF-visible as TG9 slot 2 unless that policy is deliberately changed.
|
||||||
|
|
||||||
|
## FreeDMR Routing Model
|
||||||
|
|
||||||
|
- TGs are conference groups.
|
||||||
|
- DMR IDs are like phone numbers.
|
||||||
|
- Timeslots are access/capacity paths, more like phone lines.
|
||||||
|
- FreeDMR is intended to be relatively timeslot agnostic.
|
||||||
|
- TS1 control affecting TS2 reflector state is consistent with the FreeDMR PBX/line model.
|
||||||
|
|
||||||
|
This model also allows future arbitrary RF TG aliases, not only the traditional TG9 dial-a-TG rewrite.
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
# Packet and Stream Model
|
||||||
|
|
||||||
|
## Packet Mutation Boundaries
|
||||||
|
|
||||||
|
Raw DMR packet bytes should be treated as immutable input until an explicit rewrite operation. Transport simulation and protocol mutation must remain separate.
|
||||||
|
|
||||||
|
Packet mutation must be named, explicit, and testable. FreeDMR should preserve packet bytes unless it intentionally rewrites them.
|
||||||
|
|
||||||
|
Protocol-sensitive rewrite areas include:
|
||||||
|
|
||||||
|
- Slot bit rewrite.
|
||||||
|
- TG rewrite.
|
||||||
|
- Stream ID preservation.
|
||||||
|
- Source ID preservation.
|
||||||
|
- Voice header LC rewrite.
|
||||||
|
- Terminator LC rewrite.
|
||||||
|
- Embedded LC rewrite.
|
||||||
|
|
||||||
|
Voice header/terminator LC and embedded LC must be handled carefully. Embedded LC rewrite should apply only to voice bursts B-E, not data/control packets.
|
||||||
|
|
||||||
|
Same-TG voice forwarding should preserve embedded LC payloads where possible. TG-mapped forwarding may regenerate embedded LC for routing correctness.
|
||||||
|
|
||||||
|
Data/control packets are packet-oriented and not AMBE voice streams. Group-addressed data is valid and can be routed as data, not reported as voice. Data/control classification must remain separate from group-vs-unit addressing.
|
||||||
|
|
||||||
|
Unit/private calls are control-plane only in FreeDMR. Do not introduce general private voice routing unless project policy changes.
|
||||||
|
|
||||||
|
## Sequence and Lifecycle Principles
|
||||||
|
|
||||||
|
DMRD sequence numbers are one byte and modulo-256.
|
||||||
|
|
||||||
|
- Delta `0`: duplicate.
|
||||||
|
- Delta `1`: normal progress.
|
||||||
|
- Delta `2..127`: forward progress with loss.
|
||||||
|
- Delta `128..255`: stale or out-of-order.
|
||||||
|
|
||||||
|
Explicit voice terminator is a strong end-of-stream signal. Timeout without terminator is softer and may remain recoverable on HBP to preserve audio continuity.
|
||||||
|
|
||||||
|
HBP should be more tolerant because it is RF-facing and real deployments include imperfect terminals, repeaters, RF paths, cellular links, and RF IP links. FBP/OBP can be stricter because it is server-to-server, but should still preserve audio where possible on unreliable links.
|
||||||
|
|
||||||
|
Loop-control safety must not be overridden by tolerance for delayed or out-of-order packets.
|
||||||
|
|
||||||
|
## LC and OVCM
|
||||||
|
|
||||||
|
For DMR Group Voice Channel User LC, the first bytes are:
|
||||||
|
|
||||||
|
- FLCO
|
||||||
|
- FID
|
||||||
|
- Service Options
|
||||||
|
|
||||||
|
Normal synthetic group voice LC should use service options `0x00`.
|
||||||
|
|
||||||
|
OVCM is `0x04` if explicitly required.
|
||||||
|
|
||||||
|
HBLink legacy `0x20` should be documented as legacy/compatibility only. It is not standards-clean OVCM and should not be used as a new synthetic/system-generated traffic marker.
|
||||||
|
|
||||||
|
Decoded real inbound LC must be preserved unchanged unless there is a deliberate reason to rewrite. Synthetic/fallback LC generation must be explicit and tested. FreeDMR routing metadata should be used for routing state, not magic bits in synthetic LC.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Exact live RF behaviour after long HBP gaps still needs validation with real repeaters and terminals.
|
||||||
|
- Some prompt/late-entry behaviour may need live testing because terminal interpretation of DMR standards can be loose or incomplete.
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
# Data Packet Policy
|
||||||
|
|
||||||
|
No regression of DMR data support is permitted.
|
||||||
|
|
||||||
|
FreeDMR should forward supported DMR data packets according to conference/subscription and mesh rules. The FreeDMR core should not become a general GPS/SMS application processor.
|
||||||
|
|
||||||
|
GPS, SMS, and similar application processing should be implemented by systems connected via FBP or another mesh/access-adjacent interface. `DATA-GATEWAY` is understood as an earlier expression of this model.
|
||||||
|
|
||||||
|
Existing `SUB_MAP` / last-known-location behaviour is intentional: data addressed to a DMR ID can be routed toward the last known HBP/client location.
|
||||||
|
|
||||||
|
Narrow exceptions may exist for SMS-based dial-a-TG control or DMR SMS alerts to the sysop.
|
||||||
|
|
||||||
|
Data/control classification must be separate from group-vs-unit addressing. A group-addressed data packet is not automatically a voice stream.
|
||||||
|
|
||||||
|
The packet layer may inspect data packets for admission, routing, loop/source-quench safety, reporting, and metadata preservation. Application-level GPS/SMS semantics should live outside the core unless a specific control-plane feature requires it.
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
# Mesh Model
|
||||||
|
|
||||||
|
FreeDMR is a peer network, not hub-and-spoke. Local sysops retain policy autonomy.
|
||||||
|
|
||||||
|
The guiding principle remains "everything everywhere", subject to source quench, STUN, ACLs, local policy, authentication, loop-control, and documented exceptions.
|
||||||
|
|
||||||
|
## Loop Control and Bridge Control
|
||||||
|
|
||||||
|
Loop control, source selection, duplicate suppression, source quench, and STUN are packet-plane safety mechanisms.
|
||||||
|
|
||||||
|
Source quench is a control hint to suppress a stream/TG toward a peer. It is optional and scoped per stream/TG.
|
||||||
|
|
||||||
|
STUN is a broader FBP/OpenBridge traffic gate. Under the current conceptual model, `BCST`/STUN applies to all FBP traffic from that peer until cleared or expired by policy.
|
||||||
|
|
||||||
|
`BCSQ` is per stream/TG.
|
||||||
|
|
||||||
|
`BCST`/STUN is all FBP traffic.
|
||||||
|
|
||||||
|
## Protocol Versions and Metadata
|
||||||
|
|
||||||
|
Source server and source repeater metadata must be preserved according to the protocol version actually in use for that session.
|
||||||
|
|
||||||
|
OBP/FBP protocol version controls metadata layout and option order.
|
||||||
|
|
||||||
|
Protocol v1 OBP remains an important open interop path where intentionally configured. FBP v5 is the current richer peer-server protocol target. FBP v4 is historical/deprecation context unless explicitly retained.
|
||||||
|
|
||||||
|
## TG Namespace Rule
|
||||||
|
|
||||||
|
HBP/RF-visible TG and FBP/OBP-visible conference TG can intentionally differ, especially with dial-a-TG.
|
||||||
|
|
||||||
|
Source quench must use the TG namespace visible to the peer sending or receiving the quench.
|
||||||
|
|
||||||
|
For HBP-to-FBP dial-a-TG, `BCSQ` should use the FBP/reflector TG, not local RF TG9.
|
||||||
|
|
||||||
|
For OBP-source traffic, `BCSQ` should use the inbound OBP TG because that is the source-server namespace.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Final FBP v5 identity/auth fields still need a concrete wire-format decision.
|
||||||
|
- STUN recovery policy needs an operator workflow, likely API-driven.
|
||||||
@ -0,0 +1,203 @@
|
|||||||
|
# Reporting Model
|
||||||
|
|
||||||
|
Decision: FreeDMR 2 replaces the legacy dashboard/report socket model with a new structured reporting event model.
|
||||||
|
|
||||||
|
The existing dashboard is not a compatibility constraint for the FreeDMR 2 core. It must be updated separately or supported by an optional out-of-process adapter.
|
||||||
|
|
||||||
|
Reporting is observational only. Packet routing must not depend on dashboard/report consumers.
|
||||||
|
|
||||||
|
The v2 event schema is the compatibility contract. Raw `BRIDGES`/`SYSTEM` state should not be exposed as the primary v2 API. Old dashboard/report socket event names should not shape the FreeDMR 2 core.
|
||||||
|
|
||||||
|
Any old dashboard compatibility must live in a sidecar/adapter, not inside packet routing. FreeDMR 1.x/current code remains live until FreeDMR 2 is ready, so FreeDMR 2 can make a clean reporting break.
|
||||||
|
|
||||||
|
## Preferred Transport
|
||||||
|
|
||||||
|
MQTT is the preferred external live reporting transport.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packet path -> non-blocking bounded local event queue -> MQTT publisher worker -> local broker/feed
|
||||||
|
```
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- MQTT publishing must be asynchronous from the packet worker.
|
||||||
|
- Use a bounded queue.
|
||||||
|
- The bounded local event queue is the only coupling from packet path to reporting worker.
|
||||||
|
- Drop or coalesce low-priority events under pressure.
|
||||||
|
- Emit a later reporting-health event rather than blocking packet handling.
|
||||||
|
- Voice stability takes precedence over reporting completeness.
|
||||||
|
- Reconnect with exponential backoff.
|
||||||
|
- Refresh retained state after reconnect.
|
||||||
|
- Reporting backpressure must be visible through reporting-health events but must not delay DMR packets.
|
||||||
|
|
||||||
|
Reporting is the first major candidate for out-of-process execution. The MQTT publisher should be an independent worker or sidecar where practical. The global lastheard exporter should be a separate process. Dashboard aggregation should not run in the packet hot path.
|
||||||
|
|
||||||
|
Reporting worker crash must not affect packet routing. Reporting worker restart should refresh retained state after reconnect.
|
||||||
|
|
||||||
|
## Local Dashboard and Global Lastheard
|
||||||
|
|
||||||
|
Local dashboard:
|
||||||
|
|
||||||
|
- Consumes local MQTT live state/events.
|
||||||
|
- Displays live client/repeater/server traffic.
|
||||||
|
- Recovers from retained state after reconnect.
|
||||||
|
|
||||||
|
Global lastheard:
|
||||||
|
|
||||||
|
- Central/non-real-time.
|
||||||
|
- Consumes summaries, not packet-plane traffic.
|
||||||
|
- Should preferably be fed by a separate exporter process.
|
||||||
|
- Central outage must not affect local packet handling or local dashboard.
|
||||||
|
|
||||||
|
Preferred flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FreeDMR core -> local MQTT feed -> local dashboard
|
||||||
|
-> global-exporter process -> network MQTT/collector
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initial Event Families
|
||||||
|
|
||||||
|
- `server.started`
|
||||||
|
- `server.stopping`
|
||||||
|
- `client.connected`
|
||||||
|
- `client.disconnected`
|
||||||
|
- `client.options_changed`
|
||||||
|
- `subscription.activated`
|
||||||
|
- `subscription.deactivated`
|
||||||
|
- `subscription.expired`
|
||||||
|
- `call.started`
|
||||||
|
- `call.ended`
|
||||||
|
- `call.lost`
|
||||||
|
- `mesh.peer_up`
|
||||||
|
- `mesh.peer_down`
|
||||||
|
- `mesh.source_quench`
|
||||||
|
- `mesh.stun`
|
||||||
|
- `loop.detected`
|
||||||
|
- `packet.rate_limited`
|
||||||
|
- `reporting.queue_overflow`
|
||||||
|
- `reporting.publisher_disconnected`
|
||||||
|
- `reporting.publisher_reconnected`
|
||||||
|
- `reporting.events_dropped`
|
||||||
|
|
||||||
|
## Suggested MQTT Topics
|
||||||
|
|
||||||
|
```text
|
||||||
|
freedmr/v2/{server_id}/state
|
||||||
|
freedmr/v2/{server_id}/client/{client_id}/state
|
||||||
|
freedmr/v2/{server_id}/client/{client_id}/slot/{slot}/activity
|
||||||
|
freedmr/v2/{server_id}/subscription/{subscription_id}/state
|
||||||
|
freedmr/v2/{server_id}/call/{stream_id}/start
|
||||||
|
freedmr/v2/{server_id}/call/{stream_id}/end
|
||||||
|
freedmr/v2/{server_id}/mesh/{peer_id}/state
|
||||||
|
freedmr/v2/{server_id}/event
|
||||||
|
```
|
||||||
|
|
||||||
|
Use retained messages for current state and non-retained messages for transient events.
|
||||||
|
|
||||||
|
## Example Events
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "server.started",
|
||||||
|
"server_id": "2345",
|
||||||
|
"version": "2.0-dev",
|
||||||
|
"time": "2026-05-24T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "client.connected",
|
||||||
|
"server_id": "2345",
|
||||||
|
"client_id": 2345001,
|
||||||
|
"listener": "hbp-public",
|
||||||
|
"endpoint": "198.51.100.10:62031",
|
||||||
|
"time": "2026-05-24T12:00:01Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "subscription.activated",
|
||||||
|
"server_id": "2345",
|
||||||
|
"subscription_id": "2345001-2-9-4400",
|
||||||
|
"client_id": 2345001,
|
||||||
|
"slot": 2,
|
||||||
|
"rf_tg": 9,
|
||||||
|
"conference_tg": 4400,
|
||||||
|
"mode": "dial",
|
||||||
|
"source": "dial-a-tg",
|
||||||
|
"time": "2026-05-24T12:00:02Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "call.started",
|
||||||
|
"server_id": "2345",
|
||||||
|
"stream_id": 12345678,
|
||||||
|
"client_id": 2345001,
|
||||||
|
"slot": 2,
|
||||||
|
"source_id": 2345678,
|
||||||
|
"rf_tg": 9,
|
||||||
|
"conference_tg": 4400,
|
||||||
|
"source": "hbp",
|
||||||
|
"time": "2026-05-24T12:00:03Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "call.ended",
|
||||||
|
"server_id": "2345",
|
||||||
|
"stream_id": 12345678,
|
||||||
|
"reason": "terminator",
|
||||||
|
"duration_ms": 18420,
|
||||||
|
"packets": 614,
|
||||||
|
"time": "2026-05-24T12:00:21Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "call.lost",
|
||||||
|
"server_id": "2345",
|
||||||
|
"stream_id": 12345678,
|
||||||
|
"reason": "timeout",
|
||||||
|
"last_seen_ms_ago": 7000,
|
||||||
|
"time": "2026-05-24T12:00:28Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "mesh.source_quench",
|
||||||
|
"server_id": "2345",
|
||||||
|
"peer_id": "2350",
|
||||||
|
"stream_id": 12345678,
|
||||||
|
"conference_tg": 4400,
|
||||||
|
"reason": "duplicate-source",
|
||||||
|
"time": "2026-05-24T12:00:04Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "reporting.queue_overflow",
|
||||||
|
"server_id": "2345",
|
||||||
|
"dropped_events": 42,
|
||||||
|
"queue_limit": 2048,
|
||||||
|
"policy": "drop-low-priority",
|
||||||
|
"time": "2026-05-24T12:00:05Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Broker packaging and defaults: Mosquitto, embedded broker, external broker, or optional dependency.
|
||||||
|
- Exact retained-state expiry policy.
|
||||||
|
- Authentication model for MQTT clients.
|
||||||
|
- Whether legacy dashboard compatibility is a supplied sidecar or a separate dashboard migration task.
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
# API and Control Model
|
||||||
|
|
||||||
|
The current HTTP/JSON API is experimental and should be treated as a starting point, not a fixed FreeDMR 2 contract.
|
||||||
|
|
||||||
|
## Current API Principles
|
||||||
|
|
||||||
|
- Local administration and automation.
|
||||||
|
- Not for public internet exposure.
|
||||||
|
- Small in-memory operations only.
|
||||||
|
- Bounded request bodies.
|
||||||
|
- No expensive live serialization of internal state.
|
||||||
|
- No blocking packet path.
|
||||||
|
- User-level authentication by connected peer/client session key.
|
||||||
|
- System-level authentication by system API key.
|
||||||
|
|
||||||
|
The API should bind to localhost by default unless explicitly configured otherwise.
|
||||||
|
|
||||||
|
## FreeDMR 2 Direction
|
||||||
|
|
||||||
|
API operations should be bounded control-plane operations over access sessions, subscriptions, mesh peers, and reporting state.
|
||||||
|
|
||||||
|
Destructive system actions such as kill, resetall, and STUN clear should be separately enableable. Audit logs are required. Keys and secrets must never be logged.
|
||||||
|
|
||||||
|
Dashboard controls should use authenticated HTTP API operations unless a future UI genuinely needs bidirectional streaming.
|
||||||
|
|
||||||
|
Compatibility with the old API should be an adapter concern if needed.
|
||||||
|
|
||||||
|
The API may eventually run as a separate control-plane worker or sidecar. API requests should become `ControlCommand` messages sent to the owner of the relevant state.
|
||||||
|
|
||||||
|
The API must not directly mutate packet-plane state it does not own. Destructive operations must go through explicit owner-handled commands. API worker failure should not stop existing packet routing, although new control actions may fail until the worker recovers.
|
||||||
|
|
||||||
|
## Suggested v2 Operations
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v2/health
|
||||||
|
GET /api/v2/version
|
||||||
|
GET /api/v2/state
|
||||||
|
GET /api/v2/client/{client_id}
|
||||||
|
GET /api/v2/client/{client_id}/slot/{slot}
|
||||||
|
POST /api/v2/client/{client_id}/slot/{slot}/subscriptions
|
||||||
|
DELETE /api/v2/client/{client_id}/slot/{slot}/subscriptions/{subscription_id}
|
||||||
|
POST /api/v2/mesh/{peer_id}/stun
|
||||||
|
DELETE /api/v2/mesh/{peer_id}/stun
|
||||||
|
POST /api/v2/system/reset
|
||||||
|
POST /api/v2/system/stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packet-Path Rule
|
||||||
|
|
||||||
|
API handlers must not perform work that delays packet routing. Expensive state export, dashboard compatibility, global reporting, and administrative analysis should use snapshots, bounded queues, or separate processes.
|
||||||
|
|
||||||
|
No API path should force expensive live serialization of packet-plane state.
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
# Security Model
|
||||||
|
|
||||||
|
Core principle: FreeDMR may sign/authenticate traffic and control messages. FreeDMR should not encrypt amateur-radio or mesh traffic by default.
|
||||||
|
|
||||||
|
The security model is authenticity, integrity, membership validation, and local policy, not secrecy. Amateur radio is public, and users may provide IP backhaul over amateur-radio links where encryption rules matter.
|
||||||
|
|
||||||
|
## Mesh Authentication
|
||||||
|
|
||||||
|
Preferred direction:
|
||||||
|
|
||||||
|
- PKI-backed FBP peer admission through Bridge Control / BCXX.
|
||||||
|
- Signed server/sysop identity.
|
||||||
|
- Bind server ID, authorized sub-IDs, public key, validity, and features where useful.
|
||||||
|
- Bind authenticated identity to observed endpoint/IP.
|
||||||
|
- If endpoint changes, peer must re-authenticate.
|
||||||
|
- Expensive signature/cert validation is control-plane work.
|
||||||
|
- Packet-plane uses cached authenticated session state.
|
||||||
|
- Soft renewal should avoid interrupting in-flight voice when safe.
|
||||||
|
- Hard stop on revocation, explicit failure, endpoint mismatch outside policy, grace expiry, or local policy.
|
||||||
|
|
||||||
|
## Identity and Listing
|
||||||
|
|
||||||
|
Signed identity proves membership/identity, not mandatory carriage. Public listing is separate from mesh identity.
|
||||||
|
|
||||||
|
Local sysops may choose whether to carry or vouch for traffic. A valid signed key does not override local policy.
|
||||||
|
|
||||||
|
Vouching sysop accountability is part of FreeDMR's social trust model. A sysop allowing problematic traffic onto the mesh may see other peers stop peering with them.
|
||||||
|
|
||||||
|
One verification of a key may cover the server ID and authorized sub-IDs for that sysop/server deployment.
|
||||||
|
|
||||||
|
## Distributed Key Gossip Option
|
||||||
|
|
||||||
|
Signed membership documents may be gossiped over bounded/rate-limited BCXX.
|
||||||
|
|
||||||
|
Peers validate signatures and build local key tables. Revocation, expiry, serials, and replay protection are required.
|
||||||
|
|
||||||
|
Key gossip cannot create trust by mere repetition. The packet path must use cached key/policy state.
|
||||||
|
|
||||||
|
This supports autonomous routing decisions for packets that originated from a server even when that source server is not directly connected.
|
||||||
|
|
||||||
|
## Analogue and Digital Bridge Policy
|
||||||
|
|
||||||
|
Analogue ROIP bridges may connect as HBP clients. Permitted does not mean automatically valuable.
|
||||||
|
|
||||||
|
Analogue bridges can be operationally sensitive because mixed or continuous analogue audio is a poor fit for DMR one-source-at-a-time stream behaviour. They may hold a TG open, play tones, or prevent digital users from breaking in until a carrier/timer drops.
|
||||||
|
|
||||||
|
Analogue bridges should be subject to local policy, listing expectations, and peer accountability.
|
||||||
|
|
||||||
|
YSF/NXDN and other AMBE-family networks are often a better technical match than analogue or unlike-codec transcoding, because they can avoid lossy audio translation.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- X.509 certificates versus simpler Ed25519 signed membership documents.
|
||||||
|
- Exact revocation and renewal distribution process.
|
||||||
|
- Default grace period for soft re-authentication.
|
||||||
|
- How much key gossip should be enabled by default.
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
# Runtime and Concurrency
|
||||||
|
|
||||||
|
Decision: Do not replace Twisted as the first FreeDMR 2 architecture move.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
Current packet behaviour is subtle. Twisted's single-threaded reactor is currently a safety boundary. Replacing the event loop and the state model at the same time mixes too many changes.
|
||||||
|
|
||||||
|
The first goal is architectural clarity and testability, not event-loop novelty.
|
||||||
|
|
||||||
|
## Immediate Runtime Strategy
|
||||||
|
|
||||||
|
- Keep Twisted initially as the transport shell.
|
||||||
|
- Use Twisted's single-threaded reactor as a safety boundary while the core is extracted.
|
||||||
|
- Do not replace the event loop and state model at the same time.
|
||||||
|
- Extract the protocol/routing/subscription core behind deterministic interfaces.
|
||||||
|
- Keep packet-plane state local and deterministic.
|
||||||
|
- No blocking work in reactor callbacks.
|
||||||
|
- No dashboard/API/database/MQTT waits in the packet path.
|
||||||
|
- Single-owner state is preferred.
|
||||||
|
- Explicit messages/events are preferred over shared mutable dictionaries across threads/processes.
|
||||||
|
|
||||||
|
## Eventual Capacity Strategy
|
||||||
|
|
||||||
|
FreeDMR 2 should support worker-process scaling once state ownership and message boundaries are explicit and tested.
|
||||||
|
|
||||||
|
The purpose of worker processes is not merely performance. It is also:
|
||||||
|
|
||||||
|
- Clearer state ownership.
|
||||||
|
- Failure isolation.
|
||||||
|
- Safer concurrency.
|
||||||
|
- Testable boundaries.
|
||||||
|
- Future capacity scaling.
|
||||||
|
|
||||||
|
Prefer process/actor ownership over shared-memory no-GIL threading for authoritative routing state. No-GIL Python does not remove the need for clear ownership of mutable packet-plane state.
|
||||||
|
|
||||||
|
Offload non-packet-path work first, including reporting, MQTT publishing, global export, SQL writes, dashboard aggregation, alias refresh, analytics, and lab/codec work.
|
||||||
|
|
||||||
|
Routing-core workers are a later stage. Multi-worker sharding should only be considered after single-worker message-boundary behaviour is proven.
|
||||||
|
|
||||||
|
Twisted can remain the transport shell while reporting/export/control workers move out-of-process.
|
||||||
|
|
||||||
|
## Ownership Split
|
||||||
|
|
||||||
|
Twisted parent/transport process may own:
|
||||||
|
|
||||||
|
- UDP sockets.
|
||||||
|
- HBP/FBP packet receive/send.
|
||||||
|
- Timers.
|
||||||
|
- Process supervision.
|
||||||
|
|
||||||
|
Routing core should eventually own:
|
||||||
|
|
||||||
|
- Stream state.
|
||||||
|
- Subscription state.
|
||||||
|
- Dial-a-TG state.
|
||||||
|
- Loop-control state.
|
||||||
|
- Duplicate suppression.
|
||||||
|
- Routing decisions.
|
||||||
|
|
||||||
|
Migration must be staged and covered by tests. FreeDMR should remain deployable on ordinary low-cost systems such as cheap VPS instances and Raspberry Pi-class hardware.
|
||||||
|
|
||||||
|
See `13-worker-process-scaling.md` for the eventual worker-process capacity model.
|
||||||
|
|
||||||
|
## External Databases
|
||||||
|
|
||||||
|
External stores can be useful for configuration, reporting snapshots, global lastheard, operator UI, and coordination. They should not sit in the packet hot path.
|
||||||
|
|
||||||
|
Packet-plane state should stay local and in memory unless a future design proves a bounded, non-blocking alternative.
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
# Testing and Release Gates
|
||||||
|
|
||||||
|
FreeDMR 2 must preserve behaviour through tests before changing architecture. The existing deterministic harness, UDP black-box harness, codec tests, support tests, and future live RF validation form the release gate structure.
|
||||||
|
|
||||||
|
## Test Commands
|
||||||
|
|
||||||
|
General test run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest discover -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Focused support/codec tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest tests.test_freedmr_dmr_codec tests.test_utils -v
|
||||||
|
```
|
||||||
|
|
||||||
|
UDP black-box tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FREEDMR_RUN_UDP_TESTS=1 python -m unittest tests.test_udp_blackbox_harness -v
|
||||||
|
```
|
||||||
|
|
||||||
|
UDP black-box tests with venv bootstrap:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FREEDMR_RUN_UDP_TESTS=1 \
|
||||||
|
FREEDMR_UDP_BOOTSTRAP_VENV=1 \
|
||||||
|
FREEDMR_UDP_VENV_DIR=/tmp/freedmr-blackbox-venv3 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
python -m unittest tests.test_udp_blackbox_harness -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release Gates
|
||||||
|
|
||||||
|
Level 0: Codec/unit/config/support tests. Must pass for every commit.
|
||||||
|
|
||||||
|
Level 1: Deterministic packet/state harness. Must pass before merge to main.
|
||||||
|
|
||||||
|
Level 2: Black-box UDP harness. Must pass before release candidate.
|
||||||
|
|
||||||
|
Level 3: Live RF / real repeater / real peer validation. Required before changing protocol-visible behaviour.
|
||||||
|
|
||||||
|
## What Each Layer Proves
|
||||||
|
|
||||||
|
Deterministic harness:
|
||||||
|
|
||||||
|
- Good for packet parsing seams, routing state, dial-a-TG state, fake-clock expiry, rewrite boundaries, LC/embedded LC tests, data-vs-voice classification, and reporting event generation.
|
||||||
|
- Bypasses real UDP, socket binding, subprocess startup, and Twisted timing.
|
||||||
|
|
||||||
|
UDP black-box harness:
|
||||||
|
|
||||||
|
- Good for subprocess startup, HBP login, UDP parsing, FBP signing, bridge-control, malformed/hostile packets, cadence, packet ordering, and link impairment.
|
||||||
|
- Cannot prove RF-side modem/radio behaviour.
|
||||||
|
|
||||||
|
Live RF validation:
|
||||||
|
|
||||||
|
- Required for protocol-visible changes, prompt/ident behaviour, late entry, OVCM/LC options, repeater/radio compatibility, and real terminal quirks.
|
||||||
|
|
||||||
|
## Required Assertions
|
||||||
|
|
||||||
|
Tests should assert:
|
||||||
|
|
||||||
|
- Route recipients and non-recipients.
|
||||||
|
- Packet byte preservation outside allowed rewrite regions.
|
||||||
|
- Explicit rewrite ranges.
|
||||||
|
- Stream lifecycle.
|
||||||
|
- Subscription state.
|
||||||
|
- Reporting events.
|
||||||
|
- Source quench/STUN behaviour.
|
||||||
|
- Absence of unintended traffic.
|
||||||
|
|
||||||
|
## Worker/process-boundary Release Gates
|
||||||
|
|
||||||
|
Before moving packet-plane behaviour across a process boundary:
|
||||||
|
|
||||||
|
- Deterministic in-process behaviour must already be covered.
|
||||||
|
- The same scenario must be covered through message-boundary tests.
|
||||||
|
- The same scenario must be covered through UDP black-box tests where observable.
|
||||||
|
- Packet bytes must be compared before and after crossing the process boundary.
|
||||||
|
- Route recipient/non-recipient sets must match.
|
||||||
|
- Allowed rewrite regions must match.
|
||||||
|
- Source quench/STUN/loop-control behaviour must match.
|
||||||
|
- Failure injection must prove worker crash/restart does not replay stale packets.
|
||||||
|
- Reporting/control worker backpressure must not block packet routing.
|
||||||
|
- Live RF validation is required for protocol-visible behaviour.
|
||||||
|
|
||||||
|
Suggested future test categories:
|
||||||
|
|
||||||
|
- Reporting worker crash during active call.
|
||||||
|
- Global exporter outage.
|
||||||
|
- API worker unavailable during normal traffic.
|
||||||
|
- Routing worker restart while stream active.
|
||||||
|
- Routing worker backpressure.
|
||||||
|
- Queue overflow from packet process to reporting worker.
|
||||||
|
- Stale `PacketReceived` replay prevention.
|
||||||
|
- Duplicate packet prevention after worker restart.
|
||||||
|
- Stream ownership handoff/drain test.
|
||||||
|
- Coordinator restart test, if a coordinator is introduced.
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
# Migration Plan
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- No big-bang rewrite of packet semantics.
|
||||||
|
- Current FreeDMR remains live until FreeDMR 2 is tested.
|
||||||
|
- Build FreeDMR 2 core beside current code where practical.
|
||||||
|
- Preserve behaviours behind tests.
|
||||||
|
- Compatibility adapters are allowed, but old internal shape should not define the new core.
|
||||||
|
- Worker-process scaling is a design direction, not a reason for a big-bang rewrite.
|
||||||
|
|
||||||
|
## Stages
|
||||||
|
|
||||||
|
Stage 0: Stabilise current code, harness, docs, codec, and known behaviour.
|
||||||
|
|
||||||
|
Stage 1: Distil architecture, glossary, state model, and reporting event contract.
|
||||||
|
|
||||||
|
Stage 2: Extract packet/codec helpers and deterministic routing/subscription seams.
|
||||||
|
|
||||||
|
Stage 3: Introduce explicit internal message/event objects in-process.
|
||||||
|
|
||||||
|
Stage 4: Implement new subscription store in parallel with compatibility export if needed.
|
||||||
|
|
||||||
|
Stage 5: Move reporting/MQTT publisher to an independent worker/sidecar.
|
||||||
|
|
||||||
|
Stage 6: Move global lastheard exporter, SQL writes, dashboard aggregation, and non-critical analytics to workers.
|
||||||
|
|
||||||
|
Stage 7: Define v2 API/control-plane operations over sessions, subscriptions, mesh state, and reporting health, and express them as owner-handled `ControlCommand` messages.
|
||||||
|
|
||||||
|
Stage 8: Introduce listener/client session model supporting multiple clients per listener.
|
||||||
|
|
||||||
|
Stage 9: Introduce mesh auth/BCXX identity admission into the control plane.
|
||||||
|
|
||||||
|
Stage 10: Experiment with a single routing-core worker behind the already-tested message interface.
|
||||||
|
|
||||||
|
Stage 11: Evaluate multi-routing-worker sharding only after single-worker routing is stable and covered.
|
||||||
|
|
||||||
|
Stage 12: Cut over only after deterministic, UDP, and live RF validation.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not add general user-to-user private voice routing.
|
||||||
|
- Do not make FreeDMR core a GPS/SMS application processor.
|
||||||
|
- Do not make reporting/dashboard consumers part of packet routing.
|
||||||
|
- Do not encrypt amateur-radio traffic by default.
|
||||||
|
- Do not replace Twisted before extracting and test-covering the core.
|
||||||
|
- Do not preserve the old dashboard protocol inside the FreeDMR 2 packet core.
|
||||||
|
- Do not make worker-process scaling an immediate rewrite requirement.
|
||||||
|
- Do not use Redis, Postgres, MQTT, dashboards, or APIs for live per-packet routing decisions.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Exact cut-over mechanism from current `BRIDGES` state to subscription state.
|
||||||
|
- Whether old dashboard compatibility is shipped as part of FreeDMR 2 or maintained with the dashboard.
|
||||||
|
- Final mesh authentication wire format and key distribution policy.
|
||||||
|
- Live RF validation matrix for repeaters, hotspots, terminals, and analogue/digital bridges.
|
||||||
@ -0,0 +1,289 @@
|
|||||||
|
# Worker Process Scaling
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
FreeDMR 2 should be designed so that, after the protocol/routing/subscription core has been extracted and tested, selected parts of the system can be moved into separate worker processes to improve capacity, isolate failures, and avoid the practical single-thread/GIL limits of one Python process.
|
||||||
|
|
||||||
|
This is not a first-stage rewrite requirement.
|
||||||
|
|
||||||
|
The first stage remains:
|
||||||
|
|
||||||
|
- Keep Twisted initially.
|
||||||
|
- Extract the deterministic core.
|
||||||
|
- Make state ownership explicit.
|
||||||
|
- Preserve packet behaviour through tests.
|
||||||
|
|
||||||
|
But the FreeDMR 2 state model must not block a later move to worker processes.
|
||||||
|
|
||||||
|
## Why Worker Processes
|
||||||
|
|
||||||
|
CPython single-process execution has practical limits for CPU-bound Python code.
|
||||||
|
|
||||||
|
Twisted's single reactor is useful as an initial safety boundary, but one reactor process should not be assumed to be the final capacity architecture.
|
||||||
|
|
||||||
|
Worker processes provide stronger ownership and failure boundaries than shared-memory threads. Process/message boundaries are safer for FreeDMR routing state than no-GIL shared mutable dictionaries.
|
||||||
|
|
||||||
|
Worker processes fit FreeDMR's need for explicit ownership of routing, stream, subscription, loop-control, and reporting state.
|
||||||
|
|
||||||
|
Worker-process scaling must not make ordinary small FreeDMR deployments heavyweight or hard to run. A single-server deployment on a cheap VPS or Raspberry Pi-class system must remain supported.
|
||||||
|
|
||||||
|
## What Should Be Offloaded First
|
||||||
|
|
||||||
|
Low-risk early offload candidates:
|
||||||
|
|
||||||
|
- MQTT/reporting publisher.
|
||||||
|
- Global lastheard exporter.
|
||||||
|
- Dashboard aggregation.
|
||||||
|
- SQL/database writing.
|
||||||
|
- Historical analytics.
|
||||||
|
- Alias download/refresh.
|
||||||
|
- Expensive codec experiments.
|
||||||
|
- Packet capture/replay analysis.
|
||||||
|
- Non-critical maintenance jobs.
|
||||||
|
- Future transcoding/bridge adjuncts.
|
||||||
|
- Future network-analysis or observability tools.
|
||||||
|
|
||||||
|
These workers must be asynchronous from the packet path.
|
||||||
|
|
||||||
|
If they are slow, blocked, crashed, overloaded, or absent, packet routing must continue.
|
||||||
|
|
||||||
|
Reporting completeness is secondary to voice stability.
|
||||||
|
|
||||||
|
## What Should Not Be Offloaded Early
|
||||||
|
|
||||||
|
Do not initially offload hot-path routing decisions if doing so would add IPC, network, database, lock, queue, or back-pressure waits to every DMR packet.
|
||||||
|
|
||||||
|
Specifically keep local and deterministic until the model is proven:
|
||||||
|
|
||||||
|
- Stream admission.
|
||||||
|
- Duplicate suppression.
|
||||||
|
- Loop control.
|
||||||
|
- Source quench checks.
|
||||||
|
- Dial-a-TG state mutation.
|
||||||
|
- Subscription lookup.
|
||||||
|
- Slot/TG rewrite decisions.
|
||||||
|
- Voice/data classification.
|
||||||
|
- Packet mutation/rewrite.
|
||||||
|
- HBP RF-facing tolerance logic.
|
||||||
|
- Protocol-version-sensitive FBP/OBP metadata handling.
|
||||||
|
|
||||||
|
The packet plane must continue to use local in-memory state and must not depend on external databases, MQTT, dashboards, APIs, or reporting consumers.
|
||||||
|
|
||||||
|
## Possible Long-Term Worker Architecture
|
||||||
|
|
||||||
|
### Transport/listener Process
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- UDP sockets.
|
||||||
|
- HBP receive/send.
|
||||||
|
- FBP/OBP receive/send.
|
||||||
|
- Raw packet admission.
|
||||||
|
- Socket identity.
|
||||||
|
- Keepalive.
|
||||||
|
- Low-level protocol parsing.
|
||||||
|
- Forwarding packet events to the owning routing component.
|
||||||
|
|
||||||
|
### Routing Core Worker
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- Subscription state.
|
||||||
|
- Stream state.
|
||||||
|
- Dial-a-TG state.
|
||||||
|
- Loop-control state.
|
||||||
|
- Source-quench state.
|
||||||
|
- Duplicate suppression.
|
||||||
|
- Packet routing decisions.
|
||||||
|
- Explicit packet rewrite decisions.
|
||||||
|
- Authoritative packet-plane state for its assigned clients/streams/TGs.
|
||||||
|
|
||||||
|
### Reporting Worker
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- MQTT publishing.
|
||||||
|
- Retained state refresh.
|
||||||
|
- Reporting event queue.
|
||||||
|
- Dashboard event fanout.
|
||||||
|
- Reporting health events.
|
||||||
|
- Drop/coalesce policy under pressure.
|
||||||
|
|
||||||
|
### Global Exporter Worker
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- Subscribing to local reporting feed.
|
||||||
|
- Filtering and summarising local events.
|
||||||
|
- Publishing curated summaries to global lastheard/network collector.
|
||||||
|
- Retry/spool policy for central outage.
|
||||||
|
|
||||||
|
### Control/API Worker or Control-Plane Adapter
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- Sysop/API requests.
|
||||||
|
- Validating control-plane credentials.
|
||||||
|
- Converting API requests into explicit state-change commands.
|
||||||
|
- Receiving `ControlResult` messages.
|
||||||
|
- Never directly mutating packet-plane state it does not own.
|
||||||
|
|
||||||
|
### Optional Future Codec/Transcode/Analysis Workers
|
||||||
|
|
||||||
|
Own:
|
||||||
|
|
||||||
|
- Expensive or experimental codec work.
|
||||||
|
- Transcoding adjuncts.
|
||||||
|
- Packet replay analysis.
|
||||||
|
- Offline diagnostics.
|
||||||
|
- Future lab features.
|
||||||
|
|
||||||
|
These must remain outside the live packet hot path unless explicitly proven safe.
|
||||||
|
|
||||||
|
## State Ownership Rules
|
||||||
|
|
||||||
|
- Every mutable authoritative state object must have exactly one owner.
|
||||||
|
- Other processes may hold snapshots or caches, but only the owner mutates authoritative state.
|
||||||
|
- Do not use `multiprocessing.Manager().dict()` or shared mutable proxy objects as the main architecture.
|
||||||
|
- Do not recreate a cross-process global `BRIDGES`-style mutable structure.
|
||||||
|
- Use explicit messages/events instead of pretending cross-process state is a normal Python dict.
|
||||||
|
- Packet bytes crossing process boundaries should be immutable.
|
||||||
|
- Packet mutation must remain explicit, named, and testable.
|
||||||
|
- A process boundary must not hide unclear ownership.
|
||||||
|
- State ownership must be visible in tests and documentation.
|
||||||
|
|
||||||
|
## Message Boundary
|
||||||
|
|
||||||
|
Likely internal message families:
|
||||||
|
|
||||||
|
| Message | Plane |
|
||||||
|
| --- | --- |
|
||||||
|
| `PacketReceived` | packet-plane |
|
||||||
|
| `PacketAccepted` | packet-plane |
|
||||||
|
| `PacketDropped` | packet-plane |
|
||||||
|
| `RouteDecision` | packet-plane |
|
||||||
|
| `PacketToSend` | packet-plane |
|
||||||
|
| `PacketMutated` | packet-plane |
|
||||||
|
| `StreamStarted` | packet-plane |
|
||||||
|
| `StreamEnded` | packet-plane |
|
||||||
|
| `StreamLost` | packet-plane |
|
||||||
|
| `SubscriptionActivated` | control-plane |
|
||||||
|
| `SubscriptionDeactivated` | control-plane |
|
||||||
|
| `SubscriptionExpired` | packet-plane |
|
||||||
|
| `SourceQuenchReceived` | packet-plane |
|
||||||
|
| `SourceQuenchSendRequested` | packet-plane |
|
||||||
|
| `StunActivated` | control-plane |
|
||||||
|
| `StunCleared` | control-plane |
|
||||||
|
| `ReportingEvent` | reporting-plane |
|
||||||
|
| `ControlCommand` | control-plane |
|
||||||
|
| `ControlResult` | control-plane |
|
||||||
|
| `WorkerStarted` | worker/supervision-plane |
|
||||||
|
| `WorkerStopping` | worker/supervision-plane |
|
||||||
|
| `WorkerHealth` | worker/supervision-plane |
|
||||||
|
| `WorkerBackpressure` | worker/supervision-plane |
|
||||||
|
| `WorkerCrashed` | worker/supervision-plane |
|
||||||
|
| `WorkerRestarted` | worker/supervision-plane |
|
||||||
|
|
||||||
|
Packet-plane messages must be compact, bounded, and safe for high frequency use.
|
||||||
|
|
||||||
|
## Partitioning / Sharding Options
|
||||||
|
|
||||||
|
Possible future sharding models, without choosing one prematurely:
|
||||||
|
|
||||||
|
- By client/repeater DMR ID.
|
||||||
|
- By listener/access socket.
|
||||||
|
- By conference TG.
|
||||||
|
- By source server / mesh peer.
|
||||||
|
- By stream ID.
|
||||||
|
- Hybrid model.
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- All packets for a given live stream must be processed in order by the same stream owner.
|
||||||
|
- Dial-a-TG state for one client/slot must have one owner.
|
||||||
|
- Subscription state for one client/slot must have one owner.
|
||||||
|
- Loop-control/source-quench state must be consistent for a given TG/stream/source path.
|
||||||
|
- Cross-worker routing must not reintroduce duplicate packets or loops.
|
||||||
|
- Worker assignment must be observable and testable.
|
||||||
|
- Worker assignment must not depend on dashboard/reporting state.
|
||||||
|
- Sharding must preserve the FreeDMR "everything everywhere" mesh principle, subject to existing source quench, STUN, ACL, policy, and authentication rules.
|
||||||
|
|
||||||
|
## Coordinator Model
|
||||||
|
|
||||||
|
FreeDMR 2 may eventually need a lightweight coordinator.
|
||||||
|
|
||||||
|
The coordinator may:
|
||||||
|
|
||||||
|
- Assign clients/sessions/TGs to workers.
|
||||||
|
- Distribute subscription snapshots.
|
||||||
|
- Manage worker health.
|
||||||
|
- Restart workers.
|
||||||
|
- Publish control-plane updates.
|
||||||
|
- Provide routing-worker discovery.
|
||||||
|
- Coordinate graceful drain/restart.
|
||||||
|
|
||||||
|
The coordinator must not:
|
||||||
|
|
||||||
|
- Synchronously participate in every packet routing decision.
|
||||||
|
- Become a single blocking dependency for live voice.
|
||||||
|
- Hide packet-plane state in an external database.
|
||||||
|
- Make ordinary small deployments require clustered infrastructure.
|
||||||
|
|
||||||
|
Single-process/small-server deployment must remain supported. A coordinator should be optional or internal for simple deployments.
|
||||||
|
|
||||||
|
## Failure Behaviour
|
||||||
|
|
||||||
|
- Reporting worker failure: packet routing continues.
|
||||||
|
- Global exporter failure: local service continues.
|
||||||
|
- Dashboard aggregation failure: packet routing continues.
|
||||||
|
- API/control worker failure: existing packet routing continues, but new control actions may fail.
|
||||||
|
- Alias refresh worker failure: current aliases remain in use.
|
||||||
|
- Analytics worker failure: packet routing continues.
|
||||||
|
- Routing worker failure: affected sessions/streams are dropped or restarted according to explicit policy.
|
||||||
|
- Transport/listener failure: affected sockets/sessions are lost until restart.
|
||||||
|
- Worker restart must not replay stale DMR packets.
|
||||||
|
- Retained/reporting state may be refreshed after recovery.
|
||||||
|
- In-flight voice may be lost during worker crash, but failure must not poison the mesh or produce loops.
|
||||||
|
- Backpressure from non-packet workers must not propagate into the packet path.
|
||||||
|
|
||||||
|
## Tests Required Before Worker Split
|
||||||
|
|
||||||
|
Before moving any packet-plane component into a separate process, require:
|
||||||
|
|
||||||
|
- Deterministic harness coverage of the state machine.
|
||||||
|
- UDP black-box coverage of the same behaviour.
|
||||||
|
- Message-boundary tests proving packet bytes and route decisions are preserved.
|
||||||
|
- Failure-injection tests for worker timeout/crash/restart.
|
||||||
|
- Queue-backpressure tests proving reporting/control workers cannot block packets.
|
||||||
|
- Tests proving no stale packet replay after worker restart.
|
||||||
|
- Tests proving source quench, STUN, loop-control, and duplicate suppression are preserved across the boundary.
|
||||||
|
- Live RF validation for protocol-visible behaviour.
|
||||||
|
|
||||||
|
Worker split must not be considered complete until deterministic, UDP, and live RF validation agree for protocol-visible paths.
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
Stage A: Extract pure/deterministic routing/subscription state behind explicit interfaces.
|
||||||
|
|
||||||
|
Stage B: Introduce internal message/event objects in-process.
|
||||||
|
|
||||||
|
Stage C: Move reporting/MQTT/global export to separate processes.
|
||||||
|
|
||||||
|
Stage D: Move slow maintenance, alias refresh, SQL/global lastheard, and analytics work to workers.
|
||||||
|
|
||||||
|
Stage E: Experiment with routing core as a child process behind the same message interface.
|
||||||
|
|
||||||
|
Stage F: Evaluate multi-routing-worker sharding only after the single routing-worker process model is stable and fully tested.
|
||||||
|
|
||||||
|
Stage G: Only after the above, consider whether transport/listener processes should be split by listener, client set, protocol, or deployment role.
|
||||||
|
|
||||||
|
## Explicit Non-Goals
|
||||||
|
|
||||||
|
- Do not introduce shared mutable cross-process `BRIDGES`-like state.
|
||||||
|
- Do not depend on Redis/Postgres/MQTT for per-packet routing decisions.
|
||||||
|
- Do not require heavyweight infrastructure for ordinary single-server deployments.
|
||||||
|
- Do not use worker processes to hide unclear state ownership.
|
||||||
|
- Do not move protocol-sensitive packet mutation across process boundaries until byte-preservation and rewrite tests prove equivalence.
|
||||||
|
- Do not assume no-GIL Python solves FreeDMR's state ownership problem.
|
||||||
|
- Do not replace Twisted and introduce worker sharding in the same step.
|
||||||
|
- Do not make reporting, dashboard, SQL, global lastheard, or API availability part of the packet routing path.
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
# FreeDMR 2 Architecture Notes
|
||||||
|
|
||||||
|
This directory contains the distilled FreeDMR 2 design notes. The older notes in `docs/codex-notes.md`, `docs/freedmr-2-architecture-decisions.md`, and the test/API docs remain source material and engineering history.
|
||||||
|
|
||||||
|
FreeDMR 2 is not a blind rewrite of packet behaviour. The protected asset is the FreeDMR operating model: DMR packet semantics, dial-a-TG, TG/DMR-ID-centric routing, loop control, source quench, mesh behaviour, RF-side tolerance, data forwarding, and practical amateur-radio interoperability.
|
||||||
|
|
||||||
|
Recommended reading order:
|
||||||
|
|
||||||
|
1. `00-glossary.md`
|
||||||
|
2. `01-system-model.md`
|
||||||
|
3. `02-state-model.md`
|
||||||
|
4. `03-subscription-model.md`
|
||||||
|
5. `04-packet-and-stream-model.md`
|
||||||
|
6. `05-data-packet-policy.md`
|
||||||
|
7. `06-mesh-model.md`
|
||||||
|
8. `07-reporting-model.md`
|
||||||
|
9. `08-api-control-model.md`
|
||||||
|
10. `09-security-model.md`
|
||||||
|
11. `10-runtime-and-concurrency.md`
|
||||||
|
12. `13-worker-process-scaling.md`
|
||||||
|
13. `11-testing-and-release-gates.md`
|
||||||
|
14. `12-migration-plan.md`
|
||||||
|
|
||||||
|
Architecture Decision Records live in `adr/`. They record proposed FreeDMR 2 decisions separately from implementation work.
|
||||||
|
|
||||||
|
Current FreeDMR 1.x remains live until FreeDMR 2 is tested. Compatibility adapters are allowed where useful, but old HBLink object layout, dashboard socket assumptions, and legacy `BRIDGES` structure should not define the FreeDMR 2 core.
|
||||||
|
|
||||||
|
FreeDMR 2 keeps Twisted initially but is designed for eventual worker-process scaling. Non-packet-path workers such as reporting/global export move first; packet-plane routing workers are a later stage after state ownership and message-boundary tests are proven.
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# ADR 0001: Protected Model, Not HBLink Structure
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
FreeDMR current code is HBLink-derived and centred around `bridge_master.py`, `hblink.py`, configured `MASTER`/`SYSTEM` stanzas, global `BRIDGES`, Twisted, and the current dashboard/report model.
|
||||||
|
|
||||||
|
The valuable part is the operating model learned from real deployments: packet semantics, dial-a-TG, TG/DMR-ID-centric routing, loop control, source quench, mesh behaviour, RF-side tolerance, data forwarding, and practical amateur-radio interoperability.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
The protected asset is FreeDMR behaviour/model, not the HBLink-derived object layout.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
HBLink-era structure blocks clarity, scaling, multi-client listeners, and testability. FreeDMR 2 should preserve validated packet behaviour while allowing cleaner internal models.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
FreeDMR 2 may replace legacy internal structures. Behaviour changes still require tests and live validation where protocol-visible.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
Compatibility views may expose old shapes such as `BRIDGES`, `MASTER`, `SYSTEM`, or `#` reflector names, but they are adapters, not authoritative core state.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Regression tests must cover routing, dial-a-TG, loop control, source quench, data forwarding, and packet rewrite behaviour before internal models are replaced.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0002: Keep Twisted Initially
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Twisted currently provides UDP transport, timers, and a single-threaded reactor boundary. Packet behaviour is subtle and production-proven.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Keep Twisted initially as a transport safety boundary; consider replacement only after it is a thin shell.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Replacing the event loop and state model at the same time creates avoidable risk. Extracting the routing/subscription core first gives deterministic test coverage and clearer future migration options.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
FreeDMR 2 starts with evolutionary architecture work, not an event-loop rewrite. Twisted callbacks must remain non-blocking.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
Current deployment and transport behaviour can remain familiar while the core model is extracted.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Deterministic core tests and UDP black-box tests must cover behaviour before any later Twisted replacement is considered.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0003: Subscription Model Replaces BRIDGES
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The legacy `BRIDGES` dict mixes configuration, runtime state, reflector naming, routing, and export concerns. FreeDMR 2 models each TG as a conference bridge to which clients subscribe.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use subscription-oriented internal state instead of legacy `BRIDGES` as the authoritative FreeDMR 2 model.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Subscription state directly represents the FreeDMR user model: clients subscribe to TGs they want to hear. It supports direct TGs, dial-a-TG, default reflectors, API control, and future aliases without hard-coding routing modes.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
The packet path can use indexed subscription lookups. Existing dashboard/config expectations need compatibility export or migration.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
`BRIDGES` and `#` reflector names may be generated as compatibility/export state where required, but routing should not depend on them.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Tests must assert subscription activation, expiry, direct TG routing, dial-a-TG mapping, default reflector behaviour, and absence of unintended recipients.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0004: Reporting v2 Replaces Legacy Dashboard Protocol
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The old dashboard/report socket has shaped parts of the current implementation and has caused operational friction. FreeDMR 1.x remains live while FreeDMR 2 is developed.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
FreeDMR 2 reporting is a new structured event contract. The old dashboard/report socket is not a compatibility constraint inside the core.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Reporting must be observational only. A clean event schema avoids leaking legacy `BRIDGES`/`SYSTEM` state into the new packet core.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
The dashboard must be updated or served by an adapter. The core can emit stable v2 events without preserving legacy report names.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
Old dashboard support belongs in a sidecar or adapter, not in packet routing.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Tests must assert expected v2 events for server, client, subscription, call, mesh, loop, and reporting-health changes.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0005: MQTT Reporting Transport
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
FreeDMR needs live local dashboard state and non-real-time global lastheard feeds without blocking packet handling.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
MQTT is the preferred external live reporting transport, fed through a non-blocking bounded queue and independent publisher.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
MQTT is lightweight, familiar in radio/network operations, supports topics, retained state, last-will messages, and network fanout. A local broker lets extra consumers attach without adding work to the packet process.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
FreeDMR gains a broker dependency or optional integration. Reporting completeness is best-effort under pressure.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
Legacy dashboard consumers need an adapter or dashboard update. Packet routing must continue if MQTT or consumers are unavailable.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Tests must cover enqueue, overflow/drop policy, publisher disconnect/reconnect events, retained state refresh, and packet-path non-blocking behaviour.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0006: Local Dashboard and Global Lastheard Are Separate
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Each server has its own live dashboard. The global lastheard service is centrally hosted and non-real-time.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Local dashboard consumes local live feed; global lastheard consumes curated summaries via exporter/collector.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Local live visibility must survive central outages. Global aggregation should not add packet-process load or require the core to do database/export work.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
A separate exporter process may be needed for global feeds. The broker handles fanout.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
Existing global lastheard behaviour should be migrated to consume summaries rather than packet-plane events.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Tests should confirm local reporting works without global exporter connectivity and that exporter failure does not affect packet handling.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0007: Synthetic LC Service Options
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Legacy HBLink used `0x20` in synthetic LC service options. Later MMDVMHost evidence indicates `0x20` was an early OVCM bit-position mistake; standards-clean OVCM is `0x04`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Synthetic group voice LC uses normal service options `0x00` by default. OVCM is `0x04` if explicitly selected. HBLink `0x20` is legacy compatibility only. Real inbound LC is preserved.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
FreeDMR should not set reserved service-option bits in newly generated LC. Real inbound LC should not be rewritten without a deliberate reason.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
Generated fallback LC becomes cleaner. Some legacy interop assumptions may need live RF testing.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
`0x20` may remain as a named legacy compatibility option, not as the default or as a traffic marker.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Codec/unit tests must assert synthetic LC defaults to `0x00`, OVCM uses `0x04`, real inbound LC is preserved, and `0x20` is only used when explicitly configured for compatibility.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0008: Data Packet Forwarding Policy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
FreeDMR currently supports DMR data forwarding, including data gateway concepts and last-known-location routing. GPS/SMS application processing is better handled outside the core.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
FreeDMR continues forwarding DMR data packets but does not become a general GPS/SMS application processor.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Data support is part of network interoperability. Application processing would add complexity and CPU cost to the packet process and is better done by FBP-connected systems or sidecars.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
The core must preserve data packet routing semantics while keeping application parsing out of the hot path.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
Existing `SUB_MAP` / last-known-location behaviour remains intentional. `DATA-GATEWAY` remains a supported concept where useful.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Tests must cover group-addressed data, unit-addressed data, last-known-location routing, data-vs-voice reporting, and preservation of data forwarding over FBP.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0009: Mesh Authentication Without Default Encryption
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
FreeDMR is an amateur-radio network. In many jurisdictions amateur-radio traffic must not be encrypted, and IP backhaul may itself use amateur-radio links.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use authenticity, integrity, membership validation, and local policy; do not encrypt amateur-radio mesh traffic by default.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Signing and authentication protect the mesh from impersonation and unauthorized traffic while preserving FreeDMR's open, inspectable, amateur-radio character.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
Traffic remains visible. Security focuses on who is allowed to inject or carry traffic, not secrecy.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
Existing cleartext FBP/OBP interop remains possible. New authenticated admission can be introduced through bridge-control mechanisms and cached session state.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Tests must cover valid identity, invalid signature, revocation, endpoint change requiring re-authentication, grace expiry, and local policy overriding signed membership.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0010: API Is Control Plane Only
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The API is useful for local administration, automation, and dashboard control. FreeDMR is a live voice stream program, so API work must not delay packet processing.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
API operations are bounded control-plane actions and must not perform heavy serialization or block packet routing.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Control operations should act on sessions, subscriptions, mesh peers, and reporting state. Heavy views should come from snapshots or reporting feeds.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
The API remains small and predictable. Complex dashboard state should not be assembled synchronously from hot packet state.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
Old API endpoints may be adapted if needed, but the v2 API should not expose raw legacy internals as its primary contract.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Tests must cover auth, bounded request bodies, destructive-action gating, audit logging, and packet-path independence under API load.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0011: Process/Actor Model Over No-GIL Threading
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
FreeDMR may need more concurrency for reporting, export, analysis, or future scaling. Shared mutable routing state is risky.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
If FreeDMR 2 needs concurrency beyond the reactor, prefer explicit parent/child or actor-style ownership boundaries over shared-memory no-GIL threading for routing state.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Single-owner state and explicit messages are easier for sysops and contributors to reason about. They reduce race risks in delay-sensitive packet handling.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
Some features may require serialization and message protocols between processes. This is clearer than shared locks around routing dictionaries.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
Twisted can remain the initial transport shell while workers handle reporting/export or expensive tasks.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Tests must cover worker failure, queue overflow, restart behaviour, message ordering where required, and packet handling continuing when a non-critical worker fails.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0012: Testing Gates for Protocol-Visible Change
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
FreeDMR behaviour was validated through real global servers, RF links, and community use. Protocol-visible changes can affect repeaters, terminals, dashboards, and peer servers.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Protocol-visible changes require deterministic, UDP, and live RF validation according to release gates.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
Deterministic tests catch state and rewrite errors. UDP black-box tests catch transport/subprocess/protocol integration issues. Live RF catches terminal/repeater quirks that harnesses cannot prove.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
Some changes take longer to release. The risk of breaking real deployments is reduced.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
FreeDMR 1.x remains live while FreeDMR 2 behaviour is validated. Changes can be staged behind compatibility adapters.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
Level 0 unit/support tests, Level 1 deterministic harness, Level 2 UDP black-box harness, and Level 3 live RF validation are required according to the risk and protocol visibility of the change.
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
# ADR 0013: Worker Process Capacity Scaling
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
FreeDMR currently relies heavily on a single Python process, Twisted reactor callbacks, and in-memory mutable state inherited from the HBLink-era architecture.
|
||||||
|
|
||||||
|
This is useful as an initial safety boundary because it avoids many shared-memory races, but it also creates practical capacity limits and makes some kinds of expensive work unsafe in the packet path.
|
||||||
|
|
||||||
|
CPython's GIL and single-reactor execution mean that CPU-bound work, reporting fanout, SQL/global export, analytics, and future codec/transcoding work should not be assumed to scale inside one process.
|
||||||
|
|
||||||
|
At the same time, FreeDMR's packet/routing state is subtle and protocol-sensitive. Moving it prematurely across process boundaries could introduce latency, ordering bugs, stale packet replay, duplicate packets, routing loops, broken source quench, or incorrect packet mutation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
FreeDMR 2 will be designed for eventual worker-process scaling, but the first migration stage will keep Twisted as the transport shell and extract/test the routing/subscription core in-process.
|
||||||
|
|
||||||
|
The first worker-process targets are non-packet-path or low-risk side effects:
|
||||||
|
|
||||||
|
- Reporting/MQTT publisher.
|
||||||
|
- Global lastheard exporter.
|
||||||
|
- SQL/database writes.
|
||||||
|
- Dashboard aggregation.
|
||||||
|
- Alias refresh.
|
||||||
|
- Analytics.
|
||||||
|
- Packet replay/diagnostics.
|
||||||
|
- Future codec/transcoding adjuncts.
|
||||||
|
|
||||||
|
Packet-plane routing may move behind a process/message boundary later, but only after state ownership, subscription lookup, stream lifecycle, loop control, source quench, and packet mutation semantics are covered by deterministic, UDP, and live RF tests.
|
||||||
|
|
||||||
|
FreeDMR 2 prefers explicit process/actor ownership boundaries over shared-memory threading or no-GIL Python for authoritative routing state.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
- Worker processes provide clearer ownership and failure boundaries.
|
||||||
|
- Explicit messages are easier to test than shared mutable dictionaries.
|
||||||
|
- Reporting/export failures must not affect packet routing.
|
||||||
|
- FreeDMR should be able to scale beyond one reactor process without making ordinary small deployments complex.
|
||||||
|
- No-GIL Python does not remove the need for state ownership discipline.
|
||||||
|
- A process model better matches FreeDMR's distributed-system nature: packet events, routing decisions, control messages, and reporting events are already conceptually separate.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Positive:
|
||||||
|
|
||||||
|
- Clearer state ownership.
|
||||||
|
- Improved future capacity.
|
||||||
|
- Safer isolation of reporting/export/database work.
|
||||||
|
- Better failure containment.
|
||||||
|
- Better testability of message boundaries.
|
||||||
|
- Easier future sharding by client, TG, stream, listener, or mesh peer.
|
||||||
|
|
||||||
|
Negative:
|
||||||
|
|
||||||
|
- More implementation complexity.
|
||||||
|
- Message schemas must be designed and versioned.
|
||||||
|
- IPC adds latency and failure modes.
|
||||||
|
- Routing-worker split requires strong tests.
|
||||||
|
- Worker supervision and restart policy become part of the system design.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
FreeDMR 1.x/current code remains live until FreeDMR 2 is ready.
|
||||||
|
|
||||||
|
Initial FreeDMR 2 worker work should not change packet semantics.
|
||||||
|
|
||||||
|
Legacy dashboard/reporting compatibility, if required, belongs in reporting/export adapters, not in the packet core.
|
||||||
|
|
||||||
|
A single-process deployment must remain supported for small servers.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
Before any packet-plane worker split:
|
||||||
|
|
||||||
|
- Deterministic harness coverage of the state machine.
|
||||||
|
- Message-boundary equivalence tests.
|
||||||
|
- UDP black-box equivalence tests.
|
||||||
|
- Packet byte preservation tests.
|
||||||
|
- Allowed rewrite-region tests.
|
||||||
|
- Source quench/STUN/loop-control tests.
|
||||||
|
- Duplicate/out-of-order tests.
|
||||||
|
- Worker crash/restart tests.
|
||||||
|
- Stale packet replay prevention tests.
|
||||||
|
- Queue backpressure tests.
|
||||||
|
- Live RF validation for protocol-visible behaviour.
|
||||||
@ -0,0 +1,653 @@
|
|||||||
|
# FreeDMR End-to-End Packet Test Harness Design
|
||||||
|
|
||||||
|
For concise commands to run these tests, see [testing.md](testing.md).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
FreeDMR needs two complementary packet test layers:
|
||||||
|
|
||||||
|
1. An in-process deterministic harness for fast, isolated tests of decoded packet
|
||||||
|
handling in `bridge_master.py`.
|
||||||
|
2. A black-box UDP integration harness for realistic process, socket, login,
|
||||||
|
authentication and packet-cadence tests.
|
||||||
|
|
||||||
|
Both layers now exist as test-only code under `tests/`. The current
|
||||||
|
implementation is intentionally small: it establishes the harness architecture,
|
||||||
|
packet builders, captures, dependency isolation, and one smoke scenario per
|
||||||
|
layer. The scenario set should be expanded incrementally without changing
|
||||||
|
production behaviour.
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
The harness code is split as follows:
|
||||||
|
|
||||||
|
- `tests/harness/deterministic.py`
|
||||||
|
- `PacketSpec`: synthetic `DMRD` packet builder and decoded argument adapter.
|
||||||
|
- `parse_dmr_fields()`: shared parser for captured `DMRD` payload assertions.
|
||||||
|
- `PacketCapture` and `CapturedPacket`: in-process send capture.
|
||||||
|
- `ReportCapture`, `FakeClock`, `FakeReactor`, and `FakeTransport`: test
|
||||||
|
doubles for FreeDMR runtime boundaries.
|
||||||
|
- `DeterministicScenario`: isolated in-process scenario setup for
|
||||||
|
`bridge_master.py` globals and real `routerHBP` / `routerOBP` instances.
|
||||||
|
- `minimal_config()`, `active_bridge()`, and `add_openbridge_system()`:
|
||||||
|
helpers for small test topologies.
|
||||||
|
- `tests/harness/udp_blackbox.py`
|
||||||
|
- `DependencySandbox`: chooses an interpreter for starting FreeDMR, or
|
||||||
|
bootstraps a venv from `requirements.txt` when explicitly enabled.
|
||||||
|
- `write_bridge_master_config()`: emits a loopback-only subprocess config,
|
||||||
|
including optional OpenBridge/FBP peer sections.
|
||||||
|
- `FreeDmrProcess`: starts and stops `bridge_master.py`.
|
||||||
|
- `HbpRepeater`: UDP HBP client emulator with login, ping, packet send, stream
|
||||||
|
send, and capture support. The initial login challenge is retried for a
|
||||||
|
bounded startup window so subprocess startup work does not race the first
|
||||||
|
test packet.
|
||||||
|
- `FbpPeer`: UDP FBP v5 peer emulator with signed packet sends, keepalive,
|
||||||
|
version negotiation, STUN and source-quench control helpers.
|
||||||
|
- `UdpBlackBoxScenario`: process plus two-master loopback topology with
|
||||||
|
optional FBP peers.
|
||||||
|
- `tests/test_deterministic_harness.py`
|
||||||
|
- Packet builder smoke coverage.
|
||||||
|
- In-process HBP static TG routing smoke coverage, skipped when runtime
|
||||||
|
dependencies needed to import `bridge_master.py` are unavailable.
|
||||||
|
- Dial-a-TG TS1 private-call control and status reporting of TS2 reflector
|
||||||
|
state, including reserved target no-op behavior.
|
||||||
|
- `tests/test_udp_blackbox_harness.py`
|
||||||
|
- Opt-in subprocess UDP coverage for two registered repeaters and static TG 91
|
||||||
|
routing.
|
||||||
|
- Opt-in dial-a-TG prompt coverage for a reserved control private call,
|
||||||
|
asserting local TG9 TS2 announcement packets and no inter-master UDP leak.
|
||||||
|
- Opt-in FBP v5 coverage for HBP-to-FBP and FBP-to-HBP static TG routing,
|
||||||
|
source-quench suppression, and network-ID rejection.
|
||||||
|
|
||||||
|
The concise run commands live in [testing.md](testing.md).
|
||||||
|
|
||||||
|
## Layer 1: In-Process Deterministic Harness
|
||||||
|
|
||||||
|
The deterministic harness bypasses UDP sockets and DMR 30 ms slot timing. The
|
||||||
|
implemented scenario path uses the router seam and supports a parser seam:
|
||||||
|
|
||||||
|
- Parser seam: feed raw `DMRD` bytes directly to
|
||||||
|
`HBSYSTEM.master_datagramReceived()` or `OPENBRIDGE.datagramReceived()` with a
|
||||||
|
fake source address and fake transport via `DeterministicScenario.inject_datagram()`.
|
||||||
|
This tests packet parsing and transport gates without binding sockets. `DMRE`
|
||||||
|
packet-builder support is planned but not implemented yet.
|
||||||
|
- Router seam: inject already-decoded packet metadata at the smallest safe seam
|
||||||
|
around `bridge_master.py`.
|
||||||
|
|
||||||
|
The router seam is implemented and is the default for most scenarios:
|
||||||
|
|
||||||
|
- HBP traffic enters at `routerHBP.dmrd_received(peer_id, rf_src, dst_id, seq,
|
||||||
|
slot, call_type, frame_type, dtype_vseq, stream_id, data)`.
|
||||||
|
- OpenBridge traffic enters at `routerOBP.dmrd_received(peer_id, rf_src, dst_id,
|
||||||
|
seq, slot, call_type, frame_type, dtype_vseq, stream_id, data, hash, hops,
|
||||||
|
source_server, ber, rssi, source_rptr)`.
|
||||||
|
- Outbound traffic is captured by replacing each test system's `send_system()`
|
||||||
|
method. Production routing calls `systems[target].send_system(...)` after
|
||||||
|
applying intended rewrites, making it the narrow outbound observation point.
|
||||||
|
|
||||||
|
The harness owns test-only state setup:
|
||||||
|
|
||||||
|
- Build a minimal `CONFIG`, `BRIDGES`, `SUB_MAP`, aliases and `systems` map per
|
||||||
|
scenario.
|
||||||
|
- Instantiate real `routerHBP` and `routerOBP` objects.
|
||||||
|
- Replace network-facing sends and report sends with capture objects.
|
||||||
|
- Provide a fake clock by monkeypatching `bridge_master.time`.
|
||||||
|
- Generate synthetic `DMRD` payloads while preserving original bytes for
|
||||||
|
comparison. Recorded fixture loading and `DMRE` fixture generation are planned
|
||||||
|
extensions.
|
||||||
|
|
||||||
|
This layer treats timing as explicit scenario input. A test can advance the fake
|
||||||
|
clock by 0.030 seconds per frame, by several seconds for hangtime, or by minutes
|
||||||
|
for rule timeout checks without sleeping.
|
||||||
|
|
||||||
|
### Bugs Layer 1 Can Detect
|
||||||
|
|
||||||
|
- Packet parsing bugs when tests use the parser seam: incorrect source,
|
||||||
|
destination, slot, call type, frame type, dtype/voice sequence or stream ID
|
||||||
|
extraction from raw bytes.
|
||||||
|
- Routing-rule mistakes: wrong target system, duplicate routing, missed bridge,
|
||||||
|
wrong active/inactive bridge selection.
|
||||||
|
- Dial-a-TG state transition bugs: reflector bridge creation, activation,
|
||||||
|
deactivation, timer reset and single-mode behaviour.
|
||||||
|
- Dial-a-TG system-scope bugs: control calls on one master should only mutate
|
||||||
|
that receiving master's TS2 reflector state, not another master's entry in the
|
||||||
|
same bridge.
|
||||||
|
- Dial-a-TG control-slot bugs: private calls from TS1 or TS2 should control the
|
||||||
|
TS2 reflector state so TS1 can disconnect or retune TS2 when TS2 RF is busy.
|
||||||
|
Status query `5000` follows the same rule and reports TS2 reflector state from
|
||||||
|
either RF slot.
|
||||||
|
- Dial-a-TG status-report bugs: private call `5000` should report one active
|
||||||
|
TS2 reflector for the receiving master. It does not repair inconsistent
|
||||||
|
multi-active reflector state.
|
||||||
|
- Dial-a-TG reserved-target bugs: private calls to local/control targets such as
|
||||||
|
`5`, `6`, `7`, and `9`, and reserved control-range targets `4001..4999`,
|
||||||
|
should not create, activate or retune reflector state. The `4001..4999`
|
||||||
|
range should report busy rather than announce a successful link.
|
||||||
|
- Dial-a-TG AllStar-control bugs: private call `8` is an AllStar mode control
|
||||||
|
target. When AllStar is disabled it reports busy; when enabled it enters
|
||||||
|
AllStar mode and schedules reset. It should not create or retune reflector
|
||||||
|
state or announce a dial-a-TG link.
|
||||||
|
- Dial-a-TG default-dial configuration bugs: startup and live options reload
|
||||||
|
should use the same prohibited default targets. Reserved/control targets such
|
||||||
|
as `6`, `7`, and AllStar control target `8` should not create an active
|
||||||
|
default reflector at startup. `DEFAULT_DIAL_TS1` and `DEFAULT_DIAL_TS2` are
|
||||||
|
the canonical per-slot defaults; deprecated `DEFAULT_REFLECTOR`, `DIAL` and
|
||||||
|
`StartRef` remain TS2 compatibility aliases. The FreeDMR policy cap should
|
||||||
|
match RF dial-a-TG handling: `999999` is valid and higher defaults are
|
||||||
|
rejected. Invalid default options should disable any existing default for the
|
||||||
|
current session rather than preserving stale TG9 reflector state. Invalid
|
||||||
|
startup defaults should be logged and should not create bridge state; the
|
||||||
|
in-memory effective default should normalize to `0` without writing back to
|
||||||
|
the config file. System-wide defaults are intended for sparing use; client
|
||||||
|
requested settings are preferential.
|
||||||
|
- Static TG configuration bugs: startup and live options reload should reject
|
||||||
|
prohibited local/control TGs consistently on both TS1 and TS2 after parsing the
|
||||||
|
configured TG strings to integers. Invalid IDs at or above `16777215` should
|
||||||
|
be rejected consistently. Simple whitespace should be normalized away. Invalid
|
||||||
|
tokens inside one static TG list should be skipped without blocking other
|
||||||
|
valid tokens or the other slot's valid list. A prohibited TS1 static TG should
|
||||||
|
not be logged as ignored and then created anyway.
|
||||||
|
- Client options parsing bugs: malformed independent numeric fields such as
|
||||||
|
`IDENTTG=A`, `VOICE=A`, or `SINGLE=A` should not abort otherwise valid session
|
||||||
|
options in the same string. `VOICE` and `SINGLE` accept only `0` or `1`.
|
||||||
|
Empty `DIAL` / `DEFAULT_REFLECTOR` is equivalent to `0` and means no TS2
|
||||||
|
default reflector. Invalid `TIMER` values should be logged and should not
|
||||||
|
block valid static TG changes, which should use the current effective timer.
|
||||||
|
- Voice ident override bugs: `OVERRIDE_IDENT_TG` should be parsed before packet
|
||||||
|
generation. Valid positive TGs below all-call are used as the destination;
|
||||||
|
empty or false override values use all-call; malformed, out-of-range, or
|
||||||
|
local/control TG values are logged and fall back to all-call.
|
||||||
|
- Voice prompt stream scoping bugs: generated prompt helpers should use the
|
||||||
|
router instance's `self._system` for stream bookkeeping. A stale module-level
|
||||||
|
`system` loop variable must not cause prompt packets for one master to mutate
|
||||||
|
another master's status.
|
||||||
|
- Voice ident lifecycle bugs: generated ident playback should use the same
|
||||||
|
prompt token/cancellation lifecycle as other generated voice helpers, so an
|
||||||
|
interrupted ident cannot leave stale cancel state that blocks later idents.
|
||||||
|
- Bridge reset lifecycle bugs: resetting a master should leave that master
|
||||||
|
represented by an inactive bridge entry, preserve unrelated bridge entries,
|
||||||
|
and keep reflector activation triggers such as `ON=[235]` for `#235` bridges.
|
||||||
|
- HBP reset/reload admission bugs: packets should be admitted when lifecycle
|
||||||
|
flags are absent or false, and should be dropped with a one-shot log while the
|
||||||
|
receiving master is actively resetting or reloading options.
|
||||||
|
- Data packet reporting bugs: HBP unit data forwarded to OBP should report on
|
||||||
|
the OBP target system and reporting must not raise after the packet has
|
||||||
|
already been sent.
|
||||||
|
- Data packet metadata bugs: HBP unit data forwarded to OpenBridge/FBP should
|
||||||
|
preserve BER/RSSI send metadata just like the group/voice path, while
|
||||||
|
DATA-GATEWAY remains a protocol-v1 SMS/GPS path and must not be evaluated as
|
||||||
|
an FBP peer.
|
||||||
|
- OBP unit-data FBP metadata bugs: unit data forwarded from one FBP peer to
|
||||||
|
another should preserve source server, source repeater for protocol versions
|
||||||
|
that support it, hops, BER and RSSI without treating lower protocol versions
|
||||||
|
as if they carried every field.
|
||||||
|
- OpenBridge parser bugs: truncated `DMRE` datagrams should be logged and
|
||||||
|
discarded before fixed-offset metadata parsing, so malformed UDP input cannot
|
||||||
|
raise out of the parser.
|
||||||
|
- OpenBridge bridge-control bugs: generated `BCST` STUN packets should validate
|
||||||
|
against the same signed bytes on receive and set the traffic gate that
|
||||||
|
production OpenBridge send/receive paths already check.
|
||||||
|
- OpenBridge source-quench bugs: dial-a-TG reflector forwarding from HBP to FBP
|
||||||
|
should apply `BCSQ` using the reflector TG carried on FBP, not local TG9.
|
||||||
|
- HBP parser bugs: truncated `DMRD` datagrams from connected peers should be
|
||||||
|
logged and discarded before fixed-offset header parsing reaches decoded packet
|
||||||
|
handling.
|
||||||
|
- Data packet HBP target-slot reporting bugs: unit data forwarded to HBP via
|
||||||
|
`SUB_MAP` should report the explicit target slot `_d_slot`, matching the
|
||||||
|
captured packet slot bits.
|
||||||
|
- Group-addressed data reporting bugs: group data headers and data continuation
|
||||||
|
blocks routed over TG bridges should be reported as data, not as `GROUP VOICE`
|
||||||
|
lifecycle events. Timeout cleanup should not create voice end events for
|
||||||
|
data-only state.
|
||||||
|
- Voice LC rewrite boundary bugs: embedded-LC rewrite should apply only to voice
|
||||||
|
bursts B-E and must not mutate data-sync/control payload bytes while forwarding
|
||||||
|
over HBP or FBP paths. Same-TG voice forwarding should preserve burst payload
|
||||||
|
bytes so embedded Talker Alias/GPS-like LC can traverse; target-TG mapped
|
||||||
|
forwarding should still regenerate embedded LC for the rewritten TG.
|
||||||
|
- Embedded-LC observability bugs: accepted in-call Talker Alias and GPS LC cycles
|
||||||
|
should be decoded with the standalone MMDVMHost-style embedded-LC codec and
|
||||||
|
logged without changing packet routing or mutation behaviour.
|
||||||
|
- HBP group/VCSBK rate-control bugs: same-timestamp packet bursts should not
|
||||||
|
raise during local packet-rate calculation before duplicate/drop handling can
|
||||||
|
run.
|
||||||
|
- OBP group voice rate-control bugs: per-stream packet-rate protection should
|
||||||
|
use elapsed stream duration, not the absolute stream start timestamp.
|
||||||
|
- OBP voice lifecycle/report-coupling bugs: terminators should mark streams
|
||||||
|
finished even when live reporting is disabled, so late same-stream packets do
|
||||||
|
not route because a dashboard option is off.
|
||||||
|
- OBP voice rewrite error-path bugs: missing target LC state should log with the
|
||||||
|
correct router name and should not crash while handling malformed or
|
||||||
|
inconsistent stream state.
|
||||||
|
- HBP voice lifecycle bugs: terminators should mark the slot stream finished so
|
||||||
|
late same-stream voice bursts do not route or reopen the ended stream, and
|
||||||
|
new-stream classification must not inherit stale data state from the previous
|
||||||
|
slot occupant. Terminator-only first observations on idle slots should still
|
||||||
|
close the stream locally.
|
||||||
|
- HBP/OBP voice packet-control bugs: DMRD sequence numbers are one-byte
|
||||||
|
modulo-256 values. The deterministic harness verifies streams continue after
|
||||||
|
wrap with packet loss and that sequence `0` duplicates are still rejected.
|
||||||
|
- HBP stale duplicate-state bugs: new HBP streams should reset per-stream
|
||||||
|
duplicate state such as `lastSeq` and `lastData` so a stream after a timeout
|
||||||
|
is not judged against the previous slot occupant.
|
||||||
|
- OpenBridge target lifecycle bugs: forwarded voice terminators should mark OBP
|
||||||
|
target streams finished so timeout cleanup only handles missing terminators,
|
||||||
|
not streams that already ended normally.
|
||||||
|
- HBP VCSBK reporting bugs: specific VCSBK block data reports should not be
|
||||||
|
duplicated by generic `OTHER DATA` fallback reports; unknown VCSBK types
|
||||||
|
should still use the fallback and should not create voice lifecycle reports.
|
||||||
|
- OBP unit-data loop-control bugs: same-timestamp duplicate OBP sources should
|
||||||
|
not crash diagnostic packet-rate calculations while first-source loop-control
|
||||||
|
ignores the later source.
|
||||||
|
- Enhanced OpenBridge sendability bugs: enhanced OBP targets require recent
|
||||||
|
`_bcka` keepalive state before receiving forwarded traffic. Missing or stale
|
||||||
|
keepalive state should suppress HBP-originated voice/data and OBP-originated
|
||||||
|
data without mutating packet bytes.
|
||||||
|
- Config/startup support bugs: config booleans should be parsed as booleans
|
||||||
|
across both `hblink.py` admission and `bridge_master.py` forwarding layers,
|
||||||
|
alias reload timing should use the already-normalized seconds value, alias
|
||||||
|
reloads should update both module globals and shared `CONFIG` dictionaries,
|
||||||
|
and bridge reset should tolerate session keys removed by hblink disconnect
|
||||||
|
lifecycle.
|
||||||
|
- Protocol-version-sensitive metadata: packet metadata/options and argument
|
||||||
|
ordering must be asserted against the protocol version actually in use for the
|
||||||
|
session. FBP expectations must not be applied to protocol v1 DATA-GATEWAY
|
||||||
|
traffic.
|
||||||
|
- Data packet protocol model: data packets are packet-oriented rather than
|
||||||
|
AMBE2+ audio-style streams, and may be unit addressed or group addressed to a
|
||||||
|
talkgroup.
|
||||||
|
- Dial-a-TG echo-target regressions: private call `9990` is intentionally
|
||||||
|
linkable as an echo/test target, while `9991..9999` remain information
|
||||||
|
services.
|
||||||
|
- Dial-a-TG information-service regressions: private calls to `9991..9999`
|
||||||
|
schedule the requested on-demand AMBE file and also keep the existing generic
|
||||||
|
silence speech scheduling. They should not create or retune reflector state.
|
||||||
|
- Dial-a-TG policy-range regressions: the current FreeDMR dial-a-TG link policy
|
||||||
|
caps link targets at `999999`. `999999` remains linkable; higher private-call
|
||||||
|
targets should report busy rather than announcing a successful link.
|
||||||
|
- Private voice lifecycle bugs: private unit calls such as dial-a-TG and AMI
|
||||||
|
control should not be timed out as `GROUP VOICE` RX lifecycle events.
|
||||||
|
- Dial-a-TG FBP target regressions: when a linkable reflector target is created,
|
||||||
|
matching OpenBridge/FBP systems are intentionally added as active route
|
||||||
|
targets. OpenBridge protocol versions greater than 1 are termed FBP,
|
||||||
|
FreeDMR Bridge Protocol. The current reflector creation rule excludes
|
||||||
|
`9990..9999` from FBP target creation. FBP route target lifetime follows
|
||||||
|
FreeDMR's "everything everywhere" principle: retuning or disconnecting a local
|
||||||
|
master reflector entry does not deactivate already-created FBP route targets;
|
||||||
|
source quench provides the selective behavior, and `rule_timer_loop()` clears
|
||||||
|
disconnected FBP-only route targets.
|
||||||
|
- Slot handling bugs after decoded metadata is available: wrong target slot,
|
||||||
|
incorrect slot-bit rewrite and incorrect slot-specific `STATUS` updates.
|
||||||
|
- Packet rewrite bugs in `bridge_master.py`: destination TG rewrite, stream ID
|
||||||
|
preservation, source ID preservation, LC rewrite regions and unintended byte
|
||||||
|
mutation.
|
||||||
|
- Stream lifecycle bugs in router state: duplicate detection, terminator
|
||||||
|
handling, stale stream trimming and source-timeout logic when driven by a fake
|
||||||
|
clock.
|
||||||
|
- Data-call routing bugs that depend on `SUB_MAP`, configured peer systems and
|
||||||
|
bridge state.
|
||||||
|
|
||||||
|
### Bugs Layer 1 Cannot Detect
|
||||||
|
|
||||||
|
- UDP socket binding, address-family, packet loss or process startup issues.
|
||||||
|
- Repeater login/authentication handshake bugs.
|
||||||
|
- Socket-level UDP receive bugs. Parser-seam tests can cover malformed payload
|
||||||
|
handling, but they do not prove the OS socket path delivers those bytes.
|
||||||
|
- Real scheduling bugs caused by Twisted reactor timing, OS buffering or packets
|
||||||
|
arriving at true 30 ms cadence.
|
||||||
|
- Interoperability bugs with real clients that depend on exact UDP source
|
||||||
|
address, port reuse, NAT behaviour or keepalive timing.
|
||||||
|
- Bugs in final transport serialization performed by production
|
||||||
|
`send_peers()`, `send_master()` or OpenBridge `send_system()` after the
|
||||||
|
deterministic capture point.
|
||||||
|
|
||||||
|
## Layer 2: Black-Box UDP Integration Harness
|
||||||
|
|
||||||
|
The UDP harness starts FreeDMR as a subprocess with a generated test
|
||||||
|
configuration and interacts only through UDP and observable outputs. The current
|
||||||
|
implementation emulates HBP repeaters/clients and FBP/OpenBridge peer servers.
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- One or more HBP repeaters/clients, including registration/config handshake and
|
||||||
|
keepalive ping.
|
||||||
|
- One or more FBP v5 peer servers, including signed `DMRE` packet sends,
|
||||||
|
signed `BCKA`, `BCVE`, `BCSQ` and `BCST` bridge-control packets, and capture
|
||||||
|
of outbound `DMRE` traffic.
|
||||||
|
- Synthetic `DMRD` packet sends using the shared `PacketSpec`.
|
||||||
|
- Synthetic FBP v5 packets derived from `PacketSpec`, with the OpenBridge
|
||||||
|
transport envelope, timestamp, source server, source repeater, hop count,
|
||||||
|
BER/RSSI and BLAKE2b hash generated by the harness.
|
||||||
|
- Synthetic FBP v4 packets derived from `PacketSpec`, using the older metadata
|
||||||
|
layout without a source-repeater field. This is characterization/deprecation
|
||||||
|
coverage; v4 is historical and is not expected to remain a long-term protocol
|
||||||
|
contract.
|
||||||
|
- Synthetic signed v1 OpenBridge `DMRD` packets derived from `PacketSpec`, for
|
||||||
|
protocol-refusal tests on enhanced/FBP-configured links.
|
||||||
|
- Recorded packet fixtures loaded from hex-encoded UDP payload files. Replay
|
||||||
|
preserves bytes and leaves all parsing, routing and mutation to FreeDMR.
|
||||||
|
- Reusable `StreamProfile` helpers for realistic 30 ms voice-over packet
|
||||||
|
sequences with optional headers and terminators.
|
||||||
|
- Optional fixed stream cadence through `HbpRepeater.send_stream(...,
|
||||||
|
cadence_seconds=...)`, including realistic 0.030 second spacing.
|
||||||
|
- Deterministic `LinkImpairment` scheduling for fake endpoint sends. It can
|
||||||
|
model drops, duplicates, jitter, fixed/random delay and explicit per-packet
|
||||||
|
delay, while keeping runs reproducible through a seed. This is sender-side
|
||||||
|
UDP impairment only; the harness does not implement a receive-side jitter
|
||||||
|
buffer.
|
||||||
|
- Named `ImpairmentProfiles` for common patterns such as clean links, provider
|
||||||
|
VXLAN-style reordering, mobile flutter drops, burst loss and duplicated UDP
|
||||||
|
datagrams.
|
||||||
|
- UDP capture and parsed assertions for received packets.
|
||||||
|
- Subprocess stdout capture for optional warning/error log assertions.
|
||||||
|
- Loopback-only generated FreeDMR config with reports, API, AllStar, voice ident
|
||||||
|
and alias downloads disabled. The generated config supports scenario-level
|
||||||
|
knobs for global ACL fields, static TG lists and optional FBP peers.
|
||||||
|
- Black-box HBP coverage for static routing, global ACL startup parsing,
|
||||||
|
data/control payload preservation, same-TG voice embedded-LC payload
|
||||||
|
preservation, in-call Talker Alias/GPS log observability, sequence wrap,
|
||||||
|
duplicate sequence `0` suppression, terminator lifecycle suppression, recorded
|
||||||
|
fixture replay, burst loss and duplicate UDP profiles, and local generated
|
||||||
|
prompt output for dial-a-TG reserved controls.
|
||||||
|
- Black-box FBP coverage for enhanced keepalive/version setup, static TG routing
|
||||||
|
from HBP to FBP and from FBP to HBP, BCKA gating of enhanced HBP-to-FBP
|
||||||
|
forwarding, BCSQ source-quench suppression, invalid BCSQ rejection, BCST STUN
|
||||||
|
gating of OpenBridge send/receive traffic, BCVE downgrade/unsupported/invalid
|
||||||
|
handling, historical FBP v4 inbound packet layout characterization, signed v1
|
||||||
|
OBP refusal on a v5-configured link, and rejection of inbound FBP packets with
|
||||||
|
a mismatched network ID.
|
||||||
|
- Black-box unreliable-link coverage for HBP and FBP delayed/out-of-order
|
||||||
|
packet arrival. Current tests delay sequence `1` behind sequence `2` at a
|
||||||
|
realistic 30 ms cadence and assert FreeDMR forwards `0,2` while discarding the
|
||||||
|
late `1`. The FBP case also verifies a following stream on the same trunk
|
||||||
|
still routes after the impaired stream.
|
||||||
|
- Black-box multi-stream trunk coverage for HBP-to-FBP output: one stream is
|
||||||
|
reordered and drops its late packet while a second clean stream on another TG
|
||||||
|
still traverses the same FBP peer.
|
||||||
|
- Black-box generated-prompt interruption coverage: a local TG9 TS2 generated
|
||||||
|
prompt is observed, then real HBP voice is injected and must route to another
|
||||||
|
master rather than being blocked by the prompt.
|
||||||
|
- Black-box hostile/negative packet coverage: malformed short HBP `DMRD`,
|
||||||
|
malformed short FBP `DMRE`, bad FBP hashes, stale FBP timestamps and max-hop
|
||||||
|
FBP packets are exercised against the subprocess. Bad or malformed packets
|
||||||
|
must not leak to HBP targets; stale and max-hop FBP packets must return BCSQ
|
||||||
|
source-quench for the affected TG/stream. Selected negative tests assert the
|
||||||
|
subprocess log messages as well as packet behavior.
|
||||||
|
- Runtime dependency resolution through current Python, `FREEDMR_UDP_PYTHON`, or
|
||||||
|
an opt-in venv bootstrap. The venv bootstrap installs `requirements.txt` into
|
||||||
|
the test venv and does not modify production code.
|
||||||
|
|
||||||
|
Planned:
|
||||||
|
|
||||||
|
- Additional unreliable-link scenarios: whole-trunk impairment warning and more
|
||||||
|
simultaneous FBP streams with different impairment profiles.
|
||||||
|
- Voice-ident interruption coverage with `VOICE_IDENT` enabled, once a reliable
|
||||||
|
short-trigger mechanism is added to the generated test config. Production
|
||||||
|
currently starts the ident loop after a fixed 914 second interval, so a fast
|
||||||
|
subprocess test would need a test hook or a long-running opt-in mode.
|
||||||
|
- A third opt-in Docker/proxy integration layer for packaged deployments that
|
||||||
|
run the hotspot proxy by default. Proxy/firewall tests should avoid modifying
|
||||||
|
real host firewall state unless isolated by Docker or a fake command runner.
|
||||||
|
Related firewall code may live outside this repo and should be inspected only
|
||||||
|
when network access is explicitly needed.
|
||||||
|
|
||||||
|
The UDP harness should capture outbound UDP packets using local sockets bound to
|
||||||
|
the emulated client or peer addresses. Assertions should parse captured UDP
|
||||||
|
payloads and compare observable behaviour:
|
||||||
|
|
||||||
|
- Which emulated endpoint received traffic.
|
||||||
|
- Packet counts, order and timing windows.
|
||||||
|
- Header fields, slot bit, source, destination, stream ID, BER/RSSI and OBP
|
||||||
|
metadata.
|
||||||
|
- Keepalive, registration and source-quench behaviour.
|
||||||
|
- Absence of unintended traffic to real network addresses.
|
||||||
|
|
||||||
|
The subprocess config must bind only to loopback and ephemeral or test-reserved
|
||||||
|
ports. Test config files should disable production reports, API, voice ident,
|
||||||
|
AllStar and external alias downloads unless a scenario explicitly covers them.
|
||||||
|
|
||||||
|
The UDP harness can run FreeDMR under:
|
||||||
|
|
||||||
|
- the current Python interpreter, when all runtime dependencies are already
|
||||||
|
installed;
|
||||||
|
- an explicit interpreter selected with `FREEDMR_UDP_PYTHON=/path/to/python`;
|
||||||
|
- an opt-in virtualenv created by the harness when
|
||||||
|
`FREEDMR_UDP_BOOTSTRAP_VENV=1` is set.
|
||||||
|
|
||||||
|
When bootstrapping is enabled, dependencies are installed from `requirements.txt`
|
||||||
|
inside the venv. Set `FREEDMR_UDP_VENV_DIR=/path/to/venv` to reuse a persistent
|
||||||
|
test venv; otherwise a temporary venv is used for the scenario.
|
||||||
|
|
||||||
|
### Bugs Layer 2 Can Detect
|
||||||
|
|
||||||
|
- UDP parsing and raw packet validation bugs in `hblink.py`.
|
||||||
|
- Authentication, registration, keepalive and peer timeout bugs.
|
||||||
|
- HMAC/BLAKE2 hash handling for OpenBridge versions.
|
||||||
|
- Transport serialization bugs after `bridge_master.py` calls `send_system()`.
|
||||||
|
- Bugs caused by FreeDMR startup config, process lifecycle, Twisted reactor
|
||||||
|
scheduling or socket binding.
|
||||||
|
- Cadence-sensitive bugs: packet-rate limiting, duplicate/out-of-order handling
|
||||||
|
under realistic arrival spacing and jitter.
|
||||||
|
- Regressions against FreeDMR's real-time discard model: delayed packets should
|
||||||
|
not be re-emitted in corrected order or override loop-control/source-quench
|
||||||
|
decisions.
|
||||||
|
- Robustness bugs in malformed/hostile UDP handling: short datagrams, bad FBP
|
||||||
|
hashes, stale timestamps and max-hop enforcement should be logged/ignored or
|
||||||
|
quenched without crashing or forwarding invalid traffic.
|
||||||
|
- Bridge-control state bugs visible over UDP: missing enhanced keepalive should
|
||||||
|
suppress enhanced target forwarding, invalid BCSQ must not suppress streams,
|
||||||
|
and valid BCST STUN should block OpenBridge traffic without being confused
|
||||||
|
with unrelated HBP-to-HBP routing.
|
||||||
|
- Version-negotiation bugs visible over UDP: BCVE downgrade, unsupported version
|
||||||
|
or invalid hash must not mutate the configured outbound behavior, and v4
|
||||||
|
packet fixtures characterize the historical v4 metadata layout.
|
||||||
|
- Known protocol-version issues can be carried as expected-failure black-box
|
||||||
|
tests until runtime behavior is changed: unsupported embedded `DMRE` versions
|
||||||
|
are currently not rejected, and the v4 send layout currently carries the
|
||||||
|
module default version byte instead of the configured `PROTO_VER` value. v4
|
||||||
|
is historical/deprecation context, not a desired long-term compatibility
|
||||||
|
target.
|
||||||
|
- Protocol-refusal bugs visible over UDP: signed v1 OBP packets on a
|
||||||
|
v5-configured link should produce BCVE and should not leak to HBP targets.
|
||||||
|
v1 itself remains supported as an open OBP interop protocol, especially for
|
||||||
|
external network bridge instances through `bridge.py`; direct
|
||||||
|
`bridge_master.py` FBP tests only assert refusal when a link is configured for
|
||||||
|
v5.
|
||||||
|
- `bridge.py` backport checks are intentionally narrower than the
|
||||||
|
`bridge_master.py` harness. Current coverage verifies source-level shared
|
||||||
|
sequence arithmetic and uses `py_compile` for syntax; full packet-path
|
||||||
|
behavior remains covered through the main deterministic and UDP harnesses
|
||||||
|
unless a dedicated bridge-instance runtime harness is added.
|
||||||
|
- Observable interoperability regressions between emulated repeaters, clients
|
||||||
|
and peer servers.
|
||||||
|
- Generated voice prompt/ident regressions that are externally visible as
|
||||||
|
blocked or missing real HBP traffic.
|
||||||
|
|
||||||
|
### Bugs Layer 2 Cannot Detect
|
||||||
|
|
||||||
|
- Internal state transitions that have no observable UDP effect unless extra
|
||||||
|
reporting or logs are asserted.
|
||||||
|
- Exact branch-level causes for routing decisions without coupling tests to
|
||||||
|
logs or report streams.
|
||||||
|
- RF-side behaviour outside the UDP protocol, such as real radio timing,
|
||||||
|
repeater firmware quirks and modem-level DMR slot contention.
|
||||||
|
- AMBE recovery, terminal late entry, MMDVM jitter buffering or RF-path stream
|
||||||
|
recovery decisions. FreeDMR-owned stream IDs and UDP/IP impairment are the
|
||||||
|
model under test here.
|
||||||
|
- Rare internet or NAT behaviour unless the harness is extended beyond loopback.
|
||||||
|
- Proxy packaging behaviour, hotspot-proxy multiplexing and firewall/iptables
|
||||||
|
integration until a third Docker/proxy harness is added.
|
||||||
|
|
||||||
|
## Shared Packet and Fixture Model
|
||||||
|
|
||||||
|
Both layers share packet builders and capture parsing today, and should share
|
||||||
|
fixture readers once recorded fixtures are added:
|
||||||
|
|
||||||
|
- `PacketSpec` represents intent: client/repeater identity, slot, source ID,
|
||||||
|
destination TG or unit ID, stream ID, sequence, frame type, call type,
|
||||||
|
dtype/voice sequence, payload bytes and optional frame delay.
|
||||||
|
- Synthetic fixtures build canonical `DMRD` payload bytes from `PacketSpec`.
|
||||||
|
- `freedmr_dmr_codec.py` is a standalone codec proving ground for protocol
|
||||||
|
helpers before they are linked into packet routing. Its first scope is
|
||||||
|
MMDVMHost-style embedded LC encode/decode with Hamming(16,11,4), column parity
|
||||||
|
and 5-bit checksum validation. It now also covers full LC header/terminator
|
||||||
|
BPTC generation, RS(12,9) LC parity masks, a small LC classifier and
|
||||||
|
Golay(20,8,7) slot type encode/decode correction. TA/GPS logging fixtures are
|
||||||
|
generated through this module so the logging path sees valid embedded-LC
|
||||||
|
cycles rather than hand-coded bit slices. The module also exposes
|
||||||
|
legacy-compatible LC generation function names used by
|
||||||
|
`bridge_master.py`, `bridge.py` and `mk_voice.py`, plus compatible
|
||||||
|
`voice_head_term()` and `voice()` decode helpers used by `bridge_master.py`
|
||||||
|
and `bridge.py`. Synthetic group voice LC fallback is generated through this
|
||||||
|
module with normal service options (`0x00`); decoded inbound LC bytes remain
|
||||||
|
the source of truth when a real voice header is available. Active runtime
|
||||||
|
byte/alias helpers are FreeDMR-owned in `utils.py`; remaining `dmr_utils3`
|
||||||
|
imports are limited to legacy/lab tools unless those tools are updated later.
|
||||||
|
- Recorded fixture support is not implemented yet. When added, fixtures should
|
||||||
|
keep raw bytes plus sidecar metadata describing expected decoded fields and
|
||||||
|
allowed rewrite regions.
|
||||||
|
- Transport simulation and protocol mutation are separate. Builders may create
|
||||||
|
valid transport envelopes; only production code may perform route-driven
|
||||||
|
rewrites. Tests compare original and captured bytes with explicit allowed
|
||||||
|
rewrite ranges.
|
||||||
|
|
||||||
|
## Capture and Assertions
|
||||||
|
|
||||||
|
The deterministic harness captures calls to `send_system()` before real network
|
||||||
|
traffic. The UDP harness captures datagrams at socket boundaries. Both should
|
||||||
|
produce a common capture record where possible:
|
||||||
|
|
||||||
|
- Target system or endpoint.
|
||||||
|
- Exact packet bytes at that layer.
|
||||||
|
- Parsed DMR fields: peer/network ID, source, destination, slot, call type,
|
||||||
|
frame type, dtype/voice sequence and stream ID.
|
||||||
|
- Transport metadata when present: source server, source repeater, hops, BER,
|
||||||
|
RSSI, hash/version fields and UDP address.
|
||||||
|
- Scenario time or wall-clock receive time.
|
||||||
|
|
||||||
|
Assertions should be grouped by intent:
|
||||||
|
|
||||||
|
- Routing assertions: recipient set, non-recipient set, count and order.
|
||||||
|
- Byte preservation assertions: unchanged bytes outside allowed rewrite ranges.
|
||||||
|
- Rewrite assertions: TG, slot bit, LC and transport envelope changes.
|
||||||
|
- State assertions: `STATUS`, bridge `ACTIVE`, timers, `SUB_MAP`, report events.
|
||||||
|
- Timing assertions: deterministic fake-clock checks in layer 1, wall-clock
|
||||||
|
windows in layer 2.
|
||||||
|
|
||||||
|
## Risks and Limitations
|
||||||
|
|
||||||
|
- `bridge_master.py` relies heavily on module globals. Deterministic scenarios
|
||||||
|
must isolate and restore `CONFIG`, `BRIDGES`, `SUB_MAP`, aliases, `AMIOBJ` and
|
||||||
|
`hblink.systems`.
|
||||||
|
- Direct `dmrd_received()` injection bypasses transport gates by design. Any
|
||||||
|
test claiming login, HMAC, UDP parsing or real cadence coverage belongs in the
|
||||||
|
UDP layer.
|
||||||
|
- Minimal synthetic voice payloads may not be sufficient for scenarios that
|
||||||
|
assert full packet audio behaviour. Full LC and slot type codec behaviour is
|
||||||
|
covered in the standalone codec layer, while recorded fixtures or carefully
|
||||||
|
generated payloads should still be used for packet-path scenarios.
|
||||||
|
- Embedded LC can carry information such as embedded GPS and talker alias. The
|
||||||
|
current harness protects same-TG carry-over and standalone codec behavior, but
|
||||||
|
does not yet verify selective embedded-LC rewrite/preservation on TG-mapped
|
||||||
|
streams.
|
||||||
|
- Generated prompt interruption is covered in both layers for state and
|
||||||
|
UDP-visible routing. The harness still does not prove RF-side audio behavior
|
||||||
|
or how a physical repeater/radio reacts to an abandoned prompt without a
|
||||||
|
terminator.
|
||||||
|
- Fake-clock tests can hide real scheduling issues. UDP cadence tests should
|
||||||
|
cover packet-rate and timeout behaviour before relying on the harness for
|
||||||
|
release confidence.
|
||||||
|
- Black-box UDP tests are slower and more brittle. They should cover a small
|
||||||
|
number of high-value flows, while the deterministic layer carries most routing
|
||||||
|
and rewrite coverage.
|
||||||
|
|
||||||
|
## Current Scenario Coverage
|
||||||
|
|
||||||
|
- `PacketSpec` builds parseable `DMRD` payloads.
|
||||||
|
- Deterministic HBP group packet routes to an active static TG target.
|
||||||
|
- Deterministic cross-slot routing tests verify that TS1-to-TS2 routing rewrites
|
||||||
|
only the slot bit while preserving source ID, destination TG, peer ID, stream
|
||||||
|
ID and packet bytes outside the expected header bit.
|
||||||
|
- Deterministic dial-a-TG tests verify that private-call control is slot-local:
|
||||||
|
TS1 controls TS1 reflector state, TS2 controls TS2 reflector state, and TS1 no
|
||||||
|
longer retunes TS2.
|
||||||
|
- Deterministic generated-prompt tests verify first-packet prompt state, prompt
|
||||||
|
cancellation when real HBP voice wins the same slot, and embedded-LC rewrite
|
||||||
|
for late entry after cancellation.
|
||||||
|
- Deterministic status tests verify that `5000` reports one active reflector for
|
||||||
|
the receiving master even if stale multi-active state exists.
|
||||||
|
- Deterministic dial-a-TG scope tests verify that disconnecting or retuning on
|
||||||
|
one master does not mutate another master's reflector entry.
|
||||||
|
- Deterministic reserved-target tests verify that TS1 private calls to `5`, `6`,
|
||||||
|
`7` and `9` do not create or retune reflector bridges.
|
||||||
|
- Deterministic AllStar-control tests verify that target `8` reports busy when
|
||||||
|
AllStar is disabled, enters AllStar mode when enabled, and never creates or
|
||||||
|
retunes dial-a-TG reflector state.
|
||||||
|
- Deterministic reserved control-range tests verify that TS1 private calls to
|
||||||
|
`4001..4999` do not create or retune reflector bridges and report busy rather
|
||||||
|
than linked.
|
||||||
|
- Deterministic echo-target tests verify that TS1 private call `9990` is an
|
||||||
|
intentional linkable echo/test target and announces a link without creating an
|
||||||
|
FBP route target.
|
||||||
|
- Deterministic information-service tests verify that `9991..9999` schedules
|
||||||
|
both the requested AMBE file and the generic silence prompt without creating a
|
||||||
|
reflector.
|
||||||
|
- Deterministic policy-range tests verify that `999999` is still linkable while
|
||||||
|
`1000000` does not create or retune reflector state and reports busy rather
|
||||||
|
than linked.
|
||||||
|
- Deterministic default-dial tests verify that startup rejects reserved control
|
||||||
|
targets `6`, `7`, and `8`, while still allowing linkable
|
||||||
|
`DEFAULT_DIAL_TS1` and `DEFAULT_DIAL_TS2` targets to create active per-slot
|
||||||
|
reflectors. Deprecated `DEFAULT_REFLECTOR`, `DIAL` and `StartRef` remain TS2
|
||||||
|
aliases. These tests also verify `999999` remains valid and startup/options
|
||||||
|
reject targets above that policy cap, with invalid options disabling active
|
||||||
|
default state and invalid startup defaults producing a warning while
|
||||||
|
normalizing runtime state to `0`.
|
||||||
|
- Deterministic static-TG configuration tests verify that startup rejects
|
||||||
|
prohibited TS1 and TS2 static TGs after integer parsing, rejects invalid IDs
|
||||||
|
at or above `16777215`, and that options reload rejects prohibited or
|
||||||
|
out-of-range TS1 static TGs rather than creating them after logging the
|
||||||
|
prohibition. They also verify whitespace normalization and token-level
|
||||||
|
skipping of invalid static TG tokens while valid tokens still apply.
|
||||||
|
- Deterministic options parser tests verify malformed independent numeric fields
|
||||||
|
do not block valid default-dial/static fields, boolean-like options reject
|
||||||
|
values other than `0` or `1`, empty `DIAL` disables TS2 default reflector
|
||||||
|
state, and invalid `TIMER` values are logged without blocking valid static TG
|
||||||
|
changes.
|
||||||
|
- Deterministic voice-ident tests verify override destination selection for
|
||||||
|
valid string TGs, empty/false overrides, malformed values, control TGs, and
|
||||||
|
all-call.
|
||||||
|
- Deterministic FBP-target tests verify that linkable dial-a-TG reflector
|
||||||
|
creation adds active FBP route targets where the current production rule
|
||||||
|
permits it, and that those route targets remain active across local master
|
||||||
|
retunes and disconnects until `rule_timer_loop()` removes disconnected
|
||||||
|
FBP-only reflector bridges.
|
||||||
|
- UDP black-box HBP repeaters register with FreeDMR and observe static TG 91
|
||||||
|
routing over real UDP.
|
||||||
|
- UDP black-box dial-a-TG tests verify that a reserved control private call
|
||||||
|
emits a local TG9 TS2 prompt without sending traffic to another master.
|
||||||
|
- UDP black-box FBP bridge-control tests verify that enhanced targets require
|
||||||
|
BCKA before HBP-to-FBP forwarding, invalid BCSQ does not suppress a stream,
|
||||||
|
and valid BCST STUN blocks OpenBridge traffic in both directions.
|
||||||
|
- UDP black-box FBP version tests verify that BCVE downgrade, unsupported
|
||||||
|
version and invalid hash do not change outbound packet version, and that
|
||||||
|
historical v4 packet fixtures currently route using the older metadata layout.
|
||||||
|
This v4 coverage is characterization/deprecation context.
|
||||||
|
- UDP black-box OBP-v1 refusal tests verify that a signed v1 packet received on
|
||||||
|
a v5-configured link receives BCVE and does not route onward.
|
||||||
|
|
||||||
|
## Next Deterministic Scenario Tests
|
||||||
|
|
||||||
|
1. HBP group voice routes to another HBP master on the same TG.
|
||||||
|
The current smoke test covers a single packet. Extend it to a header, burst
|
||||||
|
and terminator stream and assert expected LC rewrite regions.
|
||||||
|
|
||||||
|
2. HBP slot rewrite when bridge targets a different slot.
|
||||||
|
Build `MASTER-A` active on TG 91 slot 1 and `MASTER-B` active on TG 91 slot
|
||||||
|
2. Inject a slot 1 packet from `MASTER-A`. Assert captured traffic to
|
||||||
|
`MASTER-B` has the slot bit flipped to slot 2 while source ID and stream ID
|
||||||
|
remain unchanged.
|
||||||
|
|
||||||
|
3. Dial-a-TG timeout lifecycle.
|
||||||
|
Build one master system with default UA timer enabled and an active TS2
|
||||||
|
reflector bridge. Advance fake time and run the timer path to assert the
|
||||||
|
bridge deactivates without emitting network traffic.
|
||||||
@ -0,0 +1,292 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
FreeDMR currently has two packet test harness layers under `tests/`.
|
||||||
|
|
||||||
|
## Deterministic Harness
|
||||||
|
|
||||||
|
The deterministic harness runs in-process. It bypasses UDP sockets and captures
|
||||||
|
calls that would otherwise send network traffic.
|
||||||
|
|
||||||
|
Run it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 python -m unittest tests.test_deterministic_harness -v
|
||||||
|
```
|
||||||
|
|
||||||
|
If FreeDMR runtime dependencies are not installed, tests that import
|
||||||
|
`bridge_master.py` are skipped. Pure harness tests still run.
|
||||||
|
|
||||||
|
The deterministic suite includes static TG routing and packet rewrite coverage.
|
||||||
|
It verifies cross-slot TS1-to-TS2 routing changes only the expected slot bit
|
||||||
|
while preserving packet identity fields and bytes outside that header bit.
|
||||||
|
|
||||||
|
API controller coverage verifies the experimental HTTP/JSON API performs only
|
||||||
|
small in-memory control-plane operations, returns a clear no-options response,
|
||||||
|
preserves caller-supplied `OPTIONS` strings unchanged, validates peer/system
|
||||||
|
keys, and exposes JSON error responses without requiring Spyne.
|
||||||
|
|
||||||
|
Auxiliary utility coverage verifies small non-packet helpers used by optional
|
||||||
|
operations: AMI client factories keep per-command state on protocol instances,
|
||||||
|
report receiver CLI flags parse `0` as false, and the SQL report client uses
|
||||||
|
the factory-held database object with parameterized inserts. It also covers the
|
||||||
|
hotspot proxy environment boolean parser so Docker settings such as
|
||||||
|
`FDPROXY_IPV6=0` disable the feature.
|
||||||
|
|
||||||
|
Standalone DMR codec coverage verifies the new `freedmr_dmr_codec.py` helpers
|
||||||
|
before they are wired into packet forwarding. These tests cover MMDVMHost-style
|
||||||
|
embedded LC encode/decode, Hamming(16,11,4) single-bit correction,
|
||||||
|
uncorrectable error rejection, checksum-checked round trips, DMR payload
|
||||||
|
embedded-LC slice helpers, full LC header/terminator BPTC generation, RS(12,9)
|
||||||
|
LC parity masks, group/unit LC classification, and Golay(20,8,7) slot type
|
||||||
|
encode/decode correction. Synthetic group voice LC generation defaults to
|
||||||
|
normal service options (`0x00`) and keeps the HBLink `0x20` value only as an
|
||||||
|
explicit legacy constant. The full LC, routing-style embedded LC, voice
|
||||||
|
header/terminator and voice burst fixtures are fixed byte/bit vectors captured
|
||||||
|
from known-good behaviour, so these tests no longer need `dmr_utils3`.
|
||||||
|
Runtime LC generation and the voice header/terminator and burst decode helpers
|
||||||
|
used by `bridge_master.py` and `bridge.py` now use compatibility functions in
|
||||||
|
`freedmr_dmr_codec.py`. Active runtime helper functions such as `bytes_3()`,
|
||||||
|
`bytes_4()`, `int_id()` and `get_alias()` are provided by FreeDMR `utils.py`.
|
||||||
|
|
||||||
|
The deterministic suite includes dial-a-TG coverage. It verifies that private
|
||||||
|
calls from TS1 create, retune, disconnect and query TS1 reflector state, while
|
||||||
|
private calls from TS2 control TS2 reflector state. TS1 no longer controls TS2.
|
||||||
|
Default dial-a-TG startup and OPTIONS handling is covered through canonical
|
||||||
|
per-slot `DEFAULT_DIAL_TS1` and `DEFAULT_DIAL_TS2`; deprecated
|
||||||
|
`DEFAULT_REFLECTOR`, `DIAL` and `StartRef` remain TS2 compatibility aliases.
|
||||||
|
It also covers late-entry synthetic LC fallback: streams without a decodable
|
||||||
|
voice header fabricate normal group voice LC bytes, while streams with a real
|
||||||
|
voice header preserve the decoded LC service-options byte unchanged.
|
||||||
|
It verifies these state changes are scoped to the receiving master system.
|
||||||
|
Status query `5000` reports one active reflector and does not repair stale
|
||||||
|
multi-active state.
|
||||||
|
It also verifies reserved local/control targets do not create or retune reflector
|
||||||
|
bridges, and that reserved control-range targets `4001..4999` report busy rather
|
||||||
|
than announcing a successful link. Target `8` is covered as an AllStar control
|
||||||
|
target, not a dial-a-TG link target. Private call `9990` is covered as an
|
||||||
|
intentional echo/test link target. Information-service targets `9991..9999`
|
||||||
|
are covered as scheduling both the requested AMBE file and the generic silence
|
||||||
|
prompt without creating reflector state. The current FreeDMR dial-a-TG policy
|
||||||
|
cap is covered: `999999` remains linkable and higher targets report busy rather
|
||||||
|
than linked. Private dial-a-TG timeout coverage verifies private unit calls do
|
||||||
|
not emit unmatched `GROUP VOICE,END,RX` lifecycle events. Startup default-dial handling is covered so reserved/control
|
||||||
|
targets `6`, `7`, and `8` are rejected, `999999` is accepted, and higher targets
|
||||||
|
are rejected. Invalid default-dial options disable the effective slot TG9
|
||||||
|
default reflector for the session; invalid startup defaults are logged and do
|
||||||
|
not create bridge state, with the in-memory effective default normalized to `0`.
|
||||||
|
A linkable target can still create an active per-slot default reflector. Static TG startup and options handling is
|
||||||
|
covered so prohibited local/control TGs are rejected consistently on both TS1
|
||||||
|
and TS2, and invalid IDs at or above `16777215` are rejected while linkable
|
||||||
|
static TGs are still created. Static TG option parsing also covers simple
|
||||||
|
whitespace normalization and token-level skipping of invalid tokens so valid
|
||||||
|
tokens in the same TS1 or TS2 list still apply. Client options parsing covers
|
||||||
|
malformed independent numeric fields so valid default-dial/static fields still apply,
|
||||||
|
`VOICE` and `SINGLE` accept only `0`/`1`, and empty `DIAL` disables the default
|
||||||
|
TS2 reflector. Invalid `TIMER` values are logged and static TG changes continue
|
||||||
|
using the current effective timer. Voice ident override coverage verifies valid
|
||||||
|
override TGs are used, empty/false overrides use all-call, and malformed,
|
||||||
|
control, or all-call override values are logged and fall back to all-call.
|
||||||
|
Generated voice prompt helper coverage verifies prompt stream state is attached
|
||||||
|
to the router instance sending the prompt, even if a stale module-level
|
||||||
|
`system` variable names another master. This protects dial-a-TG prompts, idents,
|
||||||
|
disconnected announcements and on-demand files from cross-system status
|
||||||
|
corruption. Prompt lifecycle coverage also verifies the first generated packet
|
||||||
|
records prompt activity, real HBP voice cancels a generated prompt instead of
|
||||||
|
being blocked by it, and late-entry embedded LC rewrite still occurs for the
|
||||||
|
real voice burst after cancellation. Voice ident lifecycle coverage verifies an
|
||||||
|
interrupted ident does not leave stale prompt-cancel state that blocks a later
|
||||||
|
ident.
|
||||||
|
Bridge reset coverage verifies a reset master remains represented by its own
|
||||||
|
bridge entry, unrelated master and FBP entries are not duplicated or rewritten,
|
||||||
|
and `#` reflector activation triggers survive reset.
|
||||||
|
HBP packet admission coverage verifies reset/reload lifecycle flags are optional
|
||||||
|
false-default booleans: packets continue when both flags are false or absent,
|
||||||
|
and packets are dropped with one log record while either lifecycle flag is true.
|
||||||
|
Data packet coverage verifies HBP unit data forwarded to OBP systems still
|
||||||
|
emits reporting on the OBP target when reporting is enabled, without changing
|
||||||
|
the captured packet destination or raising from a reporting side effect. It also
|
||||||
|
verifies HBP unit data preserves BER/RSSI send metadata when forwarded to
|
||||||
|
OpenBridge/FBP targets; DATA-GATEWAY remains present for protocol-v1 SMS/GPS
|
||||||
|
handling and is not treated as an FBP peer. OBP-originated unit data forwarded
|
||||||
|
to another FBP peer is covered for source server, source repeater, hops, BER and
|
||||||
|
RSSI metadata preservation.
|
||||||
|
The OpenBridge parser seam is covered for truncated `DMRE` packets so malformed
|
||||||
|
UDP input is logged and discarded before fixed-offset parsing can raise.
|
||||||
|
Enhanced OpenBridge bridge-control coverage verifies a valid generated `BCST`
|
||||||
|
STUN packet sets the global `STUN` traffic gate.
|
||||||
|
Dial-a-TG source-quench coverage verifies HBP-to-FBP reflector forwarding checks
|
||||||
|
`BCSQ` against the reflector TG visible on FBP, not the local TG9 control path.
|
||||||
|
The HBP master parser seam is covered for truncated `DMRD` packets from a
|
||||||
|
connected peer so malformed client traffic is discarded before decoded packet
|
||||||
|
handling.
|
||||||
|
Unit data forwarded to HBP via `SUB_MAP` is covered for both HBP and OBP
|
||||||
|
sources; captured packet slot bits and TX report slot metadata must both match
|
||||||
|
the target HBP slot.
|
||||||
|
Group-addressed data reporting is covered for HBP and OBP sources; group data
|
||||||
|
headers and data continuation blocks must emit data `RX/TX` events and must not
|
||||||
|
generate `GROUP VOICE` timeout lifecycle events, while ordinary group voice still emits
|
||||||
|
voice start/end reports. HBP group data rate-drop coverage verifies
|
||||||
|
same-timestamp packet bursts do not divide by zero before duplicate/drop
|
||||||
|
handling can run.
|
||||||
|
Data-sync control payload preservation is covered across HBP-to-HBP, HBP-to-FBP,
|
||||||
|
FBP-to-HBP and FBP-to-FBP forwarding so voice embedded-LC rewrite does not mutate
|
||||||
|
VCSBK/control payload bytes.
|
||||||
|
Voice embedded-LC preservation is covered across HBP-to-HBP, HBP-to-FBP,
|
||||||
|
FBP-to-HBP and FBP-to-FBP same-TG forwarding. Those tests assert voice bursts
|
||||||
|
B-E keep their DMR payload bytes unless the target TG is intentionally rewritten.
|
||||||
|
Embedded-LC observability coverage verifies accepted in-call Talker Alias and
|
||||||
|
GPS LC cycles are decoded through the standalone validated codec to system log
|
||||||
|
messages without changing packet routing or mutation behaviour.
|
||||||
|
OBP group voice rate-drop coverage verifies per-stream packet-rate protection is
|
||||||
|
calculated from elapsed stream duration rather than the absolute stream start
|
||||||
|
timestamp. OBP voice lifecycle coverage verifies a voice terminator marks the
|
||||||
|
stream finished even when live reporting is disabled, so late packets with the
|
||||||
|
same stream ID are suppressed independently of dashboard configuration.
|
||||||
|
OBP voice rewrite error-path coverage verifies missing target embedded-LC state
|
||||||
|
is logged with the handling router name and does not crash packet processing.
|
||||||
|
HBP voice lifecycle coverage verifies a voice terminator marks the slot stream
|
||||||
|
finished, so late same-stream voice bursts are suppressed instead of reopening
|
||||||
|
or routing the ended stream. It also verifies a new voice terminator observed
|
||||||
|
after a group data packet uses the current packet's voice classification, not
|
||||||
|
stale data state from the previous slot occupant, and that an idle-slot
|
||||||
|
terminator-only voice packet still marks the stream finished.
|
||||||
|
HBP and OBP voice packet-control coverage verifies DMRD sequence numbers are
|
||||||
|
handled as modulo-256 values: a stream can route through `254`, `255`, then `2`
|
||||||
|
with the missing post-wrap packets counted as loss rather than being rejected
|
||||||
|
as out-of-order. Sequence `0` duplicate handling is also covered. HBP
|
||||||
|
new-stream duplicate-state coverage verifies a stream following a timed-out
|
||||||
|
prior stream does not inherit `lastSeq`/`lastData` and false packet loss from
|
||||||
|
the previous slot occupant.
|
||||||
|
The `bridge.py` conference-bridge backport has a lightweight source-level test
|
||||||
|
for the shared modulo-256 sequence helper. Full runtime coverage remains in the
|
||||||
|
`bridge_master.py` deterministic and UDP harnesses because importing `bridge.py`
|
||||||
|
requires the deployed FreeDMR runtime dependencies.
|
||||||
|
OpenBridge target lifecycle coverage verifies forwarded voice terminators mark
|
||||||
|
target streams finished for HBP-to-OBP and OBP-to-OBP paths, preventing the
|
||||||
|
timeout trimmer from later emitting duplicate `GROUP VOICE,END,RX` events for
|
||||||
|
streams that already ended normally.
|
||||||
|
HBP VCSBK reporting verifies specific VCSBK block RX events are not duplicated
|
||||||
|
by the generic `OTHER DATA` fallback, while unknown VCSBK types still use the
|
||||||
|
fallback event. Unknown VCSBK reports are covered for HBP and OBP sources and
|
||||||
|
must not generate `GROUP VOICE` lifecycle events.
|
||||||
|
OBP unit-data loop-control coverage verifies same-timestamp duplicate OBP
|
||||||
|
sources do not raise from diagnostic packet-rate calculation and still mark the
|
||||||
|
later source as loop-controlled.
|
||||||
|
Enhanced OpenBridge keepalive coverage verifies missing or stale `_bcka` state
|
||||||
|
suppresses forwarding to enhanced OBP targets for HBP-originated voice/data and
|
||||||
|
OBP-originated data, while recent keepalive state permits forwarding.
|
||||||
|
Config/startup support coverage verifies `GLOBAL.USE_ACL: False` is parsed as a
|
||||||
|
boolean false, alias stale days are converted to seconds exactly once, periodic
|
||||||
|
alias reload updates both `bridge_master.py` globals and the shared `CONFIG`
|
||||||
|
alias dictionaries read by `hblink.py`, and bridge reset tolerates a missing
|
||||||
|
session `OPTIONS` key after HBP disconnect/timeout lifecycle cleanup.
|
||||||
|
Linkable dial-a-TG reflector creation is covered for FBP route targets;
|
||||||
|
OpenBridge protocol
|
||||||
|
versions greater than 1 are termed FBP, FreeDMR Bridge Protocol. FBP route
|
||||||
|
targets follow FreeDMR's "everything everywhere" principle and remain active
|
||||||
|
across local master retunes and disconnects; source quench provides selective
|
||||||
|
behavior, and `rule_timer_loop()` clears disconnected FBP-only route targets.
|
||||||
|
|
||||||
|
## Black-Box UDP Harness
|
||||||
|
|
||||||
|
The UDP harness starts `bridge_master.py` as a subprocess with a generated
|
||||||
|
loopback-only test config. It emulates HBP repeaters and FBP/OpenBridge peer
|
||||||
|
servers over UDP, performs HBP login, sends signed packets/control messages, and
|
||||||
|
captures outbound UDP packets. The HBP login helper retries the initial login
|
||||||
|
challenge for a short startup window because `bridge_master.py` can spend time
|
||||||
|
loading aliases, keys and voice assets before Twisted has bound the loopback UDP
|
||||||
|
sockets.
|
||||||
|
|
||||||
|
Current UDP scenarios cover HBP registration/config handshake, static TG
|
||||||
|
routing, global `USE_ACL: False` startup parsing observed through packet
|
||||||
|
admission, data-sync/control payload preservation, same-TG voice embedded-LC
|
||||||
|
payload preservation, in-call Talker Alias/GPS log observability,
|
||||||
|
modulo-256 voice sequence wrap, sequence `0` duplicate suppression, voice
|
||||||
|
terminator suppression of late
|
||||||
|
same-stream packets, recorded HBP fixture replay, and a dial-a-TG reserved
|
||||||
|
control private call that emits a local TG9 TS2 prompt without leaking traffic
|
||||||
|
to another master. They now also cover FBP v5 static TG routing in both
|
||||||
|
directions, FBP keepalive/version control setup, BCKA gating of enhanced
|
||||||
|
HBP-to-FBP forwarding, BCSQ source-quench suppression of HBP-to-FBP forwarding,
|
||||||
|
rejection of invalid BCSQ, BCVE downgrade/unsupported/invalid-version handling,
|
||||||
|
current FBP v5 packet handling, historical FBP v4 characterization, and signed
|
||||||
|
v1 OBP packet refusal on a v5-configured link. v1 remains an important open OBP
|
||||||
|
interop protocol for external network bridge instances, primarily through
|
||||||
|
`bridge.py`; the `bridge_master.py` UDP test here only verifies that a
|
||||||
|
v5-configured FBP link refuses v1 traffic. They also cover rejection of FBP
|
||||||
|
packets carrying the wrong OpenBridge network ID. Valid BCST STUN is covered as
|
||||||
|
an OpenBridge traffic gate in both directions; ordinary HBP-to-HBP routing is
|
||||||
|
not the target of that gate. The UDP
|
||||||
|
harness also includes deterministic `LinkImpairment` scheduling for fake
|
||||||
|
endpoint sends; current scenarios use it to delay sequence `1` behind sequence
|
||||||
|
`2` at a 30 ms cadence, model burst loss and duplicate UDP datagrams, and assert
|
||||||
|
that late out-of-order or duplicate HBP and FBP packets are discarded rather
|
||||||
|
than buffered or replayed. Reusable `StreamProfile` and `ImpairmentProfiles`
|
||||||
|
helpers provide named stream and link patterns for more real-world scenarios.
|
||||||
|
Current coverage also includes a multi-stream HBP-to-FBP trunk case where one
|
||||||
|
stream is reordered while another clean stream on the same FBP trunk still
|
||||||
|
routes, plus a generated prompt interruption case where real HBP voice routes
|
||||||
|
after a local TG9 TS2 prompt has started. Negative-path coverage includes
|
||||||
|
malformed short HBP `DMRD`, malformed short FBP `DMRE`, bad FBP BLAKE2b hashes,
|
||||||
|
stale FBP timestamps and max-hop FBP packets; these must not leak traffic to HBP
|
||||||
|
targets, and stale/max-hop FBP packets must produce a source-quench response.
|
||||||
|
Selected malformed packet tests also assert subprocess warning logs.
|
||||||
|
|
||||||
|
Two UDP tests are marked as expected failures because they document current
|
||||||
|
protocol-version issues rather than fixed behavior: unsupported embedded `DMRE`
|
||||||
|
packet versions are not yet rejected, and the historical v4 send layout
|
||||||
|
currently carries the module default version byte instead of the configured
|
||||||
|
`PROTO_VER` value. v4 is characterization/deprecation context, not a long-term
|
||||||
|
protocol contract.
|
||||||
|
|
||||||
|
UDP integration tests are opt-in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FREEDMR_RUN_UDP_TESTS=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
python -m unittest tests.test_udp_blackbox_harness -v
|
||||||
|
```
|
||||||
|
|
||||||
|
If dependencies are already installed in another Python, point the harness at it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FREEDMR_RUN_UDP_TESTS=1 \
|
||||||
|
FREEDMR_UDP_PYTHON=/path/to/python \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
python -m unittest tests.test_udp_blackbox_harness -v
|
||||||
|
```
|
||||||
|
|
||||||
|
To let the harness create a virtualenv and install `requirements.txt`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FREEDMR_RUN_UDP_TESTS=1 \
|
||||||
|
FREEDMR_UDP_BOOTSTRAP_VENV=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
python -m unittest tests.test_udp_blackbox_harness -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The venv bootstrap installs `requirements.txt` into the test virtualenv when the
|
||||||
|
selected Python does not already have the FreeDMR runtime dependencies.
|
||||||
|
|
||||||
|
To reuse a persistent test virtualenv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FREEDMR_RUN_UDP_TESTS=1 \
|
||||||
|
FREEDMR_UDP_BOOTSTRAP_VENV=1 \
|
||||||
|
FREEDMR_UDP_VENV_DIR=/tmp/freedmr-blackbox-venv \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
python -m unittest tests.test_udp_blackbox_harness -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Test Discovery
|
||||||
|
|
||||||
|
Run all tests with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 python -m unittest discover -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The black-box UDP tests still skip unless `FREEDMR_RUN_UDP_TESTS=1` is set.
|
||||||
|
|
||||||
|
See [test-harness-design.md](test-harness-design.md) for the harness design and
|
||||||
|
coverage tradeoffs.
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
# FreeDMR 1.x Changelog
|
||||||
|
|
||||||
|
## Test Harnesses
|
||||||
|
|
||||||
|
- Added an in-process deterministic packet harness for `bridge_master.py`
|
||||||
|
routing, state, expiry and packet rewrite checks without UDP.
|
||||||
|
- Added a black-box UDP harness that starts FreeDMR with generated test configs,
|
||||||
|
emulates HBP clients and FBP/OpenBridge peers, captures outbound UDP, supports
|
||||||
|
venv bootstrap, and can model packet loss, duplicates and reordering.
|
||||||
|
- Added synthetic and recorded packet fixture coverage for routing, slot rewrite,
|
||||||
|
byte preservation, malformed packets, cadence and link impairment.
|
||||||
|
|
||||||
|
## Configuration and Options
|
||||||
|
|
||||||
|
- Hardened config parsing for booleans, alias stale time, missing session
|
||||||
|
options and invalid numeric fields.
|
||||||
|
- Added `DIAL_A_TG` to disable private-call dial-a-TG control.
|
||||||
|
- Added `DYNAMIC_TG_ROUTING` to disable automatic creation of unknown
|
||||||
|
conventional TG bridges.
|
||||||
|
- Deprecated `DEFAULT_REFLECTOR` as the system default dial-a-TG setting, while
|
||||||
|
keeping it as a TS2 compatibility alias.
|
||||||
|
- Added canonical per-slot defaults: `DEFAULT_DIAL_TS1` and `DEFAULT_DIAL_TS2`.
|
||||||
|
- Kept legacy OPTIONS aliases `DIAL`, `StartRef` and `DEFAULT_REFLECTOR` mapped
|
||||||
|
to TS2; explicit `DEFAULT_DIAL_TS2` takes precedence.
|
||||||
|
- Added validation/logging so invalid default dial values do not create bridge
|
||||||
|
state and normalize to no default for the active runtime session.
|
||||||
|
|
||||||
|
## Dial-a-TG
|
||||||
|
|
||||||
|
- Made dial-a-TG private-call control slot-local: TS1 controls TS1, TS2 controls
|
||||||
|
TS2. TS1 no longer retunes or disconnects TS2.
|
||||||
|
- Preserved voice prompts on TG9 TS2.
|
||||||
|
- Preserved TG9 as the RF-visible dial-a-TG talkgroup for both slots.
|
||||||
|
- Rejected reserved/control targets consistently for live dial-a-TG and default
|
||||||
|
startup/session configuration.
|
||||||
|
- Preserved the current FreeDMR dial-a-TG policy cap of `999999`.
|
||||||
|
- Ensured FBP route targets created for dial-a-TG remain active across local
|
||||||
|
retunes/disconnects, in line with the mesh "everything everywhere" model.
|
||||||
|
|
||||||
|
## Data Path
|
||||||
|
|
||||||
|
- Preserved DMR data forwarding support.
|
||||||
|
- Kept DATA-GATEWAY behavior for protocol-v1 SMS/GPS style handling.
|
||||||
|
- Reported group-addressed data as data/control, not as voice lifecycle.
|
||||||
|
- Suppressed false `GROUP VOICE` timeout reports for data/control packets.
|
||||||
|
- Preserved data-sync/control payload bytes across HBP and FBP forwarding.
|
||||||
|
- Kept `SUB_MAP` last-known-location behavior for unit data routed toward HBP.
|
||||||
|
- Preserved FBP metadata such as source server, source repeater, BER, RSSI and
|
||||||
|
hops according to protocol version.
|
||||||
|
|
||||||
|
## Voice Path
|
||||||
|
|
||||||
|
- Preserved real inbound LC where available and used explicit synthetic LC only
|
||||||
|
as fallback.
|
||||||
|
- Switched normal synthetic group voice LC service options to `0x00`; retained
|
||||||
|
HBLink `0x20` only as an explicit legacy compatibility constant.
|
||||||
|
- Reworked embedded LC handling so same-TG forwarding preserves embedded LC
|
||||||
|
payloads where possible, while TG-mapped forwarding regenerates routing LC.
|
||||||
|
- Added in-call Talker Alias and GPS embedded-LC logging without changing
|
||||||
|
routing or packet mutation behavior.
|
||||||
|
- Added generated prompt lifecycle handling so real RF voice can interrupt a
|
||||||
|
prompt instead of being blocked as busy.
|
||||||
|
- Fixed private dial-a-TG/AMI timeout reporting so private control calls do not
|
||||||
|
emit unmatched group voice lifecycle events.
|
||||||
|
- Made HBP and FBP voice sequence handling modulo-256 with explicit duplicate,
|
||||||
|
loss and stale/out-of-order treatment.
|
||||||
|
- Ensured voice terminators mark streams finished even when reporting is
|
||||||
|
disabled, preventing late same-stream packets from reopening ended streams.
|
||||||
|
|
||||||
|
## Mesh and FBP/OpenBridge
|
||||||
|
|
||||||
|
- Added malformed/truncated `DMRD` and `DMRE` guards before fixed-offset parsing.
|
||||||
|
- Corrected source-quench matching so BCSQ uses the TG namespace visible to the
|
||||||
|
peer being quenched, including dial-a-TG reflector TGs.
|
||||||
|
- Made STUN/BCST handling consistent as a broad FBP traffic gate.
|
||||||
|
- Preserved protocol-version-sensitive FBP/OBP metadata layout.
|
||||||
|
- Added tests for FBP keepalive gating, wrong network ID, bad hashes, stale
|
||||||
|
timestamps, max-hop handling, v4 characterization and v1 refusal on v5 links.
|
||||||
|
|
||||||
|
## Reporting and Dashboard Compatibility
|
||||||
|
|
||||||
|
- Kept the legacy report socket opcode model unchanged.
|
||||||
|
- Kept bridge event CSV field order unchanged.
|
||||||
|
- Kept `DEFAULT_REFLECTOR` in runtime config as the effective TS2 default for
|
||||||
|
compatibility with existing config/API/report consumers.
|
||||||
|
- Kept prompt/ident generated audio visible as TG9 TS2.
|
||||||
|
- The latest per-slot default-dial changes do not introduce new report event
|
||||||
|
names or new report event fields.
|
||||||
|
- Expected dashboard impact is low if the dashboard reads event fields and
|
||||||
|
bridge entries by their existing keys.
|
||||||
|
- Compatibility risk: `BRIDGE_SND` pickled bridge state may now include active
|
||||||
|
TS1 `#reflector` entries. A dashboard that assumes every `#reflector` entry is
|
||||||
|
TS2-only may need an update; a dashboard that already respects `TS`, `TGID`
|
||||||
|
and `ACTIVE` should continue to parse it.
|
||||||
|
- TS1 dial-a-TG activity may now appear as RF-visible TG9 on slot 1, which is
|
||||||
|
intentional new behavior.
|
||||||
|
|
||||||
|
## Codec and Utility Cleanup
|
||||||
|
|
||||||
|
- Added `freedmr_dmr_codec.py` for locally tested DMR LC, embedded LC, slot type,
|
||||||
|
BPTC, Hamming, Golay and RS parity helper behavior.
|
||||||
|
- Moved runtime LC generation and byte/int helper usage away from older
|
||||||
|
`dmrutils3` functions where covered.
|
||||||
|
- Added standalone codec tests using fixed fixtures and MMDVMHost-style behavior.
|
||||||
|
- Added focused utility tests for ID/byte helpers and alias lookup.
|
||||||
|
|
||||||
|
## API and Support Tools
|
||||||
|
|
||||||
|
- Replaced the Spyne-based API path with a small bounded HTTP/JSON control API.
|
||||||
|
- Kept API operations as small in-memory control-plane actions.
|
||||||
|
- Added API tests for request size limits, key validation, JSON responses,
|
||||||
|
option storage and reset/kill behavior.
|
||||||
|
- Tidied auxiliary tests for report receiver flags, SQL report insertion, AMI
|
||||||
|
factory state and proxy environment booleans.
|
||||||
|
|
||||||
|
## Bridge.py Backports
|
||||||
|
|
||||||
|
- Backported only directly relevant, already-supported fixes from
|
||||||
|
`bridge_master.py` to `bridge.py`.
|
||||||
|
- Kept `bridge.py` focused on its existing conference-bridge role; did not add
|
||||||
|
FreeDMR master-only features such as dial-a-TG.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Added and updated harness, testing, API and architecture documentation.
|
||||||
|
- Added FreeDMR 2 design/ADR documents separately, without changing current
|
||||||
|
1.x runtime behavior.
|
||||||
|
- Maintained `docs/codex-notes.md` as the engineering notebook for findings,
|
||||||
|
assumptions, protocol-sensitive areas, invariants and unresolved questions.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Current non-UDP test discovery passes.
|
||||||
|
- Focused UDP black-box tests for TS1 dial-a-TG and disabled dynamic TG routing
|
||||||
|
pass when local UDP sockets are permitted.
|
||||||
|
- Live RF validation is still required before treating protocol-visible behavior
|
||||||
|
changes as release-ready.
|
||||||
@ -0,0 +1,648 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# Embedded LC codec helpers for FreeDMR.
|
||||||
|
#
|
||||||
|
# The embedded LC BPTC layout follows MMDVMHost CDMREmbeddedData,
|
||||||
|
# CHamming::encode16114/decode16114, and CCRC::encodeFiveBit.
|
||||||
|
# MMDVMHost is Copyright (C) Jonathan Naylor G4KLX and licensed GPLv2+
|
||||||
|
# (or later). FreeDMR is GPLv3+, so this port is used under GPLv3+.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from bitarray import bitarray
|
||||||
|
|
||||||
|
|
||||||
|
FULL_LC_BITS = 196
|
||||||
|
FULL_LC_BYTES = 9
|
||||||
|
FULL_LC_PARITY_BYTES = 3
|
||||||
|
FULL_LC_CODE_BYTES = 12
|
||||||
|
FULL_LC_INFO_BITS = 196
|
||||||
|
EMBEDDED_LC_FRAGMENT_BITS = 32
|
||||||
|
EMBEDDED_LC_RAW_BITS = 128
|
||||||
|
EMBEDDED_LC_PAYLOAD_BITS = 72
|
||||||
|
EMBEDDED_LC_PAYLOAD_BYTES = 9
|
||||||
|
SLOT_TYPE_BITS = 20
|
||||||
|
SLOT_TYPE_DATA_BITS = 8
|
||||||
|
LC_FLCO_GROUP_VOICE = 0x00
|
||||||
|
LC_FLCO_UNIT_VOICE = 0x03
|
||||||
|
LC_FID_ETSI = 0x00
|
||||||
|
LC_SERVICE_OPTIONS_NORMAL = 0x00
|
||||||
|
LC_SERVICE_OPTIONS_OVCM = 0x04
|
||||||
|
LC_SERVICE_OPTIONS_HBLINK_LEGACY = 0x20
|
||||||
|
GROUP_VOICE_LC_OPT = b'\x00\x00\x00'
|
||||||
|
UNIT_VOICE_LC_OPT = b'\x03\x00\x00'
|
||||||
|
BS_VOICE_SYNC = bitarray('011101010101111111010111110111110111010111110111')
|
||||||
|
BS_DATA_SYNC = bitarray('110111111111010101111101011101011101111101011101')
|
||||||
|
EMB = {
|
||||||
|
'BURST_B': bitarray('0001001110010001'),
|
||||||
|
'BURST_C': bitarray('0001011101110100'),
|
||||||
|
'BURST_D': bitarray('0001011101110100'),
|
||||||
|
'BURST_E': bitarray('0001010100000111'),
|
||||||
|
'BURST_F': bitarray('0001000111100010'),
|
||||||
|
}
|
||||||
|
SLOT_TYPE = {
|
||||||
|
'PI_HEAD': bitarray('00010000001101100111'),
|
||||||
|
'VOICE_LC_HEAD': bitarray('00010001101110001100'),
|
||||||
|
'VOICE_LC_TERM': bitarray('00010010101001011001'),
|
||||||
|
'CSBK': bitarray('00010011001010110010'),
|
||||||
|
'MBC_HEAD': bitarray('00010100100111110000'),
|
||||||
|
'MBC_CONT': bitarray('00010101000100011011'),
|
||||||
|
'DATA_HEAD': bitarray('00010110000011001110'),
|
||||||
|
'1/2_DATA': bitarray('00010111100000100101'),
|
||||||
|
'3/4_DATA': bitarray('00011000111010100001'),
|
||||||
|
'IDLE': bitarray('00011001011001001010'),
|
||||||
|
'1/1_DATA': bitarray('00011010011110011111'),
|
||||||
|
'RES_1': bitarray('00011011111101110100'),
|
||||||
|
'RES_2': bitarray('00011100010000110110'),
|
||||||
|
'RES_3': bitarray('00011101110011011101'),
|
||||||
|
'RES_4': bitarray('00011110110100001000'),
|
||||||
|
'RES_5': bitarray('00011111010111100011'),
|
||||||
|
}
|
||||||
|
|
||||||
|
FULL_LC_INTERLEAVE_19696 = (
|
||||||
|
0, 181, 166, 151, 136, 121, 106, 91, 76, 61, 46, 31, 16, 1, 182, 167, 152, 137,
|
||||||
|
122, 107, 92, 77, 62, 47, 32, 17, 2, 183, 168, 153, 138, 123, 108, 93, 78, 63,
|
||||||
|
48, 33, 18, 3, 184, 169, 154, 139, 124, 109, 94, 79, 64, 49, 34, 19, 4, 185, 170,
|
||||||
|
155, 140, 125, 110, 95, 80, 65, 50, 35, 20, 5, 186, 171, 156, 141, 126, 111, 96,
|
||||||
|
81, 66, 51, 36, 21, 6, 187, 172, 157, 142, 127, 112, 97, 82, 67, 52, 37, 22, 7,
|
||||||
|
188, 173, 158, 143, 128, 113, 98, 83, 68, 53, 38, 23, 8, 189, 174, 159, 144, 129,
|
||||||
|
114, 99, 84, 69, 54, 39, 24, 9, 190, 175, 160, 145, 130, 115, 100, 85, 70, 55, 40,
|
||||||
|
25, 10, 191, 176, 161, 146, 131, 116, 101, 86, 71, 56, 41, 26, 11, 192, 177, 162,
|
||||||
|
147, 132, 117, 102, 87, 72, 57, 42, 27, 12, 193, 178, 163, 148, 133, 118, 103, 88,
|
||||||
|
73, 58, 43, 28, 13, 194, 179, 164, 149, 134, 119, 104, 89, 74, 59, 44, 29, 14,
|
||||||
|
195, 180, 165, 150, 135, 120, 105, 90, 75, 60, 45, 30, 15,
|
||||||
|
)
|
||||||
|
|
||||||
|
FULL_LC_PAYLOAD_INDEXES = (
|
||||||
|
136, 121, 106, 91, 76, 61, 46, 31,
|
||||||
|
152, 137, 122, 107, 92, 77, 62, 47, 32, 17, 2,
|
||||||
|
123, 108, 93, 78, 63, 48, 33, 18, 3, 184, 169,
|
||||||
|
94, 79, 64, 49, 34, 19, 4, 185, 170, 155, 140,
|
||||||
|
65, 50, 35, 20, 5, 186, 171, 156, 141, 126, 111,
|
||||||
|
36, 21, 6, 187, 172, 157, 142, 127, 112, 97, 82,
|
||||||
|
7, 188, 173, 158, 143, 128, 113, 98, 83,
|
||||||
|
)
|
||||||
|
|
||||||
|
RS129_HEADER_MASK = (0x96, 0x96, 0x96)
|
||||||
|
RS129_TERMINATOR_MASK = (0x99, 0x99, 0x99)
|
||||||
|
RS129_POLY = (64, 56, 14, 1)
|
||||||
|
|
||||||
|
GOLAY_2087_PARITY = (
|
||||||
|
0x0000, 0xB08E, 0xE093, 0x501D, 0x70A9, 0xC027, 0x903A, 0x20B4,
|
||||||
|
0x60DC, 0xD052, 0x804F, 0x30C1, 0x1075, 0xA0FB, 0xF0E6, 0x4068,
|
||||||
|
0x7036, 0xC0B8, 0x90A5, 0x202B, 0x009F, 0xB011, 0xE00C, 0x5082,
|
||||||
|
0x10EA, 0xA064, 0xF079, 0x40F7, 0x6043, 0xD0CD, 0x80D0, 0x305E,
|
||||||
|
0xD06C, 0x60E2, 0x30FF, 0x8071, 0xA0C5, 0x104B, 0x4056, 0xF0D8,
|
||||||
|
0xB0B0, 0x003E, 0x5023, 0xE0AD, 0xC019, 0x7097, 0x208A, 0x9004,
|
||||||
|
0xA05A, 0x10D4, 0x40C9, 0xF047, 0xD0F3, 0x607D, 0x3060, 0x80EE,
|
||||||
|
0xC086, 0x7008, 0x2015, 0x909B, 0xB02F, 0x00A1, 0x50BC, 0xE032,
|
||||||
|
0x90D9, 0x2057, 0x704A, 0xC0C4, 0xE070, 0x50FE, 0x00E3, 0xB06D,
|
||||||
|
0xF005, 0x408B, 0x1096, 0xA018, 0x80AC, 0x3022, 0x603F, 0xD0B1,
|
||||||
|
0xE0EF, 0x5061, 0x007C, 0xB0F2, 0x9046, 0x20C8, 0x70D5, 0xC05B,
|
||||||
|
0x8033, 0x30BD, 0x60A0, 0xD02E, 0xF09A, 0x4014, 0x1009, 0xA087,
|
||||||
|
0x40B5, 0xF03B, 0xA026, 0x10A8, 0x301C, 0x8092, 0xD08F, 0x6001,
|
||||||
|
0x2069, 0x90E7, 0xC0FA, 0x7074, 0x50C0, 0xE04E, 0xB053, 0x00DD,
|
||||||
|
0x3083, 0x800D, 0xD010, 0x609E, 0x402A, 0xF0A4, 0xA0B9, 0x1037,
|
||||||
|
0x505F, 0xE0D1, 0xB0CC, 0x0042, 0x20F6, 0x9078, 0xC065, 0x70EB,
|
||||||
|
0xA03D, 0x10B3, 0x40AE, 0xF020, 0xD094, 0x601A, 0x3007, 0x8089,
|
||||||
|
0xC0E1, 0x706F, 0x2072, 0x90FC, 0xB048, 0x00C6, 0x50DB, 0xE055,
|
||||||
|
0xD00B, 0x6085, 0x3098, 0x8016, 0xA0A2, 0x102C, 0x4031, 0xF0BF,
|
||||||
|
0xB0D7, 0x0059, 0x5044, 0xE0CA, 0xC07E, 0x70F0, 0x20ED, 0x9063,
|
||||||
|
0x7051, 0xC0DF, 0x90C2, 0x204C, 0x00F8, 0xB076, 0xE06B, 0x50E5,
|
||||||
|
0x108D, 0xA003, 0xF01E, 0x4090, 0x6024, 0xD0AA, 0x80B7, 0x3039,
|
||||||
|
0x0067, 0xB0E9, 0xE0F4, 0x507A, 0x70CE, 0xC040, 0x905D, 0x20D3,
|
||||||
|
0x60BB, 0xD035, 0x8028, 0x30A6, 0x1012, 0xA09C, 0xF081, 0x400F,
|
||||||
|
0x30E4, 0x806A, 0xD077, 0x60F9, 0x404D, 0xF0C3, 0xA0DE, 0x1050,
|
||||||
|
0x5038, 0xE0B6, 0xB0AB, 0x0025, 0x2091, 0x901F, 0xC002, 0x708C,
|
||||||
|
0x40D2, 0xF05C, 0xA041, 0x10CF, 0x307B, 0x80F5, 0xD0E8, 0x6066,
|
||||||
|
0x200E, 0x9080, 0xC09D, 0x7013, 0x50A7, 0xE029, 0xB034, 0x00BA,
|
||||||
|
0xE088, 0x5006, 0x001B, 0xB095, 0x9021, 0x20AF, 0x70B2, 0xC03C,
|
||||||
|
0x8054, 0x30DA, 0x60C7, 0xD049, 0xF0FD, 0x4073, 0x106E, 0xA0E0,
|
||||||
|
0x90BE, 0x2030, 0x702D, 0xC0A3, 0xE017, 0x5099, 0x0084, 0xB00A,
|
||||||
|
0xF062, 0x40EC, 0x10F1, 0xA07F, 0x80CB, 0x3045, 0x6058, 0xD0D6,
|
||||||
|
)
|
||||||
|
|
||||||
|
SLOT_TYPE_NAMES = {
|
||||||
|
0x0: "PI_HEAD",
|
||||||
|
0x1: "VOICE_LC_HEAD",
|
||||||
|
0x2: "VOICE_LC_TERM",
|
||||||
|
0x3: "CSBK",
|
||||||
|
0x4: "MBC_HEAD",
|
||||||
|
0x5: "MBC_CONT",
|
||||||
|
0x6: "DATA_HEAD",
|
||||||
|
0x7: "1/2_RATE",
|
||||||
|
0x8: "3/4_RATE",
|
||||||
|
0x9: "IDLE",
|
||||||
|
0xA: "1/1_RATE",
|
||||||
|
0xB: "RES_1",
|
||||||
|
0xC: "RES_2",
|
||||||
|
0xD: "RES_3",
|
||||||
|
0xE: "RES_4",
|
||||||
|
0xF: "RES_5",
|
||||||
|
}
|
||||||
|
EMBEDDED_LC_PAYLOAD_RANGES = (
|
||||||
|
(0, 11),
|
||||||
|
(16, 27),
|
||||||
|
(32, 42),
|
||||||
|
(48, 58),
|
||||||
|
(64, 74),
|
||||||
|
(80, 90),
|
||||||
|
(96, 106),
|
||||||
|
)
|
||||||
|
EMBEDDED_LC_CRC_POSITIONS = (42, 58, 74, 90, 106)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmbeddedLC:
|
||||||
|
data: bytes
|
||||||
|
flco: int
|
||||||
|
raw: bitarray
|
||||||
|
corrected: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FullLC:
|
||||||
|
data: bytes
|
||||||
|
flco: int
|
||||||
|
source_id: int
|
||||||
|
target_id: int
|
||||||
|
service_options: int
|
||||||
|
is_group_call: bool
|
||||||
|
is_unit_call: bool
|
||||||
|
|
||||||
|
|
||||||
|
def build_group_voice_lc(
|
||||||
|
dst_id: bytes,
|
||||||
|
src_id: bytes,
|
||||||
|
service_options: int = LC_SERVICE_OPTIONS_NORMAL,
|
||||||
|
) -> bytes:
|
||||||
|
if len(dst_id) != 3 or len(src_id) != 3:
|
||||||
|
raise FullLCError("DMR LC target and source IDs must be three bytes")
|
||||||
|
if service_options < 0 or service_options > 0xFF:
|
||||||
|
raise FullLCError("DMR LC service options must fit in one byte")
|
||||||
|
|
||||||
|
return bytes([LC_FLCO_GROUP_VOICE, LC_FID_ETSI, service_options]) + dst_id + src_id
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SlotType:
|
||||||
|
color_code: int
|
||||||
|
data_type: int
|
||||||
|
name: str
|
||||||
|
corrected: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddedLCError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FullLCError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SlotTypeError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def bytes_to_bits(data: bytes) -> bitarray:
|
||||||
|
bits = bitarray(endian="big")
|
||||||
|
bits.frombytes(data)
|
||||||
|
return bits
|
||||||
|
|
||||||
|
|
||||||
|
def bits_to_int(bits: bitarray) -> int:
|
||||||
|
value = 0
|
||||||
|
for bit in bits:
|
||||||
|
value = (value << 1) | int(bit)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _bits_from_int(value: int, length: int) -> bitarray:
|
||||||
|
return bitarray((bool(value & (1 << bit)) for bit in range(length - 1, -1, -1)), endian="big")
|
||||||
|
|
||||||
|
|
||||||
|
def _hamming_distance(a: int, b: int) -> int:
|
||||||
|
return (a ^ b).bit_count()
|
||||||
|
|
||||||
|
|
||||||
|
def _gf256_mul(left: int, right: int) -> int:
|
||||||
|
result = 0
|
||||||
|
while right:
|
||||||
|
if right & 0x01:
|
||||||
|
result ^= left
|
||||||
|
right >>= 1
|
||||||
|
left <<= 1
|
||||||
|
if left & 0x100:
|
||||||
|
left ^= 0x11D
|
||||||
|
return result & 0xFF
|
||||||
|
|
||||||
|
|
||||||
|
def encode_hamming_15113(data: bitarray) -> bitarray:
|
||||||
|
if len(data) != 11:
|
||||||
|
raise FullLCError("Hamming(15,11,3) input must be 11 bits")
|
||||||
|
return bitarray((
|
||||||
|
data[0] ^ data[1] ^ data[2] ^ data[3] ^ data[5] ^ data[7] ^ data[8],
|
||||||
|
data[1] ^ data[2] ^ data[3] ^ data[4] ^ data[6] ^ data[8] ^ data[9],
|
||||||
|
data[2] ^ data[3] ^ data[4] ^ data[5] ^ data[7] ^ data[9] ^ data[10],
|
||||||
|
data[0] ^ data[1] ^ data[2] ^ data[4] ^ data[6] ^ data[7] ^ data[10],
|
||||||
|
), endian="big")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_hamming_1393(data: bitarray) -> bitarray:
|
||||||
|
if len(data) != 9:
|
||||||
|
raise FullLCError("Hamming(13,9,3) input must be 9 bits")
|
||||||
|
return bitarray((
|
||||||
|
data[0] ^ data[1] ^ data[3] ^ data[5] ^ data[6],
|
||||||
|
data[0] ^ data[1] ^ data[2] ^ data[4] ^ data[6] ^ data[7],
|
||||||
|
data[0] ^ data[1] ^ data[2] ^ data[3] ^ data[5] ^ data[7] ^ data[8],
|
||||||
|
data[0] ^ data[2] ^ data[4] ^ data[5] ^ data[8],
|
||||||
|
), endian="big")
|
||||||
|
|
||||||
|
|
||||||
|
def rs129_parity(data: bytes) -> bytes:
|
||||||
|
if len(data) != FULL_LC_BYTES:
|
||||||
|
raise FullLCError("RS(12,9) LC payload must be 9 bytes")
|
||||||
|
|
||||||
|
parity = [0x00, 0x00, 0x00]
|
||||||
|
for byte in data:
|
||||||
|
dbyte = byte ^ parity[2]
|
||||||
|
parity[2] = parity[1] ^ _gf256_mul(RS129_POLY[2], dbyte)
|
||||||
|
parity[1] = parity[0] ^ _gf256_mul(RS129_POLY[1], dbyte)
|
||||||
|
parity[0] = _gf256_mul(RS129_POLY[0], dbyte)
|
||||||
|
return bytes((parity[2], parity[1], parity[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def encode_full_lc_parity(data: bytes, terminator: bool = False) -> bytes:
|
||||||
|
mask = RS129_TERMINATOR_MASK if terminator else RS129_HEADER_MASK
|
||||||
|
return bytes(value ^ mask[index] for index, value in enumerate(rs129_parity(data)))
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_19696(data: bytes) -> bitarray:
|
||||||
|
if len(data) != FULL_LC_CODE_BYTES:
|
||||||
|
raise FullLCError("BPTC(196,96) input must be 12 bytes")
|
||||||
|
|
||||||
|
bits = bytes_to_bits(data)
|
||||||
|
for _index in range(4):
|
||||||
|
bits.insert(0, 0)
|
||||||
|
|
||||||
|
for index in range(9):
|
||||||
|
start = (index * 15) + 1
|
||||||
|
end = start + 11
|
||||||
|
parity = encode_hamming_15113(bits[start:end])
|
||||||
|
for pbit in range(4):
|
||||||
|
bits.insert(end + pbit, parity[pbit])
|
||||||
|
|
||||||
|
for _index in range(60):
|
||||||
|
bits.append(0)
|
||||||
|
|
||||||
|
column = bitarray(9, endian="big")
|
||||||
|
for col in range(15):
|
||||||
|
start = col + 1
|
||||||
|
for index in range(9):
|
||||||
|
column[index] = bits[start]
|
||||||
|
start += 15
|
||||||
|
parity = encode_hamming_1393(column)
|
||||||
|
|
||||||
|
target = 136 + col
|
||||||
|
for pbit in range(4):
|
||||||
|
bits[target] = parity[pbit]
|
||||||
|
target += 15
|
||||||
|
|
||||||
|
return bits
|
||||||
|
|
||||||
|
|
||||||
|
def interleave_19696(data: bitarray) -> bitarray:
|
||||||
|
if len(data) != FULL_LC_BITS:
|
||||||
|
raise FullLCError("BPTC(196,96) data must be 196 bits")
|
||||||
|
interleaved = bitarray(FULL_LC_BITS, endian="big")
|
||||||
|
for index in range(FULL_LC_BITS):
|
||||||
|
interleaved[FULL_LC_INTERLEAVE_19696[index]] = data[index]
|
||||||
|
return interleaved
|
||||||
|
|
||||||
|
|
||||||
|
def encode_full_lc(data: bytes, terminator: bool = False) -> bitarray:
|
||||||
|
if len(data) != FULL_LC_BYTES:
|
||||||
|
raise FullLCError("full LC payload must be 9 bytes")
|
||||||
|
code = data + encode_full_lc_parity(data, terminator=terminator)
|
||||||
|
return interleave_19696(_encode_19696(code))
|
||||||
|
|
||||||
|
|
||||||
|
def encode_header_lc(data: bytes) -> bitarray:
|
||||||
|
return encode_full_lc(data, terminator=False)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_terminator_lc(data: bytes) -> bitarray:
|
||||||
|
return encode_full_lc(data, terminator=True)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_full_lc(bits: bitarray) -> FullLC:
|
||||||
|
if len(bits) != FULL_LC_INFO_BITS:
|
||||||
|
raise FullLCError("full LC data must be 196 bits")
|
||||||
|
payload = bitarray((bits[index] for index in FULL_LC_PAYLOAD_INDEXES), endian="big")
|
||||||
|
data = payload.tobytes()
|
||||||
|
flco = data[0] & 0x3F
|
||||||
|
return FullLC(
|
||||||
|
data=data,
|
||||||
|
flco=flco,
|
||||||
|
source_id=int.from_bytes(data[6:9], "big"),
|
||||||
|
target_id=int.from_bytes(data[3:6], "big"),
|
||||||
|
service_options=data[2],
|
||||||
|
is_group_call=flco == LC_FLCO_GROUP_VOICE,
|
||||||
|
is_unit_call=flco == LC_FLCO_UNIT_VOICE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _padded_bits_to_bytes(bits: bitarray) -> bytes:
|
||||||
|
padded = bits.copy()
|
||||||
|
add_bits = 8 - (len(padded) % 8)
|
||||||
|
if add_bits < 8:
|
||||||
|
for _bit in range(add_bits):
|
||||||
|
padded.insert(0, 0)
|
||||||
|
return padded.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def voice_head_term(payload: bytes) -> dict[str, object]:
|
||||||
|
bits = bytes_to_bits(payload)
|
||||||
|
info = bits[0:98] + bits[166:264]
|
||||||
|
slot_type = bits[98:108] + bits[156:166]
|
||||||
|
sync = bits[108:156]
|
||||||
|
lc = decode_full_lc(info).data
|
||||||
|
return {
|
||||||
|
"LC": lc,
|
||||||
|
"CC": _padded_bits_to_bytes(slot_type[0:4]),
|
||||||
|
"DTYPE": _padded_bits_to_bytes(slot_type[4:8]),
|
||||||
|
"SYNC": sync,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def voice_sync(payload: bytes) -> dict[str, object]:
|
||||||
|
bits = bytes_to_bits(payload)
|
||||||
|
return {
|
||||||
|
"AMBE": [
|
||||||
|
bits[0:72],
|
||||||
|
bits[72:108] + bits[156:192],
|
||||||
|
bits[192:264],
|
||||||
|
],
|
||||||
|
"SYNC": bits[108:156],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def voice(payload: bytes) -> dict[str, object]:
|
||||||
|
bits = bytes_to_bits(payload)
|
||||||
|
emb = bits[108:116] + bits[148:156]
|
||||||
|
return {
|
||||||
|
"AMBE": [
|
||||||
|
bits[0:72],
|
||||||
|
bits[72:108] + bits[156:192],
|
||||||
|
bits[192:264],
|
||||||
|
],
|
||||||
|
"CC": _padded_bits_to_bytes(emb[0:4]),
|
||||||
|
"LCSS": _padded_bits_to_bytes(emb[5:7]),
|
||||||
|
"EMBED": bits[116:148],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_slot_type(color_code: int, data_type: int) -> bitarray:
|
||||||
|
if not 0 <= color_code <= 0x0F:
|
||||||
|
raise SlotTypeError("slot color code must fit in four bits")
|
||||||
|
if not 0 <= data_type <= 0x0F:
|
||||||
|
raise SlotTypeError("slot data type must fit in four bits")
|
||||||
|
|
||||||
|
value = (color_code << 4) | data_type
|
||||||
|
checksum = GOLAY_2087_PARITY[value]
|
||||||
|
code = (value << 12) | ((checksum & 0xFF) << 4) | (checksum >> 12)
|
||||||
|
return _bits_from_int(code, SLOT_TYPE_BITS)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_slot_type(bits: bitarray) -> SlotType:
|
||||||
|
if len(bits) != SLOT_TYPE_BITS:
|
||||||
|
raise SlotTypeError("slot type must be 20 bits")
|
||||||
|
|
||||||
|
observed = bits_to_int(bits)
|
||||||
|
candidates = []
|
||||||
|
for value in range(256):
|
||||||
|
encoded = bits_to_int(encode_slot_type(value >> 4, value & 0x0F))
|
||||||
|
candidates.append((_hamming_distance(observed, encoded), value))
|
||||||
|
candidates.sort()
|
||||||
|
|
||||||
|
distance, value = candidates[0]
|
||||||
|
if len(candidates) > 1 and candidates[1][0] == distance:
|
||||||
|
raise SlotTypeError("ambiguous Golay(20,8,7) slot type")
|
||||||
|
if distance > 3:
|
||||||
|
raise SlotTypeError("slot type Golay(20,8,7) check failed")
|
||||||
|
|
||||||
|
data_type = value & 0x0F
|
||||||
|
return SlotType(
|
||||||
|
color_code=value >> 4,
|
||||||
|
data_type=data_type,
|
||||||
|
name=SLOT_TYPE_NAMES[data_type],
|
||||||
|
corrected=distance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def crc5(data: bytes | bitarray) -> int:
|
||||||
|
bits = bytes_to_bits(data) if isinstance(data, bytes) else data
|
||||||
|
if len(bits) != EMBEDDED_LC_PAYLOAD_BITS:
|
||||||
|
raise EmbeddedLCError("embedded LC payload must be 72 bits")
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for offset in range(0, EMBEDDED_LC_PAYLOAD_BITS, 8):
|
||||||
|
total += bits_to_int(bits[offset:offset + 8])
|
||||||
|
return total % 31
|
||||||
|
|
||||||
|
|
||||||
|
def encode_hamming_16114(row: bitarray) -> None:
|
||||||
|
if len(row) != 16:
|
||||||
|
raise EmbeddedLCError("Hamming row must be 16 bits")
|
||||||
|
|
||||||
|
row[11] = row[0] ^ row[1] ^ row[2] ^ row[3] ^ row[5] ^ row[7] ^ row[8]
|
||||||
|
row[12] = row[1] ^ row[2] ^ row[3] ^ row[4] ^ row[6] ^ row[8] ^ row[9]
|
||||||
|
row[13] = row[2] ^ row[3] ^ row[4] ^ row[5] ^ row[7] ^ row[9] ^ row[10]
|
||||||
|
row[14] = row[0] ^ row[1] ^ row[2] ^ row[4] ^ row[6] ^ row[7] ^ row[10]
|
||||||
|
row[15] = row[0] ^ row[2] ^ row[5] ^ row[6] ^ row[8] ^ row[9] ^ row[10]
|
||||||
|
|
||||||
|
|
||||||
|
def decode_hamming_16114(row: bitarray) -> bool:
|
||||||
|
if len(row) != 16:
|
||||||
|
raise EmbeddedLCError("Hamming row must be 16 bits")
|
||||||
|
|
||||||
|
c0 = row[0] ^ row[1] ^ row[2] ^ row[3] ^ row[5] ^ row[7] ^ row[8]
|
||||||
|
c1 = row[1] ^ row[2] ^ row[3] ^ row[4] ^ row[6] ^ row[8] ^ row[9]
|
||||||
|
c2 = row[2] ^ row[3] ^ row[4] ^ row[5] ^ row[7] ^ row[9] ^ row[10]
|
||||||
|
c3 = row[0] ^ row[1] ^ row[2] ^ row[4] ^ row[6] ^ row[7] ^ row[10]
|
||||||
|
c4 = row[0] ^ row[2] ^ row[5] ^ row[6] ^ row[8] ^ row[9] ^ row[10]
|
||||||
|
|
||||||
|
syndrome = 0
|
||||||
|
syndrome |= 0x01 if c0 != row[11] else 0
|
||||||
|
syndrome |= 0x02 if c1 != row[12] else 0
|
||||||
|
syndrome |= 0x04 if c2 != row[13] else 0
|
||||||
|
syndrome |= 0x08 if c3 != row[14] else 0
|
||||||
|
syndrome |= 0x10 if c4 != row[15] else 0
|
||||||
|
|
||||||
|
corrections = {
|
||||||
|
0x01: 11,
|
||||||
|
0x02: 12,
|
||||||
|
0x04: 13,
|
||||||
|
0x08: 14,
|
||||||
|
0x10: 15,
|
||||||
|
0x19: 0,
|
||||||
|
0x0B: 1,
|
||||||
|
0x1F: 2,
|
||||||
|
0x07: 3,
|
||||||
|
0x0E: 4,
|
||||||
|
0x15: 5,
|
||||||
|
0x1A: 6,
|
||||||
|
0x0D: 7,
|
||||||
|
0x13: 8,
|
||||||
|
0x16: 9,
|
||||||
|
0x1C: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
if syndrome == 0:
|
||||||
|
return True
|
||||||
|
if syndrome not in corrections:
|
||||||
|
return False
|
||||||
|
row[corrections[syndrome]] = not row[corrections[syndrome]]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def encode_embedded_lc(lc: bytes | bitarray) -> tuple[bitarray, bitarray, bitarray, bitarray]:
|
||||||
|
payload = bytes_to_bits(lc) if isinstance(lc, bytes) else lc.copy()
|
||||||
|
if len(payload) != EMBEDDED_LC_PAYLOAD_BITS:
|
||||||
|
raise EmbeddedLCError("embedded LC must be 9 bytes / 72 bits")
|
||||||
|
|
||||||
|
data = bitarray([0] * EMBEDDED_LC_RAW_BITS, endian="big")
|
||||||
|
checksum = crc5(payload)
|
||||||
|
data[106] = bool(checksum & 0x01)
|
||||||
|
data[90] = bool(checksum & 0x02)
|
||||||
|
data[74] = bool(checksum & 0x04)
|
||||||
|
data[58] = bool(checksum & 0x08)
|
||||||
|
data[42] = bool(checksum & 0x10)
|
||||||
|
|
||||||
|
position = 0
|
||||||
|
for start, end in EMBEDDED_LC_PAYLOAD_RANGES:
|
||||||
|
data[start:end] = payload[position:position + (end - start)]
|
||||||
|
position += end - start
|
||||||
|
|
||||||
|
for row_start in range(0, 112, 16):
|
||||||
|
row = data[row_start:row_start + 16]
|
||||||
|
encode_hamming_16114(row)
|
||||||
|
data[row_start:row_start + 16] = row
|
||||||
|
|
||||||
|
for column in range(16):
|
||||||
|
data[column + 112] = (
|
||||||
|
data[column + 0]
|
||||||
|
^ data[column + 16]
|
||||||
|
^ data[column + 32]
|
||||||
|
^ data[column + 48]
|
||||||
|
^ data[column + 64]
|
||||||
|
^ data[column + 80]
|
||||||
|
^ data[column + 96]
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = bitarray([0] * EMBEDDED_LC_RAW_BITS, endian="big")
|
||||||
|
position = 0
|
||||||
|
for index in range(EMBEDDED_LC_RAW_BITS):
|
||||||
|
raw[index] = data[position]
|
||||||
|
position += 16
|
||||||
|
if position > 127:
|
||||||
|
position -= 127
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
raw[index:index + EMBEDDED_LC_FRAGMENT_BITS]
|
||||||
|
for index in range(0, EMBEDDED_LC_RAW_BITS, EMBEDDED_LC_FRAGMENT_BITS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_emblc(lc: bytes | bitarray) -> dict[int, bitarray]:
|
||||||
|
fragments = encode_embedded_lc(lc)
|
||||||
|
return {
|
||||||
|
1: fragments[0],
|
||||||
|
2: fragments[1],
|
||||||
|
3: fragments[2],
|
||||||
|
4: fragments[3],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_embedded_lc(fragments: tuple[bitarray, bitarray, bitarray, bitarray] | list[bitarray]) -> EmbeddedLC:
|
||||||
|
if len(fragments) != 4:
|
||||||
|
raise EmbeddedLCError("embedded LC requires four fragments")
|
||||||
|
|
||||||
|
raw = bitarray(endian="big")
|
||||||
|
for fragment in fragments:
|
||||||
|
if len(fragment) != EMBEDDED_LC_FRAGMENT_BITS:
|
||||||
|
raise EmbeddedLCError("embedded LC fragments must be 32 bits")
|
||||||
|
raw += fragment
|
||||||
|
|
||||||
|
data = bitarray([0] * EMBEDDED_LC_RAW_BITS, endian="big")
|
||||||
|
position = 0
|
||||||
|
for index in range(EMBEDDED_LC_RAW_BITS):
|
||||||
|
data[position] = raw[index]
|
||||||
|
position += 16
|
||||||
|
if position > 127:
|
||||||
|
position -= 127
|
||||||
|
|
||||||
|
corrected = 0
|
||||||
|
for row_start in range(0, 112, 16):
|
||||||
|
row = data[row_start:row_start + 16]
|
||||||
|
before = row.copy()
|
||||||
|
if not decode_hamming_16114(row):
|
||||||
|
raise EmbeddedLCError("embedded LC Hamming check failed")
|
||||||
|
if row != before:
|
||||||
|
corrected += 1
|
||||||
|
data[row_start:row_start + 16] = row
|
||||||
|
|
||||||
|
for column in range(16):
|
||||||
|
parity = (
|
||||||
|
data[column + 0]
|
||||||
|
^ data[column + 16]
|
||||||
|
^ data[column + 32]
|
||||||
|
^ data[column + 48]
|
||||||
|
^ data[column + 64]
|
||||||
|
^ data[column + 80]
|
||||||
|
^ data[column + 96]
|
||||||
|
^ data[column + 112]
|
||||||
|
)
|
||||||
|
if parity:
|
||||||
|
raise EmbeddedLCError("embedded LC column parity check failed")
|
||||||
|
|
||||||
|
payload = bitarray(endian="big")
|
||||||
|
for start, end in EMBEDDED_LC_PAYLOAD_RANGES:
|
||||||
|
payload += data[start:end]
|
||||||
|
|
||||||
|
checksum = 0
|
||||||
|
if data[42]:
|
||||||
|
checksum += 16
|
||||||
|
if data[58]:
|
||||||
|
checksum += 8
|
||||||
|
if data[74]:
|
||||||
|
checksum += 4
|
||||||
|
if data[90]:
|
||||||
|
checksum += 2
|
||||||
|
if data[106]:
|
||||||
|
checksum += 1
|
||||||
|
|
||||||
|
if crc5(payload) != checksum:
|
||||||
|
raise EmbeddedLCError("embedded LC 5-bit checksum failed")
|
||||||
|
|
||||||
|
decoded = payload.tobytes()
|
||||||
|
return EmbeddedLC(data=decoded, flco=decoded[0] & 0x3F, raw=raw, corrected=corrected)
|
||||||
|
|
||||||
|
|
||||||
|
def embedded_lc_fragment_from_payload(payload: bytes) -> bitarray:
|
||||||
|
if len(payload) != 33:
|
||||||
|
raise EmbeddedLCError("DMR payload must be 33 bytes")
|
||||||
|
bits = bytes_to_bits(payload)
|
||||||
|
return bits[116:148]
|
||||||
|
|
||||||
|
|
||||||
|
def payload_with_embedded_lc_fragment(payload: bytes, fragment: bitarray) -> bytes:
|
||||||
|
if len(payload) != 33:
|
||||||
|
raise EmbeddedLCError("DMR payload must be 33 bytes")
|
||||||
|
if len(fragment) != EMBEDDED_LC_FRAGMENT_BITS:
|
||||||
|
raise EmbeddedLCError("embedded LC fragment must be 32 bits")
|
||||||
|
bits = bytes_to_bits(payload)
|
||||||
|
bits = bits[:116] + fragment + bits[148:264]
|
||||||
|
return bits.tobytes()
|
||||||
@ -1,3 +0,0 @@
|
|||||||
home = /usr/bin
|
|
||||||
include-system-site-packages = false
|
|
||||||
version = 3.10.12
|
|
||||||
@ -0,0 +1 @@
|
|||||||
|
"""FreeDMR test package."""
|
||||||
@ -0,0 +1 @@
|
|||||||
|
"""Test harness helpers for FreeDMR."""
|
||||||
@ -0,0 +1,495 @@
|
|||||||
|
"""In-process deterministic packet harness for bridge_master tests.
|
||||||
|
|
||||||
|
This module is test-only. It avoids UDP sockets and replaces production
|
||||||
|
network sends with capture functions while leaving production modules unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import copy
|
||||||
|
import importlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
DMRD = b"DMRD"
|
||||||
|
HBPF_VOICE = 0x0
|
||||||
|
HBPF_VOICE_SYNC = 0x1
|
||||||
|
HBPF_DATA_SYNC = 0x2
|
||||||
|
HBPF_SLT_VHEAD = 0x1
|
||||||
|
HBPF_SLT_VTERM = 0x2
|
||||||
|
ID_MAX = 16776415
|
||||||
|
PEER_MAX = 4294967295
|
||||||
|
|
||||||
|
|
||||||
|
def require_bridge_master():
|
||||||
|
"""Import bridge_master or skip tests when runtime deps are unavailable."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return importlib.import_module("bridge_master")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
f"bridge_master runtime dependency is not installed: {exc.name}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def bytes_3(value: int | bytes) -> bytes:
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
if len(value) != 3:
|
||||||
|
raise ValueError("expected exactly 3 bytes")
|
||||||
|
return value
|
||||||
|
return int(value).to_bytes(3, "big")
|
||||||
|
|
||||||
|
|
||||||
|
def bytes_4(value: int | bytes) -> bytes:
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
if len(value) != 4:
|
||||||
|
raise ValueError("expected exactly 4 bytes")
|
||||||
|
return value
|
||||||
|
return int(value).to_bytes(4, "big")
|
||||||
|
|
||||||
|
|
||||||
|
def int_id(value: int | bytes) -> int:
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
return int.from_bytes(value, "big")
|
||||||
|
|
||||||
|
|
||||||
|
def acl_permit_all(max_id: int = ID_MAX) -> tuple[bool, list[tuple[int, int]]]:
|
||||||
|
return True, [(1, max_id)]
|
||||||
|
|
||||||
|
|
||||||
|
def hbp_bits(slot: int, call_type: str, frame_type: int, dtype_vseq: int) -> int:
|
||||||
|
bits = ((frame_type & 0x3) << 4) | (dtype_vseq & 0xF)
|
||||||
|
if slot == 2:
|
||||||
|
bits |= 0x80
|
||||||
|
if call_type == "unit":
|
||||||
|
bits |= 0x40
|
||||||
|
return bits
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dmr_fields(packet: bytes) -> dict[str, object]:
|
||||||
|
if len(packet) < 20 or packet[:4] != DMRD:
|
||||||
|
return {"raw": packet}
|
||||||
|
|
||||||
|
bits = packet[15]
|
||||||
|
if bits & 0x40:
|
||||||
|
call_type = "unit"
|
||||||
|
elif (bits & 0x23) == 0x23:
|
||||||
|
call_type = "vcsbk"
|
||||||
|
else:
|
||||||
|
call_type = "group"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"opcode": packet[:4],
|
||||||
|
"seq": packet[4],
|
||||||
|
"rf_src": packet[5:8],
|
||||||
|
"dst_id": packet[8:11],
|
||||||
|
"peer_id": packet[11:15],
|
||||||
|
"bits": bits,
|
||||||
|
"slot": 2 if bits & 0x80 else 1,
|
||||||
|
"call_type": call_type,
|
||||||
|
"frame_type": (bits & 0x30) >> 4,
|
||||||
|
"dtype_vseq": bits & 0xF,
|
||||||
|
"stream_id": packet[16:20],
|
||||||
|
"dmr_payload": packet[20:53],
|
||||||
|
"ber": packet[53:54],
|
||||||
|
"rssi": packet[54:55],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PacketSpec:
|
||||||
|
peer_id: int | bytes = 1001
|
||||||
|
rf_src: int | bytes = 3120001
|
||||||
|
dst_id: int | bytes = 91
|
||||||
|
slot: int = 2
|
||||||
|
stream_id: int | bytes = 0x01020304
|
||||||
|
seq: int = 0
|
||||||
|
call_type: str = "group"
|
||||||
|
frame_type: int = HBPF_VOICE
|
||||||
|
dtype_vseq: int = 0
|
||||||
|
payload: bytes = b"\x00" * 33
|
||||||
|
ber: bytes = b"\x00"
|
||||||
|
rssi: bytes = b"\x00"
|
||||||
|
delay: float = 0.0
|
||||||
|
|
||||||
|
def data(self) -> bytes:
|
||||||
|
if len(self.payload) != 33:
|
||||||
|
raise ValueError("DMR payload must be exactly 33 bytes")
|
||||||
|
return b"".join(
|
||||||
|
[
|
||||||
|
DMRD,
|
||||||
|
bytes([self.seq & 0xFF]),
|
||||||
|
bytes_3(self.rf_src),
|
||||||
|
bytes_3(self.dst_id),
|
||||||
|
bytes_4(self.peer_id),
|
||||||
|
bytes([hbp_bits(self.slot, self.call_type, self.frame_type, self.dtype_vseq)]),
|
||||||
|
bytes_4(self.stream_id),
|
||||||
|
self.payload,
|
||||||
|
self.ber,
|
||||||
|
self.rssi,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def decoded_args(self) -> tuple[bytes, bytes, bytes, int, int, str, int, int, bytes, bytes]:
|
||||||
|
return (
|
||||||
|
bytes_4(self.peer_id),
|
||||||
|
bytes_3(self.rf_src),
|
||||||
|
bytes_3(self.dst_id),
|
||||||
|
self.seq & 0xFF,
|
||||||
|
self.slot,
|
||||||
|
self.call_type,
|
||||||
|
self.frame_type,
|
||||||
|
self.dtype_vseq,
|
||||||
|
bytes_4(self.stream_id),
|
||||||
|
self.data(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def decoded_obp_args(
|
||||||
|
self,
|
||||||
|
packet_hash: bytes = b"",
|
||||||
|
hops: bytes = b"",
|
||||||
|
source_server: int | bytes = 9990,
|
||||||
|
source_rptr: int | bytes = 0,
|
||||||
|
) -> tuple[bytes, bytes, bytes, int, int, str, int, int, bytes, bytes, bytes, bytes, bytes, bytes, bytes, bytes]:
|
||||||
|
return (
|
||||||
|
bytes_4(self.peer_id),
|
||||||
|
bytes_3(self.rf_src),
|
||||||
|
bytes_3(self.dst_id),
|
||||||
|
self.seq & 0xFF,
|
||||||
|
self.slot,
|
||||||
|
self.call_type,
|
||||||
|
self.frame_type,
|
||||||
|
self.dtype_vseq,
|
||||||
|
bytes_4(self.stream_id),
|
||||||
|
self.data(),
|
||||||
|
packet_hash,
|
||||||
|
hops,
|
||||||
|
bytes_4(source_server),
|
||||||
|
self.ber,
|
||||||
|
self.rssi,
|
||||||
|
bytes_4(source_rptr),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CapturedPacket:
|
||||||
|
target_system: str
|
||||||
|
packet: bytes
|
||||||
|
hops: bytes | None = None
|
||||||
|
ber: bytes = b"\x00"
|
||||||
|
rssi: bytes = b"\x00"
|
||||||
|
source_server: bytes = b"\x00\x00\x00\x00"
|
||||||
|
source_rptr: bytes = b"\x00\x00\x00\x00"
|
||||||
|
fields: dict[str, object] = field(init=False)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.fields = parse_dmr_fields(self.packet)
|
||||||
|
|
||||||
|
|
||||||
|
class PacketCapture:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.packets: list[CapturedPacket] = []
|
||||||
|
|
||||||
|
def recorder(self, target_system: str):
|
||||||
|
def record(
|
||||||
|
packet: bytes,
|
||||||
|
hops: bytes | None = b"",
|
||||||
|
ber: bytes = b"\x00",
|
||||||
|
rssi: bytes = b"\x00",
|
||||||
|
source_server: bytes = b"\x00\x00\x00\x00",
|
||||||
|
source_rptr: bytes = b"\x00\x00\x00\x00",
|
||||||
|
) -> None:
|
||||||
|
self.packets.append(
|
||||||
|
CapturedPacket(
|
||||||
|
target_system=target_system,
|
||||||
|
packet=packet,
|
||||||
|
hops=hops,
|
||||||
|
ber=ber,
|
||||||
|
rssi=rssi,
|
||||||
|
source_server=source_server,
|
||||||
|
source_rptr=source_rptr,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
def for_system(self, system: str) -> list[CapturedPacket]:
|
||||||
|
return [packet for packet in self.packets if packet.target_system == system]
|
||||||
|
|
||||||
|
|
||||||
|
class ReportCapture:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.events: list[bytes] = []
|
||||||
|
|
||||||
|
def send_bridgeEvent(self, data: bytes) -> None:
|
||||||
|
self.events.append(data)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self, start: float = 1_700_000_000.0) -> None:
|
||||||
|
self.now = float(start)
|
||||||
|
|
||||||
|
def time(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> float:
|
||||||
|
self.now += seconds
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
class FakeReactor:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.later: list[tuple[float, object, tuple, dict]] = []
|
||||||
|
self.thread_calls: list[tuple[object, tuple, dict]] = []
|
||||||
|
|
||||||
|
def callLater(self, delay, func, *args, **kwargs):
|
||||||
|
self.later.append((delay, func, args, kwargs))
|
||||||
|
return SimpleNamespace(cancel=lambda: None, active=lambda: True)
|
||||||
|
|
||||||
|
def callInThread(self, func, *args, **kwargs):
|
||||||
|
self.thread_calls.append((func, args, kwargs))
|
||||||
|
|
||||||
|
def callFromThread(self, func, *args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.writes: list[tuple[bytes, tuple[str, int] | None]] = []
|
||||||
|
|
||||||
|
def write(self, packet: bytes, sockaddr=None) -> None:
|
||||||
|
self.writes.append((packet, sockaddr))
|
||||||
|
|
||||||
|
|
||||||
|
def minimal_config(system_names: tuple[str, ...] = ("MASTER-A", "MASTER-B")) -> dict:
|
||||||
|
config = {
|
||||||
|
"GLOBAL": {
|
||||||
|
"SERVER_ID": bytes_4(9990),
|
||||||
|
"USE_ACL": False,
|
||||||
|
"TG1_ACL": acl_permit_all(),
|
||||||
|
"TG2_ACL": acl_permit_all(),
|
||||||
|
"SUB_ACL": acl_permit_all(),
|
||||||
|
"GEN_STAT_BRIDGES": False,
|
||||||
|
"DATA_GATEWAY": False,
|
||||||
|
"VALIDATE_SERVER_IDS": False,
|
||||||
|
},
|
||||||
|
"REPORTS": {"REPORT": False},
|
||||||
|
"ALIASES": {"PATH": "./", "SUB_MAP_FILE": ""},
|
||||||
|
"ALLSTAR": {"ENABLED": False},
|
||||||
|
"SYSTEMS": {},
|
||||||
|
"_SUB_IDS": {},
|
||||||
|
"_PEER_IDS": {},
|
||||||
|
"_LOCAL_SUBSCRIBER_IDS": {},
|
||||||
|
"_SERVER_IDS": {},
|
||||||
|
"CHECKSUMS": {},
|
||||||
|
}
|
||||||
|
for name in system_names:
|
||||||
|
config["SYSTEMS"][name] = {
|
||||||
|
"MODE": "MASTER",
|
||||||
|
"ENABLED": True,
|
||||||
|
"REPEAT": True,
|
||||||
|
"MAX_PEERS": 1,
|
||||||
|
"IP": "127.0.0.1",
|
||||||
|
"PORT": 0,
|
||||||
|
"PASSPHRASE": b"",
|
||||||
|
"GROUP_HANGTIME": 0,
|
||||||
|
"USE_ACL": False,
|
||||||
|
"REG_ACL": acl_permit_all(PEER_MAX),
|
||||||
|
"SUB_ACL": acl_permit_all(),
|
||||||
|
"TG1_ACL": acl_permit_all(),
|
||||||
|
"TG2_ACL": acl_permit_all(),
|
||||||
|
"DEFAULT_UA_TIMER": 1,
|
||||||
|
"SINGLE_MODE": True,
|
||||||
|
"VOICE_IDENT": False,
|
||||||
|
"DIAL_A_TG": True,
|
||||||
|
"DYNAMIC_TG_ROUTING": True,
|
||||||
|
"TS1_STATIC": "",
|
||||||
|
"TS2_STATIC": "",
|
||||||
|
"DEFAULT_DIAL_TS1": 0,
|
||||||
|
"DEFAULT_DIAL_TS2": 0,
|
||||||
|
"DEFAULT_REFLECTOR": 0,
|
||||||
|
"GENERATOR": 0,
|
||||||
|
"ANNOUNCEMENT_LANGUAGE": "en_GB",
|
||||||
|
"ALLOW_UNREG_ID": True,
|
||||||
|
"PROXY_CONTROL": False,
|
||||||
|
"OVERRIDE_IDENT_TG": False,
|
||||||
|
"PEERS": {},
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def add_openbridge_system(config: dict, name: str = "OBP-1", network_id: int = 1) -> dict:
|
||||||
|
config["SYSTEMS"][name] = {
|
||||||
|
"MODE": "OPENBRIDGE",
|
||||||
|
"ENABLED": True,
|
||||||
|
"NETWORK_ID": bytes_4(network_id),
|
||||||
|
"IP": "127.0.0.1",
|
||||||
|
"PORT": 0,
|
||||||
|
"PASSPHRASE": b"test-passphrase\x00\x00\x00\x00\x00\x00",
|
||||||
|
"TARGET_IP": "127.0.0.1",
|
||||||
|
"TARGET_PORT": 0,
|
||||||
|
"TARGET_SOCK": ("127.0.0.1", 0),
|
||||||
|
"USE_ACL": False,
|
||||||
|
"SUB_ACL": acl_permit_all(),
|
||||||
|
"TG1_ACL": acl_permit_all(),
|
||||||
|
"TG2_ACL": acl_permit_all(),
|
||||||
|
"RELAX_CHECKS": True,
|
||||||
|
"ENHANCED_OBP": False,
|
||||||
|
"VER": 5,
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def active_bridge(
|
||||||
|
name: str,
|
||||||
|
tg_id: int,
|
||||||
|
entries: tuple[tuple[str, int], ...],
|
||||||
|
timeout_minutes: int = 1,
|
||||||
|
) -> dict[str, list[dict]]:
|
||||||
|
tg_bytes = bytes_3(tg_id)
|
||||||
|
return {
|
||||||
|
name: [
|
||||||
|
{
|
||||||
|
"SYSTEM": system,
|
||||||
|
"TS": slot,
|
||||||
|
"TGID": tg_bytes,
|
||||||
|
"ACTIVE": True,
|
||||||
|
"TIMEOUT": timeout_minutes * 60,
|
||||||
|
"TO_TYPE": "ON",
|
||||||
|
"OFF": [],
|
||||||
|
"ON": [tg_bytes],
|
||||||
|
"RESET": [],
|
||||||
|
"TIMER": 0,
|
||||||
|
}
|
||||||
|
for system, slot in entries
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DeterministicScenario:
|
||||||
|
def __init__(self, config: dict | None = None, bridges: dict | None = None) -> None:
|
||||||
|
self.config = config or minimal_config()
|
||||||
|
self.bridges = bridges or {}
|
||||||
|
self.clock = FakeClock()
|
||||||
|
self.capture = PacketCapture()
|
||||||
|
self.reports: dict[str, ReportCapture] = {}
|
||||||
|
self.transports: dict[str, FakeTransport] = {}
|
||||||
|
self.reactor = FakeReactor()
|
||||||
|
self.bm = None
|
||||||
|
self._saved_attrs: dict[str, object] = {}
|
||||||
|
self._saved_systems: dict | None = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.bm = require_bridge_master()
|
||||||
|
self._saved_systems = dict(self.bm.systems)
|
||||||
|
|
||||||
|
for attr in (
|
||||||
|
"CONFIG",
|
||||||
|
"BRIDGES",
|
||||||
|
"SUB_MAP",
|
||||||
|
"peer_ids",
|
||||||
|
"subscriber_ids",
|
||||||
|
"talkgroup_ids",
|
||||||
|
"local_subscriber_ids",
|
||||||
|
"server_ids",
|
||||||
|
"checksums",
|
||||||
|
"reactor",
|
||||||
|
"time",
|
||||||
|
"words",
|
||||||
|
):
|
||||||
|
if hasattr(self.bm, attr):
|
||||||
|
self._saved_attrs[attr] = getattr(self.bm, attr)
|
||||||
|
|
||||||
|
self.bm.CONFIG = self.config
|
||||||
|
self.bm.BRIDGES = copy.deepcopy(self.bridges)
|
||||||
|
self.bm.SUB_MAP = {}
|
||||||
|
self.bm.peer_ids = {}
|
||||||
|
self.bm.subscriber_ids = {}
|
||||||
|
self.bm.talkgroup_ids = {}
|
||||||
|
self.bm.local_subscriber_ids = {}
|
||||||
|
self.bm.server_ids = {}
|
||||||
|
self.bm.checksums = {}
|
||||||
|
self.bm.words = {"en_GB": {"silence": b"", "busy": b"", "notlinked": b"", "linkedto": b"", "to": b""}}
|
||||||
|
self.bm.reactor = self.reactor
|
||||||
|
self.bm.time = self.clock.time
|
||||||
|
|
||||||
|
self.bm.systems.clear()
|
||||||
|
for system_name, system_config in self.config["SYSTEMS"].items():
|
||||||
|
report = ReportCapture()
|
||||||
|
self.reports[system_name] = report
|
||||||
|
if system_config["MODE"] == "MASTER":
|
||||||
|
system = self.bm.routerHBP(system_name, self.config, report)
|
||||||
|
elif system_config["MODE"] == "OPENBRIDGE":
|
||||||
|
system = self.bm.routerOBP(system_name, self.config, report)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
system.send_system = self.capture.recorder(system_name)
|
||||||
|
transport = FakeTransport()
|
||||||
|
system.transport = transport
|
||||||
|
self.transports[system_name] = transport
|
||||||
|
self.bm.systems[system_name] = system
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb) -> None:
|
||||||
|
if self.bm is None:
|
||||||
|
return
|
||||||
|
self.bm.systems.clear()
|
||||||
|
if self._saved_systems is not None:
|
||||||
|
self.bm.systems.update(self._saved_systems)
|
||||||
|
|
||||||
|
for attr in (
|
||||||
|
"CONFIG",
|
||||||
|
"BRIDGES",
|
||||||
|
"SUB_MAP",
|
||||||
|
"peer_ids",
|
||||||
|
"subscriber_ids",
|
||||||
|
"talkgroup_ids",
|
||||||
|
"local_subscriber_ids",
|
||||||
|
"server_ids",
|
||||||
|
"checksums",
|
||||||
|
"reactor",
|
||||||
|
"time",
|
||||||
|
"words",
|
||||||
|
):
|
||||||
|
if attr in self._saved_attrs:
|
||||||
|
setattr(self.bm, attr, self._saved_attrs[attr])
|
||||||
|
elif hasattr(self.bm, attr):
|
||||||
|
delattr(self.bm, attr)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def systems(self):
|
||||||
|
return self.bm.systems
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bridge_state(self):
|
||||||
|
return self.bm.BRIDGES
|
||||||
|
|
||||||
|
def inject_hbp(self, system_name: str, packet: PacketSpec) -> None:
|
||||||
|
self.systems[system_name].dmrd_received(*packet.decoded_args())
|
||||||
|
|
||||||
|
def inject_obp(self, system_name: str, packet: PacketSpec) -> None:
|
||||||
|
self.systems[system_name].dmrd_received(*packet.decoded_obp_args())
|
||||||
|
|
||||||
|
def inject_datagram(self, system_name: str, packet: bytes, sockaddr=("127.0.0.1", 50000)) -> None:
|
||||||
|
self.systems[system_name].datagramReceived(packet, sockaddr)
|
||||||
|
|
||||||
|
def register_peer(
|
||||||
|
self,
|
||||||
|
system_name: str,
|
||||||
|
peer_id: int | bytes = 1001,
|
||||||
|
sockaddr=("127.0.0.1", 50000),
|
||||||
|
callsign: bytes = b"TEST ",
|
||||||
|
) -> bytes:
|
||||||
|
peer = bytes_4(peer_id)
|
||||||
|
self.config["SYSTEMS"][system_name]["PEERS"][peer] = {
|
||||||
|
"CONNECTION": "YES",
|
||||||
|
"SOCKADDR": sockaddr,
|
||||||
|
"CALLSIGN": callsign,
|
||||||
|
"RADIO_ID": peer,
|
||||||
|
"LAST_PING": self.clock.time(),
|
||||||
|
}
|
||||||
|
return peer
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,139 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRequest:
|
||||||
|
def __init__(self, path, payload=None):
|
||||||
|
self.postpath = [part.encode("utf-8") for part in path.strip("/").split("/") if part]
|
||||||
|
self.content = io.BytesIO(
|
||||||
|
b"" if payload is None else json.dumps(payload).encode("utf-8")
|
||||||
|
)
|
||||||
|
self.code = None
|
||||||
|
self.headers = {}
|
||||||
|
|
||||||
|
def setResponseCode(self, code):
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
def setHeader(self, name, value):
|
||||||
|
self.headers[name] = value
|
||||||
|
|
||||||
|
def getHeader(self, name):
|
||||||
|
if name == "content-length":
|
||||||
|
return str(len(self.content.getvalue()))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class APITest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
try:
|
||||||
|
import twisted.web.resource # noqa: F401
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
self.skipTest(f"Twisted is not installed: {exc}")
|
||||||
|
import API
|
||||||
|
|
||||||
|
self.api = API
|
||||||
|
self.peer_id = (1234567).to_bytes(4, "big")
|
||||||
|
self.config = {
|
||||||
|
"GLOBAL": {"SYSTEM_API_KEY": "system-secret", "_KILL_SERVER": False},
|
||||||
|
"SYSTEMS": {
|
||||||
|
"MASTER-A": {
|
||||||
|
"MODE": "MASTER",
|
||||||
|
"PEERS": {self.peer_id: {}},
|
||||||
|
"_opt_key": "peer-secret",
|
||||||
|
},
|
||||||
|
"OBP-A": {
|
||||||
|
"MODE": "OPENBRIDGE",
|
||||||
|
"PEERS": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.bridges = {}
|
||||||
|
self.controller = API.FD_APIController(self.config, self.bridges)
|
||||||
|
|
||||||
|
def test_getoptions_returns_clear_no_options_response(self):
|
||||||
|
result = self.controller.getoptions("MASTER-A")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
{"connected": True, "has_options": False, "options": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_getoptions_decodes_byte_options_for_json(self):
|
||||||
|
self.config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = b"KEY=peer-secret;TS1=91"
|
||||||
|
|
||||||
|
result = self.controller.getoptions("MASTER-A")
|
||||||
|
|
||||||
|
self.assertEqual(result["options"], "KEY=peer-secret;TS1=91")
|
||||||
|
self.assertTrue(result["has_options"])
|
||||||
|
|
||||||
|
def test_setoptions_stores_full_options_string_unchanged(self):
|
||||||
|
options = "KEY=peer-secret;TS1=91;DIAL=2350"
|
||||||
|
|
||||||
|
self.controller.options("MASTER-A", options)
|
||||||
|
|
||||||
|
self.assertEqual(self.config["SYSTEMS"]["MASTER-A"]["OPTIONS"], options)
|
||||||
|
|
||||||
|
def test_user_reset_is_allowed_only_for_matching_peer_key(self):
|
||||||
|
system = self.controller.validateKey(1234567, "peer-secret")
|
||||||
|
|
||||||
|
self.assertEqual(system, "MASTER-A")
|
||||||
|
self.controller.reset(system)
|
||||||
|
self.assertTrue(self.config["SYSTEMS"]["MASTER-A"]["_reset"])
|
||||||
|
self.assertFalse(self.controller.validateKey(1234567, "wrong"))
|
||||||
|
|
||||||
|
def test_system_kill_sets_existing_control_flag(self):
|
||||||
|
self.assertTrue(self.controller.validateSystemKey("system-secret"))
|
||||||
|
|
||||||
|
self.controller.killserver()
|
||||||
|
|
||||||
|
self.assertTrue(self.config["GLOBAL"]["_KILL_SERVER"])
|
||||||
|
|
||||||
|
def test_options_get_endpoint_returns_json(self):
|
||||||
|
resource = self.api.make_api_resource(self.config, self.bridges)
|
||||||
|
request = FakeRequest(
|
||||||
|
"/api/v1/options/get",
|
||||||
|
{"dmrid": 1234567, "key": "peer-secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
body = resource.render_POST(request)
|
||||||
|
|
||||||
|
self.assertEqual(request.code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(body.decode("utf-8")),
|
||||||
|
{"ok": True, "connected": True, "has_options": False, "options": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_options_get_endpoint_rejects_bad_key(self):
|
||||||
|
resource = self.api.make_api_resource(self.config, self.bridges)
|
||||||
|
request = FakeRequest(
|
||||||
|
"/api/v1/options/get",
|
||||||
|
{"dmrid": 1234567, "key": "wrong"},
|
||||||
|
)
|
||||||
|
|
||||||
|
body = resource.render_POST(request)
|
||||||
|
|
||||||
|
self.assertEqual(request.code, 401)
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(body.decode("utf-8")),
|
||||||
|
{"ok": False, "error": "invalid_credentials"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_endpoint_rejects_large_request_body(self):
|
||||||
|
resource = self.api.make_api_resource(self.config, self.bridges)
|
||||||
|
request = FakeRequest(
|
||||||
|
"/api/v1/options/set",
|
||||||
|
{"dmrid": 1234567, "key": "peer-secret", "options": "A" * 9000},
|
||||||
|
)
|
||||||
|
|
||||||
|
body = resource.render_POST(request)
|
||||||
|
|
||||||
|
self.assertEqual(request.code, 413)
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(body.decode("utf-8")),
|
||||||
|
{"ok": False, "error": "request_too_large"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
import importlib
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
|
||||||
|
|
||||||
|
class AuxiliaryToolTests(unittest.TestCase):
|
||||||
|
def test_report_receiver_bool_flag(self):
|
||||||
|
import report_receiver
|
||||||
|
|
||||||
|
self.assertTrue(report_receiver.bool_flag("1"))
|
||||||
|
self.assertTrue(report_receiver.bool_flag("true"))
|
||||||
|
self.assertTrue(report_receiver.bool_flag("yes"))
|
||||||
|
self.assertFalse(report_receiver.bool_flag("0"))
|
||||||
|
self.assertFalse(report_receiver.bool_flag(""))
|
||||||
|
self.assertFalse(report_receiver.bool_flag(None))
|
||||||
|
|
||||||
|
def test_ami_factory_builds_protocol_with_instance_state(self):
|
||||||
|
try:
|
||||||
|
import AMI
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
self.skipTest(str(exc))
|
||||||
|
|
||||||
|
factory = AMI.AMI.AMIClientFactory(
|
||||||
|
AMI.AMI.AMIClient,
|
||||||
|
b"user",
|
||||||
|
b"secret",
|
||||||
|
b"1234",
|
||||||
|
b"ilink 3 2350",
|
||||||
|
)
|
||||||
|
protocol = factory.buildProtocol(None)
|
||||||
|
|
||||||
|
self.assertEqual(protocol.username, b"user")
|
||||||
|
self.assertEqual(protocol.secret, b"secret")
|
||||||
|
self.assertEqual(protocol.nodenum, b"1234")
|
||||||
|
self.assertEqual(protocol.command, b"ilink 3 2350")
|
||||||
|
|
||||||
|
def test_report_sql_uses_factory_db_and_parameterized_insert(self):
|
||||||
|
self._install_mysql_stub()
|
||||||
|
try:
|
||||||
|
import report_sql
|
||||||
|
report_sql = importlib.reload(report_sql)
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
self.skipTest(str(exc))
|
||||||
|
|
||||||
|
fake_db = _FakeDB()
|
||||||
|
fake_reactor = object()
|
||||||
|
factory = report_sql.reportClientFactory(report_sql.reportClient, fake_db, fake_reactor)
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
client = factory.buildProtocol(None)
|
||||||
|
|
||||||
|
self.assertIs(client.db, fake_db)
|
||||||
|
self.assertIs(client.reactor, fake_reactor)
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"type": "GROUP VOICE",
|
||||||
|
"event": "START",
|
||||||
|
"trx": "RX",
|
||||||
|
"system": "SYSTEM",
|
||||||
|
"streamid": "1234",
|
||||||
|
"peerid": "5678",
|
||||||
|
"subid": "9012",
|
||||||
|
"slot": "2",
|
||||||
|
"dstid": "2350",
|
||||||
|
"duration": "0",
|
||||||
|
}
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
client.send_mysql(event)
|
||||||
|
|
||||||
|
statement, params = fake_db.cursor_obj.executed
|
||||||
|
self.assertIn("%s", statement)
|
||||||
|
self.assertEqual(params[0], "GROUP VOICE")
|
||||||
|
self.assertEqual(params[8], "2350")
|
||||||
|
self.assertTrue(fake_db.committed)
|
||||||
|
self.assertTrue(fake_db.cursor_obj.closed)
|
||||||
|
|
||||||
|
def test_proxy_environment_bool_parser(self):
|
||||||
|
saved_modules = self._install_proxy_stubs()
|
||||||
|
try:
|
||||||
|
import hotspot_proxy_v2
|
||||||
|
hotspot_proxy_v2 = importlib.reload(hotspot_proxy_v2)
|
||||||
|
|
||||||
|
self.assertTrue(hotspot_proxy_v2.bool_from_env("1"))
|
||||||
|
self.assertTrue(hotspot_proxy_v2.bool_from_env("true"))
|
||||||
|
self.assertTrue(hotspot_proxy_v2.bool_from_env("yes"))
|
||||||
|
self.assertFalse(hotspot_proxy_v2.bool_from_env("0"))
|
||||||
|
self.assertFalse(hotspot_proxy_v2.bool_from_env(""))
|
||||||
|
self.assertFalse(hotspot_proxy_v2.bool_from_env(None))
|
||||||
|
finally:
|
||||||
|
self._restore_modules(saved_modules)
|
||||||
|
|
||||||
|
def _install_mysql_stub(self):
|
||||||
|
mysql_module = types.ModuleType("mysql")
|
||||||
|
connector_module = types.ModuleType("mysql.connector")
|
||||||
|
|
||||||
|
class ConnectorError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
connector_module.Error = ConnectorError
|
||||||
|
connector_module.errorcode = types.SimpleNamespace(
|
||||||
|
ER_ACCESS_DENIED_ERROR=1045,
|
||||||
|
ER_BAD_DB_ERROR=1049,
|
||||||
|
)
|
||||||
|
mysql_module.connector = connector_module
|
||||||
|
sys.modules["mysql"] = mysql_module
|
||||||
|
sys.modules["mysql.connector"] = connector_module
|
||||||
|
|
||||||
|
def _install_proxy_stubs(self):
|
||||||
|
stubbed = ["Pyro5", "Pyro5.api"]
|
||||||
|
saved_modules = {name: sys.modules.get(name) for name in stubbed + ["hotspot_proxy_v2"]}
|
||||||
|
|
||||||
|
pyro5_module = types.ModuleType("Pyro5")
|
||||||
|
pyro5_api_module = types.ModuleType("Pyro5.api")
|
||||||
|
pyro5_api_module.Proxy = object
|
||||||
|
pyro5_module.api = pyro5_api_module
|
||||||
|
sys.modules["Pyro5"] = pyro5_module
|
||||||
|
sys.modules["Pyro5.api"] = pyro5_api_module
|
||||||
|
sys.modules.pop("hotspot_proxy_v2", None)
|
||||||
|
return saved_modules
|
||||||
|
|
||||||
|
def _restore_modules(self, saved_modules):
|
||||||
|
for name, module in saved_modules.items():
|
||||||
|
if module is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = module
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCursor:
|
||||||
|
def __init__(self):
|
||||||
|
self.executed = None
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def execute(self, statement, params):
|
||||||
|
self.executed = (statement, params)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(self):
|
||||||
|
self.cursor_obj = _FakeCursor()
|
||||||
|
self.committed = False
|
||||||
|
|
||||||
|
def is_connected(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def cursor(self):
|
||||||
|
return self.cursor_obj
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_bridge_helper(name):
|
||||||
|
source = (ROOT / "bridge.py").read_text()
|
||||||
|
module = ast.parse(source)
|
||||||
|
for node in module.body:
|
||||||
|
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||||
|
namespace = {}
|
||||||
|
exec(compile(ast.Module([node], []), "bridge.py", "exec"), namespace)
|
||||||
|
return namespace[name]
|
||||||
|
raise AssertionError(f"bridge.py helper not found: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeBackportTests(unittest.TestCase):
|
||||||
|
def test_dmrd_seq_delta_is_modulo_256(self):
|
||||||
|
dmrd_seq_delta = load_bridge_helper("dmrd_seq_delta")
|
||||||
|
|
||||||
|
self.assertIsNone(dmrd_seq_delta(1, False))
|
||||||
|
self.assertEqual(dmrd_seq_delta(2, 1), 1)
|
||||||
|
self.assertEqual(dmrd_seq_delta(0, 255), 1)
|
||||||
|
self.assertEqual(dmrd_seq_delta(2, 255), 3)
|
||||||
|
self.assertEqual(dmrd_seq_delta(250, 2), 248)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,265 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from freedmr_dmr_codec import (
|
||||||
|
EmbeddedLCError,
|
||||||
|
FullLCError,
|
||||||
|
LC_SERVICE_OPTIONS_HBLINK_LEGACY,
|
||||||
|
LC_SERVICE_OPTIONS_NORMAL,
|
||||||
|
build_group_voice_lc,
|
||||||
|
SlotTypeError,
|
||||||
|
decode_embedded_lc,
|
||||||
|
decode_full_lc,
|
||||||
|
decode_slot_type,
|
||||||
|
embedded_lc_fragment_from_payload,
|
||||||
|
encode_embedded_lc,
|
||||||
|
encode_full_lc,
|
||||||
|
encode_full_lc_parity,
|
||||||
|
encode_slot_type,
|
||||||
|
payload_with_embedded_lc_fragment,
|
||||||
|
rs129_parity,
|
||||||
|
voice,
|
||||||
|
voice_head_term,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
GROUP_VOICE_LC = bytes.fromhex("000000000c302f9be5")
|
||||||
|
HEADER_LC_BITS = (
|
||||||
|
"000010111100001000000100110101100001111000001100001010111001100000001000"
|
||||||
|
"010100000111010001100001000000000000001011110100100001110110011100000000"
|
||||||
|
"0000000000000010111000000000111111001000010110100111"
|
||||||
|
)
|
||||||
|
TERMINATOR_LC_BITS = (
|
||||||
|
"000010111010110100000100000000100001111010111100001010111110000000001000"
|
||||||
|
"001000000111010011000001000100010100001011000111000001110001000100000000"
|
||||||
|
"1100010000000010011000000000111001011000010110010100"
|
||||||
|
)
|
||||||
|
EMBEDDED_LC_BITS = (
|
||||||
|
"00001111000011110000011000000110",
|
||||||
|
"00010111000100010000000000000110",
|
||||||
|
"00001100000000110011010100011011",
|
||||||
|
"00010111001101100010001000100010",
|
||||||
|
)
|
||||||
|
VOICE_HEAD_TERM_PAYLOAD = bytes.fromhex(
|
||||||
|
"2b6004101f842dd00df07d41046dff57d75df5de30152e2070b20f803f88c695e2"
|
||||||
|
)
|
||||||
|
VOICE_BURST_PAYLOAD = bytes.fromhex(
|
||||||
|
"b9e881526173002a6bb9e881526134e0f060691173002a6bb9e881526173002a6a"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FreeDmrDmrCodecTest(unittest.TestCase):
|
||||||
|
def test_full_lc_header_encode_matches_current_codec_fixture(self):
|
||||||
|
lc = GROUP_VOICE_LC
|
||||||
|
|
||||||
|
encoded = encode_full_lc(lc)
|
||||||
|
|
||||||
|
self.assertEqual(encoded.to01(), HEADER_LC_BITS)
|
||||||
|
|
||||||
|
def test_full_lc_terminator_encode_matches_current_codec_fixture(self):
|
||||||
|
lc = GROUP_VOICE_LC
|
||||||
|
|
||||||
|
encoded = encode_full_lc(lc, terminator=True)
|
||||||
|
|
||||||
|
self.assertEqual(encoded.to01(), TERMINATOR_LC_BITS)
|
||||||
|
|
||||||
|
def test_embedded_lc_group_voice_encode_matches_current_codec(self):
|
||||||
|
lc = GROUP_VOICE_LC
|
||||||
|
|
||||||
|
encoded = encode_embedded_lc(lc)
|
||||||
|
|
||||||
|
self.assertEqual(tuple(fragment.to01() for fragment in encoded), EMBEDDED_LC_BITS)
|
||||||
|
|
||||||
|
def test_current_runtime_compatibility_function_names(self):
|
||||||
|
import freedmr_dmr_codec as dmr_codec
|
||||||
|
|
||||||
|
lc = GROUP_VOICE_LC
|
||||||
|
|
||||||
|
self.assertEqual(dmr_codec.encode_header_lc(lc).to01(), HEADER_LC_BITS)
|
||||||
|
self.assertEqual(dmr_codec.encode_terminator_lc(lc).to01(), TERMINATOR_LC_BITS)
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(dmr_codec.encode_emblc(lc)[index].to01() for index in (1, 2, 3, 4)),
|
||||||
|
EMBEDDED_LC_BITS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_voice_head_term_decode_matches_current_codec(self):
|
||||||
|
decoded = voice_head_term(VOICE_HEAD_TERM_PAYLOAD)
|
||||||
|
|
||||||
|
self.assertEqual(decoded["LC"], bytes.fromhex("001020000c302f9be5"))
|
||||||
|
self.assertEqual(decoded["CC"], b"\x01")
|
||||||
|
self.assertEqual(decoded["DTYPE"], b"\x01")
|
||||||
|
self.assertEqual(decoded["SYNC"].to01(), "110111111111010101111101011101011101111101011101")
|
||||||
|
|
||||||
|
def test_voice_burst_decode_matches_current_codec(self):
|
||||||
|
decoded = voice(VOICE_BURST_PAYLOAD)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[ambe.to01() for ambe in decoded["AMBE"]],
|
||||||
|
[
|
||||||
|
"101110011110100010000001010100100110000101110011000000000010101001101011",
|
||||||
|
"101110011110100010000001010100100110000101110011000000000010101001101011",
|
||||||
|
"101110011110100010000001010100100110000101110011000000000010101001101010",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(decoded["CC"], b"\x01")
|
||||||
|
self.assertEqual(decoded["LCSS"], b"\x01")
|
||||||
|
self.assertEqual(decoded["EMBED"].to01(), "01001110000011110000011000000110")
|
||||||
|
|
||||||
|
def test_full_lc_decode_classifies_group_voice(self):
|
||||||
|
lc = GROUP_VOICE_LC
|
||||||
|
|
||||||
|
decoded = decode_full_lc(encode_full_lc(lc))
|
||||||
|
|
||||||
|
self.assertEqual(decoded.data, lc)
|
||||||
|
self.assertEqual(decoded.flco, 0x00)
|
||||||
|
self.assertTrue(decoded.is_group_call)
|
||||||
|
self.assertFalse(decoded.is_unit_call)
|
||||||
|
self.assertEqual(decoded.target_id, 3120)
|
||||||
|
self.assertEqual(decoded.source_id, 3120101)
|
||||||
|
|
||||||
|
def test_build_group_voice_lc_defaults_to_normal_service_options(self):
|
||||||
|
lc = build_group_voice_lc(bytes.fromhex("00005b"), bytes.fromhex("2f9be5"))
|
||||||
|
|
||||||
|
self.assertEqual(lc, bytes.fromhex("00000000005b2f9be5"))
|
||||||
|
self.assertEqual(decode_full_lc(encode_full_lc(lc)).service_options, LC_SERVICE_OPTIONS_NORMAL)
|
||||||
|
|
||||||
|
def test_build_group_voice_lc_can_represent_legacy_hblink_options_explicitly(self):
|
||||||
|
lc = build_group_voice_lc(
|
||||||
|
bytes.fromhex("00005b"),
|
||||||
|
bytes.fromhex("2f9be5"),
|
||||||
|
service_options=LC_SERVICE_OPTIONS_HBLINK_LEGACY,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(lc, bytes.fromhex("00002000005b2f9be5"))
|
||||||
|
self.assertEqual(decode_full_lc(encode_full_lc(lc)).service_options, LC_SERVICE_OPTIONS_HBLINK_LEGACY)
|
||||||
|
|
||||||
|
def test_full_lc_decode_classifies_unit_voice(self):
|
||||||
|
lc = bytes.fromhex("030000000c302f9be5")
|
||||||
|
|
||||||
|
decoded = decode_full_lc(encode_full_lc(lc))
|
||||||
|
|
||||||
|
self.assertEqual(decoded.flco, 0x03)
|
||||||
|
self.assertFalse(decoded.is_group_call)
|
||||||
|
self.assertTrue(decoded.is_unit_call)
|
||||||
|
|
||||||
|
def test_full_lc_rs129_parity_matches_header_and_terminator_masks(self):
|
||||||
|
lc = bytes.fromhex("001020000c302f9be5")
|
||||||
|
|
||||||
|
self.assertEqual(rs129_parity(lc), bytes.fromhex("4c42cc"))
|
||||||
|
self.assertEqual(encode_full_lc_parity(lc), bytes.fromhex("dad45a"))
|
||||||
|
self.assertEqual(encode_full_lc_parity(lc, terminator=True), bytes.fromhex("d5db55"))
|
||||||
|
|
||||||
|
def test_full_lc_requires_nine_byte_lc(self):
|
||||||
|
with self.assertRaises(FullLCError):
|
||||||
|
encode_full_lc(b"\x00" * 8)
|
||||||
|
|
||||||
|
def test_slot_type_encode_matches_current_codec_fixtures(self):
|
||||||
|
self.assertEqual(encode_slot_type(1, 0x1).to01(), "00010001101110001100")
|
||||||
|
self.assertEqual(encode_slot_type(1, 0x2).to01(), "00010010101001011001")
|
||||||
|
self.assertEqual(encode_slot_type(1, 0x3).to01(), "00010011001010110010")
|
||||||
|
self.assertEqual(encode_slot_type(1, 0x6).to01(), "00010110000011001110")
|
||||||
|
|
||||||
|
def test_slot_type_decode_corrects_three_bits(self):
|
||||||
|
bits = encode_slot_type(1, 0x2)
|
||||||
|
bits[0] = not bits[0]
|
||||||
|
bits[7] = not bits[7]
|
||||||
|
bits[19] = not bits[19]
|
||||||
|
|
||||||
|
decoded = decode_slot_type(bits)
|
||||||
|
|
||||||
|
self.assertEqual(decoded.color_code, 1)
|
||||||
|
self.assertEqual(decoded.data_type, 0x2)
|
||||||
|
self.assertEqual(decoded.name, "VOICE_LC_TERM")
|
||||||
|
self.assertEqual(decoded.corrected, 3)
|
||||||
|
|
||||||
|
def test_slot_type_decode_rejects_uncorrectable_error(self):
|
||||||
|
bits = encode_slot_type(1, 0x2)
|
||||||
|
for index in (0, 1, 2, 3):
|
||||||
|
bits[index] = not bits[index]
|
||||||
|
|
||||||
|
with self.assertRaises(SlotTypeError):
|
||||||
|
decode_slot_type(bits)
|
||||||
|
|
||||||
|
def test_slot_type_rejects_values_outside_four_bits(self):
|
||||||
|
with self.assertRaises(SlotTypeError):
|
||||||
|
encode_slot_type(16, 0x1)
|
||||||
|
with self.assertRaises(SlotTypeError):
|
||||||
|
encode_slot_type(1, 16)
|
||||||
|
|
||||||
|
def test_embedded_lc_round_trips_talker_alias_header(self):
|
||||||
|
lc = bytes.fromhex("04004c43414c4c3132")
|
||||||
|
|
||||||
|
encoded = encode_embedded_lc(lc)
|
||||||
|
decoded = decode_embedded_lc(encoded)
|
||||||
|
|
||||||
|
self.assertEqual(decoded.data, lc)
|
||||||
|
self.assertEqual(decoded.flco, 0x04)
|
||||||
|
self.assertEqual(decoded.corrected, 0)
|
||||||
|
|
||||||
|
def test_embedded_lc_round_trips_gps_info(self):
|
||||||
|
lc = bytes.fromhex("080007fcfae048b57b")
|
||||||
|
|
||||||
|
encoded = encode_embedded_lc(lc)
|
||||||
|
decoded = decode_embedded_lc(encoded)
|
||||||
|
|
||||||
|
self.assertEqual(decoded.data, lc)
|
||||||
|
self.assertEqual(decoded.flco, 0x08)
|
||||||
|
|
||||||
|
def test_encoded_talker_alias_fragments_match_mmdvmhost_layout_fixture(self):
|
||||||
|
lc = bytes.fromhex("04004c43414c4c3132")
|
||||||
|
payload = b"\x55" * 33
|
||||||
|
|
||||||
|
encoded_payloads = [
|
||||||
|
payload_with_embedded_lc_fragment(payload, fragment).hex()
|
||||||
|
for fragment in encode_embedded_lc(lc)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
encoded_payloads,
|
||||||
|
[
|
||||||
|
"555555555555555555555555555550517092855555555555555555555555555555",
|
||||||
|
"555555555555555555555555555550382441d55555555555555555555555555555",
|
||||||
|
"5555555555555555555555555555522717b8e55555555555555555555555555555",
|
||||||
|
"5555555555555555555555555555522b73ae255555555555555555555555555555",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_payload_fragment_helpers_extract_and_apply_embedded_lc(self):
|
||||||
|
lc = bytes.fromhex("080007fcfae048b57b")
|
||||||
|
fragment = encode_embedded_lc(lc)[0]
|
||||||
|
original = b"\x55" * 33
|
||||||
|
|
||||||
|
updated = payload_with_embedded_lc_fragment(original, fragment)
|
||||||
|
extracted = embedded_lc_fragment_from_payload(updated)
|
||||||
|
|
||||||
|
self.assertEqual(extracted, fragment)
|
||||||
|
self.assertEqual(updated[:14], original[:14])
|
||||||
|
self.assertEqual(updated[19:], original[19:])
|
||||||
|
|
||||||
|
def test_embedded_lc_decode_corrects_single_bit_error(self):
|
||||||
|
lc = bytes.fromhex("04004c43414c4c3132")
|
||||||
|
fragments = list(encode_embedded_lc(lc))
|
||||||
|
fragments[0] = fragments[0].copy()
|
||||||
|
fragments[0][0] = not fragments[0][0]
|
||||||
|
|
||||||
|
decoded = decode_embedded_lc(fragments)
|
||||||
|
|
||||||
|
self.assertEqual(decoded.data, lc)
|
||||||
|
self.assertEqual(decoded.corrected, 1)
|
||||||
|
|
||||||
|
def test_embedded_lc_decode_rejects_uncorrectable_error(self):
|
||||||
|
lc = bytes.fromhex("04004c43414c4c3132")
|
||||||
|
fragments = list(encode_embedded_lc(lc))
|
||||||
|
fragments[0] = fragments[0].copy()
|
||||||
|
fragments[0][0] = not fragments[0][0]
|
||||||
|
fragments[0][8] = not fragments[0][8]
|
||||||
|
|
||||||
|
with self.assertRaises(EmbeddedLCError):
|
||||||
|
decode_embedded_lc(fragments)
|
||||||
|
|
||||||
|
def test_embedded_lc_requires_nine_byte_lc(self):
|
||||||
|
with self.assertRaises(EmbeddedLCError):
|
||||||
|
encode_embedded_lc(b"\x00" * 8)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,24 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from utils import bytes_3, bytes_4, get_alias, int_id
|
||||||
|
|
||||||
|
|
||||||
|
class FreeDmrUtilsTest(unittest.TestCase):
|
||||||
|
def test_integer_byte_helpers_are_big_endian(self):
|
||||||
|
self.assertEqual(bytes_3(0x010203), b"\x01\x02\x03")
|
||||||
|
self.assertEqual(bytes_4(0x01020304), b"\x01\x02\x03\x04")
|
||||||
|
self.assertEqual(int_id(b"\x01\x02\x03\x04"), 0x01020304)
|
||||||
|
|
||||||
|
def test_get_alias_returns_matching_record_or_original_id(self):
|
||||||
|
aliases = {
|
||||||
|
3120001: {"callsign": "M0ABC", "name": "Test"},
|
||||||
|
235: "TG235",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(get_alias(bytes_3(3120001), aliases, "callsign"), ["M0ABC"])
|
||||||
|
self.assertEqual(get_alias(235, aliases), "TG235")
|
||||||
|
self.assertEqual(get_alias(999, aliases), 999)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in new issue