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
4
.gitignore
vendored
4
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
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
@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
218
site/src/content/docs/guides/experimenter-roadmap.mdx
Normal file
218
site/src/content/docs/guides/experimenter-roadmap.mdx
Normal 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>
|
||||||
95
site/src/content/docs/tools/arc-survey.mdx
Normal file
95
site/src/content/docs/tools/arc-survey.mdx
Normal 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`.
|
||||||
75
site/src/content/docs/tools/beacon-logger.mdx
Normal file
75
site/src/content/docs/tools/beacon-logger.mdx
Normal 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 |
|
||||||
81
site/src/content/docs/tools/h21cm.mdx
Normal file
81
site/src/content/docs/tools/h21cm.mdx
Normal 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 |
|
||||||
112
site/src/content/docs/tools/mcp-server.mdx
Normal file
112
site/src/content/docs/tools/mcp-server.mdx
Normal 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
451
tools/arc_survey.py
Normal 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
376
tools/beacon_logger.py
Normal 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
357
tools/h21cm.py
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user