signalk-crewradio
v0.3.0
Published
Crew Radio: lets Signal K tell the sailing crew what matters, by voice, on their push-to-talk phones.
Maintainers
Readme
signalk-crewradio
Lets the boat's Signal K server speak to the crew on the Crew Radio channel. The server becomes one more node on the crew's push-to-talk network, over the boat's LAN or WLAN, and the phones relay it onward over Bluetooth and Wi‑Fi Aware like any other talker. The voice is made inside the plugin: no cloud, no containers, no other plugin needed.
Five things:
- Say anything to the crew. A text becomes speech and goes out on the channel. Three doors:
a PUT to the Signal K path
communication.crewradio.say,POST /plugins/signalk-crewradio/say, and an in-processsay()for other plugins. Node-RED, dashboards and automations use the first two. An urgent announcement goes to the front of the queue and cuts a normal one short. - Signal K notifications are announced. A notification at or above a chosen state (default
alarm) that asks for sound is said when it is raised, again every 30 s while it stays raised, and stops when it clears.emergencyis said as urgent. - The crew roster in Signal K.
communication.crewradio.online,.nodes,.talkingand.speakingshow who is on the channel, who is talking and whether the server is announcing. - A web page. Webapps › Crew Radio (
http://<server>:3000/signalk-crewradio/) shows the network link, who is on the channel and who is talking, the queue, and has a test call: type a text, choose normal or urgent, and it is said on the channel. The quickest way to check that the server reaches the phones. Its header links to the latest GitHub release, where a new phone gets the Android app. - The channel over Reticulum, optionally. With Reticulum enabled the plugin also joins the channel through a Reticulum transport node and relays between it and the boat's LAN, so a phone ashore that reaches the same Reticulum network (over mobile data, through a hub) hears the crew and is heard. See Reticulum below.
Everything on the wire is the app's own format: AES‑256‑GCM under the crew's channel key, the same packets, the same roster hellos. The plugin's tests and the app's unit tests check the same byte vector, so the two cannot drift apart unnoticed.





The admin UI and web page pictures are captures of a Signal K server 2.31 with the plugin installed; the two others are drawn. All carry example names only.
The voice
Flite, CMU's small speech synthesizer, compiled to
WebAssembly (@echogarden/flite-wasi, the plugin's only dependency, 21 MB, no native code, no
download at run time). English, four voices: slt (female, the default), kal16, rms and awb.
It speaks at 16 kHz, the channel's own rate, and a sentence takes a fraction of a second on a
laptop and about a second on a Raspberry Pi; a sentence said before comes from a cache at once.
Numbers and the units in alarm texts are spelled out: "25 m" is read as "25 metres", "12.2 V"
as "12.2 volts". Texts are capped at 500 characters.
Requirements
- Signal K server 2.x on Node 24 or newer (the server itself needs 22; the plugin uses Node's WASI, which prints one "experimental" notice in the server log at first use).
- The server on the same network as the phones: wired to the boat's router (LAN) or on its WLAN, either way one network with the WLAN the phones use. One phone's hotspot works too if the server can join it.
- The Crew Radio app on the phones: the APK is on the
latest GitHub release
(
CrewRadio-<version>.apk); the plugin's web page links there too. - The crew's channel key (on any phone: Settings › Channel key), and the WLAN link ticked on at least one phone; that phone relays the server to the rest.
Install
Until it is on npm, install from a clone of this repository (npm cannot install a subdirectory of a Git URL):
git clone https://github.com/KEGustafsson/crew-radio.git
cd ~/.signalk
npm install /path/to/crew-radio/sk-pluginand restart the server. Then, in the Signal K admin UI, Server › Plugin Config › Crew Radio: paste the channel key, check the multicast group and port match the phones (the defaults do), pick the server's interface on the boat network (eth0 or wlan0), enable. Test from a shell on the server:
curl -X POST http://localhost:3000/plugins/signalk-crewradio/say \
-H 'Content-Type: application/json' \
-d '{"text":"Crew radio check. This is the boat.","priority":"normal"}'The phones on the channel play the chime and the sentence, and list the server on their roster.
The same works as a PUT to vessels.self.communication.crewradio.say with a string or
{"text": "...", "priority": "urgent"}, and from another plugin through
app.onPropertyValues("signalk-crewradio.api", values => values.at(-1)?.value?.say({ text })).
say() resolves when the announcement is queued: {ok, queued: <items ahead>, priority, seconds}.
Settings
| Setting | Default | Meaning |
|---|---|---|
| Channel key | | The crew's key, exactly as on the phones. |
| Name on the roster | vessel name | How the phones list the server. |
| Voice | slt | slt (female), kal16, rms, awb (male). |
| Speaking rate | 1 | 0.7 slow to 1.3 fast. |
| Chime | on | Two notes before an announcement, three quick ones before an urgent one. |
| Wait for a gap in talk | 2000 ms | An announcement waits this long at most before cutting in. |
| Multicast group, UDP port | 239.255.42.1, 47474 | Must match the phones' WLAN settings. |
| Network interface | auto | The server's interface on the boat network, wired (eth0) or WLAN (wlan0). auto: wlan first, then eth/en, then anything with an IPv4 address, container/bridge/VPN interfaces last; looked at again every 5 s. |
| Hop budget | 4 | How far phones may relay the server's packets. |
| Reticulum: enabled | off | Join the channel through a Reticulum transport node as well, and relay between it and the LAN. |
| Reticulum: transport node host, port | 127.0.0.1, 4242 | The TCP server interface of an rnsd with enable_transport = Yes: the boat's own, or a hub ashore. |
| Reticulum: answer the crew's questions | off | A phone ashore can use the app's Ask boat data over its Reticulum link: the plugin reads the answer from this server and says a Whole crew answer. Anyone holding the channel key can then read the vessel's data this way. |
| Announce from state | alarm | alert, warn, alarm or emergency. |
| Say the state and the path first | on | "Alarm, navigation position: no contact with sensor for 70 seconds" rather than the message alone, so the crew hears where it comes from. |
| Only notifications that ask for sound | on | Signal K notifications carry method: [visual, sound]. |
| Repeat every | 30 s | 0 says it once. |
| Urgent states | emergency | Said first, interrupting a normal announcement. |
| Only / never these paths | | Globs under notifications., e.g. navigation.anchor, mob, navigation.**. |
Notes
- Half duplex. A phone in half-duplex mode mutes playback while its own mic is keyed, so a crew member who is talking misses the announcement. The plugin waits for a gap first; a future app change will let urgent announcements through regardless.
- Every phone gets its own copy. Besides multicast and broadcast, each frame is sent unicast to every phone the plugin has heard from directly. Access points send multicast at their lowest rate without acknowledgement, and phones lose a few percent of it even in the same cabin, which was audible as gaps; unicast is retried and rate-adapted, and the phones drop the copies they get twice. Frames also leave 100 ms ahead of time, which keeps a late timer tick on the server (the usual 15 ms) from running a phone's queue dry; a longer stall is still audible.
- Bandwidth. Speech goes out as PCM, 34 kB/s per copy while talking, nothing otherwise but one small hello a second; over a Bluetooth relay hop it is the same as a phone talking in PCM.
- Two nodes named alike are fine; the phones list nodes by id.
- Security. The channel key sits in the server's plugin config like any other secret there. Anyone who can read the Signal K configuration can read it, and anyone who can reach the server's REST API or PUT paths can make it speak; treat the server as a crew member.
Reticulum
The plugin can carry the channel over Reticulum as well as the LAN:
the protocol is implemented here from the published manual (lib/rns/, Node's own crypto, no
dependency), not taken from the reference implementation, and checked against it (rnsd 1.5.4).
- What it needs. A Reticulum transport node the server can reach over TCP, with a
TCPServerInterfaceandenable_transport = Yes: usually a hub ashore that the server dials out to directly (Transport node host set to the hub), or the boat's ownrnsdconnected on to one over TCP. Interface access codes (a network name or passphrase on the interface) are not supported. RETICULUM_HUB.md sets up a hub, and optionally anrnsdon the server itself, with autostart at boot. - How it works. The channel's sealed packets ride unchanged inside Reticulum links, so the
channel key still does all the securing; Reticulum adds its own encryption around it. The
plugin announces
crewradio.channel.<tag>, the tag an HMAC of the packet key, so only a node with the channel key recognises it. Every Crew Radio node on the Reticulum network links to every other (one link per pair); a link carries nothing until the far end has sent its key proof, an HMAC of its role and the link id under a key from the packet key, so a stranger who copies the public name hash and links in gets nothing, and a sealed packet copied from elsewhere proves nothing. Announces and link requests beyond 10 a second are not checked at all, so a flood costs the server no signature work. The identity is new at every start. - Finding each other. A Reticulum transport node (
rnsdwith transport on) passes a node's announces on only so often: by default its first six, then about one an hour, and that limit cannot be switched off. So the plugin announces when it connects, and again every 5 minutes only while it has no link at all and the node would still pass it on with two to spare; it never announces in answer to a phone, nor while linked. When it hears a phone that should dial it and nothing has dialled in 5 s, it dials the phone itself, since the phone may never have heard it. (Answering with an announce, the first design, stopped working once the plugin had been alone on a hub for a quarter of an hour: rnsd had stopped passing its announces on, and a phone that joined then never found it until the plugin was restarted.) - A connection that has died. It is opened again rather than left to TCP, which would retransmit into it for a quarter of an hour: every link silent and nothing at all from the transport node for 12 s, or, with no link at all, an announce the node should have passed on that does not come back within 12 s (it sends every one it passes on back to the sender). That second rule applies only once the connection has echoed an announce, so a node that never echoes cannot cause a reconnect loop.
- Diagnostics.
GET /plugins/signalk-crewradio/statuscarriesreticulum.detail: the peers heard (hops, how long ago, who dials, taken over, linked), the links, the announce budget, echoes and announces ignored as older. With the plugin's debug log on, every peer heard, dial, link up or down and reconnect is a line there. - Relaying. A first, authentic copy heard on the LAN goes on to Reticulum and the other way, ttl lowered by one, as a phone relays between its transports. A phone ashore shows on the roster with the Reticulum flag.
- When the LAN is down. The channel keeps going on Reticulum alone (the status says "LAN down", and the hello no longer claims the LAN). An announcement made then waits for the LAN for 10 s first, since the boat's own phones are its first audience and a Wi‑Fi reconnect is over in a second or two; after that it goes to the crew ashore. If the LAN comes back while it is being said, it is said again, whole, on the LAN. When Reticulum is down the status says why.
- Bandwidth. Announcements are PCM: 686 bytes a frame, which a Reticulum link carries in two packets, about 40 kB/s per remote node while speaking. Fine over the internet or Wi‑Fi, not over LoRa, which cannot carry live voice at all.
- Asking the boat from ashore. With Answer the crew's questions on, a phone on the
channel over Reticulum can use the app's Ask boat data without reaching the server's HTTP port:
it asks on its link, and the plugin reads the top-level branches it names from the server's own
tree (
app.getSelfPath), cut to value, timestamp and source and deflated, or says a whole-crew answer through the same queue as every other announcement. Nothing else is reachable that way: no PUTs, no other vessel, no deeper path. It is off by default because it answers anyone who holds the channel key, whatever the server's own security says. Each link may ask 30 times a minute;GET /statuscounts the reads, says and refusals.
Development
cd sk-plugin
npm test # Node's test runner
npm run coverage # the same, gated at 80 % linestest/vector.json is the cross-language vector; regenerate it only when the wire format
changes, and change the app's CrossLanguageVectorTest with it. test/rns.vector.json is the
same for Reticulum, read by the app's RnsVectorTest straight from this directory; its values
were checked against the reference implementation when it was made.
tools/cli.js runs the plugin's pieces from a shell on any machine on the boat network, no
Signal K needed, which is how the plugin was verified against real phones:
node tools/cli.js roster --key KEY # join, list who is on the channel, leave
node tools/cli.js say --key KEY --text "Anchor is dragging" # speak a text on the channel
node tools/cli.js say --key KEY --wav clip.wav # speak a WAV on the channel
node tools/cli.js tts --text "Hello" --out hello.wav --voice rms # speech to a file, no networkPhones list the machine under --name (default "Laptop"). On Windows the adapter may need
--iface WiFi or --iface Ethernet. The phones must have the WLAN transport on (Settings › Links).
Licence: EUPL‑1.2, like the app. Flite is under CMU's BSD-style licence.
