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.
378 lines
14 KiB
Python
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
|