Add Phase 1 experimenter tools: MCP server, H21cm, beacon logger, arc survey
Four new tools transforming the SkyWalker-1 from satellite TV receiver into a general-purpose RF observatory: - skywalker-mcp: FastMCP server exposing 20 tools, 4 resources, 2 prompts. Thread-safe DeviceBridge with motor safety (continuous drive opt-in), input validation on all frequency/symbol rate/step parameters, try/finally on TS capture, path traversal sanitization, and reduced lock scope so emergency motor halt isn't blocked during long surveys. - h21cm.py: Hydrogen 21 cm drift-scan radiometer at 1420.405 MHz with Doppler velocity calculation, control band comparison, and CSV output. - beacon_logger.py: Long-term Ku-band beacon SNR/AGC logger with auto-relock, dual CSV/JSONL output, signal handlers, and systemd unit generation. - arc_survey.py: Multi-satellite orbital arc census with USALS motor control, per-slot catalog persistence, resume support, and defensive motor halt on all error/interrupt paths. Documentation: experimenter's roadmap guide + 4 tool reference pages (48 pages total).
This commit is contained in:
parent
6c00f941eb
commit
a9dcf84c38
15 changed files with 4374 additions and 0 deletions
8
mcp/skywalker-mcp/.mcp.json
Normal file
8
mcp/skywalker-mcp/.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"skywalker-mcp": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--directory", ".", "skywalker-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
35
mcp/skywalker-mcp/pyproject.toml
Normal file
35
mcp/skywalker-mcp/pyproject.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "skywalker-mcp"
|
||||
version = "2026.2.17"
|
||||
description = "MCP server for the Genpix SkyWalker-1 DVB-S USB receiver"
|
||||
requires-python = ">=3.11"
|
||||
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||
dependencies = [
|
||||
"fastmcp>=2.0",
|
||||
"pyusb>=1.3",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
skywalker-mcp = "skywalker_mcp.server:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/skywalker_mcp"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.9",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
3
mcp/skywalker-mcp/src/skywalker_mcp/__init__.py
Normal file
3
mcp/skywalker-mcp/src/skywalker_mcp/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""MCP server for the Genpix SkyWalker-1 DVB-S USB receiver."""
|
||||
|
||||
__version__ = "2026.2.17"
|
||||
877
mcp/skywalker-mcp/src/skywalker_mcp/server.py
Normal file
877
mcp/skywalker-mcp/src/skywalker_mcp/server.py
Normal file
|
|
@ -0,0 +1,877 @@
|
|||
"""
|
||||
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 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[3] / "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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, device: SkyWalker1):
|
||||
self._dev = device
|
||||
self._lock = threading.RLock()
|
||||
|
||||
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
|
||||
|
||||
|
||||
# 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."""
|
||||
global _bridge
|
||||
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:
|
||||
_bridge = None
|
||||
dev.close()
|
||||
print("skywalker-mcp: device closed", file=sys.stderr)
|
||||
|
||||
|
||||
mcp = FastMCP(
|
||||
"skywalker-mcp",
|
||||
description="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"}
|
||||
|
||||
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"}
|
||||
|
||||
return await asyncio.to_thread(_move)
|
||||
|
||||
|
||||
@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()
|
||||
1677
mcp/skywalker-mcp/uv.lock
generated
Normal file
1677
mcp/skywalker-mcp/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue