diff --git a/mcp/src/birdcage_mcp/tools/signal.py b/mcp/src/birdcage_mcp/tools/signal.py index fc82037..a96f89d 100644 --- a/mcp/src/birdcage_mcp/tools/signal.py +++ b/mcp/src/birdcage_mcp/tools/signal.py @@ -55,14 +55,42 @@ def register(mcp): async def enable_lna(ctx: Context) -> dict: """Enable the LNA by setting LNB voltage to 13V (V-pol). - This powers the low-noise amplifier in the dish's outdoor unit. - Boot default is 18V (H-pol). Required before meaningful RSSI - readings when used for radio astronomy. + Shortcut for set_lnb_voltage(mode='odu'). Use set_lnb_voltage + to switch back to 18V (H-pol) when done. """ state: BirdcageState = ctx.request_context.lifespan_context _require_device(state) state.device.enable_lna() - return {"status": "lna_enabled", "voltage": "13V"} + return {"status": "lna_enabled", "voltage": "13V", "polarity": "V"} + + @mcp.tool() + async def set_lnb_voltage(ctx: Context, mode: str) -> dict: + """Set LNB DC voltage and polarization. + + Controls the LNB bias voltage on the coax feed line. This + determines both LNA power state and receive polarization: + + - 'odu' = 13V (V-pol, LNA enabled) — for radio astronomy / ham + - 'stb' = 18V (H-pol, boot default) — standard consumer mode + + V-pol and H-pol see different transponders. The PEAK submenu's + 'rssits' command alternates between them for comparison. + + Args: + mode: 'odu' for 13V/V-pol or 'stb' for 18V/H-pol. + """ + state: BirdcageState = ctx.request_context.lifespan_context + _require_device(state) + raw = state.device.set_lnb_voltage(mode) + voltage = "13V" if mode.lower() == "odu" else "18V" + polarity = "V" if mode.lower() == "odu" else "H" + return { + "status": "set", + "mode": mode.lower(), + "voltage": voltage, + "polarity": polarity, + "raw": raw, + } @mcp.tool() async def get_dvb_config(ctx: Context) -> dict: diff --git a/mcp/tests/test_signal.py b/mcp/tests/test_signal.py index e263ba0..a476cad 100644 --- a/mcp/tests/test_signal.py +++ b/mcp/tests/test_signal.py @@ -3,6 +3,7 @@ import pytest from conftest import parse_result from fastmcp import Client +from fastmcp.exceptions import ToolError @pytest.mark.anyio @@ -42,6 +43,37 @@ async def test_enable_lna(mcp_client: Client): data = parse_result(result) assert data["status"] == "lna_enabled" assert data["voltage"] == "13V" + assert data["polarity"] == "V" + + +@pytest.mark.anyio +async def test_set_lnb_voltage_odu(mcp_client: Client): + result = await mcp_client.call_tool( + "set_lnb_voltage", {"mode": "odu"} + ) + data = parse_result(result) + assert data["voltage"] == "13V" + assert data["polarity"] == "V" + assert data["mode"] == "odu" + + +@pytest.mark.anyio +async def test_set_lnb_voltage_stb(mcp_client: Client): + result = await mcp_client.call_tool( + "set_lnb_voltage", {"mode": "stb"} + ) + data = parse_result(result) + assert data["voltage"] == "18V" + assert data["polarity"] == "H" + assert data["mode"] == "stb" + + +@pytest.mark.anyio +async def test_set_lnb_voltage_invalid(mcp_client: Client): + with pytest.raises(ToolError): + await mcp_client.call_tool( + "set_lnb_voltage", {"mode": "invalid"} + ) @pytest.mark.anyio diff --git a/src/birdcage/bridge.py b/src/birdcage/bridge.py index 6744e25..c89e6ad 100644 --- a/src/birdcage/bridge.py +++ b/src/birdcage/bridge.py @@ -526,10 +526,26 @@ class SerialBridge: raise ValueError(f"Could not parse RSSI: {response!r}") def enable_lna(self) -> None: - """Enable LNA in ODU mode (sets LNB to 13V).""" + """Enable LNA in ODU mode (13V). Alias for set_lnb_voltage('odu').""" + self.set_lnb_voltage("odu") + + def set_lnb_voltage(self, mode: str) -> str: + """Set LNB DC voltage mode. + + Args: + mode: 'odu' for 13V (V-pol, LNA enabled) or 'stb' for 18V (H-pol). + + Returns: + Raw firmware response. + """ + mode = mode.strip().lower() + if mode not in ("odu", "stb"): + raise ValueError( + f"Invalid LNB mode {mode!r}: use 'odu' (13V) or 'stb' (18V)" + ) with self._lock: self._ensure_menu(Menu.DVB) - self._send("lnbdc odu") + return self._send(f"lnbdc {mode}") def get_lock_status(self) -> str: """Read quick lock status (single-shot).""" diff --git a/src/birdcage/demo.py b/src/birdcage/demo.py index fdf5a9e..8b663f6 100644 --- a/src/birdcage/demo.py +++ b/src/birdcage/demo.py @@ -219,6 +219,9 @@ class DemoDevice: self._az_accel = 400.0 self._el_accel = 400.0 + # LNB state (boot default is 18V / H-pol / STB mode). + self._lnb_mode = "stb" + # Submenu tracking for console simulation. self._menu = _DemoMenu.ROOT @@ -429,7 +432,18 @@ class DemoDevice: } def enable_lna(self) -> None: - pass # No-op in demo mode. + self._lnb_mode = "odu" + + def set_lnb_voltage(self, mode: str) -> str: + mode = mode.strip().lower() + if mode not in ("odu", "stb"): + raise ValueError( + f"Invalid LNB mode {mode!r}: use 'odu' (13V) or 'stb' (18V)" + ) + self._lnb_mode = mode + if mode == "odu": + return "Enabled LNB ODU" + return "Enabled LNB STB" def get_lock_status(self) -> str: rssi = int(self._compute_rssi()) diff --git a/tui/src/birdcage_tui/screens/signal.py b/tui/src/birdcage_tui/screens/signal.py index a0d1d3a..765e943 100644 --- a/tui/src/birdcage_tui/screens/signal.py +++ b/tui/src/birdcage_tui/screens/signal.py @@ -115,7 +115,7 @@ class SignalScreen(Container): yield Static(" Hz", classes="label") yield Button("Start", id="btn-start", variant="primary") yield Button("Stop", id="btn-stop") - yield Button("Enable LNA", id="btn-lna") + yield Button("V-pol 13V", id="btn-lna") yield Button("Reset Peak", id="btn-reset-peak") # -- Sweep mode ------------------------------------------- @@ -350,9 +350,13 @@ class SignalScreen(Container): label = "YES" if locked else "NO" self.query_one("#lock-status", Static).update(f" Lock: {label}") - def _update_lna_label(self) -> None: - label = "ON" if self._lna_enabled else "OFF" - self.query_one("#lna-status", Static).update(f" LNA: {label}") + def _update_lna_ui(self) -> None: + if self._lna_enabled: + self.query_one("#lna-status", Static).update(" LNA: ON (V-pol)") + self.query_one("#btn-lna", Button).label = "H-pol 18V" + else: + self.query_one("#lna-status", Static).update(" LNA: OFF (H-pol)") + self.query_one("#btn-lna", Button).label = "V-pol 13V" def _update_status_strip_rssi(self, rssi_avg: int) -> None: """Push RSSI to the app-level StatusStrip.""" @@ -386,19 +390,29 @@ class SignalScreen(Container): if self._device is None: self.app.notify("No device connected", severity="warning") return - self._do_enable_lna() + self._do_toggle_lnb() @work(thread=True, exclusive=False, group="signal-cmd") - def _do_enable_lna(self) -> None: + def _do_toggle_lnb(self) -> None: try: - self._device.enable_lna() - self._lna_enabled = True - self.app.call_from_thread(self._update_lna_label) - self.app.call_from_thread(self.app.notify, "LNA enabled (13V ODU)") + if self._lna_enabled: + self._device.set_lnb_voltage("stb") + self._lna_enabled = False + self.app.call_from_thread(self._update_lna_ui) + self.app.call_from_thread( + self.app.notify, "LNB set to 18V (H-pol)" + ) + else: + self._device.set_lnb_voltage("odu") + self._lna_enabled = True + self.app.call_from_thread(self._update_lna_ui) + self.app.call_from_thread( + self.app.notify, "LNB set to 13V (V-pol, LNA on)" + ) except Exception: - log.exception("LNA enable failed") + log.exception("LNB voltage switch failed") self.app.call_from_thread( - self.app.notify, "LNA enable failed", severity="error" + self.app.notify, "LNB voltage switch failed", severity="error" ) def _handle_reset_peak(self) -> None: