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

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