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:
Ryan Malloy 2026-02-15 17:01:11 -07:00
parent 0f4ba4766f
commit cc3a0707a1
20 changed files with 5645 additions and 84 deletions

377
tools/carrier_catalog.py Normal file
View 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
View 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
View 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
View 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,
}

View file

@ -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
View 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
View 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

View file

@ -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 == '-':