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.
944 lines
36 KiB
Python
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()
|