Add LNB polarity toggle (V-pol 13V / H-pol 18V)

Bridge: set_lnb_voltage(mode) wraps firmware lnbdc command, enable_lna()
now delegates to it. MCP: new set_lnb_voltage tool + 3 tests. TUI: Signal
screen button toggles between V-pol and H-pol instead of one-way LNA enable.
This commit is contained in:
Ryan Malloy 2026-02-17 17:24:05 -07:00
parent 8a6b99bd8c
commit f8bfd69ceb
5 changed files with 123 additions and 19 deletions

View File

@ -55,14 +55,42 @@ def register(mcp):
async def enable_lna(ctx: Context) -> dict: async def enable_lna(ctx: Context) -> dict:
"""Enable the LNA by setting LNB voltage to 13V (V-pol). """Enable the LNA by setting LNB voltage to 13V (V-pol).
This powers the low-noise amplifier in the dish's outdoor unit. Shortcut for set_lnb_voltage(mode='odu'). Use set_lnb_voltage
Boot default is 18V (H-pol). Required before meaningful RSSI to switch back to 18V (H-pol) when done.
readings when used for radio astronomy.
""" """
state: BirdcageState = ctx.request_context.lifespan_context state: BirdcageState = ctx.request_context.lifespan_context
_require_device(state) _require_device(state)
state.device.enable_lna() 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() @mcp.tool()
async def get_dvb_config(ctx: Context) -> dict: async def get_dvb_config(ctx: Context) -> dict:

View File

@ -3,6 +3,7 @@
import pytest import pytest
from conftest import parse_result from conftest import parse_result
from fastmcp import Client from fastmcp import Client
from fastmcp.exceptions import ToolError
@pytest.mark.anyio @pytest.mark.anyio
@ -42,6 +43,37 @@ async def test_enable_lna(mcp_client: Client):
data = parse_result(result) data = parse_result(result)
assert data["status"] == "lna_enabled" assert data["status"] == "lna_enabled"
assert data["voltage"] == "13V" 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 @pytest.mark.anyio

View File

@ -526,10 +526,26 @@ class SerialBridge:
raise ValueError(f"Could not parse RSSI: {response!r}") raise ValueError(f"Could not parse RSSI: {response!r}")
def enable_lna(self) -> None: 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: with self._lock:
self._ensure_menu(Menu.DVB) self._ensure_menu(Menu.DVB)
self._send("lnbdc odu") return self._send(f"lnbdc {mode}")
def get_lock_status(self) -> str: def get_lock_status(self) -> str:
"""Read quick lock status (single-shot).""" """Read quick lock status (single-shot)."""

View File

@ -219,6 +219,9 @@ class DemoDevice:
self._az_accel = 400.0 self._az_accel = 400.0
self._el_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. # Submenu tracking for console simulation.
self._menu = _DemoMenu.ROOT self._menu = _DemoMenu.ROOT
@ -429,7 +432,18 @@ class DemoDevice:
} }
def enable_lna(self) -> None: 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: def get_lock_status(self) -> str:
rssi = int(self._compute_rssi()) rssi = int(self._compute_rssi())

View File

@ -115,7 +115,7 @@ class SignalScreen(Container):
yield Static(" Hz", classes="label") yield Static(" Hz", classes="label")
yield Button("Start", id="btn-start", variant="primary") yield Button("Start", id="btn-start", variant="primary")
yield Button("Stop", id="btn-stop") 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") yield Button("Reset Peak", id="btn-reset-peak")
# -- Sweep mode ------------------------------------------- # -- Sweep mode -------------------------------------------
@ -350,9 +350,13 @@ class SignalScreen(Container):
label = "YES" if locked else "NO" label = "YES" if locked else "NO"
self.query_one("#lock-status", Static).update(f" Lock: {label}") self.query_one("#lock-status", Static).update(f" Lock: {label}")
def _update_lna_label(self) -> None: def _update_lna_ui(self) -> None:
label = "ON" if self._lna_enabled else "OFF" if self._lna_enabled:
self.query_one("#lna-status", Static).update(f" LNA: {label}") 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: def _update_status_strip_rssi(self, rssi_avg: int) -> None:
"""Push RSSI to the app-level StatusStrip.""" """Push RSSI to the app-level StatusStrip."""
@ -386,19 +390,29 @@ class SignalScreen(Container):
if self._device is None: if self._device is None:
self.app.notify("No device connected", severity="warning") self.app.notify("No device connected", severity="warning")
return return
self._do_enable_lna() self._do_toggle_lnb()
@work(thread=True, exclusive=False, group="signal-cmd") @work(thread=True, exclusive=False, group="signal-cmd")
def _do_enable_lna(self) -> None: def _do_toggle_lnb(self) -> None:
try: try:
self._device.enable_lna() if self._lna_enabled:
self._lna_enabled = True self._device.set_lnb_voltage("stb")
self.app.call_from_thread(self._update_lna_label) self._lna_enabled = False
self.app.call_from_thread(self.app.notify, "LNA enabled (13V ODU)") self.app.call_from_thread(self._update_lna_ui)
except Exception:
log.exception("LNA enable failed")
self.app.call_from_thread( self.app.call_from_thread(
self.app.notify, "LNA enable failed", severity="error" 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("LNB voltage switch failed")
self.app.call_from_thread(
self.app.notify, "LNB voltage switch failed", severity="error"
) )
def _handle_reset_peak(self) -> None: def _handle_reset_peak(self) -> None: