20 KiB
mototrbo — Plan
Context
We have a working Node.js prototype (node-dmr-lib + a small example script) that
talks to a MOTOTRBO IPSC repeater and round-trips TMS messages with a radio.
That experiment proved out the wire encoding (TMS payload format, Motorola
charset bit, NETWORK_RADIO source namespace) but is the wrong place to build a
real service:
- Node lib has multiple bugs we patched ad-hoc.
- It speaks raw IPSC, which limits us to plain IP Site Connect repeaters. Capacity Plus, Connect Plus, and Capacity Max are out of reach without reimplementing whole protocol stacks.
- It is a demo script, not a service.
The user already runs MOTOTRBO MNIS on a Windows host. MNIS is Motorola's
own data gateway; it speaks every MOTOTRBO system mode (IPSC, Cap+, Cap Max,
Connect Plus) and exposes a flat IP/UDP interface to local apps via a TUN
adapter. Anything we send as a UDP packet to 12.<hi>.<mid>.<lo>:<service-port>
is delivered to the corresponding radio; replies arrive on the same socket.
This project, mototrbo, is a Go service that:
- Runs on the same Windows host as MNIS, binds to the MNIS tunnel adapter, and handles all the MNIS-side I/O.
- Exposes a clean, typed gRPC API over mTLS so applications on Linux hosts (and elsewhere on the LAN) can send/receive TMS, request and stream LRRP location data, observe ARS registration events, and look up radios by friendly name.
- Also exposes a smaller REST API (Fiber) for the same TMS and contact
directory surface, so quick scripts, web pages, and tools that don't want
to deal with gRPC codegen can still drive the radios with a
curl.
Goals
- Single binary, runs as a Windows service next to MNIS.
- gRPC + mTLS API. Strongly typed, supports server-streaming for inbound events, and easy to call from any language.
- REST API (Fiber) for TMS send/receive (via SSE) and the contact directory. Same mTLS posture as gRPC. Both APIs are thin transport adapters on top of the same service core.
- Per-client identity via client certificates. Identity comes from the certificate Subject CN — no separate auth system.
- Zero-config for the radio side. The service learns radios as it sees them; the contact directory is just nicknames layered on top.
- No persistent state required to function. Contacts are persisted, but a fresh install with an empty DB still works.
- Conform to
Golang-Programming-Standards.mdend to end (project layout, naming, error handling, testing, tooling).
Non-Goals (v0.1)
- Voice / AMBE handling. MNIS doesn't expose voice in a useful way for our needs and we have no codec.
- Talking to a repeater directly without MNIS. The Node prototype already proved that path; this project deliberately depends on MNIS for protocol coverage.
- Web UI. A
mototrboctlCLI incmd/is enough for v0.1. - Multi-MNIS / multi-system aggregation. One MNIS instance per service for now.
High-level architecture
+----------------------+ +---------------------+ +-------------------+
| Linux client (gRPC) | | Linux client (gRPC)| | curl / web app |
| mTLS | | mTLS | | REST + mTLS |
+----------+-----------+ +----------+----------+ +---------+---------+
\ | /
\ TLS over LAN /
v v v
+------------------------------------------------------+
| mototrbod (Windows service) |
| |
| +-----------------------------+ +---------------+ |
| | gRPC server (mTLS) | | REST (Fiber) | |
| +-----------------------------+ +---------------+ |
| +--------------------------------------------------+|
| | service core (single source of truth): ||
| | - TMS send/recv ||
| | - LRRP request/recv ||
| | - ARS events ||
| | - Contacts (BadgerDB) ||
| +--------------------------------------------------+|
| | mnis transport ||
| | (UDP sockets bound to 12.0.0.0/8 via TUN) ||
| +--------------------------------------------------+|
+-------------------------+----------------------------+
|
v
+-----------------------+
| MOTOTRBO MNIS |
| (TUN adapter) |
+-----------+-----------+
|
(radio system:
IPSC / Cap+ /
Cap Max / etc.)
Repository layout
Follows golang-standards/project-layout. Only directories with content.
mototrbo/
├── api/
│ └── proto/
│ └── mototrbo/v1/mototrbo.proto # gRPC service + messages
├── cmd/
│ ├── mototrbod/main.go # the service binary
│ └── mototrboctl/main.go # admin / smoke-test CLI
├── internal/
│ ├── app/
│ │ └── mototrbod/
│ │ ├── server.go # wires config -> transports -> grpc
│ │ └── server_test.go
│ ├── pkg/
│ │ ├── config/ # Config struct + loader
│ │ ├── mnis/ # MNIS UDP transport (per-service sockets)
│ │ ├── proto/ # generated pb.go (output of protoc)
│ │ ├── tms/ # TMS encode/decode
│ │ ├── lrrp/ # LRRP encode/decode
│ │ ├── ars/ # ARS encode/decode
│ │ ├── radioid/ # uint32 <-> 12.x.x.x conversions
│ │ ├── contacts/ # in-memory + BadgerDB-backed directory
│ │ ├── grpcserver/ # gRPC handlers, mTLS setup, ACL hook
│ │ ├── restserver/ # Fiber HTTP handlers, mTLS, SSE
│ │ ├── ntfybridge/ # forwards inbound TMS to ntfy.sh
│ │ └── eventbus/ # fan-out of inbound events to streams
├── configs/
│ └── mototrbod.example.yaml
├── docs/
│ ├── PLAN.md # this file
│ ├── architecture.md # deeper diagrams once code lands
│ └── tls-setup.md # how to mint the CA + client certs
├── build/
│ └── package/
│ └── windows-service/ # nssm or kardianos/service config
├── scripts/
│ ├── gen-proto.sh
│ └── make-certs.sh # dev CA + client cert helper
├── test/
│ └── integration/ # exercises grpc -> mock mnis loopback
├── go.mod
├── Makefile
└── README.md
Notes:
internal/pkg/proto/holds generated code sointernal/appcan import it without exposing it viapkg/.tms,lrrp,arsare pure encoder packages — no I/O, easy to unit test with hex fixtures captured from the Node prototype.mnisis the only package that touches sockets. It exposes a smallTransportinterface sogrpcservercan be tested with a fake.
Wire details (carry-over from Node prototype)
Already validated end-to-end against a real radio:
| Item | Value |
|---|---|
| Address space | 12.<hi>.<mid>.<lo> for radio IDs |
| TMS port | UDP/4007 |
| LRRP port | UDP/4001 |
| ARS port | UDP/4005 |
| TMS text encoding | UTF-16LE, prefixed \r\n |
| TMS Motorola charset | high bit (0x80) of msgId must be set |
| TMS source namespace | NETWORK_RADIO (12), not NETWORK_SERVER |
| TMS opcode byte | 0xa0 for plain text message |
These go straight into internal/pkg/tms as constants and the encoder.
gRPC API sketch
syntax = "proto3";
package mototrbo.v1;
option go_package = "github.com/<org>/mototrbo/internal/pkg/proto/mototrbov1";
service Mototrbo {
// -------- TMS --------
rpc SendText(SendTextRequest) returns (SendTextResponse);
rpc StreamText(StreamTextRequest) returns (stream TextEvent);
// -------- LRRP --------
rpc RequestLocation(RequestLocationRequest) returns (RequestLocationResponse);
rpc StreamLocation(StreamLocationRequest) returns (stream LocationEvent);
// -------- ARS --------
rpc StreamRegistration(StreamRegistrationRequest) returns (stream RegistrationEvent);
// -------- Contacts --------
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse);
rpc UpsertContact(UpsertContactRequest) returns (Contact);
rpc DeleteContact(DeleteContactRequest) returns (DeleteContactResponse);
}
message Radio {
uint32 id = 1; // DMR/MotoTRBO radio ID
string name = 2; // optional friendly name from contact directory
}
message SendTextRequest {
uint32 radio_id = 1;
string text = 2;
}
message SendTextResponse {
uint32 message_id = 1;
}
message TextEvent {
Radio from = 1;
string text = 2;
google.protobuf.Timestamp received_at = 3;
}
message RequestLocationRequest {
uint32 radio_id = 1;
// simple one-shot triggered request; periodic requests come later
}
message LocationEvent {
Radio from = 1;
double latitude = 2;
double longitude = 3;
double altitude_m = 4;
double speed_mps = 5;
google.protobuf.Timestamp time = 6;
}
message RegistrationEvent {
Radio radio = 1;
enum State { UNKNOWN = 0; REGISTERED = 1; DEREGISTERED = 2; }
State state = 2;
google.protobuf.Timestamp time = 3;
}
message Contact {
uint32 radio_id = 1;
string name = 2;
string notes = 3;
}
Streaming RPCs are server-streaming so a Linux client can hold one persistent gRPC connection per service it cares about.
REST API (Fiber)
The REST surface is intentionally smaller than the gRPC one — it only covers the things people most often want from a script: send TMS, watch TMS, manage the contact directory. Everything goes through the same service core that backs gRPC, so behavior is identical.
Base path: /api/v1. JSON request and response bodies. mTLS required (same
CA as gRPC). Client identity comes from the cert CN, surfaced to handlers
via Fiber middleware.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/tms |
Send a TMS to a radio |
| GET | /api/v1/tms/stream |
Server-Sent Events stream of inbound TMS |
| GET | /api/v1/contacts |
List all contacts |
| GET | /api/v1/contacts/{radioID} |
Fetch one contact |
| PUT | /api/v1/contacts/{radioID} |
Create or update a contact |
| DELETE | /api/v1/contacts/{radioID} |
Delete a contact |
| GET | /api/v1/healthz |
Liveness |
| GET | /api/v1/readyz |
Readiness (MNIS transport up) |
POST /api/v1/tms body:
{ "radio_id": 24044, "text": "Hello from REST" }
GET /api/v1/tms/stream is a long-lived SSE stream. Each event:
event: text
data: {"from":{"id":24044,"name":"KE0XYZ Mobile"},"text":"Hi","received_at":"2026-04-07T20:14:03Z"}
The Fiber router lives in internal/pkg/restserver. It depends only on the
service core types, never on grpcserver. Tests use fiber.App.Test() plus
the same loopback MNIS transport the gRPC tests use.
ntfy.sh bridge
A small bridge subscribes to inbound TMS events from the service core and forwards every inbound TMS to a single configured ntfy.sh topic. Useful for getting push notifications on a phone whenever any radio sends a text.
Lives in internal/pkg/ntfybridge. Pure outbound HTTP — no inbound surface,
no auth wired into the service. One topic, all radios. Optional per-radio
overrides (custom title, tags, priority) come from the contact directory if
the radio has a contact entry; otherwise the radio ID is used as the title.
Config
ntfy:
enabled: true
baseURL: "https://ntfy.sh" # or self-hosted
topic: "mototrbo-inbound" # one topic, all radios
defaultPriority: 3 # 1..5
defaultTags: ["radio"]
# Optional auth for protected topics:
username: ""
password: ""
token: ""
Behavior
- Subscribe to the inbound TMS event bus on startup.
- For every inbound TMS, POST to
<baseURL>/<topic>with the message text as the body. The title is the friendly name from the contact directory if one exists for that radio, otherwise the radio ID as a string. Tags and priority come fromdefaultTags/defaultPriority. - HTTP failures are logged at WARN with the radio ID and topic, and retried with bounded exponential backoff (max 3 attempts). The bridge never blocks the event bus — sends happen on a worker goroutine with a bounded queue; if the queue is full, drop the oldest and increment a counter.
- Disabling the bridge (
enabled: false) skips startup wiring entirely.
Testing
- Unit test the route lookup and the HTTP request builder against a fake
http.Client. - Component test: feed a message into the loopback MNIS transport, assert
the bridge POSTs the expected URL/body to a
httptest.Server.
Security model
- Transport: gRPC over TLS 1.3, server cert + required client cert.
- Identity: the client certificate Subject CN (or SAN URI) is the client identity. No tokens, no passwords.
- CA: a private CA whose root cert is shipped with the server. Helper
script
scripts/make-certs.shmints client certs from the CA. Document the flow indocs/tls-setup.md. - Authorization (post v0.1): an optional ACL config maps client identity to allowed radio IDs and allowed RPCs. v0.1 ships with a single permission bit ("allowed clients"); per-radio scoping is a stretch goal once we know what real usage looks like.
- Bind address: by default the gRPC listener binds to the LAN-facing interface, never the public internet. Document this in the README.
- Logs never contain message text or location coordinates at INFO. Those are DEBUG-only and DEBUG must be off in production.
Data persistence
- Contact directory: a single
BadgerDBfile (pure Go, no CGO). Stored next to the binary by default; configurable. - All other state (recent events, in-flight TMS requests) lives in memory. Restarts wipe it; clients re-stream from "now".
Configuration
YAML, loaded once in cmd/mototrbod/main.go, passed via constructors. Example:
server:
listen: "0.0.0.0:7443"
tls:
cert: "C:/ProgramData/mototrbo/tls/server.crt"
key: "C:/ProgramData/mototrbo/tls/server.key"
clientCA: "C:/ProgramData/mototrbo/tls/ca.crt"
mnis:
# The IP MNIS assigned to this app's "radio ID" inside the tunnel.
bindIP: "12.0.61.126" # 15742
appRadioID: 15742
contacts:
dbPath: "C:/ProgramData/mototrbo/contacts.db"
logging:
level: "info"
format: "json"
Milestones
M1 — protocol packages (no I/O, no service)
internal/pkg/radioidwith conversions + tests against fixtures.internal/pkg/tmsencoder/decoder. Round-trip tests against hex fixtures captured from the Node prototype's working "Hello back" exchange.internal/pkg/arsdecoder.internal/pkg/lrrpdecoder + simple "triggered location request" encoder.
Done when: go test -race ./internal/pkg/... is green and exercises real
fixtures.
M2 — MNIS transport
internal/pkg/mnis.Transportinterface:Send(ctx, radioID, port, payload)andRecv() (src, port, payload)(channel-based).- Concrete implementation that opens one UDP socket per service port, binds to the configured tunnel IP, and demultiplexes inbound packets.
- A
loopbackTransportfor tests so the rest of the service has no MNIS dependency in CI.
Done when: an integration test sends a packet through the loopback transport and the same packet is received on the other side.
M3 — service core + transports skeleton
api/proto/mototrbo/v1/mototrbo.protoandscripts/gen-proto.sh.internal/pkg/grpcserverwith handlers wired to aTransportand an in-memoryeventbus.internal/pkg/restserverwith the Fiber app wired to the same service core (no logic duplicated between the two transports).internal/pkg/ntfybridgesubscribed to the inbound TMS bus, forwarding every message to the configured ntfy topic.- mTLS in front of both listeners, loaded from config.
cmd/mototrbod/main.gowires config -> BadgerDB -> mnis -> service core -> grpcserver + restserver + ntfybridge and serves until SIGINT/SIGTERM.
Done when:
mototrboctl send-text --radio 24044 --text "hi"(gRPC) reaches a real radio.curl --cert ... --key ... -X POST https://host:7443/api/v1/tms -d '{"radio_id":24044,"text":"hi"}'does the same.- An inbound TMS from the radio shows up as a phone notification on the configured ntfy topic.
M4 — round-trip features
StreamTextdelivers inbound TMS to all subscribed clients.RequestLocation+StreamLocationend-to-end against a real radio.StreamRegistrationsurfaces ARS register/deregister events.mototrboctlsubcommands for each (send-text,watch-text,locate,watch-locations,watch-registrations).
M5 — productionization
- Run as a Windows service via
kardianos/service. build/package/windows-service/with install scripts.- Structured logging with
slog, JSON in production. govulncheckandstaticcheckclean in CI.docs/tls-setup.mdwalks through CA + client cert minting.
Stretch (post v0.1)
- BMS (battery) telemetry.
- Per-client ACLs by radio ID.
- Web UI (Svelte or htmx).
- Multi-MNIS aggregation.
- Optional metric export (Prometheus
/metricsendpoint).
Risks and unknowns
- MNIS bind IP correctness. Whether to bind to
0.0.0.0or to the per-radio tunnel IP MNIS assigns is something we'll only verify with MNIS in hand. Themnispackage should support both and the config should pick. - MNIS doesn't deliver from arbitrary radios out of the box. Some radios
must be in MNIS's "Application Server" configuration for inbound TMS to
reach the host. Document this in
docs/once we hit it. - kardianos/service on Windows vs raw
nssm. Decide at M5; not blocking. - LRRP encoder coverage. We only need triggered location requests for v0.1; the rest of LRRP (geofences, periodic distance, etc.) is post-v0.1.
Verification plan
End-to-end tests are layered:
- Unit: every encoder/decoder package round-trips its own output and parses captured hex fixtures.
- Component:
grpcserver_test.goruns the full service against the loopback transport and a real gRPC client over mTLS using ephemeral certs. - Manual smoke:
mototrboctl send-textto a known radio ID using a real MNIS install. Receive radio -> service viamototrboctl watch-text. - Tooling gate:
gofmt,goimports,go vet,staticcheck,go test -race,govulncheckall clean before any tag is cut.