Skip to content

Commit d283968

Browse files
thatSFguyclaude
andcommitted
TCP / WebSocket connection option
Adds a third connect transport alongside Web Bluetooth and Web Serial: WebSocket to a local or remote rnsd via a small Python bridge. This unlocks Safari, Firefox, and iOS (none of which have Web Bluetooth or Web Serial) and lets the web client join a Reticulum network via any backbone rnsd is configured to reach, not just a local RNode's LoRa radio. Architecture. Browsers cannot open raw TCP sockets so "TCP" really means WebSocket to a bridge that forwards bytes to rnsd's TCPServerInterface. All Reticulum protocol logic continues to run in the browser; the bridge and rnsd are only a transport. Identity stays in IndexedDB in the browser — the bridge never sees private keys. js/hdlc.js: HDLC frame encode/decode (0x7E flag, 0x7D escape, 0x20 mask) matching RNS TCPInterface. Streaming parser across arbitrary chunk boundaries. Separate from kiss.js because the framing bytes and command-byte semantics are different. js/websocket-transport.js: WebSocket byte-stream mirroring the shape of ble-transport.js and serial-transport.js. Binary mode, emits each received ArrayBuffer up to the parser, ignores text messages, wires close events through to the interface layer. js/rnsd-interface.js: wraps WebSocketTransport + HdlcParser and exposes the same {connect, disconnect, sendPacket, _onPacket, _onLog} shape as rnode.js so app.js can drive either without branching. Exposes a capabilities flag {rnodeControl: false, radioConfig: false} that app.js consumes to skip the RNode command sequence and the radio-config panel on the WebSocket path. Stubs out detect / getFirmwareVersion / getBattery / configureAndStart so any forgotten caller still works. js/app.js: adds a 'ws' transport type to connect(), a new markInterfaceReady() helper that fires the startup auto-announce, the periodic announce timer, and the outbound retry tick, shared between the RNode path (called from startRadio when the radio reports on) and the WebSocket path (called immediately after the socket opens). startRadio is simplified to call markInterfaceReady instead of inlining the setup. New btn-connect-ws event handler, browser-capability check for typeof WebSocket, disconnect cleanup for the new button and URL field. index.html: new "Connect (WebSocket)" button and a URL field that defaults to ws://localhost:7878. tools/ws_bridge.py: async Python bridge, ~140 lines. Accepts WebSocket connections on ws://localhost:7878 by default, opens a fresh TCP connection to tcp://localhost:4242 per WS client, and copies bytes in both directions without parsing HDLC frames (rnsd's TCP interface and our HdlcParser both handle streaming). CLI flags for --ws-host / --ws-port / --rnsd-host / --rnsd-port. Requires 'pip install websockets', everything else is stdlib asyncio. README.md: extensive new "TCP (WebSocket) connection" section covering the architecture, step-by-step setup (install rnsd, configure TCPServerInterface, install websockets, start the bridge, connect from the client), the mixed-content caveat with three workarounds (localhost, wss:// with a cert, reverse proxy), security notes (browser owns identity, bridge is a dumb forwarder, don't expose plain ws:// publicly), and troubleshooting for the usual failure modes. Updates the platform support table to show WebSocket as universally supported, updates the architecture diagram to show both transport paths, adds websocket-transport.js / hdlc.js / rnsd-interface.js to the module layout, and adds ws_bridge.py to the tools list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bb1dc20 commit d283968

7 files changed

Lines changed: 662 additions & 90 deletions

File tree

README.md

Lines changed: 156 additions & 41 deletions
Large diffs are not rendered by default.

index.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,16 @@ <h2>Connect</h2>
2828
<span><span class="status-dot" id="conn-dot"></span><span id="conn-text">Disconnected</span></span>
2929
<button id="btn-connect-ble">Connect (BLE)</button>
3030
<button id="btn-connect-serial" class="secondary">Connect (Serial)</button>
31+
<button id="btn-connect-ws" class="secondary">Connect (WebSocket)</button>
3132
<button id="btn-disconnect" class="secondary hidden">Disconnect</button>
3233
<span id="radio-status" class="status-off"></span>
3334
</div>
35+
<div class="row" id="ws-url-row" style="margin-top: 8px;">
36+
<div class="field wide">
37+
<label>WebSocket URL (for TCP-via-bridge)</label>
38+
<input id="ws-url" type="text" value="ws://localhost:7878" placeholder="ws://localhost:7878">
39+
</div>
40+
</div>
3441
</div>
3542

3643
<!-- Identity panel -->

js/app.js

Lines changed: 78 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { encode as msgpackEncode } from '@msgpack/msgpack';
66
import { RNode } from './rnode.js';
7+
import { RnsdInterface } from './rnsd-interface.js';
78
import { toHex } from './kiss.js';
89
import { Identity, computeDestinationHash, computeNameHash } from './identity.js';
910
import { parsePacket, buildPacket, PACKET_ANNOUNCE, PACKET_DATA, PACKET_LINKREQ, PACKET_PROOF, DEST_SINGLE, DEST_LINK, HEADER_1, PACKET_TYPE_NAMES } from './reticulum.js';
@@ -1057,12 +1058,22 @@ function escapeHtml(str) {
10571058
async function connect(transportType) {
10581059
const btnBle = $('btn-connect-ble');
10591060
const btnSerial = $('btn-connect-serial');
1061+
const btnWs = $('btn-connect-ws');
10601062
try {
10611063
btnBle.disabled = true;
10621064
btnSerial.disabled = true;
1063-
1064-
// Re-instantiate RNode with chosen transport
1065-
rnode = new RNode(transportType);
1065+
btnWs.disabled = true;
1066+
1067+
// Pick the right interface based on transport type.
1068+
// 'ble' / 'serial' → RNode-over-KISS (owns a radio)
1069+
// 'ws' → rnsd-over-HDLC (no radio, direct to a Reticulum daemon)
1070+
if (transportType === 'ws') {
1071+
const url = ($('ws-url').value || '').trim();
1072+
if (!url) { log('err', 'WebSocket URL is empty'); return; }
1073+
rnode = new RnsdInterface(url);
1074+
} else {
1075+
rnode = new RNode(transportType);
1076+
}
10661077
rnode._onLog = (msg) => log('info', msg);
10671078
rnode._onPacket = onPacket;
10681079

@@ -1073,30 +1084,67 @@ async function connect(transportType) {
10731084
$('btn-disconnect').classList.remove('hidden');
10741085
btnBle.classList.add('hidden');
10751086
btnSerial.classList.add('hidden');
1076-
1077-
const detected = await rnode.detect();
1078-
if (!detected) { log('err', 'RNode detect failed'); return; }
1079-
1080-
const fw = await rnode.getFirmwareVersion();
1081-
const battery = await rnode.getBattery();
1082-
log('ok', `RNode FW ${fw?.major}.${fw?.minor}, Bat ${battery}%`);
1083-
1084-
// Show panels
1085-
$('config-panel').classList.remove('hidden');
1086-
$('messaging-panel').classList.remove('hidden');
1087-
1088-
// Auto-start radio with form values
1089-
await startRadio();
1087+
btnWs.classList.add('hidden');
1088+
$('ws-url-row').classList.add('hidden');
1089+
1090+
// Interfaces with an RNode on the other side (BLE/Serial) need
1091+
// the full detect/fw/battery/radio-config sequence. Interfaces
1092+
// that talk directly to a Reticulum daemon via WebSocket skip
1093+
// all of that — there is no radio to configure.
1094+
const usesRnode = rnode.capabilities?.rnodeControl !== false;
1095+
1096+
if (usesRnode) {
1097+
const detected = await rnode.detect();
1098+
if (!detected) { log('err', 'RNode detect failed'); return; }
1099+
const fw = await rnode.getFirmwareVersion();
1100+
const battery = await rnode.getBattery();
1101+
log('ok', `RNode FW ${fw?.major}.${fw?.minor}, Bat ${battery}%`);
1102+
$('config-panel').classList.remove('hidden');
1103+
$('messaging-panel').classList.remove('hidden');
1104+
await startRadio();
1105+
} else {
1106+
// WebSocket path: no radio config, no detect, no battery.
1107+
// Go straight to the "ready for messaging" state that
1108+
// startRadio would have reached for the RNode path.
1109+
$('messaging-panel').classList.remove('hidden');
1110+
log('ok', `Connected to Reticulum network via WebSocket`);
1111+
markInterfaceReady();
1112+
}
10901113
} catch (e) {
10911114
log('err', 'Connect: ' + e.message);
10921115
} finally {
10931116
btnBle.disabled = false;
10941117
btnSerial.disabled = false;
1118+
btnWs.disabled = false;
10951119
}
10961120
}
10971121

1122+
// Flip the "we are ready to send and receive" bit, fire the startup
1123+
// auto-announce, start the periodic announce timer, and start the
1124+
// outbound retry tick. Called from both the RNode path (after
1125+
// startRadio reports the radio is on) and the WebSocket path (after
1126+
// the socket is up — there is no radio to wait for).
1127+
function markInterfaceReady() {
1128+
radioOn = true;
1129+
$('radio-status').textContent = 'Ready';
1130+
$('radio-status').className = 'status-on';
1131+
sendAnnounce().catch(e => log('info', `Startup announce skipped: ${e.message}`));
1132+
if (announceTimer) clearInterval(announceTimer);
1133+
announceTimer = setInterval(() => {
1134+
if (radioOn) {
1135+
sendAnnounce().catch(e => log('info', `Periodic announce skipped: ${e.message}`));
1136+
}
1137+
}, 5 * 60 * 1000);
1138+
if (outboundRetryTimer) clearInterval(outboundRetryTimer);
1139+
outboundRetryTimer = setInterval(() => {
1140+
outboundRetryTick().catch(e => log('info', `Retry tick error: ${e.message}`));
1141+
}, MSG_RETRY_TICK_MS);
1142+
outboundRetryTick().catch(e => log('info', `Retry tick error: ${e.message}`));
1143+
}
1144+
10981145
$('btn-connect-ble').addEventListener('click', () => connect('ble'));
10991146
$('btn-connect-serial').addEventListener('click', () => connect('serial'));
1147+
$('btn-connect-ws').addEventListener('click', () => connect('ws'));
11001148

11011149
$('btn-disconnect').addEventListener('click', async () => {
11021150
if (announceTimer) { clearInterval(announceTimer); announceTimer = null; }
@@ -1107,6 +1155,8 @@ $('btn-disconnect').addEventListener('click', async () => {
11071155
$('btn-disconnect').classList.add('hidden');
11081156
$('btn-connect-ble').classList.remove('hidden');
11091157
$('btn-connect-serial').classList.remove('hidden');
1158+
$('btn-connect-ws').classList.remove('hidden');
1159+
$('ws-url-row').classList.remove('hidden');
11101160
$('config-panel').classList.add('hidden');
11111161
$('messaging-panel').classList.add('hidden');
11121162
radioOn = false;
@@ -1123,40 +1173,13 @@ async function startRadio() {
11231173
const cr = parseInt($('cfg-cr').value);
11241174
const txp = parseInt($('cfg-txp').value);
11251175
const on = await rnode.configureAndStart({ freq, bw, sf, cr, txp });
1126-
radioOn = on;
11271176
$('radio-status').textContent = on ? 'Radio: ON' : '';
11281177
$('radio-status').className = on ? 'status-on' : 'status-off';
11291178
if (on) {
11301179
log('ok', 'Radio on');
1131-
// Emit one announce right away and then again every 5 minutes so
1132-
// every RNS relay in reach keeps our identity warm in its path
1133-
// table / known_destinations cache. The relay-side validation of
1134-
// inbound LRPROOFs calls Identity.recall(destination_hash), and
1135-
// if our entry has been GC'd or never made it past a further hop
1136-
// the proof is silently dropped — the exact symptom of incoming
1137-
// link handshakes stalling. Periodic re-announce is what every
1138-
// long-running Python RNS daemon does by default (Sideband uses
1139-
// 30 minutes; we use 5 because the test bench has a small mesh).
1140-
// Best-effort: swallow errors so a transient send failure can't
1141-
// take down the timer.
1142-
sendAnnounce().catch(e => log('info', `Startup announce skipped: ${e.message}`));
1143-
if (announceTimer) clearInterval(announceTimer);
1144-
announceTimer = setInterval(() => {
1145-
if (radioOn) {
1146-
sendAnnounce().catch(e => log('info', `Periodic announce skipped: ${e.message}`));
1147-
}
1148-
}, 5 * 60 * 1000);
1149-
1150-
// Start the outbound retry tick now that the radio is up.
1151-
// Any outbound rows that were saved while the radio was off
1152-
// will get picked up on the first tick.
1153-
if (outboundRetryTimer) clearInterval(outboundRetryTimer);
1154-
outboundRetryTimer = setInterval(() => {
1155-
outboundRetryTick().catch(e => log('info', `Retry tick error: ${e.message}`));
1156-
}, MSG_RETRY_TICK_MS);
1157-
// Kick one immediately so queued rows don't have to wait up
1158-
// to MSG_RETRY_TICK_MS for their first attempt.
1159-
outboundRetryTick().catch(e => log('info', `Retry tick error: ${e.message}`));
1180+
markInterfaceReady();
1181+
} else {
1182+
radioOn = false;
11601183
}
11611184
} catch (e) { log('err', 'Radio: ' + e.message); }
11621185
}
@@ -1201,7 +1224,9 @@ $('msg-content').addEventListener('keydown', (e) => {
12011224
// Log
12021225
$('btn-clear-log').addEventListener('click', () => { $('log').innerHTML = ''; });
12031226

1204-
// Browser check — disable buttons for unsupported transports
1227+
// Browser check — disable buttons for unsupported transports.
1228+
// WebSocket is available in every modern browser, so it never gets
1229+
// disabled; BLE and Serial still depend on Web Bluetooth / Web Serial.
12051230
if (!navigator.bluetooth) {
12061231
$('btn-connect-ble').disabled = true;
12071232
$('btn-connect-ble').textContent = 'Connect (BLE — not supported)';
@@ -1210,7 +1235,11 @@ if (!navigator.serial) {
12101235
$('btn-connect-serial').disabled = true;
12111236
$('btn-connect-serial').textContent = 'Connect (Serial — not supported)';
12121237
}
1213-
if (!navigator.bluetooth && !navigator.serial) {
1238+
if (typeof WebSocket === 'undefined') {
1239+
$('btn-connect-ws').disabled = true;
1240+
$('btn-connect-ws').textContent = 'Connect (WebSocket — not supported)';
1241+
}
1242+
if (!navigator.bluetooth && !navigator.serial && typeof WebSocket === 'undefined') {
12141243
$('unsupported').classList.remove('hidden');
12151244
}
12161245

js/hdlc.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// js/hdlc.js — HDLC frame encode/decode for Reticulum's TCP interface.
2+
//
3+
// rnsd's TCPClientInterface / TCPServerInterface frames every raw
4+
// Reticulum packet with HDLC before writing it to the socket, and
5+
// unframes it on the receive side. Wire format is:
6+
//
7+
// FLAG (0x7E) || escaped(packet_bytes) || FLAG (0x7E)
8+
//
9+
// where escaping replaces any in-band 0x7D with 0x7D 0x5D and any
10+
// in-band 0x7E with 0x7D 0x5E. The reverse applies on unescape.
11+
//
12+
// This is a separate module from kiss.js because:
13+
// * framing bytes are different (0x7E / 0x7D vs 0xC0 / 0xDB)
14+
// * HDLC has no command byte prefix — the frame IS the packet
15+
// * there is no RSSI/SNR metadata framing like KISS has
16+
//
17+
// Source: RNS/Interfaces/TCPInterface.py class HDLC.
18+
19+
'use strict';
20+
21+
export const FLAG = 0x7E;
22+
export const ESC = 0x7D;
23+
export const ESC_MASK = 0x20;
24+
25+
// Wrap one complete Reticulum packet into an HDLC frame. Returns
26+
// FLAG || escaped(data) || FLAG.
27+
export function encodeFrame(data) {
28+
const out = [FLAG];
29+
for (const b of data) {
30+
if (b === ESC || b === FLAG) {
31+
out.push(ESC, b ^ ESC_MASK);
32+
} else {
33+
out.push(b);
34+
}
35+
}
36+
out.push(FLAG);
37+
return new Uint8Array(out);
38+
}
39+
40+
// Streaming HDLC parser. Feed it bytes as they arrive on the
41+
// transport (WebSocket messages, TCP chunks, whatever) and it calls
42+
// onFrame(bytes) for each complete frame boundary it sees. Buffers
43+
// partial frames across feeds so input chunk sizes do not matter.
44+
export class HdlcParser {
45+
constructor(onFrame) {
46+
this.onFrame = onFrame;
47+
this._buf = [];
48+
this._inFrame = false;
49+
this._escape = false;
50+
}
51+
52+
feed(bytes) {
53+
for (const b of bytes) {
54+
if (b === FLAG) {
55+
// A FLAG terminates the current frame (if any) and starts
56+
// the next. Empty frames (two FLAGs back to back) are
57+
// silently dropped — rnsd uses FLAG as both delimiter and
58+
// keepalive.
59+
if (this._inFrame && this._buf.length > 0) {
60+
this.onFrame(new Uint8Array(this._buf));
61+
}
62+
this._buf = [];
63+
this._inFrame = true;
64+
this._escape = false;
65+
continue;
66+
}
67+
68+
if (!this._inFrame) continue;
69+
70+
if (this._escape) {
71+
this._escape = false;
72+
this._buf.push(b ^ ESC_MASK);
73+
} else if (b === ESC) {
74+
this._escape = true;
75+
} else {
76+
this._buf.push(b);
77+
}
78+
}
79+
}
80+
81+
reset() {
82+
this._buf = [];
83+
this._inFrame = false;
84+
this._escape = false;
85+
}
86+
}

js/rnsd-interface.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// js/rnsd-interface.js — Reticulum-direct interface over a byte stream.
2+
//
3+
// Wraps a WebSocketTransport + HdlcParser into the same
4+
// {connect, disconnect, sendPacket, _onPacket, _onLog} shape
5+
// js/rnode.js exposes, so app.js can drive either without
6+
// branching. Unlike rnode.js this module does not speak KISS and
7+
// does not issue any RNode command — there is no radio on the
8+
// other end, just an rnsd that will inject our packets into a
9+
// Reticulum network.
10+
//
11+
// Packet flow:
12+
// TX: app.js builds a raw Reticulum packet, calls sendPacket(),
13+
// we HDLC-frame it and write it to the transport (which hands
14+
// it to the WebSocket-to-TCP bridge, which forwards to rnsd).
15+
// RX: the bridge delivers bytes from rnsd via the transport, the
16+
// HdlcParser emits one complete frame per boundary, each
17+
// frame IS a raw Reticulum packet which we hand to _onPacket.
18+
//
19+
// Since there is no physical radio, RSSI and SNR are not available.
20+
// We pass zeros into _onPacket so existing log lines still render.
21+
22+
'use strict';
23+
24+
import { WebSocketTransport } from './websocket-transport.js';
25+
import { HdlcParser, encodeFrame } from './hdlc.js';
26+
27+
export class RnsdInterface {
28+
constructor(url) {
29+
this.transport = new WebSocketTransport(url);
30+
this._onPacket = null;
31+
this._onLog = null;
32+
33+
// Stream HDLC frames out of whatever byte chunks the transport
34+
// delivers. Each complete frame is a raw Reticulum packet; we
35+
// forward it to _onPacket with rssi=0 and snr=0.
36+
this._parser = new HdlcParser((packet) => {
37+
if (this._onPacket) this._onPacket(packet, 0, 0);
38+
});
39+
this.transport._onReceive = (bytes) => this._parser.feed(bytes);
40+
}
41+
42+
get connected() {
43+
return this.transport.connected;
44+
}
45+
46+
// Capability flag consumed by app.js to decide whether to show the
47+
// radio config panel and whether to issue RNode-specific commands
48+
// (detect, getFirmwareVersion, getBattery, configureAndStart).
49+
// None of those apply over a WebSocket-to-rnsd path.
50+
get capabilities() {
51+
return {
52+
rnodeControl: false,
53+
radioConfig: false,
54+
};
55+
}
56+
57+
_log(msg) {
58+
if (this._onLog) this._onLog(msg);
59+
}
60+
61+
async connect() {
62+
this.transport._onLog = (msg) => this._log(msg);
63+
this.transport._onDisconnect = () => {
64+
this._parser.reset();
65+
};
66+
await this.transport.connect();
67+
}
68+
69+
async disconnect() {
70+
await this.transport.disconnect();
71+
}
72+
73+
// Stubs for the RNode command API so any forgotten caller in
74+
// app.js still works without branching. All return benign values;
75+
// a future refactor can replace app.js's `await rnode.detect()`
76+
// style calls with capability-gated conditionals and delete these.
77+
async detect() { return true; }
78+
async getFirmwareVersion() { return { major: 0, minor: 0 }; }
79+
async getPlatform() { return 0; }
80+
async getBoard() { return 0; }
81+
async getBattery() { return 0; }
82+
async setFrequency() { return 0; }
83+
async setBandwidth() { return 0; }
84+
async setSpreadingFactor() { return 0; }
85+
async setCodingRate() { return 0; }
86+
async setTxPower() { return 0; }
87+
async setRadioState() { return true; }
88+
async configureAndStart() { return true; }
89+
async blink() { }
90+
91+
// Send a raw Reticulum packet. HDLC-frame it and push it through
92+
// the transport in one write. rnsd's TCPClientInterface on the
93+
// other side will strip the framing and hand the packet to its
94+
// Transport for onward routing.
95+
async sendPacket(data) {
96+
if (!this.transport.connected) throw new Error('WebSocket not connected');
97+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
98+
const frame = encodeFrame(bytes);
99+
await this.transport.write(frame);
100+
}
101+
}

0 commit comments

Comments
 (0)