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
|
||||
541
tools/motor.py
Executable file
541
tools/motor.py
Executable file
|
|
@ -0,0 +1,541 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Genpix SkyWalker-1 DiSEqC 1.2 motor control tool.
|
||||
|
||||
Subcommands:
|
||||
halt - Stop motor movement
|
||||
east/west - Drive motor (continuous or stepped)
|
||||
goto - Go to stored position slot
|
||||
store - Store current position to slot
|
||||
gotox - USALS GotoX (automatic orbital positioning)
|
||||
limit - Set software travel limit
|
||||
nolimits - Disable software limits
|
||||
raw - Send raw DiSEqC bytes
|
||||
interactive - Keyboard-driven jog controller with live signal
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import time
|
||||
import atexit
|
||||
import select
|
||||
import termios
|
||||
import tty
|
||||
|
||||
# Add tools directory to path for library import
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from skywalker_lib import SkyWalker1, signal_bar
|
||||
|
||||
|
||||
# -- Safety timeout for continuous drive --
|
||||
|
||||
CONTINUOUS_DRIVE_TIMEOUT = 30.0 # seconds
|
||||
|
||||
|
||||
# -- Subcommand handlers --
|
||||
|
||||
def cmd_halt(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Stop motor movement."""
|
||||
sw.motor_halt()
|
||||
print("Motor halted")
|
||||
|
||||
|
||||
def cmd_east(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Drive motor east."""
|
||||
steps = args.steps
|
||||
if steps:
|
||||
sw.motor_drive_east(steps)
|
||||
print(f"Driving east {steps} step(s)")
|
||||
else:
|
||||
sw.motor_drive_east(0)
|
||||
print(f"Driving east (continuous) -- will auto-halt after {CONTINUOUS_DRIVE_TIMEOUT:.0f}s")
|
||||
print("Press Ctrl-C to stop")
|
||||
_wait_with_halt(sw, CONTINUOUS_DRIVE_TIMEOUT)
|
||||
|
||||
|
||||
def cmd_west(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Drive motor west."""
|
||||
steps = args.steps
|
||||
if steps:
|
||||
sw.motor_drive_west(steps)
|
||||
print(f"Driving west {steps} step(s)")
|
||||
else:
|
||||
sw.motor_drive_west(0)
|
||||
print(f"Driving west (continuous) -- will auto-halt after {CONTINUOUS_DRIVE_TIMEOUT:.0f}s")
|
||||
print("Press Ctrl-C to stop")
|
||||
_wait_with_halt(sw, CONTINUOUS_DRIVE_TIMEOUT)
|
||||
|
||||
|
||||
def _wait_with_halt(sw: SkyWalker1, timeout: float) -> None:
|
||||
"""Wait for timeout or Ctrl-C, then halt the motor."""
|
||||
try:
|
||||
time.sleep(timeout)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
sw.motor_halt()
|
||||
print("\nMotor halted")
|
||||
|
||||
|
||||
def cmd_goto(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Go to a stored position slot."""
|
||||
slot = args.slot
|
||||
if slot == 0:
|
||||
print("Going to reference position (slot 0)")
|
||||
else:
|
||||
print(f"Going to stored position {slot}")
|
||||
sw.motor_goto_position(slot)
|
||||
|
||||
|
||||
def cmd_store(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Store the current dish position into a slot."""
|
||||
slot = args.slot
|
||||
sw.motor_store_position(slot)
|
||||
print(f"Current position stored in slot {slot}")
|
||||
|
||||
|
||||
def cmd_gotox(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""USALS GotoX: calculate and drive to satellite longitude."""
|
||||
sat_lon = args.sat
|
||||
obs_lon = args.lon
|
||||
obs_lat = args.lat
|
||||
|
||||
# Import usals_angle for display
|
||||
from skywalker_lib import usals_angle, usals_encode_angle
|
||||
|
||||
angle = usals_angle(obs_lon, sat_lon, obs_lat)
|
||||
hh, ll = usals_encode_angle(angle)
|
||||
direction = "west" if angle < 0 else "east"
|
||||
|
||||
print(f"USALS GotoX")
|
||||
print(f" Observer: {obs_lon:.2f} lon, {obs_lat:.2f} lat")
|
||||
print(f" Satellite: {sat_lon:.2f} lon")
|
||||
print(f" Motor angle: {abs(angle):.2f} deg {direction}")
|
||||
print(f" DiSEqC: E0 31 6E {hh:02X} {ll:02X}")
|
||||
|
||||
sw.motor_goto_x(obs_lon, sat_lon)
|
||||
print(" Command sent")
|
||||
|
||||
|
||||
def cmd_limit(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Set a software travel limit at the current position."""
|
||||
direction = args.direction
|
||||
sw.motor_set_limit(direction)
|
||||
print(f"Software {direction} limit set at current position")
|
||||
|
||||
|
||||
def cmd_nolimits(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Disable software travel limits."""
|
||||
sw.motor_disable_limits()
|
||||
print("Software limits disabled")
|
||||
|
||||
|
||||
def cmd_raw(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Send a raw DiSEqC command."""
|
||||
raw_bytes = bytes(int(b, 16) for b in args.bytes)
|
||||
if len(raw_bytes) < 3 or len(raw_bytes) > 6:
|
||||
print("DiSEqC message must be 3-6 bytes")
|
||||
sys.exit(1)
|
||||
print(f"Sending DiSEqC: {raw_bytes.hex(' ')}")
|
||||
sw.send_diseqc_message(raw_bytes)
|
||||
print(" OK")
|
||||
|
||||
|
||||
# -- Interactive jog controller --
|
||||
|
||||
def cmd_interactive(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Keyboard-driven jog controller with live signal monitoring."""
|
||||
|
||||
# Register atexit halt -- fires on unexpected exit, Ctrl-C leak, etc.
|
||||
def emergency_halt():
|
||||
try:
|
||||
sw.motor_halt()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
atexit.register(emergency_halt)
|
||||
|
||||
# Save terminal state and switch to raw mode
|
||||
fd = sys.stdin.fileno()
|
||||
old_attrs = termios.tcgetattr(fd)
|
||||
|
||||
def restore_terminal():
|
||||
termios.tcsetattr(fd, termios.TCSAFLUSH, old_attrs)
|
||||
# Show cursor, clear line
|
||||
sys.stdout.write("\033[?25h\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
atexit.register(restore_terminal)
|
||||
|
||||
tty.setraw(fd)
|
||||
# Hide cursor for cleaner display
|
||||
sys.stdout.write("\033[?25l")
|
||||
sys.stdout.flush()
|
||||
|
||||
_interactive_loop(sw, fd, args.verbose)
|
||||
|
||||
# Cleanup (atexit handles restore, but be explicit for normal exit)
|
||||
restore_terminal()
|
||||
emergency_halt()
|
||||
atexit.unregister(restore_terminal)
|
||||
atexit.unregister(emergency_halt)
|
||||
|
||||
|
||||
def _interactive_loop(sw: SkyWalker1, fd: int, verbose: bool) -> None:
|
||||
"""Main loop for interactive jog mode."""
|
||||
|
||||
state = {
|
||||
"driving": None, # None, 'east', or 'west'
|
||||
"drive_start": 0.0, # time.time() when drive began
|
||||
"store_mode": False, # True = next digit stores position
|
||||
"running": True,
|
||||
}
|
||||
|
||||
POLL_INTERVAL = 0.5 # seconds between signal refreshes (~2 Hz)
|
||||
last_refresh = 0.0
|
||||
|
||||
_draw_header()
|
||||
|
||||
while state["running"]:
|
||||
now = time.time()
|
||||
|
||||
# Auto-halt safety: stop after CONTINUOUS_DRIVE_TIMEOUT
|
||||
if state["driving"] and (now - state["drive_start"]) >= CONTINUOUS_DRIVE_TIMEOUT:
|
||||
sw.motor_halt()
|
||||
_status_line(f"AUTO-HALT: {CONTINUOUS_DRIVE_TIMEOUT:.0f}s safety limit reached")
|
||||
state["driving"] = None
|
||||
|
||||
# Refresh signal display at ~2 Hz
|
||||
if now - last_refresh >= POLL_INTERVAL:
|
||||
try:
|
||||
sig = sw.signal_monitor()
|
||||
_draw_signal(sig, state)
|
||||
except Exception:
|
||||
_draw_signal(None, state)
|
||||
last_refresh = now
|
||||
|
||||
# Non-blocking key read
|
||||
if select.select([fd], [], [], 0.05)[0]:
|
||||
ch = os.read(fd, 8)
|
||||
_handle_key(sw, ch, state)
|
||||
|
||||
|
||||
def _handle_key(sw: SkyWalker1, ch: bytes, state: dict) -> None:
|
||||
"""Process a keypress in interactive mode."""
|
||||
|
||||
# Escape sequences for arrow keys
|
||||
if ch == b'\x1b[D' or ch == b'\x1b[C':
|
||||
direction = 'west' if ch == b'\x1b[D' else 'east'
|
||||
if state["driving"] != direction:
|
||||
if direction == 'east':
|
||||
sw.motor_drive_east(0)
|
||||
else:
|
||||
sw.motor_drive_west(0)
|
||||
state["driving"] = direction
|
||||
state["drive_start"] = time.time()
|
||||
_status_line(f"Driving {direction}...")
|
||||
return
|
||||
|
||||
# Space = halt
|
||||
if ch == b' ':
|
||||
sw.motor_halt()
|
||||
state["driving"] = None
|
||||
_status_line("Halted")
|
||||
return
|
||||
|
||||
# q = quit
|
||||
if ch in (b'q', b'Q', b'\x03'): # q, Q, or Ctrl-C
|
||||
sw.motor_halt()
|
||||
state["driving"] = None
|
||||
state["running"] = False
|
||||
_status_line("Quitting...")
|
||||
return
|
||||
|
||||
# s = enter store mode (next digit saves position)
|
||||
if ch == b's' or ch == b'S':
|
||||
state["store_mode"] = True
|
||||
_status_line("Store mode: press 1-9 to save position")
|
||||
return
|
||||
|
||||
# g = prompt for USALS GotoX
|
||||
if ch == b'g' or ch == b'G':
|
||||
_gotox_prompt(sw, state)
|
||||
return
|
||||
|
||||
# Digits 1-9: goto or store
|
||||
if len(ch) == 1 and ord(ch) in range(ord('1'), ord('9') + 1):
|
||||
slot = ord(ch) - ord('0')
|
||||
if state["store_mode"]:
|
||||
sw.motor_store_position(slot)
|
||||
_status_line(f"Position stored in slot {slot}")
|
||||
state["store_mode"] = False
|
||||
else:
|
||||
sw.motor_goto_position(slot)
|
||||
state["driving"] = None
|
||||
_status_line(f"Going to position {slot}")
|
||||
return
|
||||
|
||||
# 0 = goto reference
|
||||
if ch == b'0':
|
||||
if state["store_mode"]:
|
||||
_status_line("Slot 0 is reference -- not storable")
|
||||
state["store_mode"] = False
|
||||
else:
|
||||
sw.motor_goto_position(0)
|
||||
state["driving"] = None
|
||||
_status_line("Going to reference (slot 0)")
|
||||
return
|
||||
|
||||
# Unknown key -- clear store mode
|
||||
if state["store_mode"]:
|
||||
state["store_mode"] = False
|
||||
_status_line("Store cancelled")
|
||||
|
||||
|
||||
def _gotox_prompt(sw: SkyWalker1, state: dict) -> None:
|
||||
"""
|
||||
Prompt for USALS GotoX parameters in raw terminal mode.
|
||||
|
||||
Reads satellite longitude and observer longitude character-by-character
|
||||
since we're in raw mode and can't use input().
|
||||
"""
|
||||
_status_line("GotoX: enter satellite longitude (e.g. -97.5): ")
|
||||
sat_str = _raw_readline()
|
||||
if sat_str is None:
|
||||
_status_line("GotoX cancelled")
|
||||
return
|
||||
|
||||
_status_line(f"Sat {sat_str} -- enter observer longitude: ")
|
||||
obs_str = _raw_readline()
|
||||
if obs_str is None:
|
||||
_status_line("GotoX cancelled")
|
||||
return
|
||||
|
||||
try:
|
||||
sat_lon = float(sat_str)
|
||||
obs_lon = float(obs_str)
|
||||
except ValueError:
|
||||
_status_line("Invalid coordinates")
|
||||
return
|
||||
|
||||
from skywalker_lib import usals_angle
|
||||
angle = usals_angle(obs_lon, sat_lon)
|
||||
direction = "W" if angle < 0 else "E"
|
||||
|
||||
sw.motor_goto_x(obs_lon, sat_lon)
|
||||
state["driving"] = None
|
||||
_status_line(f"GotoX: sat {sat_lon} obs {obs_lon} -> {abs(angle):.1f} deg {direction}")
|
||||
|
||||
|
||||
def _raw_readline() -> str | None:
|
||||
"""Read a line of text in raw terminal mode, echoing characters."""
|
||||
fd = sys.stdin.fileno()
|
||||
buf = []
|
||||
while True:
|
||||
if select.select([fd], [], [], 30.0)[0]:
|
||||
ch = os.read(fd, 1)
|
||||
if ch == b'\r' or ch == b'\n':
|
||||
sys.stdout.write("\r\n")
|
||||
sys.stdout.flush()
|
||||
return ''.join(buf)
|
||||
if ch == b'\x03' or ch == b'\x1b': # Ctrl-C or Escape
|
||||
return None
|
||||
if ch == b'\x7f' or ch == b'\x08': # Backspace
|
||||
if buf:
|
||||
buf.pop()
|
||||
sys.stdout.write("\b \b")
|
||||
sys.stdout.flush()
|
||||
continue
|
||||
# Accept digits, minus, period
|
||||
c = ch.decode('ascii', errors='ignore')
|
||||
if c in '0123456789.-':
|
||||
buf.append(c)
|
||||
sys.stdout.write(c)
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
# Timeout waiting for input
|
||||
return None
|
||||
|
||||
|
||||
# -- Display helpers --
|
||||
|
||||
def _draw_header() -> None:
|
||||
"""Print the interactive mode header (once at startup)."""
|
||||
sys.stdout.write("\r\n")
|
||||
sys.stdout.write(" SkyWalker-1 Motor Control\r\n")
|
||||
sys.stdout.write(" ========================\r\n")
|
||||
sys.stdout.write(" Left/Right : jog west/east (continuous)\r\n")
|
||||
sys.stdout.write(" Space : halt\r\n")
|
||||
sys.stdout.write(" 1-9 : goto stored position\r\n")
|
||||
sys.stdout.write(" s + 1-9 : store to position slot\r\n")
|
||||
sys.stdout.write(" g : USALS GotoX prompt\r\n")
|
||||
sys.stdout.write(" q : quit\r\n")
|
||||
sys.stdout.write("\r\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _draw_signal(sig: dict | None, state: dict) -> None:
|
||||
"""Update the signal monitor line at the bottom of the display."""
|
||||
# Save cursor, move to signal display area
|
||||
sys.stdout.write("\033[s") # save cursor
|
||||
|
||||
if sig is None:
|
||||
sys.stdout.write("\r\n Signal: -- no data --\033[K")
|
||||
else:
|
||||
snr_db = sig["snr_db"]
|
||||
agc1 = sig["agc1"]
|
||||
locked = sig["locked"]
|
||||
power_db = sig["power_db"]
|
||||
pct = sig["snr_pct"]
|
||||
|
||||
lock_str = "LOCK" if locked else "----"
|
||||
bar = signal_bar(pct, width=25)
|
||||
|
||||
drive_str = ""
|
||||
if state["driving"]:
|
||||
elapsed = time.time() - state["drive_start"]
|
||||
remaining = CONTINUOUS_DRIVE_TIMEOUT - elapsed
|
||||
drive_str = f" [{state['driving'].upper()} {remaining:.0f}s]"
|
||||
|
||||
line = (f"\r [{lock_str}] SNR {snr_db:5.1f} dB "
|
||||
f"AGC {agc1:5d} "
|
||||
f"Pwr {power_db:5.1f} dB "
|
||||
f"{bar}{drive_str}")
|
||||
sys.stdout.write(f"\n{line}\033[K")
|
||||
|
||||
sys.stdout.write("\033[u") # restore cursor
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _status_line(msg: str) -> None:
|
||||
"""Write a status message on a dedicated line."""
|
||||
sys.stdout.write(f"\r > {msg}\033[K\r\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
# -- CLI --
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="motor.py",
|
||||
description="Genpix SkyWalker-1 DiSEqC 1.2 motor control",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
examples:
|
||||
%(prog)s halt
|
||||
%(prog)s east --steps 10
|
||||
%(prog)s west
|
||||
%(prog)s goto 3
|
||||
%(prog)s store 3
|
||||
%(prog)s gotox --sat -97.5 --lon -96.8
|
||||
%(prog)s gotox --sat -97.5 --lon -96.8 --lat 32.7
|
||||
%(prog)s limit east
|
||||
%(prog)s nolimits
|
||||
%(prog)s raw E0 31 6B 01
|
||||
%(prog)s interactive
|
||||
|
||||
interactive mode controls:
|
||||
Arrow left/right jog west/east (continuous drive)
|
||||
Space halt motor
|
||||
1-9 go to stored position
|
||||
s + 1-9 store position to slot
|
||||
g USALS GotoX prompt
|
||||
q quit
|
||||
|
||||
safety:
|
||||
Continuous drive auto-halts after 30 seconds.
|
||||
Motor is halted on exit (including unexpected termination).
|
||||
""")
|
||||
parser.add_argument('-v', '--verbose', action='store_true',
|
||||
help="Show raw USB traffic")
|
||||
|
||||
sub = parser.add_subparsers(dest='command')
|
||||
|
||||
# halt
|
||||
sub.add_parser('halt', help="Stop motor movement")
|
||||
|
||||
# east
|
||||
p_east = sub.add_parser('east', help="Drive motor east")
|
||||
p_east.add_argument('--steps', type=int, default=0,
|
||||
help="Number of steps (0=continuous, 1-127)")
|
||||
|
||||
# west
|
||||
p_west = sub.add_parser('west', help="Drive motor west")
|
||||
p_west.add_argument('--steps', type=int, default=0,
|
||||
help="Number of steps (0=continuous, 1-127)")
|
||||
|
||||
# goto
|
||||
p_goto = sub.add_parser('goto', help="Go to stored position")
|
||||
p_goto.add_argument('slot', type=int,
|
||||
help="Position slot (0=reference, 1-255)")
|
||||
|
||||
# store
|
||||
p_store = sub.add_parser('store', help="Store current position")
|
||||
p_store.add_argument('slot', type=int,
|
||||
help="Position slot to store to (1-255)")
|
||||
|
||||
# gotox
|
||||
p_gotox = sub.add_parser('gotox', help="USALS GotoX (automatic positioning)")
|
||||
p_gotox.add_argument('--sat', type=float, required=True,
|
||||
help="Satellite longitude (negative=west, e.g. -97.5)")
|
||||
p_gotox.add_argument('--lon', type=float, required=True,
|
||||
help="Observer longitude (negative=west)")
|
||||
p_gotox.add_argument('--lat', type=float, default=0.0,
|
||||
help="Observer latitude (default: 0.0)")
|
||||
|
||||
# limit
|
||||
p_limit = sub.add_parser('limit', help="Set software travel limit")
|
||||
p_limit.add_argument('direction', choices=['east', 'west'],
|
||||
help="Limit direction")
|
||||
|
||||
# nolimits
|
||||
sub.add_parser('nolimits', help="Disable software travel limits")
|
||||
|
||||
# raw
|
||||
p_raw = sub.add_parser('raw', help="Send raw DiSEqC bytes")
|
||||
p_raw.add_argument('bytes', nargs='+', metavar='HH',
|
||||
help="Hex bytes (e.g. E0 31 6B 01)")
|
||||
|
||||
# interactive
|
||||
sub.add_parser('interactive', help="Keyboard-driven jog controller")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
dispatch = {
|
||||
'halt': cmd_halt,
|
||||
'east': cmd_east,
|
||||
'west': cmd_west,
|
||||
'goto': cmd_goto,
|
||||
'store': cmd_store,
|
||||
'gotox': cmd_gotox,
|
||||
'limit': cmd_limit,
|
||||
'nolimits': cmd_nolimits,
|
||||
'raw': cmd_raw,
|
||||
'interactive': cmd_interactive,
|
||||
}
|
||||
|
||||
handler = dispatch.get(args.command)
|
||||
if handler is None:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
with SkyWalker1(verbose=args.verbose) as sw:
|
||||
# Boot demodulator and enable LNB power for DiSEqC
|
||||
sw.ensure_booted()
|
||||
sw.start_intersil(on=True)
|
||||
handler(sw, args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
773
tools/qo100.py
Executable file
773
tools/qo100.py
Executable file
|
|
@ -0,0 +1,773 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
QO-100 (Es'hail-2) DATV reception tool for the Genpix SkyWalker-1.
|
||||
|
||||
Provides frequency calculation, band plan display, wideband transponder
|
||||
scanning, tuning, and live video piping for QO-100 amateur television
|
||||
signals received via the SkyWalker-1 DVB-S demodulator.
|
||||
|
||||
QO-100 wideband transponder: 10491-10499 MHz (DVB-S QPSK, various SRs)
|
||||
Narrowband transponder: 10489.5-10490 MHz (SSB/CW, not demodulable)
|
||||
Engineering beacon: 10489.75 MHz (CW)
|
||||
|
||||
The BCM4500 demodulator has a minimum symbol rate of 256 ksps. QO-100
|
||||
DATV signals typically range from 333 ksps to 2000 ksps, well within
|
||||
the hardware capability. Signals below 256 ksps are detectable as
|
||||
energy via spectrum sweep but cannot be locked/demodulated.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import time
|
||||
import signal as signal_mod
|
||||
import subprocess
|
||||
|
||||
# Add tools directory to path for library import
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from skywalker_lib import (
|
||||
SkyWalker1, MODULATIONS, FEC_RATES, MOD_FEC_GROUP,
|
||||
rf_to_if, if_to_rf, detect_peaks, signal_bar,
|
||||
)
|
||||
|
||||
|
||||
# --- QO-100 constants ---
|
||||
|
||||
QO100_WB_START_MHZ = 10491.0 # wideband transponder start
|
||||
QO100_WB_STOP_MHZ = 10499.0 # wideband transponder stop
|
||||
QO100_BEACON_MHZ = 10489.75 # engineering beacon
|
||||
QO100_ORBITAL_POS = 25.9 # degrees East (Es'hail-2)
|
||||
QO100_NB_START_MHZ = 10489.5 # narrowband transponder start
|
||||
QO100_NB_STOP_MHZ = 10490.0 # narrowband transponder stop
|
||||
BCM4500_MIN_SR_KSPS = 256 # hardware minimum
|
||||
|
||||
# Common modified LNB local oscillators used for QO-100 reception
|
||||
COMMON_QO100_LOS = {
|
||||
9361: "Modified PLL LNB (TCXO, popular)",
|
||||
9000: "Round LO",
|
||||
9100: "Round LO",
|
||||
9200: "Round LO",
|
||||
9300: "Round LO",
|
||||
9750: "Standard universal (low band)",
|
||||
}
|
||||
|
||||
# Known DATV stations / frequencies on QO-100 wideband transponder
|
||||
QO100_KNOWN_STATIONS = [
|
||||
{"call": "BATC", "freq_mhz": 10491.5, "sr_ksps": 1500, "mod": "qpsk", "fec": "3/4", "note": "British ATV Club beacon"},
|
||||
{"call": "Various", "freq_mhz": 10492.0, "sr_ksps": 1000, "mod": "qpsk", "fec": "1/2", "note": "Common DATV frequency"},
|
||||
{"call": "Various", "freq_mhz": 10493.0, "sr_ksps": 500, "mod": "qpsk", "fec": "1/2", "note": "Low-power DATV"},
|
||||
{"call": "Various", "freq_mhz": 10494.0, "sr_ksps": 333, "mod": "qpsk", "fec": "1/2", "note": "Minimum viable DVB-S"},
|
||||
{"call": "Beacon", "freq_mhz": 10489.75, "sr_ksps": 0, "mod": "cw", "fec": "-", "note": "Engineering beacon (CW)"},
|
||||
]
|
||||
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
def validate_qo100_lo(lnb_lo_mhz: int) -> dict:
|
||||
"""
|
||||
Check whether a given LNB LO places the QO-100 wideband transponder
|
||||
within the SkyWalker-1 IF range (950-2150 MHz).
|
||||
|
||||
Returns dict with:
|
||||
valid - bool, True if entire WB transponder fits in IF range
|
||||
if_range - (start_if, stop_if) tuple in MHz
|
||||
warnings - list of warning strings
|
||||
"""
|
||||
start_if = rf_to_if(QO100_WB_START_MHZ, lnb_lo_mhz)
|
||||
stop_if = rf_to_if(QO100_WB_STOP_MHZ, lnb_lo_mhz)
|
||||
warnings = []
|
||||
|
||||
if start_if < 950:
|
||||
warnings.append(f"WB start IF {start_if:.0f} MHz is below 950 MHz minimum")
|
||||
if stop_if > 2150:
|
||||
warnings.append(f"WB stop IF {stop_if:.0f} MHz is above 2150 MHz maximum")
|
||||
if start_if < 0 or stop_if < 0:
|
||||
warnings.append(f"Negative IF -- LNB LO {lnb_lo_mhz} MHz is above the RF frequency")
|
||||
|
||||
# Check if LO is a known value
|
||||
if lnb_lo_mhz not in COMMON_QO100_LOS:
|
||||
nearby = [lo for lo in COMMON_QO100_LOS if abs(lo - lnb_lo_mhz) <= 100]
|
||||
if not nearby:
|
||||
warnings.append(f"LO {lnb_lo_mhz} MHz is not a common QO-100 value")
|
||||
|
||||
valid = (950 <= start_if) and (stop_if <= 2150)
|
||||
|
||||
return {
|
||||
"valid": valid,
|
||||
"if_range": (start_if, stop_if),
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def qo100_if_range(lnb_lo_mhz: float) -> tuple:
|
||||
"""Return (start_if, stop_if) in MHz for the QO-100 WB transponder."""
|
||||
return (
|
||||
rf_to_if(QO100_WB_START_MHZ, lnb_lo_mhz),
|
||||
rf_to_if(QO100_WB_STOP_MHZ, lnb_lo_mhz),
|
||||
)
|
||||
|
||||
|
||||
def qo100_band_plan(lnb_lo_mhz: float) -> list:
|
||||
"""
|
||||
Return the QO-100 known station list augmented with IF frequencies
|
||||
and lockability status for the given LNB LO.
|
||||
|
||||
Each entry is a dict with keys:
|
||||
call, freq_mhz (RF), if_mhz, sr_ksps, mod, fec, note, lockable
|
||||
"""
|
||||
plan = []
|
||||
for station in QO100_KNOWN_STATIONS:
|
||||
if_mhz = rf_to_if(station["freq_mhz"], lnb_lo_mhz)
|
||||
lockable = (
|
||||
station["sr_ksps"] >= BCM4500_MIN_SR_KSPS
|
||||
and station["mod"] in MODULATIONS
|
||||
and 950 <= if_mhz <= 2150
|
||||
)
|
||||
plan.append({
|
||||
"call": station["call"],
|
||||
"freq_mhz": station["freq_mhz"],
|
||||
"if_mhz": if_mhz,
|
||||
"sr_ksps": station["sr_ksps"],
|
||||
"mod": station["mod"],
|
||||
"fec": station["fec"],
|
||||
"note": station["note"],
|
||||
"lockable": lockable,
|
||||
})
|
||||
return plan
|
||||
|
||||
|
||||
def _resolve_fec(mod_name: str, fec_name: str) -> int:
|
||||
"""Look up the FEC index for a given modulation and FEC rate string."""
|
||||
fec_group = MOD_FEC_GROUP.get(mod_name)
|
||||
if fec_group is None:
|
||||
print(f"Unknown modulation: {mod_name}")
|
||||
sys.exit(1)
|
||||
fec_table = FEC_RATES[fec_group]
|
||||
if fec_name not in fec_table:
|
||||
print(f"Invalid FEC '{fec_name}' for {mod_name}")
|
||||
print(f"Valid: {', '.join(fec_table.keys())}")
|
||||
sys.exit(1)
|
||||
return fec_table[fec_name]
|
||||
|
||||
|
||||
def _print_lo_info(lnb_lo: float, verbose: bool = False) -> None:
|
||||
"""Print LNB LO validation summary."""
|
||||
lo_desc = COMMON_QO100_LOS.get(int(lnb_lo), "custom")
|
||||
print(f" LNB LO: {lnb_lo:.0f} MHz ({lo_desc})")
|
||||
|
||||
check = validate_qo100_lo(lnb_lo)
|
||||
start_if, stop_if = check["if_range"]
|
||||
print(f" WB IF range: {start_if:.1f} - {stop_if:.1f} MHz")
|
||||
|
||||
if not check["valid"]:
|
||||
print(f" WARNING: QO-100 WB transponder does not fit in IF range!")
|
||||
for w in check["warnings"]:
|
||||
print(f" WARNING: {w}")
|
||||
|
||||
if verbose:
|
||||
beacon_if = rf_to_if(QO100_BEACON_MHZ, lnb_lo)
|
||||
nb_start_if = rf_to_if(QO100_NB_START_MHZ, lnb_lo)
|
||||
nb_stop_if = rf_to_if(QO100_NB_STOP_MHZ, lnb_lo)
|
||||
print(f" Beacon IF: {beacon_if:.2f} MHz (CW, not demodulable)")
|
||||
print(f" NB IF range: {nb_start_if:.1f} - {nb_stop_if:.1f} MHz (SSB/CW)")
|
||||
|
||||
|
||||
# --- Subcommand handlers ---
|
||||
|
||||
def cmd_calc(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Show IF frequencies for a given LNB LO."""
|
||||
lnb_lo = args.lnb_lo
|
||||
|
||||
print(f"QO-100 IF Frequency Calculator")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Satellite: Es'hail-2 (QO-100) at {QO100_ORBITAL_POS} deg E")
|
||||
_print_lo_info(lnb_lo, verbose=True)
|
||||
|
||||
print(f"\n {'RF (MHz)':>12} {'IF (MHz)':>10} {'Description'}")
|
||||
print(f" {'─' * 50}")
|
||||
|
||||
entries = [
|
||||
(QO100_NB_START_MHZ, "Narrowband start"),
|
||||
(QO100_BEACON_MHZ, "Engineering beacon (CW)"),
|
||||
(QO100_NB_STOP_MHZ, "Narrowband stop"),
|
||||
(QO100_WB_START_MHZ, "Wideband start"),
|
||||
(QO100_WB_STOP_MHZ, "Wideband stop"),
|
||||
]
|
||||
|
||||
for rf, desc in entries:
|
||||
if_mhz = rf_to_if(rf, lnb_lo)
|
||||
in_range = 950 <= if_mhz <= 2150
|
||||
marker = "" if in_range else " [OUT OF RANGE]"
|
||||
print(f" {rf:12.2f} {if_mhz:10.2f} {desc}{marker}")
|
||||
|
||||
# Common LO comparison table
|
||||
print(f"\n Common LNB LO comparison:")
|
||||
print(f" {'LO (MHz)':>10} {'WB Start IF':>12} {'WB Stop IF':>12} {'Description'}")
|
||||
print(f" {'─' * 60}")
|
||||
for lo, desc in sorted(COMMON_QO100_LOS.items()):
|
||||
s_if = rf_to_if(QO100_WB_START_MHZ, lo)
|
||||
e_if = rf_to_if(QO100_WB_STOP_MHZ, lo)
|
||||
fits = 950 <= s_if and e_if <= 2150
|
||||
status = "" if fits else " [!]"
|
||||
current = " <--" if lo == int(lnb_lo) else ""
|
||||
print(f" {lo:10d} {s_if:12.1f} {e_if:12.1f} {desc}{status}{current}")
|
||||
|
||||
|
||||
def cmd_band_plan(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Show known QO-100 stations with IF conversion for this LNB LO."""
|
||||
lnb_lo = args.lnb_lo
|
||||
|
||||
print(f"QO-100 Wideband Transponder Band Plan")
|
||||
print(f"{'=' * 60}")
|
||||
_print_lo_info(lnb_lo, verbose=args.verbose)
|
||||
print()
|
||||
|
||||
plan = qo100_band_plan(lnb_lo)
|
||||
|
||||
# Table header
|
||||
hdr = (f" {'Call':8s} {'RF MHz':>9s} {'IF MHz':>9s} "
|
||||
f"{'SR ksps':>8s} {'Mod':5s} {'FEC':4s} {'Lock':4s} Note")
|
||||
print(hdr)
|
||||
print(f" {'─' * 76}")
|
||||
|
||||
for entry in plan:
|
||||
sr_str = f"{entry['sr_ksps']:>5d}" if entry["sr_ksps"] > 0 else " n/a"
|
||||
if entry["lockable"]:
|
||||
lock_str = " yes"
|
||||
elif entry["sr_ksps"] == 0:
|
||||
lock_str = " --"
|
||||
elif entry["sr_ksps"] < BCM4500_MIN_SR_KSPS:
|
||||
lock_str = " no"
|
||||
elif entry["mod"] not in MODULATIONS:
|
||||
lock_str = " no"
|
||||
else:
|
||||
lock_str = " no"
|
||||
|
||||
in_range = 950 <= entry["if_mhz"] <= 2150
|
||||
if_str = f"{entry['if_mhz']:9.2f}" if in_range else f"{entry['if_mhz']:7.2f} !"
|
||||
|
||||
print(f" {entry['call']:8s} {entry['freq_mhz']:9.2f} {if_str} "
|
||||
f"{sr_str:>8s} {entry['mod']:5s} {entry['fec']:4s} {lock_str} "
|
||||
f"{entry['note']}")
|
||||
|
||||
# Legend
|
||||
print(f"\n Lock column: yes = lockable by BCM4500 (SR >= {BCM4500_MIN_SR_KSPS} ksps, "
|
||||
f"supported mod, IF in range)")
|
||||
print(f" no = detectable as energy but not demodulable")
|
||||
print(f" -- = not a digital signal (CW/SSB)")
|
||||
|
||||
|
||||
def cmd_scan(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Scan the QO-100 wideband transponder for active carriers."""
|
||||
lnb_lo = args.lnb_lo
|
||||
step_mhz = args.step
|
||||
dwell_ms = args.dwell
|
||||
|
||||
# Validate LO
|
||||
check = validate_qo100_lo(lnb_lo)
|
||||
if not check["valid"]:
|
||||
print(f"ERROR: QO-100 WB transponder does not fit in IF range with LO {lnb_lo} MHz")
|
||||
for w in check["warnings"]:
|
||||
print(f" {w}")
|
||||
sys.exit(1)
|
||||
|
||||
start_if, stop_if = check["if_range"]
|
||||
|
||||
# QO-100 optimized sweep parameters
|
||||
# Low symbol rates need longer dwell, finer steps, lower measurement SR
|
||||
sr_ksps = 1000 # lower SR for better sensitivity to narrow signals
|
||||
|
||||
steps = int((stop_if - start_if) / step_mhz) + 1
|
||||
est_time = steps * (dwell_ms + 5) / 1000.0
|
||||
|
||||
print(f"QO-100 Wideband Transponder Scan")
|
||||
print(f"{'=' * 60}")
|
||||
_print_lo_info(lnb_lo, verbose=args.verbose)
|
||||
print(f" RF range: {QO100_WB_START_MHZ:.1f} - {QO100_WB_STOP_MHZ:.1f} MHz")
|
||||
print(f" IF range: {start_if:.1f} - {stop_if:.1f} MHz")
|
||||
print(f" Step: {step_mhz} MHz ({steps} points)")
|
||||
print(f" Dwell: {dwell_ms} ms")
|
||||
print(f" Meas SR: {sr_ksps} ksps")
|
||||
print(f" Est. time: {est_time:.1f}s")
|
||||
print()
|
||||
|
||||
sw.ensure_booted()
|
||||
|
||||
# Sweep the wideband transponder IF range
|
||||
print("[1/3] Sweeping wideband transponder...")
|
||||
|
||||
def progress(freq, step_num, total, result):
|
||||
pct = (step_num + 1) / total * 100
|
||||
rf = if_to_rf(freq, lnb_lo)
|
||||
sys.stdout.write(f"\r [{pct:5.1f}%] IF {freq:.1f} MHz RF {rf:.1f} MHz"
|
||||
f" pwr={result['power_db']:.1f} dB"
|
||||
f" AGC={result['agc1']}")
|
||||
sys.stdout.flush()
|
||||
|
||||
freqs, powers, results = sw.sweep_spectrum(
|
||||
start_if, stop_if, step_mhz, dwell_ms, sr_ksps,
|
||||
callback=progress if not args.verbose else None
|
||||
)
|
||||
sys.stdout.write("\r" + " " * 70 + "\r")
|
||||
print(f" {len(freqs)} points measured")
|
||||
|
||||
# Peak detection
|
||||
print(f"\n[2/3] Peak detection (threshold {args.threshold:.0f} dB)...")
|
||||
peaks = detect_peaks(freqs, powers, threshold_db=args.threshold)
|
||||
|
||||
if not peaks:
|
||||
print(" No carriers detected above noise floor.")
|
||||
print(" Check dish alignment, LNB LO, and that the transponder is active.")
|
||||
return
|
||||
|
||||
print(f" {len(peaks)} carrier(s) detected:")
|
||||
print()
|
||||
print(f" {'IF MHz':>8s} {'RF MHz':>10s} {'Power dB':>9s} Nearest known station")
|
||||
print(f" {'─' * 55}")
|
||||
|
||||
for freq_if, pwr, idx in peaks:
|
||||
freq_rf = if_to_rf(freq_if, lnb_lo)
|
||||
|
||||
# Match to nearest known station
|
||||
nearest = None
|
||||
nearest_dist = 999
|
||||
for station in QO100_KNOWN_STATIONS:
|
||||
dist = abs(station["freq_mhz"] - freq_rf)
|
||||
if dist < nearest_dist:
|
||||
nearest_dist = dist
|
||||
nearest = station
|
||||
|
||||
match_str = ""
|
||||
if nearest and nearest_dist < 1.0:
|
||||
lockable = nearest["sr_ksps"] >= BCM4500_MIN_SR_KSPS
|
||||
lock_note = "" if lockable else " [below min SR]"
|
||||
match_str = (f"{nearest['call']} ({nearest['sr_ksps']} ksps "
|
||||
f"{nearest['mod']} {nearest['fec']}){lock_note}")
|
||||
elif nearest and nearest_dist < 2.0:
|
||||
match_str = f"near {nearest['call']} ({nearest_dist:.1f} MHz off)"
|
||||
|
||||
print(f" {freq_if:8.1f} {freq_rf:10.2f} {pwr:9.1f} {match_str}")
|
||||
|
||||
# Try locking each peak that could be a DATV signal
|
||||
print(f"\n[3/3] Attempting lock on detected carriers...")
|
||||
locked_count = 0
|
||||
|
||||
for freq_if, pwr, idx in peaks:
|
||||
freq_rf = if_to_rf(freq_if, lnb_lo)
|
||||
if_khz = int(freq_if * 1000)
|
||||
|
||||
# Try common QO-100 symbol rates, highest first
|
||||
trial_srs = [1500, 1000, 500, 333, 256]
|
||||
|
||||
for sr in trial_srs:
|
||||
if sr < BCM4500_MIN_SR_KSPS:
|
||||
continue
|
||||
|
||||
sr_sps = sr * 1000
|
||||
mod_index, _ = MODULATIONS["qpsk"]
|
||||
fec_group = MOD_FEC_GROUP["qpsk"]
|
||||
fec_index = FEC_RATES[fec_group]["auto"]
|
||||
|
||||
if args.verbose:
|
||||
print(f" Trying {freq_rf:.2f} MHz SR {sr} ksps...", end="", flush=True)
|
||||
|
||||
sw.tune(sr_sps, if_khz, mod_index, fec_index)
|
||||
time.sleep(0.3)
|
||||
|
||||
if sw.get_signal_lock():
|
||||
sig = sw.get_signal_strength()
|
||||
print(f" LOCKED {freq_rf:.2f} MHz SR {sr} ksps "
|
||||
f"SNR {sig['snr_db']:.1f} dB {signal_bar(sig['snr_pct'], width=20)}")
|
||||
locked_count += 1
|
||||
break
|
||||
elif args.verbose:
|
||||
print(f" no lock")
|
||||
|
||||
print(f"\n Scan complete: {len(peaks)} carriers detected, {locked_count} locked")
|
||||
|
||||
|
||||
def cmd_tune(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Tune to a specific QO-100 frequency."""
|
||||
lnb_lo = args.lnb_lo
|
||||
freq_rf = args.freq
|
||||
sr_ksps = args.sr
|
||||
mod_name = args.mod
|
||||
fec_name = args.fec
|
||||
|
||||
# Validate
|
||||
if sr_ksps < BCM4500_MIN_SR_KSPS:
|
||||
print(f"ERROR: Symbol rate {sr_ksps} ksps is below BCM4500 minimum ({BCM4500_MIN_SR_KSPS} ksps)")
|
||||
sys.exit(1)
|
||||
|
||||
if mod_name not in MODULATIONS:
|
||||
print(f"Unknown modulation: {mod_name}")
|
||||
print(f"Valid: {', '.join(MODULATIONS.keys())}")
|
||||
sys.exit(1)
|
||||
|
||||
mod_index, mod_desc = MODULATIONS[mod_name]
|
||||
fec_index = _resolve_fec(mod_name, fec_name)
|
||||
|
||||
if_mhz = rf_to_if(freq_rf, lnb_lo)
|
||||
if_khz = int(if_mhz * 1000)
|
||||
sr_sps = sr_ksps * 1000
|
||||
|
||||
if if_khz < 950000 or if_khz > 2150000:
|
||||
print(f"ERROR: IF frequency {if_mhz:.1f} MHz is outside 950-2150 MHz range")
|
||||
print(f" RF: {freq_rf} MHz, LNB LO: {lnb_lo} MHz")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"QO-100 Tune")
|
||||
print(f"{'=' * 60}")
|
||||
_print_lo_info(lnb_lo, verbose=args.verbose)
|
||||
print(f" RF Frequency: {freq_rf} MHz")
|
||||
print(f" IF Frequency: {if_mhz:.2f} MHz ({if_khz} kHz)")
|
||||
print(f" Symbol Rate: {sr_ksps} ksps ({sr_sps} sps)")
|
||||
print(f" Modulation: {mod_desc}")
|
||||
print(f" FEC: {fec_name} (index {fec_index})")
|
||||
print()
|
||||
|
||||
sw.ensure_booted()
|
||||
|
||||
# Tune
|
||||
print("Sending tune command...", end="", flush=True)
|
||||
sw.tune(sr_sps, if_khz, mod_index, fec_index)
|
||||
print(" done")
|
||||
|
||||
# Wait for lock
|
||||
timeout = args.timeout
|
||||
print(f"Waiting for lock (timeout {timeout}s)...", end="", flush=True)
|
||||
deadline = time.time() + timeout
|
||||
locked = False
|
||||
dots = 0
|
||||
|
||||
while time.time() < deadline:
|
||||
if sw.get_signal_lock():
|
||||
locked = True
|
||||
break
|
||||
print(".", end="", flush=True)
|
||||
dots += 1
|
||||
time.sleep(0.5)
|
||||
|
||||
print()
|
||||
|
||||
if locked:
|
||||
sig = sw.get_signal_strength()
|
||||
print(f"\n LOCKED")
|
||||
print(f" SNR: {sig['snr_db']:.1f} dB (raw 0x{sig['snr_raw']:04X})")
|
||||
print(f" Quality: {signal_bar(sig['snr_pct'])}")
|
||||
else:
|
||||
print(f"\n NO LOCK after {timeout}s")
|
||||
print(f" Possible causes:")
|
||||
print(f" - No signal at {freq_rf} MHz (station may be off-air)")
|
||||
print(f" - Wrong symbol rate (try scanning first)")
|
||||
print(f" - Dish not aligned to {QO100_ORBITAL_POS} deg E")
|
||||
print(f" - LNB LO mismatch (expected {lnb_lo} MHz)")
|
||||
|
||||
|
||||
def cmd_watch(sw: SkyWalker1, args: argparse.Namespace) -> None:
|
||||
"""Tune to a QO-100 frequency and pipe the transport stream to a video player."""
|
||||
lnb_lo = args.lnb_lo
|
||||
freq_rf = args.freq
|
||||
sr_ksps = args.sr
|
||||
mod_name = args.mod
|
||||
fec_name = args.fec
|
||||
player_cmd = args.player
|
||||
|
||||
# Validate
|
||||
if sr_ksps < BCM4500_MIN_SR_KSPS:
|
||||
print(f"ERROR: Symbol rate {sr_ksps} ksps is below BCM4500 minimum ({BCM4500_MIN_SR_KSPS} ksps)")
|
||||
sys.exit(1)
|
||||
|
||||
if mod_name not in MODULATIONS:
|
||||
print(f"Unknown modulation: {mod_name}")
|
||||
print(f"Valid: {', '.join(MODULATIONS.keys())}")
|
||||
sys.exit(1)
|
||||
|
||||
mod_index, mod_desc = MODULATIONS[mod_name]
|
||||
fec_index = _resolve_fec(mod_name, fec_name)
|
||||
|
||||
if_mhz = rf_to_if(freq_rf, lnb_lo)
|
||||
if_khz = int(if_mhz * 1000)
|
||||
sr_sps = sr_ksps * 1000
|
||||
|
||||
if if_khz < 950000 or if_khz > 2150000:
|
||||
print(f"ERROR: IF frequency {if_mhz:.1f} MHz is outside 950-2150 MHz range")
|
||||
print(f" RF: {freq_rf} MHz, LNB LO: {lnb_lo} MHz")
|
||||
sys.exit(1)
|
||||
|
||||
# Status messages go to stderr so stdout is clean for piping
|
||||
status = sys.stderr
|
||||
|
||||
status.write(f"QO-100 Watch\n")
|
||||
status.write(f"{'=' * 60}\n")
|
||||
status.write(f" RF Frequency: {freq_rf} MHz\n")
|
||||
status.write(f" IF Frequency: {if_mhz:.2f} MHz\n")
|
||||
status.write(f" Symbol Rate: {sr_ksps} ksps\n")
|
||||
status.write(f" Modulation: {mod_desc}\n")
|
||||
status.write(f" FEC: {fec_name}\n")
|
||||
if player_cmd:
|
||||
status.write(f" Player: {player_cmd}\n")
|
||||
else:
|
||||
status.write(f" Output: stdout (pipe to player)\n")
|
||||
status.write(f"\n")
|
||||
status.flush()
|
||||
|
||||
sw.ensure_booted()
|
||||
|
||||
# Tune and wait for lock
|
||||
status.write("Tuning...\n")
|
||||
status.flush()
|
||||
sw.tune(sr_sps, if_khz, mod_index, fec_index)
|
||||
|
||||
timeout = args.timeout
|
||||
deadline = time.time() + timeout
|
||||
locked = False
|
||||
|
||||
while time.time() < deadline:
|
||||
if sw.get_signal_lock():
|
||||
locked = True
|
||||
break
|
||||
time.sleep(0.3)
|
||||
|
||||
if not locked:
|
||||
status.write(f"NO LOCK after {timeout}s -- aborting\n")
|
||||
status.flush()
|
||||
sys.exit(1)
|
||||
|
||||
sig = sw.get_signal_strength()
|
||||
status.write(f"LOCKED SNR {sig['snr_db']:.1f} dB {signal_bar(sig['snr_pct'], width=20)}\n")
|
||||
status.flush()
|
||||
|
||||
# Open player subprocess or use stdout
|
||||
player_proc = None
|
||||
output_fd = None
|
||||
|
||||
if player_cmd:
|
||||
try:
|
||||
player_proc = subprocess.Popen(
|
||||
player_cmd, shell=True,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
output_fd = player_proc.stdin
|
||||
status.write(f"Player started (PID {player_proc.pid})\n")
|
||||
status.flush()
|
||||
except OSError as e:
|
||||
status.write(f"Failed to start player: {e}\n")
|
||||
status.flush()
|
||||
sys.exit(1)
|
||||
else:
|
||||
output_fd = sys.stdout.buffer
|
||||
|
||||
# Stream
|
||||
sw.arm_transfer(on=True)
|
||||
status.write("Streaming...\n")
|
||||
status.flush()
|
||||
|
||||
total_bytes = 0
|
||||
start_time = time.time()
|
||||
last_status = start_time
|
||||
running = True
|
||||
|
||||
def stop_handler(signum, frame):
|
||||
nonlocal running
|
||||
running = False
|
||||
|
||||
signal_mod.signal(signal_mod.SIGINT, stop_handler)
|
||||
signal_mod.signal(signal_mod.SIGTERM, stop_handler)
|
||||
|
||||
try:
|
||||
while running:
|
||||
# Check if player is still alive
|
||||
if player_proc and player_proc.poll() is not None:
|
||||
status.write(f"\nPlayer exited (code {player_proc.returncode})\n")
|
||||
status.flush()
|
||||
break
|
||||
|
||||
chunk = sw.read_stream(timeout=2000)
|
||||
if chunk:
|
||||
try:
|
||||
output_fd.write(chunk)
|
||||
output_fd.flush()
|
||||
total_bytes += len(chunk)
|
||||
except BrokenPipeError:
|
||||
status.write("\nPipe closed\n")
|
||||
status.flush()
|
||||
break
|
||||
|
||||
now = time.time()
|
||||
if now - last_status >= 2.0:
|
||||
elapsed = now - start_time
|
||||
bitrate = (total_bytes * 8) / elapsed if elapsed > 0 else 0
|
||||
if bitrate >= 1e6:
|
||||
rate_str = f"{bitrate / 1e6:.2f} Mbps"
|
||||
else:
|
||||
rate_str = f"{bitrate / 1e3:.1f} kbps"
|
||||
|
||||
# Quick signal check
|
||||
still_locked = sw.get_signal_lock()
|
||||
lock_str = "LOCK" if still_locked else "----"
|
||||
|
||||
status.write(f"\r [{lock_str}] {total_bytes:,} bytes "
|
||||
f"{rate_str} ({elapsed:.0f}s) ")
|
||||
status.flush()
|
||||
last_status = now
|
||||
|
||||
finally:
|
||||
sw.arm_transfer(on=False)
|
||||
if player_proc:
|
||||
player_proc.terminate()
|
||||
player_proc.wait(timeout=5)
|
||||
status.write(f"\n Stopped. Total: {total_bytes:,} bytes\n")
|
||||
status.flush()
|
||||
|
||||
|
||||
# --- CLI ---
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="qo100.py",
|
||||
description="QO-100 (Es'hail-2) DATV reception tool for the SkyWalker-1",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
examples:
|
||||
%(prog)s calc --lnb-lo 9750
|
||||
%(prog)s calc --lnb-lo 9361
|
||||
%(prog)s band-plan --lnb-lo 9750
|
||||
%(prog)s scan --lnb-lo 9750
|
||||
%(prog)s scan --lnb-lo 9361 --step 0.25 --dwell 100
|
||||
%(prog)s tune --lnb-lo 9750 --freq 10491.5 --sr 1500
|
||||
%(prog)s tune --lnb-lo 9750 --freq 10491.5 --sr 1500 --fec 3/4
|
||||
%(prog)s watch --lnb-lo 9750 --freq 10491.5 --sr 1500 --player "ffplay -f mpegts -i pipe:0"
|
||||
%(prog)s watch --lnb-lo 9750 --freq 10491.5 --sr 1500 --player "mpv -"
|
||||
%(prog)s watch --lnb-lo 9750 --freq 10491.5 --sr 1500 | vlc -
|
||||
|
||||
QO-100 wideband transponder: 10491-10499 MHz (DVB-S QPSK, various SRs)
|
||||
BCM4500 minimum symbol rate: 256 ksps
|
||||
Common LNB LOs: 9750 (universal), 9361 (TCXO PLL, popular for QO-100)
|
||||
|
||||
The --lnb-lo parameter is required for all commands. It must match your
|
||||
LNB's actual local oscillator frequency for correct IF calculation.
|
||||
""")
|
||||
parser.add_argument('-v', '--verbose', action='store_true',
|
||||
help="Verbose output (USB traffic, extra detail)")
|
||||
|
||||
sub = parser.add_subparsers(dest='command')
|
||||
|
||||
# calc
|
||||
p_calc = sub.add_parser('calc',
|
||||
help="Show IF frequencies for a given LNB LO",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p_calc.add_argument('--lnb-lo', type=float, required=True,
|
||||
help="LNB local oscillator frequency in MHz")
|
||||
|
||||
# band-plan
|
||||
p_bp = sub.add_parser('band-plan',
|
||||
help="Show known QO-100 stations with IF conversion",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p_bp.add_argument('--lnb-lo', type=float, required=True,
|
||||
help="LNB local oscillator frequency in MHz")
|
||||
|
||||
# scan
|
||||
p_scan = sub.add_parser('scan',
|
||||
help="Scan wideband transponder for active carriers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p_scan.add_argument('--lnb-lo', type=float, required=True,
|
||||
help="LNB local oscillator frequency in MHz")
|
||||
p_scan.add_argument('--step', type=float, default=0.5,
|
||||
help="Frequency step in MHz (default: 0.5, finer for low-SR)")
|
||||
p_scan.add_argument('--dwell', type=int, default=75,
|
||||
help="Dwell time per step in ms (default: 75, longer for sensitivity)")
|
||||
p_scan.add_argument('--threshold', type=float, default=3.0,
|
||||
help="Peak detection threshold in dB (default: 3.0)")
|
||||
|
||||
# tune
|
||||
p_tune = sub.add_parser('tune',
|
||||
help="Tune to a specific QO-100 frequency",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p_tune.add_argument('--lnb-lo', type=float, required=True,
|
||||
help="LNB local oscillator frequency in MHz")
|
||||
p_tune.add_argument('--freq', type=float, required=True,
|
||||
help="RF frequency in MHz (e.g. 10491.5)")
|
||||
p_tune.add_argument('--sr', type=int, required=True,
|
||||
help="Symbol rate in ksps (e.g. 1500)")
|
||||
p_tune.add_argument('--mod', default='qpsk',
|
||||
choices=list(MODULATIONS.keys()),
|
||||
help="Modulation type (default: qpsk)")
|
||||
p_tune.add_argument('--fec', default='auto',
|
||||
help="FEC rate (default: auto)")
|
||||
p_tune.add_argument('--timeout', type=float, default=10,
|
||||
help="Lock timeout in seconds (default: 10)")
|
||||
|
||||
# watch
|
||||
p_watch = sub.add_parser('watch',
|
||||
help="Tune and pipe transport stream to video player",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
Watch pipes the raw MPEG-2 transport stream to a video player or stdout.
|
||||
Status output goes to stderr so the TS data on stdout stays clean.
|
||||
|
||||
Player examples:
|
||||
--player "ffplay -f mpegts -i pipe:0"
|
||||
--player "mpv --demuxer=lavf -"
|
||||
--player "vlc --demux ts -"
|
||||
|
||||
Without --player, the TS stream is written to stdout for shell piping:
|
||||
qo100.py watch --lnb-lo 9750 --freq 10491.5 --sr 1500 | vlc -
|
||||
""")
|
||||
p_watch.add_argument('--lnb-lo', type=float, required=True,
|
||||
help="LNB local oscillator frequency in MHz")
|
||||
p_watch.add_argument('--freq', type=float, required=True,
|
||||
help="RF frequency in MHz (e.g. 10491.5)")
|
||||
p_watch.add_argument('--sr', type=int, required=True,
|
||||
help="Symbol rate in ksps (e.g. 1500)")
|
||||
p_watch.add_argument('--mod', default='qpsk',
|
||||
choices=list(MODULATIONS.keys()),
|
||||
help="Modulation type (default: qpsk)")
|
||||
p_watch.add_argument('--fec', default='auto',
|
||||
help="FEC rate (default: auto)")
|
||||
p_watch.add_argument('--player', default=None,
|
||||
help="Player command (e.g. 'ffplay -f mpegts -i pipe:0')")
|
||||
p_watch.add_argument('--timeout', type=float, default=15,
|
||||
help="Lock timeout in seconds (default: 15)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
# calc and band-plan don't need the device
|
||||
if args.command in ('calc', 'band-plan'):
|
||||
dispatch = {
|
||||
'calc': cmd_calc,
|
||||
'band-plan': cmd_band_plan,
|
||||
}
|
||||
handler = dispatch[args.command]
|
||||
handler(None, args)
|
||||
return
|
||||
|
||||
dispatch = {
|
||||
'scan': cmd_scan,
|
||||
'tune': cmd_tune,
|
||||
'watch': cmd_watch,
|
||||
}
|
||||
|
||||
handler = dispatch.get(args.command)
|
||||
if handler is None:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
with SkyWalker1(verbose=args.verbose) as sw:
|
||||
handler(sw, args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
239
tools/signal_analysis.py
Normal file
239
tools/signal_analysis.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced signal analysis for carrier detection and characterization.
|
||||
|
||||
Goes beyond the basic detect_peaks() in skywalker_lib by using robust
|
||||
noise floor estimation (median + MAD), peak width measurement at -3dB,
|
||||
peak merging within estimated carrier bandwidth, and carrier classification.
|
||||
"""
|
||||
|
||||
import math
|
||||
import statistics
|
||||
|
||||
|
||||
def adaptive_noise_floor(powers: list) -> tuple:
|
||||
"""
|
||||
Robust noise floor estimation using median + MAD.
|
||||
|
||||
The median is insensitive to strong carriers in the sweep, and the
|
||||
Median Absolute Deviation (MAD) provides a robust spread measure
|
||||
that won't be pulled by a few dominant peaks.
|
||||
|
||||
Returns (noise_floor_db, mad_db).
|
||||
"""
|
||||
if not powers:
|
||||
return (0.0, 0.0)
|
||||
|
||||
median_power = statistics.median(powers)
|
||||
deviations = [abs(p - median_power) for p in powers]
|
||||
mad = statistics.median(deviations) if deviations else 0.0
|
||||
|
||||
# The noise floor sits at the median -- most bins are noise in a
|
||||
# typical satellite IF sweep. MAD gives us an idea of how "bumpy"
|
||||
# the noise is, useful for setting adaptive thresholds.
|
||||
return (median_power, mad)
|
||||
|
||||
|
||||
def detect_peaks_enhanced(freqs: list, powers: list,
|
||||
threshold_db: float = 6.0) -> list:
|
||||
"""
|
||||
Enhanced peak detection with width estimation and merging.
|
||||
|
||||
Returns list of dicts, each containing:
|
||||
freq - center frequency in MHz
|
||||
power - peak power in dB (relative)
|
||||
index - index into freqs/powers arrays
|
||||
width_mhz - estimated carrier bandwidth at -3dB
|
||||
prominence_db - peak power above noise floor
|
||||
|
||||
Steps:
|
||||
1. Compute adaptive noise floor (median + MAD)
|
||||
2. Find local maxima above noise_floor + threshold_db
|
||||
3. Estimate -3dB width around each peak
|
||||
4. Merge peaks whose -3dB extents overlap (same carrier)
|
||||
"""
|
||||
if len(powers) < 3 or len(freqs) != len(powers):
|
||||
return []
|
||||
|
||||
noise_floor, mad = adaptive_noise_floor(powers)
|
||||
# Effective threshold: user threshold, but never below 3x MAD to
|
||||
# avoid chasing noise ripples.
|
||||
effective_threshold = max(threshold_db, 3.0 * mad) if mad > 0 else threshold_db
|
||||
min_power = noise_floor + effective_threshold
|
||||
|
||||
# Step 1: find raw local maxima
|
||||
raw_peaks = []
|
||||
for i in range(1, len(powers) - 1):
|
||||
if powers[i] > powers[i - 1] and powers[i] > powers[i + 1]:
|
||||
if powers[i] >= min_power:
|
||||
raw_peaks.append(i)
|
||||
|
||||
# Also check endpoints if they are strong
|
||||
if len(powers) >= 2:
|
||||
if powers[0] > powers[1] and powers[0] >= min_power:
|
||||
raw_peaks.insert(0, 0)
|
||||
if powers[-1] > powers[-2] and powers[-1] >= min_power:
|
||||
raw_peaks.append(len(powers) - 1)
|
||||
|
||||
if not raw_peaks:
|
||||
return []
|
||||
|
||||
# Step 2: measure width and build peak dicts
|
||||
peaks = []
|
||||
for idx in raw_peaks:
|
||||
bw = estimate_carrier_bw(freqs, powers, idx)
|
||||
prominence = powers[idx] - noise_floor
|
||||
peaks.append({
|
||||
"freq": freqs[idx],
|
||||
"power": powers[idx],
|
||||
"index": idx,
|
||||
"width_mhz": bw,
|
||||
"prominence_db": prominence,
|
||||
})
|
||||
|
||||
# Step 3: merge overlapping peaks (keep the stronger one)
|
||||
merged = _merge_peaks(peaks)
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_peaks(peaks: list) -> list:
|
||||
"""
|
||||
Merge peaks whose -3dB extents overlap.
|
||||
|
||||
When two peaks are closer together than the sum of their half-widths
|
||||
they likely belong to the same carrier. Keep the stronger peak and
|
||||
take the wider bandwidth.
|
||||
"""
|
||||
if len(peaks) <= 1:
|
||||
return peaks
|
||||
|
||||
# Sort by frequency
|
||||
peaks = sorted(peaks, key=lambda p: p["freq"])
|
||||
merged = [peaks[0]]
|
||||
|
||||
for peak in peaks[1:]:
|
||||
prev = merged[-1]
|
||||
# Half-widths
|
||||
prev_upper = prev["freq"] + prev["width_mhz"] / 2
|
||||
peak_lower = peak["freq"] - peak["width_mhz"] / 2
|
||||
|
||||
if peak_lower <= prev_upper:
|
||||
# Overlap: keep the stronger peak, widen the bandwidth
|
||||
if peak["power"] > prev["power"]:
|
||||
wider = max(prev["width_mhz"], peak["width_mhz"],
|
||||
(peak["freq"] + peak["width_mhz"] / 2) -
|
||||
(prev["freq"] - prev["width_mhz"] / 2))
|
||||
peak["width_mhz"] = wider
|
||||
merged[-1] = peak
|
||||
else:
|
||||
wider = max(prev["width_mhz"], peak["width_mhz"],
|
||||
(peak["freq"] + peak["width_mhz"] / 2) -
|
||||
(prev["freq"] - prev["width_mhz"] / 2))
|
||||
prev["width_mhz"] = wider
|
||||
else:
|
||||
merged.append(peak)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def estimate_carrier_bw(freqs: list, powers: list,
|
||||
peak_idx: int) -> float:
|
||||
"""
|
||||
Estimate carrier bandwidth by walking from peak until power drops
|
||||
3 dB below the peak value (the -3dB bandwidth).
|
||||
|
||||
Walks left and right from the peak index, interpolating between
|
||||
adjacent frequency bins when the -3dB crossing falls between them.
|
||||
|
||||
Returns estimated bandwidth in MHz. Minimum return is one frequency
|
||||
step width (avoids zero-width artifacts on single-bin peaks).
|
||||
"""
|
||||
if peak_idx < 0 or peak_idx >= len(powers):
|
||||
return 0.0
|
||||
|
||||
peak_power = powers[peak_idx]
|
||||
cutoff = peak_power - 3.0
|
||||
|
||||
# Minimum step size for fallback
|
||||
if len(freqs) >= 2:
|
||||
step = abs(freqs[1] - freqs[0])
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
# Walk left
|
||||
left_freq = freqs[peak_idx]
|
||||
for i in range(peak_idx - 1, -1, -1):
|
||||
if powers[i] <= cutoff:
|
||||
# Interpolate between bin i and bin i+1
|
||||
if powers[i + 1] != powers[i]:
|
||||
frac = (cutoff - powers[i]) / (powers[i + 1] - powers[i])
|
||||
else:
|
||||
frac = 0.5
|
||||
left_freq = freqs[i] + frac * (freqs[i + 1] - freqs[i])
|
||||
break
|
||||
left_freq = freqs[i]
|
||||
|
||||
# Walk right
|
||||
right_freq = freqs[peak_idx]
|
||||
for i in range(peak_idx + 1, len(powers)):
|
||||
if powers[i] <= cutoff:
|
||||
if powers[i - 1] != powers[i]:
|
||||
frac = (cutoff - powers[i]) / (powers[i - 1] - powers[i])
|
||||
else:
|
||||
frac = 0.5
|
||||
right_freq = freqs[i] - frac * (freqs[i] - freqs[i - 1])
|
||||
break
|
||||
right_freq = freqs[i]
|
||||
|
||||
bw = right_freq - left_freq
|
||||
return max(bw, step)
|
||||
|
||||
|
||||
def classify_carrier(bw_mhz: float, power_db: float) -> dict:
|
||||
"""
|
||||
Classify a detected carrier based on bandwidth and power.
|
||||
|
||||
Uses empirical ranges for DVB-S symbol rates: SR (Msps) is roughly
|
||||
BW (MHz) / 1.35 for QPSK with roll-off 0.35.
|
||||
|
||||
Returns dict with:
|
||||
estimated_sr_range - (min_sps, max_sps) tuple
|
||||
likely_modulation - list of plausible modulation names
|
||||
signal_quality - 'strong', 'moderate', or 'weak'
|
||||
"""
|
||||
# Roll-off factor for DVB-S is typically 0.35, so BW ~ SR * 1.35.
|
||||
# Allow some tolerance on both sides.
|
||||
sr_center = bw_mhz / 1.35 # Msps
|
||||
sr_min = int(max(256_000, (bw_mhz / 1.5) * 1_000_000))
|
||||
sr_max = int(min(30_000_000, (bw_mhz / 1.2) * 1_000_000))
|
||||
if sr_max < sr_min:
|
||||
sr_max = sr_min
|
||||
|
||||
# Guess modulation based on bandwidth
|
||||
likely_mods = []
|
||||
if bw_mhz < 3.0:
|
||||
# Narrow carrier: low-SR data channels, SCPC, DCII split
|
||||
likely_mods = ["qpsk", "dcii-i", "dcii-q", "dss"]
|
||||
elif bw_mhz < 10.0:
|
||||
# Medium: typical SCPC, small MCPC
|
||||
likely_mods = ["qpsk", "turbo-qpsk", "dcii-combo"]
|
||||
elif bw_mhz < 20.0:
|
||||
# Wide: MCPC transponders
|
||||
likely_mods = ["qpsk", "turbo-qpsk", "turbo-8psk"]
|
||||
else:
|
||||
# Very wide: full transponder, high-SR
|
||||
likely_mods = ["qpsk", "turbo-qpsk", "turbo-8psk", "dcii-combo"]
|
||||
|
||||
# Signal quality heuristic (relative power, device-dependent)
|
||||
if power_db > -10.0:
|
||||
quality = "strong"
|
||||
elif power_db > -25.0:
|
||||
quality = "moderate"
|
||||
else:
|
||||
quality = "weak"
|
||||
|
||||
return {
|
||||
"estimated_sr_range": (sr_min, sr_max),
|
||||
"likely_modulation": likely_mods,
|
||||
"signal_quality": quality,
|
||||
}
|
||||
|
|
@ -59,6 +59,28 @@ CMD_SIGNAL_MONITOR = 0xB7
|
|||
CMD_TUNE_MONITOR = 0xB8
|
||||
CMD_MULTI_REG_READ = 0xB9
|
||||
|
||||
# Custom commands (v3.03+)
|
||||
CMD_PARAM_SWEEP = 0xBA
|
||||
CMD_ADAPTIVE_BLIND_SCAN = 0xBB
|
||||
CMD_GET_LAST_ERROR = 0xBC
|
||||
|
||||
# Error codes (returned by CMD_GET_LAST_ERROR)
|
||||
ERR_OK = 0x00
|
||||
ERR_I2C_TIMEOUT = 0x01
|
||||
ERR_I2C_NAK = 0x02
|
||||
ERR_I2C_ARB_LOST = 0x03
|
||||
ERR_BCM_NOT_READY = 0x04
|
||||
ERR_BCM_TIMEOUT = 0x05
|
||||
|
||||
ERROR_NAMES = {
|
||||
ERR_OK: "OK",
|
||||
ERR_I2C_TIMEOUT: "I2C timeout",
|
||||
ERR_I2C_NAK: "I2C NAK (no ACK from slave)",
|
||||
ERR_I2C_ARB_LOST: "I2C arbitration lost",
|
||||
ERR_BCM_NOT_READY: "BCM4500 not ready",
|
||||
ERR_BCM_TIMEOUT: "BCM4500 command timeout",
|
||||
}
|
||||
|
||||
# --- Config status bits ---
|
||||
|
||||
CONFIG_BITS = {
|
||||
|
|
@ -680,3 +702,196 @@ class SkyWalker1:
|
|||
return LNB_LO_HIGH
|
||||
else:
|
||||
return LNB_LO_LOW
|
||||
|
||||
# -- New commands (v3.03+) --
|
||||
|
||||
def get_last_error(self) -> int:
|
||||
"""Read last firmware error code (0xBC)."""
|
||||
data = self._vendor_in(CMD_GET_LAST_ERROR, length=1)
|
||||
return data[0]
|
||||
|
||||
def get_last_error_str(self) -> str:
|
||||
"""Read last firmware error code as human-readable string."""
|
||||
code = self.get_last_error()
|
||||
return ERROR_NAMES.get(code, f"Unknown (0x{code:02X})")
|
||||
|
||||
def param_sweep(self, start_khz: int, stop_khz: int, step_khz: int,
|
||||
sr_sps: int, mod_index: int = 0,
|
||||
fec_index: int = 5) -> bytes:
|
||||
"""
|
||||
Parameterized spectrum sweep (0xBA). Returns raw EP2 bulk data
|
||||
containing u16 LE power values, one per frequency step.
|
||||
"""
|
||||
payload = struct.pack('<IIHIB',
|
||||
start_khz, stop_khz, step_khz, sr_sps,
|
||||
mod_index)
|
||||
payload += bytes([fec_index])
|
||||
self._vendor_out(CMD_PARAM_SWEEP, data=payload)
|
||||
# Read results from EP2
|
||||
num_steps = ((stop_khz - start_khz) // step_khz) + 1
|
||||
expected_bytes = num_steps * 2
|
||||
result = b''
|
||||
while len(result) < expected_bytes:
|
||||
chunk = self.read_stream(size=min(8192, expected_bytes - len(result)),
|
||||
timeout=5000)
|
||||
if not chunk:
|
||||
break
|
||||
result += chunk
|
||||
return result
|
||||
|
||||
def adaptive_blind_scan(self, freq_khz: int, sr_min: int, sr_max: int,
|
||||
sr_step: int, quick_dwell_ms: int = 10) -> dict | None:
|
||||
"""
|
||||
Adaptive blind scan (0xBB) with AGC pre-check.
|
||||
Returns lock result dict or None if no lock found.
|
||||
"""
|
||||
payload = struct.pack('<IIIIH',
|
||||
freq_khz, sr_min, sr_max, sr_step, quick_dwell_ms)
|
||||
self._vendor_out(CMD_ADAPTIVE_BLIND_SCAN, data=payload)
|
||||
data = self._vendor_in(CMD_ADAPTIVE_BLIND_SCAN, length=8)
|
||||
if len(data) == 1 and data[0] == 0:
|
||||
return None
|
||||
freq = struct.unpack_from('<I', data, 0)[0]
|
||||
sr = struct.unpack_from('<I', data, 4)[0]
|
||||
return {"freq_khz": freq, "sr_sps": sr, "locked": True}
|
||||
|
||||
# -- DiSEqC 1.2 motor control --
|
||||
|
||||
def motor_halt(self) -> None:
|
||||
"""Stop motor movement immediately."""
|
||||
self.send_diseqc_message(diseqc_halt())
|
||||
|
||||
def motor_drive_east(self, steps: int = 0) -> None:
|
||||
"""Drive motor east. steps=0 for continuous, 1-127 for step count."""
|
||||
self.send_diseqc_message(diseqc_drive_east(steps))
|
||||
|
||||
def motor_drive_west(self, steps: int = 0) -> None:
|
||||
"""Drive motor west. steps=0 for continuous, 1-127 for step count."""
|
||||
self.send_diseqc_message(diseqc_drive_west(steps))
|
||||
|
||||
def motor_store_position(self, slot: int) -> None:
|
||||
"""Store current position in slot (0-255)."""
|
||||
self.send_diseqc_message(diseqc_store_position(slot))
|
||||
|
||||
def motor_goto_position(self, slot: int) -> None:
|
||||
"""Go to stored position slot (0-255). Slot 0 = reference/zero."""
|
||||
self.send_diseqc_message(diseqc_goto_position(slot))
|
||||
|
||||
def motor_goto_x(self, observer_lon: float, sat_lon: float) -> None:
|
||||
"""USALS GotoX: calculate and drive to satellite position."""
|
||||
self.send_diseqc_message(diseqc_goto_x(observer_lon, sat_lon))
|
||||
|
||||
def motor_set_limit(self, direction: str) -> None:
|
||||
"""Set soft limit at current position. direction: 'east' or 'west'."""
|
||||
self.send_diseqc_message(diseqc_set_limit(direction))
|
||||
|
||||
def motor_disable_limits(self) -> None:
|
||||
"""Disable east/west soft limits."""
|
||||
self.send_diseqc_message(diseqc_disable_limits())
|
||||
|
||||
|
||||
# --- DiSEqC 1.2 command builders ---
|
||||
|
||||
def diseqc_halt() -> bytes:
|
||||
"""Stop positioner movement (DiSEqC 1.2 Halt)."""
|
||||
return bytes([0xE0, 0x31, 0x60])
|
||||
|
||||
|
||||
def diseqc_drive_east(steps: int = 0) -> bytes:
|
||||
"""Drive east. steps=0 for continuous, 1-127 for step count."""
|
||||
return bytes([0xE0, 0x31, 0x68, min(steps, 0x7F)])
|
||||
|
||||
|
||||
def diseqc_drive_west(steps: int = 0) -> bytes:
|
||||
"""Drive west. steps=0 for continuous, 1-127 for step count."""
|
||||
return bytes([0xE0, 0x31, 0x69, min(steps, 0x7F)])
|
||||
|
||||
|
||||
def diseqc_store_position(slot: int) -> bytes:
|
||||
"""Store current position in slot (0-255)."""
|
||||
return bytes([0xE0, 0x31, 0x6A, slot & 0xFF])
|
||||
|
||||
|
||||
def diseqc_goto_position(slot: int) -> bytes:
|
||||
"""Go to stored position (0-255). Slot 0 = reference/zero."""
|
||||
return bytes([0xE0, 0x31, 0x6B, slot & 0xFF])
|
||||
|
||||
|
||||
def diseqc_set_limit(direction: str) -> bytes:
|
||||
"""Set east or west software limit at current position."""
|
||||
if direction.lower() == "east":
|
||||
return bytes([0xE0, 0x31, 0x66, 0x00])
|
||||
else:
|
||||
return bytes([0xE0, 0x31, 0x66, 0x01])
|
||||
|
||||
|
||||
def diseqc_disable_limits() -> bytes:
|
||||
"""Disable software limits."""
|
||||
return bytes([0xE0, 0x31, 0x63])
|
||||
|
||||
|
||||
def diseqc_goto_x(observer_lon: float, sat_lon: float) -> bytes:
|
||||
"""
|
||||
USALS GotoX command (DiSEqC 1.3 extension).
|
||||
Calculates motor rotation angle from observer and satellite longitude,
|
||||
then encodes as DiSEqC 1.2 GotoX (E0 31 6E HH LL).
|
||||
"""
|
||||
angle = usals_angle(observer_lon, sat_lon)
|
||||
hh, ll = usals_encode_angle(angle)
|
||||
return bytes([0xE0, 0x31, 0x6E, hh, ll])
|
||||
|
||||
|
||||
def usals_angle(observer_lon: float, sat_lon: float,
|
||||
observer_lat: float = 0.0) -> float:
|
||||
"""
|
||||
Calculate USALS motor rotation angle in degrees.
|
||||
|
||||
Positive = east, negative = west.
|
||||
Uses the standard USALS formula from DiSEqC 1.3 spec.
|
||||
observer_lat defaults to 0 (equator) for simplicity; the motor
|
||||
corrects for elevation internally.
|
||||
"""
|
||||
# Convert to radians
|
||||
obs_lon_r = math.radians(observer_lon)
|
||||
sat_lon_r = math.radians(sat_lon)
|
||||
obs_lat_r = math.radians(observer_lat)
|
||||
|
||||
# Longitude difference
|
||||
delta_lon = sat_lon_r - obs_lon_r
|
||||
|
||||
# USALS formula: angle = atan2(sin(delta_lon), cos(delta_lon) - R)
|
||||
# where R = Re / (Re + h) ≈ 0.1513 for GEO orbit
|
||||
# Simplified for equatorial mount:
|
||||
angle = math.degrees(math.atan2(
|
||||
math.sin(delta_lon),
|
||||
math.cos(delta_lon) - 6378.0 / (6378.0 + 35786.0)
|
||||
))
|
||||
|
||||
return angle
|
||||
|
||||
|
||||
def usals_encode_angle(angle_deg: float) -> tuple:
|
||||
"""
|
||||
Encode USALS angle to DiSEqC 1.3 byte pair (HH, LL).
|
||||
|
||||
Format: HH.HL where HH = integer degrees, H nibble of LL = tenths,
|
||||
L nibble of LL = sixteenths. Bit 7 of HH = direction (1=west).
|
||||
"""
|
||||
west = angle_deg < 0
|
||||
angle = abs(angle_deg)
|
||||
|
||||
degrees = int(angle)
|
||||
fraction = angle - degrees
|
||||
|
||||
# Fraction encoded as: upper nibble = tenths (0-9),
|
||||
# lower nibble = sixteenths (0-15)
|
||||
tenths = int(fraction * 10) & 0x0F
|
||||
sixteenths = int((fraction * 10 - tenths) * 16) & 0x0F
|
||||
|
||||
hh = degrees & 0x7F
|
||||
if west:
|
||||
hh |= 0x80 # bit 7 = west
|
||||
|
||||
ll = (tenths << 4) | sixteenths
|
||||
|
||||
return hh, ll
|
||||
|
|
|
|||
455
tools/survey.py
Normal file
455
tools/survey.py
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Carrier survey CLI for the Genpix SkyWalker-1.
|
||||
|
||||
Subcommands:
|
||||
full-scan Full six-stage carrier survey
|
||||
quick-scan Fast sweep + peak detection only
|
||||
diff Compare two saved survey catalogs
|
||||
export Export a survey to CSV, JSON, or text
|
||||
view View the latest or a specified survey
|
||||
qo100 QO-100 narrowband transponder survey with optimized params
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
|
||||
# Ensure the tools directory is on the import path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from skywalker_lib import SkyWalker1
|
||||
from signal_analysis import adaptive_noise_floor, detect_peaks_enhanced, classify_carrier
|
||||
from carrier_catalog import CarrierCatalog, CarrierEntry, CatalogDiff, CATALOG_DIR
|
||||
from survey_engine import SurveyEngine
|
||||
|
||||
|
||||
def progress_callback(verbose: bool):
|
||||
"""Return a callback function for SurveyEngine progress reporting."""
|
||||
def cb(stage, pct, msg):
|
||||
if verbose:
|
||||
print(f" [{stage:>17s}] {pct:5.1f}% {msg}", file=sys.stderr)
|
||||
else:
|
||||
sys.stderr.write(f"\r {stage}: {pct:.0f}% {msg[:60]:<60s}")
|
||||
sys.stderr.flush()
|
||||
return cb
|
||||
|
||||
|
||||
# -- Subcommand handlers --
|
||||
|
||||
def cmd_full_scan(args: argparse.Namespace) -> None:
|
||||
"""Run a full six-stage carrier survey."""
|
||||
print(f"SkyWalker-1 Full Carrier Survey")
|
||||
print(f" Range: {args.start}-{args.stop} MHz")
|
||||
print(f" Coarse step: {args.coarse_step} MHz, Fine step: {args.fine_step} MHz")
|
||||
print(f" SR range: {args.sr_min / 1e6:.1f} - {args.sr_max / 1e6:.1f} Msps")
|
||||
print()
|
||||
|
||||
cb = progress_callback(args.verbose)
|
||||
|
||||
with SkyWalker1(verbose=args.verbose) as dev:
|
||||
dev.ensure_booted()
|
||||
if args.pol or args.band:
|
||||
dev.configure_lnb(pol=args.pol, band=args.band)
|
||||
|
||||
engine = SurveyEngine(dev, callback=cb)
|
||||
catalog = engine.run_full_scan(
|
||||
start_mhz=args.start,
|
||||
stop_mhz=args.stop,
|
||||
coarse_step=args.coarse_step,
|
||||
fine_step=args.fine_step,
|
||||
sr_min=args.sr_min,
|
||||
sr_max=args.sr_max,
|
||||
sr_step=args.sr_step,
|
||||
)
|
||||
|
||||
if not args.verbose:
|
||||
sys.stderr.write("\r" + " " * 80 + "\r")
|
||||
sys.stderr.flush()
|
||||
|
||||
# Set catalog metadata
|
||||
catalog.band = args.band or ""
|
||||
catalog.pol = args.pol or ""
|
||||
if args.name:
|
||||
catalog.name = args.name
|
||||
|
||||
# Save
|
||||
if args.output:
|
||||
path = catalog.save(args.output)
|
||||
else:
|
||||
path = catalog.save()
|
||||
|
||||
print()
|
||||
print(catalog.summary())
|
||||
print()
|
||||
print(f"Saved to: {path}")
|
||||
|
||||
|
||||
def cmd_quick_scan(args: argparse.Namespace) -> None:
|
||||
"""Quick sweep + peak detection, no blind scan."""
|
||||
print(f"SkyWalker-1 Quick Scan")
|
||||
print(f" Range: {args.start}-{args.stop} MHz, step: {args.step} MHz")
|
||||
print()
|
||||
|
||||
cb = progress_callback(args.verbose)
|
||||
|
||||
with SkyWalker1(verbose=args.verbose) as dev:
|
||||
dev.ensure_booted()
|
||||
if args.pol or args.band:
|
||||
dev.configure_lnb(pol=args.pol, band=args.band)
|
||||
|
||||
engine = SurveyEngine(dev, callback=cb)
|
||||
peaks = engine.run_quick_scan(
|
||||
start_mhz=args.start,
|
||||
stop_mhz=args.stop,
|
||||
step=args.step,
|
||||
)
|
||||
|
||||
if not args.verbose:
|
||||
sys.stderr.write("\r" + " " * 80 + "\r")
|
||||
sys.stderr.flush()
|
||||
|
||||
if not peaks:
|
||||
print("No peaks detected above noise floor.")
|
||||
return
|
||||
|
||||
print(f"\nDetected {len(peaks)} carrier(s):\n")
|
||||
print(f" {'#':>3} {'Freq (MHz)':>10} {'Power (dB)':>10} "
|
||||
f"{'BW (MHz)':>8} {'Prominence':>10} Quality")
|
||||
print(f" {'---':>3} {'----------':>10} {'----------':>10} "
|
||||
f"{'--------':>8} {'----------':>10} -------")
|
||||
|
||||
for i, p in enumerate(sorted(peaks, key=lambda x: x["freq"]), 1):
|
||||
cls = p.get("classification", classify_carrier(p["width_mhz"], p["power"]))
|
||||
quality = cls.get("signal_quality", "?")
|
||||
print(f" {i:3d} {p['freq']:>10.1f} {p['power']:>+10.1f} "
|
||||
f"{p['width_mhz']:>8.1f} {p['prominence_db']:>+10.1f} {quality}")
|
||||
|
||||
|
||||
def cmd_diff(args: argparse.Namespace) -> None:
|
||||
"""Compare two survey catalog files."""
|
||||
try:
|
||||
old_cat = CarrierCatalog.load(args.file1)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"Cannot load {args.file1}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
new_cat = CarrierCatalog.load(args.file2)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"Cannot load {args.file2}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Comparing surveys:")
|
||||
print(f" Old: {args.file1} ({old_cat.created})")
|
||||
print(f" New: {args.file2} ({new_cat.created})")
|
||||
print()
|
||||
|
||||
diff = CatalogDiff.diff(old_cat, new_cat)
|
||||
print(CatalogDiff.format_diff(diff))
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
json.dump(diff, f, indent=2)
|
||||
print(f"\nDiff saved to: {args.output}")
|
||||
|
||||
|
||||
def cmd_export(args: argparse.Namespace) -> None:
|
||||
"""Export a survey catalog to CSV, JSON, or text."""
|
||||
try:
|
||||
catalog = CarrierCatalog.load(args.file)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"Cannot load {args.file}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
fmt = args.format
|
||||
|
||||
if fmt == "json":
|
||||
output = json.dumps(catalog.to_dict(), indent=2)
|
||||
elif fmt == "csv":
|
||||
output = _catalog_to_csv(catalog)
|
||||
else:
|
||||
output = catalog.summary()
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(output)
|
||||
print(f"Exported to: {args.output}")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
def cmd_view(args: argparse.Namespace) -> None:
|
||||
"""View a specific survey or the latest one."""
|
||||
if args.file:
|
||||
filename = args.file
|
||||
else:
|
||||
surveys = CarrierCatalog.list_surveys()
|
||||
if not surveys:
|
||||
print(f"No surveys found in {CATALOG_DIR}")
|
||||
sys.exit(1)
|
||||
filename = surveys[0]["path"]
|
||||
print(f"(Showing latest: {surveys[0]['filename']})\n")
|
||||
|
||||
try:
|
||||
catalog = CarrierCatalog.load(filename)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"Cannot load {filename}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(catalog.summary())
|
||||
|
||||
if args.verbose and catalog.carriers:
|
||||
print(f"\nDetailed carrier info:")
|
||||
for i, c in enumerate(sorted(catalog.carriers, key=lambda x: x.freq_khz), 1):
|
||||
print(f"\n --- Carrier {i} ---")
|
||||
print(f" Frequency: {c.freq_mhz:.3f} MHz ({c.freq_khz} kHz)")
|
||||
print(f" Power: {c.power_db:+.1f} dB")
|
||||
print(f" SNR: {c.snr_db:.1f} dB")
|
||||
if c.sr_sps:
|
||||
print(f" Symbol rate: {c.sr_sps} sps ({c.sr_sps / 1e6:.3f} Msps)")
|
||||
if c.modulation:
|
||||
print(f" Modulation: {c.modulation}")
|
||||
if c.fec:
|
||||
print(f" FEC: {c.fec}")
|
||||
print(f" Locked: {c.locked}")
|
||||
print(f" Bandwidth: {c.bw_mhz:.1f} MHz")
|
||||
if c.services:
|
||||
print(f" Services: {', '.join(c.services)}")
|
||||
print(f" First seen: {c.first_seen}")
|
||||
print(f" Last seen: {c.last_seen}")
|
||||
print(f" Scan count: {c.scan_count}")
|
||||
if c.classification:
|
||||
cls = c.classification
|
||||
if "estimated_sr_range" in cls:
|
||||
sr_lo, sr_hi = cls["estimated_sr_range"]
|
||||
print(f" Est. SR: {sr_lo / 1e6:.1f} - {sr_hi / 1e6:.1f} Msps")
|
||||
if "likely_modulation" in cls:
|
||||
print(f" Likely mod: {', '.join(cls['likely_modulation'])}")
|
||||
if "signal_quality" in cls:
|
||||
print(f" Quality: {cls['signal_quality']}")
|
||||
|
||||
|
||||
def cmd_qo100(args: argparse.Namespace) -> None:
|
||||
"""
|
||||
QO-100 narrowband transponder survey with optimized parameters.
|
||||
|
||||
QO-100 (Es'hail-2) narrowband transponder: 10489.500 - 10489.800 MHz
|
||||
With a typical LNB LO of 9750 MHz, the IF range is ~739.5 - 739.8 MHz.
|
||||
|
||||
Since most QO-100 NB signals are very narrow (< 3 kHz audio, 1-2.7 ksps
|
||||
digital), this mode uses the finest practical sweep resolution and
|
||||
restricted SR range.
|
||||
"""
|
||||
lnb_lo = args.lnb_lo
|
||||
# QO-100 NB transponder: 10489.500 - 10489.800 MHz
|
||||
rf_start = 10489.5
|
||||
rf_stop = 10489.8
|
||||
if_start = rf_start - lnb_lo
|
||||
if_stop = rf_stop - lnb_lo
|
||||
|
||||
# Validate IF range is within device capability
|
||||
if if_start < 950 or if_stop > 2150:
|
||||
print(f"QO-100 IF range ({if_start:.1f} - {if_stop:.1f} MHz) is outside "
|
||||
f"the 950-2150 MHz hardware range with LNB LO={lnb_lo} MHz.",
|
||||
file=sys.stderr)
|
||||
print(f"Check your LNB LO frequency.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"QO-100 Narrowband Transponder Survey")
|
||||
print(f" LNB LO: {lnb_lo} MHz")
|
||||
print(f" RF range: {rf_start:.3f} - {rf_stop:.3f} MHz")
|
||||
print(f" IF range: {if_start:.3f} - {if_stop:.3f} MHz")
|
||||
print()
|
||||
|
||||
# QO-100 NB uses very low symbol rates (1-33 ksps typical for DVB-S)
|
||||
# The SkyWalker-1 minimum is 256 ksps, so we set a narrow range
|
||||
sr_min = 256_000
|
||||
sr_max = 2_000_000
|
||||
sr_step = 100_000
|
||||
|
||||
cb = progress_callback(args.verbose)
|
||||
|
||||
with SkyWalker1(verbose=args.verbose) as dev:
|
||||
dev.ensure_booted()
|
||||
# QO-100 is H-pol on most setups, high band for 10 GHz
|
||||
dev.configure_lnb(pol="H", band="high", lnb_lo=lnb_lo)
|
||||
|
||||
engine = SurveyEngine(dev, callback=cb)
|
||||
catalog = engine.run_full_scan(
|
||||
start_mhz=if_start,
|
||||
stop_mhz=if_stop,
|
||||
coarse_step=0.5, # 500 kHz steps for the narrow band
|
||||
fine_step=0.1, # 100 kHz fine resolution
|
||||
sr_min=sr_min,
|
||||
sr_max=sr_max,
|
||||
sr_step=sr_step,
|
||||
)
|
||||
|
||||
if not args.verbose:
|
||||
sys.stderr.write("\r" + " " * 80 + "\r")
|
||||
sys.stderr.flush()
|
||||
|
||||
catalog.name = "QO-100 Narrowband"
|
||||
catalog.band = "high"
|
||||
catalog.pol = "H"
|
||||
catalog.lnb_lo_mhz = lnb_lo
|
||||
catalog.notes = (f"QO-100 Es'hail-2 narrowband transponder. "
|
||||
f"RF {rf_start}-{rf_stop} MHz, LNB LO {lnb_lo} MHz.")
|
||||
|
||||
if args.output:
|
||||
path = catalog.save(args.output)
|
||||
else:
|
||||
path = catalog.save(f"survey-qo100-nb-{time.strftime('%Y-%m-%d')}.json")
|
||||
|
||||
print()
|
||||
print(catalog.summary())
|
||||
print()
|
||||
print(f"Saved to: {path}")
|
||||
|
||||
|
||||
# -- Helpers --
|
||||
|
||||
def _catalog_to_csv(catalog: CarrierCatalog) -> str:
|
||||
"""Convert a catalog to CSV format."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow([
|
||||
"freq_khz", "freq_mhz", "sr_sps", "modulation", "fec",
|
||||
"power_db", "snr_db", "locked", "bw_mhz", "services",
|
||||
"first_seen", "last_seen", "scan_count",
|
||||
])
|
||||
for c in sorted(catalog.carriers, key=lambda x: x.freq_khz):
|
||||
writer.writerow([
|
||||
c.freq_khz, f"{c.freq_mhz:.3f}", c.sr_sps,
|
||||
c.modulation, c.fec,
|
||||
f"{c.power_db:.1f}", f"{c.snr_db:.1f}",
|
||||
c.locked, f"{c.bw_mhz:.1f}",
|
||||
"|".join(c.services),
|
||||
c.first_seen, c.last_seen, c.scan_count,
|
||||
])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# -- CLI --
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Carrier survey tool for the Genpix SkyWalker-1",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
examples:
|
||||
%(prog)s full-scan
|
||||
%(prog)s full-scan --start 1100 --stop 1200 --output my-scan.json
|
||||
%(prog)s quick-scan
|
||||
%(prog)s diff survey-2026-02-14-low-V.json survey-2026-02-15-low-V.json
|
||||
%(prog)s export survey-2026-02-15-low-V.json --format csv
|
||||
%(prog)s view
|
||||
%(prog)s qo100 --lnb-lo 9750
|
||||
""")
|
||||
parser.add_argument('-v', '--verbose', action='store_true',
|
||||
help="Verbose progress and debug output")
|
||||
|
||||
sub = parser.add_subparsers(dest='command')
|
||||
|
||||
# full-scan
|
||||
p_full = sub.add_parser('full-scan', help="Full six-stage carrier survey")
|
||||
p_full.add_argument('--start', type=float, default=950,
|
||||
help="Start frequency in MHz (default: 950)")
|
||||
p_full.add_argument('--stop', type=float, default=2150,
|
||||
help="Stop frequency in MHz (default: 2150)")
|
||||
p_full.add_argument('--coarse-step', type=float, default=5.0,
|
||||
help="Coarse sweep step in MHz (default: 5.0)")
|
||||
p_full.add_argument('--fine-step', type=float, default=1.0,
|
||||
help="Fine sweep step in MHz (default: 1.0)")
|
||||
p_full.add_argument('--sr-min', type=int, default=1_000_000,
|
||||
help="Min symbol rate for blind scan in sps (default: 1000000)")
|
||||
p_full.add_argument('--sr-max', type=int, default=30_000_000,
|
||||
help="Max symbol rate for blind scan in sps (default: 30000000)")
|
||||
p_full.add_argument('--sr-step', type=int, default=1_000_000,
|
||||
help="Symbol rate step for blind scan in sps (default: 1000000)")
|
||||
p_full.add_argument('--pol', choices=['H', 'V', 'L', 'R'],
|
||||
help="LNB polarization")
|
||||
p_full.add_argument('--band', choices=['low', 'high'],
|
||||
help="LNB band (low/high)")
|
||||
p_full.add_argument('--name', type=str, default="",
|
||||
help="Survey name/label")
|
||||
p_full.add_argument('--output', '-o', type=str, default=None,
|
||||
help="Output filename (default: auto-generated)")
|
||||
|
||||
# quick-scan
|
||||
p_quick = sub.add_parser('quick-scan', help="Quick sweep + peak detection")
|
||||
p_quick.add_argument('--start', type=float, default=950,
|
||||
help="Start frequency in MHz (default: 950)")
|
||||
p_quick.add_argument('--stop', type=float, default=2150,
|
||||
help="Stop frequency in MHz (default: 2150)")
|
||||
p_quick.add_argument('--step', type=float, default=5.0,
|
||||
help="Sweep step in MHz (default: 5.0)")
|
||||
p_quick.add_argument('--pol', choices=['H', 'V', 'L', 'R'],
|
||||
help="LNB polarization")
|
||||
p_quick.add_argument('--band', choices=['low', 'high'],
|
||||
help="LNB band (low/high)")
|
||||
|
||||
# diff
|
||||
p_diff = sub.add_parser('diff', help="Compare two survey catalogs")
|
||||
p_diff.add_argument('file1', help="Older survey file")
|
||||
p_diff.add_argument('file2', help="Newer survey file")
|
||||
p_diff.add_argument('--output', '-o', type=str, default=None,
|
||||
help="Save diff as JSON to this file")
|
||||
|
||||
# export
|
||||
p_export = sub.add_parser('export', help="Export survey to CSV/JSON/text")
|
||||
p_export.add_argument('file', help="Survey file to export")
|
||||
p_export.add_argument('--format', '-f', choices=['csv', 'json', 'text'],
|
||||
default='text', help="Output format (default: text)")
|
||||
p_export.add_argument('--output', '-o', type=str, default=None,
|
||||
help="Output file (default: stdout)")
|
||||
|
||||
# view
|
||||
p_view = sub.add_parser('view', help="View a survey (latest if no file given)")
|
||||
p_view.add_argument('file', nargs='?', default=None,
|
||||
help="Survey file to view (default: latest)")
|
||||
|
||||
# qo100
|
||||
p_qo100 = sub.add_parser('qo100',
|
||||
help="QO-100 narrowband transponder survey")
|
||||
p_qo100.add_argument('--lnb-lo', type=float, required=True,
|
||||
help="LNB local oscillator frequency in MHz "
|
||||
"(e.g., 9750 for universal LNB low band)")
|
||||
p_qo100.add_argument('--output', '-o', type=str, default=None,
|
||||
help="Output filename (default: auto-generated)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
dispatch = {
|
||||
'full-scan': cmd_full_scan,
|
||||
'quick-scan': cmd_quick_scan,
|
||||
'diff': cmd_diff,
|
||||
'export': cmd_export,
|
||||
'view': cmd_view,
|
||||
'qo100': cmd_qo100,
|
||||
}
|
||||
|
||||
handler = dispatch.get(args.command)
|
||||
if handler is None:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
handler(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
440
tools/survey_engine.py
Normal file
440
tools/survey_engine.py
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
#!/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
|
||||
|
|
@ -341,6 +341,388 @@ def parse_pmt(section: dict) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def parse_sdt(section: dict) -> dict:
|
||||
"""
|
||||
Parse a Service Description Table section.
|
||||
|
||||
Table IDs: 0x42 = SDT actual transport stream,
|
||||
0x46 = SDT other transport stream.
|
||||
Carried on PID 0x0011.
|
||||
|
||||
Returns dict with:
|
||||
transport_stream_id - TS ID from the table extension
|
||||
original_network_id - ONID from bytes [0:2] of section data
|
||||
services - list of service dicts, each containing:
|
||||
service_id - program number
|
||||
service_type - numeric type (1=digital TV, 2=digital radio, etc)
|
||||
service_name - decoded service name string
|
||||
provider_name - decoded provider name string
|
||||
eit_schedule - bool, EIT schedule flag
|
||||
eit_present - bool, EIT present/following flag
|
||||
running_status - numeric running status
|
||||
free_ca - bool, free/scrambled flag
|
||||
|
||||
Descriptor parsing: looks for tag 0x48 (service_descriptor) which
|
||||
encodes service_type (1 byte), provider_name_length + provider_name,
|
||||
service_name_length + service_name.
|
||||
"""
|
||||
if section is None:
|
||||
return None
|
||||
if section["table_id"] not in (0x42, 0x46):
|
||||
return None
|
||||
if not section.get("section_syntax"):
|
||||
return None
|
||||
|
||||
transport_stream_id = section["table_id_ext"]
|
||||
data = section.get("data", b'')
|
||||
|
||||
if len(data) < 2:
|
||||
return None
|
||||
|
||||
original_network_id = (data[0] << 8) | data[1]
|
||||
# Byte 2 is reserved_future_use
|
||||
offset = 3
|
||||
|
||||
services = []
|
||||
while offset + 5 <= len(data):
|
||||
service_id = (data[offset] << 8) | data[offset + 1]
|
||||
# byte 2: EIT flags and running status
|
||||
flags_byte = data[offset + 2]
|
||||
eit_schedule = bool(flags_byte & 0x02)
|
||||
eit_present = bool(flags_byte & 0x01)
|
||||
|
||||
status_byte = data[offset + 3]
|
||||
running_status = (status_byte >> 5) & 0x07
|
||||
free_ca = bool(status_byte & 0x10)
|
||||
descriptors_loop_length = ((status_byte & 0x0F) << 8) | data[offset + 4]
|
||||
|
||||
offset += 5
|
||||
|
||||
# Parse descriptors for this service
|
||||
service_type = 0
|
||||
service_name = ""
|
||||
provider_name = ""
|
||||
|
||||
desc_end = offset + descriptors_loop_length
|
||||
if desc_end > len(data):
|
||||
desc_end = len(data)
|
||||
|
||||
while offset + 2 <= desc_end:
|
||||
desc_tag = data[offset]
|
||||
desc_len = data[offset + 1]
|
||||
desc_data = data[offset + 2:offset + 2 + desc_len]
|
||||
offset += 2 + desc_len
|
||||
|
||||
if desc_tag == 0x48 and len(desc_data) >= 1:
|
||||
# service_descriptor
|
||||
service_type = desc_data[0]
|
||||
pos = 1
|
||||
|
||||
# Provider name
|
||||
if pos < len(desc_data):
|
||||
prov_len = desc_data[pos]
|
||||
pos += 1
|
||||
if pos + prov_len <= len(desc_data):
|
||||
provider_name = _decode_dvb_string(desc_data[pos:pos + prov_len])
|
||||
pos += prov_len
|
||||
|
||||
# Service name
|
||||
if pos < len(desc_data):
|
||||
svc_len = desc_data[pos]
|
||||
pos += 1
|
||||
if pos + svc_len <= len(desc_data):
|
||||
service_name = _decode_dvb_string(desc_data[pos:pos + svc_len])
|
||||
|
||||
# Advance past any unprocessed descriptor bytes
|
||||
offset = max(offset, desc_end)
|
||||
|
||||
services.append({
|
||||
"service_id": service_id,
|
||||
"service_type": service_type,
|
||||
"service_name": service_name,
|
||||
"provider_name": provider_name,
|
||||
"eit_schedule": eit_schedule,
|
||||
"eit_present": eit_present,
|
||||
"running_status": running_status,
|
||||
"free_ca": free_ca,
|
||||
})
|
||||
|
||||
return {
|
||||
"table_id": section["table_id"],
|
||||
"transport_stream_id": transport_stream_id,
|
||||
"original_network_id": original_network_id,
|
||||
"version": section["version"],
|
||||
"services": services,
|
||||
}
|
||||
|
||||
|
||||
def parse_nit(section: dict) -> dict:
|
||||
"""
|
||||
Parse a Network Information Table section.
|
||||
|
||||
Table IDs: 0x40 = NIT actual network,
|
||||
0x41 = NIT other network.
|
||||
Carried on PID 0x0010.
|
||||
|
||||
Returns dict with:
|
||||
network_id - network ID from the table extension
|
||||
network_name - decoded network name string (from descriptor 0x40)
|
||||
transports - list of transport dicts, each containing:
|
||||
ts_id - transport stream ID
|
||||
original_network_id - ONID
|
||||
frequency_ghz - satellite frequency in GHz (from 0x43)
|
||||
polarization - string: 'H', 'V', 'L', or 'R'
|
||||
symbol_rate - symbol rate in sps
|
||||
fec - FEC inner code rate string
|
||||
orbital_position - orbital position in degrees (+ east, - west)
|
||||
modulation - modulation string
|
||||
roll_off - roll-off factor string
|
||||
|
||||
Descriptor parsing: looks for tag 0x43 (satellite_delivery_system_descriptor)
|
||||
which is 11 bytes of BCD-encoded satellite parameters, and tag 0x40
|
||||
(network_name_descriptor) for the network name.
|
||||
"""
|
||||
if section is None:
|
||||
return None
|
||||
if section["table_id"] not in (0x40, 0x41):
|
||||
return None
|
||||
if not section.get("section_syntax"):
|
||||
return None
|
||||
|
||||
network_id = section["table_id_ext"]
|
||||
data = section.get("data", b'')
|
||||
|
||||
if len(data) < 2:
|
||||
return None
|
||||
|
||||
# Network descriptors loop
|
||||
network_desc_length = ((data[0] & 0x0F) << 8) | data[1]
|
||||
offset = 2
|
||||
|
||||
network_name = ""
|
||||
|
||||
nd_end = offset + network_desc_length
|
||||
if nd_end > len(data):
|
||||
nd_end = len(data)
|
||||
|
||||
while offset + 2 <= nd_end:
|
||||
desc_tag = data[offset]
|
||||
desc_len = data[offset + 1]
|
||||
desc_data = data[offset + 2:offset + 2 + desc_len]
|
||||
offset += 2 + desc_len
|
||||
|
||||
if desc_tag == 0x40:
|
||||
# network_name_descriptor
|
||||
network_name = _decode_dvb_string(desc_data)
|
||||
|
||||
offset = nd_end
|
||||
|
||||
# Transport stream loop
|
||||
if offset + 2 > len(data):
|
||||
return {
|
||||
"table_id": section["table_id"],
|
||||
"network_id": network_id,
|
||||
"network_name": network_name,
|
||||
"version": section["version"],
|
||||
"transports": [],
|
||||
}
|
||||
|
||||
ts_loop_length = ((data[offset] & 0x0F) << 8) | data[offset + 1]
|
||||
offset += 2
|
||||
|
||||
transports = []
|
||||
ts_end = offset + ts_loop_length
|
||||
if ts_end > len(data):
|
||||
ts_end = len(data)
|
||||
|
||||
while offset + 6 <= ts_end:
|
||||
ts_id = (data[offset] << 8) | data[offset + 1]
|
||||
original_network_id = (data[offset + 2] << 8) | data[offset + 3]
|
||||
td_length = ((data[offset + 4] & 0x0F) << 8) | data[offset + 5]
|
||||
offset += 6
|
||||
|
||||
# Parse transport descriptors
|
||||
frequency_ghz = 0.0
|
||||
polarization = ""
|
||||
symbol_rate = 0
|
||||
fec = ""
|
||||
orbital_position = 0.0
|
||||
modulation = ""
|
||||
roll_off = ""
|
||||
|
||||
td_end = offset + td_length
|
||||
if td_end > ts_end:
|
||||
td_end = ts_end
|
||||
|
||||
while offset + 2 <= td_end:
|
||||
desc_tag = data[offset]
|
||||
desc_len = data[offset + 1]
|
||||
desc_data = data[offset + 2:offset + 2 + desc_len]
|
||||
offset += 2 + desc_len
|
||||
|
||||
if desc_tag == 0x43 and len(desc_data) >= 11:
|
||||
# satellite_delivery_system_descriptor (11 bytes BCD)
|
||||
frequency_ghz = _bcd_freq(desc_data[0:4])
|
||||
orbital_position = _bcd_orbital(desc_data[4:6])
|
||||
# Byte 6: west/east flag (bit 7), polarization (bits 6-5),
|
||||
# roll-off (bits 4-3), modulation system (bit 2),
|
||||
# modulation type (bits 1-0)
|
||||
flag_byte = desc_data[6]
|
||||
if not (flag_byte & 0x80):
|
||||
orbital_position = -orbital_position # West
|
||||
pol_bits = (flag_byte >> 5) & 0x03
|
||||
polarization = ["H", "V", "L", "R"][pol_bits]
|
||||
ro_bits = (flag_byte >> 3) & 0x03
|
||||
roll_off = ["0.35", "0.25", "0.20", "reserved"][ro_bits]
|
||||
mod_sys = (flag_byte >> 2) & 0x01
|
||||
mod_type = flag_byte & 0x03
|
||||
if mod_sys == 0:
|
||||
modulation = ["auto", "QPSK", "8PSK", "16QAM"][mod_type]
|
||||
else:
|
||||
modulation = ["auto", "QPSK", "8PSK", "16APSK"][mod_type]
|
||||
symbol_rate = _bcd_sr(desc_data[7:11])
|
||||
fec_inner = desc_data[10] & 0x0F
|
||||
fec = _fec_inner_str(fec_inner)
|
||||
|
||||
offset = max(offset, td_end)
|
||||
|
||||
transports.append({
|
||||
"ts_id": ts_id,
|
||||
"original_network_id": original_network_id,
|
||||
"frequency_ghz": frequency_ghz,
|
||||
"polarization": polarization,
|
||||
"symbol_rate": symbol_rate,
|
||||
"fec": fec,
|
||||
"orbital_position": orbital_position,
|
||||
"modulation": modulation,
|
||||
"roll_off": roll_off,
|
||||
})
|
||||
|
||||
return {
|
||||
"table_id": section["table_id"],
|
||||
"network_id": network_id,
|
||||
"network_name": network_name,
|
||||
"version": section["version"],
|
||||
"transports": transports,
|
||||
}
|
||||
|
||||
|
||||
def _decode_dvb_string(data: bytes) -> str:
|
||||
"""
|
||||
Decode a DVB text string per EN 300 468 Annex A.
|
||||
|
||||
If the first byte is a character table selector (0x01-0x1F),
|
||||
select the appropriate encoding. Otherwise assume ISO 8859-1.
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
|
||||
first = data[0]
|
||||
if first < 0x20:
|
||||
# Character table selector byte
|
||||
if first == 0x01:
|
||||
return data[1:].decode('iso-8859-5', errors='replace')
|
||||
elif first == 0x02:
|
||||
return data[1:].decode('iso-8859-6', errors='replace')
|
||||
elif first == 0x03:
|
||||
return data[1:].decode('iso-8859-7', errors='replace')
|
||||
elif first == 0x04:
|
||||
return data[1:].decode('iso-8859-8', errors='replace')
|
||||
elif first == 0x05:
|
||||
return data[1:].decode('iso-8859-9', errors='replace')
|
||||
elif first == 0x06:
|
||||
return data[1:].decode('iso-8859-10', errors='replace')
|
||||
elif first == 0x07:
|
||||
return data[1:].decode('iso-8859-11', errors='replace')
|
||||
elif first == 0x09:
|
||||
return data[1:].decode('iso-8859-13', errors='replace')
|
||||
elif first == 0x0A:
|
||||
return data[1:].decode('iso-8859-14', errors='replace')
|
||||
elif first == 0x0B:
|
||||
return data[1:].decode('iso-8859-15', errors='replace')
|
||||
elif first == 0x10:
|
||||
# Two more selector bytes follow
|
||||
if len(data) >= 3:
|
||||
sub = (data[1] << 8) | data[2]
|
||||
try:
|
||||
return data[3:].decode(f'iso-8859-{sub}', errors='replace')
|
||||
except (LookupError, ValueError):
|
||||
return data[3:].decode('iso-8859-1', errors='replace')
|
||||
return data[1:].decode('iso-8859-1', errors='replace')
|
||||
elif first == 0x11:
|
||||
return data[1:].decode('utf-16-be', errors='replace')
|
||||
elif first == 0x13:
|
||||
return data[1:].decode('gb2312', errors='replace')
|
||||
elif first == 0x15:
|
||||
return data[1:].decode('utf-8', errors='replace')
|
||||
else:
|
||||
# Unknown selector, skip it
|
||||
return data[1:].decode('iso-8859-1', errors='replace')
|
||||
|
||||
return data.decode('iso-8859-1', errors='replace')
|
||||
|
||||
|
||||
def _bcd_freq(data: bytes) -> float:
|
||||
"""
|
||||
Decode 4-byte BCD frequency from satellite_delivery_system_descriptor.
|
||||
Per EN 300 468, the 8 BCD digits encode the frequency such that
|
||||
the integer value divided by 10^5 yields GHz.
|
||||
e.g., 0x11 0x72 0x75 0x00 -> digits 11727500 -> 11.72750 GHz.
|
||||
"""
|
||||
value = 0
|
||||
for b in data:
|
||||
value = value * 100 + ((b >> 4) & 0x0F) * 10 + (b & 0x0F)
|
||||
return value / 1_000_000.0
|
||||
|
||||
|
||||
def _bcd_orbital(data: bytes) -> float:
|
||||
"""
|
||||
Decode 2-byte BCD orbital position per EN 300 468.
|
||||
4 BCD digits: XX.XX degrees (2 integer + 2 fractional).
|
||||
e.g., 0x28 0x20 = 28.20 degrees (Astra 28.2E).
|
||||
"""
|
||||
value = 0
|
||||
for b in data:
|
||||
value = value * 100 + ((b >> 4) & 0x0F) * 10 + (b & 0x0F)
|
||||
return value / 100.0
|
||||
|
||||
|
||||
def _bcd_sr(data: bytes) -> int:
|
||||
"""
|
||||
Decode symbol rate from satellite_delivery_system_descriptor.
|
||||
4 bytes: upper 28 bits = 7 BCD digits of symbol rate (XXXX.XXX Msps),
|
||||
lower 4 bits = FEC inner code (handled separately by caller).
|
||||
e.g., 0x00 0x27 0x50 0x03 -> digits 0027500 -> 27.500 Msps = 27,500,000 sps.
|
||||
"""
|
||||
# Extract 7 BCD digits from the upper 28 bits (ignore last nibble = FEC)
|
||||
value = 0
|
||||
for b in data[:4]:
|
||||
value = value * 100 + ((b >> 4) & 0x0F) * 10 + (b & 0x0F)
|
||||
# value now has 8 BCD digits decoded; drop the last one (FEC nibble)
|
||||
value = value // 10
|
||||
# value = XXXX.XXX Msps as integer XXXXXXX, divide by 1000 for Msps
|
||||
# Multiply by 1000 to get sps: (value / 1000) * 1e6 = value * 1000
|
||||
return value * 1000
|
||||
|
||||
|
||||
def _fec_inner_str(code: int) -> str:
|
||||
"""Convert FEC inner code rate nibble to string."""
|
||||
fec_map = {
|
||||
0: "not defined",
|
||||
1: "1/2",
|
||||
2: "2/3",
|
||||
3: "3/4",
|
||||
4: "5/6",
|
||||
5: "7/8",
|
||||
6: "8/9",
|
||||
7: "3/5",
|
||||
8: "4/5",
|
||||
9: "9/10",
|
||||
15: "none",
|
||||
}
|
||||
return fec_map.get(code, f"reserved({code})")
|
||||
|
||||
|
||||
def open_input(path: str):
|
||||
"""Open TS input from a file path or stdin ('-')."""
|
||||
if path == '-':
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue