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>
11 KiB
BlueStation MS external-interface message catalog
Schema bluestation-ms-interface-1.
This directory documents and demonstrates the MS (mobile-station) external interface: the message contract a separate user-interface process uses to drive the BlueStation MS RF process (read state, provision the stack, and issue standardized TETRA mobility primitives). It exists so multiple portable-radio UIs can be built on top of the stack.
README.md(this file) — the protocol/API documentation.reference-client.py— a minimal, runnable, language-neutral reference client.
Versioning
Frozen at MS_INTERFACE_SCHEMA_VERSION = "bluestation-ms-interface-1"
(crates/tetra-entities/src/management/mod.rs), discoverable at runtime via the
GetInterfaceVersion command. This is the application schema version for the
MS interface; it is independent of the transport WebSocket subprotocol
handshake strings (bluestation-control-v1 / bluestation-telemetry-v1), which
are shared with the BS and are intentionally NOT bumped (MS-side only; no BS
behaviour change).
Transport & encoding
Both planes share the existing in-tree transport (WebSocket + JSON, TLS + argon2). The stack currently connects as a client to the UI's control and telemetry endpoints; the message schema is transport-agnostic, so a stack-hosted listen/server mode could be added later as a pure-additive change without touching Plane A/B messages.
JSON encoding is serde's default externally-tagged enum representation:
{"VariantName": { ...fields... }}. Control commands/responses are wrapped as
{"Management": { ... }} (Plane B) or the TNMM variant name (Plane A).
Two planes
- Plane A — TNMM-SAP (STANDARDIZED, ETSI TS 100 392-2 cl. 15.3): requests UI->stack over the control channel; indications/confirms stack->UI over the telemetry channel. Traces verbatim to cl. 15.3.3 (Tables 15.1-15.7) and the value enums in cl. 15.3.4.
- Plane B — management/provisioning (NON-STANDARD, implementation-defined):
runtime-state reads + config read/write/apply. ETSI does not standardize radio
programming over the air; this plane is a BlueStation-specific facility. Carried
in the
Managementwrapper variants ofControlCommand/ControlResponse.
Plane B — control channel (UI -> stack), wrapped in ControlCommand::Management
| Command | Payload | When | Response |
|---|---|---|---|
GetState |
{handle:u32} |
live/anytime | State |
GetInterfaceVersion |
{handle:u32} |
live/anytime | InterfaceVersion |
GetConfig |
{handle:u32} |
live/anytime | Config |
SetConfig |
{handle:u32, toml:String} |
live (stages to disk) | Ack |
ApplyConfig |
{handle:u32} |
drains + restarts | Ack |
Responses, wrapped in ControlResponse::Management
State {handle:u32, state: MsRuntimeState}InterfaceVersion {handle:u32, version:String}Config {handle:u32, toml:String}— canonical TOML of the active config (secrets redacted)Ack {handle:u32, accepted:bool, restart_required:bool, message:String}Error {handle:u32, message:String}
MsRuntimeState:
registration_state : "Idle" | "Registering" | "Registered" | "Detaching"
service_status : ServiceStatus (Plane A vocabulary)
own_issi : u32
home_mcc : u16
home_mnc : u16
serving_la : u16
colour_code : u8
attached_groups : [u32]
restart_required : bool
Apply model (HYBRID)
- Structural radio params (MCC/MNC, carrier/band/duplex, ISSI, SDR device):
SetConfigvalidates through the exact startup validator and writes the TOML file, setsrestart_required=true; it does NOT bounce the process. A laterApplyConfigperforms the graceful de-registration drain (U-ITSI DETACH, cl. 16.6.1) and exits with code 75 for an external supervisor to respawn (seeexample_config/bluestation-ms.serviceandbluestation-ms-supervisor.sh). - Operational TNMM actions (register/deregister, group attach/detach, energy saving): carried on Plane A and applied live.
Secret handling (redact on the wire, preserve on write-back)
GetConfigredacts every secret (control/telemetry/brew password) to the sentinel"********"on the wire; plaintext credentials never leave the RF process.SetConfigtreats the sentinel as "keep the existing on-disk secret": a secret posted back unchanged (still"********") preserves the live value; a genuinely-new value overwrites it. AGetConfig -> edit-unrelated-field -> SetConfiground-trip therefore never clobbers a credential, and the result re-parses through the same validator (closure preserved).- The on-disk TOML remains the canonical plaintext store of the real secrets.
Plane A — TNMM-SAP (STANDARDIZED, cl. 15.3)
Requests (control channel), top-level ControlCommand variants
| Command | Table / clause | Notes |
|---|---|---|
TnmmRegistration {handle, request} |
15.5 / 15.3.3.7 | initiate ITSI attach + registration |
TnmmDeregistration {handle, request} |
15.2 / 15.3.3.2 | U-ITSI DETACH (reuses shutdown drain) |
TnmmAttachDetachGroupIdentity {handle, request} |
15.1 / 15.3.3.1 | live talkgroup attach/detach (standalone procedure cl. 16.8.2); requires the MS to be registered |
TnmmStatus {handle, request} |
15.7 / 15.3.3.9 | dormant: direct mode / dual watch / energy economy not implemented |
TnmmEnergySaving {handle, request} |
15.3 / 15.3.3.5 | dormant: energy economy cl. 16.7 not implemented |
All requests are acknowledged with ControlResponse::TnmmAck {handle, accepted:bool, detail:Option<String>}. The TnmmAck only reports whether the request was accepted
for processing (e.g. rejected if the MS is not registered, or a group op is already
outstanding). The TNMM result is reported asynchronously via the telemetry-channel
indications/confirms (cl. 15.3.2).
TnmmAttachDetachGroupIdentity request shape (Table 15.1 / 15.9)
{"TnmmAttachDetachGroupIdentity": {
"handle": 31,
"request": {
"group_identity_attach_detach_mode": "Amendment"
| "DetachTheCurrentlyActiveGroupIdentities",
"group_identity_request": [
{ "gtsi": 300,
"group_identity_attach_detach_type_identifier": "Attachment",
"class_of_usage": "ClassOfUsage4", // Attachment only
"group_identity_detachment_request": null } , // Detachment only
{ "gtsi": 91,
"group_identity_attach_detach_type_identifier": "Detachment",
"class_of_usage": null,
"group_identity_detachment_request": "UserInitiatedDetachment" }
],
"group_identity_report": null // Option<"ReportRequested"|"ReportNotRequested">
}
}}
Amendment(cl. 16.10.17 mode 0) adds/removes only the listed groups; the rest of the attached set is untouched.DetachTheCurrentlyActiveGroupIdentities(mode 1) detaches everything currently attached, then attaches the listed groups — use this to switch talkgroup.gtsiis the full 48-bit GTSI (MNI << 24 | GSSI); a bare GSSI also works (the low 24 bits are taken).class_of_usagemaps to the on-air 3-bit value per cl. 16.10.6 (Table 16.32: "Class of usage N" = N-1). Detachment carries the reason per cl. 16.10.21 (onlyUserInitiatedDetachmentis defined at this SAP).
Indications/confirms (telemetry channel), TelemetryEvent variants
| Indication | Table / clause | Emit point |
|---|---|---|
| TNMM-REGISTRATION indication | 15.5 | MM reg-state transitions (accept/reject/T351) |
| TNMM-SERVICE indication | 15.6 | in/out of service transitions |
| TNMM-ATTACH DETACH GROUP IDENTITY confirm | 15.1 | on the D-ATTACH/DETACH GROUP IDENTITY ACKNOWLEDGEMENT, or on T353 expiry (failure) |
| TNMM-REPORT indication | 15.4 | DORMANT — U-ITSI DETACH transfer-result source not yet wired (MM does not observe the TxReporter through LMM-UNITDATA) |
| (STATUS / ENERGY-SAVING) | 15.7 / 15.3 | DEFINED but DORMANT — stack cannot truthfully observe |
TnmmAttachDetachGroupIdentityConfirm telemetry event shape (Table 15.1):
{"TnmmAttachDetachGroupIdentityConfirm": {
"group_identity_attach_detach_mode": "Amendment" | "DetachTheCurrentlyActiveGroupIdentities",
"group_identity_report": null,
"group_identities": [ // the SwMI-acknowledged groups (GTSIs only)
{ "gtsi": 300,
"group_identity_attach_detach_type_identifier": "Attachment",
"group_identity_lifetime": "AttachmentNeededForNextItsiAttach",
"class_of_usage": "ClassOfUsage4",
"group_identity_detachment_reason": null }
]
}}
An empty group_identities list means no group was acknowledged (e.g. the operation
failed / T353 expired). The confirmed set is also reflected in MsRuntimeState.attached_groups
(readable via GetState).
Reject-cause mapping: cl. 15.3.4 is a strict subset of the on-air 16.10.42 cause
set; causes with no cl. 15.3.4 enumerant map to None (never fabricated).
wscat (language-neutral) quick example
# Connect to the stack's control channel (subprotocol = bluestation-control-v1),
# authenticated per your transport config.
wscat -s bluestation-control-v1 -c wss://<stack-host>:<port>/
# Discover the interface schema version:
> {"Management":{"GetInterfaceVersion":{"handle":1}}}
< {"Management":{"InterfaceVersion":{"handle":1,"version":"bluestation-ms-interface-1"}}}
# Read runtime state:
> {"Management":{"GetState":{"handle":2}}}
< {"Management":{"State":{"handle":2,"state":{...}}}}
# Read config (secrets show as "********"), edit the TOML, stage it, then apply:
> {"Management":{"GetConfig":{"handle":3}}}
< {"Management":{"Config":{"handle":3,"toml":"config_version = \"0.6\"\n..."}}}
> {"Management":{"SetConfig":{"handle":4,"toml":"<edited toml>"}}}
< {"Management":{"Ack":{"handle":4,"accepted":true,"restart_required":true,"message":"..."}}}
> {"Management":{"ApplyConfig":{"handle":5}}}
< {"Management":{"Ack":{"handle":5,"accepted":true,"restart_required":true,"message":"..."}}}
# Live TNMM registration:
> {"TnmmRegistration":{"handle":6,"request":{...}}}
< {"TnmmAck":{"handle":6,"accepted":true,"detail":null}}
# Live talkgroup switch (detach all + attach GSSI 300) — must be registered first.
# The Ack only means "accepted for processing"; the RESULT arrives on the
# telemetry channel as a TnmmAttachDetachGroupIdentityConfirm.
> {"TnmmAttachDetachGroupIdentity":{"handle":7,"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":null}],"group_identity_report":null}}}
< {"TnmmAck":{"handle":7,"accepted":true,"detail":null}}
# ... then on the telemetry channel:
< {"TnmmAttachDetachGroupIdentityConfirm":{"group_identity_attach_detach_mode":"DetachTheCurrentlyActiveGroupIdentities","group_identity_report":null,"group_identities":[{"gtsi":300,"group_identity_attach_detach_type_identifier":"Attachment","group_identity_lifetime":"AttachmentNeededForNextItsiAttach","class_of_usage":"ClassOfUsage4","group_identity_detachment_reason":null}]}}