Files
mototrbo/docs/PLAN.md
William Gill 81c62ac012
Some checks failed
ci / test (ubuntu-latest) (push) Failing after 47s
ci / test (windows-latest) (push) Has been cancelled
ci / lint (push) Has been cancelled
Initial commit
2026-04-08 08:56:45 -05:00

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:

  1. Runs on the same Windows host as MNIS, binds to the MNIS tunnel adapter, and handles all the MNIS-side I/O.
  2. 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.
  3. 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.md end 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 mototrboctl CLI in cmd/ 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 so internal/app can import it without exposing it via pkg/.
  • tms, lrrp, ars are pure encoder packages — no I/O, easy to unit test with hex fixtures captured from the Node prototype.
  • mnis is the only package that touches sockets. It exposes a small Transport interface so grpcserver can 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 from defaultTags / 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.sh mints client certs from the CA. Document the flow in docs/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 BadgerDB file (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/radioid with conversions + tests against fixtures.
  • internal/pkg/tms encoder/decoder. Round-trip tests against hex fixtures captured from the Node prototype's working "Hello back" exchange.
  • internal/pkg/ars decoder.
  • internal/pkg/lrrp decoder + simple "triggered location request" encoder.

Done when: go test -race ./internal/pkg/... is green and exercises real fixtures.

M2 — MNIS transport

  • internal/pkg/mnis.Transport interface: Send(ctx, radioID, port, payload) and Recv() (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 loopbackTransport for 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.proto and scripts/gen-proto.sh.
  • internal/pkg/grpcserver with handlers wired to a Transport and an in-memory eventbus.
  • internal/pkg/restserver with the Fiber app wired to the same service core (no logic duplicated between the two transports).
  • internal/pkg/ntfybridge subscribed 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.go wires 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

  • StreamText delivers inbound TMS to all subscribed clients.
  • RequestLocation + StreamLocation end-to-end against a real radio.
  • StreamRegistration surfaces ARS register/deregister events.
  • mototrboctl subcommands 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.
  • govulncheck and staticcheck clean in CI.
  • docs/tls-setup.md walks 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 /metrics endpoint).

Risks and unknowns

  • MNIS bind IP correctness. Whether to bind to 0.0.0.0 or to the per-radio tunnel IP MNIS assigns is something we'll only verify with MNIS in hand. The mnis package 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:

  1. Unit: every encoder/decoder package round-trips its own output and parses captured hex fixtures.
  2. Component: grpcserver_test.go runs the full service against the loopback transport and a real gRPC client over mTLS using ephemeral certs.
  3. Manual smoke: mototrboctl send-text to a known radio ID using a real MNIS install. Receive radio -> service via mototrboctl watch-text.
  4. Tooling gate: gofmt, goimports, go vet, staticcheck, go test -race, govulncheck all clean before any tag is cut.