From e17382ba301cdd7406254297702ce2558ef4ad0b Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 28 Mar 2026 16:45:18 +0100 Subject: [PATCH 01/14] Add Disconnect Cloud Relay button to SettingsPanel When a cloudRelaySession is active the Cloud Relay card now shows the session status and a Disconnect button. Clicking it clears the session and immediately saves config (no manual Save required), switching RigContext back to the direct SSE connection path. Previously the only way to leave cloud-relay mode was to manually clear the hidden session field; without that the direct connection path in RigContext was never reached even after disabling the relay in rig-bridge. Co-Authored-By: Claude Sonnet 4.6 --- src/components/SettingsPanel.jsx | 205 +++++++++++++++++++++---------- 1 file changed, 138 insertions(+), 67 deletions(-) diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 03f3e549..1edba5d8 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -4788,8 +4788,8 @@ export const SettingsPanel = ({ {/* Cloud Relay Setup */}
Cloud Relay
-
- Running OpenHamClock in the cloud? The Cloud Relay connects your local rig-bridge to this server, - enabling click-to-tune, PTT, WSJT-X decodes, and APRS from anywhere. -
- -
- Requires rig-bridge running locally and RIG_BRIDGE_RELAY_KEY set on this server. -
+ {cloudRelaySession ? ( + <> +
+ + Active — session{' '} + + {cloudRelaySession.slice(0, 8)}… + +
+ +
+ Switches to direct connection. Disable the Cloud Relay plugin in rig-bridge too. +
+ + ) : ( + <> +
+ Running OpenHamClock in the cloud? The Cloud Relay connects your local rig-bridge to this + server, enabling click-to-tune, PTT, WSJT-X decodes, and APRS from anywhere. +
+ +
+ Requires rig-bridge running locally and RIG_BRIDGE_RELAY_KEY set on this server. +
+ + )} )} From de4da4e8644c107abf4d699faaf7c586058bb59b Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 28 Mar 2026 17:20:11 +0100 Subject: [PATCH 02/14] Route all plugin data over rig-bridge SSE /stream (local mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of rig-bridge firing fire-and-forget HTTP POSTs to a hardcoded localhost:8080 (wrong port, always failed silently), all plugin data now flows over the same SSE /stream connection already used for freq/mode/ptt. rig-bridge changes: - state.js: add 100-entry decode ring-buffer (addToDecodeRingBuffer / getDecodeRingBuffer) so connecting browsers see recent decodes immediately - rig-bridge.js: subscribe to pluginBus after connectIntegrations() and broadcast typed plugin messages for decode/status/qso/aprs events - server.js: send plugin-init after the rig init message on /stream connect, carrying the ring-buffer replay and list of running integration plugins - aprs-tnc.js: remove forwardToLocal() call from handleKissData — SSE broadcast replaces it; fix default ohcUrl port from 8080 → 3000 - config.js: fix default aprs.ohcUrl port 8080 → 3000 Frontend changes: - RigContext.jsx: dispatch window CustomEvent 'rig-plugin-data' for type:'plugin' and type:'plugin-init' messages from local SSE - useAPRS.js: listen for rig-plugin-data aprs events, POST raw packet to /api/aprs/local (same-origin, always reachable), then refresh stations; seed tncConnected from plugin-init - useWSJTX.js: listen for decode/status events; seed decode list from plugin-init ring-buffer replay; populate clients map from status events - useDigitalModes.js: listen for status events to update plugin statuses (OHC server has no /api/mshv|jtdx|js8call/status routes, so HTTP polling was always failing silently in local mode) Cloud relay path is entirely unchanged — window events only fire when the browser is connected directly to rig-bridge's /stream. Co-Authored-By: Claude Sonnet 4.6 --- rig-bridge/core/config.js | 2 +- rig-bridge/core/server.js | 14 ++++++- rig-bridge/core/state.js | 17 +++++++++ rig-bridge/plugins/aprs-tnc.js | 6 +-- rig-bridge/rig-bridge.js | 70 +++++++++++++++++++++++++++++++++- src/contexts/RigContext.jsx | 4 ++ src/hooks/useAPRS.js | 30 +++++++++++++++ src/hooks/useDigitalModes.js | 29 ++++++++++++++ src/hooks/useWSJTX.js | 68 +++++++++++++++++++++++++++++++++ 9 files changed, 233 insertions(+), 7 deletions(-) diff --git a/rig-bridge/core/config.js b/rig-bridge/core/config.js index a8f0e871..e2c994d2 100644 --- a/rig-bridge/core/config.js +++ b/rig-bridge/core/config.js @@ -153,7 +153,7 @@ const DEFAULT_CONFIG = { // Local forwarding: push received packets to the local OHC server's /api/aprs/local // Set to false when using cloudRelay to avoid duplicate injection on the cloud server. localForward: true, - ohcUrl: 'http://localhost:8080', // URL of the local OpenHamClock server + ohcUrl: 'http://localhost:3000', // URL of the local OpenHamClock server }, // Rotator control via rotctld (Hamlib) rotator: { diff --git a/rig-bridge/core/server.js b/rig-bridge/core/server.js index 90c79e70..69868ef4 100644 --- a/rig-bridge/core/server.js +++ b/rig-bridge/core/server.js @@ -22,7 +22,7 @@ const express = require('express'); const cors = require('cors'); const { getSerialPort, listPorts } = require('./serial-utils'); -const { state, addSseClient, removeSseClient } = require('./state'); +const { state, addSseClient, removeSseClient, getDecodeRingBuffer } = require('./state'); const { config, saveConfig, CONFIG_PATH } = require('./config'); // ─── Security helpers ───────────────────────────────────────────────────── @@ -2061,6 +2061,18 @@ function createServer(registry, version) { }; res.write(`data: ${JSON.stringify(initialData)}\n\n`); + // Send plugin-init so the browser immediately sees which integrations are + // running and gets a replay of recent decodes (no waiting for next FT8 cycle). + const recentDecodes = getDecodeRingBuffer(); + const runningPlugins = Array.from(registry.getIntegrations().keys()); + res.write( + `data: ${JSON.stringify({ + type: 'plugin-init', + plugins: runningPlugins, + decodes: recentDecodes, + })}\n\n`, + ); + const clientId = Date.now() + Math.random(); addSseClient(clientId, res); diff --git a/rig-bridge/core/state.js b/rig-bridge/core/state.js index 0ba6b0c3..d7768282 100644 --- a/rig-bridge/core/state.js +++ b/rig-bridge/core/state.js @@ -3,6 +3,21 @@ * state.js — Shared rig state store and SSE broadcast */ +// Ring-buffer of recent plugin decodes (FT8/FT4/MSHV/JTDX/JS8Call). +// Sent to browsers on SSE connect so they see recent data immediately +// without waiting for the next decode cycle. +const DECODE_RING_MAX = 100; +const decodeRingBuffer = []; + +function addToDecodeRingBuffer(decode) { + decodeRingBuffer.push(decode); + if (decodeRingBuffer.length > DECODE_RING_MAX) decodeRingBuffer.shift(); +} + +function getDecodeRingBuffer() { + return decodeRingBuffer.slice(); +} + const state = { connected: false, freq: 0, @@ -58,4 +73,6 @@ module.exports = { removeSseClient, onStateChange, removeStateChangeListener, + addToDecodeRingBuffer, + getDecodeRingBuffer, }; diff --git a/rig-bridge/plugins/aprs-tnc.js b/rig-bridge/plugins/aprs-tnc.js index 9c359a0b..835789f2 100644 --- a/rig-bridge/plugins/aprs-tnc.js +++ b/rig-bridge/plugins/aprs-tnc.js @@ -215,14 +215,12 @@ const descriptor = { timestamp: Date.now(), }; - // Emit on shared bus for cloud relay + // Emit on shared bus — picked up by cloud-relay plugin and by the + // bus-to-SSE bridge in rig-bridge.js for direct/local connections. if (bus) { bus.emit('aprs', aprsPacket); } - // Forward directly to the local OHC server (for non-cloud / self-hosted installs) - forwardToLocal([aprsPacket]); - // Notify SSE listeners notifyListeners({ source: packet.source, diff --git a/rig-bridge/rig-bridge.js b/rig-bridge/rig-bridge.js index 715fe1af..76de10a5 100644 --- a/rig-bridge/rig-bridge.js +++ b/rig-bridge/rig-bridge.js @@ -22,7 +22,14 @@ const VERSION = '2.0.0'; const { config, loadConfig, applyCliArgs } = require('./core/config'); -const { updateState, state, onStateChange, removeStateChangeListener } = require('./core/state'); +const { + updateState, + state, + broadcast, + onStateChange, + removeStateChangeListener, + addToDecodeRingBuffer, +} = require('./core/state'); const PluginRegistry = require('./core/plugin-registry'); const { startServer } = require('./core/server'); @@ -85,3 +92,64 @@ registry.connectActive(); // 8. Start all enabled integration plugins (e.g. WSJT-X relay) registry.connectIntegrations(); + +// 9. Bridge plugin bus events to the SSE /stream so browsers in local/direct +// mode receive all plugin data (decodes, status, APRS) over the same +// connection used for freq/mode/ptt — no separate HTTP POSTs needed. +pluginBus.on('decode', (msg) => { + // Build a trimmed decode object with the fields the UI needs. + // id is a stable content key used for client-side deduplication. + const d = { + id: `${msg.source}-${msg.time?.formatted ?? Date.now()}-${msg.deltaFreq}-${(msg.message ?? '').replace(/\s/g, '')}`, + source: msg.source, + snr: msg.snr, + deltaTime: msg.deltaTime, + deltaFreq: msg.deltaFreq, + freq: msg.deltaFreq, // alias used by useWSJTX dedup key + time: msg.time?.formatted ?? '', + mode: msg.mode, + message: msg.message, + dialFrequency: msg.dialFrequency, + timestamp: Date.now(), + }; + addToDecodeRingBuffer(d); + broadcast({ type: 'plugin', event: 'decode', source: msg.source, data: d }); +}); + +pluginBus.on('status', (msg) => { + broadcast({ + type: 'plugin', + event: 'status', + source: msg.source, + data: { + dialFrequency: msg.dialFrequency, + mode: msg.mode, + dxCall: msg.dxCall, + dxGrid: msg.dxGrid, + transmitting: msg.transmitting, + decoding: msg.decoding, + txEnabled: msg.txEnabled, + }, + }); +}); + +pluginBus.on('qso', (msg) => { + broadcast({ + type: 'plugin', + event: 'qso', + source: msg.source, + data: { + dxCall: msg.dxCall, + dxGrid: msg.dxGrid, + mode: msg.mode, + reportSent: msg.reportSent, + reportReceived: msg.reportReceived, + txFrequency: msg.txFrequency, + timestamp: Date.now(), + }, + }); +}); + +pluginBus.on('aprs', (pkt) => { + broadcast({ type: 'plugin', event: 'aprs', source: 'aprs-tnc', data: pkt }); +}); diff --git a/src/contexts/RigContext.jsx b/src/contexts/RigContext.jsx index 3ee5968b..014d8443 100644 --- a/src/contexts/RigContext.jsx +++ b/src/contexts/RigContext.jsx @@ -237,6 +237,10 @@ export const RigProvider = ({ children, rigConfig }) => { [data.prop]: data.value, lastUpdate: Date.now(), })); + } else if (data.type === 'plugin' || data.type === 'plugin-init') { + // Forward plugin data (decodes, status, APRS, QSOs) as a window + // event so individual hooks can subscribe without coupling to RigContext. + window.dispatchEvent(new CustomEvent('rig-plugin-data', { detail: data })); } } catch (e) { console.error('[RigContext] Failed to parse SSE message', e); diff --git a/src/hooks/useAPRS.js b/src/hooks/useAPRS.js index 096fcbc2..d633f3ac 100644 --- a/src/hooks/useAPRS.js +++ b/src/hooks/useAPRS.js @@ -90,6 +90,36 @@ export const useAPRS = (options = {}) => { return () => clearInterval(interval); }, [enabled, fetchTncStatus]); + // Receive APRS packets pushed over the rig-bridge SSE /stream (local/direct + // mode only — cloud relay handles its own forwarding path). + // Packets arrive as { type:'plugin', event:'aprs', data: rawPacket }. + // We forward them to the same-origin /api/aprs/local so the server can parse + // the APRS position and merge the station into its cache, then refresh. + // plugin-init also tells us whether the TNC plugin is running. + useEffect(() => { + if (!enabled) return; + const handler = async (e) => { + const msg = e.detail; + if (msg.type === 'plugin-init') { + setTncConnected(msg.plugins?.includes('aprs-tnc') ?? false); + return; + } + if (msg.event !== 'aprs') return; + try { + await apiFetch('/api/aprs/local', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ packets: [msg.data] }), + }); + fetchStations(); + } catch { + // best-effort + } + }; + window.addEventListener('rig-plugin-data', handler); + return () => window.removeEventListener('rig-plugin-data', handler); + }, [enabled, fetchStations]); + // Watchlist helpers const addGroup = useCallback((name) => { if (!name?.trim()) return; diff --git a/src/hooks/useDigitalModes.js b/src/hooks/useDigitalModes.js index 0354ebe2..a442ebd9 100644 --- a/src/hooks/useDigitalModes.js +++ b/src/hooks/useDigitalModes.js @@ -56,6 +56,35 @@ export function useDigitalModes() { useVisibilityRefresh(fetchStatuses, 5000); + // Receive status events pushed over the rig-bridge SSE /stream. + // In local/direct mode the OHC server doesn't proxy /api/{mshv,jtdx,js8call}/status, + // so HTTP polling always returns empty. SSE status events are the working path. + useEffect(() => { + const handler = (e) => { + const msg = e.detail; + if (msg.event !== 'status') return; + const { source, data: s } = msg; + if (!PLUGINS.includes(source)) return; + if (!mountedRef.current) return; + setStatuses((prev) => ({ + ...prev, + [source]: { + ...prev[source], + enabled: true, + running: true, + connected: s.dialFrequency != null, + dialFrequency: s.dialFrequency, + mode: s.mode, + transmitting: s.transmitting, + decoding: s.decoding, + }, + })); + setLoading(false); + }; + window.addEventListener('rig-plugin-data', handler); + return () => window.removeEventListener('rig-plugin-data', handler); + }, []); + // Control actions const sendCommand = useCallback(async (pluginId, action, body = {}) => { try { diff --git a/src/hooks/useWSJTX.js b/src/hooks/useWSJTX.js index b1563411..88ba4a53 100644 --- a/src/hooks/useWSJTX.js +++ b/src/hooks/useWSJTX.js @@ -175,6 +175,74 @@ export function useWSJTX(enabled = true) { if (enabled) fetchFull(); }, 5000); + // Receive decode/status/qso events pushed over the rig-bridge SSE /stream + // (local/direct mode only — cloud relay uses the server polling path above). + // plugin-init seeds the decode list with recent history from rig-bridge's + // ring-buffer so the UI is populated immediately on connect. + useEffect(() => { + if (!enabled) return; + const handler = (e) => { + const msg = e.detail; + + if (msg.type === 'plugin-init') { + // Seed from ring-buffer replay + if (Array.isArray(msg.decodes) && msg.decodes.length > 0) { + setData((prev) => { + const existingKeys = new Set( + prev.decodes.map((d) => `${d.time}-${d.freq}-${(d.message ?? '').replace(/\s+/g, '')}`), + ); + const fresh = msg.decodes.filter((d) => { + const k = `${d.time}-${d.freq}-${(d.message ?? '').replace(/\s+/g, '')}`; + return !existingKeys.has(k); + }); + if (fresh.length === 0) return prev; + const merged = [...fresh, ...prev.decodes].slice(-500); + return { ...prev, decodes: merged, enabled: true }; + }); + hasDataFlowing.current = true; + } + return; + } + + if (msg.event === 'decode') { + hasDataFlowing.current = true; + setData((prev) => { + const d = msg.data; + const existingIds = new Set(prev.decodes.map((x) => x.id)); + if (d.id && existingIds.has(d.id)) return prev; + const existingKeys = new Set( + prev.decodes.map((x) => `${x.time}-${x.freq}-${(x.message ?? '').replace(/\s+/g, '')}`), + ); + const contentKey = `${d.time}-${d.freq}-${(d.message ?? '').replace(/\s+/g, '')}`; + if (existingKeys.has(contentKey)) return prev; + const merged = [...prev.decodes, d].slice(-500); + return { ...prev, decodes: merged, enabled: true, stats: { ...prev.stats, totalDecodes: merged.length } }; + }); + } else if (msg.event === 'status') { + const { source, data: s } = msg; + setData((prev) => ({ + ...prev, + enabled: true, + clients: { + ...prev.clients, + [source]: { + ...(prev.clients[source] ?? {}), + dialFrequency: s.dialFrequency, + mode: s.mode, + dxCall: s.dxCall, + dxGrid: s.dxGrid, + transmitting: s.transmitting, + decoding: s.decoding, + lastSeen: Date.now(), + }, + }, + })); + } + }; + window.addEventListener('rig-plugin-data', handler); + return () => window.removeEventListener('rig-plugin-data', handler); + }, [enabled]); + // ── Derive DX target from active WSJT-X client status ── // Pick the most recently active client (most recent lastSeen). // When its dxCall changes and has resolved coordinates, update dxTarget. From 67642419d928d04353dcf4c856c536cd3a15e7fb Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 28 Mar 2026 17:55:11 +0100 Subject: [PATCH 03/14] Eliminate OHC server polling for WSJT-X and APRS RF in local mode - useWSJTX: add isLocalMode ref that stops the adaptive polling loop the moment the first rig-bridge SSE event arrives; add qso event handling; avoid setting hasDataFlowing from SSE path (prevents spurious 2 s burst) - rig-bridge/lib/aprs-parser.js: new standalone APRS position parser (!, =, /, @, ; data types) extracted so rig-bridge can produce fully- parsed station objects without a server round-trip - aprs-tnc: parse packet before bus emit; spread lat/lon/symbol/comment into the SSE payload alongside raw AX.25 fields; tag with stationSource - useAPRS: maintain separate rfStations Map fed purely by SSE events; merge RF + internet stations (RF wins on duplicate callsign); add 60-minute aging cleanup matching server APRS_MAX_AGE_MINUTES; remove server POST call entirely for local-TNC path Co-Authored-By: Claude Sonnet 4.6 --- rig-bridge/lib/aprs-parser.js | 151 +++++++++++++++++++++++++++++++++ rig-bridge/plugins/aprs-tnc.js | 17 +++- src/hooks/useAPRS.js | 106 ++++++++++++++++------- src/hooks/useWSJTX.js | 22 ++++- 4 files changed, 262 insertions(+), 34 deletions(-) create mode 100644 rig-bridge/lib/aprs-parser.js diff --git a/rig-bridge/lib/aprs-parser.js b/rig-bridge/lib/aprs-parser.js new file mode 100644 index 00000000..4b302dbe --- /dev/null +++ b/rig-bridge/lib/aprs-parser.js @@ -0,0 +1,151 @@ +'use strict'; +/** + * aprs-parser.js — Lightweight APRS position packet parser + * + * Parses a raw APRS line ("CALLSIGN>PATH:payload") into a station object + * with lat/lon, symbol, comment, speed, course, and altitude. + * Returns null for non-position packets (messages, telemetry, status, etc.). + * + * Supported APRS position formats: + * ! = Position without timestamp (uncompressed) + * / @ Position with timestamp (uncompressed) + * ; Object report + */ + +// Parse APRS uncompressed latitude: DDMM.MMN +function parseAprsLat(s) { + if (!s || s.length < 8) return NaN; + const deg = parseInt(s.substring(0, 2)); + const min = parseFloat(s.substring(2, 7)); + const hemi = s.charAt(7); + const lat = deg + min / 60; + return hemi === 'S' ? -lat : lat; +} + +// Parse APRS uncompressed longitude: DDDMM.MMW +function parseAprsLon(s) { + if (!s || s.length < 9) return NaN; + const deg = parseInt(s.substring(0, 3)); + const min = parseFloat(s.substring(3, 8)); + const hemi = s.charAt(8); + const lon = deg + min / 60; + return hemi === 'W' ? -lon : lon; +} + +// Parse resource tokens from APRS comment field (EmComm bracket notation) +// e.g. "[Beds 12/20] [Water OK]" → tokens array + clean comment +function parseResourceTokens(comment) { + if (!comment) return { tokens: [], cleanComment: '' }; + const tokens = []; + const regex = /\[([A-Za-z]+)\s+([^\]]+)\]/g; + let match; + while ((match = regex.exec(comment)) !== null) { + const key = match[1]; + const val = match[2].trim(); + const capacityMatch = val.match(/^(\d+)\/(\d+)$/); + if (capacityMatch) { + tokens.push({ key, current: parseInt(capacityMatch[1]), max: parseInt(capacityMatch[2]), type: 'capacity' }); + } else if (val === '!') { + tokens.push({ key, value: '!', type: 'critical' }); + } else if (val.toUpperCase() === 'OK') { + tokens.push({ key, value: 'OK', type: 'status' }); + } else if (/^-\d+$/.test(val)) { + tokens.push({ key, value: parseInt(val), type: 'need' }); + } else if (/^\d+$/.test(val)) { + tokens.push({ key, value: parseInt(val), type: 'quantity' }); + } else { + tokens.push({ key, value: val, type: 'text' }); + } + } + const cleanComment = comment.replace(regex, '').trim(); + return { tokens, cleanComment }; +} + +/** + * Parse a raw APRS packet line into a position station object. + * @param {string} line Raw APRS line: "CALLSIGN>PATH:payload" + * @returns {{ call, ssid, lat, lon, symbol, comment, tokens, cleanComment, + * speed, course, altitude, raw } | null} + */ +function parseAprsPacket(line) { + try { + const headerEnd = line.indexOf(':'); + if (headerEnd < 0) return null; + + const header = line.substring(0, headerEnd); + const payload = line.substring(headerEnd + 1); + const callsign = header.split('>')[0].split('-')[0].trim(); + const ssid = header.split('>')[0].trim(); + + if (!callsign || callsign.length < 3) return null; + + const dataType = payload.charAt(0); + let lat, lon, symbolTable, symbolCode, comment, rest; + + if (dataType === '!' || dataType === '=') { + // Position without timestamp: !DDMM.MMN/DDDMM.MMW$... + lat = parseAprsLat(payload.substring(1, 9)); + symbolTable = payload.charAt(9); + lon = parseAprsLon(payload.substring(10, 19)); + symbolCode = payload.charAt(19); + comment = payload.substring(20).trim(); + } else if (dataType === '/' || dataType === '@') { + // Position with timestamp: /HHMMSSh DDMM.MMN/DDDMM.MMW$... + lat = parseAprsLat(payload.substring(8, 16)); + symbolTable = payload.charAt(16); + lon = parseAprsLon(payload.substring(17, 26)); + symbolCode = payload.charAt(26); + comment = payload.substring(27).trim(); + } else if (dataType === ';') { + // Object: ;NAME_____*HHMMSSh DDMM.MMN/DDDMM.MMW$... + const objPayload = payload.substring(11); + const ts = objPayload.charAt(0) === '*' ? 8 : 0; + rest = objPayload.substring(ts); + if (rest.length >= 19) { + lat = parseAprsLat(rest.substring(0, 8)); + symbolTable = rest.charAt(8); + lon = parseAprsLon(rest.substring(9, 18)); + symbolCode = rest.charAt(18); + comment = rest.substring(19).trim(); + } + } else { + return null; // Not a position packet we handle + } + + if (isNaN(lat) || isNaN(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; + + let speed = null, + course = null, + altitude = null; + const csMatch = comment?.match(/^(\d{3})\/(\d{3})/); + if (csMatch) { + course = parseInt(csMatch[1]); + speed = parseInt(csMatch[2]); // knots + } + const altMatch = comment?.match(/\/A=(\d{6})/); + if (altMatch) { + altitude = parseInt(altMatch[1]); // feet + } + + const { tokens, cleanComment } = parseResourceTokens(comment); + + return { + call: callsign, + ssid, + lat, + lon, + symbol: `${symbolTable}${symbolCode}`, + comment: comment || '', + tokens, + cleanComment, + speed, + course, + altitude, + raw: line, + }; + } catch (e) { + return null; + } +} + +module.exports = { parseAprsPacket }; diff --git a/rig-bridge/plugins/aprs-tnc.js b/rig-bridge/plugins/aprs-tnc.js index 835789f2..705c9cb2 100644 --- a/rig-bridge/plugins/aprs-tnc.js +++ b/rig-bridge/plugins/aprs-tnc.js @@ -30,6 +30,7 @@ const net = require('net'); const http = require('http'); const https = require('https'); const { URL } = require('url'); +const { parseAprsPacket } = require('../lib/aprs-parser'); const { decodeKissFrame, encodeKissFrame, @@ -207,12 +208,26 @@ const descriptor = { console.log(`[APRS-TNC] RX: ${packet.source}>${packet.destination}: ${packet.info}`); } + // Parse position fields here so the SSE path delivers a fully-formed + // station object and the browser needs no server round-trip. + // Raw source/destination/info are kept so the cloud-relay path can + // still forward them to /api/aprs/local for server-side processing. + const rawLine = `${packet.source}>${packet.destination}:${packet.info}`; + const parsed = parseAprsPacket(rawLine); const aprsPacket = { - source: packet.source, + // Raw fields — required by cloud relay to re-parse on the server + source: packet.source, // full callsign incl. SSID destination: packet.destination, digipeaters: packet.digipeaters, info: packet.info, timestamp: Date.now(), + // Parsed position fields (call, ssid, lat, lon, symbol, comment, …) + // Spread last so raw 'source' above is not overwritten (parsed has + // 'call'/'ssid', not 'source'). + ...(parsed ?? {}), + // Explicit tag so the frontend can identify RF packets without + // a server round-trip ('source' is the callsign, not origin type). + stationSource: 'local-tnc', }; // Emit on shared bus — picked up by cloud-relay plugin and by the diff --git a/src/hooks/useAPRS.js b/src/hooks/useAPRS.js index d633f3ac..f68de7fb 100644 --- a/src/hooks/useAPRS.js +++ b/src/hooks/useAPRS.js @@ -1,6 +1,8 @@ /** * useAPRS Hook - * Polls /api/aprs/stations for real-time APRS position data. + * Polls /api/aprs/stations for internet APRS-IS data. + * In local/direct mode (rig-bridge SSE), RF stations are maintained in a + * separate in-memory store fed directly by SSE events — no server round-trip. * Manages watchlist groups stored in localStorage. */ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; @@ -8,11 +10,16 @@ import { apiFetch } from '../utils/apiFetch'; const STORAGE_KEY = 'openhamclock_aprsWatchlist'; const POLL_INTERVAL = 15000; // 15 seconds +const RF_MAX_AGE_MS = 60 * 60 * 1000; // 60 minutes — match server APRS_MAX_AGE_MINUTES export const useAPRS = (options = {}) => { const { enabled = true } = options; + // Internet APRS-IS stations from server polling const [stations, setStations] = useState([]); + // Local RF stations from rig-bridge SSE — Map keyed by ssid (full callsign) + const [rfStations, setRfStations] = useState(new Map()); + const [connected, setConnected] = useState(false); const [aprsEnabled, setAprsEnabled] = useState(false); const [loading, setLoading] = useState(true); @@ -38,7 +45,7 @@ export const useAPRS = (options = {}) => { } catch {} }, [watchlist]); - // Fetch stations + // Fetch internet APRS-IS stations from server const fetchStations = useCallback(async () => { if (!enabled) return; try { @@ -47,9 +54,6 @@ export const useAPRS = (options = {}) => { const data = await res.json(); setStations(data.stations || []); setConnected(data.connected || false); - // Panel is "enabled" when APRS-IS is configured OR when the local TNC - // has delivered at least one station — so RF-only setups work without - // needing APRS_ENABLED=true in .env. setAprsEnabled(data.enabled || data.tncActive || false); setLastUpdate(new Date()); setLoading(false); @@ -90,35 +94,79 @@ export const useAPRS = (options = {}) => { return () => clearInterval(interval); }, [enabled, fetchTncStatus]); - // Receive APRS packets pushed over the rig-bridge SSE /stream (local/direct - // mode only — cloud relay handles its own forwarding path). - // Packets arrive as { type:'plugin', event:'aprs', data: rawPacket }. - // We forward them to the same-origin /api/aprs/local so the server can parse - // the APRS position and merge the station into its cache, then refresh. - // plugin-init also tells us whether the TNC plugin is running. + // Age out stale RF stations (mirrors server-side APRS_MAX_AGE_MINUTES) + useEffect(() => { + if (!enabled) return; + const interval = setInterval(() => { + const cutoff = Date.now() - RF_MAX_AGE_MS; + setRfStations((prev) => { + let changed = false; + const next = new Map(prev); + for (const [key, st] of next) { + if ((st.timestamp ?? 0) < cutoff) { + next.delete(key); + changed = true; + } + } + return changed ? next : prev; + }); + }, 60000); // check every minute + return () => clearInterval(interval); + }, [enabled]); + + // Receive APRS packets from rig-bridge SSE /stream (local/direct mode only). + // Packets now carry parsed position fields (lat, lon, symbol, …) added by + // aprs-tnc.js, so no server round-trip is needed. + // plugin-init tells us which integration plugins are running. useEffect(() => { if (!enabled) return; - const handler = async (e) => { + const handler = (e) => { const msg = e.detail; + if (msg.type === 'plugin-init') { setTncConnected(msg.plugins?.includes('aprs-tnc') ?? false); + if (msg.plugins?.includes('aprs-tnc')) { + setAprsEnabled(true); + setLoading(false); + } return; } + if (msg.event !== 'aprs') return; - try { - await apiFetch('/api/aprs/local', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ packets: [msg.data] }), + + const pkt = msg.data; + // Only add to RF store if the packet was successfully parsed (has lat/lon) + if (pkt.lat == null || pkt.lon == null) return; + + const key = pkt.ssid ?? pkt.source; + setRfStations((prev) => { + const next = new Map(prev); + next.set(key, { + ...pkt, + source: 'local-tnc', // use the standard source tag the UI expects + timestamp: pkt.timestamp ?? Date.now(), + lastUpdate: Date.now(), }); - fetchStations(); - } catch { - // best-effort - } + return next; + }); + setTncConnected(true); + setAprsEnabled(true); + setLastUpdate(new Date()); + setLoading(false); }; window.addEventListener('rig-plugin-data', handler); return () => window.removeEventListener('rig-plugin-data', handler); - }, [enabled, fetchStations]); + }, [enabled]); + + // Merge internet stations with local RF stations. + // RF stations take precedence: if the same callsign is heard both on the + // internet and over RF, the RF entry wins (preserves local-tnc tag). + const allStations = useMemo(() => { + const rf = Array.from(rfStations.values()); + const rfKeys = new Set(rf.map((s) => s.ssid ?? s.source)); + const internet = stations.filter((s) => !rfKeys.has(s.ssid) && !rfKeys.has(s.call)); + return [...rf, ...internet]; + }, [stations, rfStations]); // Watchlist helpers const addGroup = useCallback((name) => { @@ -177,10 +225,10 @@ export const useAPRS = (options = {}) => { // Stations filtered by source (all / internet / rf) const sourceFilteredStations = useMemo(() => { - if (sourceFilter === 'rf') return stations.filter((s) => s.source === 'local-tnc'); - if (sourceFilter === 'internet') return stations.filter((s) => s.source !== 'local-tnc'); - return stations; - }, [stations, sourceFilter]); + if (sourceFilter === 'rf') return allStations.filter((s) => s.source === 'local-tnc'); + if (sourceFilter === 'internet') return allStations.filter((s) => s.source !== 'local-tnc'); + return allStations; + }, [allStations, sourceFilter]); // Filtered stations: source filter applied first, then group/watchlist filter const filteredStations = useMemo(() => { @@ -194,11 +242,11 @@ export const useAPRS = (options = {}) => { return base.filter((s) => groupCalls.has(s.call) || groupCalls.has(s.ssid)); }, [sourceFilteredStations, watchlist.activeGroup, watchlist.groups, allWatchlistCalls]); - // Whether any RF (local-tnc) station is currently in the cache - const hasRFStations = useMemo(() => stations.some((s) => s.source === 'local-tnc'), [stations]); + // Whether any RF (local-tnc) station is currently in the local store + const hasRFStations = rfStations.size > 0; return { - stations, + stations: allStations, filteredStations, connected, aprsEnabled, diff --git a/src/hooks/useWSJTX.js b/src/hooks/useWSJTX.js index 88ba4a53..036e2833 100644 --- a/src/hooks/useWSJTX.js +++ b/src/hooks/useWSJTX.js @@ -52,7 +52,8 @@ export function useWSJTX(enabled = true) { const lastTimestamp = useRef(0); const fullFetchCounter = useRef(0); const backoffUntil = useRef(0); // Rate-limit backoff timestamp - const hasDataFlowing = useRef(false); // True when relay/UDP is active + const hasDataFlowing = useRef(false); // True when relay/UDP is active (HTTP path) + const isLocalMode = useRef(false); // True once SSE data arrives from rig-bridge directly // ── DX Target tracking ── // When the operator selects a callsign in WSJT-X (Std Msgs), the server @@ -148,12 +149,15 @@ export function useWSJTX(enabled = true) { if (enabled) fetchFull(); }, [enabled, fetchFull]); - // Polling - adaptive: fast (2s) when data flows, slow (30s) when idle + // Polling - adaptive: fast (2s) when data flows, slow (30s) when idle. + // Stops entirely once local/direct SSE mode is detected (isLocalMode). useEffect(() => { if (!enabled) return; let timer; const tick = () => { + // SSE from rig-bridge is the data source — no need to poll the server. + if (isLocalMode.current) return; const interval = hasDataFlowing.current ? POLL_FAST : POLL_SLOW; fullFetchCounter.current++; if (fullFetchCounter.current >= 8) { @@ -184,6 +188,13 @@ export function useWSJTX(enabled = true) { const handler = (e) => { const msg = e.detail; + // Mark local mode on the very first SSE message — polling loop will stop. + if (!isLocalMode.current) { + isLocalMode.current = true; + setLoading(false); + setError(null); + } + if (msg.type === 'plugin-init') { // Seed from ring-buffer replay if (Array.isArray(msg.decodes) && msg.decodes.length > 0) { @@ -199,13 +210,11 @@ export function useWSJTX(enabled = true) { const merged = [...fresh, ...prev.decodes].slice(-500); return { ...prev, decodes: merged, enabled: true }; }); - hasDataFlowing.current = true; } return; } if (msg.event === 'decode') { - hasDataFlowing.current = true; setData((prev) => { const d = msg.data; const existingIds = new Set(prev.decodes.map((x) => x.id)); @@ -237,6 +246,11 @@ export function useWSJTX(enabled = true) { }, }, })); + } else if (msg.event === 'qso') { + setData((prev) => { + const updated = [msg.data, ...prev.qsos].slice(-200); + return { ...prev, qsos: updated, stats: { ...prev.stats, totalQsos: updated.length } }; + }); } }; window.addEventListener('rig-plugin-data', handler); From a38258821d471078f58b316cffa514d6595e19a9 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 28 Mar 2026 18:31:35 +0100 Subject: [PATCH 04/14] Improve APRS panel: distance column, hover tooltip, callsign SSID strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - APRSPanel: add distance-to-DE column using calculateDistance/formatDistance, respecting user metric/imperial setting; pass deLocation + units from DockableApp - APRSPanel: hover tooltip showing full comment, coordinates, distance, age, speed/course, altitude and symbol — fixed-position, pointer-events:none - APRSPanel: fix age display for RF stations (NaNh) by computing age from timestamp when server-provided age field is absent - APRSPanel: prevent server poll from resetting aprsEnabled to false when aprs-tnc was detected via SSE (tncDetectedViaSse ref) - CallsignLink: strip APRS SSID suffix (-0…-15) before QRZ lookup so W1ABC-9 opens QRZ as W1ABC Co-Authored-By: Claude Sonnet 4.6 --- src/DockableApp.jsx | 2 + src/components/APRSPanel.jsx | 102 ++++++++++++++++++++++++++++++-- src/components/CallsignLink.jsx | 9 ++- src/hooks/useAPRS.js | 17 +++++- 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx index d81bcc37..ea491b9e 100644 --- a/src/DockableApp.jsx +++ b/src/DockableApp.jsx @@ -935,6 +935,8 @@ export const DockableApp = ({ onToggleMap={toggleAPRSEff} onHoverSpot={setHoveredSpot} onSpotClick={handleSpotClick} + deLocation={config.location} + units={config.allUnits?.dist} /> ); break; diff --git a/src/components/APRSPanel.jsx b/src/components/APRSPanel.jsx index 8c75b581..c94e20a2 100644 --- a/src/components/APRSPanel.jsx +++ b/src/components/APRSPanel.jsx @@ -6,8 +6,9 @@ import { useState, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import CallsignLink from './CallsignLink.jsx'; +import { calculateDistance, formatDistance } from '../utils/geo.js'; -const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot }) => { +const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot, deLocation, units = 'metric' }) => { const { filteredStations = [], stations = [], @@ -34,6 +35,7 @@ const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot const [newGroupName, setNewGroupName] = useState(''); const [addCallInput, setAddCallInput] = useState(''); const [addCallTarget, setAddCallTarget] = useState(''); + const [tooltip, setTooltip] = useState(null); // { station, distKm, x, y } // Search filter const displayStations = useMemo(() => { @@ -60,7 +62,13 @@ const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot } }, [addCallInput, addCallTarget, addCallToGroup]); - const formatAge = (minutes) => (minutes < 1 ? 'now' : minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`); + const formatAge = (minutes) => + minutes == null ? '?' : minutes < 1 ? 'now' : minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`; + const stationAgeMinutes = (station) => { + if (station.age != null) return station.age; + if (station.timestamp != null) return Math.floor((Date.now() - station.timestamp) / 60000); + return null; + }; if (!aprsEnabled) { return ( @@ -482,12 +490,23 @@ const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot ) : ( displayStations.map((station, i) => { const isWatched = allWatchlistCalls.has(station.call) || allWatchlistCalls.has(station.ssid); + const distKm = + deLocation?.lat != null && deLocation?.lon != null && station.lat != null && station.lon != null + ? calculateDistance(deLocation.lat, deLocation.lon, station.lat, station.lon) + : null; return (
onHoverSpot?.({ call: station.call, lat: station.lat, lon: station.lon })} - onMouseLeave={() => onHoverSpot?.(null)} + onMouseEnter={(e) => { + onHoverSpot?.({ call: station.call, lat: station.lat, lon: station.lon }); + setTooltip({ station, distKm, x: e.clientX, y: e.clientY }); + }} + onMouseMove={(e) => setTooltip((prev) => (prev ? { ...prev, x: e.clientX, y: e.clientY } : prev))} + onMouseLeave={() => { + onHoverSpot?.(null); + setTooltip(null); + }} onClick={() => onSpotClick?.({ call: station.call, lat: station.lat, lon: station.lon })} style={{ display: 'grid', @@ -548,7 +567,8 @@ const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot color: 'var(--text-muted)', }} > - {formatAge(station.age)} + {formatAge(stationAgeMinutes(station))} + {distKm != null && {formatDistance(distKm, units)}} {station.speed > 0 && {station.speed} kt}
@@ -556,6 +576,78 @@ const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot }) )} + + {/* Hover tooltip */} + {tooltip && + (() => { + const s = tooltip.station; + const age = stationAgeMinutes(s); + // Keep tooltip within viewport + const tipX = tooltip.x + 14; + const tipY = tooltip.y + 14; + return ( +
+
+ + {s.ssid || s.call} + + {s.source === 'local-tnc' && ( + + RF + + )} +
+ {s.comment && ( +
+ {s.comment} +
+ )} +
+ {s.lat != null && s.lon != null && ( + + {s.lat.toFixed(4)}°, {s.lon.toFixed(4)}° + + )} + {tooltip.distKm != null && {formatDistance(tooltip.distKm, units)}} + {age != null && Age: {formatAge(age)}} + {s.speed > 0 && ( + + Speed: {s.speed} kt{s.course != null ? ` / ${s.course}°` : ''} + + )} + {s.altitude != null && Altitude: {s.altitude} ft} + {s.symbol && Symbol: {s.symbol}} +
+
+ ); + })()} ); }; diff --git a/src/components/CallsignLink.jsx b/src/components/CallsignLink.jsx index b057c1fa..78d763b8 100644 --- a/src/components/CallsignLink.jsx +++ b/src/components/CallsignLink.jsx @@ -14,10 +14,13 @@ import { createContext, useContext, useState, useCallback, useEffect } from 'rea // Picks the segment that looks most like a home callsign. const MODIFIERS = new Set(['M', 'P', 'QRP', 'MM', 'AM', 'R', 'T', 'B', 'BCN', 'LH', 'A', 'E', 'J', 'AG', 'AE', 'KT']); function extractBaseCall(raw) { - if (!raw || !raw.includes('/')) return raw || ''; - const parts = raw.toUpperCase().split('/'); + if (!raw) return ''; + // Strip APRS SSID suffix (-0 through -15) before any other processing + const withoutSsid = raw.replace(/-\d{1,2}$/, ''); + if (!withoutSsid.includes('/')) return withoutSsid; + const parts = withoutSsid.toUpperCase().split('/'); const candidates = parts.filter((p) => p && !MODIFIERS.has(p) && !/^\d$/.test(p)); - if (candidates.length === 0) return parts[0] || raw; + if (candidates.length === 0) return parts[0] || withoutSsid; if (candidates.length === 1) return candidates[0]; const pat = /^[A-Z]{1,3}\d{1,4}[A-Z]{1,4}$/; const full = candidates.filter((c) => pat.test(c)); diff --git a/src/hooks/useAPRS.js b/src/hooks/useAPRS.js index f68de7fb..3a016500 100644 --- a/src/hooks/useAPRS.js +++ b/src/hooks/useAPRS.js @@ -25,6 +25,9 @@ export const useAPRS = (options = {}) => { const [loading, setLoading] = useState(true); const [lastUpdate, setLastUpdate] = useState(null); const [tncConnected, setTncConnected] = useState(false); + // True once SSE confirms aprs-tnc is running — prevents server poll from + // resetting aprsEnabled to false when the OHC server has APRS_ENABLED=false. + const tncDetectedViaSse = useRef(false); // sourceFilter: 'all' | 'internet' | 'rf' const [sourceFilter, setSourceFilter] = useState('all'); @@ -54,7 +57,12 @@ export const useAPRS = (options = {}) => { const data = await res.json(); setStations(data.stations || []); setConnected(data.connected || false); - setAprsEnabled(data.enabled || data.tncActive || false); + // Don't let the server poll override aprsEnabled when the TNC was + // detected locally via SSE — the OHC server may have APRS_ENABLED=false + // even while rig-bridge's aprs-tnc plugin is actively receiving packets. + if (!tncDetectedViaSse.current) { + setAprsEnabled(data.enabled || data.tncActive || false); + } setLastUpdate(new Date()); setLoading(false); } @@ -124,8 +132,10 @@ export const useAPRS = (options = {}) => { const msg = e.detail; if (msg.type === 'plugin-init') { - setTncConnected(msg.plugins?.includes('aprs-tnc') ?? false); - if (msg.plugins?.includes('aprs-tnc')) { + const hasTnc = msg.plugins?.includes('aprs-tnc') ?? false; + setTncConnected(hasTnc); + if (hasTnc) { + tncDetectedViaSse.current = true; setAprsEnabled(true); setLoading(false); } @@ -149,6 +159,7 @@ export const useAPRS = (options = {}) => { }); return next; }); + tncDetectedViaSse.current = true; setTncConnected(true); setAprsEnabled(true); setLastUpdate(new Date()); From 09c86879baf9f46de58c1cbed80299dadbc0d495 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 28 Mar 2026 18:39:53 +0100 Subject: [PATCH 05/14] Add APRS symbol sprites to map markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add standard APRS symbol sprite sheets (hessu/aprs-symbols, 24 px) to public/ — primary table (0), alternate table (1), overlay table (2) - New src/utils/aprs-symbols.js: getAprsSymbolIcon() maps the two-char APRS symbol field to a Leaflet divIcon using CSS background-position into the sprite sheet; supports primary (/), alternate (\), and alphanumeric overlay table chars; falls back to null for unknown symbols - WorldMap.jsx: use symbol sprite icon when available, keeping the CSS triangle as fallback; colour ring (amber/green/cyan) preserved via box-shadow on the icon wrapper; watched stations rendered at 20 px, others at 16 px - WorldMap.jsx: fix age display for RF stations in popup (NaN) by falling back to timestamp when station.age is absent Co-Authored-By: Claude Sonnet 4.6 --- public/aprs-symbols-24-0.png | Bin 0 -> 53735 bytes public/aprs-symbols-24-1.png | Bin 0 -> 43979 bytes public/aprs-symbols-24-2.png | Bin 0 -> 11744 bytes src/components/WorldMap.jsx | 46 ++++++++------- src/utils/aprs-symbols.js | 106 +++++++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 public/aprs-symbols-24-0.png create mode 100644 public/aprs-symbols-24-1.png create mode 100644 public/aprs-symbols-24-2.png create mode 100644 src/utils/aprs-symbols.js diff --git a/public/aprs-symbols-24-0.png b/public/aprs-symbols-24-0.png new file mode 100644 index 0000000000000000000000000000000000000000..8a2713f900004088ae15017e08371d1948f65624 GIT binary patch literal 53735 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 LNkvXXu0mjfj7<#e literal 0 HcmV?d00001 diff --git a/public/aprs-symbols-24-1.png b/public/aprs-symbols-24-1.png new file mode 100644 index 0000000000000000000000000000000000000000..10126ef42b27dbb646bbd2c15fee1cef5e312f2b GIT binary patch literal 43979 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 Hu0mjfQQWZ3 literal 0 HcmV?d00001 diff --git a/public/aprs-symbols-24-2.png b/public/aprs-symbols-24-2.png new file mode 100644 index 0000000000000000000000000000000000000000..c1dfa98a4297a61094f64d5fb954e7dddabc6e56 GIT binary patch literal 11744 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 literal 0 HcmV?d00001 diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index eb7de18b..bf0037ca 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -23,6 +23,7 @@ import { saveBandColorOverrides, } from '../utils/bandColors.js'; import { createTerminator } from '../utils/terminator.js'; +import { getAprsSymbolIcon } from '../utils/aprs-symbols.js'; import { getAllLayers } from '../plugins/layerRegistry.js'; import useLocalInstall from '../hooks/app/useLocalInstall.js'; import PluginLayer from './PluginLayer.jsx'; @@ -1773,34 +1774,39 @@ export const WorldMap = ({ const isRF = station.source === 'local-tnc'; // amber for watched, green for local RF, cyan for internet const color = isWatched ? '#f59e0b' : isRF ? '#4ade80' : '#22d3ee'; - const size = isWatched ? 7 : 5; + const iconSize = isWatched ? 20 : 16; try { - // Triangle marker for APRS stations (distinct from circles/diamonds) replicatePoint(lat, lon).forEach(([rLat, rLon]) => { + // Use APRS symbol sprite when available, fall back to triangle + const symbolDesc = getAprsSymbolIcon(station.symbol, { size: iconSize, borderColor: color }); + const iconOpts = symbolDesc + ? { className: '', ...symbolDesc } + : (() => { + const s = isWatched ? 7 : 5; + return { + className: '', + html: `
`, + iconSize: [s * 2, s * 1.6], + iconAnchor: [s, s * 1.6], + }; + })(); + const marker = L.marker([rLat, rLon], { - icon: L.divIcon({ - className: '', - html: `
`, - iconSize: [size * 2, size * 1.6], - iconAnchor: [size, size * 1.6], - }), + icon: L.divIcon(iconOpts), zIndexOffset: isWatched ? 5000 : 1000, }); + const ageMin = + station.age ?? (station.timestamp != null ? Math.floor((Date.now() - station.timestamp) / 60000) : null); const ageStr = - station.age < 1 - ? 'now' - : station.age < 60 - ? `${station.age}m ago` - : `${Math.floor(station.age / 60)}h ago`; + ageMin == null + ? '' + : ageMin < 1 + ? 'now' + : ageMin < 60 + ? `${ageMin}m ago` + : `${Math.floor(ageMin / 60)}h ago`; marker .bindPopup( diff --git a/src/utils/aprs-symbols.js b/src/utils/aprs-symbols.js new file mode 100644 index 00000000..8c4b4c68 --- /dev/null +++ b/src/utils/aprs-symbols.js @@ -0,0 +1,106 @@ +'use strict'; +/** + * aprs-symbols.js — APRS symbol sprite sheet utilities + * + * APRS symbols are identified by a two-character string: + * char 0: symbol table '/' = primary, '\' = alternate, else overlay char (A-Z, 0-9) + * char 1: symbol code ASCII 33 ('!') … 126 ('~') — 96 possible symbols + * + * Sprite sheet layout (hessu/aprs-symbols, 24 px variant): + * 384 × 144 px → 16 columns × 6 rows, each cell 24 × 24 px + * Cell index = symbolCode.charCodeAt(0) - 33 + * X offset = (index % 16) * 24 + * Y offset = Math.floor(index / 16) * 24 + * + * Files (served from /public/): + * aprs-symbols-24-0.png primary table ('/') + * aprs-symbols-24-1.png alternate table ('\') + * aprs-symbols-24-2.png overlay table (alphanumeric overlay char) + */ + +const SPRITE_SIZE = 24; // px per cell in source sprite +const COLS = 16; // cells per row in sprite sheet + +/** + * Return the CSS background-position string for a symbol code character. + * @param {string} code Single character, ASCII 33–126 + * @param {number} displaySize Rendered size in pixels (for background-size scaling) + */ +function spritePosition(code, displaySize) { + const idx = code.charCodeAt(0) - 33; + if (idx < 0 || idx > 95) return '0px 0px'; + const col = idx % COLS; + const row = Math.floor(idx / COLS); + const scale = displaySize / SPRITE_SIZE; + return `-${col * displaySize}px -${row * displaySize}px`; +} + +/** + * Build a Leaflet divIcon descriptor for an APRS station. + * + * @param {string} symbol Two-char APRS symbol (e.g. '/-', '/>', '\j') + * @param {object} [opts] + * @param {number} [opts.size=16] Rendered icon size in px + * @param {string} [opts.borderColor] Optional ring color (CSS) + * @param {boolean} [opts.watched=false] Extra highlight for watched stations + * @returns {{ html: string, iconSize: [number,number], iconAnchor: [number,number] }} + * Pass directly as options to L.divIcon(). + * Returns null to signal "use fallback triangle". + */ +export function getAprsSymbolIcon(symbol, { size = 16, borderColor = null } = {}) { + if (!symbol || symbol.length < 2) return null; + + const tableChar = symbol.charAt(0); + const codeChar = symbol.charAt(1); + const codeIdx = codeChar.charCodeAt(0) - 33; + if (codeIdx < 0 || codeIdx > 95) return null; + + // Choose sprite sheet + let sheetUrl; + let overlayChar = null; + if (tableChar === '/') { + sheetUrl = '/aprs-symbols-24-0.png'; + } else if (tableChar === '\\') { + sheetUrl = '/aprs-symbols-24-1.png'; + } else if (/^[A-Z0-9]$/.test(tableChar)) { + // Overlay symbol: use alternate base sheet, stamp the overlay char on top + sheetUrl = '/aprs-symbols-24-2.png'; + overlayChar = tableChar; + } else { + return null; + } + + const bgPos = spritePosition(codeChar, size); + const sheetPx = COLS * size + 'px ' + 6 * size + 'px'; + + const border = borderColor ? `box-shadow: 0 0 0 2px ${borderColor};` : ''; + + const overlayHtml = overlayChar + ? `${overlayChar}` + : ''; + + const html = `
${overlayHtml}
`; + + return { + html, + iconSize: [size, size], + iconAnchor: [Math.round(size / 2), Math.round(size / 2)], + }; +} From e2c521eb9f99e1ba7384466d7a78f31deff8807f Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 28 Mar 2026 18:44:28 +0100 Subject: [PATCH 06/14] APRS clicks no longer move DX location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking an APRS station on the map or in the panel should not set the DX target — APRS is a monitoring tool, not a contact/spotting source. - WorldMap: remove onSpotClick call from APRS marker click; popup still opens via Leaflet bindPopup as before - DockableApp: stop passing onSpotClick to APRSPanel Co-Authored-By: Claude Sonnet 4.6 --- src/DockableApp.jsx | 1 - src/components/WorldMap.jsx | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx index ea491b9e..01177085 100644 --- a/src/DockableApp.jsx +++ b/src/DockableApp.jsx @@ -934,7 +934,6 @@ export const DockableApp = ({ showOnMap={mapLayersEff.showAPRS} onToggleMap={toggleAPRSEff} onHoverSpot={setHoveredSpot} - onSpotClick={handleSpotClick} deLocation={config.location} units={config.allUnits?.dist} /> diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index bf0037ca..7facc252 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -1822,9 +1822,7 @@ export const WorldMap = ({ ) .addTo(map); - if (onSpotClick) { - marker.on('click', () => onSpotClick({ call: station.call, lat, lon })); - } + // APRS clicks open the popup only — intentionally do not set DX location aprsMarkersRef.current.push(marker); }); From df1639303743bc09b80ea173a0a25135914978ba Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 28 Mar 2026 19:36:01 +0100 Subject: [PATCH 07/14] Add SSE-only mode for WSJT-X relay with UI toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In local/LAN mode the browser already receives all decodes via the SSE /stream — the HTTP batch POST to /api/wsjtx/relay on the OHC server is redundant and generates unnecessary traffic. - config.js: add wsjtxRelay.relayToServer (default false) — false means SSE-only, true means also POST batches to OHC server - wsjtx-relay.js: compute willRelay flag at connect() time; gate message queue push, scheduleBatch, heartbeat and health-check intervals behind willRelay; SSE bus.emit paths are always active regardless of mode; getStatus() now surfaces relayToServer and serverUrl only when active - state.js: export getSseClientCount() so other modules can read the number of live SSE connections - server.js: import getSseClientCount; add GET /api/status returning { sseClients, uptime } — lightweight endpoint for UI health display - SettingsPanel: add wsjtxRelayToServer toggle that immediately PATCHes rig-bridge config; handleConfigureWsjtxRelay now also sets relayToServer:true when pushing cloud-relay credentials; add SSE client count badge with Refresh button so users can verify local connections before disabling server relay Co-Authored-By: Claude Sonnet 4.6 --- rig-bridge/core/config.js | 1 + rig-bridge/core/server.js | 9 ++- rig-bridge/core/state.js | 5 ++ rig-bridge/plugins/wsjtx-relay.js | 92 ++++++++++++++----------- src/components/SettingsPanel.jsx | 110 ++++++++++++++++++++++++++++++ 5 files changed, 177 insertions(+), 40 deletions(-) diff --git a/rig-bridge/core/config.js b/rig-bridge/core/config.js index e2c994d2..5977c598 100644 --- a/rig-bridge/core/config.js +++ b/rig-bridge/core/config.js @@ -103,6 +103,7 @@ const DEFAULT_CONFIG = { }, wsjtxRelay: { enabled: false, + relayToServer: false, // false = SSE-only (local/LAN); true = also POST decodes to OHC server (cloud relay) url: '', // OpenHamClock server URL (e.g. https://openhamclock.com) key: '', // Relay authentication key session: '', // Browser session ID for per-user isolation diff --git a/rig-bridge/core/server.js b/rig-bridge/core/server.js index 69868ef4..b4d8f28f 100644 --- a/rig-bridge/core/server.js +++ b/rig-bridge/core/server.js @@ -22,7 +22,7 @@ const express = require('express'); const cors = require('cors'); const { getSerialPort, listPorts } = require('./serial-utils'); -const { state, addSseClient, removeSseClient, getDecodeRingBuffer } = require('./state'); +const { state, addSseClient, removeSseClient, getDecodeRingBuffer, getSseClientCount } = require('./state'); const { config, saveConfig, CONFIG_PATH } = require('./config'); // ─── Security helpers ───────────────────────────────────────────────────── @@ -2009,6 +2009,13 @@ function createServer(registry, version) { }); // Diagnostic endpoint — no auth required, designed for troubleshooting + app.get('/api/status', (req, res) => { + res.json({ + sseClients: getSseClientCount(), + uptime: Math.floor(process.uptime()), + }); + }); + app.get('/health', (req, res) => { // Collect integration plugin status const integrations = {}; diff --git a/rig-bridge/core/state.js b/rig-bridge/core/state.js index d7768282..844c2316 100644 --- a/rig-bridge/core/state.js +++ b/rig-bridge/core/state.js @@ -65,12 +65,17 @@ function removeSseClient(id) { sseClients = sseClients.filter((c) => c.id !== id); } +function getSseClientCount() { + return sseClients.length; +} + module.exports = { state, broadcast, updateState, addSseClient, removeSseClient, + getSseClientCount, onStateChange, removeStateChangeListener, addToDecodeRingBuffer, diff --git a/rig-bridge/plugins/wsjtx-relay.js b/rig-bridge/plugins/wsjtx-relay.js index 020bbec3..b6577367 100644 --- a/rig-bridge/plugins/wsjtx-relay.js +++ b/rig-bridge/plugins/wsjtx-relay.js @@ -151,6 +151,8 @@ const descriptor = { let totalDecodes = 0; let totalRelayed = 0; let serverReachable = false; + // Resolved at connect() time: true only when relayToServer AND url/key/session are all set + let willRelay = false; // Track the remote WSJT-X address for bidirectional communication let remoteAddress = null; @@ -286,23 +288,28 @@ const descriptor = { } function connect() { - if (!cfg.url || !cfg.key || !cfg.session) { - console.error('[WsjtxRelay] Cannot start: url, key, and session are required'); - return; + // Determine whether server relay is active for this session. + // relayToServer requires url + key + session to all be set. + willRelay = !!(cfg.relayToServer && cfg.url && cfg.key && cfg.session); + + if (cfg.relayToServer && !willRelay) { + console.warn('[WsjtxRelay] relayToServer=true but url/key/session incomplete — running in SSE-only mode'); } - // Validate relay URL — protocol only; host restrictions are unnecessary because - // the relay authenticates to the target via key + session, and the config API - // is protected by the rig-bridge API token. - try { - const parsed = new URL(cfg.url); - if (!['http:', 'https:'].includes(parsed.protocol)) { - console.error(`[WsjtxRelay] Blocked: only http/https URLs allowed (got ${parsed.protocol})`); - return; + if (willRelay) { + // Validate relay URL — protocol only; host restrictions are unnecessary because + // the relay authenticates to the target via key + session, and the config API + // is protected by the rig-bridge API token. + try { + const parsed = new URL(cfg.url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + console.error(`[WsjtxRelay] Blocked: only http/https URLs allowed (got ${parsed.protocol})`); + willRelay = false; + } + } catch (e) { + console.error(`[WsjtxRelay] Invalid relay URL: ${e.message}`); + willRelay = false; } - } catch (e) { - console.error(`[WsjtxRelay] Invalid relay URL: ${e.message}`); - return; } const udpPort = cfg.udpPort || 2237; @@ -322,7 +329,7 @@ const descriptor = { if (msg.type === WSJTX_MSG.STATUS && bus) bus.emit('status', { source: 'wsjtx-relay', ...msg }); if (msg.type === WSJTX_MSG.QSO_LOGGED && bus) bus.emit('qso', { source: 'wsjtx-relay', ...msg }); if (msg.type !== WSJTX_MSG.REPLAY) { - messageQueue.push(msg); + if (willRelay) messageQueue.push(msg); if (cfg.verbose && msg.type === WSJTX_MSG.DECODE) { const snr = msg.snr != null ? (msg.snr >= 0 ? `+${msg.snr}` : msg.snr) : '?'; console.log( @@ -359,30 +366,34 @@ const descriptor = { } } - scheduleBatch(); - - // Initial health check then heartbeat - const healthUrl = `${serverUrl}/api/health`; - makeRequest(healthUrl, 'GET', null, {}, (err, statusCode) => { - if (!err && statusCode === 200) { - console.log(`[WsjtxRelay] Server reachable (${serverUrl})`); - } else if (err) { - console.error(`[WsjtxRelay] Cannot reach server: ${err.message}`); - } - sendHeartbeat(); - }); - - heartbeatInterval = setInterval(sendHeartbeat, 30000); + if (willRelay) { + scheduleBatch(); - healthInterval = setInterval(() => { - const checkUrl = `${serverUrl}/api/wsjtx`; - makeRequest(checkUrl, 'GET', null, {}, (err, statusCode) => { - if (!err && statusCode === 200 && consecutiveErrors > 0) { - console.log('[WsjtxRelay] Server connection restored'); - consecutiveErrors = 0; + // Initial health check then heartbeat + const healthUrl = `${serverUrl}/api/health`; + makeRequest(healthUrl, 'GET', null, {}, (err, statusCode) => { + if (!err && statusCode === 200) { + console.log(`[WsjtxRelay] Server reachable (${serverUrl})`); + } else if (err) { + console.error(`[WsjtxRelay] Cannot reach server: ${err.message}`); } + sendHeartbeat(); }); - }, 60000); + + heartbeatInterval = setInterval(sendHeartbeat, 30000); + + healthInterval = setInterval(() => { + const checkUrl = `${serverUrl}/api/wsjtx`; + makeRequest(checkUrl, 'GET', null, {}, (err, statusCode) => { + if (!err && statusCode === 200 && consecutiveErrors > 0) { + console.log('[WsjtxRelay] Server connection restored'); + consecutiveErrors = 0; + } + }); + }, 60000); + } else { + console.log('[WsjtxRelay] SSE-only mode — decodes flow via /stream, no OHC server relay'); + } }); // SECURITY: Bind to localhost by default to prevent external UDP packet injection. @@ -423,19 +434,22 @@ const descriptor = { socket = null; } _currentInstance = null; - console.log(`[WsjtxRelay] Stopped (session: ${totalDecodes} decodes, ${totalRelayed} relayed)`); + console.log( + `[WsjtxRelay] Stopped (${totalDecodes} decode(s)${willRelay ? `, ${totalRelayed} relayed to server` : ', SSE-only mode'})`, + ); } function getStatus() { return { - enabled: !!(cfg.url && cfg.key && cfg.session), + enabled: cfg.enabled, + relayToServer: willRelay, running: socket !== null, serverReachable, decodeCount: totalDecodes, relayCount: totalRelayed, consecutiveErrors, udpPort: cfg.udpPort || 2237, - serverUrl, + serverUrl: willRelay ? serverUrl : null, multicast: mcEnabled, multicastGroup: mcEnabled ? mcGroup : null, }; diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 1edba5d8..f9a790ba 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -90,6 +90,8 @@ export const SettingsPanel = ({ const [wsjtxMulticastAddress, setWsjtxMulticastAddress] = useState( config?.wsjtxRelayMulticast.address || '224.0.0.1', ); + const [wsjtxRelayToServer, setWsjtxRelayToServer] = useState(false); + const [sseClientCount, setSseClientCount] = useState(null); // Local-only integration flags const [n3fjpEnabled, setN3fjpEnabled] = useState(() => { @@ -477,6 +479,7 @@ export const SettingsPanel = ({ key: relayKey, session: wsjtxSessionId || '', enabled: true, + relayToServer: true, }, }), }); @@ -485,6 +488,7 @@ export const SettingsPanel = ({ setWsjtxRelayMsg(t('station.settings.rigControl.wsjtxRelay.status.error.push')); return; } + setWsjtxRelayToServer(true); setWsjtxRelayStatus('ok'); setWsjtxRelayMsg(t('station.settings.rigControl.wsjtxRelay.status.ok')); } catch { @@ -493,6 +497,37 @@ export const SettingsPanel = ({ } }; + const handleToggleRelayToServer = async (enabled) => { + const rigBridgeUrl = `${rigHost.replace(/\/$/, '')}:${rigPort}`; + if (!rigHost || !rigPort) return; + try { + const headers = { 'Content-Type': 'application/json' }; + if (rigApiToken) headers['X-RigBridge-Token'] = rigApiToken; + const res = await fetch(`${rigBridgeUrl}/api/config`, { + method: 'POST', + headers, + body: JSON.stringify({ wsjtxRelay: { relayToServer: enabled } }), + }); + if (res.ok) setWsjtxRelayToServer(enabled); + } catch { + // connection error — user can see via rig-bridge status + } + }; + + const fetchSseClientCount = async () => { + const rigBridgeUrl = `${rigHost.replace(/\/$/, '')}:${rigPort}`; + if (!rigHost || !rigPort) return; + try { + const res = await fetch(`${rigBridgeUrl}/api/status`); + if (res.ok) { + const data = await res.json(); + setSseClientCount(data.sseClients ?? 0); + } + } catch { + setSseClientCount(null); + } + }; + const handleSave = () => { persistCurrentSettings(); onClose(); @@ -1565,6 +1600,81 @@ export const SettingsPanel = ({ {wsjtxRelayMsg} )} + + {/* Relay-to-server toggle */} +
+ +
+ {wsjtxRelayToServer + ? 'ON — rig-bridge batches decodes and POSTs them to the OHC server (required for cloud relay mode).' + : 'OFF — decodes flow via SSE /stream only. No server traffic. Use this for local/LAN connections.'} +
+
+ + {/* SSE client count */} +
+ + SSE clients:{' '} + 0 ? 'var(--accent-green, #4ade80)' : 'var(--text-muted)', + fontWeight: '600', + }} + > + {sseClientCount ?? '—'} + + + +
)} From a0afceba6bc7433087910f9d5b5ae6faa15d6fe8 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 28 Mar 2026 20:01:10 +0100 Subject: [PATCH 08/14] Move relay-to-server mode switch to rig-bridge UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery mode toggle belongs on the source side (rig-bridge) not in the OHC settings panel. rig-bridge UI (server.js): - Replace the flat wsjtx opts section with a two-option delivery mode selector: "SSE only" (local/LAN) and "Relay to OHC server" (cloud) - SSE mode shows only UDP port and multicast options — server fields (URL, relay key, session ID, batch interval) are hidden in a separate wsjtxServerOpts div that only appears in relay mode - populateIntegrations() reads relayToServer flag to set the radio - toggleWsjtxMode() shows/hides the server-specific block - saveIntegrations() writes relayToServer from the selected radio OHC SettingsPanel: - Remove wsjtxRelayToServer state, handleToggleRelayToServer handler, fetchSseClientCount handler, toggle UI block and SSE count badge — all moved to rig-bridge UI - relayToServer:true is still sent when the user clicks Configure Cloud Relay, since that action explicitly enables server delivery Co-Authored-By: Claude Sonnet 4.6 --- rig-bridge/core/server.js | 80 +++++++++++++++------- src/components/SettingsPanel.jsx | 110 ------------------------------- 2 files changed, 55 insertions(+), 135 deletions(-) diff --git a/rig-bridge/core/server.js b/rig-bridge/core/server.js index b4d8f28f..fc6345f3 100644 --- a/rig-bridge/core/server.js +++ b/rig-bridge/core/server.js @@ -758,46 +758,40 @@ function buildSetupHtml(version, firstRunToken = null) {
📡 WSJT-X Relay

- Captures WSJT-X UDP packets on your machine and forwards decoded messages - to an OpenHamClock server in real time. In WSJT-X: Settings → Reporting → UDP Server: 127.0.0.1 port 2237. + Captures WSJT-X UDP packets on your machine and delivers decoded messages + to OpenHamClock. In WSJT-X: Settings → Reporting → UDP Server: 127.0.0.1 port 2237.

- Enable WSJT-X Relay + Enable WSJT-X
)} From 312efa1e384e0a5bcdab2339e956a0d7f418c9b9 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 28 Mar 2026 20:08:51 +0100 Subject: [PATCH 09/14] docs(rig-bridge): update README for SSE delivery mode, new API endpoint and lib files - Rewrite WSJT-X Relay section: document SSE-only (default) vs relay-to-server delivery modes, three setup options, updated config reference with relayToServer - Add GET /api/status endpoint to API reference table ({sseClients, uptime}) - Add lib/aprs-parser.js and lib/wsjtx-protocol.js to project structure tree - Update pluginBus event docs to include status, qso and aprs events - Add SSE ring-buffer replay note to Digital Mode Plugins section Co-Authored-By: Claude Sonnet 4.6 --- rig-bridge/README.md | 107 +++++++++++++++++++++++++------------------ 1 file changed, 62 insertions(+), 45 deletions(-) diff --git a/rig-bridge/README.md b/rig-bridge/README.md index 6c5f6339..24e38ad2 100644 --- a/rig-bridge/README.md +++ b/rig-bridge/README.md @@ -233,19 +233,34 @@ You should see: ## WSJT-X Relay -The WSJT-X Relay is an **integration plugin** (not a radio plugin) that listens for WSJT-X UDP packets on the local machine and forwards decoded messages to an OpenHamClock server in real-time. This lets OpenHamClock display your FT8/FT4 decodes as DX spots without any manual intervention. +The WSJT-X Relay is an **integration plugin** (not a radio plugin) that listens for WSJT-X UDP packets on the local machine and delivers decoded messages to OpenHamClock in real-time. It supports two delivery modes: + +| Mode | How it works | Use case | +| -------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------- | +| **📶 SSE only** (default) | Decodes flow over the existing `/stream` SSE connection to the browser — no server involved | Local install, LAN, self-hosted | +| **☁️ Relay to OHC server** | Batches decodes and POSTs them to an OpenHamClock server; browser polls the server | Cloud relay / remote access | + +Switch between modes in **http://localhost:5555 → Integrations → WSJT-X → Delivery mode**. In SSE-only mode no server credentials are needed. > **⚠️ Startup order matters when running on the same machine as OpenHamClock** > > Both rig-bridge and a locally-running OpenHamClock instance listen on the same UDP port (default **2237**) for WSJT-X packets. Only one process can hold the port at a time. > -> **Always start rig-bridge first.** It will bind UDP 2237 and relay decoded messages to OHC via HTTP. If OpenHamClock starts first and claims the port, rig-bridge will log `UDP port already in use` and receive nothing — the relay will be silently inactive. +> **Always start rig-bridge first.** It will bind UDP 2237. If OpenHamClock starts first and claims the port, rig-bridge will log `UDP port already in use` and receive nothing. > > If you see that warning in the rig-bridge console log, stop OpenHamClock, restart rig-bridge, then start OpenHamClock again. -### Getting your relay credentials +### SSE-only mode (local/LAN) + +This is the default. Enable the plugin and set your UDP port — that's it. Decodes, status updates and logged QSOs flow directly to any browser connected to `/stream`. No relay key, no session ID, no server URL required. + +In WSJT-X: **File → Settings → Reporting → UDP Server → `127.0.0.1:2237`** + +When the browser connects to `/stream` it immediately receives a `plugin-init` message containing the list of running plugins and a replay of the last 100 decodes, so the panel is populated instantly without waiting for the next FT8 cycle. + +### Relay-to-server mode (cloud) -The relay requires two values from your OpenHamClock server: a **relay key** and a **session ID**. There are two ways to set them up: +Enable relay mode when using a cloud-hosted OpenHamClock or any setup where the browser cannot reach rig-bridge directly. #### Option A — Auto-configure from OpenHamClock (recommended) @@ -253,24 +268,24 @@ The relay requires two values from your OpenHamClock server: a **relay key** and 2. Make sure Rig Control is enabled and the rig-bridge Host URL/Port are filled in 3. Scroll to the **WSJT-X Relay** sub-section 4. Note your **Session ID** (copy it with the 📋 button) -5. Click **Configure Relay on Rig Bridge** — OpenHamClock fetches the relay key from its own server and pushes both credentials directly to rig-bridge in one step +5. Click **Configure Relay on Rig Bridge** — OpenHamClock fetches the relay key from its own server and pushes credentials + enables relay mode directly to rig-bridge in one step -#### Option B — Fetch from rig-bridge setup UI +#### Option B — Configure from the rig-bridge setup UI 1. Open **http://localhost:5555** → **Integrations** tab -2. Enable the WSJT-X Relay checkbox and enter the OpenHamClock Server URL -3. Click **🔗 Fetch credentials** — rig-bridge retrieves the relay key automatically -4. Copy your **Session ID** from OpenHamClock → Settings → Station → Rig Control → WSJT-X Relay and paste it into the Session ID field -5. Click **Save Integrations** +2. Enable the WSJT-X checkbox +3. Select **☁️ Relay to OHC server** +4. Enter the OpenHamClock Server URL and click **🔗 Fetch credentials** +5. Copy your **Session ID** from OpenHamClock → Settings → Station → Rig Control → WSJT-X Relay and paste it into the Session ID field +6. Click **Save Integrations** #### Option C — Manual config -Edit `rig-bridge-config.json` directly: - ```json { "wsjtxRelay": { "enabled": true, + "relayToServer": true, "url": "https://openhamclock.com", "key": "your-relay-key", "session": "your-session-id", @@ -286,25 +301,19 @@ Edit `rig-bridge-config.json` directly: ### Config reference -| Field | Description | Default | -| -------------------- | ------------------------------------------------------- | -------------------------- | -| `enabled` | Activate the relay on startup | `false` | -| `url` | OpenHamClock server URL | `https://openhamclock.com` | -| `key` | Relay authentication key (from your OHC server) | — | -| `session` | Browser session ID for per-user isolation | — | -| `udpPort` | UDP port WSJT-X is sending to | `2237` | -| `batchInterval` | How often decoded messages are sent (ms) | `2000` | -| `verbose` | Log every decoded message to the console | `false` | -| `multicast` | Join a UDP multicast group to receive WSJT-X packets | `false` | -| `multicastGroup` | Multicast group IP address to join | `224.0.0.1` | -| `multicastInterface` | Local NIC IP for multi-homed systems; `""` = OS default | `""` | - -### In WSJT-X - -Make sure WSJT-X is configured to send UDP packets to `localhost` on the same port as `udpPort` (default `2237`): -**File → Settings → Reporting → UDP Server → `127.0.0.1:2237`** - -The relay runs alongside your radio plugin — you can use direct USB or TCI at the same time. +| Field | Description | Default | +| -------------------- | --------------------------------------------------------------- | ----------- | +| `enabled` | Activate the plugin on startup | `false` | +| `relayToServer` | `true` = also POST batches to OHC server; `false` = SSE-only | `false` | +| `url` | OpenHamClock server URL (relay mode only) | — | +| `key` | Relay authentication key from your OHC server (relay mode only) | — | +| `session` | Browser session ID for per-user isolation (relay mode only) | — | +| `udpPort` | UDP port WSJT-X is sending to | `2237` | +| `batchInterval` | How often batches are POSTed to the server in relay mode (ms) | `2000` | +| `verbose` | Log every decoded message to the console | `false` | +| `multicast` | Join a UDP multicast group to receive WSJT-X packets | `false` | +| `multicastGroup` | Multicast group IP address to join | `224.0.0.1` | +| `multicastInterface` | Local NIC IP for multi-homed systems; `""` = OS default | `""` | ### Multicast Mode @@ -313,7 +322,7 @@ By default the relay uses **unicast** — WSJT-X sends packets directly to `127. If you want multiple applications on the same machine or LAN to receive WSJT-X packets simultaneously, enable multicast: 1. In WSJT-X: **File → Settings → Reporting → UDP Server** — set the address to `224.0.0.1` -2. In `rig-bridge-config.json` (or via the setup UI at `http://localhost:5555`): +2. In the rig-bridge setup UI, enable **Enable Multicast** and set the group address, or in `rig-bridge-config.json`: ```json { @@ -434,6 +443,8 @@ Open the rig-bridge setup UI at http://localhost:5555 → **Plugins** tab to ena All digital mode plugins are **bidirectional** — OHC can send replies, halt TX, set free text, and highlight callsigns in the decode window. +Decodes are delivered to the browser over the `/stream` SSE connection in real-time. When a new browser tab connects, the last 100 decodes are replayed immediately via the `plugin-init` message so the panel is populated without waiting for the next FT8/FT4 cycle. No server round-trip is needed in local or LAN mode. + In your digital mode software, set UDP Server to `127.0.0.1` and the port shown above. ### APRS TNC Plugin @@ -565,17 +576,18 @@ Executables are output to the `dist/` folder. Fully backward compatible with the original rig-daemon API: -| Method | Endpoint | Description | -| ------ | ------------- | ----------------------------------------- | -| GET | `/status` | Current freq, mode, PTT, connected status | -| GET | `/stream` | SSE stream of real-time updates | -| POST | `/freq` | Set frequency: `{ "freq": 14074000 }` | -| POST | `/mode` | Set mode: `{ "mode": "USB" }` | -| POST | `/ptt` | Set PTT: `{ "ptt": true }` | -| GET | `/api/ports` | List available serial ports | -| GET | `/api/config` | Get current configuration | -| POST | `/api/config` | Update configuration & reconnect | -| POST | `/api/test` | Test a serial port connection | +| Method | Endpoint | Description | +| ------ | ------------- | ------------------------------------------------------ | +| GET | `/status` | Current freq, mode, PTT, connected status | +| GET | `/stream` | SSE stream of real-time updates + plugin decode events | +| POST | `/freq` | Set frequency: `{ "freq": 14074000 }` | +| POST | `/mode` | Set mode: `{ "mode": "USB" }` | +| POST | `/ptt` | Set PTT: `{ "ptt": true }` | +| GET | `/api/ports` | List available serial ports | +| GET | `/api/config` | Get current configuration | +| POST | `/api/config` | Update configuration & reconnect | +| POST | `/api/test` | Test a serial port connection | +| GET | `/api/status` | Lightweight health check: `{ sseClients, uptime }` | --- @@ -594,7 +606,9 @@ rig-bridge/ │ ├── lib/ │ ├── message-log.js # Persistent message log (WSJT-X, JS8Call, etc.) -│ └── kiss-protocol.js # KISS frame encode/decode for APRS TNC +│ ├── kiss-protocol.js # KISS frame encode/decode for APRS TNC +│ ├── wsjtx-protocol.js # WSJT-X UDP binary protocol parser/encoder +│ └── aprs-parser.js # APRS packet decoder (position, weather, objects, etc.) │ └── plugins/ ├── usb/ @@ -638,7 +652,10 @@ module.exports = { // onStateChange(fn) — subscribe to any rig state change (immediate callback) // removeStateChangeListener(fn) — unsubscribe // pluginBus — EventEmitter for inter-plugin events - // emits: 'decode' (WSJT-X), 'aprs' (APRS packets) + // emits: 'decode' (WSJT-X/MSHV/JTDX/JS8Call decodes) + // 'status' (plugin connection status changes) + // 'qso' (logged QSO records) + // 'aprs' (parsed APRS packets from TNC) // messageLog — persistent log for decoded messages const { updateState, state, onStateChange, removeStateChangeListener, pluginBus } = services; From bc62430201dc62cb36929cd7f1bacd5547f5c18c Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Mon, 30 Mar 2026 18:48:15 +0200 Subject: [PATCH 10/14] feat(rig-bridge): add HTTPS/TLS support with self-signed certificate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables rig-bridge to serve over HTTPS so browsers running OpenHamClock on an HTTPS origin can connect without mixed-content errors. - New core/tls.js module: generates RSA-2048 self-signed cert (10-year validity, SAN for localhost/127.0.0.1) using node-forge; stores key+cert in ~/.config/openhamclock/certs/; exposes ensureCerts(), loadCreds(), getCertInfo() (fingerprint, expiry, days remaining) - core/config.js: bump CONFIG_VERSION 7→8, add tls.{enabled,certGenerated} defaults, export CONFIG_DIR - core/server.js: startServer() made async, switches to https.createServer() when tls.enabled, falls back to HTTP on failure; CORS list auto-includes https:// variants when TLS is active; POST /api/config made async and handles tls section with cert generation and forceRegen support; three new routes: GET /api/tls/status, GET /api/tls/cert (download), POST /api/tls/install (OS cert install with manual fallback) - Setup UI: new 🔒 Security tab with HTTPS toggle, certificate fingerprint/ expiry info, download button, OS-detected install instructions, and one-click Install Certificate button with graceful fallback to manual command on permission error - rig-bridge.js: wrapped startup in async IIFE so startServer() is awaited before connectActive()/connectIntegrations() - Add node-forge ^1.4.0 dependency (pure JS, pkg-compatible) - Update rig-bridge-config.example.json and README with HTTPS setup docs Co-Authored-By: Claude Sonnet 4.6 --- rig-bridge/README.md | 81 +++++ rig-bridge/core/config.js | 11 +- rig-bridge/core/server.js | 386 ++++++++++++++++++++-- rig-bridge/core/tls.js | 162 +++++++++ rig-bridge/package-lock.json | 10 + rig-bridge/package.json | 1 + rig-bridge/rig-bridge-config.example.json | 4 + rig-bridge/rig-bridge.js | 22 +- 8 files changed, 641 insertions(+), 36 deletions(-) create mode 100644 rig-bridge/core/tls.js diff --git a/rig-bridge/README.md b/rig-bridge/README.md index 24e38ad2..9b3e2c59 100644 --- a/rig-bridge/README.md +++ b/rig-bridge/README.md @@ -528,6 +528,87 @@ On first run, if no config exists at the external path, rig-bridge creates one f --- +## HTTPS / TLS + +### Why HTTPS? + +Browsers block **mixed-content** requests: if OpenHamClock is served over `https://` (e.g. on openhamclock.com or a self-hosted instance with SSL), the browser will refuse to connect to rig-bridge over plain `http://`. Enabling HTTPS on rig-bridge solves this. + +### Enabling HTTPS + +1. Open the rig-bridge setup UI at **http://localhost:5555** +2. Click the **🔒 Security** tab +3. Toggle **Enable HTTPS** — rig-bridge will generate a self-signed certificate automatically +4. **Restart rig-bridge** +5. Open **https://localhost:5555** (note: `https://`) + +### Installing the Certificate + +Because the certificate is self-signed, your browser will show a warning until you install it as trusted. The Security tab provides one-click installation and manual fallback instructions. + +#### macOS + +Click **Install Certificate** in the Security tab. If it asks for your password, enter your macOS login password. + +Manual fallback: + +```bash +sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain \ + ~/.config/openhamclock/certs/rig-bridge.crt +``` + +#### Windows + +Click **Install Certificate** in the Security tab (runs `certutil` automatically). + +Manual fallback — download the certificate and double-click it, then: + +- **Install Certificate → Local Machine → Trusted Root Certification Authorities** + +Or via command line (run as Administrator): + +```cmd +certutil -addstore -f ROOT %APPDATA%\openhamclock\certs\rig-bridge.crt +``` + +#### Linux + +Download the certificate and run: + +```bash +sudo cp ~/Downloads/rig-bridge.crt /usr/local/share/ca-certificates/ +sudo update-ca-certificates +``` + +Then import the certificate into your browser's certificate store: + +- Chrome/Chromium: **Settings → Privacy & Security → Manage Certificates → Authorities → Import** +- Firefox: **Settings → Privacy & Security → View Certificates → Authorities → Import** + +### Certificate Location + +| OS | Certificate Path | +| --------------- | --------------------------------------------- | +| **macOS/Linux** | `~/.config/openhamclock/certs/rig-bridge.crt` | +| **Windows** | `%APPDATA%\openhamclock\certs\rig-bridge.crt` | + +The private key (`rig-bridge.key`) is stored alongside the certificate with permissions `0600` (owner-read only). + +### Fallback to HTTP + +If you need to revert to plain HTTP, open the Security tab and uncheck **Enable HTTPS**, then restart rig-bridge. HTTP mode is the default — no changes are needed for existing setups. + +If rig-bridge fails to start HTTPS (e.g. cert generation error), it automatically falls back to HTTP and logs an error. + +### OpenHamClock Settings when Using HTTPS + +After enabling HTTPS, update the rig-bridge Host URL in OpenHamClock: + +- Settings → Rig Bridge → Host: `https://localhost` (or `https://your-machine-ip`) + +--- + ## Building Executables To create standalone executables (no Node.js required): diff --git a/rig-bridge/core/config.js b/rig-bridge/core/config.js index 5977c598..0b95f827 100644 --- a/rig-bridge/core/config.js +++ b/rig-bridge/core/config.js @@ -55,7 +55,7 @@ function resolveConfigPath() { const { dir: CONFIG_DIR, path: CONFIG_PATH } = resolveConfigPath(); // Increment when DEFAULT_CONFIG structure changes (new keys, renamed keys, etc.) -const CONFIG_VERSION = 7; +const CONFIG_VERSION = 8; const DEFAULT_CONFIG = { configVersion: CONFIG_VERSION, @@ -185,6 +185,11 @@ const DEFAULT_CONFIG = { port: 8080, }, }, + // TLS/HTTPS — enables HTTPS to avoid mixed-content errors when OHC is served over HTTPS + tls: { + enabled: false, // false = plain HTTP (backward-compatible default) + certGenerated: false, // tracks whether a cert has been generated at least once + }, }; let config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); @@ -224,6 +229,7 @@ function loadConfig() { ...(raw.winlink || {}), pat: { ...DEFAULT_CONFIG.winlink.pat, ...((raw.winlink || {}).pat || {}) }, }, + tls: { ...DEFAULT_CONFIG.tls, ...(raw.tls || {}) }, // Coerce logging to boolean in case the stored value is a string logging: raw.logging !== undefined ? !!raw.logging : DEFAULT_CONFIG.logging, }); @@ -245,6 +251,7 @@ function loadConfig() { 'rotator', 'cloudRelay', 'winlink', + 'tls', ]) { if (DEFAULT_CONFIG[section] && raw[section]) { for (const key of Object.keys(DEFAULT_CONFIG[section])) { @@ -297,4 +304,4 @@ function applyCliArgs() { } } -module.exports = { config, loadConfig, saveConfig, applyCliArgs, CONFIG_PATH }; +module.exports = { config, loadConfig, saveConfig, applyCliArgs, CONFIG_PATH, CONFIG_DIR }; diff --git a/rig-bridge/core/server.js b/rig-bridge/core/server.js index fc6345f3..25b1ba1c 100644 --- a/rig-bridge/core/server.js +++ b/rig-bridge/core/server.js @@ -518,6 +518,7 @@ function buildSetupHtml(version, firstRunToken = null) { +
@@ -877,6 +878,56 @@ function buildSetupHtml(version, firstRunToken = null) { + +
+
+
🔒 HTTPS / TLS
+

+ Enable HTTPS so OpenHamClock (served over HTTPS) can connect to rig-bridge + without mixed-content browser errors. A self-signed certificate is generated + automatically. Restart required after toggling. +

+ +
+ + Enable HTTPS +
+

+ When enabled, open + https://localhost:${config.port} + instead of the HTTP URL. +

+
+ + + + +
@@ -1675,6 +1726,170 @@ function buildSetupHtml(version, firstRunToken = null) { connect(); } + // ── TLS / Security tab ──────────────────────────────────────────────── + + async function loadTlsStatus() { + try { + const r = await fetch('/api/tls/status'); + const d = await r.json(); + + const enabledCb = document.getElementById('tlsEnabled'); + const certCard = document.getElementById('tlsCertCard'); + const installCard = document.getElementById('tlsInstallCard'); + + if (enabledCb) enabledCb.checked = !!d.enabled; + + if (d.exists) { + if (certCard) certCard.style.display = 'block'; + if (installCard) installCard.style.display = 'block'; + const infoEl = document.getElementById('tlsCertInfo'); + if (infoEl) { + const expires = d.notAfter ? new Date(d.notAfter).toLocaleDateString() : '—'; + infoEl.innerHTML = + '
Fingerprint' + + '' + (d.fingerprint || '—') + '
' + + '
Expires' + + '' + expires + ' (' + (d.daysLeft ?? '?') + ' days)
'; + } + renderTlsInstallInstructions(); + } else { + if (certCard) certCard.style.display = 'none'; + if (installCard) installCard.style.display = 'none'; + } + } catch (e) { + console.error('Failed to load TLS status:', e.message); + } + } + + function renderTlsInstallInstructions() { + const el = document.getElementById('tlsInstallInstructions'); + const btn = document.getElementById('tlsInstallBtn'); + if (!el) return; + const ua = navigator.userAgent; + let html = ''; + if (/Windows/i.test(ua)) { + html = '

' + + 'Click Install Certificate to run certutil -addstore -f ROOT. ' + + 'If it fails, download the certificate and double-click it, then choose ' + + 'Install Certificate → Local Machine → Trusted Root Certification Authorities.

'; + } else if (/Macintosh|Mac OS/i.test(ua)) { + html = '

' + + 'Click Install Certificate to run security add-trusted-cert. ' + + 'If it asks for your password, enter your macOS login password. ' + + 'If it fails, open Keychain Access, drag the downloaded .crt file into ' + + 'System, then double-click it and set SSL → Always Trust.

'; + } else { + // Linux — no auto-install + if (btn) { btn.disabled = true; btn.style.opacity = '0.4'; } + html = '

' + + 'Download the certificate and run the following commands:

' + + '' + + 'sudo cp ~/Downloads/rig-bridge.crt /usr/local/share/ca-certificates/
' + + 'sudo update-ca-certificates
' + + '

Then import the certificate into your browser certificate store ' + + '(Settings → Privacy & Security → Manage Certificates).

'; + } + el.innerHTML = html; + } + + async function onTlsToggle(enabled) { + try { + const r = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ tls: { enabled } }), + }); + const d = await r.json(); + if (d.success) { + showToast(enabled + ? 'HTTPS enabled — restart rig-bridge to apply' + : 'HTTP mode — restart rig-bridge to apply', 'success'); + await loadTlsStatus(); + } else { + showToast(d.error || 'Failed to update TLS setting', 'error'); + const cb = document.getElementById('tlsEnabled'); + if (cb) cb.checked = !enabled; + } + } catch (e) { + showToast('Failed to save TLS setting: ' + e.message, 'error'); + const cb = document.getElementById('tlsEnabled'); + if (cb) cb.checked = !enabled; + } + } + + function downloadCert() { + const a = document.createElement('a'); + a.href = '/api/tls/cert'; + a.download = 'rig-bridge.crt'; + // Add token as a query param isn't supported by this endpoint — it uses the header. + // Instead trigger via fetch+blob so the auth header is sent. + fetch('/api/tls/cert', { headers: authHeaders() }) + .then((r) => { + if (!r.ok) throw new Error('Cert not available'); + return r.blob(); + }) + .then((blob) => { + const url = URL.createObjectURL(blob); + a.href = url; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }) + .catch((e) => showToast('Download failed: ' + e.message, 'error')); + } + + async function regenCert() { + if (!confirm('Regenerate the self-signed certificate?\\n\\nAny existing browser trust for the old certificate will be lost and you will need to install the new one.')) return; + try { + const r = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ tls: { enabled: true, forceRegen: true } }), + }); + const d = await r.json(); + if (d.success) { + showToast('Certificate regenerated — restart rig-bridge to use the new cert', 'success'); + await loadTlsStatus(); + } else { + showToast(d.error || 'Failed to regenerate certificate', 'error'); + } + } catch (e) { + showToast('Failed to regenerate certificate: ' + e.message, 'error'); + } + } + + async function installCert() { + const btn = document.getElementById('tlsInstallBtn'); + const resultEl = document.getElementById('tlsInstallResult'); + if (btn) btn.disabled = true; + if (resultEl) { resultEl.textContent = 'Running installer…'; resultEl.style.color = '#9ca3af'; } + + try { + const r = await fetch('/api/tls/install', { method: 'POST', headers: authHeaders() }); + const d = await r.json(); + if (d.success) { + if (resultEl) { resultEl.textContent = 'Certificate installed successfully. Restart your browser.'; resultEl.style.color = '#22c55e'; } + } else if (d.manual) { + if (resultEl) { resultEl.textContent = 'Auto-install not available on Linux. Use the commands above.'; resultEl.style.color = '#f59e0b'; } + } else { + const msg = d.command + ? 'Install failed (needs admin). Run manually:\\n' + d.command + : (d.error || 'Install failed'); + if (resultEl) { + resultEl.innerHTML = 'Install failed (needs admin access). Run this command manually:
' + + '' + escHtml(d.command || '') + ''; + resultEl.style.color = '#ef4444'; + } + } + } catch (e) { + if (resultEl) { resultEl.textContent = 'Install request failed: ' + e.message; resultEl.style.color = '#ef4444'; } + } finally { + if (btn) btn.disabled = false; + } + } + doAutoLogin(); @@ -1693,6 +1908,7 @@ function createServer(registry, version) { .filter(Boolean); // Always allow the local setup UI and common OHC origins + const tlsEnabled = !!(config.tls && config.tls.enabled); const defaultOrigins = [ `http://localhost:${config.port}`, `http://127.0.0.1:${config.port}`, @@ -1707,6 +1923,21 @@ function createServer(registry, version) { 'https://openhamclock.com', 'https://www.openhamclock.com', ]; + // When TLS is enabled the setup UI is served over https://, so add HTTPS variants + if (tlsEnabled) { + defaultOrigins.push( + `https://localhost:${config.port}`, + `https://127.0.0.1:${config.port}`, + 'https://localhost:3000', + 'https://localhost:3001', + 'https://127.0.0.1:3000', + 'https://127.0.0.1:3001', + 'https://localhost:8080', + 'https://127.0.0.1:8080', + 'https://localhost:8443', + 'https://127.0.0.1:8443', + ); + } const origins = [...new Set([...defaultOrigins, ...allowedOrigins])]; app.use( @@ -1830,7 +2061,7 @@ function createServer(registry, version) { res.json(safeConfig); }); - app.post('/api/config', requireAuth, cfgLimiter, (req, res) => { + app.post('/api/config', requireAuth, cfgLimiter, async (req, res) => { const newConfig = req.body; if (newConfig.port) config.port = newConfig.port; if (newConfig.radio) { @@ -1904,6 +2135,27 @@ function createServer(registry, version) { } } + // TLS config — handle enable/disable and optional cert regeneration + if (newConfig.tls) { + const forceRegen = !!newConfig.tls.forceRegen; + const tlsPayload = { ...newConfig.tls }; + delete tlsPayload.forceRegen; // transient flag — never persisted + config.tls = { ...(config.tls || {}), ...tlsPayload }; + + if (config.tls.enabled) { + try { + const { ensureCerts } = require('./tls'); + await ensureCerts(forceRegen); + config.tls.certGenerated = true; + } catch (e) { + console.error('[TLS] Certificate generation failed:', e.message); + config.tls.enabled = false; + saveConfig(); + return res.json({ success: false, error: `TLS cert generation failed: ${e.message}` }); + } + } + } + // macOS: tty.* (dial-in) blocks open() — silently upgrade to cu.* (call-out) if (process.platform === 'darwin' && config.radio.serialPort?.startsWith('/dev/tty.')) { config.radio.serialPort = config.radio.serialPort.replace('/dev/tty.', '/dev/cu.'); @@ -1996,6 +2248,61 @@ function createServer(registry, version) { res.json({ success: true }); }); + // ─── API: TLS / HTTPS certificate management ───────────────────────── + // No auth on /status — the Security tab needs this before login succeeds. + app.get('/api/tls/status', (req, res) => { + const { getCertInfo } = require('./tls'); + const info = getCertInfo(); + res.json({ enabled: !!(config.tls && config.tls.enabled), ...info }); + }); + + // Download the PEM certificate — used by the "Download Certificate" button in the UI. + // Token-gated: downloading the cert allows a user to install it as trusted, which + // only makes sense for someone who already has access to the setup UI. + app.get('/api/tls/cert', requireAuth, (req, res) => { + const { CERT_PATH } = require('./tls'); + const fs = require('fs'); + if (!fs.existsSync(CERT_PATH)) { + return res.status(404).json({ error: 'No certificate found. Enable HTTPS first.' }); + } + res.setHeader('Content-Type', 'application/x-pem-file'); + res.setHeader('Content-Disposition', 'attachment; filename="rig-bridge.crt"'); + res.sendFile(CERT_PATH); + }); + + // Attempt OS-level certificate installation. On permission failure the command + // is returned for the user to run manually. Only hardcoded paths are used in the + // exec call — no user input is interpolated — so there is no command injection risk. + app.post('/api/tls/install', requireAuth, (req, res) => { + const { CERT_PATH } = require('./tls'); + const fs = require('fs'); + const { exec } = require('child_process'); + if (!fs.existsSync(CERT_PATH)) { + return res.status(404).json({ error: 'No certificate found. Enable HTTPS first.' }); + } + let cmd; + if (process.platform === 'darwin') { + cmd = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${CERT_PATH}"`; + } else if (process.platform === 'win32') { + cmd = `certutil -addstore -f ROOT "${CERT_PATH}"`; + } else { + // Linux: many distros, provide manual instructions only + return res.json({ + success: false, + manual: true, + platform: 'linux', + certPath: CERT_PATH, + }); + } + exec(cmd, (err) => { + if (err) { + // Likely a permission error — return the command so the user can run it with sudo + return res.json({ success: false, error: err.message, command: cmd, certPath: CERT_PATH }); + } + res.json({ success: true }); + }); + }); + // ─── OHC-compatible API ─── // ─── Message Log API ───────────────────────────────────────────────── app.get('/api/messages', (req, res) => { @@ -2157,7 +2464,7 @@ function createServer(registry, version) { return app; } -function startServer(port, registry, version) { +async function startServer(port, registry, version) { const app = createServer(registry, version); // SECURITY: Bind to localhost by default. Set bindAddress to '0.0.0.0' in @@ -2165,32 +2472,59 @@ function startServer(port, registry, version) { // browser on a desktop). const bindAddress = config.bindAddress || '127.0.0.1'; - const server = app.listen(port, bindAddress, () => { - const versionLabel = `v${version}`.padEnd(8); - console.log(''); - console.log(' ╔══════════════════════════════════════════════╗'); - console.log(` ║ 📻 OpenHamClock Rig Bridge ${versionLabel} ║`); - console.log(' ╠══════════════════════════════════════════════╣'); - console.log(` ║ Setup UI: http://localhost:${port} ║`); - console.log(` ║ Radio: ${(config.radio.type || 'none').padEnd(30)}║`); - if (bindAddress !== '127.0.0.1') { - console.log(` ║ ⚠ Bound to ${bindAddress.padEnd(33)}║`); - } - console.log(' ╚══════════════════════════════════════════════╝'); - console.log(` Config: ${CONFIG_PATH}`); - console.log(''); - }); + let server; + let protocol = 'http'; - server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - console.error(`\n[Server] ERROR: Port ${port} is already in use.`); - console.error(` Another instance of Rig Bridge might be running.`); - console.error(` Please close it or use --port to start another one.\n`); - process.exit(1); - } else { - console.error(`\n[Server] Unexpected error: ${err.message}\n`); - process.exit(1); + if (config.tls && config.tls.enabled) { + const { ensureCerts, loadCreds } = require('./tls'); + try { + await ensureCerts(); + const { key, cert } = loadCreds(); + const https = require('https'); + server = https.createServer({ key, cert }, app); + protocol = 'https'; + console.log('[TLS] Starting HTTPS server with self-signed certificate'); + } catch (e) { + console.error(`[TLS] Failed to start HTTPS (${e.message}) — falling back to HTTP`); + server = require('http').createServer(app); } + } else { + server = require('http').createServer(app); + } + + return new Promise((resolve, reject) => { + server.listen(port, bindAddress, () => { + const versionLabel = `v${version}`.padEnd(8); + const uiUrl = `${protocol}://localhost:${port}`; + console.log(''); + console.log(' ╔══════════════════════════════════════════════╗'); + console.log(` ║ 📻 OpenHamClock Rig Bridge ${versionLabel} ║`); + console.log(' ╠══════════════════════════════════════════════╣'); + console.log(` ║ Setup UI: ${uiUrl.padEnd(32)}║`); + if (protocol === 'https') { + console.log(' ║ 🔒 HTTPS enabled — install cert to trust it ║'); + } + console.log(` ║ Radio: ${(config.radio.type || 'none').padEnd(30)}║`); + if (bindAddress !== '127.0.0.1') { + console.log(` ║ ⚠ Bound to ${bindAddress.padEnd(33)}║`); + } + console.log(' ╚══════════════════════════════════════════════╝'); + console.log(` Config: ${CONFIG_PATH}`); + console.log(''); + resolve(); + }); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`\n[Server] ERROR: Port ${port} is already in use.`); + console.error(` Another instance of Rig Bridge might be running.`); + console.error(` Please close it or use --port to start another one.\n`); + process.exit(1); + } else { + console.error(`\n[Server] Unexpected error: ${err.message}\n`); + reject(err); + } + }); }); } diff --git a/rig-bridge/core/tls.js b/rig-bridge/core/tls.js new file mode 100644 index 00000000..6b033958 --- /dev/null +++ b/rig-bridge/core/tls.js @@ -0,0 +1,162 @@ +'use strict'; +/** + * tls.js — Self-signed certificate generation and management + * + * Generates a self-signed RSA-2048 certificate for rig-bridge's HTTPS server. + * Certificates are stored in ~/.config/openhamclock/certs/ (or the platform + * equivalent) so they survive rig-bridge updates. + * + * This module has no dependencies on config.js — it computes the cert directory + * independently using the same platform logic — so there is no circular import. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const forge = require('node-forge'); + +// ── Cert storage path ──────────────────────────────────────────────────────── +// Mirrors config.js's externalDir logic but appends /certs +function resolveCertDir() { + if (process.platform === 'win32') { + return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'openhamclock', 'certs'); + } + return path.join(os.homedir(), '.config', 'openhamclock', 'certs'); +} + +const CERT_DIR = resolveCertDir(); +const KEY_PATH = path.join(CERT_DIR, 'rig-bridge.key'); +const CERT_PATH = path.join(CERT_DIR, 'rig-bridge.crt'); + +// ── Certificate generation ─────────────────────────────────────────────────── + +/** + * Generate a new RSA-2048 self-signed certificate. + * @returns {Promise<{ privateKeyPem: string, certPem: string }>} + */ +function generateCert() { + return new Promise((resolve, reject) => { + forge.pki.rsa.generateKeyPair({ bits: 2048, workers: -1 }, (err, keyPair) => { + if (err) return reject(err); + + const cert = forge.pki.createCertificate(); + cert.publicKey = keyPair.publicKey; + cert.serialNumber = '01'; + + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10); + + const attrs = [{ name: 'commonName', value: 'localhost' }]; + cert.setSubject(attrs); + cert.setIssuer(attrs); // self-signed + + cert.setExtensions([ + { name: 'basicConstraints', cA: false }, + { + name: 'keyUsage', + digitalSignature: true, + keyEncipherment: true, + }, + { + name: 'extKeyUsage', + serverAuth: true, + }, + { + name: 'subjectAltName', + altNames: [ + { type: 2, value: 'localhost' }, // DNS + { type: 7, ip: '127.0.0.1' }, // IP + ], + }, + ]); + + cert.sign(keyPair.privateKey, forge.md.sha256.create()); + + resolve({ + privateKeyPem: forge.pki.privateKeyToPem(keyPair.privateKey), + certPem: forge.pki.certificateToPem(cert), + }); + }); + }); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * Ensure certificate and key files exist on disk. + * Generates them if missing or if forceRegen is true. + * + * @param {boolean} [forceRegen=false] + * @returns {Promise<{ keyPath: string, certPath: string, generated: boolean }>} + */ +async function ensureCerts(forceRegen = false) { + const exists = fs.existsSync(KEY_PATH) && fs.existsSync(CERT_PATH); + + if (exists && !forceRegen) { + return { keyPath: KEY_PATH, certPath: CERT_PATH, generated: false }; + } + + console.log('[TLS] Generating self-signed certificate (RSA-2048, 10-year validity)…'); + + const { privateKeyPem, certPem } = await generateCert(); + + if (!fs.existsSync(CERT_DIR)) { + fs.mkdirSync(CERT_DIR, { recursive: true }); + } + + fs.writeFileSync(KEY_PATH, privateKeyPem, { mode: 0o600 }); + fs.writeFileSync(CERT_PATH, certPem, { mode: 0o644 }); + + console.log(`[TLS] Certificate written to ${CERT_DIR}`); + return { keyPath: KEY_PATH, certPath: CERT_PATH, generated: true }; +} + +/** + * Load key and cert buffers from disk. + * @returns {{ key: Buffer, cert: Buffer }} + * @throws if files do not exist + */ +function loadCreds() { + return { + key: fs.readFileSync(KEY_PATH), + cert: fs.readFileSync(CERT_PATH), + }; +} + +/** + * Parse the on-disk certificate and return human-readable metadata. + * Returns { exists: false } if no certificate file is present. + * + * @returns {{ exists: boolean, fingerprint?: string, subject?: string, notBefore?: string, notAfter?: string, daysLeft?: number }} + */ +function getCertInfo() { + if (!fs.existsSync(CERT_PATH)) { + return { exists: false }; + } + + try { + const pem = fs.readFileSync(CERT_PATH, 'utf8'); + const cert = forge.pki.certificateFromPem(pem); + + // SHA-1 fingerprint formatted as colon-separated hex pairs (matches browser/OS display) + const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); + const md = forge.md.sha1.create(); + md.update(der); + const raw = md.digest().toHex(); + const fingerprint = raw.match(/.{2}/g).join(':').toUpperCase(); + + const notBefore = cert.validity.notBefore.toISOString(); + const notAfter = cert.validity.notAfter.toISOString(); + const daysLeft = Math.floor((cert.validity.notAfter - Date.now()) / 86400000); + + const cnField = cert.subject.getField('CN'); + const subject = cnField ? cnField.value : 'localhost'; + + return { exists: true, fingerprint, subject, notBefore, notAfter, daysLeft }; + } catch (e) { + return { exists: true, fingerprint: null, error: e.message }; + } +} + +module.exports = { ensureCerts, loadCreds, getCertInfo, CERT_DIR, KEY_PATH, CERT_PATH }; diff --git a/rig-bridge/package-lock.json b/rig-bridge/package-lock.json index d8028f8a..dc5a82fd 100644 --- a/rig-bridge/package-lock.json +++ b/rig-bridge/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "cors": "^2.8.5", "express": "^4.18.2", + "node-forge": "^1.4.0", "serialport": "^12.0.0", "ws": "^8.14.2", "xmlrpc": "^1.3.2" @@ -1761,6 +1762,15 @@ } } }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-gyp-build": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", diff --git a/rig-bridge/package.json b/rig-bridge/package.json index dabfc28a..dbe33019 100644 --- a/rig-bridge/package.json +++ b/rig-bridge/package.json @@ -23,6 +23,7 @@ "dependencies": { "cors": "^2.8.5", "express": "^4.18.2", + "node-forge": "^1.4.0", "serialport": "^12.0.0", "ws": "^8.14.2", "xmlrpc": "^1.3.2" diff --git a/rig-bridge/rig-bridge-config.example.json b/rig-bridge/rig-bridge-config.example.json index bad26238..7e50f435 100644 --- a/rig-bridge/rig-bridge-config.example.json +++ b/rig-bridge/rig-bridge-config.example.json @@ -50,5 +50,9 @@ "multicastGroup": "224.0.0.1", "multicastInterface": "", "udpBindAddress": "" + }, + "tls": { + "enabled": false, + "certGenerated": false } } diff --git a/rig-bridge/rig-bridge.js b/rig-bridge/rig-bridge.js index 76de10a5..40cb040b 100644 --- a/rig-bridge/rig-bridge.js +++ b/rig-bridge/rig-bridge.js @@ -84,14 +84,20 @@ const registry = new PluginRegistry(config, { }); registry.registerBuiltins(); -// 6. Start HTTP server (passes registry for route dispatch and plugin route registration) -startServer(config.port, registry, VERSION); - -// 7. Auto-connect to configured radio (if any) -registry.connectActive(); - -// 8. Start all enabled integration plugins (e.g. WSJT-X relay) -registry.connectIntegrations(); +// 6. Start HTTP/HTTPS server, then wire radio and integrations once it's listening. +// startServer is async — it resolves after the server is bound to the port. +(async () => { + await startServer(config.port, registry, VERSION); + + // 7. Auto-connect to configured radio (if any) + registry.connectActive(); + + // 8. Start all enabled integration plugins (e.g. WSJT-X relay) + registry.connectIntegrations(); +})().catch((err) => { + console.error('[Startup] Fatal error:', err.message); + process.exit(1); +}); // 9. Bridge plugin bus events to the SSE /stream so browsers in local/direct // mode receive all plugin data (decodes, status, APRS) over the same From 568d4f0149824431ae0215e00e4d1b227dc33f0d Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Mon, 30 Mar 2026 19:06:20 +0200 Subject: [PATCH 11/14] =?UTF-8?q?fix(pr-review):=20address=20review=20feed?= =?UTF-8?q?back=20=E2=80=94=20isLocalMode=20fallback,=20tooltip=20clamp,?= =?UTF-8?q?=20config=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useWSJTX: reset isLocalMode after 30 s SSE silence so HTTP polling resumes if rig-bridge disconnects mid-session (was permanently latched) - APRSPanel: clamp hover tooltip to viewport bounds; remove dead onSpotClick prop - rig-bridge config: revert ohcUrl default to localhost:8080 (was 3000, dev-only port) - rig-bridge config: bump CONFIG_VERSION 7 → 8 for relayToServer key addition Co-Authored-By: Claude Sonnet 4.6 --- rig-bridge/core/config.js | 4 ++-- src/components/APRSPanel.jsx | 11 ++++++----- src/hooks/useWSJTX.js | 13 +++++++++++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/rig-bridge/core/config.js b/rig-bridge/core/config.js index 5977c598..b6ebed66 100644 --- a/rig-bridge/core/config.js +++ b/rig-bridge/core/config.js @@ -55,7 +55,7 @@ function resolveConfigPath() { const { dir: CONFIG_DIR, path: CONFIG_PATH } = resolveConfigPath(); // Increment when DEFAULT_CONFIG structure changes (new keys, renamed keys, etc.) -const CONFIG_VERSION = 7; +const CONFIG_VERSION = 8; const DEFAULT_CONFIG = { configVersion: CONFIG_VERSION, @@ -154,7 +154,7 @@ const DEFAULT_CONFIG = { // Local forwarding: push received packets to the local OHC server's /api/aprs/local // Set to false when using cloudRelay to avoid duplicate injection on the cloud server. localForward: true, - ohcUrl: 'http://localhost:3000', // URL of the local OpenHamClock server + ohcUrl: 'http://localhost:8080', // URL of the local OpenHamClock server }, // Rotator control via rotctld (Hamlib) rotator: { diff --git a/src/components/APRSPanel.jsx b/src/components/APRSPanel.jsx index c94e20a2..495e5b57 100644 --- a/src/components/APRSPanel.jsx +++ b/src/components/APRSPanel.jsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import CallsignLink from './CallsignLink.jsx'; import { calculateDistance, formatDistance } from '../utils/geo.js'; -const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot, deLocation, units = 'metric' }) => { +const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onHoverSpot, deLocation, units = 'metric' }) => { const { filteredStations = [], stations = [], @@ -507,7 +507,7 @@ const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot, onHoverSpot?.(null); setTooltip(null); }} - onClick={() => onSpotClick?.({ call: station.call, lat: station.lat, lon: station.lon })} + onClick={() => {}} style={{ display: 'grid', gridTemplateColumns: '1fr auto', @@ -582,9 +582,10 @@ const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot, (() => { const s = tooltip.station; const age = stationAgeMinutes(s); - // Keep tooltip within viewport - const tipX = tooltip.x + 14; - const tipY = tooltip.y + 14; + const TIP_W = 290; + const TIP_H = 220; + const tipX = Math.min(tooltip.x + 14, window.innerWidth - TIP_W - 4); + const tipY = Math.min(tooltip.y + 14, window.innerHeight - TIP_H - 4); return (
{ // SSE from rig-bridge is the data source — no need to poll the server. - if (isLocalMode.current) return; + // But if SSE has gone silent for >30 s, assume rig-bridge disconnected and + // resume polling so the UI doesn't show stale data indefinitely. + if (isLocalMode.current) { + if (Date.now() - lastSseAt.current < SSE_STALE_MS) return; + isLocalMode.current = false; // SSE appears stale — fall back to polling + } const interval = hasDataFlowing.current ? POLL_FAST : POLL_SLOW; fullFetchCounter.current++; if (fullFetchCounter.current >= 8) { @@ -188,7 +195,9 @@ export function useWSJTX(enabled = true) { const handler = (e) => { const msg = e.detail; - // Mark local mode on the very first SSE message — polling loop will stop. + // Mark local mode on the first SSE message and refresh the heartbeat on every one. + // The polling loop checks lastSseAt and resets isLocalMode if SSE goes silent for >30 s. + lastSseAt.current = Date.now(); if (!isLocalMode.current) { isLocalMode.current = true; setLoading(false); From 92bd7bcd0e18951d9a7ed10efcfdc0eefcf88b8e Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 30 Mar 2026 21:31:50 -0400 Subject: [PATCH 12/14] fix(wsjtx): allow spaces in WSJT-X client IDs (#846) The prototype pollution guard used isValidSessionId() to validate WSJT-X client IDs, but its regex only allowed [A-Za-z0-9_\-:.]. Users with custom instance names containing spaces (e.g. "WSJT-X - FT991A") had all packets silently rejected. Add a separate isValidClientId() that only blocks __proto__, constructor, and prototype while allowing any normal characters. Closes #846 Co-Authored-By: Claude Opus 4.6 (1M context) --- server/routes/wsjtx.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/routes/wsjtx.js b/server/routes/wsjtx.js index 6a8140cf..a845401e 100644 --- a/server/routes/wsjtx.js +++ b/server/routes/wsjtx.js @@ -86,6 +86,15 @@ module.exports = function (app, ctx) { return /^[A-Za-z0-9_\-:.]{1,128}$/.test(id); } + // Validate WSJT-X client IDs — more permissive than session IDs since these are + // user-configurable instance names (e.g. "WSJT-X - FT991A") that can contain spaces. + // Only block prototype pollution vectors and enforce a length limit. + function isValidClientId(id) { + if (!id || typeof id !== 'string' || id.length > 128) return false; + if (id === '__proto__' || id === 'constructor' || id === 'prototype') return false; + return true; + } + function getRelaySession(sessionId) { if (!isValidSessionId(sessionId)) return null; if (!wsjtxRelaySessions[sessionId]) { @@ -480,7 +489,7 @@ module.exports = function (app, ctx) { if (!msg) return; if (!state) state = wsjtxState; // Reject dangerous msg.id values to prevent prototype pollution on state.clients - if (msg.id && !isValidSessionId(msg.id)) return; + if (msg.id && !isValidClientId(msg.id)) return; // Ensure clients is a prototype-less object to prevent prototype pollution if (!state.clients || Object.getPrototypeOf(state.clients) !== null) { From 58e73f3c964fb178f9410a3445552c3873068e2e Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Tue, 31 Mar 2026 22:08:48 +0200 Subject: [PATCH 13/14] docs(rig-bridge): rewrite README for ham radio operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full rewrite focused on clarity and accessibility for non-IT users: - Added table of contents for easy navigation - Rewrote all sections in plain language — explains COM ports, localhost, API tokens, baud rate, and other concepts inline without jargon - Reorganised structure: Getting Started → per-radio setup → OHC connection → digital modes → APRS → rotator → HTTPS → troubleshooting → advanced - Added per-radio model setup tables (Yaesu, Icom, Kenwood, Elecraft) - Rewrote HTTPS section as a complete end-to-end walkthrough including: browser security warning steps for Chrome/Edge, Firefox and Safari, full manual cert install steps for macOS, Windows and Linux, verification checklist, and revert-to-HTTP instructions - Expanded troubleshooting table with plain-language descriptions - Moved developer content (API reference, plugin writing, build scripts, project structure) to an Advanced Topics section at the bottom Co-Authored-By: Claude Sonnet 4.6 --- rig-bridge/README.md | 970 ++++++++++++++++++++----------------------- 1 file changed, 454 insertions(+), 516 deletions(-) diff --git a/rig-bridge/README.md b/rig-bridge/README.md index 9b3e2c59..fd0a1912 100644 --- a/rig-bridge/README.md +++ b/rig-bridge/README.md @@ -1,67 +1,85 @@ # 📻 OpenHamClock Rig Bridge -**One download. One click. Your radio is connected.** +**Let OpenHamClock talk to your radio — click a spot, your radio tunes.** -The Rig Bridge connects OpenHamClock directly to your radio via USB — no flrig, no rigctld, no complicated setup. Just plug in your radio, run the bridge, pick your COM port, and go. +Rig Bridge is a small program that runs on your computer and acts as a translator between OpenHamClock and your radio. Once it is running, you can click any DX spot, POTA activation, or SOTA summit in OpenHamClock and your radio will automatically tune to the right frequency and mode. -Built on a **plugin architecture** — each radio integration is a standalone module, making it easy to add new integrations without touching existing code. +It also connects FT8/FT4 decoding software (WSJT-X, JTDX, MSHV, JS8Call) to OpenHamClock, so all your decoded stations appear live on the map. + +--- + +## Contents + +1. [Supported Radios](#supported-radios) +2. [Getting Started](#getting-started) +3. [Connecting Your Radio](#connecting-your-radio) +4. [Connecting to OpenHamClock](#connecting-to-openhamclock) +5. [Digital Mode Software (FT8, JS8, etc.)](#digital-mode-software) +6. [APRS via Local TNC](#aprs-via-local-tnc) +7. [Antenna Rotator](#antenna-rotator) +8. [HTTPS Setup (needed for openhamclock.com)](#https-setup) +9. [Troubleshooting](#troubleshooting) +10. [Advanced Topics](#advanced-topics) + +--- ## Supported Radios -### Direct USB (Recommended) +### Direct USB connection (recommended for most hams) -| Brand | Protocol | Tested Models | -| ----------- | -------- | --------------------------------------------------- | -| **Yaesu** | CAT | FT-991A, FT-891, FT-710, FT-DX10, FT-DX101, FT-5000 | -| **Kenwood** | Kenwood | TS-890, TS-590, TS-2000, TS-480 | -| **Icom** | CI-V | IC-7300, IC-7610, IC-9700, IC-705, IC-7851 | +You connect the radio to your computer with a USB cable — no extra software needed. -Also works with **Elecraft** radios (K3, K4, KX3, KX2) using the Kenwood plugin. +| Brand | Tested Models | +| ------------ | --------------------------------------------------- | +| **Yaesu** | FT-991A, FT-891, FT-710, FT-DX10, FT-DX101, FT-5000 | +| **Kenwood** | TS-890, TS-590, TS-2000, TS-480 | +| **Icom** | IC-7300, IC-7610, IC-9700, IC-705, IC-7851 | +| **Elecraft** | K3, K4, KX3, KX2 (use the Kenwood plugin) | -### SDR Radios via TCI (WebSocket) +### SDR software radios (Hermes Lite 2, ANAN, SunSDR) -TCI (Transceiver Control Interface) is a WebSocket-based protocol used by modern SDR applications. Unlike serial CAT, TCI **pushes** frequency, mode, and PTT changes in real-time — no polling, no serial port conflicts. +These connect over your local network rather than USB. -| Application | Radios | Default TCI Port | -| ------------- | ------------------- | ---------------- | -| **Thetis** | Hermes Lite 2, ANAN | 40001 | -| **ExpertSDR** | SunSDR2 | 40001 | +| Software | Compatible Radios | +| ------------- | -------------------------- | +| **Thetis** | Hermes Lite 2, ANAN series | +| **ExpertSDR** | SunSDR2 | -### SDR Radios (Native TCP) +### FlexRadio SmartSDR (6000 / 8000 series) -| Application | Radios | Default Port | -| ------------ | ------------------------------ | ------------ | -| **SmartSDR** | FlexRadio 6000/8000 series | 4992 | -| **rtl_tcp** | RTL-SDR dongles (receive-only) | 1234 | +Connects directly over your home network — no extra software needed on the FlexRadio side. -### Via Control Software (Legacy) +### RTL-SDR dongle (receive only) -| Software | Protocol | Default Port | -| ----------- | -------- | ------------ | -| **flrig** | XML-RPC | 12345 | -| **rigctld** | TCP | 4532 | +Cheap USB TV tuner dongles used as software-defined receivers. Frequency tuning works; transmit/PTT does not apply. -### For Testing (No Hardware Required) +### Already using flrig or rigctld? -| Type | Description | -| ------------------- | -------------------------------------------------------------------- | -| **Simulated Radio** | Fake radio that drifts through several bands — no serial port needed | +If you already have **flrig** or **rigctld** (Hamlib) running and controlling your radio, Rig Bridge can connect to those instead of talking to the radio directly. This lets you keep your existing setup. -Enable by setting `radio.type = "mock"` in `rig-bridge-config.json` or selecting **Simulated Radio** in the setup UI. +### No radio? Test with the simulator + +Select **Simulated Radio** in the setup screen. A fake radio will drift through the bands so you can try everything without any hardware connected. --- -## Quick Start +## Getting Started -### Option A: Download the Executable (Easiest) +### Step 1 — Download and run Rig Bridge -1. Download the right file for your OS from the Releases page -2. Double-click to run -3. Open **http://localhost:5555** in your browser -4. Select your radio type and COM port -5. Click **Save & Connect** +**Option A — Standalone executable (easiest, no installation needed)** -### Option B: Run with Node.js +1. Go to the Releases page and download the file for your operating system: + - `ohc-rig-bridge-win.exe` — Windows + - `ohc-rig-bridge-macos` — macOS (Intel) + - `ohc-rig-bridge-macos-arm` — macOS (Apple Silicon / M1, M2, M3, M4) + - `ohc-rig-bridge-linux` — Linux +2. Double-click the file to run it. On macOS you may need to right-click → Open the first time. +3. A terminal/console window will appear showing log messages — leave it running. + +**Option B — Run from source with Node.js** + +If you have Node.js installed: ```bash cd rig-bridge @@ -69,488 +87,395 @@ npm install node rig-bridge.js ``` -Then open **http://localhost:5555** to configure. +### Step 2 — Open the setup page -**Options:** +Once Rig Bridge is running, open your web browser and go to: -```bash -node rig-bridge.js --port 8080 # Use a different port -node rig-bridge.js --debug # Enable raw hex/ASCII CAT traffic logging -``` +**http://localhost:5555** ---- +> **What is localhost:5555?** `localhost` means "this computer" — Rig Bridge is running on your own machine, not on the internet. `5555` is just the "door number" (port) it listens on. Nothing is sent to the internet. -## Radio Setup Tips +You will see the Rig Bridge setup screen. The first time it opens, your **API Token** (a security password) will be shown automatically — Rig Bridge logs you in for you. -### Yaesu FT-991A +> **What is the API Token?** It is a password that protects Rig Bridge from being controlled by other websites you might visit. Keep it private. You will need to paste it into OpenHamClock once. -1. Connect USB-B cable from radio to computer -2. On the radio: **Menu → Operation Setting → CAT Rate → 38400** -3. In Rig Bridge: Select **Yaesu**, pick your COM port, baud **38400**, stop bits **2**, and enable **Hardware Flow (RTS/CTS)** +### Step 3 — Configure your radio -### Icom IC-7300 +See [Connecting Your Radio](#connecting-your-radio) below for step-by-step instructions for your specific radio. -1. Connect USB cable from radio to computer -2. On the radio: **Menu → Connectors → CI-V → CI-V USB Baud Rate → 115200** -3. In Rig Bridge: Select **Icom**, pick COM port, baud **115200**, stop bits **1**, address **0x94** +### Step 4 — Connect to OpenHamClock -### Kenwood TS-590 +See [Connecting to OpenHamClock](#connecting-to-openhamclock) below. -1. Connect USB cable from radio to computer -2. In Rig Bridge: Select **Kenwood**, pick COM port, baud **9600**, stop bits **1** +--- -### SDR Radios via TCI +## Connecting Your Radio -#### 1. Enable TCI in your SDR application +### Yaesu radios (FT-991A, FT-891, FT-710, FT-DX10, etc.) -**Thetis (HL2 / ANAN):** Setup → CAT Control → check **Enable TCI Server** (default port 40001) +**On the radio:** -**ExpertSDR:** Settings → TCI → Enable (default port 40001) +| Radio | Menu path | Setting | +| ------- | ----------------------------------- | --------- | +| FT-991A | Menu → Operation Setting → CAT Rate | **38400** | +| FT-891 | Menu → CAT Rate | **38400** | +| FT-710 | Menu → CAT RATE | **38400** | +| FT-DX10 | Menu → CAT RATE | **38400** | -#### 2. Configure rig-bridge +**In Rig Bridge setup (http://localhost:5555):** -Edit `rig-bridge-config.json`: +1. Radio Type → **Yaesu** +2. Serial Port → select your radio's COM port (see tip below) +3. Baud Rate → **38400** +4. Stop Bits → **2** +5. Hardware Flow (RTS/CTS) → **enabled** (important for FT-991A and FT-710) +6. Click **Save & Connect** -```json -{ - "radio": { "type": "tci" }, - "tci": { - "host": "localhost", - "port": 40001, - "trx": 0, - "vfo": 0 - } -} -``` +> **Which COM port is my radio?** On Windows, open Device Manager → Ports (COM & LPT). Look for "Silicon Labs CP210x" or similar — that is your radio. On macOS, look for `/dev/cu.usbserial-...` in the list. -| Field | Description | Default | -| ------ | -------------------------------- | ----------- | -| `host` | Host running the SDR application | `localhost` | -| `port` | TCI WebSocket port | `40001` | -| `trx` | Transceiver index (0 = primary) | `0` | -| `vfo` | VFO index (0 = VFO-A, 1 = VFO-B) | `0` | +--- -#### 3. Run rig-bridge +### Icom radios (IC-7300, IC-7610, IC-9700, IC-705) -```bash -node rig-bridge.js -``` +**On the radio:** -You should see: +- IC-7300: **Menu → Connectors → CI-V → CI-V USB Baud Rate → 115200** +- IC-7610: **Menu → Connectors → CI-V → CI-V USB Baud Rate → 115200** +- IC-9700: **Menu → Connectors → CI-V → CI-V USB Baud Rate → 115200** +- IC-705: **Menu → Connectors → CI-V → CI-V USB Baud Rate → 115200** -``` -[TCI] Connecting to ws://localhost:40001... -[TCI] ✅ Connected to ws://localhost:40001 -[TCI] Device: Thetis -[TCI] Server ready -``` +**In Rig Bridge setup:** -The bridge auto-reconnects every 5 s if the connection drops — just restart your SDR app and it will reconnect automatically. +1. Radio Type → **Icom** +2. Serial Port → select your radio's COM port +3. Baud Rate → **115200** +4. Stop Bits → **1** +5. CI-V Address → use the value for your model: ---- +| Radio | CI-V Address | +| ------- | ------------ | +| IC-7300 | 0x94 | +| IC-7610 | 0x98 | +| IC-9700 | 0xA2 | +| IC-705 | 0xA4 | +| IC-7851 | 0x8E | -## FlexRadio SmartSDR +6. Click **Save & Connect** -The SmartSDR plugin connects directly to a FlexRadio 6000 or 8000 series radio via the native SmartSDR TCP API — no rigctld, no SmartSDR CAT, no DAX required. The radio pushes frequency, mode, and slice changes in real-time. +--- -### Setup +### Kenwood and Elecraft radios (TS-890, TS-590, K3, K4, KX3) -Edit `rig-bridge-config.json`: +**In Rig Bridge setup:** -```json -{ - "radio": { "type": "smartsdr" }, - "smartsdr": { - "host": "192.168.1.100", - "port": 4992, - "sliceIndex": 0 - } -} -``` +1. Radio Type → **Kenwood** +2. Serial Port → select your radio's COM port +3. Baud Rate → **9600** (check your radio's CAT speed setting if unsure) +4. Stop Bits → **1** +5. Click **Save & Connect** -| Field | Description | Default | -| ------------ | ---------------------------------- | --------------- | -| `host` | IP address of the FlexRadio | `192.168.1.100` | -| `port` | SmartSDR TCP API port | `4992` | -| `sliceIndex` | Slice receiver index (0 = Slice A) | `0` | +--- -You should see: +### SDR radios via Thetis or ExpertSDR (Hermes Lite 2, ANAN, SunSDR) -``` -[SmartSDR] Connecting to 192.168.1.100:4992... -[SmartSDR] ✅ Connected — Slice A on 14.074 MHz -``` +These connect over your local network using the TCI protocol — no USB cable needed. -The bridge auto-reconnects every 5 s if the connection drops. +**Step 1 — Enable TCI in your SDR software** -**Supported modes:** USB, LSB, CW, AM, SAM, FM, DATA-USB (DIGU), DATA-LSB (DIGL), RTTY, FreeDV +- **Thetis:** Setup → CAT Control → tick **Enable TCI Server** (default port: 40001) +- **ExpertSDR:** Settings → TCI → Enable (default port: 40001) ---- +**Step 2 — In Rig Bridge setup:** -## RTL-SDR (rtl_tcp) +1. Radio Type → **TCI / SDR** +2. Host → `localhost` (or the IP address of the machine running the SDR software if it is on a different computer) +3. Port → **40001** +4. Click **Save & Connect** -The RTL-SDR plugin connects to an `rtl_tcp` server for cheap RTL-SDR dongles. It is **receive-only** — frequency tuning works, but mode changes and PTT are no-ops. +You should see in the Rig Bridge log: -### Setup +``` +[TCI] ✅ Connected to ws://localhost:40001 +[TCI] Device: Thetis +``` -1. Start `rtl_tcp` on the machine with the dongle: +Rig Bridge will automatically reconnect if the SDR software is restarted. -```bash -rtl_tcp -a 127.0.0.1 -p 1234 -``` +--- -2. Edit `rig-bridge-config.json`: - -```json -{ - "radio": { "type": "rtl-tcp" }, - "rtltcp": { - "host": "127.0.0.1", - "port": 1234, - "sampleRate": 2400000, - "gain": "auto" - } -} -``` +### FlexRadio SmartSDR (6000 / 8000 series) -| Field | Description | Default | -| ------------ | ----------------------------------------------- | ----------- | -| `host` | Host running `rtl_tcp` | `127.0.0.1` | -| `port` | `rtl_tcp` listen port | `1234` | -| `sampleRate` | IQ sample rate in Hz | `2400000` | -| `gain` | Tuner gain in tenths of dB, or `"auto"` for AGC | `"auto"` | +**In Rig Bridge setup:** + +1. Radio Type → **SmartSDR** +2. Host → the IP address of your FlexRadio on your network (e.g. `192.168.1.100`) +3. Port → **4992** +4. Slice Index → **0** (Slice A; change to 1 for Slice B, etc.) +5. Click **Save & Connect** You should see: ``` -[RTL-TCP] Connecting to 127.0.0.1:1234... -[RTL-TCP] ✅ Connected — tuner: R820T -[RTL-TCP] Setting sample rate: 2.4 MS/s -[RTL-TCP] Gain: auto (AGC) +[SmartSDR] ✅ Connected — Slice A on 14.074 MHz ``` --- -## WSJT-X Relay +### Connecting via flrig or rigctld (existing setups) -The WSJT-X Relay is an **integration plugin** (not a radio plugin) that listens for WSJT-X UDP packets on the local machine and delivers decoded messages to OpenHamClock in real-time. It supports two delivery modes: +If you already have flrig or rigctld (Hamlib) controlling your radio, Rig Bridge can connect to them. This way you do not need to change anything in your existing workflow. -| Mode | How it works | Use case | -| -------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------- | -| **📶 SSE only** (default) | Decodes flow over the existing `/stream` SSE connection to the browser — no server involved | Local install, LAN, self-hosted | -| **☁️ Relay to OHC server** | Batches decodes and POSTs them to an OpenHamClock server; browser polls the server | Cloud relay / remote access | +**flrig:** -Switch between modes in **http://localhost:5555 → Integrations → WSJT-X → Delivery mode**. In SSE-only mode no server credentials are needed. +1. Radio Type → **flrig** +2. Host → `127.0.0.1` (or the IP where flrig runs) +3. Port → **12345** -> **⚠️ Startup order matters when running on the same machine as OpenHamClock** -> -> Both rig-bridge and a locally-running OpenHamClock instance listen on the same UDP port (default **2237**) for WSJT-X packets. Only one process can hold the port at a time. -> -> **Always start rig-bridge first.** It will bind UDP 2237. If OpenHamClock starts first and claims the port, rig-bridge will log `UDP port already in use` and receive nothing. -> -> If you see that warning in the rig-bridge console log, stop OpenHamClock, restart rig-bridge, then start OpenHamClock again. - -### SSE-only mode (local/LAN) +**rigctld:** -This is the default. Enable the plugin and set your UDP port — that's it. Decodes, status updates and logged QSOs flow directly to any browser connected to `/stream`. No relay key, no session ID, no server URL required. +1. Radio Type → **rigctld** +2. Host → `127.0.0.1` +3. Port → **4532** -In WSJT-X: **File → Settings → Reporting → UDP Server → `127.0.0.1:2237`** +--- -When the browser connects to `/stream` it immediately receives a `plugin-init` message containing the list of running plugins and a replay of the last 100 decodes, so the panel is populated instantly without waiting for the next FT8 cycle. +## Connecting to OpenHamClock -### Relay-to-server mode (cloud) +### Scenario A — Everything on the same computer (most common) -Enable relay mode when using a cloud-hosted OpenHamClock or any setup where the browser cannot reach rig-bridge directly. +OpenHamClock and Rig Bridge both run on your shack computer. -#### Option A — Auto-configure from OpenHamClock (recommended) +1. Make sure Rig Bridge is running and your radio is connected (green dot in the status bar) +2. Open OpenHamClock in your browser +3. Go to **Settings → Rig Bridge** +4. Tick **Enable Rig Bridge** +5. Host: `http://localhost` — Port: `5555` +6. Copy the **API Token** from the Rig Bridge setup page and paste it into the token field +7. Tick **Click-to-tune** if you want spot clicks to tune your radio +8. Click **Save** -1. Open **OpenHamClock** → **Settings** → **Station Settings** → **Rig Control** -2. Make sure Rig Control is enabled and the rig-bridge Host URL/Port are filled in -3. Scroll to the **WSJT-X Relay** sub-section -4. Note your **Session ID** (copy it with the 📋 button) -5. Click **Configure Relay on Rig Bridge** — OpenHamClock fetches the relay key from its own server and pushes credentials + enables relay mode directly to rig-bridge in one step +That is it. Click any DX spot, POTA or SOTA activation on the map and your radio tunes automatically. -#### Option B — Configure from the rig-bridge setup UI +--- -1. Open **http://localhost:5555** → **Integrations** tab -2. Enable the WSJT-X checkbox -3. Select **☁️ Relay to OHC server** -4. Enter the OpenHamClock Server URL and click **🔗 Fetch credentials** -5. Copy your **Session ID** from OpenHamClock → Settings → Station → Rig Control → WSJT-X Relay and paste it into the Session ID field -6. Click **Save Integrations** +### Scenario B — Radio on one computer, OpenHamClock on another -#### Option C — Manual config +For example: Rig Bridge runs on a Raspberry Pi or shack PC connected to the radio. OpenHamClock runs on a laptop elsewhere in the house. -```json -{ - "wsjtxRelay": { - "enabled": true, - "relayToServer": true, - "url": "https://openhamclock.com", - "key": "your-relay-key", - "session": "your-session-id", - "udpPort": 2237, - "batchInterval": 2000, - "verbose": false, - "multicast": false, - "multicastGroup": "224.0.0.1", - "multicastInterface": "" - } -} -``` +**On the shack computer (where the radio is):** -### Config reference +1. Start Rig Bridge with network access enabled: + - If running from source: `node rig-bridge.js --bind 0.0.0.0` + - Or set `"bindAddress": "0.0.0.0"` in the config file +2. Find the shack computer's IP address (e.g. `192.168.1.50`) +3. Configure your radio at `http://192.168.1.50:5555` -| Field | Description | Default | -| -------------------- | --------------------------------------------------------------- | ----------- | -| `enabled` | Activate the plugin on startup | `false` | -| `relayToServer` | `true` = also POST batches to OHC server; `false` = SSE-only | `false` | -| `url` | OpenHamClock server URL (relay mode only) | — | -| `key` | Relay authentication key from your OHC server (relay mode only) | — | -| `session` | Browser session ID for per-user isolation (relay mode only) | — | -| `udpPort` | UDP port WSJT-X is sending to | `2237` | -| `batchInterval` | How often batches are POSTed to the server in relay mode (ms) | `2000` | -| `verbose` | Log every decoded message to the console | `false` | -| `multicast` | Join a UDP multicast group to receive WSJT-X packets | `false` | -| `multicastGroup` | Multicast group IP address to join | `224.0.0.1` | -| `multicastInterface` | Local NIC IP for multi-homed systems; `""` = OS default | `""` | +**On the other computer (where OpenHamClock runs):** -### Multicast Mode +1. Settings → Rig Bridge → Host: `http://192.168.1.50` — Port: `5555` +2. Paste the API Token from the shack computer's setup page +3. Save -By default the relay uses **unicast** — WSJT-X sends packets directly to `127.0.0.1` and only this process receives them. +> **Security note:** When you open Rig Bridge to the network (`0.0.0.0`), it is accessible to any device on your home network. The API Token protects it from unauthorised commands. Do not do this on a public or shared network. -If you want multiple applications on the same machine or LAN to receive WSJT-X packets simultaneously, enable multicast: +--- -1. In WSJT-X: **File → Settings → Reporting → UDP Server** — set the address to `224.0.0.1` -2. In the rig-bridge setup UI, enable **Enable Multicast** and set the group address, or in `rig-bridge-config.json`: - -```json -{ - "wsjtxRelay": { - "multicast": true, - "multicastGroup": "224.0.0.1", - "multicastInterface": "" - } -} -``` +### Scenario C — Using the cloud version at openhamclock.com -Leave `multicastInterface` blank unless you have multiple network adapters and need to specify which one to use (enter its local IP, e.g. `"192.168.1.100"`). +This lets you control your radio at home from anywhere in the world through the openhamclock.com website. -> `224.0.0.1` is the WSJT-X conventional multicast group. It is link-local — packets are not routed across subnet boundaries. +**Step 1 — Install Rig Bridge on your home computer** ---- +Download and run Rig Bridge on the computer that is connected to your radio (see [Getting Started](#getting-started)). -## Connecting to OpenHamClock +**Step 2 — Configure your radio** -### Scenario 1: Local Install (OHC + Rig Bridge on same machine) +Open http://localhost:5555 and set up your radio. Make sure the green "connected" dot appears. -This is the simplest setup — everything runs on your computer. +**Step 3 — Enable HTTPS on Rig Bridge** -1. **Start Rig Bridge** (if not already running): - ```bash - cd rig-bridge && node rig-bridge.js - ``` -2. **Configure your radio** at http://localhost:5555 — select radio type, COM port, click Save & Connect -3. **Open OpenHamClock** → **Settings** → **Rig Bridge** tab -4. Check **Enable Rig Bridge** -5. Host: `http://localhost` — Port: `5555` -6. Copy the **API Token** from the rig-bridge setup UI and paste it into the token field -7. Check **Click-to-tune** if you want spot clicks to change your radio frequency -8. Click **Save** +The openhamclock.com website uses a secure connection (HTTPS), and browsers will not allow it to talk to a non-secure Rig Bridge. You need to enable HTTPS first — see the [HTTPS Setup](#https-setup) section for the full walkthrough. -That's it — click any DX spot, POTA, SOTA, or RBN spot and your radio tunes automatically. +**Step 4 — Connect from OpenHamClock** -### Scenario 2: LAN Setup (OHC on one machine, radio on another) +1. Go to https://openhamclock.com → **Settings → Rig Bridge** +2. Host: `https://localhost` — Port: `5555` +3. Paste your API Token +4. Click **Connect Cloud Relay** -Example: Rig Bridge runs on a Raspberry Pi in the shack, OHC runs on a laptop in the office. +How it works behind the scenes: -1. **On the Pi** (where the radio is connected): - - Start rig-bridge with LAN access: `node rig-bridge.js --bind 0.0.0.0` - - Or set `"bindAddress": "0.0.0.0"` in config - - Configure your radio at `http://pi-ip:5555` -2. **On the laptop** (where OHC runs): - - Settings → Rig Bridge → Host: `http://pi-ip` — Port: `5555` - - Paste the API token from the Pi's setup UI - - Save +``` +Your shack openhamclock.com +──────────── ──────────────── +Radio (USB) ←→ Rig Bridge ──HTTPS──→ Your browser + └─ WSJT-X └─ Click-to-tune + └─ Direwolf/APRS TNC └─ PTT + └─ Antenna rotator └─ FT8 decodes on map +``` -### Scenario 3: Cloud Relay (OHC on openhamclock.com, radio at home) +--- -This lets you control your radio from anywhere via the cloud-hosted OpenHamClock. +## Digital Mode Software -**Step 1: Install Rig Bridge at home** +Rig Bridge can receive decoded FT8, FT4, JT65, and other digital mode signals from your decoding software and display them live in OpenHamClock — all stations appear on the map in real time. -Go to https://openhamclock.com → Settings → Rig Bridge tab → click the download button for your OS (Windows/Mac/Linux). Run the installer — it downloads rig-bridge, installs dependencies, and starts it. +### Supported software -Or install manually: +| Software | Mode | Default Port | +| ----------- | ----------------------------- | ------------ | +| **WSJT-X** | FT8, FT4, JT65, JT9, and more | 2237 | +| **JTDX** | FT8, JT65 (enhanced decoding) | 2238 | +| **MSHV** | MSK144, Q65, and others | 2239 | +| **JS8Call** | JS8 keyboard messaging | 2242 | -```bash -git clone --depth 1 https://github.com/accius/openhamclock.git -cd openhamclock/rig-bridge -npm install -node rig-bridge.js -``` +All of these are **bidirectional** — OpenHamClock can also send replies, stop transmit, set free text, and highlight callsigns in the decode window. -**Step 2: Configure your radio** +### Setting up WSJT-X (same steps apply to JTDX and MSHV) -Open http://localhost:5555 and set up your radio connection (USB, rigctld, flrig, etc.). +**Step 1 — In WSJT-X:** -**Step 3: Connect the Cloud Relay** +1. Open **File → Settings → Reporting** +2. Set **UDP Server** to `127.0.0.1` +3. Set **UDP Server port** to `2237` +4. Make sure **Accept UDP requests** is ticked -Option A — One-click from OHC: +**Step 2 — In Rig Bridge:** -1. Open https://openhamclock.com → Settings → Rig Bridge tab -2. Enter your local rig-bridge host (`http://localhost`) and port (`5555`) -3. Paste your API token -4. Click **Connect Cloud Relay** +1. Open http://localhost:5555 → **Plugins** tab +2. Find **WSJT-X Relay** and tick **Enable** +3. Click **Save** -Option B — Manual configuration: +Decoded stations will now appear on the OpenHamClock map. When you first open the map, the last 100 decoded stations are shown immediately — you do not have to wait for the next FT8 cycle. -1. In rig-bridge setup UI → Plugins tab → enable **Cloud Relay** -2. Set the OHC Server URL: `https://openhamclock.com` -3. Set the Relay API Key (same as `RIG_BRIDGE_RELAY_KEY` or `WSJTX_RELAY_KEY` on the server) -4. Set a Session ID (any unique string for your browser session) -5. Save and restart rig-bridge +> **⚠️ Important — start Rig Bridge before WSJT-X** +> +> Both programs listen on the same UDP port. Whichever starts first gets the port. Always start Rig Bridge first, then start WSJT-X (or JTDX / MSHV). If you see `UDP port already in use` in the Rig Bridge log, stop WSJT-X, restart Rig Bridge, then start WSJT-X again. -**How it works:** +### Multicast — sharing decodes with multiple programs -``` -Your shack Cloud -──────────── ───── -Radio (USB) ←→ Rig Bridge ──HTTPS──→ openhamclock.com - └─ WSJT-X └─ Your browser - └─ Direwolf/TNC └─ Click-to-tune - └─ Rotator └─ PTT - └─ WSJT-X decodes - └─ APRS packets -``` +By default, WSJT-X sends its decoded packets only to one listener. If you want both Rig Bridge and another program (e.g. GridTracker) to receive decodes at the same time, use multicast: -Rig Bridge pushes your rig state (frequency, mode, PTT) to the cloud server. When you click a spot or press PTT in the browser, the command is queued on the server and delivered to your local rig-bridge within approximately one network round-trip via long-polling — typically under 100 ms on a good connection. The browser UI updates optimistically before the confirmation arrives, so PTT and frequency feel immediate. +1. In WSJT-X: **File → Settings → Reporting → UDP Server** — set the address to `224.0.0.1` +2. In Rig Bridge → Plugins → WSJT-X Relay → tick **Enable Multicast**, group address `224.0.0.1` +3. Click **Save** --- -## Plugin Manager +## APRS via Local TNC -Open the rig-bridge setup UI at http://localhost:5555 → **Plugins** tab to enable and configure plugins. No JSON editing required. +If you run a local APRS TNC (for example, [Direwolf](https://github.com/wb2osz/direwolf) connected to a VHF radio), Rig Bridge can receive APRS packets from it and show nearby stations on the OpenHamClock map — without needing an internet connection. -### Digital Mode Plugins +This works alongside the regular internet-based APRS-IS feed. When the internet goes down, local RF keeps the map populated. -| Plugin | Default Port | Description | -| ---------------- | ------------ | ----------------------------------------------------- | -| **WSJT-X Relay** | 2237 | Forward FT8/FT4 decodes to OHC; bidirectional replies | -| **MSHV** | 2239 | Multi-stream digital mode software | -| **JTDX** | 2238 | Enhanced FT8/JT65 decoding | -| **JS8Call** | 2242 | JS8 keyboard-to-keyboard messaging | +### Setup with Direwolf -All digital mode plugins are **bidirectional** — OHC can send replies, halt TX, set free text, and highlight callsigns in the decode window. +1. Start Direwolf with KISS TCP enabled (it listens on port 8001 by default) +2. In Rig Bridge → Plugins tab → find **APRS TNC** → tick **Enable** +3. Protocol → **KISS TCP** +4. Host → `127.0.0.1`, Port → `8001` +5. Enter your callsign (required if you want to transmit beacons) +6. Click **Save** -Decodes are delivered to the browser over the `/stream` SSE connection in real-time. When a new browser tab connects, the last 100 decodes are replayed immediately via the `plugin-init` message so the panel is populated without waiting for the next FT8/FT4 cycle. No server round-trip is needed in local or LAN mode. +APRS packets from nearby stations on RF will now appear alongside internet-sourced APRS stations on the map. -In your digital mode software, set UDP Server to `127.0.0.1` and the port shown above. +### Hardware TNC (serial port) -### APRS TNC Plugin +If you have a traditional hardware TNC connected via serial port: -Connects to a local Direwolf or hardware TNC via KISS protocol for RF-based APRS — no internet required. +1. Protocol → **KISS Serial** +2. Serial Port → select your TNC's COM port +3. Baud Rate → **9600** (check your TNC's documentation) -| Setting | Default | Description | -| --------------- | ----------- | ------------------------------------------------------- | -| Protocol | `kiss-tcp` | `kiss-tcp` for Direwolf, `kiss-serial` for hardware TNC | -| Host | `127.0.0.1` | Direwolf KISS TCP host | -| Port | `8001` | Direwolf KISS TCP port | -| Callsign | (required) | Your callsign for TX | -| SSID | `0` | APRS SSID | -| Beacon Interval | `600` | Seconds between position beacons (0 = disabled) | +--- -**With Direwolf:** +## Antenna Rotator -1. Start Direwolf with KISS enabled (default port 8001) -2. Enable the APRS TNC plugin in rig-bridge -3. Set your callsign -4. APRS packets from nearby stations appear in OHC's APRS panel +Rig Bridge can control antenna rotators via [Hamlib's](https://hamlib.github.io/) `rotctld` daemon. -The APRS TNC runs alongside APRS-IS (internet) for dual-path coverage. When internet goes down, local RF keeps working. +1. Start rotctld for your rotator model, for example: + ``` + rotctld -m 202 -r /dev/ttyUSB1 -t 4533 + ``` +2. In Rig Bridge → Plugins tab → find **Rotator** → tick **Enable** +3. Host → `127.0.0.1`, Port → `4533` +4. Click **Save** -### Rotator Plugin +--- -Controls antenna rotators via Hamlib's `rotctld`. +## HTTPS Setup -1. Start rotctld: `rotctld -m 202 -r /dev/ttyUSB1 -t 4533` -2. Enable the Rotator plugin in rig-bridge -3. Set host and port (default: `127.0.0.1:4533`) +### Do I need this? -### Winlink Plugin +**Yes**, if you use openhamclock.com or any other HTTPS-hosted version of OpenHamClock. -Two features: +**No**, if you run OpenHamClock locally on your own computer (e.g. http://localhost:3000) — you can skip this section. -- **Gateway Discovery** — shows nearby Winlink RMS gateways on the map (requires API key from winlink.org) -- **Pat Client** — integrates with [Pat](https://getpat.io/) for composing and sending Winlink messages over RF +### Why is HTTPS needed? -### Cloud Relay Plugin +Web browsers have a security rule called "mixed content": a page loaded over a secure connection (`https://`) is not allowed to communicate with a non-secure address (`http://`). Because openhamclock.com uses HTTPS, it cannot talk to Rig Bridge unless Rig Bridge also uses HTTPS. -Bridges a locally-running rig-bridge to a cloud-hosted OpenHamClock instance so cloud users get the same rig control as local users — click-to-tune, PTT, WSJT-X decodes, APRS packets. +Rig Bridge solves this by generating its own security certificate — a small file that proves the connection is encrypted. Because the certificate is created by Rig Bridge itself (not by a certificate authority), your browser will not automatically trust it. You need to install it once, which tells your browser "I trust this certificate on this computer". -See [Scenario 3](#scenario-3-cloud-relay-ohc-on-openhamclockcom-radio-at-home) for setup instructions. +### Complete step-by-step setup -**How latency is minimised:** +#### Step 1 — Enable HTTPS in Rig Bridge -| Path | Mechanism | Typical latency | -| --------------------- | ------------------------------------------------------ | --------------- | -| Rig state → browser | Event-driven push + SSE fan-out | < 100 ms | -| Browser command → rig | Long-poll (server wakes rig-bridge on command arrival) | ~RTT (< 100 ms) | +1. Open **http://localhost:5555** in your browser +2. Click the **🔒 Security** tab +3. Tick **Enable HTTPS** +4. Rig Bridge will generate a certificate automatically (takes a few seconds) +5. **Quit and restart Rig Bridge** +6. From now on, open **https://localhost:5555** (note the `s` in `https`) -The rig-bridge holds a persistent long-poll connection to the server. The moment you click PTT or a DX spot, the server wakes that connection and delivers the command — no fixed poll tick to wait for. +#### Step 2 — Deal with the browser warning -**Config reference:** +The first time you open https://localhost:5555 after enabling HTTPS, your browser will show a security warning. This is expected — the certificate is genuine, but your browser does not yet trust it. -| Field | Description | Default | -| -------------- | ----------------------------------------------- | ------- | -| `enabled` | Activate the relay on startup | `false` | -| `url` | Cloud OHC server URL | — | -| `apiKey` | Relay authentication key (from your OHC server) | — | -| `session` | Browser session ID for per-user isolation | — | -| `pushInterval` | Fallback push interval for batched data (ms) | `2000` | -| `relayRig` | Relay rig state (freq, mode, PTT) | `true` | -| `relayWsjtx` | Relay WSJT-X decodes | `true` | -| `relayAprs` | Relay APRS packets from local TNC | `false` | -| `verbose` | Log all relay activity to the console | `false` | +**Chrome / Edge:** ---- +1. On the warning page, click **Advanced** +2. Click **Proceed to localhost (unsafe)** -## Config Location +**Firefox:** -Rig Bridge stores its configuration outside the installation directory so updates never overwrite your settings: +1. On the warning page, click **Advanced** +2. Click **Accept the Risk and Continue** -| OS | Config Path | -| --------------- | ----------------------------------------------- | -| **macOS/Linux** | `~/.config/openhamclock/rig-bridge-config.json` | -| **Windows** | `%APPDATA%\openhamclock\rig-bridge-config.json` | +**Safari:** -On first run, if no config exists at the external path, rig-bridge creates one from the example template. If you're upgrading from an older version that stored config in the `rig-bridge/` directory, it's automatically migrated. +1. Click **Show Details** +2. Click **visit this website** +3. Enter your macOS password if asked ---- +You only need to do this once. -## HTTPS / TLS +#### Step 3 — Install the certificate so you never see the warning again -### Why HTTPS? +Installing the certificate permanently tells your computer to trust Rig Bridge's HTTPS connection. After this, the browser will show a normal padlock icon with no warnings. -Browsers block **mixed-content** requests: if OpenHamClock is served over `https://` (e.g. on openhamclock.com or a self-hosted instance with SSL), the browser will refuse to connect to rig-bridge over plain `http://`. Enabling HTTPS on rig-bridge solves this. +**Easiest way — use the Install button:** -### Enabling HTTPS +1. Make sure you are on **https://localhost:5555** (accepted the warning in Step 2) +2. Go to the **🔒 Security** tab +3. Click **⬇ Download Certificate** — save the file `rig-bridge.crt` +4. Click **Install Certificate** — Rig Bridge will try to install it automatically -1. Open the rig-bridge setup UI at **http://localhost:5555** -2. Click the **🔒 Security** tab -3. Toggle **Enable HTTPS** — rig-bridge will generate a self-signed certificate automatically -4. **Restart rig-bridge** -5. Open **https://localhost:5555** (note: `https://`) - -### Installing the Certificate +If the Install button succeeds, you are done. If it asks for a password or fails, follow the manual steps for your operating system below. -Because the certificate is self-signed, your browser will show a warning until you install it as trusted. The Security tab provides one-click installation and manual fallback instructions. +--- -#### macOS +**macOS — manual install:** -Click **Install Certificate** in the Security tab. If it asks for your password, enter your macOS login password. +1. Download the certificate from the Security tab +2. Double-click `rig-bridge.crt` +3. Keychain Access opens — the certificate appears under **login** keychain +4. Double-click the certificate in Keychain Access +5. Expand **Trust** → set **When using this certificate** to **Always Trust** +6. Close the window and enter your macOS password when asked +7. Restart your browser -Manual fallback: +Or in Terminal: ```bash sudo security add-trusted-cert -d -r trustRoot \ @@ -558,218 +483,231 @@ sudo security add-trusted-cert -d -r trustRoot \ ~/.config/openhamclock/certs/rig-bridge.crt ``` -#### Windows - -Click **Install Certificate** in the Security tab (runs `certutil` automatically). +--- -Manual fallback — download the certificate and double-click it, then: +**Windows — manual install:** -- **Install Certificate → Local Machine → Trusted Root Certification Authorities** +1. Download the certificate from the Security tab +2. Double-click `rig-bridge.crt` +3. Click **Install Certificate** +4. Select **Local Machine** → click Next +5. Select **Place all certificates in the following store** → click Browse +6. Select **Trusted Root Certification Authorities** → OK +7. Click Next → Finish +8. Restart your browser -Or via command line (run as Administrator): +Or in Command Prompt (run as Administrator): ```cmd certutil -addstore -f ROOT %APPDATA%\openhamclock\certs\rig-bridge.crt ``` -#### Linux +--- + +**Linux — manual install:** -Download the certificate and run: +1. Download the certificate from the Security tab +2. Open a terminal and run: ```bash sudo cp ~/Downloads/rig-bridge.crt /usr/local/share/ca-certificates/ sudo update-ca-certificates ``` -Then import the certificate into your browser's certificate store: +3. Import the certificate into your browser: + - **Chrome / Chromium:** Settings → Privacy & Security → Manage Certificates → Authorities → Import + - **Firefox:** Settings → Privacy & Security → View Certificates → Authorities → Import → tick "Trust this CA to identify websites" -- Chrome/Chromium: **Settings → Privacy & Security → Manage Certificates → Authorities → Import** -- Firefox: **Settings → Privacy & Security → View Certificates → Authorities → Import** - -### Certificate Location +--- -| OS | Certificate Path | -| --------------- | --------------------------------------------- | -| **macOS/Linux** | `~/.config/openhamclock/certs/rig-bridge.crt` | -| **Windows** | `%APPDATA%\openhamclock\certs\rig-bridge.crt` | +#### Step 4 — Update OpenHamClock settings -The private key (`rig-bridge.key`) is stored alongside the certificate with permissions `0600` (owner-read only). +Now that Rig Bridge is running on HTTPS, update the address in OpenHamClock: -### Fallback to HTTP +1. Open OpenHamClock → **Settings → Rig Bridge** +2. Change Host from `http://localhost` to **`https://localhost`** +3. Port stays **5555** +4. Click **Save** -If you need to revert to plain HTTP, open the Security tab and uncheck **Enable HTTPS**, then restart rig-bridge. HTTP mode is the default — no changes are needed for existing setups. +#### Step 5 — Verify everything works -If rig-bridge fails to start HTTPS (e.g. cert generation error), it automatically falls back to HTTP and logs an error. +- The padlock icon appears in your browser's address bar when visiting https://localhost:5555 ✓ +- The status bar in OpenHamClock shows Rig Bridge as connected ✓ +- Clicking a spot tunes your radio ✓ -### OpenHamClock Settings when Using HTTPS +### Reverting to plain HTTP -After enabling HTTPS, update the rig-bridge Host URL in OpenHamClock: +If you ever want to go back to plain HTTP (for example, if you stop using openhamclock.com): -- Settings → Rig Bridge → Host: `https://localhost` (or `https://your-machine-ip`) +1. Open https://localhost:5555 → **🔒 Security** tab +2. Untick **Enable HTTPS** +3. Restart Rig Bridge +4. Open **http://localhost:5555** again and update OpenHamClock settings to `http://localhost` ---- +### Certificate storage location -## Building Executables +The certificate file is stored here on your computer: -To create standalone executables (no Node.js required): +| Operating System | Certificate file | +| ----------------- | --------------------------------------------- | +| **macOS / Linux** | `~/.config/openhamclock/certs/rig-bridge.crt` | +| **Windows** | `%APPDATA%\openhamclock\certs\rig-bridge.crt` | -```bash -npm install -npm run build:win # Windows .exe -npm run build:mac # macOS (Intel) -npm run build:mac-arm # macOS (Apple Silicon) -npm run build:linux # Linux x64 -npm run build:linux-arm # Linux ARM (Raspberry Pi) -npm run build:all # All platforms -``` - -Executables are output to the `dist/` folder. +The certificate is valid for 10 years and is regenerated only if you click **Regenerate** in the Security tab. It does not expire with Rig Bridge updates. --- ## Troubleshooting -| Problem | Solution | -| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| No COM ports found | Install USB driver (Silicon Labs CP210x for Yaesu, FTDI for some Kenwood) | -| Port opens but no data | Check baud rate matches radio's CAT Rate setting | -| Icom not responding | Verify CI-V address matches your radio model | -| CORS errors in browser | The bridge allows all origins by default | -| Port already in use | Close flrig/rigctld if running — you don't need them anymore | -| PTT not responsive | Enable **Hardware Flow (RTS/CTS)** (especially for FT-991A/FT-710) | -| macOS Comms Failure | The bridge automatically applies a `stty` fix for CP210x drivers. | -| TCI: Connection refused | Enable TCI in your SDR app (Thetis → Setup → CAT Control → Enable TCI Server) | -| TCI: No frequency updates | Check `trx` / `vfo` index in config match the active transceiver in your SDR app | -| TCI: Remote SDR | Set `tci.host` to the IP of the machine running the SDR application | -| SmartSDR: Connection refused | Confirm the radio is powered on and reachable; default API port is 4992 | -| SmartSDR: No slice updates | Check `sliceIndex` matches the active slice in SmartSDR | -| RTL-SDR: Connection refused | Start `rtl_tcp` first: `rtl_tcp -a 127.0.0.1 -p 1234`; check no other app holds the dongle | -| RTL-SDR: Frequency won't tune | Verify the frequency is within your dongle's supported range (typically 24 MHz–1.7 GHz for R820T) | -| Multicast: no packets | Verify `multicastGroup` matches what WSJT-X sends to; check OS firewall allows multicast UDP; set `multicastInterface` to the correct NIC IP if multi-homed | -| Cloud Relay: auth failed (401/403) | Check that `apiKey` in rig-bridge matches `RIG_BRIDGE_RELAY_KEY` on the OHC server | -| Cloud Relay: state not updating | Verify `url` points to the correct OHC server and that the server is reachable from your home network | -| Cloud Relay: PTT/tune lag | Ensure rig-bridge version ≥ 2.0 — older versions used a 250 ms poll instead of long-poll | -| Cloud Relay: connection drops frequently | Some proxies close idle HTTP connections after 30–60 s; rig-bridge reconnects automatically | +| Problem | What to try | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| **No COM ports shown** | Install the USB driver for your radio. Yaesu/Icom typically use the Silicon Labs CP210x driver. Kenwood and some others use FTDI. | +| **Port opens but radio does not respond** | Check the baud rate matches what is set in your radio's menus. | +| **Icom not responding** | Double-check the CI-V address matches your exact radio model. | +| **PTT not working** | Try enabling **Hardware Flow (RTS/CTS)** in the radio settings (especially for FT-991A, FT-710). | +| **Port already in use** | If you have flrig or rigctld running, close them first — Rig Bridge talks to the radio directly and they would conflict. | +| **macOS: "Comms Failure"** | Rig Bridge applies a serial port fix automatically on macOS. If problems persist, try unplugging and replugging the USB cable. | +| **WSJT-X decodes not appearing** | Make sure WSJT-X UDP Server is set to `127.0.0.1:2237` in File → Settings → Reporting. Start Rig Bridge before WSJT-X. | +| **TCI: Connection refused** | Enable TCI Server in your SDR software (Thetis: Setup → CAT Control → Enable TCI Server). | +| **SmartSDR: no connection** | Confirm the FlexRadio is on and reachable on your network. Default API port is 4992. | +| **RTL-SDR: connection refused** | Start `rtl_tcp` before Rig Bridge: `rtl_tcp -a 127.0.0.1 -p 1234`. Check no other program (e.g. SDR#) has the dongle open. | +| **Browser shows mixed-content error** | OpenHamClock is on HTTPS but Rig Bridge is on HTTP. Follow the [HTTPS Setup](#https-setup) guide. | +| **HTTPS: browser still shows warning after installing cert** | Restart your browser completely (close all windows, not just the tab). | +| **Cloud Relay: 401 / 403 error** | The API Token in Rig Bridge does not match what OpenHamClock has. Copy the token again from the Rig Bridge setup page. | +| **Cloud Relay: PTT / tune feels slow** | Make sure Rig Bridge version is 2.0 or newer. Older versions used a slower polling method. | --- -## API Reference +## Advanced Topics -Fully backward compatible with the original rig-daemon API: +The sections below are for technically minded users or developers who want to go deeper. -| Method | Endpoint | Description | -| ------ | ------------- | ------------------------------------------------------ | -| GET | `/status` | Current freq, mode, PTT, connected status | -| GET | `/stream` | SSE stream of real-time updates + plugin decode events | -| POST | `/freq` | Set frequency: `{ "freq": 14074000 }` | -| POST | `/mode` | Set mode: `{ "mode": "USB" }` | -| POST | `/ptt` | Set PTT: `{ "ptt": true }` | -| GET | `/api/ports` | List available serial ports | -| GET | `/api/config` | Get current configuration | -| POST | `/api/config` | Update configuration & reconnect | -| POST | `/api/test` | Test a serial port connection | -| GET | `/api/status` | Lightweight health check: `{ sseClients, uptime }` | +### Where is the config file stored? ---- +Rig Bridge saves its settings to a file in your user folder. This file survives updates — installing a new version of Rig Bridge will never overwrite your settings. + +| Operating System | Config file location | +| ----------------- | ----------------------------------------------- | +| **macOS / Linux** | `~/.config/openhamclock/rig-bridge-config.json` | +| **Windows** | `%APPDATA%\openhamclock\rig-bridge-config.json` | + +### Command-line options + +```bash +node rig-bridge.js --port 8080 # Use a different port (default: 5555) +node rig-bridge.js --bind 0.0.0.0 # Allow access from other computers on your network +node rig-bridge.js --debug # Show raw CAT command traffic in the log +node rig-bridge.js --version # Print the version number +``` -## Project Structure +### Building standalone executables + +To create the self-contained executables (no Node.js installation required on the target machine): + +```bash +npm install +npm run build:win # Windows (.exe) +npm run build:mac # macOS Intel +npm run build:mac-arm # macOS Apple Silicon (M1/M2/M3/M4) +npm run build:linux # Linux x64 +npm run build:linux-arm # Linux ARM (Raspberry Pi) +npm run build:all # All of the above +``` + +Executables are saved to the `dist/` folder. + +### API reference + +Rig Bridge exposes a simple HTTP API — compatible with the original rig-daemon format: + +| Method | Endpoint | Description | +| ------ | ------------- | --------------------------------------------------------- | +| GET | `/status` | Current frequency, mode, PTT state, and connection status | +| GET | `/stream` | Real-time updates via SSE (Server-Sent Events) | +| POST | `/freq` | Tune radio: `{ "freq": 14074000 }` (frequency in Hz) | +| POST | `/mode` | Set mode: `{ "mode": "USB" }` | +| POST | `/ptt` | Key transmitter: `{ "ptt": true }` | +| GET | `/api/ports` | List available serial ports | +| GET | `/api/config` | Read current configuration | +| POST | `/api/config` | Save configuration and reconnect | +| POST | `/api/test` | Test a serial port without connecting | +| GET | `/api/status` | Lightweight health check | + +### Project structure ``` rig-bridge/ -├── rig-bridge.js # Entry point — thin orchestrator -│ +├── rig-bridge.js # Entry point ├── core/ │ ├── config.js # Config load/save, defaults, CLI args -│ ├── state.js # Shared rig state + SSE broadcast + change listeners -│ ├── server.js # Express HTTP server + all API routes -│ ├── plugin-registry.js # Plugin lifecycle manager + dispatcher -│ └── serial-utils.js # Shared serial port helpers -│ +│ ├── tls.js # HTTPS certificate generation and management +│ ├── state.js # Shared rig state and SSE broadcast +│ ├── server.js # HTTP/HTTPS server and all API routes +│ ├── plugin-registry.js # Plugin lifecycle manager +│ └── serial-utils.js # Serial port helpers ├── lib/ -│ ├── message-log.js # Persistent message log (WSJT-X, JS8Call, etc.) -│ ├── kiss-protocol.js # KISS frame encode/decode for APRS TNC -│ ├── wsjtx-protocol.js # WSJT-X UDP binary protocol parser/encoder -│ └── aprs-parser.js # APRS packet decoder (position, weather, objects, etc.) -│ +│ ├── message-log.js # Persistent message log +│ ├── kiss-protocol.js # KISS frame encode/decode (APRS TNC) +│ ├── wsjtx-protocol.js # WSJT-X UDP protocol parser +│ └── aprs-parser.js # APRS packet decoder └── plugins/ - ├── usb/ - │ ├── index.js # USB serial lifecycle (open, reconnect, poll) - │ ├── protocol-yaesu.js # Yaesu CAT ASCII protocol - │ ├── protocol-kenwood.js # Kenwood ASCII protocol - │ └── protocol-icom.js # Icom CI-V binary protocol - ├── tci.js # TCI/SDR WebSocket plugin (Thetis, ExpertSDR, etc.) - ├── smartsdr.js # FlexRadio SmartSDR native TCP API plugin - ├── rtl-tcp.js # RTL-SDR via rtl_tcp binary protocol (receive-only) - ├── rigctld.js # rigctld TCP plugin - ├── flrig.js # flrig XML-RPC plugin - ├── mock.js # Simulated radio for testing (no hardware needed) - ├── wsjtx-relay.js # WSJT-X UDP listener → OpenHamClock relay - ├── mshv.js # MSHV UDP listener (multi-stream digital modes) - ├── jtdx.js # JTDX UDP listener (FT8/JT65 enhanced decoding) - ├── js8call.js # JS8Call UDP listener (JS8 keyboard messaging) - ├── aprs-tnc.js # APRS KISS TNC plugin (Direwolf / hardware TNC) - ├── rotator.js # Antenna rotator via rotctld (Hamlib) - ├── winlink-gateway.js # Winlink RMS gateway discovery + Pat client - └── cloud-relay.js # Cloud relay — bridges local rig-bridge to cloud OHC + ├── usb/ # Direct USB CAT (Yaesu, Kenwood, Icom) + ├── tci.js # TCI/SDR WebSocket (Thetis, ExpertSDR) + ├── smartsdr.js # FlexRadio SmartSDR + ├── rtl-tcp.js # RTL-SDR via rtl_tcp + ├── rigctld.js # Hamlib rigctld + ├── flrig.js # flrig XML-RPC + ├── mock.js # Simulated radio (for testing) + ├── wsjtx-relay.js # WSJT-X / JTDX / MSHV relay + ├── js8call.js # JS8Call messaging + ├── aprs-tnc.js # APRS KISS TNC (Direwolf / hardware) + ├── rotator.js # Antenna rotator via rotctld + ├── winlink-gateway.js # Winlink RMS gateway discovery + └── cloud-relay.js # Cloud relay to hosted OpenHamClock ``` ---- - -## Writing a Plugin +### Writing a plugin -Each plugin exports an object with the following shape: +Each plugin is a JavaScript module that exports a descriptor object: ```js module.exports = { - id: 'my-plugin', // Unique identifier (matches config.radio.type) - name: 'My Plugin', // Human-readable name + id: 'my-plugin', // unique ID — matches config.radio.type for rig plugins + name: 'My Plugin', category: 'rig', // 'rig' | 'integration' | 'rotator' | 'logger' | 'other' - configKey: 'radio', // Which config section this plugin reads + configKey: 'radio', // which config section this plugin reads create(config, services) { - // Available services: - // updateState(prop, value) — update shared rig state and broadcast via SSE - // state — read-only view of current rig state - // onStateChange(fn) — subscribe to any rig state change (immediate callback) - // removeStateChangeListener(fn) — unsubscribe - // pluginBus — EventEmitter for inter-plugin events - // emits: 'decode' (WSJT-X/MSHV/JTDX/JS8Call decodes) - // 'status' (plugin connection status changes) - // 'qso' (logged QSO records) - // 'aprs' (parsed APRS packets from TNC) - // messageLog — persistent log for decoded messages - const { updateState, state, onStateChange, removeStateChangeListener, pluginBus } = services; + const { updateState, state, pluginBus, messageLog } = services; return { connect() { - /* open connection */ + /* open connection to radio */ }, disconnect() { /* close connection */ }, - - // Rig category — implement these for radio control: setFreq(hz) { /* tune to frequency in Hz */ }, setMode(mode) { - /* set mode string e.g. 'USB' */ + /* set mode string, e.g. 'USB' */ }, setPTT(on) { - /* key/unkey transmitter */ + /* key or unkey the transmitter */ }, - // Optional — register extra HTTP routes: - // registerRoutes(app) { app.get('/my-plugin/...', handler) } + // Optional: register extra HTTP routes + // registerRoutes(app) { app.get('/my-plugin/data', handler) } }; }, }; ``` -**Categories:** +To activate a plugin, call `registry.register(descriptor)` in `rig-bridge.js` before `registry.connectActive()`. -- `rig` — radio control; the bridge dispatches `/freq`, `/mode`, `/ptt` to the active rig plugin -- `integration` — background service plugins (e.g. WSJT-X relay); started via `registry.connectIntegrations()` -- `rotator`, `logger`, `other` — use `registerRoutes(app)` to expose their own endpoints +**Plugin categories:** -To register a plugin at startup, call `registry.register(descriptor)` in `rig-bridge.js` before `registry.connectActive()`. +- `rig` — radio control; `/freq`, `/mode`, `/ptt` are dispatched to the active rig plugin +- `integration` — background service (e.g. WSJT-X relay); started via `registry.connectIntegrations()` +- `rotator`, `logger`, `other` — use `registerRoutes()` to add their own API endpoints From 0034e4218a5e7e5531c3aa8b03a12acc7c98ee8a Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Tue, 31 Mar 2026 23:34:40 +0200 Subject: [PATCH 14/14] fix(rig-bridge): address PR review findings on HTTPS implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tls.js: use random 16-byte serial number instead of hardcoded '01' so regenerated certificates are not silently returned from OS trust-store caches that key on issuer+serial (macOS Keychain, some browsers) - server.js: fix terminal banner alignment — 🔒 emoji renders as 2 display columns, so shortened the text to keep the box correctly aligned - server.js: move tls module to top-level require (tlsModule) instead of inline require() inside each route handler and startServer() - server.js: replace exec() + shell string interpolation in /api/tls/install with execFile() + explicit args array so unusual home directory characters (quotes, dollar signs) cannot affect command parsing Co-Authored-By: Claude Sonnet 4.6 --- rig-bridge/core/server.js | 49 +++++++++++++++++++++------------------ rig-bridge/core/tls.js | 4 +++- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/rig-bridge/core/server.js b/rig-bridge/core/server.js index 25b1ba1c..b9f5dd51 100644 --- a/rig-bridge/core/server.js +++ b/rig-bridge/core/server.js @@ -24,6 +24,7 @@ const cors = require('cors'); const { getSerialPort, listPorts } = require('./serial-utils'); const { state, addSseClient, removeSseClient, getDecodeRingBuffer, getSseClientCount } = require('./state'); const { config, saveConfig, CONFIG_PATH } = require('./config'); +const tlsModule = require('./tls'); // ─── Security helpers ───────────────────────────────────────────────────── @@ -2144,8 +2145,7 @@ function createServer(registry, version) { if (config.tls.enabled) { try { - const { ensureCerts } = require('./tls'); - await ensureCerts(forceRegen); + await tlsModule.ensureCerts(forceRegen); config.tls.certGenerated = true; } catch (e) { console.error('[TLS] Certificate generation failed:', e.message); @@ -2251,8 +2251,7 @@ function createServer(registry, version) { // ─── API: TLS / HTTPS certificate management ───────────────────────── // No auth on /status — the Security tab needs this before login succeeds. app.get('/api/tls/status', (req, res) => { - const { getCertInfo } = require('./tls'); - const info = getCertInfo(); + const info = tlsModule.getCertInfo(); res.json({ enabled: !!(config.tls && config.tls.enabled), ...info }); }); @@ -2260,44 +2259,49 @@ function createServer(registry, version) { // Token-gated: downloading the cert allows a user to install it as trusted, which // only makes sense for someone who already has access to the setup UI. app.get('/api/tls/cert', requireAuth, (req, res) => { - const { CERT_PATH } = require('./tls'); const fs = require('fs'); - if (!fs.existsSync(CERT_PATH)) { + if (!fs.existsSync(tlsModule.CERT_PATH)) { return res.status(404).json({ error: 'No certificate found. Enable HTTPS first.' }); } res.setHeader('Content-Type', 'application/x-pem-file'); res.setHeader('Content-Disposition', 'attachment; filename="rig-bridge.crt"'); - res.sendFile(CERT_PATH); + res.sendFile(tlsModule.CERT_PATH); }); // Attempt OS-level certificate installation. On permission failure the command - // is returned for the user to run manually. Only hardcoded paths are used in the - // exec call — no user input is interpolated — so there is no command injection risk. + // is returned for the user to run manually. + // Uses execFile (not exec) with an explicit args array — CERT_PATH is never + // interpolated into a shell string, so unusual home directory characters cannot + // affect command parsing. app.post('/api/tls/install', requireAuth, (req, res) => { - const { CERT_PATH } = require('./tls'); const fs = require('fs'); - const { exec } = require('child_process'); - if (!fs.existsSync(CERT_PATH)) { + const { execFile } = require('child_process'); + const certPath = tlsModule.CERT_PATH; + if (!fs.existsSync(certPath)) { return res.status(404).json({ error: 'No certificate found. Enable HTTPS first.' }); } - let cmd; + let bin, args, humanCmd; if (process.platform === 'darwin') { - cmd = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${CERT_PATH}"`; + bin = 'security'; + args = ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', '/Library/Keychains/System.keychain', certPath]; + humanCmd = `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`; } else if (process.platform === 'win32') { - cmd = `certutil -addstore -f ROOT "${CERT_PATH}"`; + bin = 'certutil'; + args = ['-addstore', '-f', 'ROOT', certPath]; + humanCmd = `certutil -addstore -f ROOT "${certPath}"`; } else { // Linux: many distros, provide manual instructions only return res.json({ success: false, manual: true, platform: 'linux', - certPath: CERT_PATH, + certPath, }); } - exec(cmd, (err) => { + execFile(bin, args, (err) => { if (err) { - // Likely a permission error — return the command so the user can run it with sudo - return res.json({ success: false, error: err.message, command: cmd, certPath: CERT_PATH }); + // Likely a permission error — return a human-readable command for manual use + return res.json({ success: false, error: err.message, command: humanCmd, certPath }); } res.json({ success: true }); }); @@ -2476,10 +2480,9 @@ async function startServer(port, registry, version) { let protocol = 'http'; if (config.tls && config.tls.enabled) { - const { ensureCerts, loadCreds } = require('./tls'); try { - await ensureCerts(); - const { key, cert } = loadCreds(); + await tlsModule.ensureCerts(); + const { key, cert } = tlsModule.loadCreds(); const https = require('https'); server = https.createServer({ key, cert }, app); protocol = 'https'; @@ -2502,7 +2505,7 @@ async function startServer(port, registry, version) { console.log(' ╠══════════════════════════════════════════════╣'); console.log(` ║ Setup UI: ${uiUrl.padEnd(32)}║`); if (protocol === 'https') { - console.log(' ║ 🔒 HTTPS enabled — install cert to trust it ║'); + console.log(' ║ 🔒 HTTPS enabled — install certificate ║'); } console.log(` ║ Radio: ${(config.radio.type || 'none').padEnd(30)}║`); if (bindAddress !== '127.0.0.1') { diff --git a/rig-bridge/core/tls.js b/rig-bridge/core/tls.js index 6b033958..516a5969 100644 --- a/rig-bridge/core/tls.js +++ b/rig-bridge/core/tls.js @@ -41,7 +41,9 @@ function generateCert() { const cert = forge.pki.createCertificate(); cert.publicKey = keyPair.publicKey; - cert.serialNumber = '01'; + // Random 16-byte serial — avoids OS trust-store caching bugs when a cert + // is regenerated (macOS Keychain and some browsers cache by issuer+serial). + cert.serialNumber = forge.util.bytesToHex(forge.random.getBytesSync(16)); cert.validity.notBefore = new Date(); cert.validity.notAfter = new Date();