#!/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"" 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