From 5dc5d9b0ef573271d3e41ece791642d873992a3a Mon Sep 17 00:00:00 2001 From: VU3ESV Date: Fri, 19 Jun 2026 23:09:41 +0200 Subject: [PATCH 1/2] Flex: connect on Sweep-menu open, disconnect when the cycle is over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spe-remote previously opened the SmartSDR TCP session at startup and held it for the whole life of the process. That kept the radio marked "in use" by spe-remote even when nobody was tuning, and meant the radio had to be on at startup (and spe-remote restarted if it was ever power-cycled). Make the Flex connection on-demand instead: - New spe/flex_controller.py: FlexController owns at most one FlexConnection, opened/closed on demand under an asyncio.Lock. connect() resolves the host lazily (static flex.host, else UDP discovery) so the radio may be off at startup and still be found later; both connect() and disconnect() are idempotent and never raise. Transitions broadcast as tune_event phases FLEX_CONNECTING / FLEX_CONNECTED / FLEX_DISCONNECTED / FLEX_ERROR. - TuneOrchestrator acquires the connection at the start of tune_single / tune_band and disconnects in the finally — so the radio is released as soon as the cycle or sweep is over, even on failure/abort. A sweep holds one connection across all sub-bands. - websocket_handler: new flex_connect / flex_disconnect commands. The web dashboard sends flex_connect when the Sweep panel opens and flex_disconnect when it closes while idle (never mid-tune). Both are no-ops — not forwarded to the serial handler — when Flex is disabled. - server.py: build a FlexController (no startup connect); the lazy connect at tune start keeps clients that never send flex_connect working. - web/app.js: pre-warm on Sweep-menu open, drop on idle close; FLEX_* events show pre-tune status without clobbering a finished sweep result. - flex.py: add FlexConnection.is_connected. - README: document the on-demand lifecycle and the new WS commands. Backward compatible: older clients (MacExpert, Node-RED, Vue) that only send tune_band/tune_stop still work via the lazy connect-at-tune-start safety net and get the disconnect-when-done behaviour for free. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 20 ++++- server.py | 113 ++++++++++------------------ spe/flex.py | 8 ++ spe/flex_controller.py | 157 +++++++++++++++++++++++++++++++++++++++ spe/tune_orchestrator.py | 81 +++++++++++++++----- spe/websocket_handler.py | 29 +++++++- web/app.js | 31 +++++++- 7 files changed, 341 insertions(+), 98 deletions(-) create mode 100644 spe/flex_controller.py diff --git a/README.md b/README.md index ddfdad8..f431091 100644 --- a/README.md +++ b/README.md @@ -232,14 +232,18 @@ 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: +When `flex.enabled: true` is set in `config.yaml`, spe-remote can open a second connection — TCP to a **FlexRadio 6000-series** rig over the SmartSDR API — and exposes these additional WebSocket commands that any client (MacExpert, browser dashboard, Node-RED) can call: | WS command | What it does | |---|---| +| `flex_connect` | Open the SmartSDR connection. Sent when a client opens its Sweep menu, so the radio is ready by the time the operator hits Start. Idempotent. | +| `flex_disconnect` | Close the SmartSDR connection. Sent when a client closes its Sweep menu while idle. Ignored while a tune cycle is running. | | `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. | | `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. | +**On-demand connection (the radio is only held while tuning).** spe-remote does **not** open the SmartSDR session at startup. The connection is established when the operator opens the Sweep menu (`flex_connect`) and is dropped again as soon as the tune cycle or band sweep finishes — so the radio isn't marked "in use" by spe-remote the rest of the time, and it can be powered off until you actually need it (host discovery 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 `flex_connect` still works. Connection transitions broadcast as `tune_event` phases `FLEX_CONNECTING` → `FLEX_CONNECTED` → `FLEX_DISCONNECTED` (or `FLEX_ERROR`). + Phase progress streams back to every connected client as JSON broadcasts on the same WS: ```json @@ -266,7 +270,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 +612,18 @@ 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. +**Flex tune/sweep commands** (only when `flex.enabled: true` — see [Orchestrated TUNE and Band Sweep](#orchestrated-tune-and-band-sweep)): + +| Command | Action | +|---|---| +| `flex_connect` | Open the SmartSDR connection (on Sweep-menu open). Idempotent. | +| `flex_disconnect` | Close it (on idle Sweep-menu close). Ignored mid-tune. | +| `tune_single` | One ATU tune cycle at the slice'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) | + +Progress streams back as `{"tune_event": , "tune_message": , "ts": }` — see the linked section for the full phase vocabulary, including the `FLEX_CONNECTING` / `FLEX_CONNECTED` / `FLEX_DISCONNECTED` connection-lifecycle phases. + ### Example: JavaScript Client ```javascript diff --git a/server.py b/server.py index 7253e31..abb0164 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.flex_controller import FlexController from spe.tune_orchestrator import TuneOrchestrator @@ -117,63 +117,44 @@ def main() -> None: ) # 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 + # When flex.enabled is true, create a FlexController + tune + # orchestrator that can drive an ATU tune cycle via the (SPE TUNE + # keycode + Flex carrier) combination. + # + # On-demand lifecycle: the controller does NOT open the SmartSDR TCP + # session at startup. It connects when a client opens its Sweep menu + # (the flex_connect WS command) or, as a safety net, lazily at the + # start of a tune cycle, and disconnects when the cycle is over. + # Host resolution (static flex.host vs. UDP discovery) is deferred + # into FlexController.connect() too, so the radio may be powered off + # at startup and still be found later. Disabled by default; spe-remote + # behaves exactly as before when the section is omitted from config. + flex_controller = 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" - ) + flex_controller = FlexController( + config.flex, + on_status=AmplifierWebSocket.broadcast_tune_event, + ) + tune_orchestrator = TuneOrchestrator( + serial_handler=serial_handler, + flex_controller=flex_controller, + config=config.flex, + on_status=AmplifierWebSocket.broadcast_tune_event, + ) + target = config.flex.host or "auto-discover" + logger.info( + f"Flex control enabled (on-demand): host={target}:{config.flex.port} " + f"slice={config.flex.slice_rx} " + f"tune_power={config.flex.tune_power_watts}W — connects when the " + "Sweep menu opens" + ) AmplifierWebSocket.configure( serial_handler=serial_handler, power_controller=power_controller, tune_orchestrator=tune_orchestrator, + flex_controller=flex_controller, heartbeat=config.polling.heartbeat, ) @@ -222,33 +203,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 Flex connect at startup — the FlexController opens the SmartSDR + # 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,10 +219,10 @@ 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 Flex socket if a tune cycle left it open. + if flex_controller is not None: try: - loop.run_until_complete(flex_connection.close()) + loop.run_until_complete(flex_controller.disconnect()) except Exception: logger.exception("Error while closing Flex connection") diff --git a/spe/flex.py b/spe/flex.py index 28b007e..3e13506 100644 --- a/spe/flex.py +++ b/spe/flex.py @@ -149,6 +149,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: diff --git a/spe/flex_controller.py b/spe/flex_controller.py new file mode 100644 index 0000000..5cddf32 --- /dev/null +++ b/spe/flex_controller.py @@ -0,0 +1,157 @@ +"""On-demand lifecycle manager for the Flex radio connection. + +The SmartSDR TCP session is only needed while the operator is actually +running an ATU tune cycle / band sweep. Holding it open for the whole +life of the server keeps the radio marked "in use" by another client +and means spe-remote must be restarted if the radio is power-cycled. + +``FlexController`` makes the connection on-demand instead: + + * ``connect()`` is called when the operator opens the Sweep menu + (the ``flex_connect`` WS command) and, as a safety net, lazily at + the start of every tune cycle — so older clients that don't send + ``flex_connect`` still work. + * ``disconnect()`` is called when the tune cycle/sweep finishes (the + orchestrator's ``finally``) and when the Sweep menu is closed while + idle (the ``flex_disconnect`` WS command). + +Host resolution (static ``flex.host`` vs. UDP discovery) is deferred to +``connect()`` too, so the radio may be powered off when spe-remote +starts and still be found later when the menu is opened. + +All transitions are serialised with an ``asyncio.Lock`` and surfaced to +WS clients through the same ``tune_event`` channel the orchestrator +uses, with these phases: + + FLEX_CONNECTING FLEX_CONNECTED FLEX_DISCONNECTED FLEX_ERROR +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Callable, Optional + +from spe.config import FlexConfig +from spe.flex import FlexConnection, discover as flex_discover + +logger = logging.getLogger(__name__) + +StatusCallback = Callable[[str, str], None] + + +class FlexController: + """Owns at most one :class:`FlexConnection`, opened/closed on demand.""" + + def __init__( + self, + config: FlexConfig, + on_status: Optional[StatusCallback] = None, + ): + self.config = config + self.on_status = on_status + + self._conn: Optional[FlexConnection] = None + self._lock = asyncio.Lock() + # Cache a discovered host so we don't re-run the 5 s UDP discovery + # on every connect once we've found the radio once. + self._resolved_host: str = config.host or "" + + # ------------------------------------------------------------------ + # State + # ------------------------------------------------------------------ + + @property + def connection(self) -> Optional[FlexConnection]: + """The live connection, or None when disconnected.""" + 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(f"Flex[{phase}] {message}".rstrip()) + cb = self.on_status + if cb is not None: + try: + cb(phase, message) + except Exception: + logger.exception("Flex on_status callback raised") + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> Optional[FlexConnection]: + """Ensure a live connection and return it (or None on failure). + + Idempotent: a no-op that returns the existing connection when one + is already up. Resolves the host (static or via discovery) on the + first call. Never raises — failures are reported via FLEX_ERROR + and surfaced as a None return so callers can decide what to do. + """ + async with self._lock: + if self.is_connected: + return self._conn + + host = self._resolved_host + if not host: + self._status( + "FLEX_CONNECTING", + "flex.host empty — discovering radio on UDP 4992 (up to 5s)…", + ) + try: + discovery = await flex_discover() + except Exception as e: + logger.exception("Flex discovery raised") + self._status("FLEX_ERROR", f"discovery failed: {e}") + return None + if discovery and discovery.get("ip"): + host = discovery["ip"] + self._resolved_host = host + logger.info( + f"Flex: discovered {discovery.get('model', '?')} " + f"\"{discovery.get('nickname', '?')}\" " + f"({discovery.get('callsign', '?')}) at {host}" + ) + else: + self._status( + "FLEX_ERROR", + "no radio answered discovery (is it powered on?)", + ) + return None + + self._status("FLEX_CONNECTING", f"{host}:{self.config.port}") + conn = FlexConnection(host, self.config.port) + try: + await conn.connect() + except Exception as e: + logger.exception("Flex connect failed") + # Make sure we don't leak a half-open socket. + try: + await conn.close() + except Exception: + pass + self._status("FLEX_ERROR", f"connect to {host} failed: {e}") + return None + + self._conn = conn + self._status( + "FLEX_CONNECTED", + f"{host} (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 Flex connection") + finally: + self._conn = None + self._status("FLEX_DISCONNECTED") diff --git a/spe/tune_orchestrator.py b/spe/tune_orchestrator.py index 054abf7..695a128 100644 --- a/spe/tune_orchestrator.py +++ b/spe/tune_orchestrator.py @@ -36,6 +36,7 @@ from spe.config import FlexConfig from spe.flex import FlexConnection, FlexProtocolError +from spe.flex_controller import FlexController from spe.serial_handler import SerialHandler from spe.spe_band_table import BAND_TABLE, lookup as lookup_band @@ -81,6 +82,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 + # Flex connection-lifecycle phases — emitted by FlexController 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. + "FLEX_CONNECTING", + "FLEX_CONNECTED", + "FLEX_DISCONNECTED", + "FLEX_ERROR", ) @@ -98,18 +107,32 @@ class TuneOrchestrator: def __init__( self, serial_handler: SerialHandler, - flex: FlexConnection, + flex_controller: FlexController, config: FlexConfig, on_status: Optional[StatusCallback] = None, ): self.serial = serial_handler - self.flex = flex + # The connection is opened on demand via the controller (see + # _acquire_flex / _release_flex), not held for the server's life. + self.flex_controller = flex_controller self.config = config self.on_status = on_status self._running = False self._stop_requested = asyncio.Event() + async def _acquire_flex(self) -> Optional[FlexConnection]: + """Open (or reuse) the Flex connection for a tune cycle. + + Returns the live connection, or None if it couldn't be + established — in which case FlexController has already emitted a + FLEX_ERROR status, and the caller should emit FAIL and bail.""" + return await self.flex_controller.connect() + + async def _release_flex(self) -> None: + """Drop the Flex connection now the cycle is over. Best effort.""" + await self.flex_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.""" @@ -136,12 +159,21 @@ 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) + flex = await self._acquire_flex() + if flex is None: + self._status("FAIL", "Flex radio not reachable") + return False + snap = self._snapshot_slice(flex) if freq_mhz is not None else None + try: + return await self._run_one_cycle(flex, freq_mhz) + finally: + if snap is not None: + await self._restore_slice(flex, 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_flex() self._running = False async def tune_band(self, band: str) -> bool: @@ -173,8 +205,14 @@ async def tune_band(self, band: str) -> bool: self._running = True self._stop_requested.clear() - snap = self._snapshot_slice() + flex = None + snap = None try: + flex = await self._acquire_flex() + if flex is None: + self._status("FAIL", "Flex radio not reachable") + return False + snap = self._snapshot_slice(flex) total = len(centers_khz) skipped = raw_total - total note = (f" ({skipped} out-of-band entries from the manual skipped)" @@ -197,7 +235,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(flex, freq_mhz) if not ok: # _run_one_cycle has already emitted FAIL with the # specific reason — surface a sweep-level summary @@ -221,10 +259,14 @@ 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 flex is not None and snap is not None: + await self._restore_slice(flex, snap) + # Disconnect now the sweep is over (on-demand lifecycle). + await self._release_flex() self._running = False - async def _run_one_cycle(self, freq_mhz: Optional[float]) -> bool: + async def _run_one_cycle(self, flex: FlexConnection, + 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. @@ -255,7 +297,7 @@ async def _run_one_cycle(self, freq_mhz: Optional[float]) -> bool: # ----- Optional freq + power setup ---------------------- if freq_mhz is not None: try: - await self.flex.set_slice_freq(self.config.slice_rx, freq_mhz) + await flex.set_slice_freq(self.config.slice_rx, freq_mhz) except FlexProtocolError as e: self._status("FAIL", f"set_slice_freq: {e}") return False @@ -263,7 +305,7 @@ async def _run_one_cycle(self, freq_mhz: Optional[float]) -> bool: f"{freq_mhz:.6f} MHz") try: - await self.flex.set_tune_power(self.config.tune_power_watts) + await flex.set_tune_power(self.config.tune_power_watts) except FlexProtocolError as e: self._status("FAIL", f"set_tune_power: {e}") return False @@ -282,7 +324,7 @@ 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) + await flex.tune_carrier(on=True) except FlexProtocolError as e: self._status("FAIL", f"tune_carrier(on): {e}") return False @@ -316,7 +358,7 @@ async def _run_one_cycle(self, freq_mhz: Optional[float]) -> bool: # eventually if all else fails. if carrier_on: try: - await self.flex.tune_carrier(on=False) + await flex.tune_carrier(on=False) self._status("CARRIER_OFF") except Exception: logger.exception("Failed to stop carrier in cleanup") @@ -325,7 +367,7 @@ 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]: + def _snapshot_slice(self, flex: FlexConnection) -> 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 @@ -333,7 +375,7 @@ def _snapshot_slice(self) -> Optional[dict]: slice event since spe-remote connected). Emits ``VFO_SAVED`` on success.""" rx = self.config.slice_rx - state = self.flex.slice_state.get(rx) + state = flex.slice_state.get(rx) if not state: self._status("VFO_SAVED", f"slice {rx} state unknown — restore disabled") @@ -348,7 +390,8 @@ def _snapshot_slice(self) -> Optional[dict]: f"slice {rx}: {freq} MHz {mode}") return {"rx": rx, "freq": freq, "mode": mode} - async def _restore_slice(self, snap: Optional[dict]) -> None: + async def _restore_slice(self, flex: FlexConnection, + 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 @@ -360,9 +403,9 @@ async def _restore_slice(self, snap: Optional[dict]) -> None: mode = snap["mode"] try: if freq is not None: - await self.flex.set_slice_freq(rx, float(freq)) + await flex.set_slice_freq(rx, float(freq)) if mode is not None: - await self.flex.set_slice_mode(rx, mode) + await flex.set_slice_mode(rx, mode) self._status("VFO_RESTORED", f"slice {rx}: {freq} MHz {mode}") except Exception as e: diff --git a/spe/websocket_handler.py b/spe/websocket_handler.py index c4a659e..dec9702 100644 --- a/spe/websocket_handler.py +++ b/spe/websocket_handler.py @@ -19,16 +19,19 @@ class AmplifierWebSocket(tornado.websocket.WebSocketHandler): _serial_handler = None _power_controller = None _tune_orchestrator = None + _flex_controller = None _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, flex_controller=None, + heartbeat: float = 15.0) -> None: cls._serial_handler = serial_handler cls._power_controller = power_controller cls._tune_orchestrator = tune_orchestrator + cls._flex_controller = flex_controller cls._heartbeat_interval = heartbeat def check_origin(self, origin) -> bool: @@ -98,6 +101,30 @@ 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 == "flex_connect": + # Sent when a client opens its Sweep menu — pre-warm the + # Flex connection so it's ready when the operator hits Start. + # Idempotent; FlexController broadcasts FLEX_CONNECTING / + # FLEX_CONNECTED / FLEX_ERROR so the UI can reflect status. + # No-op when Flex is disabled — handled here (not forwarded + # to the serial handler as an amp command). + if self._flex_controller: + import tornado.ioloop + tornado.ioloop.IOLoop.current().spawn_callback( + self._flex_controller.connect + ) + elif message == "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._flex_controller and not ( + self._tune_orchestrator and self._tune_orchestrator.is_running + ): + import tornado.ioloop + tornado.ioloop.IOLoop.current().spawn_callback( + self._flex_controller.disconnect + ) 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 diff --git a/web/app.js b/web/app.js index 849d207..ccaa4cf 100644 --- a/web/app.js +++ b/web/app.js @@ -378,11 +378,31 @@ ws.readyState !== WebSocket.OPEN; } + // Flex 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 flexConnect() { + if (ws && ws.readyState === WebSocket.OPEN) ws.send("flex_connect"); + } + function flexDisconnect() { + // 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("flex_disconnect"); + } + } + window.toggleSweepPanel = function () { const panel = document.getElementById("sweepPanel"); if (!panel) return; panel.hidden = !panel.hidden; - if (!panel.hidden) renderBandButtons(); + if (!panel.hidden) { + renderBandButtons(); + flexConnect(); + } else { + flexDisconnect(); + } }; window.startSelectedSweep = function () { @@ -436,6 +456,15 @@ const phase = d.tune_event; const message = d.tune_message || ""; + // Flex 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 + // FLEX_DISCONNECTED that follows it. + if (phase && phase.indexOf("FLEX_") === 0) { + if (phase !== "FLEX_DISCONNECTED") setSweepUI({ phase, message }); + return; + } + if (phase === "STARTED" || phase === "SWEEP_STARTED") { isSweeping = true; } From 2a3094e057194878daeea1e74cb70031ca07dbb9 Mon Sep 17 00:00:00 2001 From: VU3ESV Date: Mon, 22 Jun 2026 12:01:52 +0200 Subject: [PATCH 2/2] Multi-radio tune: add SunSDR/TCI backend + client-selected radio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalises the orchestrated TUNE / band sweep beyond FlexRadio so it can also drive an Expert Electronics SunSDR / ExpertSDR3 over TCI, and lets a client pick & configure the active radio at runtime (no restart). TCI command set verified against github.com/sm5tog/sm5k-spe-tuner. Radio abstraction: - spe/radio.py: RadioConnection ABC — the small primitive set the tune orchestrator needs (connect/close, set_frequency MHz, set_mode, set_tune _power, tune_carrier, snapshot/restore). - spe/flex.py: FlexConnection implements RadioConnection (thin adapters over the slice commands; VFO snapshot/restore moved here from the orchestrator). - spe/tci.py: TciConnection — async TCI client over tornado's WebSocket client (no new dependency). vfo:/modulation:/tune: commands; trx TX cache. - spe/radio_controller.py: replaces FlexController. Builds the backend by radio.kind (flex|tci|none), keeps the on-demand connect/disconnect lifecycle, and adds reconfigure() for live radio switching. Status phases are now generic RADIO_CONNECTING/CONNECTED/DISCONNECTED/ERROR. - spe/tune_orchestrator.py: drives the generic interface; same proven sequence (SPE TUNE keycode → RCU LED → carrier → LED off → carrier off). Config + client control: - spe/config.py: RadioConfig{kind} + TciConfig; parse radio:/tci: sections; back-compat derives kind from flex.enabled; persist_values() — a generic comment-preserving YAML writer (generalises persist_temperature_unit). - websocket_handler: get_config + set_radio_config: (live switch + persist, refused mid-tune), radio_connect/radio_disconnect (flex_* kept as aliases). server.py builds a RadioController from radio.kind. - config.yaml: radio: + tci: sections (flex: retained). configtool.py: radio.kind + tci.* added to SUPPORTED. - web dashboard: RADIO settings panel (pick rig + edit host/port) wired to get_config/set_radio_config; FLEX_*→RADIO_* event handling. Docs: - docs/CLIENT_RADIO_CONFIG.md: WS contract + config schema + MacExpert UX spec (the client app is updated separately). - README: multi-radio + client-driven radio config sections. Backward compatible: existing flex-only configs keep working (kind derived from flex.enabled); flex_connect/flex_disconnect and FLEX_* still accepted. Stacks on feat/flex-on-demand-connection. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 40 +++++-- config.yaml | 26 ++++- configtool.py | 6 + docs/CLIENT_RADIO_CONFIG.md | 115 +++++++++++++++++++ server.py | 70 ++++++----- spe/config.py | 132 +++++++++++++++++++++ spe/flex.py | 50 +++++++- spe/flex_controller.py | 157 ------------------------- spe/radio.py | 83 ++++++++++++++ spe/radio_controller.py | 213 ++++++++++++++++++++++++++++++++++ spe/tci.py | 223 ++++++++++++++++++++++++++++++++++++ spe/tune_orchestrator.py | 183 ++++++++++++++--------------- spe/websocket_handler.py | 137 +++++++++++++++++++--- web/app.js | 85 ++++++++++++-- web/index.html | 37 ++++++ web/style.css | 21 ++++ 16 files changed, 1241 insertions(+), 337 deletions(-) create mode 100644 docs/CLIENT_RADIO_CONFIG.md delete mode 100644 spe/flex_controller.py create mode 100644 spe/radio.py create mode 100644 spe/radio_controller.py create mode 100644 spe/tci.py diff --git a/README.md b/README.md index f431091..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,17 +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 can open a second connection — TCP to a **FlexRadio 6000-series** rig over the SmartSDR API — and exposes these 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 | |---|---| -| `flex_connect` | Open the SmartSDR connection. Sent when a client opens its Sweep menu, so the radio is ready by the time the operator hits Start. Idempotent. | -| `flex_disconnect` | Close the SmartSDR connection. Sent when a client closes its Sweep menu while idle. Ignored while a tune cycle is running. | -| `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). | -**On-demand connection (the radio is only held while tuning).** spe-remote does **not** open the SmartSDR session at startup. The connection is established when the operator opens the Sweep menu (`flex_connect`) and is dropped again as soon as the tune cycle or band sweep finishes — so the radio isn't marked "in use" by spe-remote the rest of the time, and it can be powered off until you actually need it (host discovery 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 `flex_connect` still works. Connection transitions broadcast as `tune_event` phases `FLEX_CONNECTING` → `FLEX_CONNECTED` → `FLEX_DISCONNECTED` (or `FLEX_ERROR`). +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: @@ -612,17 +628,19 @@ 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. -**Flex tune/sweep commands** (only when `flex.enabled: true` — see [Orchestrated TUNE and Band Sweep](#orchestrated-tune-and-band-sweep)): +**Radio tune/sweep commands** (when `radio.kind` is `flex` or `tci` — see [Orchestrated TUNE and Band Sweep](#orchestrated-tune-and-band-sweep)): | Command | Action | |---|---| -| `flex_connect` | Open the SmartSDR connection (on Sweep-menu open). Idempotent. | -| `flex_disconnect` | Close it (on idle Sweep-menu close). Ignored mid-tune. | -| `tune_single` | One ATU tune cycle at the slice's current freq | +| `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 `FLEX_CONNECTING` / `FLEX_CONNECTED` / `FLEX_DISCONNECTED` connection-lifecycle phases. +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 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 abb0164..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_controller import FlexController +from spe.radio_controller import RadioController from spe.tune_orchestrator import TuneOrchestrator @@ -116,45 +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, create a FlexController + tune - # orchestrator that can drive an ATU tune cycle via the (SPE TUNE - # keycode + Flex carrier) combination. + # 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 SmartSDR TCP + # On-demand lifecycle: the controller does NOT open the control # session at startup. It connects when a client opens its Sweep menu - # (the flex_connect WS command) or, as a safety net, lazily at the - # start of a tune cycle, and disconnects when the cycle is over. - # Host resolution (static flex.host vs. UDP discovery) is deferred - # into FlexController.connect() too, so the radio may be powered off - # at startup and still be found later. Disabled by default; spe-remote - # behaves exactly as before when the section is omitted from config. - flex_controller = None - tune_orchestrator = None - if config.flex.enabled: - flex_controller = FlexController( - config.flex, - on_status=AmplifierWebSocket.broadcast_tune_event, - ) - tune_orchestrator = TuneOrchestrator( - serial_handler=serial_handler, - flex_controller=flex_controller, - config=config.flex, - on_status=AmplifierWebSocket.broadcast_tune_event, - ) - target = config.flex.host or "auto-discover" - logger.info( - f"Flex control enabled (on-demand): host={target}:{config.flex.port} " - f"slice={config.flex.slice_rx} " - f"tune_power={config.flex.tune_power_watts}W — connects when the " - "Sweep menu opens" - ) + # (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, - flex_controller=flex_controller, + radio_controller=radio_controller, + app_config=config, + config_path=config_path, heartbeat=config.polling.heartbeat, ) @@ -203,7 +197,7 @@ def shutdown(sig, frame): f"(amp_alive_threshold={config.polling.amp_alive_threshold:.1f}s)" ) - # No Flex connect at startup — the FlexController opens the SmartSDR + # 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. @@ -219,12 +213,12 @@ def shutdown(sig, frame): except Exception: pass - # Close the Flex socket if a tune cycle left it open. - if flex_controller 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_controller.disconnect()) + 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 3e13506..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: @@ -369,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/flex_controller.py b/spe/flex_controller.py deleted file mode 100644 index 5cddf32..0000000 --- a/spe/flex_controller.py +++ /dev/null @@ -1,157 +0,0 @@ -"""On-demand lifecycle manager for the Flex radio connection. - -The SmartSDR TCP session is only needed while the operator is actually -running an ATU tune cycle / band sweep. Holding it open for the whole -life of the server keeps the radio marked "in use" by another client -and means spe-remote must be restarted if the radio is power-cycled. - -``FlexController`` makes the connection on-demand instead: - - * ``connect()`` is called when the operator opens the Sweep menu - (the ``flex_connect`` WS command) and, as a safety net, lazily at - the start of every tune cycle — so older clients that don't send - ``flex_connect`` still work. - * ``disconnect()`` is called when the tune cycle/sweep finishes (the - orchestrator's ``finally``) and when the Sweep menu is closed while - idle (the ``flex_disconnect`` WS command). - -Host resolution (static ``flex.host`` vs. UDP discovery) is deferred to -``connect()`` too, so the radio may be powered off when spe-remote -starts and still be found later when the menu is opened. - -All transitions are serialised with an ``asyncio.Lock`` and surfaced to -WS clients through the same ``tune_event`` channel the orchestrator -uses, with these phases: - - FLEX_CONNECTING FLEX_CONNECTED FLEX_DISCONNECTED FLEX_ERROR -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Callable, Optional - -from spe.config import FlexConfig -from spe.flex import FlexConnection, discover as flex_discover - -logger = logging.getLogger(__name__) - -StatusCallback = Callable[[str, str], None] - - -class FlexController: - """Owns at most one :class:`FlexConnection`, opened/closed on demand.""" - - def __init__( - self, - config: FlexConfig, - on_status: Optional[StatusCallback] = None, - ): - self.config = config - self.on_status = on_status - - self._conn: Optional[FlexConnection] = None - self._lock = asyncio.Lock() - # Cache a discovered host so we don't re-run the 5 s UDP discovery - # on every connect once we've found the radio once. - self._resolved_host: str = config.host or "" - - # ------------------------------------------------------------------ - # State - # ------------------------------------------------------------------ - - @property - def connection(self) -> Optional[FlexConnection]: - """The live connection, or None when disconnected.""" - 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(f"Flex[{phase}] {message}".rstrip()) - cb = self.on_status - if cb is not None: - try: - cb(phase, message) - except Exception: - logger.exception("Flex on_status callback raised") - - # ------------------------------------------------------------------ - # Lifecycle - # ------------------------------------------------------------------ - - async def connect(self) -> Optional[FlexConnection]: - """Ensure a live connection and return it (or None on failure). - - Idempotent: a no-op that returns the existing connection when one - is already up. Resolves the host (static or via discovery) on the - first call. Never raises — failures are reported via FLEX_ERROR - and surfaced as a None return so callers can decide what to do. - """ - async with self._lock: - if self.is_connected: - return self._conn - - host = self._resolved_host - if not host: - self._status( - "FLEX_CONNECTING", - "flex.host empty — discovering radio on UDP 4992 (up to 5s)…", - ) - try: - discovery = await flex_discover() - except Exception as e: - logger.exception("Flex discovery raised") - self._status("FLEX_ERROR", f"discovery failed: {e}") - return None - if discovery and discovery.get("ip"): - host = discovery["ip"] - self._resolved_host = host - logger.info( - f"Flex: discovered {discovery.get('model', '?')} " - f"\"{discovery.get('nickname', '?')}\" " - f"({discovery.get('callsign', '?')}) at {host}" - ) - else: - self._status( - "FLEX_ERROR", - "no radio answered discovery (is it powered on?)", - ) - return None - - self._status("FLEX_CONNECTING", f"{host}:{self.config.port}") - conn = FlexConnection(host, self.config.port) - try: - await conn.connect() - except Exception as e: - logger.exception("Flex connect failed") - # Make sure we don't leak a half-open socket. - try: - await conn.close() - except Exception: - pass - self._status("FLEX_ERROR", f"connect to {host} failed: {e}") - return None - - self._conn = conn - self._status( - "FLEX_CONNECTED", - f"{host} (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 Flex connection") - finally: - self._conn = None - self._status("FLEX_DISCONNECTED") 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 695a128..8821cba 100644 --- a/spe/tune_orchestrator.py +++ b/spe/tune_orchestrator.py @@ -34,9 +34,8 @@ import logging from typing import Callable, Optional -from spe.config import FlexConfig -from spe.flex import FlexConnection, FlexProtocolError -from spe.flex_controller import FlexController +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 @@ -82,14 +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 - # Flex connection-lifecycle phases — emitted by FlexController on the + # 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. - "FLEX_CONNECTING", - "FLEX_CONNECTED", - "FLEX_DISCONNECTED", - "FLEX_ERROR", + "RADIO_CONNECTING", + "RADIO_CONNECTED", + "RADIO_DISCONNECTED", + "RADIO_ERROR", ) @@ -107,31 +106,30 @@ class TuneOrchestrator: def __init__( self, serial_handler: SerialHandler, - flex_controller: FlexController, - config: FlexConfig, + radio_controller: RadioController, on_status: Optional[StatusCallback] = None, ): self.serial = serial_handler # The connection is opened on demand via the controller (see - # _acquire_flex / _release_flex), not held for the server's life. - self.flex_controller = flex_controller - self.config = config + # _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_flex(self) -> Optional[FlexConnection]: - """Open (or reuse) the Flex connection for a tune cycle. + 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 FlexController has already emitted a - FLEX_ERROR status, and the caller should emit FAIL and bail.""" - return await self.flex_controller.connect() + 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_flex(self) -> None: - """Drop the Flex connection now the cycle is over. Best effort.""" - await self.flex_controller.disconnect() + 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 @@ -147,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") @@ -160,20 +157,19 @@ async def tune_single(self, freq_mhz: Optional[float] = None) -> bool: self._running = True self._stop_requested.clear() try: - flex = await self._acquire_flex() - if flex is None: - self._status("FAIL", "Flex radio not reachable") + radio = await self._acquire_radio() + if radio is None: + self._status("FAIL", "Radio not reachable") return False - snap = self._snapshot_slice(flex) if freq_mhz is not None else None + snap = self._snapshot(radio) try: - return await self._run_one_cycle(flex, freq_mhz) + return await self._run_one_cycle(radio, freq_mhz) finally: - if snap is not None: - await self._restore_slice(flex, snap) + await self._restore(radio, snap) finally: # Disconnect once the cycle is over, per the on-demand # lifecycle — the radio is only held while actually tuning. - await self._release_flex() + await self._release_radio() self._running = False async def tune_band(self, band: str) -> bool: @@ -205,14 +201,14 @@ async def tune_band(self, band: str) -> bool: self._running = True self._stop_requested.clear() - flex = None + radio = None snap = None try: - flex = await self._acquire_flex() - if flex is None: - self._status("FAIL", "Flex radio not reachable") + radio = await self._acquire_radio() + if radio is None: + self._status("FAIL", "Radio not reachable") return False - snap = self._snapshot_slice(flex) + snap = self._snapshot(radio) total = len(centers_khz) skipped = raw_total - total note = (f" ({skipped} out-of-band entries from the manual skipped)" @@ -235,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(flex, 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 @@ -259,18 +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. - if flex is not None and snap is not None: - await self._restore_slice(flex, snap) + if radio is not None: + await self._restore(radio, snap) # Disconnect now the sweep is over (on-demand lifecycle). - await self._release_flex() + await self._release_radio() self._running = False - async def _run_one_cycle(self, flex: FlexConnection, + 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 @@ -294,20 +296,17 @@ async def _run_one_cycle(self, flex: FlexConnection, self._status("PREFLIGHT_OK") - # ----- Optional freq + power setup ---------------------- - if freq_mhz is not None: - try: - await 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 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 ------------------ @@ -324,13 +323,14 @@ async def _run_one_cycle(self, flex: FlexConnection, # ----- Carrier on, wait for ATU done -------------------- try: - await 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 " @@ -353,12 +353,11 @@ async def _run_one_cycle(self, flex: FlexConnection, # 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 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") @@ -367,49 +366,35 @@ async def _run_one_cycle(self, flex: FlexConnection, self._status("SUCCESS" if success else "FAIL", "cycle complete" if success else "see prior status") - def _snapshot_slice(self, flex: FlexConnection) -> 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 = 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, flex: FlexConnection, - 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 flex.set_slice_freq(rx, float(freq)) - if mode is not None: - await 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 dec9702..62d1f5b 100644 --- a/spe/websocket_handler.py +++ b/spe/websocket_handler.py @@ -19,19 +19,24 @@ class AmplifierWebSocket(tornado.websocket.WebSocketHandler): _serial_handler = None _power_controller = None _tune_orchestrator = None - _flex_controller = 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, flex_controller=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._flex_controller = flex_controller + 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: @@ -101,30 +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 == "flex_connect": - # Sent when a client opens its Sweep menu — pre-warm the - # Flex connection so it's ready when the operator hits Start. - # Idempotent; FlexController broadcasts FLEX_CONNECTING / - # FLEX_CONNECTED / FLEX_ERROR so the UI can reflect status. - # No-op when Flex is disabled — handled here (not forwarded - # to the serial handler as an amp command). - if self._flex_controller: + 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._flex_controller.connect + self._radio_controller.connect ) - elif message == "flex_disconnect": + 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._flex_controller and not ( + 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._flex_controller.disconnect + 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 @@ -137,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 ccaa4cf..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,30 +382,84 @@ ws.readyState !== WebSocket.OPEN; } - // Flex connection is on-demand: opening the Sweep menu pre-warms it, + // 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 flexConnect() { - if (ws && ws.readyState === WebSocket.OPEN) ws.send("flex_connect"); + function radioConnect() { + if (ws && ws.readyState === WebSocket.OPEN) ws.send("radio_connect"); } - function flexDisconnect() { + 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("flex_disconnect"); + 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(); - flexConnect(); + radioConnect(); } else { - flexDisconnect(); + radioDisconnect(); } }; @@ -456,12 +514,17 @@ const phase = d.tune_event; const message = d.tune_message || ""; - // Flex connection-lifecycle events. Show pre-tune feedback + // 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 - // FLEX_DISCONNECTED that follows it. - if (phase && phase.indexOf("FLEX_") === 0) { - if (phase !== "FLEX_DISCONNECTED") setSweepUI({ phase, message }); + // 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; } 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

+ + + + +