diff --git a/README.md b/README.md index ddfdad8..b64d245 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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:` | Sweep the SPE manual's recommended in-band sub-band centers for `` (`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:` | 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 ` | `vfo:,0,;` | +| Set mode | `slice s mode=CWU` | `modulation:,CW;` | +| Tune carrier | `transmit tune on/off` | `tune:,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:` (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: @@ -266,7 +286,7 @@ Four UIs render the same broadcast stream as a sweep panel: | Node-RED `/ui` | `http://:1880/ui` SPE tab | SWEEP button on the SPE Panel; collapsible panel below | | Vue `/shack` | `http:///shack` SPE card | SWEEP in the 4-button controls grid; expandable panel inside the card | -All four send `tune_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:` / `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 @@ -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:` | Sweep the manual's sub-bands for `` (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:` | Switch/edit the active radio live + persist (see [`docs/CLIENT_RADIO_CONFIG.md`](docs/CLIENT_RADIO_CONFIG.md)) | + +Progress streams back as `{"tune_event": , "tune_message": , "ts": }` — see the linked section for the full phase vocabulary, including the `RADIO_CONNECTING` / `RADIO_CONNECTED` / `RADIO_DISCONNECTED` connection-lifecycle phases. + ### Example: JavaScript Client ```javascript diff --git a/config.yaml b/config.yaml index 3eed0d3..45a13e7 100644 --- a/config.yaml +++ b/config.yaml @@ -32,11 +32,18 @@ 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 @@ -44,5 +51,14 @@ flex: 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 diff --git a/configtool.py b/configtool.py index 9ddf64c..0b79016 100755 --- a/configtool.py +++ b/configtool.py @@ -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 = { diff --git a/docs/CLIENT_RADIO_CONFIG.md b/docs/CLIENT_RADIO_CONFIG.md new file mode 100644 index 0000000..e26042a --- /dev/null +++ b/docs/CLIENT_RADIO_CONFIG.md @@ -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://: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:` | 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:` / `tune_stop` | unchanged | Drive a tune cycle / sweep / abort. | + +### `set_radio_config:` 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": "", "tune_message": "...", "ts": }`. 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:` 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. diff --git a/server.py b/server.py index 7253e31..5e5a84c 100644 --- a/server.py +++ b/server.py @@ -21,7 +21,7 @@ from spe.serial_handler import SerialHandler from spe.power_control import PowerController from spe.websocket_handler import AmplifierWebSocket -from spe.flex import FlexConnection, discover as flex_discover +from spe.radio_controller import RadioController from spe.tune_orchestrator import TuneOrchestrator @@ -116,64 +116,39 @@ def main() -> None: serial_handler=serial_handler, ) - # Optional Flex 6000 control — Phase 2 of the band-sweep work. - # When flex.enabled is true, connect to SmartSDR's TCP API and - # create a tune orchestrator that can drive an ATU tune cycle via - # the (SPE TUNE keycode + Flex carrier) combination. flex.host - # picks the radio: set explicitly to an IP, or leave empty to - # auto-discover via the SmartSDR UDP broadcast on port 4992. - # Disabled by default; spe-remote behaves exactly as before when - # the section is omitted from config.yaml. - flex_connection = None - tune_orchestrator = None - if config.flex.enabled: - flex_host = config.flex.host - if not flex_host: - logger.info( - "Flex: flex.host empty — listening for SmartSDR discovery " - "broadcast on UDP 4992 (up to 5s)…" - ) - # Run discovery synchronously here; spe-remote startup blocks - # on it for a few seconds at most. Doing this on the main - # loop is fine because nothing else is running yet — the - # serial reader / tornado IOLoop haven't started. - try: - discovery = asyncio.get_event_loop().run_until_complete( - flex_discover() - ) - except Exception: - logger.exception("Flex discovery raised; skipping") - discovery = None - if discovery and discovery.get("ip"): - flex_host = discovery["ip"] - logger.info( - f"Flex: discovered {discovery.get('model','?')} " - f"\"{discovery.get('nickname','?')}\" " - f"({discovery.get('callsign','?')}) at {flex_host}" - ) - else: - logger.warning( - "Flex: discovery timed out and flex.host is empty — " - "Flex disabled for this session" - ) - if flex_host: - flex_connection = FlexConnection(flex_host, config.flex.port) - tune_orchestrator = TuneOrchestrator( - serial_handler=serial_handler, - flex=flex_connection, - config=config.flex, - on_status=AmplifierWebSocket.broadcast_tune_event, - ) - logger.info( - f"Flex control enabled: host={flex_host}:{config.flex.port} " - f"slice={config.flex.slice_rx} " - f"tune_power={config.flex.tune_power_watts}W" - ) + # Optional radio control for orchestrated TUNE + band sweep. The + # RadioController drives the rig chosen by radio.kind — a FlexRadio + # 6000 over SmartSDR ("flex") or an ExpertSDR3/SunSDR over TCI + # ("tci") — through one generic interface. "none" disables tuning. + # + # On-demand lifecycle: the controller does NOT open the control + # session at startup. It connects when a client opens its Sweep menu + # (the radio_connect WS command) or, as a safety net, lazily at the + # start of a tune cycle, and disconnects when the cycle is over. The + # active kind/settings can be changed live by a client (set_radio_config) + # without a restart. Always built so a client can switch a "none" config + # to flex/tci at runtime; tunes simply FAIL while kind == "none". + radio_controller = RadioController( + config.radio, config.flex, config.tci, + on_status=AmplifierWebSocket.broadcast_tune_event, + ) + tune_orchestrator = TuneOrchestrator( + serial_handler=serial_handler, + radio_controller=radio_controller, + on_status=AmplifierWebSocket.broadcast_tune_event, + ) + logger.info( + "Radio control: kind=%s — connects on Sweep-menu open / tune start", + config.radio.kind, + ) AmplifierWebSocket.configure( serial_handler=serial_handler, power_controller=power_controller, tune_orchestrator=tune_orchestrator, + radio_controller=radio_controller, + app_config=config, + config_path=config_path, heartbeat=config.polling.heartbeat, ) @@ -222,33 +197,15 @@ def shutdown(sig, frame): f"(amp_alive_threshold={config.polling.amp_alive_threshold:.1f}s)" ) - # If a Flex is configured, kick off a connect task. Failure here - # shouldn't take down the whole server — clients can still talk to - # the amp; the tune commands will surface a clear error message - # back through the WS instead. - flex_task = None - if flex_connection is not None: - async def _flex_connect(): - try: - await flex_connection.connect() - logger.info( - f"Flex: connected (version={flex_connection.radio_version!r}, " - f"handle={flex_connection.client_handle!r})" - ) - except Exception: - logger.exception( - "Flex: initial connect failed; tune_single will retry " - "on each command" - ) - flex_task = loop.create_task(_flex_connect()) + # No radio connect at startup — the RadioController opens the control + # session on demand (Sweep-menu open / tune start) and closes it when + # the cycle is over. See the on-demand lifecycle note above. try: tornado.ioloop.IOLoop.current().start() finally: # Ensure background tasks are cancelled if still running cleanup_tasks = [serial_task, heartbeat_task] - if flex_task is not None: - cleanup_tasks.append(flex_task) for task in cleanup_tasks: try: if not task.done(): @@ -256,12 +213,12 @@ async def _flex_connect(): except Exception: pass - # Close the Flex socket so we don't leak a half-open TCP session. - if flex_connection is not None: + # Close the radio socket if a tune cycle left it open. + if radio_controller is not None: try: - loop.run_until_complete(flex_connection.close()) + loop.run_until_complete(radio_controller.disconnect()) except Exception: - logger.exception("Error while closing Flex connection") + logger.exception("Error while closing radio connection") # Make a best-effort to stop serial handler and close resources try: diff --git a/spe/config.py b/spe/config.py index 06fd9a2..f31a8a4 100644 --- a/spe/config.py +++ b/spe/config.py @@ -54,6 +54,33 @@ class FlexConfig: tune_power_watts: int = 10 # Carrier power for ATU tunes; SPE wants 2-15W +@dataclass +class TciConfig: + """Connection to an ExpertSDR3 / SunSDR radio over TCI. + + TCI is the WebSocket text protocol Expert Electronics radios speak + (default port 50001). An alternative tune backend to Flex — see + spe/tci.py. ``trx`` selects which receiver to key. + """ + host: str = "127.0.0.1" # ExpertSDR3 / SunSDR TCI host + port: int = 50001 # TCI WebSocket port + trx: int = 0 # which TRX/receiver to drive (0 or 1) + mode: str = "CW" # mode set on the tuned TRX + tune_drive: int = 0 # tune-power percent; 0 ⇒ leave to ExpertSDR + + +@dataclass +class RadioConfig: + """Which tune backend is active. + + ``kind`` selects the radio family the tune orchestrator drives: + ``"flex"`` (SmartSDR), ``"tci"`` (ExpertSDR3 / SunSDR), or ``"none"`` + (no rig — tune commands fail cleanly). Clients can change this at + runtime over the WebSocket; it persists back to config.yaml. + """ + kind: str = "none" # flex | tci | none + + @dataclass class AmpConfig: """Amp-side characteristics that the protocol doesn't report. @@ -73,6 +100,8 @@ class AppConfig: polling: PollingConfig = field(default_factory=PollingConfig) amp: AmpConfig = field(default_factory=AmpConfig) flex: FlexConfig = field(default_factory=FlexConfig) + tci: TciConfig = field(default_factory=TciConfig) + radio: RadioConfig = field(default_factory=RadioConfig) log_level: str = "INFO" @@ -114,6 +143,93 @@ def persist_temperature_unit(unit: str, path: str = "config.yaml") -> bool: return False +def _fmt_value(value) -> str: + """Format a Python value as the YAML scalar to write. Booleans → + true/false, ints bare, strings quoted only when they look like a host + (contain a dot/colon) or are empty — matching config.yaml's style + (hosts quoted, barewords like ``flex``/``CW`` unquoted).""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + s = str(value) + if s == "" or "." in s or ":" in s: + return '"%s"' % s + return s + + +_SECTION_HDR = re.compile(r"^([A-Za-z0-9_]+):\s*(#.*)?$") +_KEY_LINE = re.compile(r"^(\s+)([A-Za-z0-9_]+)(\s*:\s*)(.*?)(\s+#.*)?\s*$") + + +def persist_values(changes: dict, path: str = "config.yaml") -> bool: + """Write ``changes`` into ``config.yaml`` in place, preserving comments. + + ``changes`` maps dotted ``"section.key"`` to a value. Uses section-aware + line substitution (the same comment-preserving approach as + :func:`persist_temperature_unit`) so the rest of the file — including + all explanatory comments — survives. Keys not present are inserted under + their section (creating the section if needed). Returns True on success. + """ + p = Path(path) + if not p.exists(): + logger.warning(f"Cannot persist config: {path} does not exist") + return False + + by_section: dict = {} + for dotted, val in changes.items(): + section, key = dotted.split(".", 1) + by_section.setdefault(section, {})[key] = val + remaining = {(s, k) for s, kv in by_section.items() for k in kv} + + text = p.read_text() + out, cur = [], None + for line in text.split("\n"): + m = _SECTION_HDR.match(line) + if m: + cur = m.group(1) + out.append(line) + continue + if cur in by_section: + km = _KEY_LINE.match(line) + if km and km.group(2) in by_section[cur]: + indent, k, sep, _old, comment = km.groups() + out.append(f"{indent}{k}{sep}{_fmt_value(by_section[cur][k])}{comment or ''}") + remaining.discard((cur, k)) + continue + out.append(line) + + # Insert any keys/sections that didn't already exist. + for section in by_section: + missing = {k: by_section[section][k] for s, k in remaining if s == section} + if not missing: + continue + block = [f" {k}: {_fmt_value(v)}" for k, v in missing.items()] + hdr_idx = next( + (i for i, l in enumerate(out) + if re.match(rf"^{re.escape(section)}:\s*(#.*)?$", l)), + None, + ) + if hdr_idx is not None: + out[hdr_idx + 1:hdr_idx + 1] = block + else: + if out and out[-1].strip() != "": + out.append("") + out.append(f"{section}:") + out.extend(block) + + new_text = "\n".join(out) + if new_text == text: + return True + try: + p.write_text(new_text) + logger.info(f"Persisted {len(changes)} config value(s) to {path}") + return True + except OSError as e: + logger.warning(f"Failed to persist config: {e}") + return False + + def load_config(path: str = "config.yaml") -> AppConfig: config = AppConfig() config_path = Path(path) @@ -151,6 +267,22 @@ def load_config(path: str = "config.yaml") -> AppConfig: if hasattr(config.flex, k): setattr(config.flex, k, v) + if "tci" in raw: + for k, v in raw["tci"].items(): + if hasattr(config.tci, k): + setattr(config.tci, k, v) + + if "radio" in raw and raw["radio"].get("kind"): + config.radio.kind = str(raw["radio"]["kind"]).strip().lower() + else: + # Back-compat: configs written before the radio.kind selector + # only had flex.enabled. Treat enabled Flex as kind=flex so + # existing installs keep working untouched. + config.radio.kind = "flex" if config.flex.enabled else "none" + if config.radio.kind not in ("flex", "tci", "none"): + logger.warning(f"Unknown radio.kind {config.radio.kind!r}; using 'none'") + config.radio.kind = "none" + if "logging" in raw: config.log_level = raw["logging"].get("level", "INFO") diff --git a/spe/flex.py b/spe/flex.py index 28b007e..6942ffa 100644 --- a/spe/flex.py +++ b/spe/flex.py @@ -29,6 +29,8 @@ import logging from typing import Any, Callable, Optional +from spe.radio import RadioConnection + logger = logging.getLogger(__name__) FLEX_TCP_PORT = 4992 @@ -57,7 +59,7 @@ class FlexProtocolError(Exception): """Raised when the radio rejects a command (non-zero status code).""" -class FlexConnection: +class FlexConnection(RadioConnection): """Async client for one Flex 6000-series radio. Lifecycle: @@ -149,6 +151,14 @@ async def connect(self) -> None: logger.warning("Flex: could not subscribe to slice events; " "slice_state will stay empty") + @property + def is_connected(self) -> bool: + """True while the TCP socket is open and the reader loop is live. + + Used by :class:`spe.flex_controller.FlexController` to make + connect/disconnect idempotent in the on-demand lifecycle.""" + return self._writer is not None and self._reader_task is not None + async def close(self) -> None: """Shut down the connection. Idempotent.""" if self._reader_task is not None: @@ -361,6 +371,52 @@ async def slice_list(self) -> str: Useful for confirming connectivity without keying anything.""" return await self.send("slice list") + # ------------------------------------------------------------------ + # RadioConnection interface (generic names the orchestrator uses) + # ------------------------------------------------------------------ + # + # Thin adapters over the slice-oriented methods above so the tune + # orchestrator can drive a Flex and a SunSDR/TCI rig through one API. + # ``channel`` is the Flex slice index. + + async def set_frequency(self, channel: int, freq_mhz: float) -> None: + await self.set_slice_freq(channel, freq_mhz) + + async def set_mode(self, channel: int, mode: str) -> None: + # The orchestrator asks for a generic "CW"; on a Flex the clean + # tune carrier wants an actual CW sub-mode. Map CW→CWU; pass any + # other mode (e.g. a restored "USB"/"LSB") through verbatim. + flex_mode = "CWU" if mode.strip().upper() == "CW" else mode + await self.set_slice_mode(channel, flex_mode) + + def snapshot(self, channel: int) -> Optional[dict]: + """Capture the slice's current freq+mode from the subscribed + ``slice_state`` cache (populated by ``sub slice all``). Returns + None when the cache hasn't been seen yet — the orchestrator then + skips restore.""" + state = self.slice_state.get(channel) + if not state: + return None + freq = state.get("RF_frequency") + mode = state.get("mode") + if freq is None and mode is None: + return None + return {"channel": channel, "freq": freq, "mode": mode} + + async def restore(self, snap: Optional[dict]) -> None: + if snap is None: + return + channel = snap["channel"] + freq = snap.get("freq") + mode = snap.get("mode") + try: + if freq is not None: + await self.set_slice_freq(channel, float(freq)) + if mode is not None: + await self.set_slice_mode(channel, mode) + except Exception: + logger.exception("Flex: failed to restore slice freq+mode") + # ────────────────────────────────────────────────────────────────── # UDP discovery diff --git a/spe/radio.py b/spe/radio.py new file mode 100644 index 0000000..927bbcb --- /dev/null +++ b/spe/radio.py @@ -0,0 +1,83 @@ +"""Radio backend abstraction for the SPE tune orchestrator. + +spe-remote drives an external rig to key a clean carrier while the SPE's +ATU sweeps. Originally that rig was always a FlexRadio 6000 over the +SmartSDR TCP API; this module factors out the small set of primitives the +:class:`spe.tune_orchestrator.TuneOrchestrator` actually needs, so other +radio families can be plugged in — notably Expert Electronics SunSDR over +the TCI protocol (see :mod:`spe.tci`). + +A backend only has to do five things during a tune cycle: + + * open / close its control connection, + * set the operating frequency and mode on a channel, + * (optionally) set the tune-carrier power, + * key the tune carrier on / off, + * and snapshot / restore the operator's VFO around the cycle. + +``channel`` is the backend's notion of "which receiver/slice to drive": +the Flex *slice* index or the TCI *trx* index. The orchestrator passes the +value straight through from config, so each backend interprets it natively. + +Frequencies cross this interface in **MHz** (the orchestrator and the SPE +band table work in MHz); a backend converts to its own wire unit (TCI uses +Hz, for instance). Modes cross as the generic string ``"CW"``; a backend +maps it to whatever its protocol wants (Flex wants ``CWU``). +""" + +from __future__ import annotations + +import abc +from typing import Optional + + +class RadioConnection(abc.ABC): + """Minimal control surface the tune orchestrator needs from a rig.""" + + #: Firmware / version string the radio reported on connect (best + #: effort; empty until known). Surfaced in status messages. + radio_version: str = "" + + @property + @abc.abstractmethod + def is_connected(self) -> bool: + """True while the control connection is open and usable.""" + + @abc.abstractmethod + async def connect(self) -> None: + """Open the control connection. Raises on failure.""" + + @abc.abstractmethod + async def close(self) -> None: + """Close the control connection. Idempotent; must not raise.""" + + @abc.abstractmethod + async def set_frequency(self, channel: int, freq_mhz: float) -> None: + """Tune ``channel`` to ``freq_mhz`` (MHz).""" + + @abc.abstractmethod + async def set_mode(self, channel: int, mode: str) -> None: + """Set ``channel`` to ``mode`` (generic, e.g. ``"CW"``).""" + + @abc.abstractmethod + async def set_tune_power(self, watts: int) -> None: + """Set the tune-carrier power in watts. A backend that has no + wire control for this (power set in the radio's own UI) may treat + it as a no-op.""" + + @abc.abstractmethod + async def tune_carrier(self, on: bool) -> None: + """Key (on=True) or unkey (on=False) the built-in tune carrier.""" + + @abc.abstractmethod + def snapshot(self, channel: int) -> Optional[dict]: + """Capture ``channel``'s current freq+mode so it can be restored + after the cycle. Returns an opaque dict for :meth:`restore`, or + None when the backend doesn't yet know the state (restore is then + skipped). Synchronous: reads a cache populated by the backend's + own event stream.""" + + @abc.abstractmethod + async def restore(self, snap: Optional[dict]) -> None: + """Write a :meth:`snapshot` result back. No-op when ``snap`` is + None. Best effort — should log rather than raise.""" diff --git a/spe/radio_controller.py b/spe/radio_controller.py new file mode 100644 index 0000000..4f7adb1 --- /dev/null +++ b/spe/radio_controller.py @@ -0,0 +1,213 @@ +"""On-demand lifecycle manager for the tune radio (any backend). + +Generalises the former FlexController: the SmartSDR/TCI control session is +only needed while the operator is actually running a tune cycle / sweep, +so it is opened on demand (Sweep-menu open, or lazily at tune start) and +closed when the cycle is over. This keeps the radio free for other clients +and lets it be powered off until needed. + +The backend is chosen by ``radio.kind``: + + * ``flex`` → :class:`spe.flex.FlexConnection` (SmartSDR TCP, host may be + discovered via UDP when ``flex.host`` is empty), + * ``tci`` → :class:`spe.tci.TciConnection` (ExpertSDR3 / SunSDR), + * ``none`` → no radio; tune commands fail cleanly. + +``reconfigure()`` swaps the active kind/settings in place so a client can +switch radios at runtime without restarting the server — the orchestrator +holds one stable reference to this controller across the switch. + +Transitions broadcast on the same ``tune_event`` channel the orchestrator +uses, with these phases: + + RADIO_CONNECTING RADIO_CONNECTED RADIO_DISCONNECTED RADIO_ERROR +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Callable, Optional + +from spe.config import RadioConfig, FlexConfig, TciConfig +from spe.radio import RadioConnection +from spe.flex import FlexConnection, discover as flex_discover +from spe.tci import TciConnection + +logger = logging.getLogger(__name__) + +StatusCallback = Callable[[str, str], None] + + +class RadioController: + """Owns at most one :class:`RadioConnection`, opened/closed on demand.""" + + def __init__( + self, + radio: RadioConfig, + flex: FlexConfig, + tci: TciConfig, + on_status: Optional[StatusCallback] = None, + ): + self.radio = radio + self.flex = flex + self.tci = tci + self.on_status = on_status + + self._conn: Optional[RadioConnection] = None + self._lock = asyncio.Lock() + # Cache a discovered Flex host so we don't re-run UDP discovery. + self._resolved_flex_host: str = flex.host or "" + + # ------------------------------------------------------------------ + # State / accessors the orchestrator reads + # ------------------------------------------------------------------ + + @property + def kind(self) -> str: + return self.radio.kind + + @property + def channel(self) -> int: + """Backend channel to drive: Flex slice or TCI trx.""" + if self.kind == "tci": + return self.tci.trx + return self.flex.slice_rx + + @property + def tune_power_watts(self) -> int: + """Power hint for set_tune_power (Flex watts; TCI ignores it and + uses its own configured tune_drive).""" + return self.flex.tune_power_watts if self.kind == "flex" else 0 + + @property + def connection(self) -> Optional[RadioConnection]: + return self._conn if (self._conn and self._conn.is_connected) else None + + @property + def is_connected(self) -> bool: + return self._conn is not None and self._conn.is_connected + + def _status(self, phase: str, message: str = "") -> None: + logger.info("Radio[%s] %s", phase, message) + cb = self.on_status + if cb is not None: + try: + cb(phase, message) + except Exception: + logger.exception("Radio on_status callback raised") + + # ------------------------------------------------------------------ + # Live reconfiguration + # ------------------------------------------------------------------ + + def reconfigure(self, radio: RadioConfig, flex: FlexConfig, + tci: TciConfig) -> None: + """Swap the active radio kind/settings. Caller must disconnect() + first; the next connect() builds the new backend.""" + self.radio = radio + self.flex = flex + self.tci = tci + self._resolved_flex_host = flex.host or "" + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> Optional[RadioConnection]: + """Ensure a live connection and return it (or None on failure). + + Idempotent. Never raises — failures report RADIO_ERROR and return + None so the orchestrator can FAIL the cycle cleanly.""" + async with self._lock: + if self.is_connected: + return self._conn + + kind = self.kind + if kind == "none": + self._status("RADIO_ERROR", "no radio configured") + return None + + conn = await self._build(kind) + if conn is None: + return None + + self._status("RADIO_CONNECTING", self._target_desc(kind)) + try: + await conn.connect() + except Exception as e: + logger.exception("Radio connect failed") + try: + await conn.close() + except Exception: + pass + self._status("RADIO_ERROR", f"connect failed: {e}") + return None + + self._conn = conn + self._status( + "RADIO_CONNECTED", + f"{kind}: {self._target_desc(kind)} " + f"(version={conn.radio_version or '?'})", + ) + return conn + + async def disconnect(self) -> None: + """Close the connection if open. Idempotent; never raises.""" + async with self._lock: + if self._conn is None: + return + try: + await self._conn.close() + except Exception: + logger.exception("Error closing radio connection") + finally: + self._conn = None + self._status("RADIO_DISCONNECTED") + + # ------------------------------------------------------------------ + # Backend construction + # ------------------------------------------------------------------ + + def _target_desc(self, kind: str) -> str: + if kind == "tci": + return f"{self.tci.host}:{self.tci.port} trx={self.tci.trx}" + host = self._resolved_flex_host or "auto-discover" + return f"{host}:{self.flex.port} slice={self.flex.slice_rx}" + + async def _build(self, kind: str) -> Optional[RadioConnection]: + """Construct (but don't connect) the backend for ``kind``.""" + if kind == "flex": + host = self._resolved_flex_host + if not host: + self._status( + "RADIO_CONNECTING", + "flex.host empty — discovering on UDP 4992 (up to 5s)…", + ) + try: + disc = await flex_discover() + except Exception as e: + logger.exception("Flex discovery raised") + self._status("RADIO_ERROR", f"discovery failed: {e}") + return None + if disc and disc.get("ip"): + host = disc["ip"] + self._resolved_flex_host = host + logger.info("Flex: discovered radio at %s", host) + else: + self._status("RADIO_ERROR", + "no Flex answered discovery (powered on?)") + return None + return FlexConnection(host, self.flex.port) + + if kind == "tci": + if not self.tci.host: + self._status("RADIO_ERROR", "tci.host is empty") + return None + return TciConnection( + self.tci.host, self.tci.port, + mode=self.tci.mode, tune_drive=self.tci.tune_drive, + ) + + self._status("RADIO_ERROR", f"unknown radio kind {kind!r}") + return None diff --git a/spe/tci.py b/spe/tci.py new file mode 100644 index 0000000..7c39eae --- /dev/null +++ b/spe/tci.py @@ -0,0 +1,223 @@ +"""Expert Electronics TCI backend for the SPE tune orchestrator. + +TCI (Transceiver Control Interface) is the WebSocket text protocol spoken +by ExpertSDR3 / SunSDR-series radios. It is line/`;`-oriented, lowercase, +e.g. ``vfo:0,0,14025000;``. Default port is 50001. + +This drives the same SM5TOG-style ATU tune flow the Flex backend does — +the commands are different but the shape is identical (set freq, set mode, +key the tune carrier). Command set verified against the reference +implementation https://github.com/sm5tog/sm5k-spe-tuner: + + * set frequency: ``vfo:,0,;`` + * set mode: ``modulation:,CW;`` + * tune carrier: ``tune:,true;`` / ``tune:,false;`` + * TX status in: ``trx:,true|false`` (reliable; unlike tx_enable) + +Transport is tornado's async WebSocket client, so no extra dependency: the +project already depends on tornado for the server side. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +from tornado.websocket import websocket_connect, WebSocketClientConnection + +from spe.radio import RadioConnection + +logger = logging.getLogger(__name__) + +TCI_PORT = 50001 + +# How long to wait for ExpertSDR3's initial state burst (it streams the +# current vfo/mode/etc. and ends with `ready;` right after connect). We +# wait for `ready` so snapshot() has freq+mode to restore, but don't fail +# if a firmware revision skips it. +_READY_TIMEOUT = 3.0 +_CONNECT_TIMEOUT = 5.0 + + +class TciConnection(RadioConnection): + """Async TCI client for one ExpertSDR3 / SunSDR radio.""" + + def __init__(self, host: str, port: int = TCI_PORT, + mode: str = "CW", tune_drive: int = 0): + self.host = host + self.port = port + self.default_mode = mode or "CW" + # Optional tune-drive percent (0-100). 0 ⇒ leave tune power to + # ExpertSDR's own setting (don't send a drive command). + self.tune_drive = int(tune_drive or 0) + + self._ws: Optional[WebSocketClientConnection] = None + self._read_task: Optional[asyncio.Task] = None + self._ready = asyncio.Event() + # The TRX the orchestrator is driving — set by set_frequency / + # set_mode and keyed by tune_carrier (which takes no channel arg, + # to match the Flex interface). + self._tx_channel = 0 + + # Per-TRX cache populated from the radio's event stream: freq (Hz + # as a string) and mode. Used by snapshot()/restore(). + self.vfo_state: dict[int, dict[str, str]] = {} + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + @property + def is_connected(self) -> bool: + return self._ws is not None + + async def connect(self) -> None: + url = f"ws://{self.host}:{self.port}/" + logger.info("TCI: connecting to %s", url) + self._ready.clear() + self._ws = await websocket_connect(url, connect_timeout=_CONNECT_TIMEOUT) + self._read_task = asyncio.ensure_future(self._read_loop()) + + # ExpertSDR streams current state on connect and ends with + # `ready;`. Wait for it (best effort) so snapshot() has data. + try: + await asyncio.wait_for(self._ready.wait(), timeout=_READY_TIMEOUT) + except asyncio.TimeoutError: + logger.warning("TCI: no `ready;` within %.1fs — continuing", _READY_TIMEOUT) + + # Nudge the radio to (re)emit both TRX VFOs so the cache is fresh. + await self._send(f"vfo:0,0;") + await self._send(f"vfo:1,0;") + logger.info("TCI: connected to %s (version=%r)", self.host, self.radio_version) + + async def close(self) -> None: + if self._read_task is not None: + self._read_task.cancel() + try: + await self._read_task + except (asyncio.CancelledError, Exception): + pass + self._read_task = None + if self._ws is not None: + try: + self._ws.close() + except Exception: + pass + self._ws = None + self._ready.clear() + + # ------------------------------------------------------------------ + # Send / receive + # ------------------------------------------------------------------ + + async def _send(self, message: str) -> None: + if self._ws is None: + raise ConnectionError("TCI not connected") + # tornado's write_message returns a Future; await it so back- + # pressure / write errors surface here rather than being swallowed. + await self._ws.write_message(message) + + async def _read_loop(self) -> None: + assert self._ws is not None + try: + while True: + msg = await self._ws.read_message() + if msg is None: # socket closed by radio + logger.info("TCI: socket closed by radio") + break + if isinstance(msg, bytes): + continue # TCI control channel is text-only + # A single WS frame may carry several `;`-terminated cmds. + for part in msg.split(";"): + self._dispatch(part.strip()) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("TCI: read loop crashed") + + def _dispatch(self, line: str) -> None: + if not line: + return + head, _, rest = line.partition(":") + head = head.lower() + if head == "ready": + self._ready.set() + return + if head == "vfo": + # vfo:,, + parts = rest.split(",") + if len(parts) >= 3: + try: + trx = int(parts[0]) + channel = int(parts[1]) + except ValueError: + return + if channel == 0: # VFO A — the one we tune + self.vfo_state.setdefault(trx, {})["freq"] = parts[2].strip() + return + if head == "modulation": + # modulation:, + parts = rest.split(",") + if len(parts) >= 2: + try: + trx = int(parts[0]) + except ValueError: + return + self.vfo_state.setdefault(trx, {})["mode"] = parts[1].strip() + return + if head in ("device", "protocol") and not self.radio_version: + # device:SunSDR2_PRO,... / protocol:ExpertSDR3,1.9 + self.radio_version = rest.strip() + return + + # ------------------------------------------------------------------ + # RadioConnection interface + # ------------------------------------------------------------------ + + async def set_frequency(self, channel: int, freq_mhz: float) -> None: + self._tx_channel = channel + hz = int(round(freq_mhz * 1_000_000)) + await self._send(f"vfo:{channel},0,{hz};") + + async def set_mode(self, channel: int, mode: str) -> None: + # TCI takes the mode verbatim ("CW" for the tune carrier). + self._tx_channel = channel + await self._send(f"modulation:{channel},{mode.strip().upper()};") + + async def set_tune_power(self, watts: int) -> None: + # TCI has no per-watt tune-power command; ExpertSDR owns the tune + # drive. Apply the configured percent only if the operator set one + # (>0); otherwise leave the radio's own setting alone. The ``watts`` + # hint from the orchestrator is intentionally ignored here. + if self.tune_drive > 0: + await self._send(f"tune_drive:{self.tune_drive};") + + async def tune_carrier(self, on: bool) -> None: + # Keyed per-TRX; channel == trx for TCI. The orchestrator sets the + # frequency/mode on _tx_channel first, so key that same TRX. + await self._send(f"tune:{self._tx_channel},{'true' if on else 'false'};") + + def snapshot(self, channel: int) -> Optional[dict]: + state = self.vfo_state.get(channel) + if not state: + return None + freq = state.get("freq") + mode = state.get("mode") + if freq is None and mode is None: + return None + return {"channel": channel, "freq": freq, "mode": mode} + + async def restore(self, snap: Optional[dict]) -> None: + if snap is None: + return + channel = snap["channel"] + freq = snap.get("freq") # Hz string straight from the radio + mode = snap.get("mode") + try: + if freq is not None: + await self._send(f"vfo:{channel},0,{int(freq)};") + if mode is not None: + await self._send(f"modulation:{channel},{mode};") + except Exception: + logger.exception("TCI: failed to restore vfo freq+mode") diff --git a/spe/tune_orchestrator.py b/spe/tune_orchestrator.py index 054abf7..8821cba 100644 --- a/spe/tune_orchestrator.py +++ b/spe/tune_orchestrator.py @@ -34,8 +34,8 @@ import logging from typing import Callable, Optional -from spe.config import FlexConfig -from spe.flex import FlexConnection, FlexProtocolError +from spe.radio import RadioConnection +from spe.radio_controller import RadioController from spe.serial_handler import SerialHandler from spe.spe_band_table import BAND_TABLE, lookup as lookup_band @@ -81,6 +81,14 @@ "SWEEP_STARTED", # band sweep accepted; first sub-band about to start "SWEEP_STEP", # next sub-band's tune cycle is about to begin "SWEEP_DONE", # terminal: all sub-bands tuned cleanly + # Radio connection-lifecycle phases — emitted by RadioController on the + # same channel as the connection is opened on Sweep-menu open / tune + # start and closed when the cycle is over. Listed here so clients have + # the full phase vocabulary in one place. + "RADIO_CONNECTING", + "RADIO_CONNECTED", + "RADIO_DISCONNECTED", + "RADIO_ERROR", ) @@ -98,18 +106,31 @@ class TuneOrchestrator: def __init__( self, serial_handler: SerialHandler, - flex: FlexConnection, - config: FlexConfig, + radio_controller: RadioController, on_status: Optional[StatusCallback] = None, ): self.serial = serial_handler - self.flex = flex - self.config = config + # The connection is opened on demand via the controller (see + # _acquire_radio / _release_radio), not held for the server's life. + # The controller also knows the active backend, channel, and power. + self.radio_controller = radio_controller self.on_status = on_status self._running = False self._stop_requested = asyncio.Event() + async def _acquire_radio(self) -> Optional[RadioConnection]: + """Open (or reuse) the radio connection for a tune cycle. + + Returns the live connection, or None if it couldn't be + established — in which case RadioController has already emitted a + RADIO_ERROR status, and the caller should emit FAIL and bail.""" + return await self.radio_controller.connect() + + async def _release_radio(self) -> None: + """Drop the radio connection now the cycle is over. Best effort.""" + await self.radio_controller.disconnect() + def _status(self, phase: str, message: str = "") -> None: """Emit a phase transition. Internal logging at INFO; the external callback gets phase + a human-readable message.""" @@ -124,11 +145,10 @@ def _status(self, phase: str, message: str = "") -> None: async def tune_single(self, freq_mhz: Optional[float] = None) -> bool: """Run a single tune cycle. Returns True on SUCCESS, else False. - ``freq_mhz`` overrides the Flex slice frequency before keying; - the operator's pre-call freq + mode are snapshotted and - restored after the cycle. Omit ``freq_mhz`` to tune at whatever - freq the slice is already on (no save/restore needed in that - case — the slice didn't move). + ``freq_mhz`` overrides the radio's frequency before keying. The + operator's pre-call freq + mode are snapshotted and restored after + the cycle either way (the cycle sets the channel to CW). Omit + ``freq_mhz`` to tune at the radio's current freq. """ if self._running: self._status("FAIL", "Tune already in progress") @@ -136,12 +156,20 @@ async def tune_single(self, freq_mhz: Optional[float] = None) -> bool: self._running = True self._stop_requested.clear() - snap = self._snapshot_slice() if freq_mhz is not None else None try: - return await self._run_one_cycle(freq_mhz) + radio = await self._acquire_radio() + if radio is None: + self._status("FAIL", "Radio not reachable") + return False + snap = self._snapshot(radio) + try: + return await self._run_one_cycle(radio, freq_mhz) + finally: + await self._restore(radio, snap) finally: - if snap is not None: - await self._restore_slice(snap) + # Disconnect once the cycle is over, per the on-demand + # lifecycle — the radio is only held while actually tuning. + await self._release_radio() self._running = False async def tune_band(self, band: str) -> bool: @@ -173,8 +201,14 @@ async def tune_band(self, band: str) -> bool: self._running = True self._stop_requested.clear() - snap = self._snapshot_slice() + radio = None + snap = None try: + radio = await self._acquire_radio() + if radio is None: + self._status("FAIL", "Radio not reachable") + return False + snap = self._snapshot(radio) total = len(centers_khz) skipped = raw_total - total note = (f" ({skipped} out-of-band entries from the manual skipped)" @@ -197,7 +231,7 @@ async def tune_band(self, band: str) -> bool: self._status("SWEEP_STEP", f"{i}/{total}: {freq_mhz:.4f} MHz") - ok = await self._run_one_cycle(freq_mhz) + ok = await self._run_one_cycle(radio, freq_mhz) if not ok: # _run_one_cycle has already emitted FAIL with the # specific reason — surface a sweep-level summary @@ -221,14 +255,24 @@ async def tune_band(self, band: str) -> bool: # Restore the operator's pre-sweep VFO + mode before we # release _running. Best effort — log on failure but don't # mask whatever terminal phase the sweep produced. - await self._restore_slice(snap) + if radio is not None: + await self._restore(radio, snap) + # Disconnect now the sweep is over (on-demand lifecycle). + await self._release_radio() self._running = False - async def _run_one_cycle(self, freq_mhz: Optional[float]) -> bool: + async def _run_one_cycle(self, radio: RadioConnection, + freq_mhz: Optional[float]) -> bool: """Single ATU tune cycle. Used by both tune_single (one call) and tune_band (called N times in a loop). Caller is responsible for setting / clearing self._running around this method. + + Drives the active radio through the generic RadioConnection + interface, so the same sequence works for a Flex (SmartSDR) or a + SunSDR (TCI) rig. """ + channel = self.radio_controller.channel + power = self.radio_controller.tune_power_watts carrier_on = False success = False @@ -252,20 +296,17 @@ async def _run_one_cycle(self, freq_mhz: Optional[float]) -> bool: self._status("PREFLIGHT_OK") - # ----- Optional freq + power setup ---------------------- - if freq_mhz is not None: - try: - await self.flex.set_slice_freq(self.config.slice_rx, freq_mhz) - except FlexProtocolError as e: - self._status("FAIL", f"set_slice_freq: {e}") - return False - self._status("FREQ_SET", f"slice {self.config.slice_rx} → " - f"{freq_mhz:.6f} MHz") - + # ----- Freq + mode + power setup ------------------------ try: - await self.flex.set_tune_power(self.config.tune_power_watts) - except FlexProtocolError as e: - self._status("FAIL", f"set_tune_power: {e}") + if freq_mhz is not None: + await radio.set_frequency(channel, freq_mhz) + self._status("FREQ_SET", + f"channel {channel} → {freq_mhz:.6f} MHz") + # Key a clean CW carrier on the tuned channel. + await radio.set_mode(channel, "CW") + await radio.set_tune_power(power) + except Exception as e: + self._status("FAIL", f"radio setup: {e}") return False # ----- Send TUNE keycode, wait for LED ------------------ @@ -282,13 +323,14 @@ async def _run_one_cycle(self, freq_mhz: Optional[float]) -> bool: # ----- Carrier on, wait for ATU done -------------------- try: - await self.flex.tune_carrier(on=True) - except FlexProtocolError as e: + await radio.tune_carrier(on=True) + except Exception as e: self._status("FAIL", f"tune_carrier(on): {e}") return False carrier_on = True self._status("CARRIER_ON", - f"Flex {self.config.tune_power_watts}W") + f"{self.radio_controller.kind} carrier" + + (f" {power}W" if power else "")) if not await self._wait_for_tune_active(False, TUNE_SWEEP_TIMEOUT): self._status("FAIL", "ATU didn't complete within " @@ -311,12 +353,11 @@ async def _run_one_cycle(self, freq_mhz: Optional[float]) -> bool: # Carrier off MUST run regardless of how we got here — # the carrier is the only thing that can hurt antennas / # the amp if left on. Tolerate the off failing (best - # effort); the FlexConnection's own reconnect will sort - # things out and the rig's own watchdog will cut TX - # eventually if all else fails. + # effort); the radio's own watchdog will cut TX eventually + # if all else fails. if carrier_on: try: - await self.flex.tune_carrier(on=False) + await radio.tune_carrier(on=False) self._status("CARRIER_OFF") except Exception: logger.exception("Failed to stop carrier in cleanup") @@ -325,48 +366,35 @@ async def _run_one_cycle(self, freq_mhz: Optional[float]) -> bool: self._status("SUCCESS" if success else "FAIL", "cycle complete" if success else "see prior status") - def _snapshot_slice(self) -> Optional[dict]: - """Read the current freq+mode of the operator's slice from - FlexConnection.slice_state. Returns a small dict the - orchestrator can hand to ``_restore_slice`` later, or None if - the cache isn't populated yet (e.g. the radio hasn't emitted a - slice event since spe-remote connected). Emits ``VFO_SAVED`` on - success.""" - rx = self.config.slice_rx - state = self.flex.slice_state.get(rx) - if not state: - self._status("VFO_SAVED", - f"slice {rx} state unknown — restore disabled") - return None - freq = state.get("RF_frequency") - mode = state.get("mode") - if freq is None and mode is None: + def _snapshot(self, radio: RadioConnection) -> Optional[dict]: + """Capture the operator's current freq+mode via the radio backend + so it can be restored after the cycle. Returns an opaque dict for + :meth:`_restore`, or None if the backend doesn't know the state + yet (restore is then skipped). Emits ``VFO_SAVED``.""" + channel = self.radio_controller.channel + snap = radio.snapshot(channel) + if snap is None: self._status("VFO_SAVED", - f"slice {rx} state empty — restore disabled") + f"channel {channel} state unknown — restore disabled") return None self._status("VFO_SAVED", - f"slice {rx}: {freq} MHz {mode}") - return {"rx": rx, "freq": freq, "mode": mode} - - async def _restore_slice(self, snap: Optional[dict]) -> None: - """Write the saved freq+mode back to the Flex slice. Best - effort — any failure logs at WARN and a FAIL status is emitted, - but we never re-raise (the cycle that called us already has - its own terminal phase queued).""" + f"channel {channel}: {snap.get('freq')} {snap.get('mode')}") + return snap + + async def _restore(self, radio: RadioConnection, + snap: Optional[dict]) -> None: + """Write a :meth:`_snapshot` result back via the radio backend. + Best effort — never re-raises (the cycle already has its own + terminal phase queued).""" if snap is None: return - rx = snap["rx"] - freq = snap["freq"] - mode = snap["mode"] try: - if freq is not None: - await self.flex.set_slice_freq(rx, float(freq)) - if mode is not None: - await self.flex.set_slice_mode(rx, mode) + await radio.restore(snap) self._status("VFO_RESTORED", - f"slice {rx}: {freq} MHz {mode}") + f"channel {snap.get('channel')}: " + f"{snap.get('freq')} {snap.get('mode')}") except Exception as e: - logger.exception("Failed to restore slice freq+mode") + logger.exception("Failed to restore radio freq+mode") self._status("FAIL", f"VFO restore: {e}") async def _wait_for_tune_active(self, expected: bool, timeout: float) -> bool: diff --git a/spe/websocket_handler.py b/spe/websocket_handler.py index c4a659e..62d1f5b 100644 --- a/spe/websocket_handler.py +++ b/spe/websocket_handler.py @@ -19,16 +19,24 @@ class AmplifierWebSocket(tornado.websocket.WebSocketHandler): _serial_handler = None _power_controller = None _tune_orchestrator = None + _radio_controller = None + _app_config = None + _config_path = "config.yaml" _last_json = "" _last_broadcast_time = 0.0 _heartbeat_interval = 15.0 @classmethod def configure(cls, serial_handler, power_controller=None, - tune_orchestrator=None, heartbeat: float = 15.0) -> None: + tune_orchestrator=None, radio_controller=None, + app_config=None, config_path="config.yaml", + heartbeat: float = 15.0) -> None: cls._serial_handler = serial_handler cls._power_controller = power_controller cls._tune_orchestrator = tune_orchestrator + cls._radio_controller = radio_controller + cls._app_config = app_config + cls._config_path = config_path cls._heartbeat_interval = heartbeat def check_origin(self, origin) -> bool: @@ -98,6 +106,44 @@ def on_message(self, message: str) -> None: # cut before it returns. Sweep checks the stop event # before each sub-band so abort lands quickly. self._tune_orchestrator.stop() + elif message in ("radio_connect", "flex_connect"): + # Sent when a client opens its Sweep menu — pre-warm the radio + # connection so it's ready when the operator hits Start. + # Idempotent; RadioController broadcasts RADIO_CONNECTING / + # RADIO_CONNECTED / RADIO_ERROR so the UI can reflect status. + # No-op when no radio is configured — handled here (not + # forwarded to the serial handler as an amp command). + # `flex_connect` is kept as an alias for older clients. + if self._radio_controller: + import tornado.ioloop + tornado.ioloop.IOLoop.current().spawn_callback( + self._radio_controller.connect + ) + elif message in ("radio_disconnect", "flex_disconnect"): + # Sent when a client closes its Sweep menu while idle. Don't + # drop the radio mid-tune — the orchestrator owns the + # connection for the duration of a cycle and disconnects + # itself when the cycle is over. + if self._radio_controller and not ( + self._tune_orchestrator and self._tune_orchestrator.is_running + ): + import tornado.ioloop + tornado.ioloop.IOLoop.current().spawn_callback( + self._radio_controller.disconnect + ) + elif message == "get_config": + # Reply (to this client only) with the current radio config so + # the client can render its radio picker / settings form. + self._send_radio_config() + elif message.startswith("set_radio_config:") and self._radio_controller: + # Client-driven radio selection / settings edit. Payload is + # JSON, e.g. {"kind":"tci","tci":{"host":"127.0.0.1","port":50001}}. + # Applies live (no restart) and persists to config.yaml. + import tornado.ioloop + payload = message.split(":", 1)[1] + tornado.ioloop.IOLoop.current().spawn_callback( + self._handle_set_radio_config, payload + ) elif message.startswith("set_temp_unit:") and self._serial_handler: # Live temperature-unit toggle. Example payloads: "set_temp_unit:F" # or "set_temp_unit:C". Updates in-memory unit on the handler @@ -110,6 +156,94 @@ def on_message(self, message: str) -> None: elif self._serial_handler: self._serial_handler.send_command(message) + # ------------------------------------------------------------------ + # Radio configuration (client-selected radio) + # ------------------------------------------------------------------ + + @classmethod + def _radio_config_payload(cls) -> str: + """Serialise the current radio config for a client picker/form.""" + cfg = cls._app_config + flex = cfg.flex if cfg else None + tci = cfg.tci if cfg else None + kind = cfg.radio.kind if cfg else "none" + return json.dumps({ + "config_event": "radio", + "radio": { + "kind": kind, + "flex": { + "host": flex.host, "port": flex.port, + "slice_rx": flex.slice_rx, + "tune_power_watts": flex.tune_power_watts, + } if flex else {}, + "tci": { + "host": tci.host, "port": tci.port, "trx": tci.trx, + "mode": tci.mode, "tune_drive": tci.tune_drive, + } if tci else {}, + }, + }) + + def _send_radio_config(self) -> None: + """Send the current radio config to this client only.""" + try: + self.write_message(self._radio_config_payload()) + except tornado.websocket.WebSocketClosedError: + pass + + async def _handle_set_radio_config(self, payload: str) -> None: + """Apply a client's radio-config change live, persist it, and + broadcast the new config to every client. Payload is JSON: + ``{"kind": "...", "flex": {...}, "tci": {...}}`` (sections + optional). Refused while a tune cycle is running.""" + from spe.config import persist_values + + cfg = self._app_config + if cfg is None: + return + if self._tune_orchestrator and self._tune_orchestrator.is_running: + AmplifierWebSocket.broadcast_tune_event( + "RADIO_ERROR", "cannot change radio while a tune is running") + return + try: + data = json.loads(payload) + except (ValueError, TypeError) as e: + AmplifierWebSocket.broadcast_tune_event( + "RADIO_ERROR", f"bad set_radio_config payload: {e}") + return + + changes: dict = {} + kind = str(data.get("kind", cfg.radio.kind)).strip().lower() + if kind not in ("flex", "tci", "none"): + AmplifierWebSocket.broadcast_tune_event( + "RADIO_ERROR", f"unknown radio kind {kind!r}") + return + cfg.radio.kind = kind + changes["radio.kind"] = kind + # Keep flex.enabled consistent with the selector for back-compat. + cfg.flex.enabled = (kind == "flex") + changes["flex.enabled"] = cfg.flex.enabled + + # Apply only the fields the client sent for each section. + for field in ("host", "port", "slice_rx", "tune_power_watts"): + if "flex" in data and field in data["flex"]: + setattr(cfg.flex, field, data["flex"][field]) + changes[f"flex.{field}"] = data["flex"][field] + for field in ("host", "port", "trx", "mode", "tune_drive"): + if "tci" in data and field in data["tci"]: + setattr(cfg.tci, field, data["tci"][field]) + changes[f"tci.{field}"] = data["tci"][field] + + # Drop any open connection, then swap the controller's backend. + await self._radio_controller.disconnect() + self._radio_controller.reconfigure(cfg.radio, cfg.flex, cfg.tci) + + persist_values(changes, self._config_path) + logger.info("Radio config changed live: kind=%s", kind) + + AmplifierWebSocket.broadcast_raw(self._radio_config_payload()) + AmplifierWebSocket.broadcast_tune_event( + "RADIO_CONFIG_UPDATED", f"radio set to {kind}") + async def _handle_power(self, command: str) -> None: """Handle power on/off commands asynchronously.""" if command == "power_on": diff --git a/web/app.js b/web/app.js index 849d207..49afc74 100644 --- a/web/app.js +++ b/web/app.js @@ -14,6 +14,8 @@ ws.onopen = () => { reconnectDelay = 1000; setConnected(true); + // Pull the current radio config so the settings panel reflects it. + ws.send("get_config"); }; ws.onclose = () => { @@ -29,6 +31,8 @@ const d = JSON.parse(evt.data); if (d.power_result) { handlePowerResult(d); + } else if (d.config_event === "radio") { + handleRadioConfig(d.radio); } else if (d.tune_event) { handleTuneEvent(d); } else if (d.heartbeat) { @@ -378,11 +382,85 @@ ws.readyState !== WebSocket.OPEN; } + // Radio connection is on-demand: opening the Sweep menu pre-warms it, + // closing it (while idle) drops it. The server also connects lazily at + // tune start and disconnects when the cycle is over, so this is purely + // a head-start, not a hard requirement. + function radioConnect() { + if (ws && ws.readyState === WebSocket.OPEN) ws.send("radio_connect"); + } + function radioDisconnect() { + // Never drop the radio mid-sweep; the server ignores this while a + // cycle is running, but guard here too to avoid the needless message. + if (!isSweeping && ws && ws.readyState === WebSocket.OPEN) { + ws.send("radio_disconnect"); + } + } + + // ---- Radio settings panel (client-selected radio) ----------------- + // The server sends {config_event:"radio", radio:{kind, flex:{}, tci:{}}} + // on connect and after any change; we mirror it into the form. Applying + // sends set_radio_config: which the server persists + applies live. + function val(id) { const el = document.getElementById(id); return el ? el.value : ""; } + function setVal(id, v) { const el = document.getElementById(id); if (el && v !== undefined && v !== null) el.value = v; } + + function showRadioFields(kind) { + const flex = document.getElementById("flexFields"); + const tci = document.getElementById("tciFields"); + if (flex) flex.hidden = kind !== "flex"; + if (tci) tci.hidden = kind !== "tci"; + } + + function handleRadioConfig(radio) { + if (!radio) return; + setVal("radioKind", radio.kind); + const f = radio.flex || {}, t = radio.tci || {}; + setVal("flexHost", f.host); setVal("flexPort", f.port); + setVal("flexSlice", f.slice_rx); setVal("flexPower", f.tune_power_watts); + setVal("tciHost", t.host); setVal("tciPort", t.port); + setVal("tciTrx", t.trx); setVal("tciMode", t.mode); setVal("tciDrive", t.tune_drive); + showRadioFields(radio.kind); + } + + window.onRadioKindChange = function () { showRadioFields(val("radioKind")); }; + + window.toggleRadioPanel = function () { + const panel = document.getElementById("radioPanel"); + if (!panel) return; + panel.hidden = !panel.hidden; + if (!panel.hidden && ws && ws.readyState === WebSocket.OPEN) ws.send("get_config"); + }; + + window.applyRadioConfig = function () { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const kind = val("radioKind"); + const payload = { kind }; + if (kind === "flex") { + payload.flex = { + host: val("flexHost"), port: Number(val("flexPort")) || 4992, + slice_rx: Number(val("flexSlice")) || 0, + tune_power_watts: Number(val("flexPower")) || 10, + }; + } else if (kind === "tci") { + payload.tci = { + host: val("tciHost"), port: Number(val("tciPort")) || 50001, + trx: Number(val("tciTrx")) || 0, mode: val("tciMode") || "CW", + tune_drive: Number(val("tciDrive")) || 0, + }; + } + ws.send("set_radio_config:" + JSON.stringify(payload)); + }; + window.toggleSweepPanel = function () { const panel = document.getElementById("sweepPanel"); if (!panel) return; panel.hidden = !panel.hidden; - if (!panel.hidden) renderBandButtons(); + if (!panel.hidden) { + renderBandButtons(); + radioConnect(); + } else { + radioDisconnect(); + } }; window.startSelectedSweep = function () { @@ -436,6 +514,20 @@ const phase = d.tune_event; const message = d.tune_message || ""; + // Radio connection-lifecycle events. Show pre-tune feedback + // (connecting / connected / error) but never flip sweeping state or + // clobber a finished sweep's terminal status with the housekeeping + // RADIO_DISCONNECTED that follows it. RADIO_CONFIG_UPDATED is handled + // via the config_event message, so ignore it here. (FLEX_ kept for + // tolerance with an older server.) + if (phase && (phase.indexOf("RADIO_") === 0 || phase.indexOf("FLEX_") === 0)) { + if (phase === "RADIO_CONFIG_UPDATED") return; + if (phase !== "RADIO_DISCONNECTED" && phase !== "FLEX_DISCONNECTED") { + setSweepUI({ phase, message }); + } + return; + } + if (phase === "STARTED" || phase === "SWEEP_STARTED") { isSweeping = true; } diff --git a/web/index.html b/web/index.html index b14f5be..c954b52 100644 --- a/web/index.html +++ b/web/index.html @@ -85,6 +85,43 @@

SPE Expert — Remote Control

+ + + + +