Add alternative operating modes: spectrum, scan, monitor, lband, track

Firmware v3.02.0 adds three new vendor commands:
- 0xB7 SIGNAL_MONITOR: fast 8-byte combined signal read
- 0xB8 TUNE_MONITOR: tune + dwell + read in one round-trip
- 0xB9 MULTI_REG_READ: batch read up to 64 indirect registers

New tools/skywalker.py provides five modes that use the BCM4500's
AGC registers as a crude power detector across 950-2150 MHz IF,
even without demodulator lock:
- spectrum: sweep analyzer with ASCII/waterfall/matplotlib display
- scan: automated transponder scanner (sweep + peak detect + blind scan)
- monitor: real-time signal strength for dish alignment
- lband: direct input analyzer with L-band allocation annotations
- track: carrier/beacon tracker with CSV/JSON logging and drift detection

Extracts shared SkyWalker1 class and constants into skywalker_lib.py;
tune.py now imports from the shared library.
This commit is contained in:
Ryan Malloy 2026-02-12 17:29:00 -07:00
parent b21f4957f6
commit 23055f34ab
4 changed files with 1756 additions and 312 deletions

View file

@ -18,316 +18,18 @@ import json
import signal
import os
try:
import usb.core
import usb.util
except ImportError:
print("pyusb required: pip install pyusb")
sys.exit(1)
# Add tools directory to path for library import
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
VENDOR_ID = 0x09C0
PRODUCT_ID = 0x0203
from skywalker_lib import (
SkyWalker1, VENDOR_ID, PRODUCT_ID, EP2_URB_SIZE,
MODULATIONS, FEC_RATES, MOD_FEC_GROUP,
LNB_LO_LOW, LNB_LO_HIGH,
CONFIG_BITS,
signal_bar, format_config_bits,
)
# Streaming endpoint
EP2_ADDR = 0x82
EP2_URB_SIZE = 8192
# Vendor commands
CMD_GET_8PSK_CONFIG = 0x80
CMD_I2C_WRITE = 0x83
CMD_I2C_READ = 0x84
CMD_ARM_TRANSFER = 0x85
CMD_TUNE_8PSK = 0x86
CMD_GET_SIGNAL_STRENGTH = 0x87
CMD_LOAD_BCM4500 = 0x88
CMD_BOOT_8PSK = 0x89
CMD_START_INTERSIL = 0x8A
CMD_SET_LNB_VOLTAGE = 0x8B
CMD_SET_22KHZ_TONE = 0x8C
CMD_SEND_DISEQC = 0x8D
CMD_GET_SIGNAL_LOCK = 0x90
CMD_GET_FW_VERS = 0x92
CMD_GET_SERIAL_NUMBER = 0x93
CMD_USE_EXTRA_VOLT = 0x94
# Config status bits (GET_8PSK_CONFIG response)
CONFIG_BITS = {
0x01: ("8PSK Started", "bm8pskStarted"),
0x02: ("BCM4500 FW Loaded", "bm8pskFW_Loaded"),
0x04: ("LNB Power On", "bmIntersilOn"),
0x08: ("DVB Mode", "bmDVBmode"),
0x10: ("22 kHz Tone", "bm22kHz"),
0x20: ("18V Selected", "bmSEL18V"),
0x40: ("DC Tuned", "bmDCtuned"),
0x80: ("Armed (streaming)", "bmArmed"),
}
# Modulation types for TUNE_8PSK byte 8
MODULATIONS = {
"qpsk": (0, "DVB-S QPSK"),
"turbo-qpsk": (1, "Turbo QPSK"),
"turbo-8psk": (2, "Turbo 8PSK"),
"turbo-16qam": (3, "Turbo 16QAM"),
"dcii-combo": (4, "DCII Combo"),
"dcii-i": (5, "DCII I-stream"),
"dcii-q": (6, "DCII Q-stream"),
"dcii-oqpsk": (7, "DCII Offset QPSK"),
"dss": (8, "DSS QPSK"),
"bpsk": (9, "DVB BPSK"),
}
# FEC rate indices per modulation group
FEC_RATES = {
"dvbs": {
"1/2": 0, "2/3": 1, "3/4": 2, "5/6": 3,
"7/8": 4, "auto": 5, "none": 6,
},
"turbo": {
"1/2": 0, "2/3": 1, "3/4": 2, "5/6": 3, "auto": 4,
},
"turbo-16qam": {
"3/4": 0, "auto": 0,
},
"dcii": {
"1/2": 0, "2/3": 1, "6/7": 2, "3/4": 3, "5/11": 4,
"1/2+": 5, "2/3+": 6, "6/7+": 7, "3/4+": 8, "auto": 0,
},
}
# Map modulation names to FEC group
MOD_FEC_GROUP = {
"qpsk": "dvbs",
"turbo-qpsk": "turbo",
"turbo-8psk": "turbo",
"turbo-16qam": "turbo-16qam",
"dcii-combo": "dcii",
"dcii-i": "dcii",
"dcii-q": "dcii",
"dcii-oqpsk": "dcii",
"dss": "dvbs",
"bpsk": "dvbs",
}
# Default LNB LO frequencies (MHz)
LNB_LO_LOW = 9750 # Universal LNB low-band
LNB_LO_HIGH = 10600 # Universal LNB high-band
class SkyWalker1:
"""USB interface to the Genpix SkyWalker-1 DVB-S receiver."""
def __init__(self, verbose: bool = False):
self.dev = None
self.detached_intf = None
self.verbose = verbose
def open(self) -> None:
"""Find and claim the SkyWalker-1 USB device."""
self.dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
if self.dev is None:
print("SkyWalker-1 not found (VID 0x09C0, PID 0x0203). Is it plugged in?")
sys.exit(1)
# Detach kernel driver if bound
for cfg in self.dev:
for intf in cfg:
if self.dev.is_kernel_driver_active(intf.bInterfaceNumber):
try:
self.dev.detach_kernel_driver(intf.bInterfaceNumber)
self.detached_intf = intf.bInterfaceNumber
if self.verbose:
print(f" Detached kernel driver from interface {intf.bInterfaceNumber}")
except usb.core.USBError as e:
print(f"Cannot detach kernel driver: {e}")
print("The gp8psk module must be unbound first. Try one of:")
print(" sudo modprobe -r dvb_usb_gp8psk")
print(" echo '<bus-path>' | sudo tee /sys/bus/usb/drivers/gp8psk/unbind")
sys.exit(1)
try:
self.dev.set_configuration()
except usb.core.USBError:
pass # May already be configured
def close(self) -> None:
"""Release device and re-attach kernel driver."""
if self.dev is None:
return
if self.detached_intf is not None:
try:
usb.util.release_interface(self.dev, self.detached_intf)
self.dev.attach_kernel_driver(self.detached_intf)
if self.verbose:
print("Re-attached kernel driver")
except usb.core.USBError:
print("Note: run 'sudo modprobe dvb_usb_gp8psk' to reload driver")
def __enter__(self):
self.open()
return self
def __exit__(self, *exc):
self.close()
# -- Low-level USB transfers --
def _vendor_in(self, request: int, value: int = 0, index: int = 0,
length: int = 64, retries: int = 3) -> bytes:
"""Vendor IN control transfer (device-to-host), with retry."""
for attempt in range(retries):
try:
data = self.dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
request, value, index, length, 2000
)
if self.verbose:
raw = bytes(data).hex(' ')
print(f" USB IN req=0x{request:02X} val=0x{value:04X} "
f"idx=0x{index:04X} -> [{len(data)}] {raw}")
if len(data) == length:
return bytes(data)
# Partial read, retry
if self.verbose:
print(f" Partial read ({len(data)}/{length}), retry {attempt + 1}")
continue
except usb.core.USBError as e:
if self.verbose:
print(f" USB IN req=0x{request:02X} FAILED: {e}")
if attempt == retries - 1:
raise
return bytes(data)
def _vendor_out(self, request: int, value: int = 0, index: int = 0,
data: bytes = b'') -> int:
"""Vendor OUT control transfer (host-to-device)."""
if self.verbose:
raw = data.hex(' ') if data else "(no data)"
print(f" USB OUT req=0x{request:02X} val=0x{value:04X} "
f"idx=0x{index:04X} data=[{len(data)}] {raw}")
return self.dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_OUT,
request, value, index, data, 2000
)
# -- Device info commands --
def get_config(self) -> int:
"""Read 8PSK config status byte (GET_8PSK_CONFIG 0x80)."""
data = self._vendor_in(CMD_GET_8PSK_CONFIG, length=1)
return data[0]
def get_fw_version(self) -> dict:
"""Read firmware version (GET_FW_VERS 0x92). Returns dict."""
data = self._vendor_in(CMD_GET_FW_VERS, length=6)
return {
"major": data[2],
"minor": data[1],
"patch": data[0],
"version": f"{data[2]}.{data[1]:02d}.{data[0]}",
"date": f"20{data[5]:02d}-{data[4]:02d}-{data[3]:02d}",
}
def get_signal_lock(self) -> bool:
"""Read signal lock status (GET_SIGNAL_LOCK 0x90)."""
data = self._vendor_in(CMD_GET_SIGNAL_LOCK, length=1)
return data[0] != 0
def get_signal_strength(self) -> dict:
"""Read signal strength (GET_SIGNAL_STRENGTH 0x87). Returns SNR info."""
data = self._vendor_in(CMD_GET_SIGNAL_STRENGTH, length=6)
snr_raw = struct.unpack_from('<H', data, 0)[0]
# SNR is in dBu * 256 units. Scale: snr * 17 maps to 0-65535.
snr_scaled = min(snr_raw * 17, 65535)
snr_pct = (snr_scaled / 65535) * 100
snr_db = snr_raw / 256.0
return {
"snr_raw": snr_raw,
"snr_db": snr_db,
"snr_pct": snr_pct,
"raw_bytes": bytes(data).hex(' '),
}
# -- Power and boot commands --
def boot(self, on: bool = True) -> int:
"""Power on/off the 8PSK demodulator (BOOT_8PSK 0x89)."""
data = self._vendor_in(CMD_BOOT_8PSK, value=int(on), length=1)
return data[0]
def start_intersil(self, on: bool = True) -> int:
"""Enable/disable LNB power supply (START_INTERSIL 0x8A)."""
data = self._vendor_in(CMD_START_INTERSIL, value=int(on), length=1)
return data[0]
def set_lnb_voltage(self, high: bool) -> None:
"""Set LNB voltage: high=True for 18V (H/L), high=False for 13V (V/R)."""
self._vendor_out(CMD_SET_LNB_VOLTAGE, value=int(high))
def set_22khz_tone(self, on: bool) -> None:
"""Enable/disable 22 kHz tone (SET_22KHZ_TONE 0x8C)."""
self._vendor_out(CMD_SET_22KHZ_TONE, value=int(on))
def set_extra_voltage(self, on: bool) -> None:
"""Enable +1V LNB boost: 13->14V, 18->19V (USE_EXTRA_VOLT 0x94)."""
self._vendor_out(CMD_USE_EXTRA_VOLT, value=int(on))
# -- Tuning --
def tune(self, symbol_rate_sps: int, freq_khz: int,
mod_index: int, fec_index: int) -> None:
"""Send TUNE_8PSK (0x86) with 10-byte payload."""
payload = struct.pack('<II', symbol_rate_sps, freq_khz)
payload += bytes([mod_index, fec_index])
self._vendor_out(CMD_TUNE_8PSK, data=payload)
# -- Streaming --
def arm_transfer(self, on: bool) -> None:
"""Start/stop MPEG-2 transport stream (ARM_TRANSFER 0x85)."""
self._vendor_out(CMD_ARM_TRANSFER, value=int(on))
def read_stream(self, size: int = EP2_URB_SIZE,
timeout: int = 1000) -> bytes:
"""Read a chunk from the TS bulk endpoint (EP2 0x82)."""
try:
data = self.dev.read(EP2_ADDR, size, timeout)
return bytes(data)
except usb.core.USBTimeoutError:
return b''
except usb.core.USBError as e:
if self.verbose:
print(f" EP2 read error: {e}")
return b''
# -- DiSEqC --
def send_diseqc_tone_burst(self, mini_cmd: int) -> None:
"""Send tone burst (mini-DiSEqC). 0=SEC_MINI_A, 1=SEC_MINI_B."""
self._vendor_out(CMD_SEND_DISEQC, value=mini_cmd)
def send_diseqc_message(self, msg: bytes) -> None:
"""Send full DiSEqC message (3-6 bytes). wValue = framing byte."""
if len(msg) < 3 or len(msg) > 6:
raise ValueError(f"DiSEqC message must be 3-6 bytes, got {len(msg)}")
self._vendor_out(CMD_SEND_DISEQC, value=msg[0], data=msg)
# -- Signal bar rendering --
def signal_bar(pct: float, width: int = 40) -> str:
"""Render a signal strength bar."""
filled = int(pct / 100 * width)
filled = max(0, min(filled, width))
bar = '#' * filled + '-' * (width - filled)
return f"[{bar}] {pct:.1f}%"
def format_config_bits(status: int) -> list:
"""Return list of (bit_name, is_set) tuples for config byte."""
result = []
for bit, (name, _field) in CONFIG_BITS.items():
result.append((name, bool(status & bit)))
return result
import usb.core
# -- Subcommand handlers --