skywalker-1/tools/carrier_catalog.py
Ryan Malloy cc3a0707a1 Add DiSEqC motor control, QO-100 DATV reception, and carrier survey
Firmware v3.03.0: DiSEqC Manchester encoder (cmd 0x8D extended),
parameterized spectrum sweep (0xBA), adaptive blind scan (0xBB),
error code reporting (0xBC). All new function locals moved to XDATA
to fit within FX2LP 256-byte internal RAM constraint.

Motor control: DiSEqC 1.2 positioner with USALS GotoX, stored
positions, interactive keyboard jog, 30-second safety auto-halt.

QO-100 DATV: Es'hail-2 wideband transponder tools — LNB IF
calculator, narrowband scan, tune, and TS-to-video pipe (ffplay/mpv).

Carrier survey: six-stage pipeline (coarse sweep → peak detection →
fine sweep → blind scan → TS sample → catalog). JSON catalog with
differential analysis, QO-100 optimized mode, CSV/text export.

TUI: F9 Motor screen (3-column layout with signal gauge), F10 Survey
screen (Full Band + QO-100 tabs). Bridge, demo, and theme updated.

Docs: motor.mdx, survey.mdx, qo100-datv.mdx guide, tui.mdx updated
for 10 screens. Site builds 41 pages, all links valid.
2026-02-15 17:01:11 -07:00

378 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Carrier catalog: persistent JSON storage for survey results.
Stores detected carriers with their parameters, services, and timestamps
in ~/.skywalker1/surveys/ for historical comparison and diff reporting.
"""
import json
import os
from datetime import datetime, timezone
from pathlib import Path
CATALOG_DIR = Path.home() / ".skywalker1" / "surveys"
class CarrierEntry:
"""Single carrier identification from a survey."""
def __init__(self, freq_khz: int = 0, sr_sps: int = 0,
modulation: str = "", fec: str = "",
power_db: float = 0.0, snr_db: float = 0.0,
locked: bool = False, services: list = None,
first_seen: str = None, last_seen: str = None,
scan_count: int = 1, bw_mhz: float = 0.0,
classification: dict = None):
self.freq_khz = freq_khz
self.sr_sps = sr_sps
self.modulation = modulation
self.fec = fec
self.power_db = power_db
self.snr_db = snr_db
self.locked = locked
self.services = services or []
now = datetime.now(timezone.utc).isoformat()
self.first_seen = first_seen or now
self.last_seen = last_seen or now
self.scan_count = scan_count
self.bw_mhz = bw_mhz
self.classification = classification or {}
def to_dict(self) -> dict:
return {
"freq_khz": self.freq_khz,
"sr_sps": self.sr_sps,
"modulation": self.modulation,
"fec": self.fec,
"power_db": self.power_db,
"snr_db": self.snr_db,
"locked": self.locked,
"services": self.services,
"first_seen": self.first_seen,
"last_seen": self.last_seen,
"scan_count": self.scan_count,
"bw_mhz": self.bw_mhz,
"classification": self.classification,
}
@classmethod
def from_dict(cls, d: dict) -> "CarrierEntry":
return cls(
freq_khz=d.get("freq_khz", 0),
sr_sps=d.get("sr_sps", 0),
modulation=d.get("modulation", ""),
fec=d.get("fec", ""),
power_db=d.get("power_db", 0.0),
snr_db=d.get("snr_db", 0.0),
locked=d.get("locked", False),
services=d.get("services", []),
first_seen=d.get("first_seen"),
last_seen=d.get("last_seen"),
scan_count=d.get("scan_count", 1),
bw_mhz=d.get("bw_mhz", 0.0),
classification=d.get("classification", {}),
)
@property
def freq_mhz(self) -> float:
return self.freq_khz / 1000.0
@property
def sr_ksps(self) -> float:
return self.sr_sps / 1000.0
def key(self) -> str:
"""Unique key for diffing: frequency rounded to nearest 500 kHz."""
rounded = round(self.freq_khz / 500) * 500
return str(rounded)
def summary(self) -> str:
"""One-line human-readable summary."""
lock_str = "LOCKED" if self.locked else "no lock"
sr_str = f"{self.sr_sps / 1e6:.3f} Msps" if self.sr_sps else "SR unknown"
mod_str = self.modulation if self.modulation else "mod unknown"
svc_str = f", {len(self.services)} svc" if self.services else ""
return (f"{self.freq_mhz:.1f} MHz {self.power_db:+.1f} dB "
f"{sr_str} {mod_str} {lock_str}{svc_str}")
def __repr__(self):
return f"<CarrierEntry {self.freq_mhz:.1f} MHz {self.sr_sps} sps>"
class CarrierCatalog:
"""Collection of carriers from a survey."""
def __init__(self, name: str = "", band: str = "", pol: str = "",
lnb_lo_mhz: float = 0.0, notes: str = ""):
self.name = name
self.band = band
self.pol = pol
self.lnb_lo_mhz = lnb_lo_mhz
self.notes = notes
self.created = datetime.now(timezone.utc).isoformat()
self.carriers: list[CarrierEntry] = []
self.sweep_params: dict = {}
def add_carrier(self, entry: CarrierEntry) -> None:
"""Add a carrier entry, merging with existing if frequency matches."""
for existing in self.carriers:
if existing.key() == entry.key():
# Update existing entry
existing.last_seen = entry.last_seen
existing.scan_count += 1
existing.power_db = entry.power_db
existing.snr_db = entry.snr_db
existing.locked = entry.locked
if entry.sr_sps:
existing.sr_sps = entry.sr_sps
if entry.modulation:
existing.modulation = entry.modulation
if entry.fec:
existing.fec = entry.fec
if entry.services:
existing.services = entry.services
if entry.bw_mhz:
existing.bw_mhz = entry.bw_mhz
if entry.classification:
existing.classification = entry.classification
return
self.carriers.append(entry)
def to_dict(self) -> dict:
return {
"name": self.name,
"band": self.band,
"pol": self.pol,
"lnb_lo_mhz": self.lnb_lo_mhz,
"notes": self.notes,
"created": self.created,
"sweep_params": self.sweep_params,
"carrier_count": len(self.carriers),
"locked_count": sum(1 for c in self.carriers if c.locked),
"carriers": [c.to_dict() for c in self.carriers],
}
@classmethod
def from_dict(cls, d: dict) -> "CarrierCatalog":
cat = cls(
name=d.get("name", ""),
band=d.get("band", ""),
pol=d.get("pol", ""),
lnb_lo_mhz=d.get("lnb_lo_mhz", 0.0),
notes=d.get("notes", ""),
)
cat.created = d.get("created", cat.created)
cat.sweep_params = d.get("sweep_params", {})
for cd in d.get("carriers", []):
cat.carriers.append(CarrierEntry.from_dict(cd))
return cat
def save(self, filename: str = None) -> Path:
"""
Save catalog to JSON in CATALOG_DIR.
If filename is not given, generates one from date/band/pol:
survey-YYYY-MM-DD-{band}-{pol}.json
"""
CATALOG_DIR.mkdir(parents=True, exist_ok=True)
if filename is None:
date_str = datetime.now().strftime("%Y-%m-%d")
parts = ["survey", date_str]
if self.band:
parts.append(self.band)
if self.pol:
parts.append(self.pol)
filename = "-".join(parts) + ".json"
path = CATALOG_DIR / filename
with open(path, 'w') as f:
json.dump(self.to_dict(), f, indent=2)
return path
@classmethod
def load(cls, filename: str) -> "CarrierCatalog":
"""Load a catalog from JSON. Accepts filename or full path."""
path = Path(filename)
if not path.is_absolute():
path = CATALOG_DIR / filename
with open(path) as f:
data = json.load(f)
return cls.from_dict(data)
@classmethod
def list_surveys(cls) -> list:
"""List saved survey files in CATALOG_DIR, newest first."""
if not CATALOG_DIR.exists():
return []
files = sorted(CATALOG_DIR.glob("survey-*.json"), reverse=True)
results = []
for f in files:
try:
with open(f) as fh:
data = json.load(fh)
results.append({
"filename": f.name,
"path": str(f),
"created": data.get("created", ""),
"carrier_count": data.get("carrier_count", 0),
"locked_count": data.get("locked_count", 0),
"band": data.get("band", ""),
"pol": data.get("pol", ""),
})
except (json.JSONDecodeError, OSError):
results.append({
"filename": f.name,
"path": str(f),
"created": "",
"carrier_count": -1,
"locked_count": -1,
"band": "",
"pol": "",
})
return results
def summary(self) -> str:
"""Multi-line text summary of the catalog."""
lines = []
lines.append(f"Survey: {self.name or '(unnamed)'}")
lines.append(f"Created: {self.created}")
if self.band or self.pol:
lines.append(f"Band: {self.band} Pol: {self.pol}")
if self.lnb_lo_mhz:
lines.append(f"LNB LO: {self.lnb_lo_mhz} MHz")
lines.append(f"Carriers: {len(self.carriers)} total, "
f"{sum(1 for c in self.carriers if c.locked)} locked")
lines.append("")
for i, c in enumerate(sorted(self.carriers, key=lambda x: x.freq_khz), 1):
lines.append(f" {i:3d}. {c.summary()}")
return "\n".join(lines)
class CatalogDiff:
"""Compare two catalog snapshots to find changes."""
@staticmethod
def diff(old_catalog: CarrierCatalog,
new_catalog: CarrierCatalog) -> dict:
"""
Compare old and new catalogs.
Returns dict with:
new - carriers in new but not old
missing - carriers in old but not new
changed - carriers at same freq but different SR/power/services
stable - carriers unchanged between scans
"""
old_map = {c.key(): c for c in old_catalog.carriers}
new_map = {c.key(): c for c in new_catalog.carriers}
old_keys = set(old_map.keys())
new_keys = set(new_map.keys())
result = {
"new": [],
"missing": [],
"changed": [],
"stable": [],
}
# New carriers
for key in sorted(new_keys - old_keys):
result["new"].append(new_map[key].to_dict())
# Missing carriers
for key in sorted(old_keys - new_keys):
result["missing"].append(old_map[key].to_dict())
# Compare common carriers
for key in sorted(old_keys & new_keys):
old_c = old_map[key]
new_c = new_map[key]
changes = _find_changes(old_c, new_c)
if changes:
result["changed"].append({
"carrier": new_c.to_dict(),
"previous": old_c.to_dict(),
"changes": changes,
})
else:
result["stable"].append(new_c.to_dict())
return result
@staticmethod
def format_diff(diff_result: dict) -> str:
"""Format a diff result as human-readable text."""
lines = []
if diff_result["new"]:
lines.append(f"NEW CARRIERS ({len(diff_result['new'])}):")
for c in diff_result["new"]:
entry = CarrierEntry.from_dict(c)
lines.append(f" + {entry.summary()}")
lines.append("")
if diff_result["missing"]:
lines.append(f"MISSING CARRIERS ({len(diff_result['missing'])}):")
for c in diff_result["missing"]:
entry = CarrierEntry.from_dict(c)
lines.append(f" - {entry.summary()}")
lines.append("")
if diff_result["changed"]:
lines.append(f"CHANGED CARRIERS ({len(diff_result['changed'])}):")
for item in diff_result["changed"]:
entry = CarrierEntry.from_dict(item["carrier"])
lines.append(f" ~ {entry.summary()}")
for change in item["changes"]:
lines.append(f" {change}")
lines.append("")
stable_count = len(diff_result["stable"])
lines.append(f"STABLE: {stable_count} carrier(s) unchanged")
return "\n".join(lines)
def _find_changes(old: CarrierEntry, new: CarrierEntry) -> list:
"""Compare two carriers at the same frequency, return list of change descriptions."""
changes = []
# Frequency drift (within the 500 kHz key bucket)
if abs(old.freq_khz - new.freq_khz) > 100:
changes.append(f"freq: {old.freq_khz} -> {new.freq_khz} kHz")
# Symbol rate change
if old.sr_sps and new.sr_sps and old.sr_sps != new.sr_sps:
changes.append(f"SR: {old.sr_sps} -> {new.sr_sps} sps")
# Power change (>2 dB is significant)
if abs(old.power_db - new.power_db) > 2.0:
changes.append(f"power: {old.power_db:+.1f} -> {new.power_db:+.1f} dB")
# Lock state change
if old.locked != new.locked:
changes.append(f"lock: {old.locked} -> {new.locked}")
# Modulation change
if old.modulation and new.modulation and old.modulation != new.modulation:
changes.append(f"mod: {old.modulation} -> {new.modulation}")
# Service list change
old_svcs = set(old.services)
new_svcs = set(new.services)
if old_svcs != new_svcs:
added = new_svcs - old_svcs
removed = old_svcs - new_svcs
parts = []
if added:
parts.append(f"+{list(added)}")
if removed:
parts.append(f"-{list(removed)}")
changes.append(f"services: {', '.join(parts)}")
return changes