parent
21c8087281
commit
562d86f949
@ -1,23 +1,26 @@
|
|||||||
|
import json
|
||||||
from twisted.internet import reactor
|
import sys
|
||||||
from twisted.web.xmlrpc import Proxy
|
from urllib import request
|
||||||
|
|
||||||
|
|
||||||
def printValue(value):
|
def post(path, payload):
|
||||||
print(repr(value))
|
body = json.dumps(payload).encode("utf-8")
|
||||||
reactor.stop()
|
req = request.Request(
|
||||||
|
"http://127.0.0.1:8000" + path,
|
||||||
|
data=body,
|
||||||
def printError(error):
|
headers={"content-type": "application/json"},
|
||||||
print("error", error)
|
method="POST",
|
||||||
reactor.stop()
|
)
|
||||||
|
with request.urlopen(req, timeout=3) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
def capitalize(value):
|
|
||||||
print(value)
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 4:
|
||||||
proxy = Proxy(b"http://localhost:7080/xmlrpc")
|
print("usage: api_client.py <dmrid> <key> <options>")
|
||||||
# The callRemote method accepts a method name and an argument list.
|
raise SystemExit(2)
|
||||||
proxy.callRemote("FD_API.reset", '2', '55555').addCallbacks(capitalize, printError)
|
print(post("/api/v1/options/set", {
|
||||||
reactor.run()
|
"dmrid": int(sys.argv[1]),
|
||||||
|
"key": sys.argv[2],
|
||||||
|
"options": sys.argv[3],
|
||||||
|
}))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,113 @@
|
|||||||
|
# FreeDMR API
|
||||||
|
|
||||||
|
FreeDMR includes an experimental HTTP/JSON API for small live control-plane
|
||||||
|
actions. It is intended for local administration and automation, not for public
|
||||||
|
internet exposure.
|
||||||
|
|
||||||
|
Enable it with:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[GLOBAL]
|
||||||
|
ENABLE_API: True
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, the API listens on TCP port `8000`.
|
||||||
|
|
||||||
|
## Safety Notes
|
||||||
|
|
||||||
|
FreeDMR is a live voice routing process. API requests are deliberately limited
|
||||||
|
to small in-memory operations so they do not delay DMR voice packet handling.
|
||||||
|
Request bodies larger than 8192 bytes are rejected.
|
||||||
|
|
||||||
|
Bind or firewall port `8000` appropriately. Do not expose it publicly without a
|
||||||
|
trusted reverse proxy and access controls.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
User-level endpoints require:
|
||||||
|
|
||||||
|
- `dmrid`: the connected HBP peer/repeater DMR ID
|
||||||
|
- `key`: the session options key for that peer
|
||||||
|
|
||||||
|
System-level endpoints require:
|
||||||
|
|
||||||
|
- `systemkey`: the FreeDMR system API key
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/v1/version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/options/get \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"dmrid":1234567,"key":"secret"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
If no live options are present, the response is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok":true,"connected":true,"has_options":false,"options":""}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/options/set \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"dmrid":1234567,"key":"secret","options":"KEY=secret;TS1=91;DIAL=2350"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `options` value must be the complete FreeDMR `OPTIONS` string. The API does
|
||||||
|
not add or preserve `KEY=...` automatically.
|
||||||
|
|
||||||
|
### Reset Peer Session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/reset \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"dmrid":1234567,"key":"secret"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
FreeDMR expects one HBP peer per master instance, so this resets the master
|
||||||
|
instance that owns the authenticated peer session.
|
||||||
|
|
||||||
|
### Reset All Connections
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/system/resetall \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"systemkey":"system-secret"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop FreeDMR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/v1/system/kill \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"systemkey":"system-secret"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responses
|
||||||
|
|
||||||
|
Successful responses include `"ok": true`. Failed responses include
|
||||||
|
`"ok": false` and an `error` string.
|
||||||
|
|
||||||
|
Common errors:
|
||||||
|
|
||||||
|
- `invalid_credentials`
|
||||||
|
- `invalid_json`
|
||||||
|
- `missing_options`
|
||||||
|
- `request_too_large`
|
||||||
|
- `not_found`
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,621 @@
|
|||||||
|
# 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.
|
||||||
|
- `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-reflector configuration bugs: startup and live options reload
|
||||||
|
should use the same prohibited default-reflector targets. Reserved/control
|
||||||
|
targets such as `6`, `7`, and AllStar control target `8` should not create an
|
||||||
|
active default TS2 reflector at startup. The FreeDMR policy cap should match
|
||||||
|
RF dial-a-TG handling: `999999` is valid and higher default reflectors are
|
||||||
|
rejected. Invalid default-reflector options should disable any existing
|
||||||
|
default reflector for the current session rather than preserving stale TS2 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 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.
|
||||||
|
- 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, 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`.
|
||||||
|
- 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 LC encoding. Recorded fixtures or carefully generated payloads
|
||||||
|
should be used for those cases.
|
||||||
|
- Embedded LC can carry information such as embedded GPS and talker alias. The
|
||||||
|
current harness protects against accidental mutation of data/control packets,
|
||||||
|
but it does not yet verify future source-to-destination embedded-LC carry-over.
|
||||||
|
- 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 TS1 private calls create, retune,
|
||||||
|
disconnect and query TS2 reflector state without emitting network traffic.
|
||||||
|
- 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` creates
|
||||||
|
and activates the TS2 reflector state and announces a link.
|
||||||
|
- 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-reflector tests verify that startup rejects reserved
|
||||||
|
control targets `6`, `7`, and `8`, while still allowing a linkable default
|
||||||
|
reflector target to create an active TS2 reflector. They also verify
|
||||||
|
`999999` remains valid and startup/options reject default reflector targets
|
||||||
|
above that policy cap, with invalid options disabling any active default
|
||||||
|
reflector 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 DIAL/static fields, boolean-like options reject values other
|
||||||
|
than `0` or `1`, empty `DIAL` disables 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,258 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
The deterministic suite includes dial-a-TG coverage. It verifies that private
|
||||||
|
calls from TS1 can create, retune, disconnect and query the TS2 reflector state.
|
||||||
|
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-reflector handling is covered so reserved/control
|
||||||
|
targets `6`, `7`, and `8` are rejected, `999999` is accepted, and higher targets
|
||||||
|
are rejected. Invalid default-reflector options disable the effective TS2 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 TS2 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 DIAL/static fields still apply,
|
||||||
|
`VOICE` and `SINGLE` accept only `0`/`1`, and empty `DIAL` disables the default
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
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, 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.
|
||||||
@ -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,491 @@
|
|||||||
|
"""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,
|
||||||
|
"TS1_STATIC": "",
|
||||||
|
"TS2_STATIC": "",
|
||||||
|
"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,162 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
def install_dmr_utils_stub():
|
||||||
|
if "dmr_utils3.utils" in sys.modules:
|
||||||
|
return None
|
||||||
|
dmr_utils3 = types.ModuleType("dmr_utils3")
|
||||||
|
utils = types.ModuleType("dmr_utils3.utils")
|
||||||
|
|
||||||
|
def bytes_4(value):
|
||||||
|
return int(value).to_bytes(4, "big")
|
||||||
|
|
||||||
|
utils.bytes_4 = bytes_4
|
||||||
|
sys.modules["dmr_utils3"] = dmr_utils3
|
||||||
|
sys.modules["dmr_utils3.utils"] = utils
|
||||||
|
return ("dmr_utils3", "dmr_utils3.utils")
|
||||||
|
|
||||||
|
|
||||||
|
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}")
|
||||||
|
self.stubbed_modules = install_dmr_utils_stub()
|
||||||
|
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 tearDown(self):
|
||||||
|
if self.stubbed_modules:
|
||||||
|
for module in self.stubbed_modules:
|
||||||
|
sys.modules.pop(module, None)
|
||||||
|
|
||||||
|
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,165 @@
|
|||||||
|
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 = ["dmr_utils3", "dmr_utils3.utils", "Pyro5", "Pyro5.api"]
|
||||||
|
saved_modules = {name: sys.modules.get(name) for name in stubbed + ["hotspot_proxy_v2"]}
|
||||||
|
|
||||||
|
dmr_utils3_module = types.ModuleType("dmr_utils3")
|
||||||
|
dmr_utils3_utils_module = types.ModuleType("dmr_utils3.utils")
|
||||||
|
dmr_utils3_utils_module.int_id = lambda value: int.from_bytes(value, "big")
|
||||||
|
dmr_utils3_module.utils = dmr_utils3_utils_module
|
||||||
|
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["dmr_utils3"] = dmr_utils3_module
|
||||||
|
sys.modules["dmr_utils3.utils"] = dmr_utils3_utils_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
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue