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:
Ryan Malloy 2026-02-17 14:45:02 -07:00
parent 6c00f941eb
commit a9dcf84c38
15 changed files with 4374 additions and 0 deletions

4
.gitignore vendored
View File

@ -12,6 +12,10 @@ tools/__pycache__/
tui/.venv/ tui/.venv/
tui/__pycache__/ tui/__pycache__/
# MCP server
mcp/skywalker-mcp/.venv/
mcp/skywalker-mcp/__pycache__/
# Documentation site # Documentation site
site/node_modules/ site/node_modules/
site/dist/ site/dist/

View File

@ -0,0 +1,8 @@
{
"mcpServers": {
"skywalker-mcp": {
"command": "uv",
"args": ["run", "--directory", ".", "skywalker-mcp"]
}
}
}

View 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"

View File

@ -0,0 +1,3 @@
"""MCP server for the Genpix SkyWalker-1 DVB-S USB receiver."""
__version__ = "2026.2.17"

View 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

File diff suppressed because it is too large Load Diff

View File

@ -121,6 +121,10 @@ export default defineConfig({
{ label: 'Debugging', slug: 'tools/debugging' }, { label: 'Debugging', slug: 'tools/debugging' },
{ label: 'TS Analyzer', slug: 'tools/ts-analyzer' }, { label: 'TS Analyzer', slug: 'tools/ts-analyzer' },
{ label: 'Spectrum Analysis', slug: 'tools/spectrum-analysis' }, { label: 'Spectrum Analysis', slug: 'tools/spectrum-analysis' },
{ label: 'Hydrogen 21 cm', slug: 'tools/h21cm' },
{ label: 'Beacon Logger', slug: 'tools/beacon-logger' },
{ label: 'Arc Survey', slug: 'tools/arc-survey' },
{ label: 'MCP Server', slug: 'tools/mcp-server' },
{ label: 'Safety Testing', slug: 'tools/safety-testing' }, { label: 'Safety Testing', slug: 'tools/safety-testing' },
], ],
}, },
@ -128,6 +132,7 @@ export default defineConfig({
label: 'Guides', label: 'Guides',
items: [ items: [
{ label: 'QO-100 DATV Reception', slug: 'guides/qo100-datv' }, { label: 'QO-100 DATV Reception', slug: 'guides/qo100-datv' },
{ label: "Experimenter's Roadmap", slug: 'guides/experimenter-roadmap' },
], ],
}, },
{ {

View File

@ -0,0 +1,218 @@
---
title: Experimenter's Roadmap
description: What else can the SkyWalker-1 hardware do? A field guide to creative RF experiments beyond satellite TV reception.
---
import { Aside, Badge, Card, CardGrid, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
The SkyWalker-1 reverse engineering project has produced a fully documented, custom-firmware-driven,
Python-controllable RF instrument. With v3.05.0 deployed and all 55 Hamilton safety tests passing,
the question becomes: **what can experimenters actually do with this platform beyond satellite TV reception?**
The approach: start from the hardware capabilities, ask "what physical processes produce or interact
with signals these capabilities can measure," and reason outward to creative applications.
## Hardware as a Platform
The SkyWalker-1 is simultaneously:
- A **power meter** (16-bit AGC, ~30-40 dB dynamic range)
- A **spectrum analyzer** (~346 kHz RBW, 950-2150 MHz)
- A **satellite receiver** (10 modulation types, 256 ksps - 30 Msps)
- A **time-series data logger** (signal_monitor at ~50 Hz)
- A **dish positioning system** (DiSEqC 1.2 motor, USALS GotoX)
All controllable from Python.
<Aside type="tip" title="The key realization">
The AGC registers respond to **any RF energy** at the tuned frequency, regardless of modulation.
You don't need to demodulate a signal to detect and measure it.
</Aside>
## What's Directly Receivable (No LNB)
The 950-2150 MHz IF range contains far more than satellite TV when you connect an antenna directly:
| Frequency | What's There | Detectable? |
|---|---|---|
| **1420.405 MHz** | Hydrogen 21 cm line — galactic emission | Yes (AGC power) |
| 1575.42 MHz | GPS L1 | Yes (energy) |
| 1176.45 MHz | GPS L5 / Galileo E5a | Yes (energy) |
| 1227.6 MHz | GPS L2 | Yes (energy) |
| 1602 MHz | GLONASS L1 | Yes (energy) |
| 1525-1559 MHz | Inmarsat downlink | Yes (energy) |
| 1616-1626 MHz | Iridium downlink | Yes (burst energy) |
| 1670-1710 MHz | GOES LRIT, NOAA HRPT | Yes (carrier) |
| 1240-1300 MHz | Amateur 23 cm band | Yes (energy) |
## Phase 1 Tools (Available Now)
### Hydrogen 21 cm Drift-Scan Radiometer
<Badge text="tools/h21cm.py" variant="note" />
Neutral hydrogen emits at 1420.405 MHz — directly in the IF range. The Milky Way's spiral arms
create a velocity-dispersed emission profile detectable even with the BCM4500's resolution bandwidth.
<Steps>
1. Connect an L-band antenna (patch, helical, or horn) directly to the F-connector
2. Run `python h21cm.py` for a single sweep centered on 1420.405 MHz
3. Look for elevated power across the 1419-1421 MHz range (the hydrogen emission)
4. Use `--drift --duration 3600` for a one-hour drift scan as Earth rotates
5. Use `--averages 8` for 8x averaging to pull the signal from the noise
</Steps>
```bash
# Single sweep with 8x averaging
python tools/h21cm.py --averages 8
# One-hour drift scan with CSV output
python tools/h21cm.py --drift --duration 3600 --averages 4 --output h21cm-data.csv
# Include control band comparison
python tools/h21cm.py --averages 8 --control
```
The velocity of the detected hydrogen is calculated from Doppler shift:
**v = c × (f_rest f_observed) / f_rest**. This maps directly to the rotation curve
of the Milky Way.
### Beacon Logger
<Badge text="tools/beacon_logger.py" variant="note" />
Lock onto a stable Ku-band broadcast transponder and log SNR/AGC at configurable intervals
for hours, days, or weeks. Produces propagation datasets useful for:
- **Rain fade analysis** — correlate attenuation with rainfall rate
- **Diurnal thermal drift** — track LNB gain vs. temperature over 24 hours
- **Antenna mount stability** — detect dish drift from wind/thermal expansion
- **ITU propagation model validation** — contribute real measurements
```bash
# Log a Ku-band beacon for 24 hours, 1 sample/sec, report every minute
python tools/beacon_logger.py --freq 1265000 --sr 20000000 \
--output beacon-24h.csv --json-output beacon-stats.jsonl \
--duration 86400
# Generate a systemd unit file for unattended operation
python tools/beacon_logger.py --generate-systemd \
--freq 1265000 --sr 20000000 --output /var/log/beacon.csv
```
The logger automatically re-locks on signal loss and computes per-interval statistics
(min/max/mean/stddev of SNR).
### Multi-Satellite Arc Survey
<Badge text="tools/arc_survey.py" variant="note" />
Automated "satellite census": points the dish motor to each GEO orbital longitude,
runs a full-band six-stage survey at each position, and aggregates results into a comprehensive sky map.
```bash
# Survey specific North American slots
python tools/arc_survey.py --observer-lon -96.8 --slots "97W,99W,101W,103W"
# Survey an arc at 3-degree intervals
python tools/arc_survey.py --observer-lon -96.8 --arc -120 -60 --step 3
# List common NA orbital slots
python tools/arc_survey.py --list-slots
# Resume an interrupted survey
python tools/arc_survey.py --resume ~/.skywalker1/arc-surveys/arc-survey-2026-02-17.json
```
<Aside type="caution" title="Time commitment">
Each orbital slot takes 5-15 minutes to survey. A full North American arc
(~35 positions) is an overnight operation. The tool saves progress after each
slot, so Ctrl-C pauses safely and `--resume` continues where you left off.
</Aside>
### MCP Server
<Badge text="mcp/skywalker-mcp/" variant="note" />
The `skywalker-mcp` FastMCP server wraps the entire hardware API as MCP tools, making
every function accessible to LLMs. This is the foundation for autonomous RF exploration.
```bash
# Install and run (from project directory)
cd mcp/skywalker-mcp && uv run skywalker-mcp
# Add to Claude Code
claude mcp add skywalker-mcp -- uv run --directory mcp/skywalker-mcp skywalker-mcp
# Test with headless mode
claude -p "What firmware version is loaded?" \
--mcp-config .mcp.json --allowedTools "mcp__skywalker-mcp__*"
```
**20 MCP tools** covering:
- Device status and signal quality
- Spectrum sweep with peak detection
- Frequency tuning across 10 modulation types
- Blind scan for unknown carriers
- Six-stage carrier survey with catalog persistence
- Dish motor control (jog, goto, USALS)
- LNB configuration
- I2C bus scanning
- Transport stream capture and PSI parsing
- Frequency identification against allocation tables
**MCP Resources:**
- `skywalker://status` — live device state
- `skywalker://catalog/latest` — most recent survey
- `skywalker://allocations/lband` — frequency allocation table
- `skywalker://modulations` — supported modulation types
**MCP Prompts:**
- `explore_rf_environment` — autonomous RF exploration strategy
- `hydrogen_line_observation` — guided hydrogen 21 cm procedure
## Future Tiers
### Tier B: Creative Combinations (no firmware changes)
| Idea | Description |
|---|---|
| **Gradient-descent dish auto-peaking** | Closed-loop: tune to beacon, read AGC, step motor, converge |
| **Rain fade monitor** | Dual-frequency SNR logging + weather API correlation |
| **NIT-driven survey** | Parse NIT from one carrier → skip to ALL transponders |
| **Spectral anomaly detection** | Build baseline, flag N-sigma deviations, catch interference |
| **PCR timing analysis** | Extract clock jitter from transport stream timestamps |
### Tier C: Firmware Enhancements (v4.x)
| Feature | Impact | Size |
|---|---|---|
| Continuous AGC streaming via EP2 | Real-time waterfall displays | ~100 bytes |
| Firmware-side moving average | 12 dB noise floor improvement (16x avg) | ~50 bytes |
| GPIO event counter via Timer1 | General-purpose frequency counter | ~80 bytes |
| Multi-byte I2C transactions | External sensor integration | ~120 bytes |
| Fast power-only read (no retune) | 2x measurement rate | ~30 bytes |
### Tier D: External Hardware
- **I2C environmental sensors** (BME280/SHT40) for LNB drift vs. temperature
- **External TCXO/OCXO reference** for calibrated LNB frequency (±50 Hz vs ±5 MHz)
- **Noise source + Y-factor calibration** for absolute power measurements
- **GPIO antenna switch matrix** for automated antenna comparison
## Who Gets What
<CardGrid>
<Card title="RF Phreaks" icon="rocket">
Iridium burst detector, arc survey, anomaly detection, MCP autonomous explorer
</Card>
<Card title="Ham Radio" icon="open-book">
Hydrogen line, dish auto-peaking, QO-100 monitoring, arc survey
</Card>
<Card title="Skywatchers" icon="star">
Hydrogen line, GNSS monitoring, arc survey, beacon logger
</Card>
<Card title="Engineers" icon="setting">
Beacon logger, rain fade monitor, polarization analysis, PCR timing
</Card>
</CardGrid>

View File

@ -0,0 +1,95 @@
---
title: Arc Survey
description: Automated multi-satellite orbital arc survey with motor control and carrier catalog aggregation.
---
import { Aside, Steps } from '@astrojs/starlight/components';
The `arc_survey.py` tool automates a complete satellite census across the GEO orbital arc.
It points the dish motor to each orbital longitude, runs a full six-stage carrier survey,
saves individual catalogs, and produces an aggregated sky map.
## Quick Start
```bash
# Survey 4 specific North American slots
python tools/arc_survey.py --observer-lon -96.8 --slots "97W,99W,101W,103W"
# Survey a 60-degree arc at 3-degree intervals
python tools/arc_survey.py --observer-lon -96.8 --arc -120 -60 --step 3
# List common NA orbital positions
python tools/arc_survey.py --list-slots
```
## How It Works
<Steps>
1. **Parse orbital slot list** from CLI, JSON file, or arc range
2. **For each slot**: send USALS GotoX command to motor
3. **Wait for settle** (scales with angular distance, minimum 15 seconds)
4. **Run six-stage survey**: coarse sweep → peak detection → fine sweep → blind scan → TS sample → catalog
5. **Save per-slot catalog** to `~/.skywalker1/surveys/`
6. **Save arc survey state** to `~/.skywalker1/arc-surveys/` (for resume)
7. **Print aggregated summary** with per-slot carrier counts
</Steps>
## Resume Support
The tool saves state after every completed slot. If interrupted (Ctrl-C, power loss, etc.):
```bash
python tools/arc_survey.py --resume ~/.skywalker1/arc-surveys/arc-survey-2026-02-17.json
```
Already-surveyed slots are skipped. The survey continues from where it left off.
## Slot Specification
<Aside type="tip" title="Slot formats">
Slots accept multiple formats: `97W`, `97.5W`, `3E`, `-97`, `-97.5`
</Aside>
**CLI list:**
```bash
--slots "97W,99W,101W,103W,105W"
```
**JSON file:**
```json
[
{"name": "97W", "lon": -97.0},
{"name": "99W", "lon": -99.0},
{"name": "Galaxy 16", "lon": -99.0}
]
```
**Arc range:**
```bash
--arc -120 -60 --step 3
```
## Options
| Flag | Default | Description |
|---|---|---|
| `--observer-lon` | (required) | Observer longitude (negative = west) |
| `--observer-lat` | 0.0 | Observer latitude |
| `--slots` | — | Comma-separated slot list |
| `--file` | — | JSON file with slot definitions |
| `--arc` | — | Start/stop longitude for range scan |
| `--step` | 3.0 | Degrees between arc positions |
| `--coarse-step` | 5.0 | MHz step for coarse spectrum sweep |
| `--settle-time` | 15 | Minimum motor settle time (seconds) |
| `--resume` | — | Resume from saved state file |
| `--list-slots` | — | Print common NA slots and exit |
## Output Files
| Location | Content |
|---|---|
| `~/.skywalker1/surveys/arc-DATE-SLOT.json` | Per-slot carrier catalog |
| `~/.skywalker1/arc-surveys/arc-survey-DATE.json` | Arc survey state (for resume) |
The per-slot catalogs are standard `CarrierCatalog` JSON files, compatible with
the diff and comparison tools in `carrier_catalog.py`.

View File

@ -0,0 +1,75 @@
---
title: Beacon Logger
description: Long-term satellite signal logging for propagation research, rain fade analysis, and link budget validation.
---
import { Aside, Steps } from '@astrojs/starlight/components';
The `beacon_logger.py` tool locks onto a stable satellite transponder and records SNR, AGC levels,
and signal power at configurable intervals. Multi-day datasets reveal rain fade events, diurnal
thermal effects, antenna mount drift, and LNB gain variations.
## Quick Start
```bash
# Log a Ku-band transponder for 1 hour, CSV output
python tools/beacon_logger.py --freq 1265000 --sr 20000000 --output beacon.csv
# 24-hour unattended logging with per-minute statistics
python tools/beacon_logger.py --freq 1265000 --sr 20000000 \
--output beacon-24h.csv --json-output stats.jsonl --duration 86400
```
<Aside type="note" title="Frequency units">
The `--freq` parameter is in **kHz** (IF frequency). For a Ku-band transponder at 12015 MHz
with a universal LNB at LO 10750 MHz, the IF is 12015 10750 = 1265 MHz = **1265000 kHz**.
</Aside>
## Features
- **Auto-relock**: If signal is lost (weather, dish movement), automatically retunes and relocks
- **Statistics per interval**: min/max/mean/stddev of SNR over each reporting window
- **Dual output**: Raw per-sample CSV + per-interval JSONL statistics
- **Signal handlers**: Clean shutdown on SIGTERM/SIGINT, no data loss
- **Systemd integration**: `--generate-systemd` prints a ready-to-use unit file
## Options
| Flag | Default | Description |
|---|---|---|
| `--freq` | (required) | IF frequency in kHz |
| `--sr` | 20000000 | Symbol rate in sps |
| `--mod` | qpsk | Modulation type |
| `--fec` | auto | FEC rate |
| `--output` | — | CSV output file (raw samples) |
| `--json-output` | — | JSONL file (per-interval statistics) |
| `--duration` | 3600 | Logging duration in seconds |
| `--sample-interval` | 1.0 | Seconds between samples |
| `--report-interval` | 60 | Seconds between summary reports |
| `--pol` | — | LNB polarization (H/V) |
| `--band` | — | LNB band (low/high) |
| `--daemon` | — | Suppress stdout for background operation |
| `--generate-systemd` | — | Print systemd unit file and exit |
## Systemd Daemon Mode
```bash
# Generate and install the service
python tools/beacon_logger.py --generate-systemd \
--freq 1265000 --sr 20000000 \
--output /var/log/skywalker/beacon.csv \
--duration 999999 > /tmp/beacon-logger.service
sudo cp /tmp/beacon-logger.service /etc/systemd/system/
sudo systemctl enable --now beacon-logger
```
## Applications
| Use Case | What to Measure | Interval |
|---|---|---|
| Rain fade | SNR drops during precipitation | 1 Hz |
| LNB thermal drift | AGC shift over temperature cycle | 10s |
| Antenna mount stability | Slow SNR decay over days | 60s |
| Link budget validation | Long-term average vs. predicted | 60s |
| Ionospheric scintillation | Rapid AGC fluctuations | 1 Hz |

View File

@ -0,0 +1,81 @@
---
title: Hydrogen 21 cm Radiometer
description: Detect neutral hydrogen emission at 1420.405 MHz using the SkyWalker-1 as an L-band radiometer.
---
import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
The `h21cm.py` tool turns the SkyWalker-1 into a hydrogen line radiometer. Neutral hydrogen
atoms emit radiation at **1420.405 MHz** when the electron's spin flips relative to the proton —
the most fundamental spectral line in radio astronomy, and it falls directly in the IF range.
No LNB is needed. Connect an L-band antenna directly to the F-connector.
## Quick Start
```bash
# Single sweep, 8x averaging for best sensitivity
python tools/h21cm.py --averages 8
# One-hour drift scan with CSV output
python tools/h21cm.py --drift --duration 3600 --averages 4 --output h21cm-data.csv
```
## How It Works
<Steps>
1. **LNB power is disabled** — direct input mode, no frequency conversion
2. **Sweeps 1418-1422 MHz** (configurable) at 0.5 MHz steps
3. **Measures AGC power** at each step — the BCM4500 responds to any RF energy
4. **Estimates baseline** from the band edges (where no hydrogen is expected)
5. **Calculates excess power** above baseline — the hydrogen emission
6. **Computes Doppler velocity** for each frequency bin
</Steps>
The velocity axis maps frequency to radial velocity via:
**v = c × (1420.405 f_observed) / 1420.405**
Positive velocity = hydrogen moving away (lower frequency). The ~200 km/s spread
in a typical observation maps the rotation curve of the Milky Way.
## Options
| Flag | Default | Description |
|---|---|---|
| `--center` | 1420.405 | Center frequency in MHz |
| `--span` | 4.0 | Frequency span in MHz |
| `--step` | 0.5 | Frequency step in MHz |
| `--dwell` | 50 | Integration time per step in ms |
| `--averages` | 1 | Number of sweeps to average (4-16 recommended) |
| `--output` | — | CSV output file |
| `--control` | — | Include control band comparison |
| `--drift` | — | Enable drift scan mode |
| `--duration` | 3600 | Drift scan duration in seconds |
| `--interval` | 60 | Seconds between drift scans |
| `--motor-step` | 0 | Motor steps between scans (declination scanning) |
## Sensitivity Notes
<Aside type="tip" title="Improving SNR">
The hydrogen line is weak. The BCM4500's ~346 kHz resolution bandwidth is actually
helpful here — it's wide enough to capture the broad galactic emission without
excessive noise. Use `--averages 8` or higher and `--dwell 100` for best results.
Each doubling of averages improves SNR by ~3 dB.
</Aside>
The `--control` flag sweeps an adjacent band (1430-1434 MHz) where no hydrogen emission
is expected. Comparing the two bands confirms that any detected bump is real signal,
not system noise variation.
## CSV Output Format
| Column | Description |
|---|---|
| `timestamp` | ISO 8601 UTC timestamp |
| `scan_num` | Scan number (drift mode only) |
| `freq_mhz` | Frequency in MHz |
| `power_db` | Raw power in dB (relative) |
| `excess_db` | Power above baseline |
| `velocity_km_s` | Doppler velocity in km/s |
| `baseline_db` | Estimated noise floor |

View File

@ -0,0 +1,112 @@
---
title: MCP Server
description: Model Context Protocol server that exposes the SkyWalker-1 hardware API to LLMs for autonomous RF exploration.
---
import { Aside, Steps } from '@astrojs/starlight/components';
The `skywalker-mcp` package wraps the entire SkyWalker-1 Python API as an MCP (Model Context Protocol)
server, making every hardware function accessible to LLMs. This enables natural-language signal analysis,
autonomous RF exploration, and scheduled observation campaigns.
## Installation
```bash
cd mcp/skywalker-mcp
uv sync
```
## Running
```bash
# Local development
uv run --directory mcp/skywalker-mcp skywalker-mcp
# Add to Claude Code
claude mcp add skywalker-mcp -- uv run --directory mcp/skywalker-mcp skywalker-mcp
```
## Tools (20)
### Device Status
| Tool | Description |
|---|---|
| `get_device_status` | Firmware version, config bits, USB speed, serial, last error |
| `get_signal_quality` | SNR, AGC, power, lock status |
| `get_stream_diagnostics` | Poll count, overflows, sync loss |
### Spectrum & Tuning
| Tool | Description |
|---|---|
| `sweep_spectrum` | Full-band power measurement with peak detection |
| `tune_frequency` | Tune to specific freq/modulation/FEC, read signal |
| `run_blind_scan` | Symbol rate sweep at single frequency |
### Survey & Catalog
| Tool | Description |
|---|---|
| `run_carrier_survey` | Six-stage pipeline: sweep → peaks → blind → TS → catalog |
| `compare_surveys` | Diff two saved catalogs for changes |
| `list_surveys` | List saved survey files with metadata |
### Dish Motor
| Tool | Description |
|---|---|
| `move_dish` | Halt, east, west, goto slot, USALS GotoX (continuous drive requires explicit opt-in) |
| `jog_dish` | Small steps (1-30) + signal quality readback |
| `store_position` | Save current position to memory slot |
### LNB & I2C
| Tool | Description |
|---|---|
| `set_lnb_config` | Voltage (13V/18V), 22 kHz tone, power off |
| `scan_i2c_bus` | Enumerate all I2C devices |
| `read_i2c_register` | Read single byte from I2C address |
### Transport Stream & Identification
| Tool | Description |
|---|---|
| `capture_transport_stream` | Capture + parse PAT/PMT/SDT for service names |
| `identify_frequency` | Look up frequency against allocation tables |
## Resources
| URI | Description |
|---|---|
| `skywalker://status` | Live device state (firmware, config, signal) |
| `skywalker://catalog/latest` | Most recent survey catalog as JSON |
| `skywalker://allocations/lband` | L-band frequency allocation table |
| `skywalker://modulations` | Supported modulations and FEC rates |
## Prompts
| Prompt | Description |
|---|---|
| `explore_rf_environment` | Strategy for autonomous RF discovery |
| `hydrogen_line_observation` | Guided 21 cm observation procedure |
## Architecture
<Aside type="note" title="Thread safety">
The BCM4500 demodulator cannot handle overlapping USB control transfers. The MCP server
uses the same `DeviceBridge` pattern as the TUI — a `threading.RLock` serializes all
hardware access. Async MCP handlers use `asyncio.to_thread()` to avoid blocking the
event loop during USB I/O.
</Aside>
The server uses FastMCP's lifespan pattern: the USB device opens on server startup and
closes on shutdown. All tools receive the device bridge through the lifespan context.
## Testing
```bash
# Verify the server starts and can talk to hardware
claude -p "What firmware version is loaded?" \
--mcp-config .mcp.json \
--allowedTools "mcp__skywalker-mcp__*"
# Run a spectrum sweep via natural language
claude -p "Sweep the full IF band and tell me what you find" \
--mcp-config .mcp.json \
--allowedTools "mcp__skywalker-mcp__*"
```

451
tools/arc_survey.py Normal file
View File

@ -0,0 +1,451 @@
#!/usr/bin/env python3
"""
Multi-satellite arc survey for the Genpix SkyWalker-1.
Automated "satellite census": points the dish motor to each known GEO
longitude, runs a full-band carrier survey at each position, and aggregates
results into a comprehensive sky map. The diff capability tracks changes
between survey runs.
Usage:
python arc_survey.py --observer-lon -96.8 --slots "97W,99W,101W,103W"
python arc_survey.py --observer-lon -96.8 --file slots.json
python arc_survey.py --observer-lon -96.8 --arc -120 -60 --step 3
python arc_survey.py --resume arc-survey-2026-02-17.json
The tool saves progress after each orbital slot, so interrupted surveys
can be resumed. Each slot's catalog is saved individually, and a summary
report covers the entire arc.
"""
import sys
import os
import argparse
import time
import json
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, usals_angle
from survey_engine import SurveyEngine
from carrier_catalog import CarrierCatalog, CATALOG_DIR
# Common North American GEO orbital slots
NA_ORBITAL_SLOTS = {
"129W": -129.0, "125W": -125.0, "123W": -123.0, "121W": -121.0,
"119W": -119.0, "118.7W": -118.7, "116.8W": -116.8, "114.9W": -114.9,
"113W": -113.0, "111.1W": -111.1, "110W": -110.0, "107.3W": -107.3,
"105W": -105.0, "103W": -103.0, "101W": -101.0, "99W": -99.0,
"97W": -97.0, "95W": -95.0, "93W": -93.0, "91W": -91.0,
"89W": -89.0, "87W": -87.0, "85W": -85.0, "83W": -83.0,
"82W": -82.0, "79W": -79.0, "77W": -77.0, "75W": -75.0,
"72.7W": -72.7, "70W": -70.0, "67W": -67.0, "65W": -65.0,
"63W": -63.0, "61.5W": -61.5, "58W": -58.0, "55.5W": -55.5,
}
ARC_SURVEY_DIR = CATALOG_DIR.parent / "arc-surveys"
class ArcSurvey:
"""Multi-position orbital arc survey with persistence and resume."""
def __init__(self, sw: SkyWalker1, observer_lon: float,
observer_lat: float = 0.0, settle_time: float = 15.0):
self.sw = sw
self.observer_lon = observer_lon
self.observer_lat = observer_lat
self.settle_time = settle_time
def survey_slot(self, name: str, sat_lon: float,
coarse_step: float = 5.0,
band: str = "", pol: str = "",
callback=None) -> CarrierCatalog:
"""Survey a single orbital slot: move dish, wait, run survey."""
# Calculate motor angle
angle = usals_angle(self.observer_lon, sat_lon, self.observer_lat)
direction = "west" if angle < 0 else "east"
if callback:
callback("moving", 0,
f"Moving to {name} ({sat_lon:.1f}), "
f"angle {abs(angle):.1f} deg {direction}")
# Command the motor
self.sw.motor_goto_x(self.observer_lon, sat_lon)
# Wait for motor to settle (larger angles need more time)
settle = max(self.settle_time, abs(angle) * 0.3)
if callback:
callback("settling", 20, f"Settling {settle:.0f}s...")
time.sleep(settle)
# Verify we have signal (check AGC for any RF energy)
sig = self.sw.signal_monitor()
if callback:
callback("signal_check", 30,
f"AGC1={sig['agc1']}, power={sig['power_db']:.1f} dB")
# Run the six-stage survey
def survey_cb(stage, pct, msg):
overall_pct = 30 + int(pct * 0.7)
if callback:
callback(stage, overall_pct, msg)
engine = SurveyEngine(self.sw, callback=survey_cb)
catalog = engine.run_full_scan(
coarse_step=coarse_step,
ts_capture_secs=2.0,
)
catalog.name = f"{name} ({sat_lon:.1f})"
catalog.band = band
catalog.pol = pol
catalog.notes = (f"Arc survey position: {name}, "
f"observer: {self.observer_lon:.2f} lon, "
f"motor angle: {angle:.2f} deg")
if callback:
callback("complete", 100,
f"{name}: {len(catalog.carriers)} carriers, "
f"{sum(1 for c in catalog.carriers if c.locked)} locked")
return catalog
def run_arc(self, slots: list[tuple[str, float]],
coarse_step: float = 5.0,
band: str = "", pol: str = "",
save_individual: bool = True,
resume_state: dict | None = None) -> dict:
"""Survey an entire arc of orbital slots.
slots: list of (name, sat_lon) tuples
resume_state: previous arc survey state dict for resuming
Returns a complete arc survey result dict.
"""
ARC_SURVEY_DIR.mkdir(parents=True, exist_ok=True)
date_str = datetime.now().strftime("%Y-%m-%d")
# Initialize or resume state
if resume_state:
state = resume_state
completed_names = set(state.get("completed_slots", {}).keys())
else:
state = {
"started": datetime.now(timezone.utc).isoformat(),
"observer_lon": self.observer_lon,
"observer_lat": self.observer_lat,
"total_slots": len(slots),
"completed_slots": {},
"skipped_slots": {},
"summary": {
"total_carriers": 0,
"total_locked": 0,
"total_services": 0,
},
}
completed_names = set()
state_path = ARC_SURVEY_DIR / f"arc-survey-{date_str}.json"
for i, (name, sat_lon) in enumerate(slots):
if name in completed_names:
print(f" [{i+1}/{len(slots)}] Skipping {name} (already surveyed)")
continue
print(f"\n [{i+1}/{len(slots)}] Surveying {name} ({sat_lon:.1f} lon)")
def progress_cb(stage, pct, msg):
print(f" [{pct:3d}%] {stage}: {msg}")
try:
catalog = self.survey_slot(
name, sat_lon,
coarse_step=coarse_step,
band=band, pol=pol,
callback=progress_cb,
)
# Save individual catalog
if save_individual:
slot_filename = f"arc-{date_str}-{name.replace('.', '_')}.json"
cat_path = catalog.save(slot_filename)
print(f" Saved: {cat_path}")
# Update state
carrier_count = len(catalog.carriers)
locked_count = sum(1 for c in catalog.carriers if c.locked)
service_count = sum(len(c.services) for c in catalog.carriers)
state["completed_slots"][name] = {
"sat_lon": sat_lon,
"completed": datetime.now(timezone.utc).isoformat(),
"carriers": carrier_count,
"locked": locked_count,
"services": service_count,
"catalog_file": slot_filename if save_individual else None,
}
state["summary"]["total_carriers"] += carrier_count
state["summary"]["total_locked"] += locked_count
state["summary"]["total_services"] += service_count
except KeyboardInterrupt:
print(f"\n Survey interrupted at {name}")
try:
self.sw.motor_halt()
except Exception:
pass
state["interrupted_at"] = name
_save_state(state, state_path)
print(f" Motor halted. Progress saved to {state_path}")
print(f" Resume with: python arc_survey.py --resume {state_path}")
return state
except Exception as e:
print(f" Error at {name}: {e}")
try:
self.sw.motor_halt()
except Exception:
pass
state["skipped_slots"][name] = {
"sat_lon": sat_lon,
"error": str(e),
}
# Save state after each slot for resume capability
_save_state(state, state_path)
# Final summary
state["completed"] = datetime.now(timezone.utc).isoformat()
_save_state(state, state_path)
return state
def _save_state(state: dict, path: Path) -> None:
"""Save arc survey state to JSON."""
with open(path, 'w') as f:
json.dump(state, f, indent=2)
def parse_slot_string(slot_str: str) -> list[tuple[str, float]]:
"""Parse a comma-separated slot string like '97W,99W,101W'.
Accepts formats: '97W', '97.5W', '3E', '-97', '-97.5'
"""
slots = []
for part in slot_str.split(','):
part = part.strip()
if not part:
continue
if part in NA_ORBITAL_SLOTS:
slots.append((part, NA_ORBITAL_SLOTS[part]))
elif part.upper().endswith('W'):
lon = -float(part[:-1])
slots.append((part.upper(), lon))
elif part.upper().endswith('E'):
lon = float(part[:-1])
slots.append((part.upper(), lon))
else:
lon = float(part)
name = f"{abs(lon):.1f}{'W' if lon < 0 else 'E'}"
slots.append((name, lon))
return slots
def generate_arc_range(start_lon: float, stop_lon: float,
step: float) -> list[tuple[str, float]]:
"""Generate orbital slots at regular intervals across an arc."""
slots = []
lon = start_lon
while lon <= stop_lon:
name = f"{abs(lon):.1f}{'W' if lon < 0 else 'E'}"
slots.append((name, lon))
lon += step
return slots
def print_summary(state: dict) -> None:
"""Print a human-readable arc survey summary."""
print(f"\n Arc Survey Summary")
print(f" ==================")
print(f" Observer: {state['observer_lon']:.2f} lon")
print(f" Slots surveyed: {len(state['completed_slots'])} / {state['total_slots']}")
print(f" Total carriers: {state['summary']['total_carriers']}")
print(f" Total locked: {state['summary']['total_locked']}")
print(f" Total services: {state['summary']['total_services']}")
if state.get("skipped_slots"):
print(f" Skipped: {len(state['skipped_slots'])}")
print(f"\n Per-slot results:")
for name, info in sorted(state["completed_slots"].items(),
key=lambda x: x[1]["sat_lon"]):
lock_str = f"{info['locked']}/{info['carriers']}"
svc_str = f"{info['services']} svc" if info['services'] else ""
print(f" {name:>8s} ({info['sat_lon']:+7.1f}): "
f"{lock_str:>7s} locked {svc_str}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="arc_survey.py",
description="Multi-satellite arc survey for SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
# Survey specific slots (North American arc)
%(prog)s --observer-lon -96.8 --slots "97W,99W,101W,103W"
# Survey an arc range at 3-degree intervals
%(prog)s --observer-lon -96.8 --arc -120 -60 --step 3
# Load slots from a JSON file
%(prog)s --observer-lon -96.8 --file my-slots.json
# Resume an interrupted survey
%(prog)s --resume ~/.skywalker1/arc-surveys/arc-survey-2026-02-17.json
# List common North American orbital slots
%(prog)s --list-slots
slot file format (JSON):
[
{"name": "97W", "lon": -97.0},
{"name": "99W", "lon": -99.0}
]
notes:
- Motor settle time scales with angle (min 15s, + 0.3s per degree)
- Each slot takes 5-15 minutes depending on carrier density
- Progress is saved after each slot; Ctrl-C to pause safely
- Individual catalogs saved to ~/.skywalker1/surveys/
- Arc survey state saved to ~/.skywalker1/arc-surveys/
""",
)
parser.add_argument('-v', '--verbose', action='store_true')
parser.add_argument('--observer-lon', type=float,
help="Observer longitude (negative=west, e.g. -96.8)")
parser.add_argument('--observer-lat', type=float, default=0.0,
help="Observer latitude (default: 0.0)")
source = parser.add_mutually_exclusive_group()
source.add_argument('--slots', type=str,
help="Comma-separated slot list (e.g. '97W,99W,101W')")
source.add_argument('--file', type=str,
help="JSON file with slot definitions")
source.add_argument('--arc', nargs=2, type=float, metavar=('START', 'STOP'),
help="Arc range in degrees longitude")
source.add_argument('--resume', type=str,
help="Resume from a saved arc survey state file")
source.add_argument('--list-slots', action='store_true',
help="List common NA orbital slots and exit")
parser.add_argument('--step', type=float, default=3.0,
help="Step size for --arc mode (default: 3.0 degrees)")
parser.add_argument('--coarse-step', type=float, default=5.0,
help="Coarse sweep step in MHz (default: 5.0)")
parser.add_argument('--settle-time', type=float, default=15.0,
help="Minimum motor settle time in seconds (default: 15)")
parser.add_argument('--pol', type=str, default="",
help="Polarization label (H/V, for catalog metadata)")
parser.add_argument('--band', type=str, default="",
help="Band label (low/high, for catalog metadata)")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.list_slots:
print("Common North American GEO orbital slots:")
for name in sorted(NA_ORBITAL_SLOTS, key=lambda n: NA_ORBITAL_SLOTS[n]):
lon = NA_ORBITAL_SLOTS[name]
print(f" {name:>8s} {lon:+7.1f}")
return
# Determine slot list
resume_state = None
if args.resume:
with open(args.resume) as f:
resume_state = json.load(f)
observer_lon = resume_state["observer_lon"]
observer_lat = resume_state.get("observer_lat", 0.0)
# Reconstruct slots from state
all_slot_names = (
list(resume_state.get("completed_slots", {}).keys()) +
list(resume_state.get("skipped_slots", {}).keys())
)
# We need the original slot list — reconstruct from completed + remaining
slots = []
for name, info in resume_state.get("completed_slots", {}).items():
slots.append((name, info["sat_lon"]))
for name, info in resume_state.get("skipped_slots", {}).items():
slots.append((name, info["sat_lon"]))
# Sort by longitude
slots.sort(key=lambda x: x[1])
print(f"Resuming arc survey: {len(resume_state.get('completed_slots', {}))} "
f"of {len(slots)} slots completed")
else:
if not args.observer_lon and args.observer_lon != 0:
parser.error("--observer-lon is required (or use --resume)")
observer_lon = args.observer_lon
observer_lat = args.observer_lat
if args.slots:
slots = parse_slot_string(args.slots)
elif args.file:
with open(args.file) as f:
data = json.load(f)
slots = [(d["name"], d["lon"]) for d in data]
elif args.arc:
start, stop = sorted(args.arc)
slots = generate_arc_range(start, stop, args.step)
else:
parser.error("Specify --slots, --file, --arc, or --resume")
if not slots:
print("No orbital slots to survey", file=sys.stderr)
sys.exit(1)
print(f"Arc Survey")
print(f" Observer: {observer_lon:.2f} lon, {observer_lat:.2f} lat")
print(f" Orbital slots: {len(slots)}")
for name, lon in slots:
angle = usals_angle(observer_lon, lon, observer_lat)
direction = "W" if angle < 0 else "E"
print(f" {name:>8s} {lon:+7.1f} (motor: {abs(angle):.1f} deg {direction})")
print()
with SkyWalker1(verbose=args.verbose) as sw:
sw.ensure_booted()
survey = ArcSurvey(
sw, observer_lon, observer_lat,
settle_time=args.settle_time,
)
state = survey.run_arc(
slots,
coarse_step=args.coarse_step,
band=args.band, pol=args.pol,
resume_state=resume_state,
)
print_summary(state)
if __name__ == "__main__":
main()

376
tools/beacon_logger.py Normal file
View File

@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""
Long-term satellite beacon logger for the Genpix SkyWalker-1.
Locks onto a stable Ku-band transponder and logs SNR/AGC at configurable
intervals for hours, days, or weeks. Produces propagation datasets useful
for rain fade analysis, diurnal thermal drift measurement, antenna mount
stability assessment, and ITU propagation model validation.
Usage:
python beacon_logger.py --freq 12015 --sr 20000 # log to stdout
python beacon_logger.py --freq 12015 --sr 20000 -o log.csv # log to CSV
python beacon_logger.py --freq 12015 --sr 20000 --daemon # background mode
python beacon_logger.py --generate-systemd # print unit file
The tool automatically re-locks on signal loss and logs statistics per
reporting interval (min/max/mean/stddev of SNR over each window).
"""
import sys
import os
import argparse
import time
import csv
import math
import json
import signal
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, MODULATIONS, MOD_FEC_GROUP, FEC_RATES
def compute_stats(values: list[float]) -> dict:
"""Compute min/max/mean/stddev for a list of measurements."""
if not values:
return {"min": 0, "max": 0, "mean": 0, "stddev": 0, "count": 0}
n = len(values)
mean = sum(values) / n
variance = sum((v - mean) ** 2 for v in values) / n if n > 1 else 0
return {
"min": round(min(values), 3),
"max": round(max(values), 3),
"mean": round(mean, 3),
"stddev": round(math.sqrt(variance), 3),
"count": n,
}
class BeaconLogger:
"""Persistent signal logger with auto-relock and statistics."""
def __init__(self, sw: SkyWalker1, freq_khz: int, sr_sps: int,
mod_index: int = 0, fec_index: int = 5,
sample_interval: float = 1.0, report_interval: float = 60.0):
self.sw = sw
self.freq_khz = freq_khz
self.sr_sps = sr_sps
self.mod_index = mod_index
self.fec_index = fec_index
self.sample_interval = sample_interval
self.report_interval = report_interval
self._running = False
self._relock_count = 0
self._total_samples = 0
def tune_and_lock(self) -> bool:
"""Tune to the beacon frequency and check for lock."""
self.sw.tune(self.sr_sps, self.freq_khz, self.mod_index, self.fec_index)
time.sleep(0.5)
sig = self.sw.signal_monitor()
return sig.get("locked", False)
def run(self, duration_secs: float, csv_path: str | None = None,
json_path: str | None = None, quiet: bool = False) -> None:
"""Main logging loop.
Samples signal at sample_interval, computes statistics over
report_interval, outputs to CSV/JSON/stdout.
"""
self._running = True
# Register signal handlers for clean shutdown
def _stop(signum, frame):
self._running = False
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
# Initial tune
locked = self.tune_and_lock()
if not locked:
print(f"Warning: no lock at {self.freq_khz} kHz, will keep trying",
file=sys.stderr)
# Open CSV
csv_file = None
csv_writer = None
if csv_path:
csv_file = open(csv_path, 'w', newline='')
csv_writer = csv.writer(csv_file)
csv_writer.writerow([
"timestamp", "elapsed_s", "snr_db", "agc1", "agc2",
"power_db", "locked", "relock_count",
])
# Open JSON log (append mode, one JSON object per report line)
json_file = None
if json_path:
json_file = open(json_path, 'a')
start_time = time.time()
last_report = start_time
window_snr = []
window_power = []
window_agc1 = []
lock_count_window = 0
sample_count_window = 0
try:
while self._running and (time.time() - start_time) < duration_secs:
now = time.time()
elapsed = now - start_time
# Sample
try:
sig = self.sw.signal_monitor()
except Exception as e:
if not quiet:
print(f" USB error: {e}", file=sys.stderr)
time.sleep(self.sample_interval)
continue
self._total_samples += 1
sample_count_window += 1
snr_db = sig["snr_db"]
agc1 = sig["agc1"]
agc2 = sig["agc2"]
power_db = sig["power_db"]
locked = sig["locked"]
if locked:
lock_count_window += 1
window_snr.append(snr_db)
window_power.append(power_db)
window_agc1.append(agc1)
# Write raw sample to CSV
if csv_writer:
csv_writer.writerow([
datetime.now(timezone.utc).isoformat(),
f"{elapsed:.1f}",
f"{snr_db:.3f}",
agc1, agc2,
f"{power_db:.3f}",
int(locked),
self._relock_count,
])
csv_file.flush()
# Auto-relock
if not locked:
if not quiet:
print(f" [{elapsed:.0f}s] Signal lost, attempting relock...",
file=sys.stderr)
if self.tune_and_lock():
self._relock_count += 1
if not quiet:
print(f" [{elapsed:.0f}s] Relocked (count: {self._relock_count})",
file=sys.stderr)
# Periodic report
if now - last_report >= self.report_interval:
report = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"elapsed_s": round(elapsed, 1),
"samples": sample_count_window,
"lock_pct": round(100 * lock_count_window / max(sample_count_window, 1), 1),
"snr": compute_stats(window_snr),
"power": compute_stats(window_power),
"agc1": compute_stats(window_agc1),
"relock_count": self._relock_count,
}
if not quiet:
snr_s = report["snr"]
print(f" [{elapsed:7.0f}s] SNR {snr_s['mean']:5.1f} dB "
f"(min {snr_s['min']:.1f}, max {snr_s['max']:.1f}, "
f"std {snr_s['stddev']:.2f}) "
f"lock {report['lock_pct']:.0f}% "
f"relocks {self._relock_count}")
if json_file:
json_file.write(json.dumps(report) + "\n")
json_file.flush()
# Reset window
window_snr.clear()
window_power.clear()
window_agc1.clear()
lock_count_window = 0
sample_count_window = 0
last_report = now
time.sleep(self.sample_interval)
finally:
if csv_file:
csv_file.close()
if json_file:
json_file.close()
total_elapsed = time.time() - start_time
if not quiet:
print(f"\n Session complete: {self._total_samples} samples in "
f"{total_elapsed:.0f}s, {self._relock_count} relocks")
def generate_systemd_unit(args) -> str:
"""Generate a systemd unit file for daemon operation."""
cmd_parts = ["python3", os.path.abspath(__file__)]
cmd_parts.extend(["--freq", str(args.freq)])
cmd_parts.extend(["--sr", str(args.sr)])
if args.output:
cmd_parts.extend(["--output", os.path.abspath(args.output)])
if args.json_output:
cmd_parts.extend(["--json-output", os.path.abspath(args.json_output)])
cmd_parts.extend(["--duration", str(args.duration)])
cmd_parts.extend(["--sample-interval", str(args.sample_interval)])
cmd_parts.extend(["--report-interval", str(args.report_interval)])
cmd_parts.append("--quiet")
return f"""[Unit]
Description=SkyWalker-1 Beacon Logger ({args.freq} kHz)
After=network.target
[Service]
Type=simple
ExecStart={' '.join(cmd_parts)}
Restart=on-failure
RestartSec=30
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
"""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="beacon_logger.py",
description="Long-term satellite beacon logger for SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s --freq 12015 --sr 20000 # Ku-band beacon, stdout
%(prog)s --freq 12015 --sr 20000 -o beacon.csv # log to CSV
%(prog)s --freq 12015 --sr 20000 --json-output beacon.jsonl # per-minute JSON
%(prog)s --freq 12015 --sr 20000 --duration 86400 # 24-hour log
%(prog)s --freq 12015 --sr 20000 --daemon # background
%(prog)s --generate-systemd --freq 12015 --sr 20000 # print unit file
The --freq is in kHz (IF frequency), not MHz. For Ku-band with a universal
LNB at LO 10750 MHz, a transponder at 12015 MHz has IF = 12015 - 10750 = 1265 MHz,
so you'd use --freq 1265000.
For IF frequencies, multiply MHz by 1000 (e.g., 1265 MHz = 1265000 kHz).
""",
)
parser.add_argument('-v', '--verbose', action='store_true')
parser.add_argument('--freq', type=int, required=True,
help="IF frequency in kHz (e.g., 1265000 for 1265 MHz)")
parser.add_argument('--sr', type=int, default=20000000,
help="Symbol rate in sps (default: 20000000)")
parser.add_argument('--mod', type=str, default="qpsk",
help="Modulation type (default: qpsk)")
parser.add_argument('--fec', type=str, default="auto",
help="FEC rate (default: auto)")
parser.add_argument('--output', '-o', type=str, default=None,
help="CSV output file (raw samples)")
parser.add_argument('--json-output', type=str, default=None,
help="JSONL output file (per-interval statistics)")
parser.add_argument('--duration', type=float, default=3600,
help="Logging duration in seconds (default: 3600)")
parser.add_argument('--sample-interval', type=float, default=1.0,
help="Seconds between samples (default: 1.0)")
parser.add_argument('--report-interval', type=float, default=60.0,
help="Seconds between summary reports (default: 60)")
parser.add_argument('--pol', type=str, default=None, choices=['H', 'V'],
help="LNB polarization (H=18V, V=13V)")
parser.add_argument('--band', type=str, default=None, choices=['low', 'high'],
help="LNB band (low=no tone, high=22kHz)")
parser.add_argument('--daemon', action='store_true',
help="Run as daemon (suppress stdout)")
parser.add_argument('--quiet', action='store_true',
help="Suppress progress output to stderr")
parser.add_argument('--generate-systemd', action='store_true',
help="Print a systemd unit file and exit")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.generate_systemd:
print(generate_systemd_unit(args))
return
# Resolve modulation/FEC indices
mod_entry = MODULATIONS.get(args.mod)
if mod_entry is None:
print(f"Unknown modulation '{args.mod}'. Valid: {list(MODULATIONS.keys())}",
file=sys.stderr)
sys.exit(1)
mod_idx = mod_entry[0]
fec_group = MOD_FEC_GROUP.get(args.mod, "dvbs")
fec_table = FEC_RATES.get(fec_group, {})
fec_idx = fec_table.get(args.fec, fec_table.get("auto", 0))
quiet = args.daemon or args.quiet
with SkyWalker1(verbose=args.verbose) as sw:
sw.ensure_booted()
# Configure LNB
if args.pol:
sw.set_lnb_voltage(args.pol.upper() in ("H", "L"))
if args.band:
sw.set_22khz_tone(args.band == "high")
freq_mhz = args.freq / 1000.0
sr_msps = args.sr / 1e6
if not quiet:
print(f"Beacon Logger")
print(f" Frequency: {freq_mhz:.3f} MHz IF ({args.freq} kHz)")
print(f" Symbol rate: {sr_msps:.3f} Msps")
print(f" Modulation: {args.mod}, FEC: {args.fec}")
print(f" Sample interval: {args.sample_interval}s")
print(f" Report interval: {args.report_interval}s")
print(f" Duration: {args.duration}s ({args.duration/3600:.1f}h)")
if args.output:
print(f" CSV output: {args.output}")
if args.json_output:
print(f" JSON output: {args.json_output}")
print()
logger = BeaconLogger(
sw, args.freq, args.sr,
mod_index=mod_idx, fec_index=fec_idx,
sample_interval=args.sample_interval,
report_interval=args.report_interval,
)
logger.run(
duration_secs=args.duration,
csv_path=args.output,
json_path=args.json_output,
quiet=quiet,
)
if __name__ == "__main__":
main()

357
tools/h21cm.py Normal file
View File

@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""
Hydrogen 21 cm drift-scan radiometer for the Genpix SkyWalker-1.
Detects neutral hydrogen emission at 1420.405 MHz directly in the IF range
with no LNB required. Connect an L-band antenna (patch, helical, or horn)
directly to the F-connector.
The Milky Way's spiral arms create a velocity-dispersed emission profile
detectable even with the BCM4500's ~346 kHz RBW. Earth's rotation provides
a natural drift-scan across the sky.
Usage:
python h21cm.py # single sweep, print spectrum
python h21cm.py --drift --duration 3600 # 1-hour drift scan
python h21cm.py --drift --motor-step 5 # step motor between sweeps
python h21cm.py --output data.csv # log to CSV
The c in 21 cm stands for centimeters. The frequency (1420.405 MHz) comes from
the hyperfine transition in neutral hydrogen when the electron's spin flips
relative to the proton. This is the most fundamental spectral line in radio
astronomy, and you can detect it with a $30 DVB-S dongle.
"""
import sys
import os
import argparse
import time
import csv
import math
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, agc_to_power_db
# Physical constants
H1_FREQ_MHZ = 1420.405751 # Hydrogen 21 cm rest frequency
C_KM_S = 299792.458 # Speed of light
def freq_to_velocity(freq_mhz: float) -> float:
"""Convert observed frequency to radial velocity via Doppler shift.
v = c * (f_rest - f_obs) / f_rest
Positive velocity = receding (redshifted, lower frequency).
Negative velocity = approaching (blueshifted, higher frequency).
"""
return C_KM_S * (H1_FREQ_MHZ - freq_mhz) / H1_FREQ_MHZ
def sweep_h1_band(sw: SkyWalker1, center_mhz: float = H1_FREQ_MHZ,
span_mhz: float = 4.0, step_mhz: float = 0.5,
dwell_ms: int = 50, averages: int = 1) -> dict:
"""Sweep the hydrogen line band and return power measurements.
Higher dwell_ms and multiple averages improve SNR for this weak signal.
Default 50ms dwell is 5x longer than typical satellite sweeps.
Returns dict with frequencies, powers, velocities, and statistics.
"""
start = center_mhz - span_mhz / 2
stop = center_mhz + span_mhz / 2
# Accumulate multiple sweeps for averaging
all_powers = None
for avg in range(averages):
freqs, powers, raw = sw.sweep_spectrum(
start, stop, step_mhz=step_mhz, dwell_ms=dwell_ms,
sr_ksps=1000, mod_index=0, fec_index=5,
)
if all_powers is None:
all_powers = [0.0] * len(powers)
for i in range(len(powers)):
all_powers[i] += powers[i]
# Average
avg_powers = [p / averages for p in all_powers]
# Calculate velocities
velocities = [freq_to_velocity(f) for f in freqs]
# Baseline: edges of the band should be "empty" (no hydrogen)
edge_count = max(2, len(avg_powers) // 5)
baseline = (sum(avg_powers[:edge_count]) + sum(avg_powers[-edge_count:])) / (2 * edge_count)
# Excess power above baseline
excess = [p - baseline for p in avg_powers]
# Find peak excess (the hydrogen line center)
peak_idx = max(range(len(excess)), key=lambda i: excess[i])
peak_freq = freqs[peak_idx]
peak_excess = excess[peak_idx]
peak_velocity = velocities[peak_idx]
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"freqs_mhz": freqs,
"powers_db": avg_powers,
"velocities_km_s": velocities,
"excess_db": excess,
"baseline_db": baseline,
"peak_freq_mhz": peak_freq,
"peak_excess_db": peak_excess,
"peak_velocity_km_s": peak_velocity,
"averages": averages,
"dwell_ms": dwell_ms,
}
def sweep_control_band(sw: SkyWalker1, step_mhz: float = 0.5,
dwell_ms: int = 50) -> dict:
"""Sweep a control band (1430-1434 MHz) where no hydrogen is expected.
Comparing the control band to the hydrogen band reveals whether a
detected power bump is real emission or just system noise variation.
"""
freqs, powers, _ = sw.sweep_spectrum(
1430.0, 1434.0, step_mhz=step_mhz, dwell_ms=dwell_ms,
sr_ksps=1000, mod_index=0, fec_index=5,
)
mean_power = sum(powers) / len(powers) if powers else 0
return {
"control_freqs_mhz": freqs,
"control_powers_db": powers,
"control_mean_db": mean_power,
}
def print_spectrum(result: dict, show_velocity: bool = True) -> None:
"""Print an ASCII spectrum of the hydrogen band."""
freqs = result["freqs_mhz"]
excess = result["excess_db"]
velocities = result["velocities_km_s"]
baseline = result["baseline_db"]
# Scale for display
max_excess = max(excess) if excess else 1.0
min_excess = min(excess)
span = max(max_excess - min_excess, 0.5)
print(f"\n Hydrogen 21 cm Spectrum")
print(f" Baseline: {baseline:.2f} dB | Peak excess: {result['peak_excess_db']:.2f} dB")
print(f" Peak at {result['peak_freq_mhz']:.3f} MHz ({result['peak_velocity_km_s']:+.1f} km/s)")
print()
bar_width = 50
for i in range(len(freqs)):
f = freqs[i]
e = excess[i]
v = velocities[i]
# Normalize to bar width
filled = int((e - min_excess) / span * bar_width)
filled = max(0, min(filled, bar_width))
bar = '#' * filled + '-' * (bar_width - filled)
# Mark the hydrogen rest frequency
marker = " *" if abs(f - H1_FREQ_MHZ) < 0.3 else " "
if show_velocity:
print(f" {f:8.3f} MHz {v:+7.1f} km/s [{bar}] {e:+.2f} dB{marker}")
else:
print(f" {f:8.3f} MHz [{bar}] {e:+.2f} dB{marker}")
print()
print(" * = hydrogen rest frequency (1420.405 MHz)")
def drift_scan(sw: SkyWalker1, duration_secs: float, interval_secs: float,
step_mhz: float, dwell_ms: int, averages: int,
motor_step: int, output_path: str | None) -> None:
"""Run a drift scan: repeated sweeps over time.
Earth's rotation naturally scans the sky. Each sweep captures the
hydrogen profile at the current sky position. Over hours, you trace
out the galactic plane.
"""
csv_writer = None
csv_file = None
header_written = False
if output_path:
csv_file = open(output_path, 'w', newline='')
csv_writer = csv.writer(csv_file)
start_time = time.time()
scan_num = 0
try:
while time.time() - start_time < duration_secs:
scan_num += 1
elapsed = time.time() - start_time
remaining = duration_secs - elapsed
print(f"\n--- Scan #{scan_num} (elapsed {elapsed:.0f}s, "
f"remaining {remaining:.0f}s) ---")
# Motor step between scans (for declination scanning)
if motor_step and scan_num > 1:
print(f" Stepping motor {motor_step} steps east...")
sw.motor_drive_east(motor_step)
time.sleep(1.0)
result = sweep_h1_band(sw, step_mhz=step_mhz,
dwell_ms=dwell_ms, averages=averages)
print_spectrum(result, show_velocity=True)
# Write CSV
if csv_writer:
if not header_written:
csv_writer.writerow([
"timestamp", "scan_num", "freq_mhz", "power_db",
"excess_db", "velocity_km_s", "baseline_db",
])
header_written = True
for i in range(len(result["freqs_mhz"])):
csv_writer.writerow([
result["timestamp"],
scan_num,
f"{result['freqs_mhz'][i]:.3f}",
f"{result['powers_db'][i]:.3f}",
f"{result['excess_db'][i]:.3f}",
f"{result['velocities_km_s'][i]:.1f}",
f"{result['baseline_db']:.3f}",
])
csv_file.flush()
# Wait for next scan
if remaining > interval_secs:
print(f" Next scan in {interval_secs:.0f}s...")
time.sleep(interval_secs)
except KeyboardInterrupt:
print("\n Drift scan interrupted")
finally:
if csv_file:
csv_file.close()
print(f" Data saved to {output_path}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="h21cm.py",
description="Hydrogen 21 cm line radiometer for SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s # single sweep, print spectrum
%(prog)s --averages 8 # 8x averaging for better SNR
%(prog)s --drift --duration 3600 # 1-hour drift scan
%(prog)s --drift --motor-step 5 # step motor between sweeps
%(prog)s --output h21cm-data.csv # log to CSV
%(prog)s --control # include control band comparison
notes:
- Connect an L-band antenna directly to the F-connector (no LNB)
- LNB power is disabled automatically for direct input
- Hydrogen emission is weak; use --averages 4-16 for best results
- The --dwell option increases per-step integration time (default 50ms)
- Earth rotation provides natural sky drift at ~15 deg/hour
""",
)
parser.add_argument('-v', '--verbose', action='store_true',
help="Show raw USB traffic")
parser.add_argument('--center', type=float, default=H1_FREQ_MHZ,
help=f"Center frequency in MHz (default: {H1_FREQ_MHZ})")
parser.add_argument('--span', type=float, default=4.0,
help="Frequency span in MHz (default: 4.0)")
parser.add_argument('--step', type=float, default=0.5,
help="Frequency step in MHz (default: 0.5)")
parser.add_argument('--dwell', type=int, default=50,
help="Dwell time per step in ms (default: 50)")
parser.add_argument('--averages', type=int, default=1,
help="Number of sweeps to average (default: 1)")
parser.add_argument('--output', '-o', type=str, default=None,
help="CSV output file path")
parser.add_argument('--control', action='store_true',
help="Include control band (1430-1434 MHz) for comparison")
parser.add_argument('--no-velocity', action='store_true',
help="Don't show velocity axis in spectrum display")
drift_group = parser.add_argument_group('drift scan')
drift_group.add_argument('--drift', action='store_true',
help="Enable drift scan mode (repeated sweeps)")
drift_group.add_argument('--duration', type=float, default=3600,
help="Drift scan duration in seconds (default: 3600)")
drift_group.add_argument('--interval', type=float, default=60,
help="Seconds between sweeps (default: 60)")
drift_group.add_argument('--motor-step', type=int, default=0,
help="Motor steps between sweeps (0=no motor, default: 0)")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
with SkyWalker1(verbose=args.verbose) as sw:
sw.ensure_booted()
# Disable LNB power for direct input
sw.start_intersil(on=False)
print("LNB power disabled (direct L-band input mode)")
if args.drift:
drift_scan(sw, duration_secs=args.duration,
interval_secs=args.interval,
step_mhz=args.step, dwell_ms=args.dwell,
averages=args.averages, motor_step=args.motor_step,
output_path=args.output)
else:
# Single sweep
print(f"\nSweeping {args.center - args.span/2:.1f} - "
f"{args.center + args.span/2:.1f} MHz "
f"(step={args.step} MHz, dwell={args.dwell}ms, "
f"avg={args.averages}x)")
result = sweep_h1_band(sw, center_mhz=args.center,
span_mhz=args.span, step_mhz=args.step,
dwell_ms=args.dwell, averages=args.averages)
print_spectrum(result, show_velocity=not args.no_velocity)
if args.control:
print(" Control band (1430-1434 MHz, no hydrogen expected):")
ctrl = sweep_control_band(sw, step_mhz=args.step, dwell_ms=args.dwell)
print(f" Control mean: {ctrl['control_mean_db']:.2f} dB")
print(f" H1 baseline: {result['baseline_db']:.2f} dB")
diff = result["peak_excess_db"]
print(f" H1 peak excess above baseline: {diff:+.2f} dB")
# Write single sweep to CSV if requested
if args.output:
with open(args.output, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow([
"freq_mhz", "power_db", "excess_db",
"velocity_km_s", "baseline_db",
])
for i in range(len(result["freqs_mhz"])):
writer.writerow([
f"{result['freqs_mhz'][i]:.3f}",
f"{result['powers_db'][i]:.3f}",
f"{result['excess_db'][i]:.3f}",
f"{result['velocities_km_s'][i]:.1f}",
f"{result['baseline_db']:.3f}",
])
print(f" Data saved to {args.output}")
if __name__ == "__main__":
main()