bugfix pass

codex-dialatg-analysis
Simon 2 weeks ago
parent 21c8087281
commit 562d86f949

6
.gitignore vendored

@ -81,6 +81,8 @@ celerybeat-schedule
# virtualenv
venv/
ENV/
.venv/
pyvenv.cfg
# Spyder project settings
.spyderproject
@ -93,6 +95,10 @@ hblink.cfg
*.config
*.bak
rules.py
bridge.pkl
config.pkl
sub_map.pkl
keys.json
subscriber_ids.*
local_subscriber_ids.*
peer_ids.*

@ -1,38 +1,47 @@
import sys
from time import time
import logging
from twisted.internet import reactor,task
from twisted.internet.defer import Deferred
from twisted.internet.protocol import ClientFactory,ClientFactory,Protocol
from twisted.internet import reactor
from twisted.internet.protocol import ClientFactory
from twisted.protocols.basic import LineReceiver
logger = logging.getLogger(__name__)
class AMI():
def __init__(self,host,port,username,secret,nodenum):
self._AMIClient = self.AMIClient
self.host = host
self.port = port
self.username = username.encode('utf-8')
self.secret = secret.encode('utf-8')
self.nodenum = str(nodenum)
self.nodenum = str(nodenum).encode('utf-8')
self.CF = None
def send_command(self,command):
self._AMIClient.command = command.encode('utf-8')
self._AMIClient.username = self.username
self._AMIClient.secret = self.secret
self._AMIClient.nodenum = self.nodenum.encode('utf-8')
self.command = command
self.CF = reactor.connectTCP(self.host, self.port, self.AMIClientFactory(self._AMIClient))
factory = self.AMIClientFactory(
self.AMIClient,
self.username,
self.secret,
self.nodenum,
command.encode('utf-8')
)
self.CF = reactor.connectTCP(self.host, self.port, factory)
def closeConnection(self):
self.transport.loseConnection()
if self.CF is not None:
self.CF.disconnect()
class AMIClient(LineReceiver):
delimiter = b'\r\n'
def __init__(self, username, secret, nodenum, command):
self.username = username
self.secret = secret
self.nodenum = nodenum
self.command = command
def connectionMade(self):
self.sendLine(b'Action: login')
self.sendLine(b''.join([b'Username: ',self.username]))
@ -40,31 +49,40 @@ class AMI():
self.sendLine(self.delimiter)
def lineReceived(self,line):
print(line)
logger.debug('(AMI) RX: %r', line)
if line == b'Asterisk Call Manager/1.0':
return
if line == b'Response: Success':
self.sendLine(b'Action: command')
#print(b''.join([b'Command: ',b'rpt cmd ',self.nodenum,b' ',self.command]))
self.sendLine(b''.join([b'Command: ',b'rpt cmd ',self.nodenum,b' ',self.command]))
#self.sendLine(b'Command: ' + b'rpt cmd 29177 ilink 3 2001')
self.sendLine(self.delimiter)
self.transport.loseConnection()
class AMIClientFactory(ClientFactory):
def __init__(self,AMIClient):
#self.command = command
self.done = Deferred()
def __init__(self,AMIClient,username,secret,nodenum,command):
self.protocol = AMIClient
#self.protocol.command = command
self.username = username
self.secret = secret
self.nodenum = nodenum
self.command = command
def buildProtocol(self, addr):
return self.protocol(
self.username,
self.secret,
self.nodenum,
self.command
)
def clientConnectionFailed(self, connector, reason):
ClientFactory.clientConnectionLost(self, connector, reason)
logger.warning('(AMI) Connection failed: %s', reason)
ClientFactory.clientConnectionFailed(self, connector, reason)
def clientConnectionLost(self, connector, reason):
logger.debug('(AMI) Connection lost: %s', reason)
ClientFactory.clientConnectionLost(self, connector, reason)

274
API.py

@ -17,132 +17,194 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
from spyne import ServiceBase, rpc, Integer, Decimal, UnsignedInteger32, Unicode, Iterable, error
from dmr_utils3.utils import bytes_3, bytes_4
import json
import logging
from twisted.web.resource import Resource
class FD_APIUserDefinedContext(object):
def __init__(self,CONFIG,BRIDGES):
from dmr_utils3.utils import bytes_4
logger = logging.getLogger(__name__)
MAX_API_BODY = 8192
class APIError(Exception):
def __init__(self, status, message):
self.status = status
self.message = message
super().__init__(message)
class FD_APIController(object):
"""Small, non-blocking control-plane API for live FreeDMR state.
The methods intentionally perform only in-memory dictionary reads/writes.
Expensive snapshots of CONFIG/BRIDGES are not exposed here because this API
shares the Twisted reactor with packet handling.
"""
version = 1
def __init__(self, CONFIG, BRIDGES):
self.CONFIG = CONFIG
self.BRIDGES = BRIDGES
def getconfig(self):
return self.CONFIG
def getbridges(self):
return self.BRIDGES
def validateKey(self,dmrid,key):
systems = self.CONFIG['SYSTEMS']
dmrid = bytes_4(dmrid)
print(dmrid)
for system in systems:
if systems[system]['MODE'] == 'MASTER':
for peerid in systems[system]['PEERS']:
print(peerid)
if peerid == dmrid:
def validateKey(self, dmrid, key):
try:
if key == systems[system]['_opt_key']:
return(system)
else:
return(False)
except KeyError:
return(False)
return(False)
def validateSystemKey(self,systemkey):
if systemkey == self.CONFIG['GLOBAL']['SYSTEM_API_KEY']:
return True
else:
peer_id = bytes_4(int(dmrid))
except (TypeError, ValueError, OverflowError):
return False
for system, system_config in self.CONFIG['SYSTEMS'].items():
if system_config['MODE'] != 'MASTER':
continue
if peer_id not in system_config['PEERS']:
continue
if key == system_config.get('_opt_key'):
return system
return False
return False
def validateSystemKey(self, systemkey):
return systemkey == self.CONFIG['GLOBAL'].get('SYSTEM_API_KEY')
def reset(self,system):
def reset(self, system):
self.CONFIG['SYSTEMS'][system]['_reset'] = True
def options(self,system,options):
def options(self, system, options):
self.CONFIG['SYSTEMS'][system]['OPTIONS'] = options
def getoptions(self,system):
return self.CONFIG['SYSTEMS'][system]['OPTIONS']
def getoptions(self, system):
options = self.CONFIG['SYSTEMS'][system].get('OPTIONS')
has_options = options is not None
if isinstance(options, bytes):
options = options.decode('utf-8', 'ignore')
elif options is None:
options = ''
else:
options = str(options)
return {
'connected': bool(self.CONFIG['SYSTEMS'][system]['PEERS']),
'has_options': has_options,
'options': options,
}
def killserver(self):
self.CONFIG['GLOBAL']['_KILL_SERVER'] = True
def resetAllConnections(self):
systems = self.CONFIG['SYSTEMS']
for system in systems:
for system in self.CONFIG['SYSTEMS']:
self.CONFIG['SYSTEMS'][system]['_reset'] = True
class FD_APIResource(Resource):
isLeaf = True
class FD_API(ServiceBase):
_version = 0.1
#return API version
@rpc(Unicode, _returns=Decimal())
def version(ctx, sessionid):
return(FD_API._version)
@rpc()
def dummy(ctx):
pass
######################
#User level API calls#
######################
@rpc(UnsignedInteger32,Unicode)
def reset(ctx,dmrid,key):
system = ctx.udc.validateKey(int(dmrid),key)
if system:
ctx.udc.reset(system)
else:
raise error.InvalidCredentialsError()
@rpc(UnsignedInteger32,Unicode,Unicode)
def setoptions(ctx,dmrid,key,options):
system = ctx.udc.validateKey(int(dmrid),key)
if system:
ctx.udc.options(system,options)
else:
raise error.InvalidCredentialsError()
@rpc(UnsignedInteger32,Unicode,_returns=Unicode())
def getoptions(ctx,dmrid,key):
system = ctx.udc.validateKey(int(dmrid),key)
if system:
return ctx.udc.getoptions(system)
else:
raise error.InvalidCredentialsError()
########################
#System level API calls#
########################
@rpc(Unicode)
def killserver(ctx,systemkey):
if ctx.udc.validateSystemKey(systemkey):
return ctx.udc.killserver()
else:
raise error.InvalidCredentialsError()
@rpc(Unicode)
def resetall(ctx,systemkey):
if ctx.udc.validateSystemKey(systemkey):
return ctx.udc.resetAllConnections()
else:
raise error.InvalidCredentialsError()
@rpc(Unicode,_returns=Unicode())
def getconfig(ctx,systemkey):
if ctx.udc.validateSystemKey(systemkey):
return ctx.udc.getconfig()
else:
raise error.InvalidCredentialsError()
@rpc(Unicode,_returns=Unicode())
def getbridges(ctx,systemkey):
if ctx.udc.validateSystemKey(systemkey):
return ctx.udc.getbridges()
else:
raise error.InvalidCredentialsError()
def __init__(self, controller):
Resource.__init__(self)
self.controller = controller
def render_GET(self, request):
path = self._path(request)
if path == '/api/v1/version':
return self._json(request, 200, {'ok': True, 'version': self.controller.version})
if path == '/api/v1/health':
return self._json(request, 200, {'ok': True})
return self._json(request, 404, {'ok': False, 'error': 'not_found'})
def render_POST(self, request):
try:
payload = self._payload(request)
path = self._path(request)
if path == '/api/v1/reset':
return self._user_call(request, payload, self._reset)
if path == '/api/v1/options/get':
return self._user_call(request, payload, self._get_options)
if path == '/api/v1/options/set':
return self._user_call(request, payload, self._set_options)
if path == '/api/v1/system/kill':
return self._system_call(request, payload, self._kill_server)
if path == '/api/v1/system/resetall':
return self._system_call(request, payload, self._reset_all)
return self._json(request, 404, {'ok': False, 'error': 'not_found'})
except APIError as exc:
return self._json(request, exc.status, {'ok': False, 'error': exc.message})
except Exception:
logger.exception('(API) Unhandled API error')
return self._json(request, 500, {'ok': False, 'error': 'internal_error'})
def _path(self, request):
return (b'/' + b'/'.join(request.postpath)).decode('utf-8', 'ignore')
def _payload(self, request):
content_length = None
if hasattr(request, 'getHeader'):
content_length = request.getHeader('content-length')
if content_length is not None:
try:
if int(content_length) > MAX_API_BODY:
raise APIError(413, 'request_too_large')
except ValueError:
raise APIError(400, 'invalid_content_length')
body = request.content.read()
if len(body) > MAX_API_BODY:
raise APIError(413, 'request_too_large')
if not body:
return {}
try:
payload = json.loads(body.decode('utf-8'))
except (TypeError, ValueError):
raise APIError(400, 'invalid_json')
if not isinstance(payload, dict):
raise APIError(400, 'invalid_json')
return payload
def _user_call(self, request, payload, handler):
dmrid = payload.get('dmrid')
key = payload.get('key')
system = self.controller.validateKey(dmrid, key)
if not system:
raise APIError(401, 'invalid_credentials')
result = handler(system, payload)
return self._json(request, 200, {'ok': True, **result})
def _system_call(self, request, payload, handler):
if not self.controller.validateSystemKey(payload.get('systemkey')):
raise APIError(401, 'invalid_credentials')
result = handler(payload)
return self._json(request, 200, {'ok': True, **result})
def _reset(self, system, payload):
self.controller.reset(system)
return {'reset': True, 'system': system}
def _get_options(self, system, payload):
return self.controller.getoptions(system)
def _set_options(self, system, payload):
options = payload.get('options')
if not isinstance(options, str):
raise APIError(400, 'missing_options')
self.controller.options(system, options)
return {'updated': True, 'system': system}
def _kill_server(self, payload):
self.controller.killserver()
return {'killserver': True}
def _reset_all(self, payload):
self.controller.resetAllConnections()
return {'resetall': True}
def _json(self, request, status, payload):
request.setResponseCode(status)
request.setHeader(b'content-type', b'application/json')
return json.dumps(payload, separators=(',', ':')).encode('utf-8')
def make_api_resource(CONFIG, BRIDGES):
return FD_APIResource(FD_APIController(CONFIG, BRIDGES))

@ -3,3 +3,5 @@
FreeDMR Peer Server - software to assist in building a peer mesh network
Please see the wiki for documentation.
For packet harness tests and run commands, see [docs/testing.md](docs/testing.md).

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

@ -67,6 +67,12 @@ __email__ = 'n0mjs@me.com'
# Module gobal varaibles
def dmrd_seq_delta(seq, last_seq):
if last_seq is False or last_seq is None:
return None
return (seq - last_seq) % 256
# Timed loop used for reporting HBP status
#
# REPORT BASED ON THE TYPE SELECTED IN THE MAIN CONFIG FILE
@ -323,16 +329,16 @@ class routerOBP(OPENBRIDGE):
if self.STATUS[_stream_id]['lastData'] and self.STATUS[_stream_id]['lastData'] == _data and _seq > 1:
logger.warning("(%s) *PacketControl* last packet is a complete duplicate of the previous one, disgarding. Stream ID:, %s TGID: %s",self._system,int_id(_stream_id),int_id(_dst_id))
return
#Handle inbound duplicates
if _seq and _seq == self.STATUS[_stream_id]['lastSeq']:
_seq_delta = dmrd_seq_delta(_seq, self.STATUS[_stream_id]['lastSeq'])
if _seq_delta == 0:
logger.warning("(%s) *PacketControl* Duplicate sequence number %s, disgarding. Stream ID:, %s TGID: %s",self._system,_seq,int_id(_stream_id),int_id(_dst_id))
return
#Inbound out-of-order packets
if _seq and self.STATUS[_stream_id]['lastSeq'] and (_seq != 1) and (_seq < self.STATUS[_stream_id]['lastSeq']):
if _seq_delta is not None and _seq_delta > 127:
logger.warning("%s) *PacketControl* Out of order packet - last SEQ: %s, this SEQ: %s, disgarding. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_stream_id]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id))
return
#Inbound missed packets
if _seq and self.STATUS[_stream_id]['lastSeq'] and _seq > (self.STATUS[_stream_id]['lastSeq']+1):
if _seq_delta is not None and _seq_delta > 1:
logger.warning("(%s) *PacketControl* Missed packet(s) - last SEQ: %s, this SEQ: %s. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_stream_id]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id))
#Save this sequence number
@ -501,8 +507,9 @@ class routerOBP(OPENBRIDGE):
#if not removed:
#selflogger.error('(%s) *CALL END* STREAM ID: %s NOT IN LIST -- THIS IS A REAL PROBLEM', self._system, int_id(_stream_id))
#Reset sequence number
self._lastSeq = False
#Reset sequence tracking
self.STATUS[_stream_id]['lastSeq'] = False
self.STATUS[_stream_id]['lastData'] = False
class routerHBP(HBSYSTEM):
@ -539,7 +546,9 @@ class routerHBP(HBSYSTEM):
4: b'\x00',
},
'lastSeq': False,
'lastData': False
'lastData': False,
'RX_FINISHED_STREAM_ID': b'\x00',
'RX_FINISHED_STREAM_LOG': False
},
2: {
@ -568,7 +577,9 @@ class routerHBP(HBSYSTEM):
4: b'\x00',
},
'lastSeq': False,
'lastData': False
'lastData': False,
'RX_FINISHED_STREAM_ID': b'\x00',
'RX_FINISHED_STREAM_LOG': False
}
}
@ -584,9 +595,19 @@ class routerHBP(HBSYSTEM):
_source_rptr = _peer_id
if _call_type == 'group':
if self.STATUS[_slot].get('RX_FINISHED_STREAM_ID') == _stream_id:
if not self.STATUS[_slot].get('RX_FINISHED_STREAM_LOG'):
logger.warning("(%s) HBP *LoopControl* STREAM ID: %s ALREADY FINISHED FROM THIS SOURCE, IGNORING",self._system, int_id(_stream_id))
self.STATUS[_slot]['RX_FINISHED_STREAM_LOG'] = True
return
# Is this a new call stream?
if (_stream_id != self.STATUS[_slot]['RX_STREAM_ID']):
self.STATUS[_slot]['RX_FINISHED_STREAM_ID'] = b'\x00'
self.STATUS[_slot]['RX_FINISHED_STREAM_LOG'] = False
self.STATUS[_slot]['lastSeq'] = False
self.STATUS[_slot]['lastData'] = False
if (self.STATUS[_slot]['RX_TYPE'] != HBPF_SLT_VTERM) and (pkt_time < (self.STATUS[_slot]['RX_TIME'] + STREAM_TO)) and (_rf_src != self.STATUS[_slot]['RX_RFS']):
logger.warning('(%s) Packet received with STREAM ID: %s <FROM> SUB: %s PEER: %s <TO> TGID %s, SLOT %s collided with existing call', self._system, int_id(_stream_id), int_id(_rf_src), int_id(_peer_id), int_id(_dst_id), _slot)
return
@ -639,16 +660,16 @@ class routerHBP(HBSYSTEM):
if self.STATUS[_slot]['lastData'] and self.STATUS[_slot]['lastData'] == _data and _seq > 1:
logger.warning("(%s) *PacketControl* last packet is a complete duplicate of the previous one, disgarding. Stream ID:, %s TGID: %s",self._system,int_id(_stream_id),int_id(_dst_id))
return
#Handle inbound duplicates
if _seq and _seq == self.STATUS[_slot]['lastSeq']:
_seq_delta = dmrd_seq_delta(_seq, self.STATUS[_slot]['lastSeq'])
if _seq_delta == 0:
logger.warning("(%s) *PacketControl* Duplicate sequence number %s, disgarding. Stream ID:, %s TGID: %s",self._system,_seq,int_id(_stream_id),int_id(_dst_id))
return
#Inbound out-of-order packets
if _seq and self.STATUS[_slot]['lastSeq'] and (_seq != 1) and (_seq < self.STATUS[_slot]['lastSeq']):
if _seq_delta is not None and _seq_delta > 127:
logger.warning("%s) *PacketControl* Out of order packet - last SEQ: %s, this SEQ: %s, disgarding. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_slot]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id))
return
#Inbound missed packets
if _seq and self.STATUS[_slot]['lastSeq'] and _seq > (self.STATUS[_slot]['lastSeq']+1):
if _seq_delta is not None and _seq_delta > 1:
logger.warning("(%s) *PacketControl* Missed packet(s) - last SEQ: %s, this SEQ: %s. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_slot]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id))
#Save this sequence number
@ -812,6 +833,10 @@ class routerHBP(HBSYSTEM):
self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot, call_duration)
if CONFIG['REPORTS']['REPORT']:
self._report.send_bridgeEvent('GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id), call_duration).encode(encoding='utf-8', errors='ignore'))
self.STATUS[_slot]['RX_FINISHED_STREAM_ID'] = _stream_id
self.STATUS[_slot]['RX_FINISHED_STREAM_LOG'] = False
self.STATUS[_slot]['lastSeq'] = False
self.STATUS[_slot]['lastData'] = False
#
# Begin in-band signalling for call end. This has nothign to do with routing traffic directly.

File diff suppressed because it is too large Load Diff

@ -137,7 +137,7 @@ def build_config(_config_file):
'PATH': config.get(section, 'PATH',fallback='./'),
'PING_TIME': config.getint(section, 'PING_TIME', fallback=10),
'MAX_MISSED': config.getint(section, 'MAX_MISSED', fallback=3),
'USE_ACL': config.get(section, 'USE_ACL', fallback=True),
'USE_ACL': config.getboolean(section, 'USE_ACL', fallback=True),
'REG_ACL': config.get(section, 'REG_ACL', fallback='PERMIT:ALL'),
'SUB_ACL': config.get(section, 'SUB_ACL', fallback='DENY:1'),
'TG1_ACL': config.get(section, 'TGID_TS1_ACL', fallback='PERMIT:ALL'),
@ -149,7 +149,8 @@ def build_config(_config_file):
'DATA_GATEWAY': config.getboolean(section, 'DATA_GATEWAY', fallback=False),
'VALIDATE_SERVER_IDS': config.getboolean(section, 'VALIDATE_SERVER_IDS', fallback=True),
'DEBUG_BRIDGES' : config.getboolean(section, 'DEBUG_BRIDGES', fallback=False),
'ENABLE_API' : config.getboolean(section, 'ENABLE_API', fallback=False)
'ENABLE_API' : config.getboolean(section, 'ENABLE_API', fallback=False),
'_KILL_SERVER': False
})

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

@ -377,8 +377,16 @@ class OPENBRIDGE(DatagramProtocol):
return
elif _packet[:4] == DMRE:
if len(_packet) < 56:
h,p = _sockaddr
logger.warning('(%s) FreeBridge packet too short, discarded - OPCODE: %s LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, _packet[:4], len(_packet), h, p)
return
if _packet[55] > 4:
if len(_packet) < 89:
h,p = _sockaddr
logger.warning('(%s) FreeBridge v%s packet too short, discarded - OPCODE: %s LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, _packet[55], _packet[:4], len(_packet), h, p)
return
_data = _packet[:53]
_ber = _packet[53:54]
_rssi = _packet[54:55]
@ -393,6 +401,10 @@ class OPENBRIDGE(DatagramProtocol):
_h = blake2b(key=self._config['PASSPHRASE'], digest_size=16)
_h.update(_packet[:73])
else:
if len(_packet) < 85:
h,p = _sockaddr
logger.warning('(%s) FreeBridge v%s packet too short, discarded - OPCODE: %s LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, _packet[55], _packet[:4], len(_packet), h, p)
return
_data = _packet[:53]
_ber = _packet[53:54]
_rssi = _packet[54:55]
@ -595,10 +607,10 @@ class OPENBRIDGE(DatagramProtocol):
if _packet[:4] == BCST:
#_data = _packet[:11]
_hash = _packet[4:]
_ckhs = hmac_new(self._config['PASSPHRASE'],_packet[4:],sha1).digest()
_ckhs = hmac_new(self._config['PASSPHRASE'],_packet[:4],sha1).digest()
if compare_digest(_hash, _ckhs):
logger.trace('(%s) *BridgeControl* BCST STUN request received for TGID: %s, Stream ID: %s',self._system,int_id(_tgid), int_id(_stream_id))
self._config['_STUN'] = True
logger.trace('(%s) *BridgeControl* BCST STUN request received',self._system)
self._CONFIG['STUN'] = True
else:
h,p = _sockaddr
logger.warning('(%s) *BridgeControl* BCST invalid STUN, packet discarded - OPCODE: %s DATA: %s HMAC LENGTH: %s HMAC: %s SRC IP: %s SRC PORT: %s', self._system, _packet[:4], repr(_packet[:53]), len(_packet[53:]), repr(_packet[53:]),h,p)
@ -843,6 +855,9 @@ class HBSYSTEM(DatagramProtocol):
# Extract the command, which is various length, all but one 4 significant characters -- RPTCL
_command = _data[:4]
if _command == DMRD: # DMRData -- encapsulated DMR data frame
if len(_data) < 53:
logger.warning('(%s) DMRD packet too short, discarded - LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, len(_data), _sockaddr[0], _sockaddr[1])
return
_peer_id = _data[11:15]
if _peer_id in self._peers \
and self._peers[_peer_id]['CONNECTION'] == 'YES' \
@ -1094,6 +1109,9 @@ class HBSYSTEM(DatagramProtocol):
_command = _data[:4]
if _command == DMRD: # DMRData -- encapsulated DMR data frame
if len(_data) < 53:
logger.warning('(%s) DMRD packet too short, discarded - LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, len(_data), _sockaddr[0], _sockaddr[1])
return
_peer_id = _data[11:15]
if self._config['LOOSE'] or _peer_id == self._config['RADIO_ID']: # Validate the Radio_ID unless using loose validation
#_seq = _data[4:5]

@ -35,6 +35,9 @@ __license__ = 'GNU GPLv3'
__maintainer__ = 'Simon Adlem G7RZU'
__email__ = 'simon@gb7fr.org.uk'
def bool_from_env(value):
return str(value).strip().lower() in ('1', 'true', 'yes', 'on')
def IsIPv4Address(ip):
try:
ipaddress.IPv4Address(ip)
@ -362,26 +365,23 @@ if __name__ == '__main__':
print('(PROXY)(GLOBAL) SHUTDOWN: PROXY IS TERMINATING WITH SIGNAL {}'.format(str(_signal)))
reactor.stop()
def sigt(_signal,_frame):
print('oooh')
#Install signal handlers
signal.signal(signal.SIGINT, sig_handler)
signal.signal(signal.SIGTERM, sigt)
signal.signal(signal.SIGTERM, sig_handler)
#readState()
#If IPv6 is enabled by enivornment variable...
if ListenIP == '' and 'FDPROXY_IPV6' in os.environ and bool(os.environ['FDPROXY_IPV6']):
if ListenIP == '' and bool_from_env(os.environ.get('FDPROXY_IPV6')):
ListenIP = '::'
#Override static config from Environment
if 'FDPROXY_STATS' in os.environ:
Stats = bool(os.environ['FDPROXY_STATS'])
Stats = bool_from_env(os.environ['FDPROXY_STATS'])
#if 'FDPROXY_DEBUG' in os.environ:
# Debug = bool(os.environ['FDPROXY_DEBUG'])
# Debug = bool_from_env(os.environ['FDPROXY_DEBUG'])
if 'FDPROXY_CLIENTINFO' in os.environ:
ClientInfo = bool(os.environ['FDPROXY_CLIENTINFO'])
ClientInfo = bool_from_env(os.environ['FDPROXY_CLIENTINFO'])
if 'FDPROXY_LISTENPORT' in os.environ:
ListenPort = int(os.environ['FDPROXY_LISTENPORT'])

@ -1,3 +0,0 @@
home = /usr/bin
include-system-site-packages = false
version = 3.10.12

@ -29,6 +29,9 @@ from reporting_const import *
from pprint import pprint
def bool_flag(value):
return str(value).strip().lower() in ('1', 'true', 'yes', 'on')
class reportClient(NetstringReceiver):
def stringReceived(self, data):
@ -36,10 +39,10 @@ class reportClient(NetstringReceiver):
if data[:1] == REPORT_OPCODES['BRDG_EVENT']:
self.bridgeEvent(data[1:].decode('UTF-8'))
elif data[:1] == REPORT_OPCODES['CONFIG_SND']:
if cli_args.CONFIG:
if bool_flag(cli_args.CONFIG):
self.configSend(data[1:])
elif data[:1] == REPORT_OPCODES['BRIDGE_SND']:
if cli_args.BRIDGES:
if bool_flag(cli_args.BRIDGES):
self.bridgeSend(data[1:])
elif data == b'bridge updated':
pass
@ -64,12 +67,12 @@ class reportClient(NetstringReceiver):
if len(datalist) > 9:
event['duration'] = datalist[9]
if cli_args.EVENTS:
if bool_flag(cli_args.EVENTS):
pprint(event, compact=True)
def bridgeSend(self,data):
self.BRIDGES = pickle.loads(data)
if cli_args.STATS:
if bool_flag(cli_args.STATS):
print('There are currently {} active bridges in the bridge table:\n'.format(len(self.BRIDGES)))
for _bridge in self.BRIDGES.keys():
print('{},'.format({str(_bridge)}))

@ -28,6 +28,7 @@ __email__ = 'simon@gb7fr.org.uk'
#It can be used as a skeleton to build logging and monitoring tools.
import pickle
import threading
from twisted.internet import reactor
from twisted.internet.protocol import ReconnectingClientFactory
@ -42,6 +43,7 @@ class reportClient(NetstringReceiver):
def __init__(self,db,reactor):
self.db = db
self.reactor = reactor
self._db_lock = threading.Lock()
def stringReceived(self, data):
@ -75,11 +77,10 @@ class reportClient(NetstringReceiver):
event['duration'] = datalist[9]
#self.reactor.callInThread(self.send_mysql,event)
self.send_mysql(event)
self.reactor.callInThread(self.send_mysql,event)
def send_mysql(self,event):
with self._db_lock:
while not self.db.is_connected():
try:
self.db.reconnect()
@ -89,11 +90,26 @@ class reportClient(NetstringReceiver):
print("{} {} {} {} {} {} {} {} {}".format(event['type'],event['event'], event['trx'],event['system'],event['streamid'],event['peerid'],event['subid'],event['slot'],event['dstid'],event['duration']))
_cursor = self.db.cursor()
try:
_cursor.execute("insert into feed values (NULL,'{}','{}','{}','{}','{}','{}','{}','{}','{}','{}')".format(event['type'],event['event'], event['trx'],event['system'],event['streamid'],event['peerid'],event['subid'],event['slot'],event['dstid'],event['duration']))
_cursor.execute(
"insert into feed values (NULL,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
event['type'],
event['event'],
event['trx'],
event['system'],
event['streamid'],
event['peerid'],
event['subid'],
event['slot'],
event['dstid'],
event['duration'],
)
)
self.db.commit()
except mysql.connector.Error as err:
_cursor.close()
print('(MYSQL) error, problem with cursor execute: {}'.format(err))
finally:
_cursor.close()
def bridgeSend(self,data):
@ -116,7 +132,7 @@ class reportClientFactory(ReconnectingClientFactory):
print('Connected.')
print('Resetting reconnection delay')
self.resetDelay()
return self.proto(db,reactor)
return self.proto(self.db,self.reactor)
def clientConnectionLost(self, connector, reason):
print('Lost connection. Reason:', reason)

@ -6,4 +6,3 @@ configparser>=3.0.0
resettabletimer>=0.7.0
setproctitle
Pyro5
spyne

@ -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…
Cancel
Save

Powered by TurnKey Linux.