Ryan Malloy cbfbbf6bfe Add SKYWALKER_MOCK=1 mode for hardware-free MCP integration testing
Extract MockSkyWalker1 to shared mock_device.py (used by both unit tests
and server lifespan). Server checks SKYWALKER_MOCK env var at startup —
when set, uses mock device instead of USB hardware, enabling full MCP
transport testing via claude -p without the dongle connected.

Verified: 64 unit tests pass, claude -p integration tests exercise
identify_frequency, get_device_status, and sweep_spectrum through the
complete JSON-RPC pipeline.
2026-02-17 15:23:05 -07:00

944 lines
36 KiB
Python

"""
Genpix SkyWalker-1 MCP server.
Wraps the entire skywalker_lib.py API as MCP tools, making every hardware
function accessible to LLMs. Thread-safe concurrent access via asyncio.to_thread
and a reentrant lock, following the same USBBridge pattern used by the TUI.
"""
import os
import sys
import asyncio
import threading
import json
from contextlib import asynccontextmanager
from pathlib import Path
from fastmcp import FastMCP, Context
# Add the tools directory to path so we can import the hardware library
_TOOLS_DIR = Path(__file__).resolve().parents[4] / "tools"
if str(_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(_TOOLS_DIR))
from skywalker_lib import ( # noqa: E402
SkyWalker1,
MODULATIONS,
FEC_RATES,
MOD_FEC_GROUP,
LBAND_ALLOCATIONS,
format_config_bits,
ERROR_NAMES,
)
from carrier_catalog import CarrierCatalog, CatalogDiff # noqa: E402
from signal_analysis import adaptive_noise_floor, detect_peaks_enhanced # noqa: E402
from survey_engine import SurveyEngine # noqa: E402
MOTOR_WATCHDOG_SECS = 30
class DeviceBridge:
"""Thread-safe wrapper around SkyWalker1 for MCP tool access.
Same principle as the TUI's USBBridge: every hardware call goes through
a reentrant lock to prevent overlapping USB control transfers.
Internal access pattern: always use `with bridge.lock:` then `bridge._dev.method()`.
The `call()` convenience method does this automatically for simple cases.
Includes a motor watchdog: when continuous drive is started, a background
asyncio task will automatically halt the motor after MOTOR_WATCHDOG_SECS
unless cancelled by a halt or new motor command.
"""
def __init__(self, device: SkyWalker1):
self._dev = device
self._lock = threading.RLock()
self._motor_watchdog: asyncio.Task | None = None
def call(self, method_name: str, *args, **kwargs):
"""Call a SkyWalker1 method under the lock."""
with self._lock:
return getattr(self._dev, method_name)(*args, **kwargs)
@property
def lock(self) -> threading.RLock:
return self._lock
def cancel_motor_watchdog(self):
"""Cancel any running motor watchdog timer."""
if self._motor_watchdog is not None and not self._motor_watchdog.done():
self._motor_watchdog.cancel()
self._motor_watchdog = None
def start_motor_watchdog(self, timeout: float = MOTOR_WATCHDOG_SECS):
"""Start or restart the motor watchdog timer.
After `timeout` seconds, the motor is automatically halted.
Any subsequent motor command or explicit halt cancels the watchdog.
"""
self.cancel_motor_watchdog()
async def _watchdog():
await asyncio.sleep(timeout)
print(
f"skywalker-mcp: MOTOR WATCHDOG fired after {timeout}s — halting motor",
file=sys.stderr,
)
with self._lock:
try:
self._dev.motor_halt()
except Exception as e:
print(f"skywalker-mcp: watchdog halt failed: {e}", file=sys.stderr)
self._motor_watchdog = asyncio.create_task(_watchdog())
# Global bridge reference, set during lifespan
_bridge: DeviceBridge | None = None
@asynccontextmanager
async def lifespan(server: FastMCP):
"""Open the USB device on startup, close on shutdown.
Set SKYWALKER_MOCK=1 to use a mock device for integration testing
without USB hardware.
"""
global _bridge
if os.environ.get("SKYWALKER_MOCK"):
from skywalker_mcp.mock_device import MockSkyWalker1
dev = MockSkyWalker1(verbose=False)
print("skywalker-mcp: MOCK MODE — no USB hardware", file=sys.stderr)
else:
dev = SkyWalker1(verbose=False)
try:
dev.open()
dev.ensure_booted()
_bridge = DeviceBridge(dev)
print(f"skywalker-mcp: device open, fw {dev.get_fw_version()['version']}", file=sys.stderr)
yield {"bridge": _bridge}
finally:
if _bridge is not None:
_bridge.cancel_motor_watchdog()
_bridge = None
dev.close()
print("skywalker-mcp: device closed", file=sys.stderr)
mcp = FastMCP(
"skywalker-mcp",
instructions="MCP server for the Genpix SkyWalker-1 DVB-S USB receiver. "
"Provides spectrum sweep, signal monitoring, carrier survey, "
"dish motor control, and transport stream analysis.",
lifespan=lifespan,
)
def _get_bridge(ctx: Context) -> DeviceBridge:
"""Retrieve the DeviceBridge from lifespan context."""
return ctx.request_context.lifespan_context["bridge"]
async def _dev_call(ctx: Context, method: str, *args, **kwargs):
"""Run a device method in a thread (blocking USB I/O)."""
bridge = _get_bridge(ctx)
return await asyncio.to_thread(bridge.call, method, *args, **kwargs)
# ─────────────────────────────────────────────
# Device Status Tools
# ─────────────────────────────────────────────
@mcp.tool()
async def get_device_status(ctx: Context) -> dict:
"""Read comprehensive device status: firmware version, config bits,
USB speed, serial number, and last error code."""
bridge = _get_bridge(ctx)
def _read():
with bridge.lock:
fw = bridge._dev.get_fw_version()
config = bridge._dev.get_config()
speed = bridge._dev.get_usb_speed()
serial = bridge._dev.get_serial_number()
error = bridge._dev.get_last_error()
return {
"firmware": fw,
"config_byte": config,
"config_bits": {name: is_set for name, is_set in format_config_bits(config)},
"usb_speed": {0: "unknown", 1: "Full (12 Mbps)", 2: "High (480 Mbps)"}.get(speed, f"unknown ({speed})"),
"serial": serial.hex(' '),
"last_error": ERROR_NAMES.get(error, f"0x{error:02X}"),
}
return await asyncio.to_thread(_read)
@mcp.tool()
async def get_signal_quality(ctx: Context) -> dict:
"""Read current signal quality: SNR, AGC levels, lock status,
and estimated power. Requires a prior tune() call."""
result = await _dev_call(ctx, "signal_monitor")
return {
"snr_db": round(result["snr_db"], 2),
"snr_pct": round(result["snr_pct"], 1),
"agc1": result["agc1"],
"agc2": result["agc2"],
"power_db": round(result["power_db"], 2),
"locked": result["locked"],
"lock_byte": f"0x{result['lock']:02X}",
"status_byte": f"0x{result['status']:02X}",
}
@mcp.tool()
async def get_stream_diagnostics(ctx: Context, reset: bool = False) -> dict:
"""Read streaming diagnostics: poll count, overflow count, sync loss,
and arm status. Set reset=True to clear counters after reading."""
return await _dev_call(ctx, "get_stream_diag", reset=reset)
# ─────────────────────────────────────────────
# Spectrum & Tuning Tools
# ─────────────────────────────────────────────
@mcp.tool()
async def sweep_spectrum(
ctx: Context,
start_mhz: float = 950.0,
stop_mhz: float = 2150.0,
step_mhz: float = 5.0,
dwell_ms: int = 15,
) -> dict:
"""Sweep a frequency range and return power measurements at each step.
Default covers the full IF range (950-2150 MHz) at 5 MHz steps.
For direct L-band input (no LNB), use the full range.
For LNB-converted signals, the IF range maps to the RF band via the LO.
Returns frequency/power arrays plus detected peaks."""
if start_mhz < 950 or start_mhz > 2150:
return {"error": f"start_mhz must be 950-2150, got {start_mhz}"}
if stop_mhz < 950 or stop_mhz > 2150:
return {"error": f"stop_mhz must be 950-2150, got {stop_mhz}"}
if start_mhz >= stop_mhz:
return {"error": f"start_mhz ({start_mhz}) must be less than stop_mhz ({stop_mhz})"}
if step_mhz < 0.1 or step_mhz > 100:
return {"error": f"step_mhz must be 0.1-100, got {step_mhz}"}
if dwell_ms < 1 or dwell_ms > 255:
return {"error": f"dwell_ms must be 1-255, got {dwell_ms}"}
bridge = _get_bridge(ctx)
def _sweep():
with bridge.lock:
freqs, powers, raw = bridge._dev.sweep_spectrum(
start_mhz, stop_mhz, step_mhz=step_mhz, dwell_ms=dwell_ms,
)
noise_floor, mad = adaptive_noise_floor(powers)
peaks = detect_peaks_enhanced(freqs, powers, threshold_db=6.0)
return {
"start_mhz": start_mhz,
"stop_mhz": stop_mhz,
"step_mhz": step_mhz,
"num_points": len(freqs),
"noise_floor_db": round(noise_floor, 2),
"noise_mad_db": round(mad, 3),
"frequencies_mhz": [round(f, 3) for f in freqs],
"powers_db": [round(p, 2) for p in powers],
"peaks": [
{
"freq_mhz": round(pk["freq"], 3),
"power_db": round(pk["power"], 2),
"width_mhz": round(pk["width_mhz"], 2),
"prominence_db": round(pk["prominence_db"], 2),
}
for pk in peaks
],
}
await ctx.report_progress(0, 100)
result = await asyncio.to_thread(_sweep)
await ctx.report_progress(100, 100)
return result
@mcp.tool()
async def tune_frequency(
ctx: Context,
freq_mhz: float,
symbol_rate_ksps: int = 20000,
modulation: str = "qpsk",
fec: str = "auto",
dwell_ms: int = 10,
) -> dict:
"""Tune to a specific frequency and read signal quality.
freq_mhz: IF frequency in MHz (950-2150)
symbol_rate_ksps: symbol rate in ksps (256-30000)
modulation: one of qpsk, turbo-qpsk, turbo-8psk, turbo-16qam,
dcii-combo, dcii-i, dcii-q, dcii-oqpsk, dss, bpsk
fec: FEC rate (depends on modulation) or 'auto'
dwell_ms: time to wait after tuning before reading signal (1-255)"""
if freq_mhz < 950 or freq_mhz > 2150:
return {"error": f"freq_mhz must be 950-2150, got {freq_mhz}"}
if symbol_rate_ksps < 256 or symbol_rate_ksps > 30000:
return {"error": f"symbol_rate_ksps must be 256-30000, got {symbol_rate_ksps}"}
if dwell_ms < 1 or dwell_ms > 255:
return {"error": f"dwell_ms must be 1-255, got {dwell_ms}"}
mod_entry = MODULATIONS.get(modulation)
if mod_entry is None:
return {"error": f"Unknown modulation '{modulation}'. Valid: {list(MODULATIONS.keys())}"}
mod_idx = mod_entry[0]
fec_group = MOD_FEC_GROUP.get(modulation, "dvbs")
fec_table = FEC_RATES.get(fec_group, {})
fec_idx = fec_table.get(fec, fec_table.get("auto", 0))
result = await _dev_call(
ctx, "tune_monitor",
symbol_rate_ksps * 1000,
int(freq_mhz * 1000),
mod_idx, fec_idx, dwell_ms,
)
return {
"freq_mhz": freq_mhz,
"symbol_rate_ksps": symbol_rate_ksps,
"modulation": modulation,
"fec": fec,
"snr_db": round(result["snr_db"], 2),
"agc1": result["agc1"],
"agc2": result["agc2"],
"power_db": round(result["power_db"], 2),
"locked": result["locked"],
"dwell_ms": result["dwell_ms"],
}
@mcp.tool()
async def run_blind_scan(
ctx: Context,
freq_mhz: float,
sr_min_ksps: int = 1000,
sr_max_ksps: int = 30000,
sr_step_ksps: int = 1000,
) -> dict:
"""Run adaptive blind scan at a single frequency, sweeping symbol rates
to find a lock. Returns the locked symbol rate if found, or null."""
if freq_mhz < 950 or freq_mhz > 2150:
return {"error": f"freq_mhz must be 950-2150, got {freq_mhz}"}
if sr_min_ksps < 256:
return {"error": f"sr_min_ksps must be >= 256, got {sr_min_ksps}"}
if sr_max_ksps > 30000:
return {"error": f"sr_max_ksps must be <= 30000, got {sr_max_ksps}"}
result = await _dev_call(
ctx, "adaptive_blind_scan",
int(freq_mhz * 1000),
sr_min_ksps * 1000,
sr_max_ksps * 1000,
sr_step_ksps * 1000,
)
if result is None:
return {"freq_mhz": freq_mhz, "locked": False, "sr_sps": None}
return {
"freq_mhz": result["freq_khz"] / 1000.0,
"locked": result["locked"],
"sr_sps": result["sr_sps"],
"sr_ksps": result["sr_sps"] / 1000.0,
}
# ─────────────────────────────────────────────
# Survey & Catalog Tools
# ─────────────────────────────────────────────
@mcp.tool()
async def run_carrier_survey(
ctx: Context,
start_mhz: float = 950.0,
stop_mhz: float = 2150.0,
coarse_step: float = 5.0,
band: str = "",
pol: str = "",
lnb_lo_mhz: float = 0.0,
save: bool = True,
) -> dict:
"""Run the six-stage carrier survey pipeline: coarse sweep, peak detection,
fine sweep, blind scan, TS sampling, and catalog assembly.
This is the full automated survey. Takes 5-15 minutes depending on range.
Results are saved to ~/.skywalker1/surveys/ as JSON."""
bridge = _get_bridge(ctx)
def _survey():
# NOTE: We intentionally do NOT hold bridge.lock for the entire survey.
# The survey takes 5-15 minutes and holding the lock would block
# emergency motor halt commands. SurveyEngine calls device methods
# individually — each USB transfer is atomic at the hardware level.
# The RLock still protects concurrent tool calls from interleaving
# mid-transfer through the _dev_call / bridge.call path.
engine = SurveyEngine(bridge._dev)
catalog = engine.run_full_scan(
start_mhz=start_mhz, stop_mhz=stop_mhz,
coarse_step=coarse_step,
)
catalog.band = band
catalog.pol = pol
catalog.lnb_lo_mhz = lnb_lo_mhz
result = {
"carrier_count": len(catalog.carriers),
"locked_count": sum(1 for c in catalog.carriers if c.locked),
"summary": catalog.summary(),
}
if save:
path = catalog.save()
result["saved_to"] = str(path)
return result
return await asyncio.to_thread(_survey)
@mcp.tool()
async def compare_surveys(
ctx: Context,
old_filename: str,
new_filename: str,
) -> dict:
"""Compare two saved survey catalogs and report new, missing, and changed
carriers between them. Filenames are looked up in ~/.skywalker1/surveys/."""
# Sanitize filenames: strip path separators to prevent directory traversal
for name, label in [(old_filename, "old_filename"), (new_filename, "new_filename")]:
basename = Path(name).name
if basename != name or ".." in name:
return {"error": f"{label} must be a plain filename, not a path. Got: {name}"}
def _compare():
old_cat = CarrierCatalog.load(old_filename)
new_cat = CarrierCatalog.load(new_filename)
diff = CatalogDiff.diff(old_cat, new_cat)
return {
"new_count": len(diff["new"]),
"missing_count": len(diff["missing"]),
"changed_count": len(diff["changed"]),
"stable_count": len(diff["stable"]),
"formatted": CatalogDiff.format_diff(diff),
}
return await asyncio.to_thread(_compare)
@mcp.tool()
async def list_surveys(ctx: Context) -> list[dict]:
"""List saved survey catalog files from ~/.skywalker1/surveys/,
newest first, with carrier counts and metadata."""
return await asyncio.to_thread(CarrierCatalog.list_surveys)
# ─────────────────────────────────────────────
# Dish Motor Control Tools
# ─────────────────────────────────────────────
@mcp.tool()
async def move_dish(
ctx: Context,
action: str,
value: float = 0.0,
observer_lon: float = 0.0,
continuous: bool = False,
) -> dict:
"""Control the DiSEqC 1.2 dish motor.
action: one of 'halt', 'east', 'west', 'goto', 'gotox'
value:
- For east/west: number of steps (1-127). Must set continuous=True for non-stop drive.
- For goto: position slot number (0-255, 0=reference)
- For gotox: satellite longitude (negative=west)
observer_lon: your longitude (for gotox only, negative=west)
continuous: must be explicitly True to allow continuous (non-stop) motor drive.
Without this, steps=0 is rejected as a safety measure."""
bridge = _get_bridge(ctx)
def _move():
with bridge.lock:
if action == "halt":
bridge._dev.motor_halt()
return {"action": "halt", "status": "stopped"}
elif action in ("east", "west"):
steps = int(value)
if steps == 0 and not continuous:
return {
"error": "steps=0 means CONTINUOUS drive (motor never stops). "
"Set continuous=True to confirm, or use steps=1-127. "
"Send action='halt' to stop a running motor.",
}
if steps < 0 or steps > 127:
return {"error": f"steps must be 0-127, got {steps}"}
if action == "east":
bridge._dev.motor_drive_east(steps)
else:
bridge._dev.motor_drive_west(steps)
mode = "continuous (send halt to stop)" if steps == 0 else "stepped"
return {"action": action, "steps": steps, "mode": mode, "status": "driving",
"continuous": steps == 0}
elif action == "goto":
slot = int(value)
if slot < 0 or slot > 255:
return {"error": f"Slot must be 0-255, got {slot}"}
bridge._dev.motor_goto_position(slot)
return {"action": "goto", "slot": slot, "status": "moving"}
elif action == "gotox":
from skywalker_lib import usals_angle
angle = usals_angle(observer_lon, value)
bridge._dev.motor_goto_x(observer_lon, value)
return {
"action": "gotox",
"satellite_lon": value,
"observer_lon": observer_lon,
"motor_angle_deg": round(angle, 2),
"direction": "west" if angle < 0 else "east",
"status": "moving",
}
else:
return {"error": f"Unknown action '{action}'. Valid: halt, east, west, goto, gotox"}
result = await asyncio.to_thread(_move)
# Motor watchdog management: cancel on halt/goto/gotox, start on continuous drive
if "error" not in result:
if action == "halt":
bridge.cancel_motor_watchdog()
elif action in ("goto", "gotox"):
# GotoX/Goto have inherent motor-stop at destination
bridge.cancel_motor_watchdog()
elif result.get("continuous"):
bridge.start_motor_watchdog()
result["watchdog_secs"] = MOTOR_WATCHDOG_SECS
result["warning"] = (
f"Motor watchdog active: auto-halt in {MOTOR_WATCHDOG_SECS}s. "
"Send action='halt' to stop sooner."
)
return result
@mcp.tool()
async def jog_dish(
ctx: Context,
direction: str,
steps: int = 5,
) -> dict:
"""Jog the dish a small number of steps east or west, then read signal quality.
Useful for fine-tuning dish alignment. Steps capped at 30 for safety."""
if direction not in ("east", "west"):
return {"error": "direction must be 'east' or 'west'"}
if steps < 1 or steps > 30:
return {"error": f"steps must be 1-30 for jog (got {steps}). Use move_dish for larger moves."}
bridge = _get_bridge(ctx)
def _jog():
import time
with bridge.lock:
if direction == "east":
bridge._dev.motor_drive_east(steps)
else:
bridge._dev.motor_drive_west(steps)
time.sleep(0.5 + steps * 0.05)
sig = bridge._dev.signal_monitor()
return {
"direction": direction,
"steps": steps,
"snr_db": round(sig["snr_db"], 2),
"agc1": sig["agc1"],
"power_db": round(sig["power_db"], 2),
"locked": sig["locked"],
}
return await asyncio.to_thread(_jog)
@mcp.tool()
async def store_position(
ctx: Context,
slot: int,
) -> dict:
"""Store the current dish position to a memory slot (1-255)."""
if slot < 1 or slot > 255:
return {"error": "Slot must be 1-255 (slot 0 is reference)"}
await _dev_call(ctx, "motor_store_position", slot)
return {"stored": True, "slot": slot}
# ─────────────────────────────────────────────
# LNB & Power Tools
# ─────────────────────────────────────────────
@mcp.tool()
async def set_lnb_config(
ctx: Context,
voltage: str = "",
tone_22khz: bool | None = None,
disable_lnb: bool = False,
) -> dict:
"""Configure LNB power supply and 22 kHz tone.
voltage: '13V' or '18V' (controls polarization: 13V=vertical, 18V=horizontal)
tone_22khz: True to enable (high band), False to disable (low band)
disable_lnb: True to turn off LNB power entirely (for direct L-band input)"""
bridge = _get_bridge(ctx)
def _configure():
with bridge.lock:
result = {}
if disable_lnb:
bridge._dev.start_intersil(on=False)
result["lnb_power"] = "off"
return result
if voltage:
high = voltage.upper() in ("18V", "18", "H", "L")
bridge._dev.set_lnb_voltage(high)
result["voltage"] = "18V" if high else "13V"
if tone_22khz is not None:
bridge._dev.set_22khz_tone(tone_22khz)
result["tone_22khz"] = tone_22khz
return result
return await asyncio.to_thread(_configure)
# ─────────────────────────────────────────────
# I2C Bus Tools
# ─────────────────────────────────────────────
@mcp.tool()
async def scan_i2c_bus(ctx: Context) -> dict:
"""Scan the I2C bus for all responding devices.
Returns list of 7-bit slave addresses that ACK'd."""
addresses = await _dev_call(ctx, "i2c_bus_scan")
known_devices = {
0x08: "BCM4500 (demodulator)",
0x61: "BCM3440 (tuner)",
0x51: "24C128 EEPROM (boot)",
}
devices = []
for addr in addresses:
devices.append({
"address": f"0x{addr:02X}",
"decimal": addr,
"known_as": known_devices.get(addr, "unknown"),
})
return {"device_count": len(devices), "devices": devices}
@mcp.tool()
async def read_i2c_register(
ctx: Context,
slave_address: int,
register: int,
) -> dict:
"""Read a single byte from an I2C device register.
slave_address: 7-bit I2C address (e.g. 0x08 for BCM4500)
register: register address to read"""
value = await _dev_call(ctx, "i2c_raw_read", slave_address, register)
return {
"slave": f"0x{slave_address:02X}",
"register": f"0x{register:02X}",
"value": value,
"hex": f"0x{value:02X}",
"binary": f"0b{value:08b}",
}
# ─────────────────────────────────────────────
# Transport Stream Tools
# ─────────────────────────────────────────────
@mcp.tool()
async def capture_transport_stream(
ctx: Context,
duration_secs: float = 3.0,
) -> dict:
"""Capture transport stream data from the currently tuned carrier and
parse PAT/PMT/SDT for service information.
The device must already be tuned and locked to a carrier.
duration_secs: capture time (0.5-30 seconds).
Returns parsed service names, program table, and stream metadata."""
if duration_secs < 0.5 or duration_secs > 30:
return {"error": f"duration_secs must be 0.5-30, got {duration_secs}"}
bridge = _get_bridge(ctx)
def _capture():
import time
import io
with bridge.lock:
sig = bridge._dev.signal_monitor()
if not sig.get("locked"):
return {"error": "No signal lock. Tune to a carrier first."}
bridge._dev.arm_transfer(True)
ts_data = bytearray()
try:
deadline = time.time() + duration_secs
while time.time() < deadline:
chunk = bridge._dev.read_stream(timeout=500)
if chunk:
ts_data.extend(chunk)
finally:
bridge._dev.arm_transfer(False)
if not ts_data:
return {"error": "No TS data received", "bytes_captured": 0}
result = {
"bytes_captured": len(ts_data),
"packets": len(ts_data) // 188,
"services": [],
"programs": {},
}
try:
from ts_analyze import TSReader, PSIParser, parse_pat, parse_sdt
source = io.BytesIO(bytes(ts_data))
reader = TSReader(source)
psi_pat = PSIParser()
psi_sdt = PSIParser()
pat = None
pmt_pids = set()
for pkt in reader.iter_packets(max_packets=50000):
if pkt.pid == 0x0000 and pat is None:
section = psi_pat.feed(pkt)
if section is not None:
pat = parse_pat(section)
if pat:
result["programs"] = pat["programs"]
for prog, pid in pat["programs"].items():
if prog != 0:
pmt_pids.add(pid)
if pkt.pid == 0x0011:
section = psi_sdt.feed(pkt)
if section is not None:
sdt = parse_sdt(section)
if sdt:
for svc in sdt.get("services", []):
name = svc.get("service_name", "")
if name:
result["services"].append(name)
break
except Exception as e:
result["parse_error"] = str(e)
return result
return await asyncio.to_thread(_capture)
# ─────────────────────────────────────────────
# Frequency Identification Tool
# ─────────────────────────────────────────────
@mcp.tool()
async def identify_frequency(
ctx: Context,
freq_mhz: float,
lnb_lo_mhz: float = 0.0,
) -> dict:
"""Look up what service or allocation is at a given frequency.
freq_mhz: IF frequency (950-2150 MHz)
lnb_lo_mhz: LNB local oscillator (0 = direct input, no conversion)
Cross-references against L-band allocations and known satellite bands."""
rf_mhz = freq_mhz + lnb_lo_mhz if lnb_lo_mhz else freq_mhz
matches = []
for start, stop, name in LBAND_ALLOCATIONS:
if start <= rf_mhz <= stop:
matches.append({"band": name, "range_mhz": f"{start}-{stop}"})
# Known point frequencies
known_freqs = [
(1420.405, 2.0, "Hydrogen 21 cm line (galactic emission)"),
(1575.42, 2.0, "GPS L1"),
(1227.6, 2.0, "GPS L2"),
(1176.45, 2.0, "GPS L5 / Galileo E5a"),
(1207.14, 2.0, "Galileo E5b"),
(1602.0, 10.0, "GLONASS L1 (FDMA center)"),
(1246.0, 10.0, "GLONASS L2 (FDMA center)"),
(1544.5, 1.0, "COSPAS-SARSAT (EPIRB)"),
]
for center, tolerance, name in known_freqs:
if abs(rf_mhz - center) <= tolerance:
matches.append({"signal": name, "center_mhz": center})
return {
"if_freq_mhz": freq_mhz,
"rf_freq_mhz": rf_mhz if lnb_lo_mhz else None,
"lnb_lo_mhz": lnb_lo_mhz or None,
"matches": matches,
"in_if_range": 950 <= freq_mhz <= 2150,
}
# ─────────────────────────────────────────────
# MCP Resources
# ─────────────────────────────────────────────
@mcp.resource("skywalker://status")
async def resource_status() -> str:
"""Live device status: firmware, config, signal."""
if _bridge is None:
return json.dumps({"error": "Device not connected"})
def _read():
with _bridge.lock:
fw = _bridge._dev.get_fw_version()
config = _bridge._dev.get_config()
sig = _bridge._dev.signal_monitor()
error = _bridge._dev.get_last_error()
return json.dumps({
"firmware": fw["version"],
"firmware_date": fw["date"],
"config_bits": {name: is_set for name, is_set in format_config_bits(config)},
"signal": {
"snr_db": round(sig["snr_db"], 2),
"agc1": sig["agc1"],
"power_db": round(sig["power_db"], 2),
"locked": sig["locked"],
},
"last_error": ERROR_NAMES.get(error, f"0x{error:02X}"),
}, indent=2)
return await asyncio.to_thread(_read)
@mcp.resource("skywalker://catalog/latest")
async def resource_latest_catalog() -> str:
"""Most recent survey catalog."""
def _read():
surveys = CarrierCatalog.list_surveys()
if not surveys:
return json.dumps({"error": "No surveys saved yet"})
latest = surveys[0]
cat = CarrierCatalog.load(latest["filename"])
return json.dumps(cat.to_dict(), indent=2)
return await asyncio.to_thread(_read)
@mcp.resource("skywalker://allocations/lband")
async def resource_lband_allocations() -> str:
"""L-band frequency allocation table (950-2150 MHz IF range)."""
allocations = []
for start, stop, name in LBAND_ALLOCATIONS:
allocations.append({
"start_mhz": start,
"stop_mhz": stop,
"name": name,
"in_if_range": (start >= 950 or stop >= 950) and (start <= 2150),
})
known = [
{"freq_mhz": 1420.405, "name": "Hydrogen 21 cm line", "type": "spectral_line"},
{"freq_mhz": 1575.42, "name": "GPS L1", "type": "navigation"},
{"freq_mhz": 1227.6, "name": "GPS L2", "type": "navigation"},
{"freq_mhz": 1176.45, "name": "GPS L5 / Galileo E5a", "type": "navigation"},
{"freq_mhz": 1602.0, "name": "GLONASS L1", "type": "navigation"},
{"freq_mhz": 1544.5, "name": "COSPAS-SARSAT", "type": "distress"},
]
return json.dumps({
"allocations": allocations,
"known_frequencies": known,
"note": "These frequencies are directly receivable (no LNB) when "
"an antenna is connected to the F-connector input.",
}, indent=2)
@mcp.resource("skywalker://modulations")
async def resource_modulations() -> str:
"""Supported modulation types and FEC rates."""
mods = {}
for name, (idx, desc) in MODULATIONS.items():
fec_group = MOD_FEC_GROUP.get(name, "dvbs")
fec_options = list(FEC_RATES.get(fec_group, {}).keys())
mods[name] = {
"index": idx,
"description": desc,
"fec_options": fec_options,
}
return json.dumps(mods, indent=2)
# ─────────────────────────────────────────────
# MCP Prompts
# ─────────────────────────────────────────────
@mcp.prompt()
async def explore_rf_environment() -> str:
"""Autonomous RF environment exploration prompt.
Instructs the LLM to systematically survey and document the RF spectrum."""
return """You have access to a Genpix SkyWalker-1 DVB-S USB receiver via MCP tools.
Your task: systematically explore the RF environment and build a knowledge base
of what you discover.
Strategy:
1. Start with get_device_status to verify the hardware is working
2. Run a full-band sweep_spectrum (950-2150 MHz) to see what's out there
3. For each detected peak, use identify_frequency to classify it
4. For strong carriers, try tune_frequency with different modulations
5. If a carrier locks, use capture_transport_stream to identify services
6. Use the dish motor (move_dish/jog_dish) to explore different satellites
7. Compare results with any previous surveys (list_surveys/compare_surveys)
Report your findings as you go. Note any anomalies or interesting signals."""
@mcp.prompt()
async def hydrogen_line_observation() -> str:
"""Guide for observing the hydrogen 21 cm line at 1420.405 MHz."""
return """You have access to a Genpix SkyWalker-1 receiver for hydrogen 21 cm observation.
IMPORTANT: This requires direct antenna input (no LNB). Set disable_lnb=True.
Procedure:
1. Verify device with get_device_status
2. Configure: set_lnb_config(disable_lnb=True)
3. Sweep the hydrogen line region: sweep_spectrum(start_mhz=1418, stop_mhz=1423, step_mhz=0.5)
4. Look for a broad power bump centered near 1420.405 MHz
5. Compare with adjacent "empty" spectrum (e.g., 1430-1435 MHz) as a control
6. The velocity of the hydrogen gas can be calculated from Doppler shift:
v = c * (f_observed - 1420.405) / 1420.405
The emission is broad (several MHz) due to galactic rotation velocity dispersion.
You won't see a sharp spike — look for elevated power across the 1419-1421 MHz range."""
def main():
mcp.run()
if __name__ == "__main__":
main()