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.
This commit is contained in:
parent
0f4ba4766f
commit
cc3a0707a1
20 changed files with 5645 additions and 84 deletions
377
tools/carrier_catalog.py
Normal file
377
tools/carrier_catalog.py
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
#!/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
|
||||
Loading…
Add table
Add a link
Reference in a new issue