mirror of
https://github.com/misadeks/tetra-bluestation.git
synced 2026-09-01 16:48:32 +00:00
Implements the standalone MS-initiated group attach/detach procedure so a UI can change talkgroups at runtime instead of only using the config set, strictly per ETSI TS 100 392-2 V3.10.1. G2 (TX): on a TNMM-ATTACH DETACH GROUP IDENTITY request (Table 15.1), once registered, build and send a U-ATTACH/DETACH GROUP IDENTITY PDU (cl. 16.9.3.1) over the acknowledged basic link: report = not-report-request, amendment vs detach-all mode, one Group identity uplink per GSSI (class of usage per cl. 16.10.6 Table 16.32, or detachment reason per cl. 16.10.21). Start T353 (cl. 16.11.1.3, 10 s). Single operation outstanding; rejected if not registered. G3 (RX): on D-ATTACH/DETACH GROUP IDENTITY ACKNOWLEDGEMENT (cl. 16.9.2.2), stop T353, reconcile the attached-group set from the SwMI-confirmed downlink elements (detach-all replaces; amendment adds/removes), and push it to the MLE via MLE-IDENTITIES so the MAC downlink filter matches (cl. 23.4.1.2.1). On T353 expiry the op is treated as failed (cl. 16.8.5). G4 (confirm + UI): emit a TNMM-ATTACH DETACH GROUP IDENTITY confirm (Table 15.1, GTSIs only per cl. 16.8.2) to the user application; document the request/confirm JSON shapes and add a live talkgroup-switch example to the MS interface README + reference client. Tests: +7 mm_ms unit tests (unregistered reject, TX PDU round-trips through the BS parser + T353 armed, ACK reconcile + MLE-IDENTITIES + confirm, amendment detach, detach-all replace, second-op-while-pending reject, T353 expiry failure). 196 lib + all integration suites green; no BS air-interface change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
91 lines
4.2 KiB
Python
91 lines
4.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Minimal language-neutral reference client for the BlueStation MS external
|
|
interface (schema ``bluestation-ms-interface-1``).
|
|
|
|
Proves portability: the interface is plain JSON over a WebSocket, so any language
|
|
with a WebSocket + JSON library can drive it. This sample uses the ``websockets``
|
|
package (``pip install websockets``); the message shapes are identical in any
|
|
language. See ``README.md`` in this directory for the full message catalog.
|
|
|
|
NON-STANDARD note: the ``Management`` (Plane B) messages are implementation-defined
|
|
provisioning, NOT part of any ETSI standard. The ``Tnmm*`` (Plane A) messages trace
|
|
verbatim to ETSI TS 100 392-2 cl. 15.3. Secrets are redacted to ``"********"`` in
|
|
``GetConfig``; leaving that sentinel untouched on ``SetConfig`` preserves the live
|
|
on-disk credential.
|
|
|
|
Usage:
|
|
python reference-client.py wss://<host>:<port>/
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
import websockets
|
|
except ImportError:
|
|
sys.exit("this sample needs the 'websockets' package: pip install websockets")
|
|
|
|
CONTROL_SUBPROTOCOL = "bluestation-control-v1" # transport handshake (shared w/ BS)
|
|
|
|
|
|
async def main(url: str) -> None:
|
|
# The control channel negotiates the transport subprotocol at handshake time.
|
|
async with websockets.connect(url, subprotocols=[CONTROL_SUBPROTOCOL]) as ws:
|
|
async def call(command: dict) -> dict:
|
|
await ws.send(json.dumps(command))
|
|
return json.loads(await ws.recv())
|
|
|
|
# 1) Discover the frozen interface schema version.
|
|
resp = await call({"Management": {"GetInterfaceVersion": {"handle": 1}}})
|
|
version = resp["Management"]["InterfaceVersion"]["version"]
|
|
print(f"interface schema: {version}")
|
|
|
|
# 2) Read MS runtime state.
|
|
resp = await call({"Management": {"GetState": {"handle": 2}}})
|
|
print("state:", json.dumps(resp["Management"]["State"]["state"], indent=2))
|
|
|
|
# 3) Read the active config (canonical TOML, secrets redacted to "********").
|
|
resp = await call({"Management": {"GetConfig": {"handle": 3}}})
|
|
toml_text = resp["Management"]["Config"]["toml"]
|
|
print("config:\n", toml_text)
|
|
|
|
# 4) (Illustrative) stage an edited config and apply it. Leaving any
|
|
# "********" secret untouched preserves the live on-disk credential.
|
|
# Uncomment to use:
|
|
# edited = toml_text # ... modify structural fields here ...
|
|
# ack = await call({"Management": {"SetConfig": {"handle": 4, "toml": edited}}})
|
|
# print("set:", ack["Management"]["Ack"])
|
|
# ack = await call({"Management": {"ApplyConfig": {"handle": 5}}})
|
|
# print("apply:", ack["Management"]["Ack"]) # stack de-registers + restarts
|
|
|
|
# 5) (Plane A, STANDARD) Switch talkgroup live (cl. 15.3.3.1 / cl. 16.8.2).
|
|
# Requires the MS to already be registered. "DetachTheCurrentlyActive
|
|
# GroupIdentities" detaches the current set and attaches GSSI 300 —
|
|
# i.e. a talkgroup *change*. Use "Amendment" to add/remove without
|
|
# disturbing the rest of the set. The Ack below only means "accepted
|
|
# for processing"; the actual attach RESULT arrives asynchronously on
|
|
# the telemetry channel as a TnmmAttachDetachGroupIdentityConfirm and
|
|
# is reflected in GetState.attached_groups. Uncomment to use:
|
|
# ack = await call({"TnmmAttachDetachGroupIdentity": {
|
|
# "handle": 6,
|
|
# "request": {
|
|
# "group_identity_attach_detach_mode":
|
|
# "DetachTheCurrentlyActiveGroupIdentities",
|
|
# "group_identity_request": [{
|
|
# "gtsi": 300,
|
|
# "group_identity_attach_detach_type_identifier": "Attachment",
|
|
# "class_of_usage": "ClassOfUsage4",
|
|
# "group_identity_detachment_request": None,
|
|
# }],
|
|
# "group_identity_report": None,
|
|
# },
|
|
# }})
|
|
# print("attach ack:", ack["TnmmAck"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
sys.exit(f"usage: {sys.argv[0]} wss://<host>:<port>/")
|
|
asyncio.run(main(sys.argv[1]))
|