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 @@
|
||||
|
||||
from twisted.internet import reactor
|
||||
from twisted.web.xmlrpc import Proxy
|
||||
|
||||
|
||||
def printValue(value):
|
||||
print(repr(value))
|
||||
reactor.stop()
|
||||
|
||||
|
||||
def printError(error):
|
||||
print("error", error)
|
||||
reactor.stop()
|
||||
|
||||
|
||||
def capitalize(value):
|
||||
print(value)
|
||||
|
||||
|
||||
proxy = Proxy(b"http://localhost:7080/xmlrpc")
|
||||
# The callRemote method accepts a method name and an argument list.
|
||||
proxy.callRemote("FD_API.reset", '2', '55555').addCallbacks(capitalize, printError)
|
||||
reactor.run()
|
||||
import json
|
||||
import sys
|
||||
from urllib import request
|
||||
|
||||
|
||||
def post(path, payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(
|
||||
"http://127.0.0.1:8000" + path,
|
||||
data=body,
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with request.urlopen(req, timeout=3) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 4:
|
||||
print("usage: api_client.py <dmrid> <key> <options>")
|
||||
raise SystemExit(2)
|
||||
print(post("/api/v1/options/set", {
|
||||
"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