Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ A modern Python 3 remote control server for **SPE Expert** HF amplifiers (1.3K-F
- **Power On/Off** — remote power control via DTR line (on) and serial command 0x0A (off)
- **Full SPE protocol** — all commands from the official Application Programmer's Guide Rev 1.1, plus undocumented RCU commands
- **RCU (Remote Control Unit) mode** — live LCD display mirror streamed as binary frames; compatible with the MacExpert companion app
- **Orchestrated TUNE + band sweep** — drives a Flex 6000-series rig over SmartSDR TCP API; runs the SM5TOG-style ATU tune flow (carrier on → watch RCU TUNE-LED bit → carrier off) and sweeps the SPE manual's full sub-band table on demand. Opt-in via the `flex:` config section. Triggerable from MacExpert's SWEEP panel, the bundled web dashboard's SWEEP button, the Node-RED `/ui` SPE panel, and the Vue `/shack` SPE card — all four UIs read the same `tune_event` broadcasts.
- **Orchestrated TUNE + band sweep (multi-radio)** — drives a transmit rig to key a clean carrier while the SPE's ATU sweeps: a **FlexRadio 6000** over SmartSDR **or** an **Expert Electronics SunSDR / ExpertSDR3** over TCI. Runs the SM5TOG-style ATU tune flow (carrier on → watch RCU TUNE-LED bit → carrier off) and sweeps the SPE manual's full sub-band table on demand. The active rig is chosen by `radio.kind` and can be picked/configured live from the client. Triggerable from MacExpert's SWEEP panel, the bundled web dashboard's SWEEP button, the Node-RED `/ui` SPE panel, and the Vue `/shack` SPE card — all read the same `tune_event` broadcasts.
- **Client-selected radio** — the operator picks Flex vs SunSDR and edits host/port/etc from the client (a `RADIO` settings panel in the bundled dashboard); the change applies live on the Pi and persists to `config.yaml`. No SSH, no restart.
- **Flex auto-discovery** — leave `flex.host` empty in `config.yaml` and spe-remote listens for the SmartSDR UDP broadcast on port 4992; the radio's IP and model are picked up automatically. Static config still wins when set.
- **Self-contained** — single process serves both WebSocket API and web UI (no Apache/Nginx needed)
- **Multi-client** — multiple browsers/devices can monitor the amplifier simultaneously
Expand Down Expand Up @@ -232,13 +233,32 @@ This is the bundled dashboard. To also drive the amp from Node-RED on the same P

## Orchestrated TUNE and Band Sweep

When `flex.enabled: true` is set in `config.yaml`, spe-remote opens a second connection — TCP to a **FlexRadio 6000-series** rig over the SmartSDR API — and exposes three additional WebSocket commands that any client (MacExpert, browser dashboard, Node-RED) can call:
spe-remote can drive a transmit rig to key a clean carrier while the SPE's ATU sweeps. Set `radio.kind` in `config.yaml` to pick the backend — `flex` (FlexRadio over SmartSDR), `tci` (Expert Electronics SunSDR / ExpertSDR3 over TCI), or `none` — and it exposes these additional WebSocket commands that any client (MacExpert, browser dashboard, Node-RED) can call:

| WS command | What it does |
|---|---|
| `tune_single` | Run one ATU tune cycle on the Flex's current slice freq. Sends SPE TUNE keycode, waits for the front-panel TUNE LED to come on (RCU byte 4 bit 6), tells the Flex to emit a 10 W carrier, waits for the LED to go off (ATU done), cuts the carrier. No blind timing. |
| `radio_connect` | Open the rig connection. Sent when a client opens its Sweep menu, so the radio is ready by the time the operator hits Start. Idempotent. (`flex_connect` is a back-compat alias.) |
| `radio_disconnect` | Close the rig connection. Sent when a client closes its Sweep menu while idle. Ignored while a tune cycle is running. (`flex_disconnect` alias.) |
| `tune_single` | Run one ATU tune cycle on the rig's current freq. Sends SPE TUNE keycode, waits for the front-panel TUNE LED to come on (RCU byte 4 bit 6), keys the rig's tune carrier, waits for the LED to go off (ATU done), cuts the carrier. No blind timing. |
| `tune_band:<band>` | Sweep the SPE manual's recommended in-band sub-band centers for `<band>` (`160m`, `80m`, `60m`, …, `6m`). Saves the operator's pre-sweep VFO freq + mode, hits each sub-band in turn, restores the VFO at the end. |
| `tune_stop` | Abort an in-progress single tune or sweep. The carrier-off command runs in a `finally` block — a stopped cycle always drops the carrier before exiting. |
| `get_config` / `set_radio_config:<json>` | Read / live-change the active radio + its settings from the client. See [Client-driven radio config](#client-driven-radio-config). |

The tune **sequence is radio-agnostic** — only the per-rig commands differ:

| Step | Flex (SmartSDR) | SunSDR (TCI) |
|---|---|---|
| Set freq | `slice t <s> <MHz>` | `vfo:<trx>,0,<Hz>;` |
| Set mode | `slice s <s> mode=CWU` | `modulation:<trx>,CW;` |
| Tune carrier | `transmit tune on/off` | `tune:<trx>,true/false;` |

**On-demand connection (the radio is only held while tuning).** spe-remote does **not** open the rig session at startup. It connects when the operator opens the Sweep menu (`radio_connect`) and drops it again as soon as the tune cycle or band sweep finishes — so the radio isn't marked "in use" the rest of the time, and it can be powered off until you actually need it (host resolution is deferred too). As a safety net the server also connects lazily at the start of any `tune_single` / `tune_band`, so a client that never sends `radio_connect` still works. Connection transitions broadcast as `tune_event` phases `RADIO_CONNECTING` → `RADIO_CONNECTED` → `RADIO_DISCONNECTED` (or `RADIO_ERROR`).

### Client-driven radio config

The active rig and its settings live in `config.yaml` (`radio.kind` + a `flex:` and a `tci:` section), but a client doesn't need to touch the file or restart the service. Sending `get_config` returns the current radio config as `{"config_event":"radio","radio":{kind, flex:{…}, tci:{…}}}`; sending `set_radio_config:<json>` (e.g. `{"kind":"tci","tci":{"host":"127.0.0.1","port":50001}}`) switches/edits the rig **live** — spe-remote disconnects the old rig, rebuilds the backend, rewrites `config.yaml` (preserving comments), and broadcasts the new config. The bundled dashboard's **RADIO** button is a working example. Changes are refused while a tune is running. Full contract: [`docs/CLIENT_RADIO_CONFIG.md`](docs/CLIENT_RADIO_CONFIG.md).

**SunSDR / TCI notes.** TCI is a WebSocket text protocol (default port 50001). `tci.trx` selects which receiver to key; tune power is left to ExpertSDR unless `tci.tune_drive` (percent) is set. Command set verified against the [sm5tog/sm5k-spe-tuner](https://github.com/sm5tog/sm5k-spe-tuner) reference.

Phase progress streams back to every connected client as JSON broadcasts on the same WS:

Expand Down Expand Up @@ -266,7 +286,7 @@ Four UIs render the same broadcast stream as a sweep panel:
| Node-RED `/ui` | `http://<pi>:1880/ui` SPE tab | SWEEP button on the SPE Panel; collapsible panel below |
| Vue `/shack` | `http://<pi>/shack` SPE card | SWEEP in the 4-button controls grid; expandable panel inside the card |

All four send `tune_band:<band>` / `tune_stop` over the same WS and consume the same `tune_event` JSON, so the Pi-side orchestrator is the single source of truth.
All four send `tune_band:<band>` / `tune_stop` over the same WS and consume the same `tune_event` JSON, so the Pi-side orchestrator is the single source of truth. The bundled web dashboard also sends `flex_connect` / `flex_disconnect` as its Sweep panel opens and closes; the other clients can adopt those for a faster first tune, but don't have to — the server connects lazily at tune start regardless.

### Flex auto-discovery

Expand Down Expand Up @@ -608,6 +628,20 @@ Clients send bare command names as WebSocket text messages. The server dispatche

> **Alias:** `gain` is kept as an alias for `power_level` for backward compatibility with the original OH2GEK client.

**Radio tune/sweep commands** (when `radio.kind` is `flex` or `tci` — see [Orchestrated TUNE and Band Sweep](#orchestrated-tune-and-band-sweep)):

| Command | Action |
|---|---|
| `radio_connect` | Open the rig connection (on Sweep-menu open). Idempotent. (`flex_connect` alias.) |
| `radio_disconnect` | Close it (on idle Sweep-menu close). Ignored mid-tune. (`flex_disconnect` alias.) |
| `tune_single` | One ATU tune cycle at the rig's current freq |
| `tune_band:<band>` | Sweep the manual's sub-bands for `<band>` (e.g. `tune_band:20m`) |
| `tune_stop` | Abort an in-progress tune/sweep (always drops the carrier) |
| `get_config` | Reply with the current radio config (`config_event:"radio"`) |
| `set_radio_config:<json>` | Switch/edit the active radio live + persist (see [`docs/CLIENT_RADIO_CONFIG.md`](docs/CLIENT_RADIO_CONFIG.md)) |

Progress streams back as `{"tune_event": <phase>, "tune_message": <text>, "ts": <t>}` — see the linked section for the full phase vocabulary, including the `RADIO_CONNECTING` / `RADIO_CONNECTED` / `RADIO_DISCONNECTED` connection-lifecycle phases.

### Example: JavaScript Client

```javascript
Expand Down
26 changes: 21 additions & 5 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,33 @@ amp:
# and to scale the temperature gauge.
temperature_unit: C # C or F

# Optional FlexRadio 6000-series control for orchestrated TUNE + band
# sweep. Leave enabled: false to run spe-remote exactly as before.
# When enabled, spe-remote opens a second connection (SmartSDR TCP API)
# and exposes the tune_single / tune_band / tune_stop WS commands. See
# README "Orchestrated TUNE and Band Sweep" for the full flow.
# Which radio drives the orchestrated TUNE + band sweep:
# flex → FlexRadio 6000 over SmartSDR (the `flex:` section below)
# tci → ExpertSDR3 / SunSDR over TCI (the `tci:` section below)
# none → no rig; tune commands fail cleanly
# A client (MacExpert / web dashboard) can change this and the per-radio
# settings live over the WebSocket — see README "Client-driven radio config".
radio:
kind: flex # flex | tci | none

# FlexRadio 6000-series control over the SmartSDR TCP API. Used when
# radio.kind is flex. (enabled is kept in sync with radio.kind for
# back-compat with configs written before the selector existed.)
flex:
enabled: true
host: "192.168.1.148" # Static LAN IP of the Flex; leave empty ("") to auto-discover via SmartSDR UDP broadcast on port 4992
port: 4992 # SmartSDR TCP control port
slice_rx: 0 # Which slice to drive during tune cycles
tune_power_watts: 10 # Carrier power for ATU tunes; SPE wants 2-15 W

# Expert Electronics SunSDR / ExpertSDR3 control over TCI. Used when
# radio.kind is tci. TCI is a WebSocket text protocol (default port 50001).
tci:
host: "127.0.0.1" # ExpertSDR3 / SunSDR TCI host
port: 50001 # TCI WebSocket port
trx: 0 # which TRX/receiver to key (0 or 1)
mode: CW # mode set on the tuned TRX
tune_drive: 0 # tune-power percent; 0 = leave to ExpertSDR

logging:
level: INFO # DEBUG, INFO, WARNING, ERROR
6 changes: 6 additions & 0 deletions configtool.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,17 @@ def _fmt_qstr(v):
SUPPORTED = {
("serial", "port"): _fmt_plain,
("server", "port"): _fmt_int,
("radio", "kind"): _fmt_plain,
("flex", "enabled"): _fmt_bool,
("flex", "host"): _fmt_qstr,
("flex", "port"): _fmt_int,
("flex", "slice_rx"): _fmt_int,
("flex", "tune_power_watts"): _fmt_int,
("tci", "host"): _fmt_qstr,
("tci", "port"): _fmt_int,
("tci", "trx"): _fmt_int,
("tci", "mode"): _fmt_plain,
("tci", "tune_drive"): _fmt_int,
}

FLEX_DEFAULTS = {
Expand Down
115 changes: 115 additions & 0 deletions docs/CLIENT_RADIO_CONFIG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Client integration spec — multi-radio tune + client-selected radio config

Target audience: client apps that drive spe-remote's orchestrated TUNE / band
sweep — **MacExpert** (native macOS), the bundled web dashboard, Node-RED, the
Vue `/shack` card. This documents the WebSocket contract added by the
multi-radio work so a client can (a) drive a Flex **or** a SunSDR/TCI rig
transparently, and (b) let the operator pick & configure the radio at runtime.

The bundled web dashboard already implements all of this (`web/app.js`,
`web/index.html`) — use it as the reference.

## Background

spe-remote now drives the tune rig through a generic backend chosen by
`radio.kind` on the Pi: `flex` (FlexRadio/SmartSDR), `tci` (ExpertSDR3 / SunSDR
over the TCI WebSocket protocol), or `none`. The connection is **on-demand**
(opened on Sweep-menu open / tune start, closed when the cycle ends) and the
active radio can be **changed live by a client** — no restart.

## WebSocket commands (client → server)

Send as plain text WS messages (same socket as everything else, `ws://<pi>:8888/ws`).

| Command | When | Effect |
|---|---|---|
| `radio_connect` | Sweep menu opens | Pre-warm the radio connection. Idempotent. (Old alias: `flex_connect`.) |
| `radio_disconnect` | Sweep menu closes while idle | Drop the connection. Ignored mid-tune. (Old alias: `flex_disconnect`.) |
| `get_config` | On connect, and when opening the radio settings UI | Server replies with the current radio config (see below). |
| `set_radio_config:<json>` | Operator applies a radio choice / settings edit | Switch/edit the radio live + persist to `config.yaml`. Refused while a tune runs. |
| `tune_single` / `tune_band:<band>` / `tune_stop` | unchanged | Drive a tune cycle / sweep / abort. |

### `set_radio_config:<json>` payload

JSON after the `:`; send only the section for the chosen kind.

```json
{"kind": "tci", "tci": {"host": "127.0.0.1", "port": 50001, "trx": 0, "mode": "CW", "tune_drive": 0}}
```
```json
{"kind": "flex", "flex": {"host": "192.168.1.148", "port": 4992, "slice_rx": 0, "tune_power_watts": 10}}
```
```json
{"kind": "none"}
```
- `kind` ∈ `flex | tci | none`. Unknown values are rejected with `RADIO_ERROR`.
- All section fields are optional; omitted fields keep their stored value.
- `host` empty for `flex` ⇒ UDP auto-discovery.

## Server → client messages

### Radio config snapshot — `config_event: "radio"`
Sent in reply to `get_config`, and broadcast to all clients after a successful
`set_radio_config`. Use it to populate the radio picker / settings form.

```json
{
"config_event": "radio",
"radio": {
"kind": "flex",
"flex": {"host": "192.168.1.148", "port": 4992, "slice_rx": 0, "tune_power_watts": 10},
"tci": {"host": "127.0.0.1", "port": 50001, "trx": 0, "mode": "CW", "tune_drive": 0}
}
}
```

### Tune/connection events — `tune_event` (unchanged channel)
`{"tune_event": "<PHASE>", "tune_message": "...", "ts": <epoch>}`. Existing tune
phases are unchanged (STARTED, PREFLIGHT_OK, VFO_SAVED, FREQ_SET, TUNE_SENT,
LED_ON, CARRIER_ON, LED_OFF, CARRIER_OFF, VFO_RESTORED, SUCCESS, FAIL, ABORT,
SWEEP_STARTED, SWEEP_STEP, SWEEP_DONE). **New phases:**

| Phase | Meaning |
|---|---|
| `RADIO_CONNECTING` | Opening the rig connection (or discovering a Flex). |
| `RADIO_CONNECTED` | Connected; message carries kind + host + version. |
| `RADIO_DISCONNECTED` | Connection closed (housekeeping after a cycle). |
| `RADIO_ERROR` | Connect/config failed; message says why (e.g. radio off). |
| `RADIO_CONFIG_UPDATED` | A `set_radio_config` was applied (radio switched). |

**Client handling:** treat `RADIO_*` like the old `FLEX_*` — they are *not*
tune progress. Don't flip sweeping state on them; surface `RADIO_ERROR` to the
user; ignore `RADIO_DISCONNECTED` / `RADIO_CONFIG_UPDATED` in the sweep status
(handle `RADIO_CONFIG_UPDATED`'s effect via the `config_event` message instead).
The phase string is open-ended — latch on the well-known terminals
(SUCCESS / FAIL / ABORT / SWEEP_DONE) and treat anything unknown as info.

## MacExpert UX guidance (for the client implementation)

- **Backward-compat first:** rename the existing `flexConnect()`/`flexDisconnect()`
sends to `radio_connect`/`radio_disconnect` (the server accepts both), and
extend the `FLEX_*` handling in `handleTuneEvent` to also match `RADIO_*`
(keep `FLEX_*` for older servers). This alone keeps MacExpert working against
the new server with no UI change.
- **Radio settings sheet:** a small settings sheet (gear button, or a section in
the existing Settings) that:
1. on appear, sends `get_config` and renders the `config_event:"radio"` reply;
2. shows a segmented picker **None / FlexRadio / SunSDR (TCI)** bound to `kind`;
3. shows the fields for the selected kind (Flex: host, port, slice, tune W;
TCI: host, port, trx, mode, tune %);
4. an **Apply** button sends `set_radio_config:<json>` with just the chosen
section; disable Apply while `vm.isSweeping`.
- **Model:** add a `RadioConfig` Decodable mirroring the JSON above; store the
last snapshot on the view model so the sheet and the Sweep panel can show
which rig is active. Surface `RADIO_ERROR` in the existing error banner (as the
on-demand work already does for `FLEX_ERROR`).
- The Sweep panel's `canStart` check is unchanged (WS mode + connected); the rig
kind is transparent to it.

## Notes / constraints

- The WS is unauthenticated on the LAN (same trust model as the existing live
`set_temp_unit` config write). `set_radio_config` rewrites `config.yaml` on the
Pi (comment-preserving) and is refused while a tune is running.
- One rig at a time. Switching kind disconnects the current rig first.
- TCI tune power: ExpertSDR owns it unless `tci.tune_drive` (percent) is set > 0.
Loading