Add DiSEqC motor control, QO-100 DATV reception, and carrier survey
Firmware v3.03.0: DiSEqC Manchester encoder (cmd 0x8D extended), parameterized spectrum sweep (0xBA), adaptive blind scan (0xBB), error code reporting (0xBC). All new function locals moved to XDATA to fit within FX2LP 256-byte internal RAM constraint. Motor control: DiSEqC 1.2 positioner with USALS GotoX, stored positions, interactive keyboard jog, 30-second safety auto-halt. QO-100 DATV: Es'hail-2 wideband transponder tools — LNB IF calculator, narrowband scan, tune, and TS-to-video pipe (ffplay/mpv). Carrier survey: six-stage pipeline (coarse sweep → peak detection → fine sweep → blind scan → TS sample → catalog). JSON catalog with differential analysis, QO-100 optimized mode, CSV/text export. TUI: F9 Motor screen (3-column layout with signal gauge), F10 Survey screen (Full Band + QO-100 tabs). Bridge, demo, and theme updated. Docs: motor.mdx, survey.mdx, qo100-datv.mdx guide, tui.mdx updated for 10 screens. Site builds 41 pages, all links valid.
This commit is contained in:
parent
0f4ba4766f
commit
cc3a0707a1
20 changed files with 5645 additions and 84 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue