diff --git a/public/aprs-symbols-24-0.png b/public/aprs-symbols-24-0.png new file mode 100644 index 00000000..8a2713f9 Binary files /dev/null and b/public/aprs-symbols-24-0.png differ diff --git a/public/aprs-symbols-24-1.png b/public/aprs-symbols-24-1.png new file mode 100644 index 00000000..10126ef4 Binary files /dev/null and b/public/aprs-symbols-24-1.png differ diff --git a/public/aprs-symbols-24-2.png b/public/aprs-symbols-24-2.png new file mode 100644 index 00000000..c1dfa98a Binary files /dev/null and b/public/aprs-symbols-24-2.png differ diff --git a/rig-bridge/README.md b/rig-bridge/README.md index 6c5f6339..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) + +You connect the radio to your computer with a USB cable — no extra software needed. -| 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 | +| 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) | -Also works with **Elecraft** radios (K3, K4, KX3, KX2) using the Kenwood plugin. +### SDR software radios (Hermes Lite 2, ANAN, SunSDR) -### SDR Radios via TCI (WebSocket) +These connect over your local network rather than USB. -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. +| Software | Compatible Radios | +| ------------- | -------------------------- | +| **Thetis** | Hermes Lite 2, ANAN series | +| **ExpertSDR** | SunSDR2 | -| Application | Radios | Default TCI Port | -| ------------- | ------------------- | ---------------- | -| **Thetis** | Hermes Lite 2, ANAN | 40001 | -| **ExpertSDR** | SunSDR2 | 40001 | +### FlexRadio SmartSDR (6000 / 8000 series) -### SDR Radios (Native TCP) +Connects directly over your home network — no extra software needed on the FlexRadio side. -| Application | Radios | Default Port | -| ------------ | ------------------------------ | ------------ | -| **SmartSDR** | FlexRadio 6000/8000 series | 4992 | -| **rtl_tcp** | RTL-SDR dongles (receive-only) | 1234 | +### RTL-SDR dongle (receive only) -### Via Control Software (Legacy) +Cheap USB TV tuner dongles used as software-defined receivers. Frequency tuning works; transmit/PTT does not apply. -| Software | Protocol | Default Port | -| ----------- | -------- | ------------ | -| **flrig** | XML-RPC | 12345 | -| **rigctld** | TCP | 4532 | +### Already using flrig or rigctld? -### For Testing (No Hardware Required) +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. -| Type | Description | -| ------------------- | -------------------------------------------------------------------- | -| **Simulated Radio** | Fake radio that drifts through several bands — no serial port needed | +### No radio? Test with the simulator -Enable by setting `radio.type = "mock"` in `rig-bridge-config.json` or selecting **Simulated Radio** in the setup UI. +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)** + +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 with Node.js +**Option B — Run from source with Node.js** + +If you have Node.js installed: ```bash cd rig-bridge @@ -69,609 +87,627 @@ 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. + +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. + +> **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. + +### Step 3 — Configure your radio + +See [Connecting Your Radio](#connecting-your-radio) below for step-by-step instructions for your specific radio. + +### Step 4 — Connect to OpenHamClock + +See [Connecting to OpenHamClock](#connecting-to-openhamclock) below. --- -## Radio Setup Tips +## Connecting Your Radio -### Yaesu FT-991A +### Yaesu radios (FT-991A, FT-891, FT-710, FT-DX10, etc.) -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)** +**On the radio:** -### Icom IC-7300 +| 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** | -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** +**In Rig Bridge setup (http://localhost:5555):** -### Kenwood TS-590 +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** -1. Connect USB cable from radio to computer -2. In Rig Bridge: Select **Kenwood**, pick COM port, baud **9600**, stop bits **1** +> **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. -### SDR Radios via TCI +--- -#### 1. Enable TCI in your SDR application +### Icom radios (IC-7300, IC-7610, IC-9700, IC-705) -**Thetis (HL2 / ANAN):** Setup → CAT Control → check **Enable TCI Server** (default port 40001) +**On the radio:** -**ExpertSDR:** Settings → TCI → Enable (default port 40001) +- 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** -#### 2. Configure rig-bridge +**In Rig Bridge setup:** -Edit `rig-bridge-config.json`: +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: -```json -{ - "radio": { "type": "tci" }, - "tci": { - "host": "localhost", - "port": 40001, - "trx": 0, - "vfo": 0 - } -} -``` +| Radio | CI-V Address | +| ------- | ------------ | +| IC-7300 | 0x94 | +| IC-7610 | 0x98 | +| IC-9700 | 0xA2 | +| IC-705 | 0xA4 | +| IC-7851 | 0x8E | -| 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` | +6. Click **Save & Connect** -#### 3. Run rig-bridge +--- -```bash -node rig-bridge.js -``` +### Kenwood and Elecraft radios (TS-890, TS-590, K3, K4, KX3) -You should see: +**In Rig Bridge setup:** + +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** + +--- + +### SDR radios via Thetis or ExpertSDR (Hermes Lite 2, ANAN, SunSDR) + +These connect over your local network using the TCI protocol — no USB cable needed. + +**Step 1 — Enable TCI in your SDR software** + +- **Thetis:** Setup → CAT Control → tick **Enable TCI Server** (default port: 40001) +- **ExpertSDR:** Settings → TCI → Enable (default port: 40001) + +**Step 2 — In Rig Bridge setup:** + +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** + +You should see in the Rig Bridge log: ``` -[TCI] Connecting to ws://localhost:40001... [TCI] ✅ Connected to ws://localhost:40001 [TCI] Device: Thetis -[TCI] Server ready ``` -The bridge auto-reconnects every 5 s if the connection drops — just restart your SDR app and it will reconnect automatically. +Rig Bridge will automatically reconnect if the SDR software is restarted. --- -## FlexRadio SmartSDR - -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 +### FlexRadio SmartSDR (6000 / 8000 series) -Edit `rig-bridge-config.json`: +**In Rig Bridge setup:** -```json -{ - "radio": { "type": "smartsdr" }, - "smartsdr": { - "host": "192.168.1.100", - "port": 4992, - "sliceIndex": 0 - } -} -``` - -| 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` | +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: ``` -[SmartSDR] Connecting to 192.168.1.100:4992... [SmartSDR] ✅ Connected — Slice A on 14.074 MHz ``` -The bridge auto-reconnects every 5 s if the connection drops. +--- -**Supported modes:** USB, LSB, CW, AM, SAM, FM, DATA-USB (DIGU), DATA-LSB (DIGL), RTTY, FreeDV +### Connecting via flrig or rigctld (existing setups) + +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. + +**flrig:** + +1. Radio Type → **flrig** +2. Host → `127.0.0.1` (or the IP where flrig runs) +3. Port → **12345** + +**rigctld:** + +1. Radio Type → **rigctld** +2. Host → `127.0.0.1` +3. Port → **4532** --- -## RTL-SDR (rtl_tcp) +## Connecting to OpenHamClock + +### Scenario A — Everything on the same computer (most common) + +OpenHamClock and Rig Bridge both run on your shack computer. -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. +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** -### Setup +That is it. Click any DX spot, POTA or SOTA activation on the map and your radio tunes automatically. -1. Start `rtl_tcp` on the machine with the dongle: +--- -```bash -rtl_tcp -a 127.0.0.1 -p 1234 -``` +### Scenario B — Radio on one computer, OpenHamClock on another -2. Edit `rig-bridge-config.json`: - -```json -{ - "radio": { "type": "rtl-tcp" }, - "rtltcp": { - "host": "127.0.0.1", - "port": 1234, - "sampleRate": 2400000, - "gain": "auto" - } -} -``` +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. -| 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"` | +**On the shack computer (where the radio is):** -You should see: +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` -``` -[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) -``` +**On the other computer (where OpenHamClock runs):** + +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 + +> **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. --- -## WSJT-X Relay +### Scenario C — Using the cloud version at openhamclock.com -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. +This lets you control your radio at home from anywhere in the world through the openhamclock.com website. -> **⚠️ 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. -> -> If you see that warning in the rig-bridge console log, stop OpenHamClock, restart rig-bridge, then start OpenHamClock again. - -### Getting your relay credentials - -The relay requires two values from your OpenHamClock server: a **relay key** and a **session ID**. There are two ways to set them up: - -#### Option A — Auto-configure from OpenHamClock (recommended) - -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 both credentials directly to rig-bridge in one step - -#### Option B — Fetch from 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** - -#### Option C — Manual config - -Edit `rig-bridge-config.json` directly: - -```json -{ - "wsjtxRelay": { - "enabled": 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": "" - } -} -``` +**Step 1 — Install Rig Bridge on your home computer** -### Config reference +Download and run Rig Bridge on the computer that is connected to your radio (see [Getting Started](#getting-started)). -| 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 | `""` | +**Step 2 — Configure your radio** -### In WSJT-X +Open http://localhost:5555 and set up your radio. Make sure the green "connected" dot appears. -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`** +**Step 3 — Enable HTTPS on Rig Bridge** -The relay runs alongside your radio plugin — you can use direct USB or TCI at the same time. +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. -### Multicast Mode +**Step 4 — Connect from OpenHamClock** -By default the relay uses **unicast** — WSJT-X sends packets directly to `127.0.0.1` and only this process receives them. +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** -If you want multiple applications on the same machine or LAN to receive WSJT-X packets simultaneously, enable multicast: +How it works behind the scenes: -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`): - -```json -{ - "wsjtxRelay": { - "multicast": true, - "multicastGroup": "224.0.0.1", - "multicastInterface": "" - } -} +``` +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 ``` -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"`). +--- -> `224.0.0.1` is the WSJT-X conventional multicast group. It is link-local — packets are not routed across subnet boundaries. +## Digital Mode Software ---- +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. -## Connecting to OpenHamClock +### Supported software -### Scenario 1: Local Install (OHC + Rig Bridge on same machine) +| 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 | -This is the simplest setup — everything runs on your computer. +All of these are **bidirectional** — OpenHamClock can also send replies, stop transmit, set free text, and highlight callsigns in the decode window. -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** +### Setting up WSJT-X (same steps apply to JTDX and MSHV) -That's it — click any DX spot, POTA, SOTA, or RBN spot and your radio tunes automatically. +**Step 1 — In WSJT-X:** -### Scenario 2: LAN Setup (OHC on one machine, radio on another) +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 -Example: Rig Bridge runs on a Raspberry Pi in the shack, OHC runs on a laptop in the office. +**Step 2 — In Rig Bridge:** -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 +1. Open http://localhost:5555 → **Plugins** tab +2. Find **WSJT-X Relay** and tick **Enable** +3. Click **Save** -### Scenario 3: Cloud Relay (OHC on openhamclock.com, radio at home) +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. -This lets you control your radio from anywhere via the cloud-hosted OpenHamClock. +> **⚠️ 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. -**Step 1: Install Rig Bridge at home** +### Multicast — sharing decodes with multiple programs -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. +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: -Or install manually: +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** -```bash -git clone --depth 1 https://github.com/accius/openhamclock.git -cd openhamclock/rig-bridge -npm install -node rig-bridge.js -``` +--- -**Step 2: Configure your radio** +## APRS via Local TNC -Open http://localhost:5555 and set up your radio connection (USB, rigctld, flrig, etc.). +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. -**Step 3: Connect the Cloud Relay** +This works alongside the regular internet-based APRS-IS feed. When the internet goes down, local RF keeps the map populated. -Option A — One-click from OHC: +### Setup with Direwolf -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. 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** -Option B — Manual configuration: +APRS packets from nearby stations on RF will now appear alongside internet-sourced APRS stations on the map. -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 +### Hardware TNC (serial port) -**How it works:** +If you have a traditional hardware TNC connected via serial port: -``` -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 -``` +1. Protocol → **KISS Serial** +2. Serial Port → select your TNC's COM port +3. Baud Rate → **9600** (check your TNC's documentation) -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. +--- + +## Antenna Rotator + +Rig Bridge can control antenna rotators via [Hamlib's](https://hamlib.github.io/) `rotctld` daemon. + +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** --- -## Plugin Manager +## HTTPS Setup + +### Do I need this? -Open the rig-bridge setup UI at http://localhost:5555 → **Plugins** tab to enable and configure plugins. No JSON editing required. +**Yes**, if you use openhamclock.com or any other HTTPS-hosted version of OpenHamClock. -### Digital Mode Plugins +**No**, if you run OpenHamClock locally on your own computer (e.g. http://localhost:3000) — you can skip this section. -| 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 | +### Why is HTTPS needed? -All digital mode plugins are **bidirectional** — OHC can send replies, halt TX, set free text, and highlight callsigns in the decode window. +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. -In your digital mode software, set UDP Server to `127.0.0.1` and the port shown above. +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". -### APRS TNC Plugin +### Complete step-by-step setup -Connects to a local Direwolf or hardware TNC via KISS protocol for RF-based APRS — no internet required. +#### Step 1 — Enable HTTPS in Rig Bridge -| 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) | +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`) -**With Direwolf:** +#### Step 2 — Deal with the browser warning -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 +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. -The APRS TNC runs alongside APRS-IS (internet) for dual-path coverage. When internet goes down, local RF keeps working. +**Chrome / Edge:** -### Rotator Plugin +1. On the warning page, click **Advanced** +2. Click **Proceed to localhost (unsafe)** -Controls antenna rotators via Hamlib's `rotctld`. +**Firefox:** -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`) +1. On the warning page, click **Advanced** +2. Click **Accept the Risk and Continue** -### Winlink Plugin +**Safari:** -Two features: +1. Click **Show Details** +2. Click **visit this website** +3. Enter your macOS password if asked -- **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 +You only need to do this once. -### Cloud Relay Plugin +#### Step 3 — Install the certificate so you never see the warning again -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. +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. -See [Scenario 3](#scenario-3-cloud-relay-ohc-on-openhamclockcom-radio-at-home) for setup instructions. +**Easiest way — use the Install button:** -**How latency is minimised:** +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 -| 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) | +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. + +--- -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. +**macOS — manual install:** -**Config reference:** +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 -| 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` | +Or in Terminal: + +```bash +sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain \ + ~/.config/openhamclock/certs/rig-bridge.crt +``` --- -## Config Location +**Windows — manual install:** -Rig Bridge stores its configuration outside the installation directory so updates never overwrite your settings: +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 -| OS | Config Path | -| --------------- | ----------------------------------------------- | -| **macOS/Linux** | `~/.config/openhamclock/rig-bridge-config.json` | -| **Windows** | `%APPDATA%\openhamclock\rig-bridge-config.json` | +Or in Command Prompt (run as Administrator): -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. +```cmd +certutil -addstore -f ROOT %APPDATA%\openhamclock\certs\rig-bridge.crt +``` --- -## Building Executables +**Linux — manual install:** -To create standalone executables (no Node.js required): +1. Download the certificate from the Security tab +2. Open a terminal and run: ```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 +sudo cp ~/Downloads/rig-bridge.crt /usr/local/share/ca-certificates/ +sudo update-ca-certificates ``` -Executables are output to the `dist/` folder. +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" + +--- + +#### Step 4 — Update OpenHamClock settings + +Now that Rig Bridge is running on HTTPS, update the address in OpenHamClock: + +1. Open OpenHamClock → **Settings → Rig Bridge** +2. Change Host from `http://localhost` to **`https://localhost`** +3. Port stays **5555** +4. Click **Save** + +#### Step 5 — Verify everything works + +- 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 ✓ + +### Reverting to plain HTTP + +If you ever want to go back to plain HTTP (for example, if you stop using openhamclock.com): + +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 + +The certificate file is stored here on your computer: + +| Operating System | Certificate file | +| ----------------- | --------------------------------------------- | +| **macOS / Linux** | `~/.config/openhamclock/certs/rig-bridge.crt` | +| **Windows** | `%APPDATA%\openhamclock\certs\rig-bridge.crt` | + +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 | -| 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 | +### 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 +``` + +### Building standalone executables -## Project Structure +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 -│ +│ ├── 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), 'aprs' (APRS packets) - // 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 diff --git a/rig-bridge/core/config.js b/rig-bridge/core/config.js index a8f0e871..ba561a53 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, @@ -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 @@ -184,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)); @@ -223,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, }); @@ -244,6 +251,7 @@ function loadConfig() { 'rotator', 'cloudRelay', 'winlink', + 'tls', ]) { if (DEFAULT_CONFIG[section] && raw[section]) { for (const key of Object.keys(DEFAULT_CONFIG[section])) { @@ -296,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 90c79e70..b9f5dd51 100644 --- a/rig-bridge/core/server.js +++ b/rig-bridge/core/server.js @@ -22,8 +22,9 @@ 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, getSseClientCount } = require('./state'); const { config, saveConfig, CONFIG_PATH } = require('./config'); +const tlsModule = require('./tls'); // ─── Security helpers ───────────────────────────────────────────────────── @@ -518,6 +519,7 @@ function buildSetupHtml(version, firstRunToken = null) { + @@ -758,46 +760,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
+ +
+
+
🔒 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. +

+
+ + + + +
@@ -1045,7 +1115,12 @@ function buildSetupHtml(version, firstRunToken = null) { document.getElementById('wsjtxMulticast').checked = !!w.multicast; document.getElementById('wsjtxMulticastGroup').value = w.multicastGroup || '224.0.0.1'; document.getElementById('wsjtxMulticastInterface').value = w.multicastInterface || ''; + // Set delivery mode radio + const relayMode = !!w.relayToServer; + document.getElementById('wsjtxModeSSE').checked = !relayMode; + document.getElementById('wsjtxModeRelay').checked = relayMode; toggleWsjtxOpts(); + toggleWsjtxMode(); toggleWsjtxMulticastOpts(); } @@ -1054,6 +1129,11 @@ function buildSetupHtml(version, firstRunToken = null) { document.getElementById('wsjtxOpts').style.display = enabled ? 'block' : 'none'; } + function toggleWsjtxMode() { + const relay = document.getElementById('wsjtxModeRelay').checked; + document.getElementById('wsjtxServerOpts').style.display = relay ? 'block' : 'none'; + } + async function fetchWsjtxCredentials() { const url = document.getElementById('wsjtxUrl').value.trim(); const statusEl = document.getElementById('fetchCredsStatus'); @@ -1093,8 +1173,10 @@ function buildSetupHtml(version, firstRunToken = null) { } async function saveIntegrations() { + const relayMode = document.getElementById('wsjtxModeRelay').checked; const wsjtxRelay = { enabled: document.getElementById('wsjtxEnabled').checked, + relayToServer: relayMode, url: document.getElementById('wsjtxUrl').value.trim(), key: document.getElementById('wsjtxKey').value.trim(), session: document.getElementById('wsjtxSession').value.trim(), @@ -1645,6 +1727,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(); @@ -1663,6 +1909,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}`, @@ -1677,6 +1924,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( @@ -1800,7 +2062,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) { @@ -1874,6 +2136,26 @@ 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 { + await tlsModule.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.'); @@ -1966,6 +2248,65 @@ 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 info = tlsModule.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 fs = require('fs'); + 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(tlsModule.CERT_PATH); + }); + + // Attempt OS-level certificate installation. On permission failure the command + // 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 fs = require('fs'); + 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 bin, args, humanCmd; + if (process.platform === 'darwin') { + 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') { + 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, + }); + } + execFile(bin, args, (err) => { + if (err) { + // 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 }); + }); + }); + // ─── OHC-compatible API ─── // ─── Message Log API ───────────────────────────────────────────────── app.get('/api/messages', (req, res) => { @@ -2009,6 +2350,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 = {}; @@ -2061,6 +2409,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); @@ -2108,7 +2468,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 @@ -2116,32 +2476,58 @@ 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) { + try { + await tlsModule.ensureCerts(); + const { key, cert } = tlsModule.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 certificate ║'); + } + 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/state.js b/rig-bridge/core/state.js index 0ba6b0c3..844c2316 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, @@ -50,12 +65,19 @@ 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, + getDecodeRingBuffer, }; diff --git a/rig-bridge/core/tls.js b/rig-bridge/core/tls.js new file mode 100644 index 00000000..516a5969 --- /dev/null +++ b/rig-bridge/core/tls.js @@ -0,0 +1,164 @@ +'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; + // 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(); + 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/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/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/plugins/aprs-tnc.js b/rig-bridge/plugins/aprs-tnc.js index 9c359a0b..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,22 +208,34 @@ 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 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/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/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 715fe1af..40cb040b 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'); @@ -77,11 +84,78 @@ 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); +// 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(); + // 7. Auto-connect to configured radio (if any) + registry.connectActive(); -// 8. Start all enabled integration plugins (e.g. WSJT-X relay) -registry.connectIntegrations(); + // 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 +// 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/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) { diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx index d81bcc37..01177085 100644 --- a/src/DockableApp.jsx +++ b/src/DockableApp.jsx @@ -934,7 +934,8 @@ export const DockableApp = ({ showOnMap={mapLayersEff.showAPRS} 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..495e5b57 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, 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,13 +490,24 @@ 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)} - onClick={() => onSpotClick?.({ call: station.call, lat: station.lat, lon: station.lon })} + 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={() => {}} style={{ display: 'grid', gridTemplateColumns: '1fr auto', @@ -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,79 @@ const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot }) )} + + {/* Hover tooltip */} + {tooltip && + (() => { + const s = tooltip.station; + const age = stationAgeMinutes(s); + 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 ( +
+
+ + {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/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 03f3e549..fccd37ac 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -90,7 +90,6 @@ export const SettingsPanel = ({ const [wsjtxMulticastAddress, setWsjtxMulticastAddress] = useState( config?.wsjtxRelayMulticast.address || '224.0.0.1', ); - // Local-only integration flags const [n3fjpEnabled, setN3fjpEnabled] = useState(() => { try { @@ -477,6 +476,7 @@ export const SettingsPanel = ({ key: relayKey, session: wsjtxSessionId || '', enabled: true, + relayToServer: true, }, }), }); @@ -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. +
+ + )} )} diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index eb7de18b..7facc252 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( @@ -1816,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); }); 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..3a016500 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,16 +10,24 @@ 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); 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'); @@ -38,7 +48,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,10 +57,12 @@ 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); + // 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); } @@ -90,6 +102,83 @@ export const useAPRS = (options = {}) => { return () => clearInterval(interval); }, [enabled, fetchTncStatus]); + // 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 = (e) => { + const msg = e.detail; + + if (msg.type === 'plugin-init') { + const hasTnc = msg.plugins?.includes('aprs-tnc') ?? false; + setTncConnected(hasTnc); + if (hasTnc) { + tncDetectedViaSse.current = true; + setAprsEnabled(true); + setLoading(false); + } + return; + } + + if (msg.event !== 'aprs') return; + + 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(), + }); + return next; + }); + tncDetectedViaSse.current = true; + setTncConnected(true); + setAprsEnabled(true); + setLastUpdate(new Date()); + setLoading(false); + }; + window.addEventListener('rig-plugin-data', handler); + return () => window.removeEventListener('rig-plugin-data', handler); + }, [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) => { if (!name?.trim()) return; @@ -147,10 +236,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(() => { @@ -164,11 +253,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/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..5f5841a9 100644 --- a/src/hooks/useWSJTX.js +++ b/src/hooks/useWSJTX.js @@ -52,7 +52,9 @@ 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 + const lastSseAt = useRef(0); // Timestamp of last SSE message (ms); used for staleness check // ── DX Target tracking ── // When the operator selects a callsign in WSJT-X (Std Msgs), the server @@ -148,12 +150,21 @@ 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 SSE_STALE_MS = 30000; // Reset local mode if no SSE message for 30 s const tick = () => { + // SSE from rig-bridge is the data source — no need to poll the server. + // 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) { @@ -175,6 +186,86 @@ 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; + + // 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); + setError(null); + } + + 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 }; + }); + } + return; + } + + if (msg.event === 'decode') { + 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(), + }, + }, + })); + } 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); + 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. 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)], + }; +}