#!/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()