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.
441 lines
17 KiB
Python
441 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Automated carrier survey engine -- six-stage pipeline.
|
|
|
|
Orchestrates spectrum sweep, peak detection, blind scan, and TS
|
|
sampling to build a complete carrier catalog from the IF band.
|
|
"""
|
|
|
|
import sys
|
|
import time
|
|
import io
|
|
|
|
from skywalker_lib import SkyWalker1, MODULATIONS, MOD_FEC_GROUP, FEC_RATES
|
|
from signal_analysis import (
|
|
adaptive_noise_floor,
|
|
detect_peaks_enhanced,
|
|
estimate_carrier_bw,
|
|
classify_carrier,
|
|
)
|
|
from carrier_catalog import CarrierEntry, CarrierCatalog
|
|
from ts_analyze import TSReader, PSIParser, parse_pat, parse_pmt, parse_sdt
|
|
|
|
|
|
# Modulation index table for reverse lookup
|
|
_MOD_BY_INDEX = {}
|
|
for name, (idx, desc) in MODULATIONS.items():
|
|
_MOD_BY_INDEX[idx] = name
|
|
|
|
|
|
class SurveyEngine:
|
|
"""
|
|
Six-stage carrier survey pipeline:
|
|
|
|
1. Coarse sweep -- full IF range at configurable step size
|
|
2. Peak detection -- adaptive noise floor, peak merging
|
|
3. Fine sweep -- +/-10 MHz around each peak at 1 MHz steps
|
|
4. Blind scan -- try symbol rate range at each refined peak
|
|
5. TS sample -- for locked carriers, short capture + PAT/PMT/SDT
|
|
6. Catalog assembly -- aggregate everything into a CarrierCatalog
|
|
"""
|
|
|
|
STAGE_COARSE = "coarse_sweep"
|
|
STAGE_PEAKS = "peak_detection"
|
|
STAGE_FINE = "fine_sweep"
|
|
STAGE_BLIND = "blind_scan"
|
|
STAGE_TS = "ts_sample"
|
|
STAGE_CATALOG = "catalog_assembly"
|
|
|
|
def __init__(self, device: SkyWalker1, callback=None):
|
|
"""
|
|
device -- open SkyWalker1 instance
|
|
callback -- optional function(stage, progress_pct, message)
|
|
called at each major step for progress reporting
|
|
"""
|
|
self.dev = device
|
|
self.callback = callback
|
|
|
|
def _report(self, stage: str, pct: float, msg: str) -> None:
|
|
if self.callback:
|
|
self.callback(stage, pct, msg)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public entry points
|
|
# ------------------------------------------------------------------
|
|
|
|
def run_full_scan(self, start_mhz: float = 950, stop_mhz: float = 2150,
|
|
coarse_step: float = 5.0, fine_step: float = 1.0,
|
|
sr_min: int = 1_000_000, sr_max: int = 30_000_000,
|
|
sr_step: int = 1_000_000,
|
|
ts_capture_secs: float = 3.0) -> CarrierCatalog:
|
|
"""
|
|
Run all six stages and return a populated CarrierCatalog.
|
|
"""
|
|
# Stage 1: coarse sweep
|
|
self._report(self.STAGE_COARSE, 0, "Starting coarse sweep")
|
|
freqs, powers = self._coarse_sweep(start_mhz, stop_mhz, coarse_step)
|
|
self._report(self.STAGE_COARSE, 100, f"Coarse sweep done: {len(freqs)} points")
|
|
|
|
# Stage 2: peak detection
|
|
self._report(self.STAGE_PEAKS, 0, "Detecting peaks")
|
|
peaks = self._detect_peaks(freqs, powers)
|
|
self._report(self.STAGE_PEAKS, 100, f"Found {len(peaks)} candidate peaks")
|
|
|
|
if not peaks:
|
|
self._report(self.STAGE_CATALOG, 100, "No peaks found, empty catalog")
|
|
return self._assemble_catalog([], start_mhz, stop_mhz,
|
|
coarse_step, fine_step)
|
|
|
|
# Stage 3: fine sweep around each peak
|
|
self._report(self.STAGE_FINE, 0, "Starting fine sweeps")
|
|
refined = self._fine_sweep(peaks, fine_step)
|
|
self._report(self.STAGE_FINE, 100, f"Refined to {len(refined)} carriers")
|
|
|
|
# Stage 4: blind scan at each refined peak
|
|
self._report(self.STAGE_BLIND, 0, "Starting blind scan")
|
|
scanned = self._blind_scan_peaks(refined, sr_min, sr_max, sr_step)
|
|
self._report(self.STAGE_BLIND, 100,
|
|
f"Blind scan done: {sum(1 for s in scanned if s.get('locked'))} locked")
|
|
|
|
# Stage 5: TS sample for locked carriers
|
|
locked = [s for s in scanned if s.get("locked")]
|
|
self._report(self.STAGE_TS, 0, f"Sampling TS from {len(locked)} locked carriers")
|
|
sampled = self._sample_ts(locked, capture_secs=ts_capture_secs)
|
|
self._report(self.STAGE_TS, 100, "TS sampling done")
|
|
|
|
# Stage 6: assemble catalog
|
|
self._report(self.STAGE_CATALOG, 0, "Assembling catalog")
|
|
catalog = self._assemble_catalog(sampled, start_mhz, stop_mhz,
|
|
coarse_step, fine_step)
|
|
self._report(self.STAGE_CATALOG, 100,
|
|
f"Catalog ready: {len(catalog.carriers)} carriers")
|
|
return catalog
|
|
|
|
def run_quick_scan(self, start_mhz: float = 950, stop_mhz: float = 2150,
|
|
step: float = 5.0) -> list:
|
|
"""
|
|
Quick scan: coarse sweep + peak detection only.
|
|
Returns list of peak dicts from detect_peaks_enhanced.
|
|
No blind scan or TS capture.
|
|
"""
|
|
self._report(self.STAGE_COARSE, 0, "Quick scan: coarse sweep")
|
|
freqs, powers = self._coarse_sweep(start_mhz, stop_mhz, step)
|
|
self._report(self.STAGE_COARSE, 100, f"Sweep done: {len(freqs)} points")
|
|
|
|
self._report(self.STAGE_PEAKS, 0, "Quick scan: peak detection")
|
|
peaks = self._detect_peaks(freqs, powers)
|
|
self._report(self.STAGE_PEAKS, 100, f"Found {len(peaks)} peaks")
|
|
return peaks
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal stage methods
|
|
# ------------------------------------------------------------------
|
|
|
|
def _coarse_sweep(self, start_mhz: float, stop_mhz: float,
|
|
step: float) -> tuple:
|
|
"""
|
|
Stage 1: sweep the IF band and collect power measurements.
|
|
Returns (freqs_mhz[], powers_db[]).
|
|
"""
|
|
total_steps = int((stop_mhz - start_mhz) / step) + 1
|
|
|
|
def sweep_cb(freq, step_num, total, result):
|
|
pct = (step_num / max(total, 1)) * 100
|
|
self._report(self.STAGE_COARSE, pct,
|
|
f"{freq:.0f} MHz {result['power_db']:+.1f} dB")
|
|
|
|
freqs, powers, _ = self.dev.sweep_spectrum(
|
|
start_mhz, stop_mhz, step_mhz=step,
|
|
dwell_ms=15, callback=sweep_cb
|
|
)
|
|
return freqs, powers
|
|
|
|
def _detect_peaks(self, freqs: list, powers: list) -> list:
|
|
"""
|
|
Stage 2: enhanced peak detection with adaptive noise floor.
|
|
Returns list of peak dicts.
|
|
"""
|
|
noise_floor, mad = adaptive_noise_floor(powers)
|
|
self._report(self.STAGE_PEAKS, 50,
|
|
f"Noise floor: {noise_floor:.1f} dB, MAD: {mad:.2f} dB")
|
|
|
|
peaks = detect_peaks_enhanced(freqs, powers, threshold_db=6.0)
|
|
|
|
# Annotate each peak with classification
|
|
for p in peaks:
|
|
p["classification"] = classify_carrier(p["width_mhz"], p["power"])
|
|
|
|
return peaks
|
|
|
|
def _fine_sweep(self, peaks: list, fine_step: float = 1.0) -> list:
|
|
"""
|
|
Stage 3: sweep +/-10 MHz around each peak at fine resolution.
|
|
Returns list of refined peak dicts with updated freq/power/width.
|
|
"""
|
|
refined = []
|
|
for i, peak in enumerate(peaks):
|
|
pct = (i / max(len(peaks), 1)) * 100
|
|
center = peak["freq"]
|
|
margin = max(peak["width_mhz"] * 1.5, 10.0)
|
|
fine_start = max(950.0, center - margin)
|
|
fine_stop = min(2150.0, center + margin)
|
|
|
|
self._report(self.STAGE_FINE, pct,
|
|
f"Fine sweep {center:.0f} MHz ({fine_start:.0f}-{fine_stop:.0f})")
|
|
|
|
freqs, powers, _ = self.dev.sweep_spectrum(
|
|
fine_start, fine_stop, step_mhz=fine_step,
|
|
dwell_ms=20
|
|
)
|
|
|
|
# Re-detect peaks in the fine data
|
|
fine_peaks = detect_peaks_enhanced(freqs, powers, threshold_db=4.0)
|
|
if fine_peaks:
|
|
# Take the strongest peak from the fine sweep
|
|
best = max(fine_peaks, key=lambda p: p["power"])
|
|
best["classification"] = classify_carrier(
|
|
best["width_mhz"], best["power"]
|
|
)
|
|
refined.append(best)
|
|
else:
|
|
# Keep the coarse peak if fine sweep didn't improve it
|
|
refined.append(peak)
|
|
|
|
return refined
|
|
|
|
def _blind_scan_peaks(self, refined_peaks: list,
|
|
sr_min: int, sr_max: int,
|
|
sr_step: int) -> list:
|
|
"""
|
|
Stage 4: attempt blind scan at each refined peak frequency.
|
|
Returns list of result dicts, each with the peak info plus
|
|
blind scan results (locked, sr_sps, etc).
|
|
"""
|
|
results = []
|
|
for i, peak in enumerate(refined_peaks):
|
|
pct = (i / max(len(refined_peaks), 1)) * 100
|
|
freq_khz = int(peak["freq"] * 1000)
|
|
|
|
self._report(self.STAGE_BLIND, pct,
|
|
f"Blind scan {peak['freq']:.1f} MHz")
|
|
|
|
# Use classification to narrow SR range if possible
|
|
cls = peak.get("classification", {})
|
|
sr_range = cls.get("estimated_sr_range", (sr_min, sr_max))
|
|
scan_min = max(sr_min, sr_range[0])
|
|
scan_max = min(sr_max, sr_range[1])
|
|
|
|
result = {
|
|
"freq_mhz": peak["freq"],
|
|
"freq_khz": freq_khz,
|
|
"power_db": peak["power"],
|
|
"width_mhz": peak["width_mhz"],
|
|
"prominence_db": peak.get("prominence_db", 0),
|
|
"classification": cls,
|
|
"locked": False,
|
|
"sr_sps": 0,
|
|
"mod_index": -1,
|
|
"fec_index": -1,
|
|
}
|
|
|
|
# Try adaptive blind scan first (firmware-assisted)
|
|
try:
|
|
lock = self.dev.adaptive_blind_scan(
|
|
freq_khz, scan_min, scan_max, sr_step
|
|
)
|
|
if lock and lock.get("locked"):
|
|
result["locked"] = True
|
|
result["sr_sps"] = lock["sr_sps"]
|
|
result["freq_khz"] = lock.get("freq_khz", freq_khz)
|
|
# Read signal quality
|
|
time.sleep(0.1)
|
|
sig = self.dev.signal_monitor()
|
|
result["snr_db"] = sig.get("snr_db", 0)
|
|
result["agc1"] = sig.get("agc1", 0)
|
|
except Exception as e:
|
|
self._report(self.STAGE_BLIND, pct,
|
|
f"Blind scan error at {peak['freq']:.1f} MHz: {e}")
|
|
|
|
results.append(result)
|
|
|
|
return results
|
|
|
|
def _sample_ts(self, locked_carriers: list,
|
|
capture_secs: float = 3.0) -> list:
|
|
"""
|
|
Stage 5: for each locked carrier, tune + arm + capture TS data,
|
|
then parse PAT/PMT/SDT for service information.
|
|
"""
|
|
results = []
|
|
for i, carrier in enumerate(locked_carriers):
|
|
pct = (i / max(len(locked_carriers), 1)) * 100
|
|
freq_khz = carrier["freq_khz"]
|
|
sr_sps = carrier["sr_sps"]
|
|
|
|
self._report(self.STAGE_TS, pct,
|
|
f"Sampling {carrier['freq_mhz']:.1f} MHz "
|
|
f"SR={sr_sps / 1e6:.3f} Msps")
|
|
|
|
carrier["services"] = []
|
|
carrier["pat"] = None
|
|
carrier["pmt"] = {}
|
|
|
|
if sr_sps <= 0:
|
|
results.append(carrier)
|
|
continue
|
|
|
|
try:
|
|
# Tune with QPSK auto-FEC as a safe default
|
|
self.dev.tune(sr_sps, freq_khz, 0, 5)
|
|
time.sleep(0.3)
|
|
|
|
# Verify lock
|
|
sig = self.dev.signal_monitor()
|
|
if not sig.get("locked"):
|
|
results.append(carrier)
|
|
continue
|
|
|
|
carrier["snr_db"] = sig.get("snr_db", 0)
|
|
|
|
# Arm and capture TS data
|
|
self.dev.arm_transfer(True)
|
|
ts_data = bytearray()
|
|
deadline = time.time() + capture_secs
|
|
|
|
while time.time() < deadline:
|
|
chunk = self.dev.read_stream(timeout=500)
|
|
if chunk:
|
|
ts_data.extend(chunk)
|
|
|
|
self.dev.arm_transfer(False)
|
|
|
|
# Parse the captured TS
|
|
if ts_data:
|
|
services = _parse_ts_services(bytes(ts_data))
|
|
carrier["services"] = services.get("service_names", [])
|
|
carrier["pat"] = services.get("pat")
|
|
carrier["pmt"] = services.get("pmts", {})
|
|
carrier["sdt"] = services.get("sdt")
|
|
|
|
except Exception as e:
|
|
self._report(self.STAGE_TS, pct,
|
|
f"TS capture error at {carrier['freq_mhz']:.1f} MHz: {e}")
|
|
try:
|
|
self.dev.arm_transfer(False)
|
|
except Exception:
|
|
pass
|
|
|
|
results.append(carrier)
|
|
|
|
return results
|
|
|
|
def _assemble_catalog(self, all_results: list,
|
|
start_mhz: float = 950,
|
|
stop_mhz: float = 2150,
|
|
coarse_step: float = 5.0,
|
|
fine_step: float = 1.0) -> CarrierCatalog:
|
|
"""
|
|
Stage 6: build a CarrierCatalog from the collected results.
|
|
"""
|
|
catalog = CarrierCatalog()
|
|
catalog.sweep_params = {
|
|
"start_mhz": start_mhz,
|
|
"stop_mhz": stop_mhz,
|
|
"coarse_step_mhz": coarse_step,
|
|
"fine_step_mhz": fine_step,
|
|
}
|
|
|
|
for r in all_results:
|
|
mod_name = ""
|
|
if r.get("mod_index", -1) >= 0:
|
|
mod_name = _MOD_BY_INDEX.get(r["mod_index"], "")
|
|
|
|
entry = CarrierEntry(
|
|
freq_khz=r.get("freq_khz", int(r.get("freq_mhz", 0) * 1000)),
|
|
sr_sps=r.get("sr_sps", 0),
|
|
modulation=mod_name,
|
|
fec="",
|
|
power_db=r.get("power_db", 0),
|
|
snr_db=r.get("snr_db", 0),
|
|
locked=r.get("locked", False),
|
|
services=r.get("services", []),
|
|
bw_mhz=r.get("width_mhz", 0),
|
|
classification=r.get("classification", {}),
|
|
)
|
|
catalog.add_carrier(entry)
|
|
|
|
return catalog
|
|
|
|
|
|
def _parse_ts_services(ts_data: bytes) -> dict:
|
|
"""
|
|
Parse PAT, PMT, and SDT from a chunk of TS data.
|
|
|
|
Returns dict with:
|
|
pat - parsed PAT or None
|
|
pmts - {pmt_pid: parsed PMT}
|
|
sdt - parsed SDT or None
|
|
service_names - list of service name strings from SDT
|
|
"""
|
|
result = {
|
|
"pat": None,
|
|
"pmts": {},
|
|
"sdt": None,
|
|
"service_names": [],
|
|
}
|
|
|
|
source = io.BytesIO(ts_data)
|
|
reader = TSReader(source)
|
|
psi_pat = PSIParser()
|
|
psi_pmt = PSIParser()
|
|
psi_sdt = PSIParser()
|
|
|
|
pat = None
|
|
pmt_pids = set()
|
|
pmts_found = {}
|
|
|
|
try:
|
|
for pkt in reader.iter_packets(max_packets=50000):
|
|
# PAT on PID 0x0000
|
|
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["pat"] = pat
|
|
for prog, pid in pat["programs"].items():
|
|
if prog != 0:
|
|
pmt_pids.add(pid)
|
|
|
|
# PMT sections
|
|
if pkt.pid in pmt_pids and pkt.pid not in pmts_found:
|
|
section = psi_pmt.feed(pkt)
|
|
if section is not None:
|
|
pmt = parse_pmt(section)
|
|
if pmt:
|
|
pmts_found[pkt.pid] = pmt
|
|
|
|
# SDT on PID 0x0011
|
|
if pkt.pid == 0x0011 and result["sdt"] is None:
|
|
section = psi_sdt.feed(pkt)
|
|
if section is not None:
|
|
sdt = parse_sdt(section)
|
|
if sdt:
|
|
result["sdt"] = sdt
|
|
for svc in sdt.get("services", []):
|
|
name = svc.get("service_name", "")
|
|
if name:
|
|
result["service_names"].append(name)
|
|
|
|
# Stop early once we have everything
|
|
if (pat is not None
|
|
and len(pmts_found) >= len(pmt_pids)
|
|
and result["sdt"] is not None):
|
|
break
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
result["pmts"] = pmts_found
|
|
return result
|