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

@ -4,7 +4,9 @@
* *
* Stock-compatible vendor commands (0x80-0x94) plus custom * Stock-compatible vendor commands (0x80-0x94) plus custom
* spectrum sweep, raw demod access, blind scan (0xB0-0xB3), * spectrum sweep, raw demod access, blind scan (0xB0-0xB3),
* hardware diagnostics (0xB4-0xB6), and signal monitoring (0xB7-0xB9). * hardware diagnostics (0xB4-0xB6), signal monitoring (0xB7-0xB9),
* and advanced commands: parameterized sweep (0xBA), adaptive
* blind scan (0xBB), error codes (0xBC), DiSEqC messaging (0x8D).
* *
* SDCC + fx2lib toolchain. Loaded into FX2 RAM for testing. * SDCC + fx2lib toolchain. Loaded into FX2 RAM for testing.
*/ */
@ -57,6 +59,17 @@
#define SIGNAL_MONITOR 0xB7 #define SIGNAL_MONITOR 0xB7
#define TUNE_MONITOR 0xB8 #define TUNE_MONITOR 0xB8
#define MULTI_REG_READ 0xB9 #define MULTI_REG_READ 0xB9
#define PARAM_SWEEP 0xBA
#define ADAPTIVE_BLIND_SCAN 0xBB
#define GET_LAST_ERROR 0xBC
/* error codes (set by I2C helpers, read via 0xBC) */
#define ERR_OK 0x00
#define ERR_I2C_TIMEOUT 0x01
#define ERR_I2C_NAK 0x02
#define ERR_I2C_ARB_LOST 0x03
#define ERR_BCM_NOT_READY 0x04
#define ERR_BCM_TIMEOUT 0x05
/* configuration status byte bits */ /* configuration status byte bits */
#define BM_STARTED 0x01 #define BM_STARTED 0x01
@ -92,6 +105,15 @@ static __xdata BYTE i2c_rd[8];
/* TUNE_MONITOR result buffer: filled by OUT phase, returned by IN phase */ /* TUNE_MONITOR result buffer: filled by OUT phase, returned by IN phase */
static __xdata BYTE tm_result[10]; static __xdata BYTE tm_result[10];
/* DiSEqC message buffer (3-6 bytes) for full message transmission */
static __xdata BYTE diseqc_msg[6];
/* last error code for diagnostic reads via 0xBC */
static __xdata BYTE last_error;
/* Shared scratch buffer for vendor command case blocks (saves DSEG) */
static __xdata BYTE vc_diag[8];
/* /*
* BCM4500 register initialization data extracted from stock v2.06 firmware. * BCM4500 register initialization data extracted from stock v2.06 firmware.
* FUN_CODE_0ddd writes these 3 blocks to BCM4500 indirect registers (page 0) * FUN_CODE_0ddd writes these 3 blocks to BCM4500 indirect registers (page 0)
@ -123,8 +145,10 @@ static const __code BYTE bcm_init_block2[] = {
static BOOL i2c_wait_done(void) { static BOOL i2c_wait_done(void) {
WORD timeout = I2C_TIMEOUT; WORD timeout = I2C_TIMEOUT;
while (!(I2CS & bmDONE)) { while (!(I2CS & bmDONE)) {
if (--timeout == 0) if (--timeout == 0) {
last_error = ERR_I2C_TIMEOUT;
return FALSE; return FALSE;
}
} }
return TRUE; return TRUE;
} }
@ -154,23 +178,29 @@ static BOOL i2c_combined_read(BYTE addr, BYTE reg, BYTE len, BYTE *buf) {
I2DAT = addr << 1; I2DAT = addr << 1;
if (!i2c_wait_done()) if (!i2c_wait_done())
goto fail; goto fail;
if (!(I2CS & bmACK)) if (!(I2CS & bmACK)) {
last_error = ERR_I2C_NAK;
goto fail; goto fail;
}
/* Write register address */ /* Write register address */
I2DAT = reg; I2DAT = reg;
if (!i2c_wait_done()) if (!i2c_wait_done())
goto fail; goto fail;
if (!(I2CS & bmACK)) if (!(I2CS & bmACK)) {
last_error = ERR_I2C_NAK;
goto fail; goto fail;
}
/* REPEATED START + read address */ /* REPEATED START + read address */
I2CS |= bmSTART; I2CS |= bmSTART;
I2DAT = (addr << 1) | 1; I2DAT = (addr << 1) | 1;
if (!i2c_wait_done()) if (!i2c_wait_done())
goto fail; goto fail;
if (!(I2CS & bmACK)) if (!(I2CS & bmACK)) {
last_error = ERR_I2C_NAK;
goto fail; goto fail;
}
/* For single byte, set LASTRD before dummy read */ /* For single byte, set LASTRD before dummy read */
if (len == 1) if (len == 1)
@ -208,12 +238,12 @@ static BOOL i2c_write_timeout(BYTE addr, BYTE reg, BYTE val) {
I2CS |= bmSTART; I2CS |= bmSTART;
I2DAT = addr << 1; I2DAT = addr << 1;
if (!i2c_wait_done()) goto fail; if (!i2c_wait_done()) goto fail;
if (!(I2CS & bmACK)) goto fail; if (!(I2CS & bmACK)) { last_error = ERR_I2C_NAK; goto fail; }
/* Register address */ /* Register address */
I2DAT = reg; I2DAT = reg;
if (!i2c_wait_done()) goto fail; if (!i2c_wait_done()) goto fail;
if (!(I2CS & bmACK)) goto fail; if (!(I2CS & bmACK)) { last_error = ERR_I2C_NAK; goto fail; }
/* Data byte */ /* Data byte */
I2DAT = val; I2DAT = val;
@ -241,11 +271,11 @@ static BOOL i2c_write_multi_timeout(BYTE addr, BYTE reg, BYTE len,
I2CS |= bmSTART; I2CS |= bmSTART;
I2DAT = addr << 1; I2DAT = addr << 1;
if (!i2c_wait_done()) goto fail; if (!i2c_wait_done()) goto fail;
if (!(I2CS & bmACK)) goto fail; if (!(I2CS & bmACK)) { last_error = ERR_I2C_NAK; goto fail; }
I2DAT = reg; I2DAT = reg;
if (!i2c_wait_done()) goto fail; if (!i2c_wait_done()) goto fail;
if (!(I2CS & bmACK)) goto fail; if (!(I2CS & bmACK)) { last_error = ERR_I2C_NAK; goto fail; }
for (i = 0; i < len; i++) { for (i = 0; i < len; i++) {
I2DAT = data[i]; I2DAT = data[i];
@ -333,9 +363,12 @@ static BOOL bcm_poll_ready(void) {
if (bcm_direct_read(BCM_REG_CMD, &val)) { if (bcm_direct_read(BCM_REG_CMD, &val)) {
if (!(val & 0x01)) if (!(val & 0x01))
return TRUE; return TRUE;
} else {
return FALSE; /* I2C error, last_error already set */
} }
delay(2); delay(2);
} }
last_error = ERR_BCM_TIMEOUT;
return FALSE; return FALSE;
} }
@ -581,6 +614,330 @@ static void diseqc_tone_burst(BYTE sat_b) {
TR2 = 0; TR2 = 0;
} }
/* ---------- DiSEqC Manchester encoder ---------- */
/*
* DiSEqC uses Manchester encoding over a 22 kHz carrier.
* The external oscillator generates 22 kHz continuously when P0.3 is HIGH;
* gating P0.3 LOW silences the carrier. Timer2 provides ~500.25 us ticks.
*
* Timing (EN 50494 / DiSEqC bus spec):
* Bit '1': 1 tick tone + 2 ticks silence = ~1.5 ms
* Bit '0': 2 ticks tone + 1 tick silence = ~1.5 ms
* Preamble: 30 ticks continuous tone (~15 ms)
* Start gap: 3 ticks silence (~1.5 ms)
* Inter-byte gap: 12 ticks silence (~6 ms)
* Post-message: 12 ticks silence (~6 ms)
*/
static void diseqc_wait_ticks(BYTE count) {
static __xdata BYTE dt_i;
for (dt_i = 0; dt_i < count; dt_i++) {
while (!TF2)
;
TF2 = 0;
}
}
static BYTE diseqc_parity(BYTE val) {
/* Compute odd parity: returns 1 if even number of set bits */
BYTE p = val;
p ^= (p >> 4);
p ^= (p >> 2);
p ^= (p >> 1);
return (~p) & 0x01;
}
static void diseqc_send_bit(BYTE bit) {
if (bit) {
/* '1': 1 tick tone ON, 2 ticks silence */
IOA |= PIN_22KHZ;
diseqc_wait_ticks(1);
IOA &= ~PIN_22KHZ;
diseqc_wait_ticks(2);
} else {
/* '0': 2 ticks tone ON, 1 tick silence */
IOA |= PIN_22KHZ;
diseqc_wait_ticks(2);
IOA &= ~PIN_22KHZ;
diseqc_wait_ticks(1);
}
}
static void diseqc_send_byte(BYTE val) {
static __xdata BYTE db_i, db_parity;
/* 8 data bits, MSB first */
for (db_i = 0; db_i < 8; db_i++) {
diseqc_send_bit((val >> (7 - db_i)) & 0x01);
}
/* Odd parity bit */
db_parity = diseqc_parity(val);
diseqc_send_bit(db_parity);
}
static void diseqc_send_message(BYTE len) {
static __xdata BYTE dm_i, dm_saved_tone;
if (len < 3 || len > 6)
return;
/* Save current 22 kHz tone state */
dm_saved_tone = IOA & PIN_22KHZ;
/* Configure Timer2 for ~500 us ticks (same as tone burst) */
CKCON &= ~0x20; /* T2M=0: Timer2 clk = 48MHz/12 = 4MHz */
T2CON = 0x04; /* auto-reload, running */
RCAP2H = 0xF8;
RCAP2L = 0x2F; /* reload = 63535 -> ~500 us tick */
TL2 = 0xFF;
TH2 = 0xFF; /* force immediate overflow */
TF2 = 0;
/* Pre-message gap: 6 ticks silence (~3 ms) */
IOA &= ~PIN_22KHZ;
diseqc_wait_ticks(6);
/* Preamble: 30 ticks continuous tone (~15 ms) */
IOA |= PIN_22KHZ;
diseqc_wait_ticks(30);
/* Start gap: 3 ticks silence (~1.5 ms) */
IOA &= ~PIN_22KHZ;
diseqc_wait_ticks(3);
/* Transmit bytes */
for (dm_i = 0; dm_i < len; dm_i++) {
diseqc_send_byte(diseqc_msg[dm_i]);
/* Inter-byte gap after each byte except the last */
if (dm_i < len - 1) {
IOA &= ~PIN_22KHZ;
diseqc_wait_ticks(12);
}
}
/* Post-message gap: 12 ticks silence (~6 ms) */
IOA &= ~PIN_22KHZ;
diseqc_wait_ticks(12);
/* Stop Timer2 */
TR2 = 0;
/* Restore 22 kHz tone state */
if (dm_saved_tone)
IOA |= PIN_22KHZ;
else
IOA &= ~PIN_22KHZ;
}
/* ---------- Parameterized sweep (0xBA) ---------- */
/*
* Like SPECTRUM_SWEEP (0xB0) but host controls SR, modulation, and FEC.
* 16-byte EP0 payload:
* [0..3] start_freq_khz (u32 LE)
* [4..7] stop_freq_khz (u32 LE)
* [8..9] step_khz (u16 LE)
* [10..13] symbol_rate_sps (u32 LE)
* [14] mod_index
* [15] fec_index
*
* At each step: tune (program SR/mod/FEC via do_tune), dwell for AGC
* settling, read SNR registers, output u16 LE power to EP2.
*/
static void do_param_sweep(void) {
static __xdata DWORD ps_start, ps_stop, ps_cur, ps_sr;
static __xdata WORD ps_step, ps_buf_idx;
static __xdata BYTE ps_snr_lo, ps_snr_hi;
static __xdata BYTE ps_mod, ps_fec;
ps_start = (DWORD)EP0BUF[0] |
((DWORD)EP0BUF[1] << 8) |
((DWORD)EP0BUF[2] << 16) |
((DWORD)EP0BUF[3] << 24);
ps_stop = (DWORD)EP0BUF[4] |
((DWORD)EP0BUF[5] << 8) |
((DWORD)EP0BUF[6] << 16) |
((DWORD)EP0BUF[7] << 24);
ps_step = (WORD)EP0BUF[8] | ((WORD)EP0BUF[9] << 8);
ps_sr = (DWORD)EP0BUF[10] |
((DWORD)EP0BUF[11] << 8) |
((DWORD)EP0BUF[12] << 16) |
((DWORD)EP0BUF[13] << 24);
ps_mod = EP0BUF[14];
ps_fec = EP0BUF[15];
if (ps_step == 0)
ps_step = 1000;
ps_buf_idx = 0;
ps_cur = ps_start;
while (ps_cur <= ps_stop) {
/*
* Set up a tune payload in EP0BUF for do_tune():
* [0..3] = symbol_rate (LE), [4..7] = freq (LE), [8] = mod, [9] = fec
*/
EP0BUF[0] = (BYTE)(ps_sr);
EP0BUF[1] = (BYTE)(ps_sr >> 8);
EP0BUF[2] = (BYTE)(ps_sr >> 16);
EP0BUF[3] = (BYTE)(ps_sr >> 24);
EP0BUF[4] = (BYTE)(ps_cur);
EP0BUF[5] = (BYTE)(ps_cur >> 8);
EP0BUF[6] = (BYTE)(ps_cur >> 16);
EP0BUF[7] = (BYTE)(ps_cur >> 24);
EP0BUF[8] = ps_mod;
EP0BUF[9] = ps_fec;
do_tune();
/* Dwell for AGC settling */
delay(10);
/* Read signal strength via indirect register */
ps_snr_lo = 0;
ps_snr_hi = 0;
bcm_indirect_read(0x00, &ps_snr_lo);
bcm_indirect_read(0x01, &ps_snr_hi);
/* Store u16 LE into EP2 FIFO buffer */
if (ps_buf_idx < 1024 - 1) {
EP2FIFOBUF[ps_buf_idx++] = ps_snr_lo;
EP2FIFOBUF[ps_buf_idx++] = ps_snr_hi;
}
/* Commit chunk when buffer is half full */
if (ps_buf_idx >= 512) {
EP2BCH = MSB(ps_buf_idx);
SYNCDELAY;
EP2BCL = LSB(ps_buf_idx);
SYNCDELAY;
ps_buf_idx = 0;
while (EP2CS & bmEPFULL)
;
}
ps_cur += ps_step;
}
/* Commit remaining data */
if (ps_buf_idx > 0) {
EP2BCH = MSB(ps_buf_idx);
SYNCDELAY;
EP2BCL = LSB(ps_buf_idx);
SYNCDELAY;
}
}
/* ---------- Adaptive blind scan (0xBB) ---------- */
/*
* Enhanced blind scan with quick AGC pre-check.
* EP0 payload (18 bytes):
* [0..3] freq_khz (u32 LE)
* [4..7] sr_min (u32 LE, sps)
* [8..11] sr_max (u32 LE, sps)
* [12..15] sr_step (u32 LE, sps)
* [16..17] quick_dwell_ms (u16 LE, 0=disabled)
*
* When quick_dwell_ms > 0: at each SR step, first do a quick AGC read.
* If AGC indicates no energy (below threshold), skip the full 100ms dwell.
* Cuts survey time ~80% on empty frequencies.
*/
static BOOL do_adaptive_blind_scan(void) {
static __xdata DWORD abs_freq, abs_sr_min, abs_sr_max, abs_sr_step, abs_sr_cur;
static __xdata WORD abs_quick_dwell, abs_agc_val;
static __xdata BYTE abs_lock_val, abs_agc_lo, abs_agc_hi;
abs_freq = (DWORD)EP0BUF[0] |
((DWORD)EP0BUF[1] << 8) |
((DWORD)EP0BUF[2] << 16) |
((DWORD)EP0BUF[3] << 24);
abs_sr_min = (DWORD)EP0BUF[4] |
((DWORD)EP0BUF[5] << 8) |
((DWORD)EP0BUF[6] << 16) |
((DWORD)EP0BUF[7] << 24);
abs_sr_max = (DWORD)EP0BUF[8] |
((DWORD)EP0BUF[9] << 8) |
((DWORD)EP0BUF[10] << 16) |
((DWORD)EP0BUF[11] << 24);
abs_sr_step = (DWORD)EP0BUF[12] |
((DWORD)EP0BUF[13] << 8) |
((DWORD)EP0BUF[14] << 16) |
((DWORD)EP0BUF[15] << 24);
abs_quick_dwell = (WORD)EP0BUF[16] | ((WORD)EP0BUF[17] << 8);
if (abs_sr_step == 0)
abs_sr_step = 1000000;
abs_sr_cur = abs_sr_min;
while (abs_sr_cur <= abs_sr_max) {
/* Program SR and frequency into BCM4500 */
i2c_buf[0] = (BYTE)(abs_sr_cur >> 24);
i2c_buf[1] = (BYTE)(abs_sr_cur >> 16);
i2c_buf[2] = (BYTE)(abs_sr_cur >> 8);
i2c_buf[3] = (BYTE)(abs_sr_cur);
bcm_indirect_write_block(0x00, i2c_buf, 4);
i2c_buf[0] = (BYTE)(abs_freq >> 24);
i2c_buf[1] = (BYTE)(abs_freq >> 16);
i2c_buf[2] = (BYTE)(abs_freq >> 8);
i2c_buf[3] = (BYTE)(abs_freq);
bcm_indirect_write_block(0x00, i2c_buf, 4);
bcm_direct_write(BCM_REG_CMD, BCM_CMD_WRITE);
/* Quick AGC pre-check if enabled */
if (abs_quick_dwell > 0) {
delay((BYTE)(abs_quick_dwell > 255 ? 255 : abs_quick_dwell));
/* Read AGC registers for energy detection */
abs_agc_lo = 0;
abs_agc_hi = 0;
bcm_indirect_read(0x02, &abs_agc_lo);
bcm_indirect_read(0x03, &abs_agc_hi);
abs_agc_val = ((WORD)abs_agc_hi << 8) | abs_agc_lo;
/* High AGC = weak signal. Threshold: ~60000 means no energy.
* Skip full dwell if no energy detected. */
if (abs_agc_val > 60000) {
abs_sr_cur += abs_sr_step;
continue;
}
}
/* Full acquisition dwell */
delay(100);
/* Check lock */
abs_lock_val = 0;
bcm_direct_read(BCM_REG_LOCK, &abs_lock_val);
if (abs_lock_val & 0x20) {
EP0BUF[0] = (BYTE)(abs_freq);
EP0BUF[1] = (BYTE)(abs_freq >> 8);
EP0BUF[2] = (BYTE)(abs_freq >> 16);
EP0BUF[3] = (BYTE)(abs_freq >> 24);
EP0BUF[4] = (BYTE)(abs_sr_cur);
EP0BUF[5] = (BYTE)(abs_sr_cur >> 8);
EP0BUF[6] = (BYTE)(abs_sr_cur >> 16);
EP0BUF[7] = (BYTE)(abs_sr_cur >> 24);
EP0BCH = 0;
EP0BCL = 8;
return TRUE;
}
abs_sr_cur += abs_sr_step;
}
/* No lock found */
EP0BUF[0] = 0x00;
EP0BCH = 0;
EP0BCL = 1;
return FALSE;
}
/* ---------- Spectrum sweep (0xB0) ---------- */ /* ---------- Spectrum sweep (0xB0) ---------- */
/* /*
@ -600,9 +957,8 @@ static void diseqc_tone_burst(BYTE sat_b) {
*/ */
static void do_spectrum_sweep(void) { static void do_spectrum_sweep(void) {
static __xdata DWORD start_freq, stop_freq, cur_freq; static __xdata DWORD start_freq, stop_freq, cur_freq;
static __xdata WORD step_khz; static __xdata WORD step_khz, ss_buf_idx;
WORD buf_idx; static __xdata BYTE ss_snr_lo, ss_snr_hi;
BYTE snr_lo, snr_hi;
/* Parse the 10-byte EP0 payload */ /* Parse the 10-byte EP0 payload */
start_freq = (DWORD)EP0BUF[0] | start_freq = (DWORD)EP0BUF[0] |
@ -618,7 +974,7 @@ static void do_spectrum_sweep(void) {
if (step_khz == 0) if (step_khz == 0)
step_khz = 1000; step_khz = 1000;
buf_idx = 0; ss_buf_idx = 0;
cur_freq = start_freq; cur_freq = start_freq;
while (cur_freq <= stop_freq) { while (cur_freq <= stop_freq) {
@ -637,24 +993,24 @@ static void do_spectrum_sweep(void) {
delay(10); delay(10);
/* Read signal strength via indirect register */ /* Read signal strength via indirect register */
snr_lo = 0; ss_snr_lo = 0;
snr_hi = 0; ss_snr_hi = 0;
bcm_indirect_read(0x00, &snr_lo); bcm_indirect_read(0x00, &ss_snr_lo);
bcm_indirect_read(0x01, &snr_hi); bcm_indirect_read(0x01, &ss_snr_hi);
/* Store u16 LE into EP2 FIFO buffer */ /* Store u16 LE into EP2 FIFO buffer */
if (buf_idx < 1024 - 1) { if (ss_buf_idx < 1024 - 1) {
EP2FIFOBUF[buf_idx++] = snr_lo; EP2FIFOBUF[ss_buf_idx++] = ss_snr_lo;
EP2FIFOBUF[buf_idx++] = snr_hi; EP2FIFOBUF[ss_buf_idx++] = ss_snr_hi;
} }
/* If buffer is nearly full, commit this chunk */ /* If buffer is nearly full, commit this chunk */
if (buf_idx >= 512) { if (ss_buf_idx >= 512) {
EP2BCH = MSB(buf_idx); EP2BCH = MSB(ss_buf_idx);
SYNCDELAY; SYNCDELAY;
EP2BCL = LSB(buf_idx); EP2BCL = LSB(ss_buf_idx);
SYNCDELAY; SYNCDELAY;
buf_idx = 0; ss_buf_idx = 0;
/* Wait for the buffer to be taken by host */ /* Wait for the buffer to be taken by host */
while (EP2CS & bmEPFULL) while (EP2CS & bmEPFULL)
@ -665,10 +1021,10 @@ static void do_spectrum_sweep(void) {
} }
/* Commit any remaining data */ /* Commit any remaining data */
if (buf_idx > 0) { if (ss_buf_idx > 0) {
EP2BCH = MSB(buf_idx); EP2BCH = MSB(ss_buf_idx);
SYNCDELAY; SYNCDELAY;
EP2BCL = LSB(buf_idx); EP2BCL = LSB(ss_buf_idx);
SYNCDELAY; SYNCDELAY;
} }
} }
@ -690,7 +1046,7 @@ static void do_spectrum_sweep(void) {
*/ */
static BOOL do_blind_scan(void) { static BOOL do_blind_scan(void) {
static __xdata DWORD freq_khz, sr_min, sr_max, sr_step, sr_cur; static __xdata DWORD freq_khz, sr_min, sr_max, sr_step, sr_cur;
BYTE lock_val; static __xdata BYTE bs_lock_val;
freq_khz = (DWORD)EP0BUF[0] | freq_khz = (DWORD)EP0BUF[0] |
((DWORD)EP0BUF[1] << 8) | ((DWORD)EP0BUF[1] << 8) |
@ -737,9 +1093,9 @@ static BOOL do_blind_scan(void) {
delay(100); delay(100);
/* Check lock */ /* Check lock */
lock_val = 0; bs_lock_val = 0;
bcm_direct_read(BCM_REG_LOCK, &lock_val); bcm_direct_read(BCM_REG_LOCK, &bs_lock_val);
if (lock_val & 0x20) { if (bs_lock_val & 0x20) {
/* Locked -- report back via EP0 */ /* Locked -- report back via EP0 */
EP0BUF[0] = (BYTE)(freq_khz); EP0BUF[0] = (BYTE)(freq_khz);
EP0BUF[1] = (BYTE)(freq_khz >> 8); EP0BUF[1] = (BYTE)(freq_khz >> 8);
@ -775,8 +1131,8 @@ static BOOL do_blind_scan(void) {
* EP0BUF[9] = FEC index * EP0BUF[9] = FEC index
*/ */
static void do_tune(void) { static void do_tune(void) {
BYTE i; static __xdata BYTE tune_i;
__xdata BYTE tune_data[12]; static __xdata BYTE tune_data[13]; /* 12 data + 1 scratch for reg addr */
if (!(config_status & BM_STARTED)) if (!(config_status & BM_STARTED))
return; return;
@ -785,9 +1141,9 @@ static void do_tune(void) {
* Byte-reverse symbol rate (LE->BE) into tune_data[0..3] * Byte-reverse symbol rate (LE->BE) into tune_data[0..3]
* and frequency (LE->BE) into tune_data[4..7] * and frequency (LE->BE) into tune_data[4..7]
*/ */
for (i = 0; i < 4; i++) { for (tune_i = 0; tune_i < 4; tune_i++) {
tune_data[i] = EP0BUF[3 - i]; /* SR BE */ tune_data[tune_i] = EP0BUF[3 - tune_i]; /* SR BE */
tune_data[4 + i] = EP0BUF[7 - i]; /* Freq BE */ tune_data[4 + tune_i] = EP0BUF[7 - tune_i]; /* Freq BE */
} }
/* Modulation type and FEC rate */ /* Modulation type and FEC rate */
@ -817,10 +1173,8 @@ static void do_tune(void) {
bcm_direct_write(BCM_REG_PAGE, 0x00); bcm_direct_write(BCM_REG_PAGE, 0x00);
/* Write all configuration data to BCM4500 data register */ /* Write all configuration data to BCM4500 data register */
{ tune_data[12] = BCM_REG_DATA; /* borrow byte past data (safe: 13 bytes in xdata) */
BYTE reg = BCM_REG_DATA; i2c_write(BCM4500_ADDR, 1, &tune_data[12], 12, tune_data);
i2c_write(BCM4500_ADDR, 1, &reg, 12, tune_data);
}
/* Execute indirect write */ /* Execute indirect write */
bcm_direct_write(BCM_REG_CMD, BCM_CMD_WRITE); bcm_direct_write(BCM_REG_CMD, BCM_CMD_WRITE);
@ -1044,8 +1398,25 @@ BOOL handle_vendorcommand(BYTE cmd) {
if (wlen == 0) { if (wlen == 0) {
/* Tone burst: A if wval==0, B if wval!=0 */ /* Tone burst: A if wval==0, B if wval!=0 */
diseqc_tone_burst((BYTE)wval); diseqc_tone_burst((BYTE)wval);
} else if (wlen >= 3 && wlen <= 6) {
/* Full DiSEqC message: reject if streaming */
if (config_status & BM_ARMED) {
last_error = ERR_BCM_NOT_READY;
return TRUE;
}
/* EP0 data phase: receive message bytes */
EP0BCL = 0;
SYNCDELAY;
while (EP0CS & bmEPBUSY)
;
/* Copy message from EP0BUF to diseqc_msg buffer */
{
BYTE di;
for (di = 0; di < (BYTE)wlen; di++)
diseqc_msg[di] = EP0BUF[di];
}
diseqc_send_message((BYTE)wlen);
} }
/* Full DiSEqC message: future implementation */
return TRUE; return TRUE;
} }
@ -1062,10 +1433,10 @@ BOOL handle_vendorcommand(BYTE cmd) {
/* 0x92: GET_FW_VERS -- return firmware version and build date */ /* 0x92: GET_FW_VERS -- return firmware version and build date */
case GET_FW_VERS: case GET_FW_VERS:
EP0BUF[0] = 0x00; /* patch -> version 3.02.0 */ EP0BUF[0] = 0x00; /* patch -> version 3.03.0 */
EP0BUF[1] = 0x02; /* minor */ EP0BUF[1] = 0x03; /* minor */
EP0BUF[2] = 0x03; /* major */ EP0BUF[2] = 0x03; /* major */
EP0BUF[3] = 0x0C; /* day = 12 */ EP0BUF[3] = 0x0F; /* day = 15 */
EP0BUF[4] = 0x02; /* month = 2 */ EP0BUF[4] = 0x02; /* month = 2 */
EP0BUF[5] = 0x1A; /* year - 2000 = 26 */ EP0BUF[5] = 0x1A; /* year - 2000 = 26 */
EP0BCH = 0; EP0BCH = 0;
@ -1171,53 +1542,51 @@ BOOL handle_vendorcommand(BYTE cmd) {
* Returns 8 bytes: [write_A6_ok, readback_A6, write_A8_ok, readback_A8, * Returns 8 bytes: [write_A6_ok, readback_A6, write_A8_ok, readback_A8,
* readback_A7, direct_read_A6, direct_read_A7, direct_read_A8] */ * readback_A7, direct_read_A6, direct_read_A7, direct_read_A8] */
case 0xB6: { case 0xB6: {
BYTE target_reg = (BYTE)wval; /* Use shared xdata diag buffer to save DSEG */
BYTE diag[8]; vc_diag[0] = (BYTE)wval; /* target_reg */
BYTE rb;
/* Step 1: Write target register to page select (0xA6) */ /* Step 1: Write target register to page select (0xA6) */
diag[0] = bcm_direct_write(BCM_REG_PAGE, target_reg) ? 0x01 : 0x00; vc_diag[1] = bcm_direct_write(BCM_REG_PAGE, vc_diag[0]) ? 0x01 : 0x00;
/* Step 2: Read back 0xA6 to verify write */ /* Step 2: Read back 0xA6 to verify write */
rb = 0xEE; vc_diag[2] = 0xEE;
i2c_combined_read(BCM4500_ADDR, BCM_REG_PAGE, 1, &rb); i2c_combined_read(BCM4500_ADDR, BCM_REG_PAGE, 1, &vc_diag[2]);
diag[1] = rb;
/* Step 3: Write read command (0x01) to 0xA8 */ /* Step 3: Write read command (0x01) to 0xA8 */
diag[2] = bcm_direct_write(BCM_REG_CMD, BCM_CMD_READ) ? 0x01 : 0x00; vc_diag[3] = bcm_direct_write(BCM_REG_CMD, BCM_CMD_READ) ? 0x01 : 0x00;
/* Step 4: Read back 0xA8 to check command status */ /* Step 4: Read back 0xA8 to check command status */
rb = 0xEE; vc_diag[4] = 0xEE;
i2c_combined_read(BCM4500_ADDR, BCM_REG_CMD, 1, &rb); i2c_combined_read(BCM4500_ADDR, BCM_REG_CMD, 1, &vc_diag[4]);
diag[3] = rb;
/* Step 5: Small delay for command execution */ /* Step 5: Small delay for command execution */
delay(2); delay(2);
/* Step 6: Read 0xA7 (data register) — this is the result */ /* Step 6: Read 0xA7 (data register) — this is the result */
rb = 0xEE; vc_diag[5] = 0xEE;
i2c_combined_read(BCM4500_ADDR, BCM_REG_DATA, 1, &rb); i2c_combined_read(BCM4500_ADDR, BCM_REG_DATA, 1, &vc_diag[5]);
diag[4] = rb;
/* Step 7: Read back all three control regs for final state */ /* Step 7: Read back all three control regs for final state */
rb = 0xEE; vc_diag[6] = 0xEE;
i2c_combined_read(BCM4500_ADDR, BCM_REG_PAGE, 1, &rb); i2c_combined_read(BCM4500_ADDR, BCM_REG_PAGE, 1, &vc_diag[6]);
diag[5] = rb; vc_diag[7] = 0xEE;
rb = 0xEE; i2c_combined_read(BCM4500_ADDR, BCM_REG_DATA, 1, &vc_diag[7]);
i2c_combined_read(BCM4500_ADDR, BCM_REG_DATA, 1, &rb);
diag[6] = rb; EP0BUF[0] = vc_diag[1]; /* write_A6_ok */
rb = 0xEE; EP0BUF[1] = vc_diag[2]; /* readback_A6 */
i2c_combined_read(BCM4500_ADDR, BCM_REG_CMD, 1, &rb); EP0BUF[2] = vc_diag[3]; /* write_A8_ok */
diag[7] = rb; EP0BUF[3] = vc_diag[4]; /* readback_A8 */
EP0BUF[4] = vc_diag[5]; /* readback_A7 */
EP0BUF[5] = vc_diag[6]; /* direct_read_A6 */
/* Read remaining registers directly into EP0BUF */
vc_diag[6] = 0xEE;
i2c_combined_read(BCM4500_ADDR, BCM_REG_DATA, 1, &vc_diag[6]);
EP0BUF[6] = vc_diag[6]; /* direct_read_A7 */
vc_diag[7] = 0xEE;
i2c_combined_read(BCM4500_ADDR, BCM_REG_CMD, 1, &vc_diag[7]);
EP0BUF[7] = vc_diag[7]; /* direct_read_A8 */
EP0BUF[0] = diag[0];
EP0BUF[1] = diag[1];
EP0BUF[2] = diag[2];
EP0BUF[3] = diag[3];
EP0BUF[4] = diag[4];
EP0BUF[5] = diag[5];
EP0BUF[6] = diag[6];
EP0BUF[7] = diag[7];
EP0BCH = 0; EP0BCH = 0;
EP0BCL = 8; EP0BCL = 8;
return TRUE; return TRUE;
@ -1307,6 +1676,31 @@ BOOL handle_vendorcommand(BYTE cmd) {
return TRUE; return TRUE;
} }
/* 0xBA: PARAM_SWEEP -- parameterized spectrum sweep */
case PARAM_SWEEP:
EP0BCL = 0;
SYNCDELAY;
while (EP0CS & bmEPBUSY)
;
do_param_sweep();
return TRUE;
/* 0xBB: ADAPTIVE_BLIND_SCAN -- blind scan with AGC pre-check */
case ADAPTIVE_BLIND_SCAN:
EP0BCL = 0;
SYNCDELAY;
while (EP0CS & bmEPBUSY)
;
do_adaptive_blind_scan();
return TRUE;
/* 0xBC: GET_LAST_ERROR -- return diagnostic error code */
case GET_LAST_ERROR:
EP0BUF[0] = last_error;
EP0BCH = 0;
EP0BCL = 1;
return TRUE;
default: default:
return FALSE; return FALSE;
} }
@ -1365,6 +1759,7 @@ void hispeed_isr(void) __interrupt (HISPEED_ISR) {
void main(void) { void main(void) {
config_status = 0; config_status = 0;
last_error = ERR_OK;
got_sud = FALSE; got_sud = FALSE;
REVCTL = 0x03; /* NOAUTOARM + SKIPCOMMIT */ REVCTL = 0x03; /* NOAUTOARM + SKIPCOMMIT */

View File

@ -114,12 +114,20 @@ export default defineConfig({
{ label: 'Tuning', slug: 'tools/tuning' }, { label: 'Tuning', slug: 'tools/tuning' },
{ label: 'SkyWalker RF Tool', slug: 'tools/skywalker' }, { label: 'SkyWalker RF Tool', slug: 'tools/skywalker' },
{ label: 'SkyWalker TUI', slug: 'tools/tui' }, { label: 'SkyWalker TUI', slug: 'tools/tui' },
{ label: 'Motor Control', slug: 'tools/motor' },
{ label: 'Carrier Survey', slug: 'tools/survey' },
{ label: 'EEPROM Utilities', slug: 'tools/eeprom-utilities' }, { label: 'EEPROM Utilities', slug: 'tools/eeprom-utilities' },
{ label: 'Debugging', slug: 'tools/debugging' }, { label: 'Debugging', slug: 'tools/debugging' },
{ label: 'TS Analyzer', slug: 'tools/ts-analyzer' }, { label: 'TS Analyzer', slug: 'tools/ts-analyzer' },
{ label: 'Spectrum Analysis', slug: 'tools/spectrum-analysis' }, { label: 'Spectrum Analysis', slug: 'tools/spectrum-analysis' },
], ],
}, },
{
label: 'Guides',
items: [
{ label: 'QO-100 DATV Reception', slug: 'guides/qo100-datv' },
],
},
{ {
label: 'Reference', label: 'Reference',
items: [ items: [

View File

@ -0,0 +1,175 @@
---
title: QO-100 DATV Reception
description: Receiving amateur digital television from the Es'hail-2 geostationary satellite using the SkyWalker-1 DVB-S receiver.
---
import { Tabs, TabItem, Steps, Aside, Badge, CardGrid, Card } from '@astrojs/starlight/components';
The Es'hail-2 satellite (25.9 degrees East) carries the first geostationary amateur radio transponders, designated QO-100. The wideband transponder at 10491-10499 MHz carries DATV (Digital Amateur Television) signals that the SkyWalker-1 can receive — with some important caveats about symbol rate limitations and LNB selection.
<Badge text="Requires custom firmware v3.03.0+" variant="caution" />
## Overview
<CardGrid>
<Card title="Wideband Transponder" icon="rocket">
10491.000 - 10499.500 MHz (8.5 MHz bandwidth). Carries DVB-S DATV stations from amateur operators worldwide.
</Card>
<Card title="Engineering Beacon" icon="star">
10489.750 MHz CW/PSK beacon. Useful for dish pointing and LNB drift calibration.
</Card>
<Card title="Orbital Position" icon="sun">
25.9 degrees East — visible from Europe, Africa, western Asia, and eastern Americas.
</Card>
<Card title="Minimum Dish Size" icon="setting">
60 cm in central Europe, 1.2m+ at the edges of the footprint.
</Card>
</CardGrid>
## LNB Selection
The SkyWalker-1 accepts IF frequencies in the 950-2150 MHz range. The LNB's local oscillator frequency determines where the QO-100 signals appear in this window.
| LNB Type | LO Frequency | QO-100 IF Range | Compatible? |
|----------|-------------|-----------------|-------------|
| Universal (low band) | 9750 MHz | 741-749 MHz | No — below 950 MHz minimum |
| Universal (high band) | 10600 MHz | -109 to -101 MHz | No — negative IF |
| Modified universal | 9361 MHz | 1130-1138 MHz | Yes |
| TCXO/PLL stabilized | 9750 MHz | 741-749 MHz | No — same issue |
| Custom downconverter | varies | varies | Check IF range |
<Aside type="caution">
A **standard universal LNB** at 9750 MHz places the QO-100 wideband transponder at 741-749 MHz IF, which is **below the SkyWalker-1's 950 MHz minimum**. You need either a modified LNB or an external downconverter to shift the IF into the 950-2150 MHz range.
</Aside>
### Modified LNB Option
The most common approach among QO-100 operators is modifying a universal LNB by replacing the crystal oscillator (typically 25 MHz) with one that produces a different LO frequency. A 24.000 MHz crystal in a universal LNB that normally multiplies by 390 (9750 MHz) will produce 9360 MHz instead, placing QO-100 at 1131-1139 MHz IF.
Popular modified LO frequencies and their resulting IF ranges:
| Crystal | Multiplier | LO (MHz) | QO-100 IF (MHz) |
|---------|-----------|----------|-----------------|
| 24.000 MHz | x390 | 9360 | 1131-1139 |
| 24.385 MHz | x384 | 9363 | 1128-1136 |
| 25.000 MHz | x374 | 9350 | 1141-1149 |
| 27.000 MHz | x348 | 9396 | 1095-1103 |
## Quick Start
<Steps>
1. **Verify LNB compatibility** — check that your LO frequency places QO-100 in the 950-2150 MHz range:
```bash
python3 tools/qo100.py calc --lnb-lo 9361
```
2. **Point the dish** — aim at 25.9 degrees East. Use the motor control tool or manual positioning:
```bash
sudo python3 tools/motor.py gotox --sat 25.9 --lon -97.5
```
3. **Scan for carriers** — sweep the QO-100 IF range:
```bash
sudo python3 tools/qo100.py scan --lnb-lo 9361
```
4. **Tune to a carrier** — lock onto a detected DATV station:
```bash
sudo python3 tools/qo100.py tune --freq 1135 --sr 1000 --lnb-lo 9361
```
5. **Watch live video** — pipe the transport stream to a media player:
```bash
sudo python3 tools/qo100.py watch --freq 1135 --sr 1000 --lnb-lo 9361
```
</Steps>
## CLI Reference
### Calculate IF Frequencies
```bash
python3 tools/qo100.py calc --lnb-lo 9361
```
Displays the QO-100 wideband transponder IF range, known DATV frequencies with their IF equivalents, and the beacon frequency. No hardware required — pure calculation.
### Band Plan
```bash
python3 tools/qo100.py band-plan --lnb-lo 9361
```
Shows the complete QO-100 wideband transponder band plan with known station frequencies converted to your LNB's IF range. Includes both the wideband DATV segment and the engineering beacon.
### Scan
```bash
sudo python3 tools/qo100.py scan --lnb-lo 9361
```
Sweeps the QO-100 IF range with parameters tuned for narrowband DATV:
- 250 kHz frequency steps (vs 5 MHz for broadcast)
- 1000 ksps measurement symbol rate
- Extended dwell time for weak signal detection
### Tune
```bash
sudo python3 tools/qo100.py tune --freq 1135 --sr 1000 --lnb-lo 9361
```
Tunes the BCM4500 to the specified IF frequency and symbol rate, then monitors lock status and signal quality. The `--freq` parameter is the IF frequency (after LNB downconversion), not the RF frequency.
### Watch
```bash
sudo python3 tools/qo100.py watch --freq 1135 --sr 1000 --lnb-lo 9361
```
Tunes, locks, and pipes the raw MPEG-2 transport stream to `ffplay` (or `mpv` if ffplay is not found). The video player receives the stream on stdin and renders it in real time.
| Flag | Default | Description |
|------|---------|-------------|
| `--freq` | — | IF frequency in MHz (required) |
| `--sr` | 1000 | Symbol rate in ksps |
| `--lnb-lo` | — | LNB local oscillator in MHz (required) |
| `--player` | auto | Video player command (`ffplay` or `mpv`) |
## Hardware Limitations
### Minimum Symbol Rate
The BCM4500 demodulator has a minimum symbol rate of **256 ksps** (256,000 symbols per second). Many QO-100 DATV stations transmit at lower rates:
| Typical QO-100 SR | BCM4500 Support |
|-------------------|----------------|
| 125 ksps | Detectable as energy, cannot lock |
| 250 ksps | Marginal — may lock with strong signal |
| 333 ksps | Supported |
| 500 ksps | Supported |
| 1000 ksps | Supported |
| 2000 ksps | Supported |
The survey tool distinguishes "carrier detected" (energy above noise floor) from "carrier locked" (BCM4500 achieved demodulator lock) in its output.
### Lock Acquisition Time
QO-100 signals are weaker than broadcast satellites. The BCM4500 may need 10-30 seconds to achieve lock at low symbol rates, compared to 1-2 seconds for typical broadcast transponders. The `qo100.py tune` command uses extended timeouts automatically.
### Frequency Stability
QO-100 DATV signals have tighter frequency tolerance requirements than the SkyWalker-1's default tuning resolution. A PLL-stabilized or TCXO LNB provides better frequency accuracy than a standard DRO LNB, reducing the chance of the demodulator losing lock due to LNB drift.
## Using the TUI
The SkyWalker TUI includes QO-100 as a tab within the Survey screen (<kbd>F10</kbd>). The QO-100 tab pre-fills the sweep parameters with values optimized for the wideband transponder and includes an info panel showing known station frequencies adjusted for your LNB's local oscillator.
See [SkyWalker TUI — Survey Screen](/tools/tui/#survey--qo-100) for details.
## See Also
- [Motor Control](/tools/motor/) — DiSEqC 1.2 positioner for dish pointing
- [Carrier Survey](/tools/survey/) — automated carrier detection and cataloging
- [LNB Control](/lnb-diseqc/lnb-control/) — LNB power and voltage configuration
- [RF Specifications](/hardware/rf-specifications/) — SkyWalker-1 frequency range and sensitivity

View File

@ -0,0 +1,107 @@
---
title: Motor Control
description: DiSEqC 1.2 positioner motor control with USALS GotoX, stored positions, and live signal feedback for dish alignment.
---
import { Tabs, TabItem, Steps, Aside, Badge } from '@astrojs/starlight/components';
Command-line tool for driving a DiSEqC 1.2 positioner motor through the SkyWalker-1. Supports continuous jog, stored positions, USALS GotoX calculations, and an interactive keyboard-driven mode with live signal feedback for hands-free dish alignment.
<Badge text="Requires custom firmware v3.03.0+" variant="caution" />
## Usage
```bash title="Interactive motor jog with live signal"
sudo python3 tools/motor.py interactive
```
```bash title="Drive to a stored position"
sudo python3 tools/motor.py goto 3
```
```bash title="USALS GotoX (Es'hail-2 from central Texas)"
sudo python3 tools/motor.py gotox --sat 25.9 --lon -97.5
```
## Subcommands
| Command | Description |
|---------|-------------|
| `halt` | Emergency stop the motor |
| `east [--steps N]` | Drive east (continuous or N steps) |
| `west [--steps N]` | Drive west (continuous or N steps) |
| `goto SLOT` | Recall stored position (0 = reference) |
| `store SLOT` | Store current position in slot (1-255) |
| `gotox --sat LON --lon LON` | USALS GotoX with calculated rotation angle |
| `limit east\|west` | Set east or west movement limit |
| `nolimits` | Disable movement limits |
| `raw HEX` | Send raw DiSEqC bytes (e.g. `E0 31 60`) |
| `interactive` | Keyboard-driven jog with live signal display |
## Interactive Mode
The `interactive` subcommand turns the terminal into a real-time motor controller optimized for dish alignment. No menus, no prompts — just directional input with instant signal feedback.
| Key | Action |
|-----|--------|
| <kbd>Left</kbd> / <kbd>h</kbd> | Jog west |
| <kbd>Right</kbd> / <kbd>l</kbd> | Jog east |
| <kbd>Space</kbd> | Halt motor |
| <kbd>1</kbd><kbd>9</kbd> | Recall stored position |
| <kbd>s</kbd> | Store current position (prompts for slot) |
| <kbd>g</kbd> | USALS GotoX (prompts for coordinates) |
| <kbd>q</kbd> | Quit (motor auto-halts on exit) |
Signal readings update at ~2 Hz, showing SNR, AGC power, and lock status. The display uses ANSI color to distinguish locked (green) from unlocked (red) states.
### Safety Features
- **30-second auto-halt** — continuous jog stops automatically after 30 seconds to prevent mechanical damage
- **Exit handler** — motor is halted via `atexit` if the process terminates unexpectedly
- **Limit enforcement** — DiSEqC 1.2 hardware limits are respected when set
<Aside type="caution">
The motor continues to drive until explicitly halted. If the SkyWalker-1 loses USB power during a jog, the motor will keep moving until it hits a hardware limit. Always set east/west limits before extended use.
</Aside>
## USALS GotoX
The `gotox` subcommand calculates the optimal motor rotation angle using the USALS (Universal Satellites Automatic Location System) algorithm and sends a DiSEqC 1.3 GotoX command.
```bash title="Calculate and drive to Es'hail-2 (QO-100)"
sudo python3 tools/motor.py gotox --sat 25.9 --lon -97.5
# Output: Angle: 72.4 deg East — sending GotoX
```
The angle calculation accounts for:
- Observer longitude (negative = West)
- Satellite longitude (positive = East)
- Geostationary orbit geometry at 35,786 km altitude
<Aside type="tip">
For QO-100 reception from North America, the motor angle is extreme (~70+ degrees East). Verify your positioner has sufficient travel range before issuing the command.
</Aside>
## DiSEqC 1.2 Protocol
All motor commands use DiSEqC 1.2 (EN 50494) framing transmitted through the SkyWalker-1's Manchester encoder. The firmware generates the 22 kHz modulated waveform with correct timing:
| Command | DiSEqC Bytes | Description |
|---------|-------------|-------------|
| Halt | `E0 31 60` | Stop motor movement |
| Drive East | `E0 31 68 00` | Continuous east (00 = no step limit) |
| Drive West | `E0 31 69 00` | Continuous west |
| Store Position | `E0 31 6A NN` | Save current position to slot NN |
| Goto Position | `E0 31 6B NN` | Drive to stored position NN |
| Set East Limit | `E0 31 66 00` | Set current position as east limit |
| Set West Limit | `E0 31 66 01` | Set current position as west limit |
| Disable Limits | `E0 31 63` | Remove movement limits |
| GotoX | `E0 31 6E HH LL` | USALS rotation (angle encoded as 2 bytes) |
The `raw` subcommand accepts any hex string for advanced DiSEqC messaging beyond the built-in commands.
## See Also
- [SkyWalker TUI — Motor Screen](/tools/tui/#motor-control) — graphical motor control with signal gauge
- [DiSEqC Protocol](/lnb-diseqc/diseqc-protocol/) — protocol specification and Manchester encoding details
- [QO-100 DATV Reception](/guides/qo100-datv/) — using motor control for QO-100 dish pointing

View File

@ -0,0 +1,160 @@
---
title: Carrier Survey
description: Automated six-stage carrier detection, cataloging, and differential analysis across the full IF range or QO-100 narrowband transponder.
---
import { Tabs, TabItem, Steps, Aside, Badge, CardGrid, Card } from '@astrojs/starlight/components';
Automated carrier survey tool that sweeps the IF range, detects signals above the noise floor, identifies locked transponders with blind scan, and catalogs results with service name extraction from the transport stream. Supports full-band surveys, quick scans, QO-100 narrowband sweeps, and differential analysis between survey snapshots.
<Badge text="Requires custom firmware v3.03.0+" variant="caution" />
## Quick Start
```bash title="Full six-stage survey"
sudo python3 tools/survey.py full-scan
```
```bash title="Quick sweep (no blind scan)"
sudo python3 tools/survey.py quick-scan
```
```bash title="QO-100 narrowband survey"
sudo python3 tools/survey.py qo100 --lnb-lo 9750
```
```bash title="Compare two surveys"
python3 tools/survey.py diff survey-old.json survey-new.json
```
## Subcommands
| Command | Description |
|---------|-------------|
| `full-scan` | Complete 6-stage survey with carrier identification |
| `quick-scan` | Sweep + peak detection only (stages 1-2) |
| `diff FILE1 FILE2` | Compare two survey catalogs |
| `export FILE` | Export to CSV, JSON, or text |
| `view [FILE]` | Display latest or specified survey |
| `qo100 --lnb-lo LO` | QO-100 transponder survey with optimized parameters |
## Survey Pipeline
The full survey runs six stages sequentially. Each stage refines the previous stage's results.
<Steps>
1. **Coarse sweep** — full IF range (950-2150 MHz default) at 5 MHz steps. Measures signal power at each frequency using the firmware's spectrum sweep command.
2. **Peak detection** — adaptive noise floor estimation using median + MAD (Median Absolute Deviation), followed by local maxima finding with -3 dB bandwidth estimation and peak merging.
3. **Fine sweep** — +/-10 MHz around each detected peak at 1 MHz steps. Refines center frequencies from the coarse sweep.
4. **Blind scan** — at each refined peak, tries symbol rates from 1-30 Msps in 1 Msps steps using the firmware's adaptive blind scan (AGC pre-check skips empty frequencies). Reports lock status with frequency and symbol rate.
5. **TS sample** — for locked carriers, captures 3 seconds of transport stream data and parses PAT, PMT, and SDT tables to extract program numbers, stream types, service names, and provider names.
6. **Catalog assembly** — aggregates all results into a `CarrierCatalog` with full metadata, saves to `~/.skywalker1/surveys/`.
</Steps>
### Full Scan Options
| Flag | Default | Description |
|------|---------|-------------|
| `--start` | 950 | Start frequency (MHz) |
| `--stop` | 2150 | Stop frequency (MHz) |
| `--coarse-step` | 5.0 | Coarse sweep step (MHz) |
| `--fine-step` | 1.0 | Fine sweep step (MHz) |
| `--sr-min` | 1,000,000 | Min symbol rate (sps) |
| `--sr-max` | 30,000,000 | Max symbol rate (sps) |
| `--sr-step` | 1,000,000 | Symbol rate step (sps) |
| `--pol` | — | Polarization label (H/V/L/R) |
| `--band` | — | Band label (low/high) |
| `--output` | auto | Output filename |
## Carrier Catalog
Survey results are stored as JSON in `~/.skywalker1/surveys/` with auto-generated filenames:
```
~/.skywalker1/surveys/
survey-2026-02-15-low-V.json
survey-2026-02-16-high-H.json
survey-2026-02-16-qo100-9750.json
```
Each carrier entry records:
- Frequency, symbol rate, modulation, FEC
- Signal power, SNR, lock status
- Detected services (names, types, PIDs)
- Estimated bandwidth and carrier classification
- First/last seen timestamps, scan count
### Differential Analysis
Compare two survey snapshots to detect changes over time:
```bash
python3 tools/survey.py diff old-survey.json new-survey.json
```
Output categorizes carriers as:
| Category | Description |
|----------|-------------|
| **NEW** | Carrier present in new survey but not old |
| **MISSING** | Carrier present in old survey but not new |
| **CHANGED** | Carrier exists in both but with differences (lock state, power >2 dB, modulation, services) |
| **STABLE** | Carrier unchanged between surveys |
<Aside type="tip">
Run surveys at the same time of day and with the same LNB configuration for meaningful differential analysis. Sun transit, rain fade, and LNB drift all affect signal levels.
</Aside>
## QO-100 Mode
The `qo100` subcommand uses parameters optimized for the Es'hail-2 wideband transponder (10491-10499 MHz):
- **Step sizes**: 0.5 MHz coarse, 0.1 MHz fine (vs 5/1 MHz for broadcast)
- **Symbol rate range**: 256 ksps - 2 Msps (vs 1-30 Msps for broadcast)
- **Detection threshold**: 3.0 dB above noise floor (more sensitive)
- **IF range**: auto-calculated from `--lnb-lo` parameter
```bash title="QO-100 with universal LNB low band"
sudo python3 tools/survey.py qo100 --lnb-lo 9750
# Scans IF: 741-749 MHz
```
```bash title="QO-100 with modified LNB (9361 MHz LO)"
sudo python3 tools/survey.py qo100 --lnb-lo 9361
# Scans IF: 1130-1138 MHz
```
<Aside type="caution">
The BCM4500 demodulator has a minimum symbol rate of 256 ksps. QO-100 DATV stations transmitting below this rate will appear as detected energy in the sweep but cannot achieve signal lock. The survey distinguishes "carrier detected" from "carrier locked" in its output.
</Aside>
## Signal Analysis
The survey engine uses enhanced signal analysis beyond the basic peak detection in `skywalker_lib`:
- **Adaptive noise floor** — median + MAD robust estimator, resistant to strong carriers biasing the baseline
- **Bandwidth estimation** — walks from peak until -3 dB crossing on each side, with inter-bin interpolation
- **Carrier classification** — heuristic mapping from estimated bandwidth to likely symbol rate range and modulation type
- **Peak merging** — overlapping detections within one bandwidth are consolidated into a single carrier
## Export Formats
```bash title="Export to CSV"
python3 tools/survey.py export survey.json --format csv --output carriers.csv
```
```bash title="Export to formatted text"
python3 tools/survey.py export survey.json --format text
```
## See Also
- [SkyWalker TUI — Survey Screen](/tools/tui/#survey--qo-100) — graphical survey with spectrum plot and carrier table
- [QO-100 DATV Reception](/guides/qo100-datv/) — complete guide to receiving amateur television via Es'hail-2
- [Spectrum Analysis](/tools/spectrum-analysis/) — manual spectrum sweep techniques
- [TS Analyzer](/tools/ts-analyzer/) — standalone transport stream analysis

View File

@ -5,9 +5,9 @@ description: Interactive terminal dashboard for spectrum analysis, transponder s
import { Tabs, TabItem, Steps, Aside, Badge } from '@astrojs/starlight/components'; import { Tabs, TabItem, Steps, Aside, Badge } from '@astrojs/starlight/components';
The SkyWalker TUI is a full-screen terminal dashboard built on [Textual](https://textual.textualize.io/) that wraps all eight operating modes into a single interactive interface. Five RF signal modes (Spectrum, Scan, Monitor, L-Band, Track) plus Device management, Transport Stream analysis, and hardware Config — all with sidebar navigation, F-key shortcuts, real-time widget updates, and a dark/light theme toggle. The SkyWalker TUI is a full-screen terminal dashboard built on [Textual](https://textual.textualize.io/) that wraps all ten operating modes into a single interactive interface. Five RF signal modes (Spectrum, Scan, Monitor, L-Band, Track), Device management, Transport Stream analysis, hardware Config, DiSEqC Motor control, and an automated Carrier Survey with QO-100 DATV support — all with sidebar navigation, F-key shortcuts, real-time widget updates, and a dark/light theme toggle.
<Badge text="Requires custom firmware v3.02.0+" variant="caution" /> <Badge text="Requires custom firmware v3.03.0+" variant="caution" />
![SkyWalker TUI — Spectrum mode](../../../assets/tui/spectrum.svg) ![SkyWalker TUI — Spectrum mode](../../../assets/tui/spectrum.svg)
@ -34,7 +34,7 @@ uv run skywalker-tui --demo
| `--demo` | Synthetic signal data — no SkyWalker-1 USB device needed | | `--demo` | Synthetic signal data — no SkyWalker-1 USB device needed |
| `--no-splash` | Skip the startup splash screen | | `--no-splash` | Skip the startup splash screen |
| `--verbose, -v` | Verbose USB logging (hardware mode only) | | `--verbose, -v` | Verbose USB logging (hardware mode only) |
| `MODE` | Initial mode: `spectrum` (default), `scan`, `monitor`, `lband`, `track`, `device`, `stream`, `config` | | `MODE` | Initial mode: `spectrum` (default), `scan`, `monitor`, `lband`, `track`, `device`, `stream`, `config`, `motor`, `survey` |
<Aside type="tip"> <Aside type="tip">
Demo mode generates realistic-looking synthetic data for every screen, making it useful for familiarization, documentation, and development without satellite hardware. Demo mode generates realistic-looking synthetic data for every screen, making it useful for familiarization, documentation, and development without satellite hardware.
@ -54,6 +54,8 @@ Demo mode generates realistic-looking synthetic data for every screen, making it
| <kbd>F6</kbd> | Device management | | <kbd>F6</kbd> | Device management |
| <kbd>F7</kbd> | Transport stream | | <kbd>F7</kbd> | Transport stream |
| <kbd>F8</kbd> | Hardware config | | <kbd>F8</kbd> | Hardware config |
| <kbd>F9</kbd> | Motor control |
| <kbd>F10</kbd> | Carrier survey |
| <kbd>d</kbd> | Toggle dark/light theme | | <kbd>d</kbd> | Toggle dark/light theme |
| <kbd>q</kbd> | Quit | | <kbd>q</kbd> | Quit |
| <kbd>Ctrl+W</kbd> | Easter egg | | <kbd>Ctrl+W</kbd> | Easter egg |
@ -208,6 +210,50 @@ LNB power control, DiSEqC switching, and modulation/FEC configuration — all th
</TabItem> </TabItem>
</Tabs> </Tabs>
### Motor Control <Badge text="F9" variant="note" />
DiSEqC 1.2 positioner motor control with three-column layout: jog/halt controls, stored positions, and USALS GotoX calculator. A live signal bar at the bottom provides real-time SNR, power, lock status, and motor position feedback for hands-free dish alignment.
- Continuous jog (east/west) with keyboard arrows or button controls
- 3x3 stored position grid for quick satellite recall
- USALS GotoX calculator with observer longitude input and satellite presets (QO-100, Galaxy 19, AMC-1)
- 30-second auto-halt safety timer on continuous jog
- Motor automatically halts when switching away from the screen
- Live signal gauge updates at 2 Hz for alignment feedback
| Key | Action |
|-----|--------|
| <kbd>Left</kbd> | Jog west |
| <kbd>Right</kbd> | Jog east |
| <kbd>Space</kbd> | Halt motor |
<Aside type="caution">
The motor continues to drive until explicitly halted or the 30-second safety timer triggers. Always set east/west limits via the Limits panel before extended use.
</Aside>
### Survey + QO-100 <Badge text="F10" variant="note" />
Automated carrier survey with two tabs: Full Band sweep across the entire IF range, and a QO-100 DATV tab with parameters optimized for the Es'hail-2 wideband transponder.
<Tabs>
<TabItem label="Full Band">
Six-stage survey pipeline: coarse sweep, peak detection, fine sweep, blind scan, TS sample, and catalog assembly. Results display as a spectrum plot and frequency table showing detected carriers with their symbol rate, modulation, lock status, and service names.
- Configurable start/stop frequency and step sizes
- Full scan and quick scan modes
- Carrier table with real-time status
- Results auto-saved to `~/.skywalker1/surveys/`
</TabItem>
<TabItem label="QO-100 DATV">
Narrowband sweep tuned for Es'hail-2's wideband transponder (10491-10499 MHz). Uses tighter frequency steps (0.5/0.1 MHz vs 5/1 MHz), lower symbol rate range (256 ksps - 2 Msps), and a more sensitive detection threshold (3.0 dB).
- LNB local oscillator input for IF calculation
- Known QO-100 station frequencies displayed
- Info panel explaining BCM4500 symbol rate limitations
- Carriers classified as "detected" vs "locked"
</TabItem>
</Tabs>
--- ---
## Easter Eggs ## Easter Eggs
@ -258,4 +304,7 @@ Use `--no-splash` to skip it.
- [Tuning Tool](/tools/tuning/) — primary tuning, LNB control, and transport stream capture - [Tuning Tool](/tools/tuning/) — primary tuning, LNB control, and transport stream capture
- [EEPROM Utilities](/tools/eeprom-utilities/) — standalone CLI for firmware updates (same safety protocol as F6 Device screen) - [EEPROM Utilities](/tools/eeprom-utilities/) — standalone CLI for firmware updates (same safety protocol as F6 Device screen)
- [TS Stream Analyzer](/tools/ts-analyzer/) — standalone TS packet analyzer (same engine as F7 Stream screen) - [TS Stream Analyzer](/tools/ts-analyzer/) — standalone TS packet analyzer (same engine as F7 Stream screen)
- [Motor Control](/tools/motor/) — standalone CLI for DiSEqC 1.2 motor control (same engine as F9 Motor screen)
- [Carrier Survey](/tools/survey/) — standalone CLI for automated carrier survey (same engine as F10 Survey screen)
- [QO-100 DATV Reception](/guides/qo100-datv/) — complete guide to Es'hail-2 amateur television reception
- [RF Coverage](/hardware/rf-coverage/) — frequency coverage and antenna considerations - [RF Coverage](/hardware/rf-coverage/) — frequency coverage and antenna considerations

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_TUNE_MONITOR = 0xB8
CMD_MULTI_REG_READ = 0xB9 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 status bits ---
CONFIG_BITS = { CONFIG_BITS = {
@ -680,3 +702,196 @@ class SkyWalker1:
return LNB_LO_HIGH return LNB_LO_HIGH
else: else:
return LNB_LO_LOW 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): def open_input(path: str):
"""Open TS input from a file path or stdin ('-').""" """Open TS input from a file path or stdin ('-')."""
if path == '-': if path == '-':

View File

@ -27,6 +27,8 @@ from skywalker_tui.screens.track import TrackScreen
from skywalker_tui.screens.device import DeviceScreen from skywalker_tui.screens.device import DeviceScreen
from skywalker_tui.screens.stream import StreamScreen from skywalker_tui.screens.stream import StreamScreen
from skywalker_tui.screens.config import ConfigScreen from skywalker_tui.screens.config import ConfigScreen
from skywalker_tui.screens.motor import MotorScreen
from skywalker_tui.screens.survey import SurveyScreen
MODES = { MODES = {
@ -38,6 +40,8 @@ MODES = {
"device": ("F6 Device", DeviceScreen), "device": ("F6 Device", DeviceScreen),
"stream": ("F7 Stream", StreamScreen), "stream": ("F7 Stream", StreamScreen),
"config": ("F8 Config", ConfigScreen), "config": ("F8 Config", ConfigScreen),
"motor": ("F9 Motor", MotorScreen),
"survey": ("F10 Survey", SurveyScreen),
} }
@ -57,6 +61,8 @@ class SkyWalkerApp(App):
Binding("f6", "rf_mode('device')", "Device", show=True), Binding("f6", "rf_mode('device')", "Device", show=True),
Binding("f7", "rf_mode('stream')", "Stream", show=True), Binding("f7", "rf_mode('stream')", "Stream", show=True),
Binding("f8", "rf_mode('config')", "Config", show=True), Binding("f8", "rf_mode('config')", "Config", show=True),
Binding("f9", "rf_mode('motor')", "Motor", show=True),
Binding("f10", "rf_mode('survey')", "Survey", show=True),
Binding("q", "quit", "Quit", show=True), Binding("q", "quit", "Quit", show=True),
Binding("d", "toggle_dark", "Theme", show=True), Binding("d", "toggle_dark", "Theme", show=True),
Binding("ctrl+w", "starwars", "Star Wars", show=False), Binding("ctrl+w", "starwars", "Star Wars", show=False),

View File

@ -200,3 +200,45 @@ class USBBridge:
def multi_reg_read(self, start_reg: int, count: int) -> bytes: def multi_reg_read(self, start_reg: int, count: int) -> bytes:
with self._lock: with self._lock:
return self._dev.multi_reg_read(start_reg, count) return self._dev.multi_reg_read(start_reg, count)
# -- Motor control (v3.03+) --
def motor_halt(self) -> None:
with self._lock:
self._dev.motor_halt()
def motor_drive_east(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_east(steps)
def motor_drive_west(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_west(steps)
def motor_store_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_store_position(slot)
def motor_goto_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_goto_position(slot)
def motor_goto_x(self, observer_lon: float, sat_lon: float) -> None:
with self._lock:
self._dev.motor_goto_x(observer_lon, sat_lon)
def motor_set_limit(self, direction: str) -> None:
with self._lock:
self._dev.motor_set_limit(direction)
def motor_disable_limits(self) -> None:
with self._lock:
self._dev.motor_disable_limits()
def get_last_error(self) -> int:
with self._lock:
return self._dev.get_last_error()
def get_last_error_str(self) -> str:
with self._lock:
return self._dev.get_last_error_str()

View File

@ -26,6 +26,14 @@ _TRANSPONDERS = [
(1950, -22.0, 13000), # weaker TP (1950, -22.0, 13000), # weaker TP
] ]
# QO-100 transponders (IF MHz for 9361 LO: RF 10491-10499 -> IF 1130-1138)
_QO100_TRANSPONDERS = [
(1130.5, -20.0, 1500), # BATC beacon
(1131.0, -24.0, 1000), # DATV station
(1132.0, -28.0, 500), # low-power DATV
(1133.0, -30.0, 333), # minimum viable DVB-S
]
_NOISE_FLOOR = -35.0 _NOISE_FLOOR = -35.0
_LOCK_THRESHOLD_DB = 3.5 _LOCK_THRESHOLD_DB = 3.5
@ -73,6 +81,17 @@ class DemoDevice:
self._sample_count = 0 self._sample_count = 0
self._eeprom = _build_demo_eeprom() self._eeprom = _build_demo_eeprom()
self._cc_counters: dict[int, int] = {pid: 0 for pid in _DEMO_PIDS} self._cc_counters: dict[int, int] = {pid: 0 for pid in _DEMO_PIDS}
# Motor simulation state
self._motor_position_deg = 0.0
self._motor_target_deg = 0.0
self._motor_moving = False
self._motor_direction = 0 # -1=west, 0=stopped, 1=east
self._motor_speed_dps = 1.5 # degrees per second
self._motor_last_update = time.monotonic()
self._motor_stored: dict[int, float] = {}
self._motor_east_limit = 75.0
self._motor_west_limit = -75.0
self._last_error = 0x00
def open(self): def open(self):
pass pass
@ -89,10 +108,10 @@ class DemoDevice:
def get_fw_version(self) -> dict: def get_fw_version(self) -> dict:
return { return {
"major": 3, "major": 3,
"minor": 2, "minor": 3,
"patch": 0, "patch": 0,
"version": "3.02.0", "version": "3.03.0",
"date": "2025-02-10", "date": "2026-02-15",
} }
def get_config(self) -> int: def get_config(self) -> int:
@ -416,6 +435,129 @@ class DemoDevice:
def send_diseqc_message(self, msg: bytes) -> None: def send_diseqc_message(self, msg: bytes) -> None:
time.sleep(0.05) time.sleep(0.05)
# --- Motor simulation ---
def _motor_tick(self):
"""Update simulated motor position based on elapsed time."""
now = time.monotonic()
dt = now - self._motor_last_update
self._motor_last_update = now
if self._motor_direction != 0:
delta = self._motor_speed_dps * dt * self._motor_direction
self._motor_position_deg += delta
# Clamp to limits
self._motor_position_deg = max(
self._motor_west_limit,
min(self._motor_east_limit, self._motor_position_deg)
)
# Stop at limits
if (self._motor_position_deg >= self._motor_east_limit or
self._motor_position_deg <= self._motor_west_limit):
self._motor_direction = 0
self._motor_moving = False
# Slew to target (for goto commands)
if self._motor_moving and self._motor_direction == 0:
diff = self._motor_target_deg - self._motor_position_deg
if abs(diff) < 0.1:
self._motor_position_deg = self._motor_target_deg
self._motor_moving = False
else:
step = min(abs(diff), self._motor_speed_dps * dt)
self._motor_position_deg += step if diff > 0 else -step
def motor_halt(self) -> None:
self._motor_tick()
self._motor_direction = 0
self._motor_moving = False
time.sleep(0.02)
def motor_drive_east(self, steps: int = 0) -> None:
self._motor_tick()
if steps > 0:
self._motor_target_deg = self._motor_position_deg + steps * 0.1
self._motor_target_deg = min(self._motor_target_deg, self._motor_east_limit)
self._motor_moving = True
self._motor_direction = 0
else:
self._motor_direction = 1
self._motor_moving = True
time.sleep(0.02)
def motor_drive_west(self, steps: int = 0) -> None:
self._motor_tick()
if steps > 0:
self._motor_target_deg = self._motor_position_deg - steps * 0.1
self._motor_target_deg = max(self._motor_target_deg, self._motor_west_limit)
self._motor_moving = True
self._motor_direction = 0
else:
self._motor_direction = -1
self._motor_moving = True
time.sleep(0.02)
def motor_store_position(self, slot: int) -> None:
self._motor_tick()
self._motor_stored[slot] = self._motor_position_deg
time.sleep(0.02)
def motor_goto_position(self, slot: int) -> None:
self._motor_tick()
if slot == 0:
self._motor_target_deg = 0.0
elif slot in self._motor_stored:
self._motor_target_deg = self._motor_stored[slot]
else:
return
self._motor_moving = True
self._motor_direction = 0
time.sleep(0.02)
def motor_goto_x(self, observer_lon: float, sat_lon: float) -> None:
# Simplified USALS angle calculation
angle = math.degrees(math.atan2(
math.sin(math.radians(sat_lon - observer_lon)),
math.cos(math.radians(sat_lon - observer_lon)) - 6378.0 / (6378.0 + 35786.0)
))
self._motor_tick()
self._motor_target_deg = angle
self._motor_moving = True
self._motor_direction = 0
time.sleep(0.02)
def motor_set_limit(self, direction: str) -> None:
self._motor_tick()
if direction.lower() == "east":
self._motor_east_limit = self._motor_position_deg
else:
self._motor_west_limit = self._motor_position_deg
time.sleep(0.02)
def motor_disable_limits(self) -> None:
self._motor_east_limit = 75.0
self._motor_west_limit = -75.0
time.sleep(0.02)
def get_last_error(self) -> int:
return self._last_error
def get_last_error_str(self) -> str:
names = {0: "OK", 1: "I2C timeout", 2: "I2C NAK",
3: "I2C arb lost", 4: "BCM not ready", 5: "BCM timeout"}
return names.get(self._last_error, f"Unknown (0x{self._last_error:02X})")
@property
def motor_position(self) -> float:
"""Current simulated motor position in degrees."""
self._motor_tick()
return self._motor_position_deg
@property
def motor_is_moving(self) -> bool:
self._motor_tick()
return self._motor_moving or self._motor_direction != 0
def get_signal_lock(self) -> bool: def get_signal_lock(self) -> bool:
sig = self.signal_monitor() sig = self.signal_monitor()
return sig["locked"] return sig["locked"]
@ -442,14 +584,25 @@ class DemoDevice:
power = _NOISE_FLOOR + random.gauss(0, 0.5) power = _NOISE_FLOOR + random.gauss(0, 0.5)
# Add Gaussian peaks for each simulated transponder # Add Gaussian peaks for each simulated transponder
for tp_freq, tp_peak, _sr in _TRANSPONDERS: all_tps = list(_TRANSPONDERS) + list(_QO100_TRANSPONDERS)
# Bandwidth ~15 MHz sigma for tp_freq, tp_peak, _sr in all_tps:
# Bandwidth ~15 MHz sigma for broadcast, ~3 MHz for QO-100
sigma = 3.0 if tp_freq > 1100 and tp_freq < 1145 else 12.0
dist = (freq_mhz - tp_freq) dist = (freq_mhz - tp_freq)
gauss = math.exp(-(dist ** 2) / (2 * 12.0 ** 2)) gauss = math.exp(-(dist ** 2) / (2 * sigma ** 2))
# Slow atmospheric drift: +-2 dB over 30s period # Slow atmospheric drift: +-2 dB over 30s period
drift = 2.0 * math.sin(elapsed / 30.0 * 2 * math.pi + tp_freq / 100.0) drift = 2.0 * math.sin(elapsed / 30.0 * 2 * math.pi + tp_freq / 100.0)
power += (tp_peak - _NOISE_FLOOR + drift) * gauss power += (tp_peak - _NOISE_FLOOR + drift) * gauss
# Motor position affects signal strength (simulates dish alignment)
# Peak signal at position 0 (reference), degrades with offset
if hasattr(self, '_motor_position_deg'):
self._motor_tick()
offset = abs(self._motor_position_deg)
if offset > 2.0:
# Signal drops ~3 dB per degree off-axis beyond ±2°
power -= (offset - 2.0) * 3.0
return power return power
@staticmethod @staticmethod

View File

@ -0,0 +1,411 @@
"""Motor screen — DiSEqC 1.2 positioner control with live signal feedback.
Three-column layout: Motor Control (jog/halt/limits) | Positions (store/recall) |
USALS GotoX (calculator + presets). Bottom bar shows live signal monitor for
dish alignment feedback during jog.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, Grid
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
_QO100_SAT_LON = 25.9 # Es'hail-2
class MotorScreen(Container):
"""DiSEqC 1.2 positioner control with signal feedback."""
DEFAULT_CSS = """
MotorScreen {
layout: vertical;
}
MotorScreen #motor-main {
height: 1fr;
layout: horizontal;
}
MotorScreen .motor-col {
width: 1fr;
padding: 1;
}
MotorScreen .motor-panel {
background: #0e1420;
border: round #1a2a3a;
padding: 1;
margin: 0 0 1 0;
height: auto;
}
MotorScreen .motor-panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
MotorScreen .jog-row {
height: auto;
layout: horizontal;
margin: 0 0 1 0;
}
MotorScreen .jog-row Button {
width: 1fr;
margin: 0 1 0 0;
}
MotorScreen .pos-grid {
layout: grid;
grid-size: 3;
grid-gutter: 1;
height: auto;
}
MotorScreen .pos-grid Button {
height: 3;
}
MotorScreen .pos-btn-stored {
background: #1a3a2a;
color: #00d4aa;
border: round #00d4aa;
}
MotorScreen .pos-btn-empty {
background: #121c2a;
color: #506878;
border: round #1a3050;
}
MotorScreen #motor-signal {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
dock: bottom;
}
MotorScreen #motor-signal .sig-row {
height: 3;
layout: horizontal;
}
MotorScreen #motor-signal Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
MotorScreen #motor-usals-inputs {
height: auto;
}
MotorScreen #motor-usals-inputs Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
MotorScreen #motor-usals-inputs Input {
width: 12;
margin: 0 1;
}
MotorScreen .motor-status {
height: auto;
color: #506878;
margin: 1 0 0 0;
}
"""
BINDINGS = [
("left", "jog_west", "Jog West"),
("right", "jog_east", "Jog East"),
("space", "halt", "Halt"),
]
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._polling = False
self._poll_worker: Worker | None = None
self._jog_active = False
self._jog_start_time = 0.0
self._stored_positions: dict[int, bool] = {}
def compose(self) -> ComposeResult:
with Horizontal(id="motor-main"):
# Column 1: Motor jog control
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Motor Control", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("West", id="jog-west", variant="warning")
yield Button("HALT", id="jog-halt", variant="error")
yield Button("East", id="jog-east", variant="warning")
with Horizontal(classes="jog-row"):
yield Button("Step W", id="step-west")
yield Button("Step E", id="step-east")
yield Static("[#506878]Direction:[/] [#e8a020]Stopped[/]",
id="motor-dir-status", classes="motor-status")
yield Static("[#506878]Position:[/] [#00d4aa]0.0 deg[/]",
id="motor-pos-status", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Limits", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("Set East Limit", id="limit-east")
yield Button("Set West Limit", id="limit-west")
yield Button("Disable Limits", id="limit-disable")
# Column 2: Stored positions
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Stored Positions", classes="motor-panel-title")
yield Static(
"[#506878]Press to recall, hold S+number to store[/]",
classes="motor-status",
)
with Grid(classes="pos-grid"):
for i in range(1, 10):
yield Button(
f"Pos {i}",
id=f"pos-{i}",
classes="pos-btn-empty",
)
yield Button("Go to Reference (0)", id="pos-ref")
yield Static("", id="pos-info", classes="motor-status")
# Column 3: USALS GotoX
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("USALS GotoX", classes="motor-panel-title")
with Horizontal(id="motor-usals-inputs"):
yield Label("Observer Lon:")
yield Input("-97.5", id="usals-obs-lon")
with Horizontal(id="motor-usals-inputs"):
yield Label("Satellite Lon:")
yield Input("25.9", id="usals-sat-lon")
yield Button("Calculate & Go", id="usals-go", variant="success")
yield Static("", id="usals-result", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Presets", classes="motor-panel-title")
yield Button("QO-100 (25.9E)", id="preset-qo100")
yield Button("Galaxy 19 (97.0W)", id="preset-g19")
yield Button("AMC-1 (103.0W)", id="preset-amc1")
# Bottom: live signal bar
with Vertical(id="motor-signal"):
yield SignalGauge(id="motor-gauge")
with Horizontal(classes="sig-row"):
yield Static("[#506878]SNR:[/] [#00d4aa]-- dB[/]", id="sig-snr")
yield Static("[#506878]Power:[/] [#00d4aa]-- dB[/]", id="sig-power")
yield Static("[#506878]Lock:[/] [#e04040]NO[/]", id="sig-lock")
yield Static("[#506878]Motor:[/] [#e8a020]Idle[/]", id="sig-motor")
def on_show(self) -> None:
if not self._polling:
self._start_polling()
def on_hide(self) -> None:
self._stop_polling()
# Safety: halt motor when leaving screen
if self._jog_active:
try:
self._bridge.motor_halt()
except Exception:
pass
self._jog_active = False
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id or ""
if btn == "jog-east":
self._do_jog_east()
elif btn == "jog-west":
self._do_jog_west()
elif btn == "jog-halt":
self._do_halt()
elif btn == "step-east":
self._do_step(east=True)
elif btn == "step-west":
self._do_step(east=False)
elif btn == "limit-east":
self._bridge.motor_set_limit("east")
elif btn == "limit-west":
self._bridge.motor_set_limit("west")
elif btn == "limit-disable":
self._bridge.motor_disable_limits()
elif btn.startswith("pos-") and btn != "pos-ref":
slot = int(btn.split("-")[1])
self._do_goto_position(slot)
elif btn == "pos-ref":
self._do_goto_position(0)
elif btn == "usals-go":
self._do_usals_go()
elif btn == "preset-qo100":
self._do_preset(25.9)
elif btn == "preset-g19":
self._do_preset(-97.0)
elif btn == "preset-amc1":
self._do_preset(-103.0)
def action_jog_east(self) -> None:
self._do_jog_east()
def action_jog_west(self) -> None:
self._do_jog_west()
def action_halt(self) -> None:
self._do_halt()
def _do_jog_east(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_east()
self._update_dir_status("East", "#00e060")
def _do_jog_west(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_west()
self._update_dir_status("West", "#2196f3")
def _do_halt(self) -> None:
self._jog_active = False
self._bridge.motor_halt()
self._update_dir_status("Stopped", "#e8a020")
def _do_step(self, east: bool) -> None:
if east:
self._bridge.motor_drive_east(steps=10)
else:
self._bridge.motor_drive_west(steps=10)
self._update_dir_status("Stepping", "#e8a020")
def _do_goto_position(self, slot: int) -> None:
self._bridge.motor_goto_position(slot)
label = f"Pos {slot}" if slot > 0 else "Reference"
self._update_dir_status(f"Going to {label}", "#00d4aa")
def _do_usals_go(self) -> None:
try:
obs_lon = float(self.query_one("#usals-obs-lon", Input).value)
sat_lon = float(self.query_one("#usals-sat-lon", Input).value)
except (ValueError, TypeError):
return
self._bridge.motor_goto_x(obs_lon, sat_lon)
# Show calculated angle
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
try:
from skywalker_lib import usals_angle
angle = usals_angle(obs_lon, sat_lon)
direction = "East" if angle >= 0 else "West"
self.query_one("#usals-result", Static).update(
f"[#506878]Angle:[/] [#00d4aa]{abs(angle):.1f} deg {direction}[/]"
)
except ImportError:
pass
self._update_dir_status("USALS GotoX", "#00d4aa")
def _do_preset(self, sat_lon: float) -> None:
self.query_one("#usals-sat-lon", Input).value = str(sat_lon)
self._do_usals_go()
def _update_dir_status(self, text: str, color: str) -> None:
if not self.is_mounted:
return
self.query_one("#motor-dir-status", Static).update(
f"[#506878]Direction:[/] [{color}]{text}[/]"
)
def _start_polling(self) -> None:
self._polling = True
self._poll_worker = self._do_signal_poll()
def _stop_polling(self) -> None:
self._polling = False
if self._poll_worker:
self._poll_worker.cancel()
self._poll_worker = None
@work(thread=True)
def _do_signal_poll(self) -> None:
"""Poll signal + motor state at ~2 Hz for alignment feedback."""
import time
try:
self._bridge.ensure_booted()
except Exception:
pass
while self._polling:
t0 = time.monotonic()
# Safety: auto-halt after 30s continuous jog
if self._jog_active:
elapsed = t0 - self._jog_start_time
if elapsed > 30.0:
self._bridge.motor_halt()
self._jog_active = False
self.app.call_from_thread(
self._update_dir_status, "Auto-halted (30s)", "#e04040"
)
try:
sig = self._bridge.signal_monitor()
except Exception:
time.sleep(0.5)
continue
# Read motor position from demo device
motor_pos = None
motor_moving = False
if hasattr(self._bridge, '_dev'):
dev = self._bridge._dev
if hasattr(dev, 'motor_position'):
motor_pos = dev.motor_position
motor_moving = dev.motor_is_moving
self.app.call_from_thread(self._update_signal_ui, sig, motor_pos, motor_moving)
elapsed = time.monotonic() - t0
sleep = 0.5 - elapsed
if sleep > 0:
time.sleep(sleep)
def _update_signal_ui(self, sig: dict, motor_pos: float | None,
motor_moving: bool) -> None:
if not self.is_mounted:
return
self.query_one("#motor-gauge", SignalGauge).update_signal(sig)
snr = sig.get("snr_db", 0.0)
power = sig.get("power_db", -40.0)
locked = sig.get("locked", False)
self.query_one("#sig-snr", Static).update(
f"[#506878]SNR:[/] [#00d4aa]{snr:.1f} dB[/]"
)
self.query_one("#sig-power", Static).update(
f"[#506878]Power:[/] [#00d4aa]{power:.1f} dB[/]"
)
lock_color = "#00e060" if locked else "#e04040"
lock_text = "LOCKED" if locked else "NO"
self.query_one("#sig-lock", Static).update(
f"[#506878]Lock:[/] [{lock_color}]{lock_text}[/]"
)
if motor_pos is not None:
direction = "E" if motor_pos >= 0 else "W"
move_indicator = " [#e8a020]>>>[/]" if motor_moving else ""
self.query_one("#sig-motor", Static).update(
f"[#506878]Motor:[/] [#00d4aa]{abs(motor_pos):.1f} deg {direction}[/]{move_indicator}"
)
self.query_one("#motor-pos-status", Static).update(
f"[#506878]Position:[/] [#00d4aa]{motor_pos:.1f} deg[/]"
)
if not self._jog_active and not motor_moving:
self._update_dir_status("Stopped", "#e8a020")

View File

@ -0,0 +1,593 @@
"""Survey screen — carrier survey + QO-100 DATV reception (tabbed).
Two tabs within the Survey screen:
- Full Band: automated carrier discovery across the entire IF range
- QO-100: focused on the Es'hail-2 wideband DATV transponder
Both share a spectrum visualization + results table pattern.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import (
Label, Input, Button, Static, ProgressBar,
TabbedContent, TabPane,
)
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.frequency_table import FrequencyTable
# QO-100 IF range for common LNB LOs
_QO100_WB_RF_START = 10491.0
_QO100_WB_RF_STOP = 10499.0
class SurveyScreen(Container):
"""Carrier survey with full-band and QO-100 tabs."""
DEFAULT_CSS = """
SurveyScreen {
layout: vertical;
}
SurveyScreen TabbedContent {
height: 1fr;
}
SurveyScreen .survey-tab {
layout: vertical;
height: 1fr;
}
SurveyScreen .survey-upper {
height: 1fr;
layout: horizontal;
}
SurveyScreen .survey-spectrum-col {
width: 1fr;
}
SurveyScreen .survey-results-col {
width: 1fr;
}
SurveyScreen .survey-progress {
height: auto;
padding: 1 2;
background: #0e1018;
layout: vertical;
}
SurveyScreen .survey-progress Static {
width: auto;
margin: 0 1 0 0;
}
SurveyScreen .survey-progress-row {
height: 3;
layout: horizontal;
}
SurveyScreen .survey-progress ProgressBar {
width: 1fr;
margin: 0 1;
}
SurveyScreen .survey-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
SurveyScreen .survey-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
SurveyScreen .survey-controls Input {
width: 10;
margin: 0 1;
}
SurveyScreen .survey-controls Button {
margin: 0 1;
}
SurveyScreen .qo100-info {
height: auto;
padding: 1;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SurveyScreen .qo100-info-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
SurveyScreen .qo100-station {
color: #c8d0d8;
}
SurveyScreen .qo100-detectable {
color: #00e060;
}
SurveyScreen .qo100-not-lockable {
color: #e8a020;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._scanning = False
self._scan_worker: Worker | None = None
def compose(self) -> ComposeResult:
with TabbedContent():
with TabPane("Full Band", id="tab-fullband"):
yield self._compose_fullband()
with TabPane("QO-100 DATV", id="tab-qo100"):
yield self._compose_qo100()
def _compose_fullband(self) -> Container:
c = Vertical(classes="survey-tab")
c._nodes = []
return _FullBandTab(self._bridge, id="fullband-tab")
def _compose_qo100(self) -> Container:
return _QO100Tab(self._bridge, id="qo100-tab")
def on_hide(self) -> None:
self._stop_scan()
def _stop_scan(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
class _FullBandTab(Container):
"""Full IF band carrier survey."""
DEFAULT_CSS = """
_FullBandTab {
layout: vertical;
height: 1fr;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._scanning = False
self._scan_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Horizontal(classes="survey-upper"):
with Vertical(classes="survey-spectrum-col"):
yield SpectrumPlot(title="Survey Sweep", id="survey-spectrum")
with Vertical(classes="survey-results-col"):
yield Static("[#00d4aa bold]Carriers Found[/]")
yield FrequencyTable(id="survey-table")
with Vertical(classes="survey-progress"):
yield Static("[#506878]Ready[/]", id="survey-phase")
with Horizontal(classes="survey-progress-row"):
yield ProgressBar(total=100, show_eta=False, id="survey-pbar")
with Horizontal(classes="survey-controls"):
yield Label("Start IF:")
yield Input("950", id="survey-start")
yield Label("Stop IF:")
yield Input("2150", id="survey-stop")
yield Label("LNB LO:")
yield Input("9750", id="survey-lnb")
yield Label("Step:")
yield Input("5", id="survey-step")
yield Button("Full Scan", id="survey-full", variant="success")
yield Button("Quick Scan", id="survey-quick", variant="primary")
yield Button("Stop", id="survey-stop-btn", variant="error")
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id or ""
if btn == "survey-full":
self._start_full_scan()
elif btn == "survey-quick":
self._start_quick_scan()
elif btn == "survey-stop-btn":
self._stop()
def _stop(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
def _start_full_scan(self) -> None:
if self._scanning:
return
self._scanning = True
start = float(self.query_one("#survey-start", Input).value or "950")
stop = float(self.query_one("#survey-stop", Input).value or "2150")
lnb_lo = float(self.query_one("#survey-lnb", Input).value or "9750")
step = float(self.query_one("#survey-step", Input).value or "5")
self.query_one("#survey-table", FrequencyTable).clear_table()
self._scan_worker = self._do_full_scan(start, stop, lnb_lo, step)
def _start_quick_scan(self) -> None:
if self._scanning:
return
self._scanning = True
start = float(self.query_one("#survey-start", Input).value or "950")
stop = float(self.query_one("#survey-stop", Input).value or "2150")
lnb_lo = float(self.query_one("#survey-lnb", Input).value or "9750")
step = float(self.query_one("#survey-step", Input).value or "5")
self._scan_worker = self._do_quick_scan(start, stop, lnb_lo, step)
@work(thread=True)
def _do_full_scan(self, start: float, stop: float,
lnb_lo: float, step: float) -> None:
"""Six-stage carrier survey in background thread."""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
from skywalker_lib import detect_peaks, if_to_rf
try:
self._bridge.ensure_booted()
except Exception:
pass
# Stage 1: Coarse sweep
self.app.call_from_thread(self._set_phase, "Stage 1/6: Coarse sweep", 0)
def sweep_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 15
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell_ms=15, callback=sweep_cb,
)
if not self._scanning:
return
self.app.call_from_thread(self._update_spectrum, freqs, powers, results, lnb_lo)
# Stage 2: Peak detection
self.app.call_from_thread(self._set_phase, "Stage 2/6: Peak detection", 15)
peaks = detect_peaks(freqs, powers, threshold_db=5.0)
if not peaks:
self.app.call_from_thread(self._set_phase, "No carriers detected", 100)
self._scanning = False
return
# Stage 3: Fine sweep
self.app.call_from_thread(
self._set_phase,
f"Stage 3/6: Fine sweep ({len(peaks)} peaks)", 25,
)
refined = []
for i, (freq, pwr, idx) in enumerate(peaks):
if not self._scanning:
return
fine_start = max(start, freq - 10)
fine_stop = min(stop, freq + 10)
fine_freqs, fine_powers, fine_results = self._bridge.sweep_spectrum(
fine_start, fine_stop, step_mhz=1.0, dwell_ms=20,
)
if fine_powers:
best_idx = fine_powers.index(max(fine_powers))
refined.append((
fine_freqs[best_idx], fine_powers[best_idx],
fine_results[best_idx],
))
pct = 25 + (i + 1) / len(peaks) * 20
self.app.call_from_thread(self._set_progress, pct)
# Stage 4: Blind scan
self.app.call_from_thread(
self._set_phase,
f"Stage 4/6: Blind scan ({len(refined)} candidates)", 45,
)
locked_carriers = []
for i, (freq, pwr, result) in enumerate(refined):
if not self._scanning:
return
freq_khz = int(freq * 1000)
bs_result = self._bridge.blind_scan(freq_khz, 1000000, 30000000, 500000)
if bs_result and bs_result.get("locked"):
carrier = {
"if_mhz": bs_result.get("freq_khz", freq_khz) / 1000.0,
"rf_mhz": if_to_rf(
bs_result.get("freq_khz", freq_khz) / 1000.0, lnb_lo
),
"sr_ksps": bs_result.get("sr_sps", 0) // 1000,
"power_db": pwr,
"locked": True,
}
locked_carriers.append(carrier)
self.app.call_from_thread(self._add_carrier, carrier)
pct = 45 + (i + 1) / len(refined) * 25
self.app.call_from_thread(self._set_progress, pct)
# Stage 5: TS sample (simplified — just report locked carriers)
self.app.call_from_thread(
self._set_phase,
f"Stage 5/6: TS sampling ({len(locked_carriers)} locked)", 70,
)
# In a full implementation, we'd tune to each carrier, arm_transfer,
# capture 3s of TS, and parse PAT/PMT/SDT for service names.
# For the TUI demo, the locked carrier list is sufficient.
# Stage 6: Catalog
self.app.call_from_thread(self._set_phase, "Stage 6/6: Catalog assembly", 90)
self.app.call_from_thread(self._set_progress, 95)
total = len(locked_carriers)
self.app.call_from_thread(
self._set_phase,
f"Survey complete: {total} carrier{'s' if total != 1 else ''} cataloged",
100,
)
self._scanning = False
@work(thread=True)
def _do_quick_scan(self, start: float, stop: float,
lnb_lo: float, step: float) -> None:
"""Quick sweep + peak detection only."""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
from skywalker_lib import detect_peaks, if_to_rf
try:
self._bridge.ensure_booted()
except Exception:
pass
self.app.call_from_thread(self._set_phase, "Quick scan: sweeping", 0)
def cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 80
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell_ms=10, callback=cb,
)
if not self._scanning:
return
self.app.call_from_thread(self._update_spectrum, freqs, powers, results, lnb_lo)
self.app.call_from_thread(self._set_phase, "Quick scan: detecting peaks", 80)
peaks = detect_peaks(freqs, powers, threshold_db=5.0)
for freq, pwr, idx in peaks:
carrier = {
"if_mhz": freq,
"rf_mhz": if_to_rf(freq, lnb_lo),
"sr_ksps": 0,
"power_db": pwr,
"locked": False,
}
self.app.call_from_thread(self._add_carrier, carrier)
self.app.call_from_thread(
self._set_phase,
f"Quick scan: {len(peaks)} peaks found", 100,
)
self._scanning = False
def _set_phase(self, text: str, progress: float) -> None:
if not self.is_mounted:
return
self.query_one("#survey-phase", Static).update(f"[#00d4aa]{text}[/]")
self.query_one("#survey-pbar", ProgressBar).update(progress=progress)
def _set_progress(self, pct: float) -> None:
if not self.is_mounted:
return
self.query_one("#survey-pbar", ProgressBar).update(progress=pct)
def _update_spectrum(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#survey-spectrum", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _add_carrier(self, carrier: dict) -> None:
if not self.is_mounted:
return
self.query_one("#survey-table", FrequencyTable).add_transponder(carrier)
class _QO100Tab(Container):
"""QO-100 DATV focused scan."""
DEFAULT_CSS = """
_QO100Tab {
layout: vertical;
height: 1fr;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._scanning = False
self._scan_worker: Worker | None = None
def compose(self) -> ComposeResult:
# Info panel with known stations
with Vertical(classes="qo100-info"):
yield Label("QO-100 Wideband Transponder (Es'hail-2, 25.9E)",
classes="qo100-info-title")
yield Static(
"[#506878]RF range:[/] [#00d4aa]10491-10499 MHz[/] "
"[#506878]BCM4500 min SR:[/] [#e8a020]256 ksps[/]"
)
yield Static(
"[#506878]Known stations:[/]\n"
" [#00e060]BATC beacon 10491.5 MHz SR 1500 ksps QPSK 3/4[/]\n"
" [#00e060]DATV 10492.0 MHz SR 1000 ksps QPSK 1/2[/]\n"
" [#e8a020]Low-power 10493.0 MHz SR 500 ksps QPSK 1/2[/]\n"
" [#e8a020]Minimum 10494.0 MHz SR 333 ksps QPSK 1/2[/]\n"
" [#506878]Beacon 10489.75 MHz CW (not lockable)[/]"
)
with Horizontal(classes="survey-upper"):
with Vertical(classes="survey-spectrum-col"):
yield SpectrumPlot(title="QO-100 Sweep", id="qo100-spectrum")
with Vertical(classes="survey-results-col"):
yield Static("[#00d4aa bold]QO-100 Carriers[/]")
yield FrequencyTable(id="qo100-table")
with Vertical(classes="survey-progress"):
yield Static("[#506878]Ready — enter LNB LO frequency[/]", id="qo100-phase")
with Horizontal(classes="survey-progress-row"):
yield ProgressBar(total=100, show_eta=False, id="qo100-pbar")
with Horizontal(classes="survey-controls"):
yield Label("LNB LO (MHz):")
yield Input("9361", id="qo100-lnb")
yield Label("Step (kHz):")
yield Input("500", id="qo100-step")
yield Label("Dwell (ms):")
yield Input("50", id="qo100-dwell")
yield Button("Scan QO-100", id="qo100-scan", variant="success")
yield Button("Stop", id="qo100-stop", variant="error")
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id or ""
if btn == "qo100-scan":
self._start_scan()
elif btn == "qo100-stop":
self._stop()
def _stop(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
def _start_scan(self) -> None:
if self._scanning:
return
self._scanning = True
lnb_lo = float(self.query_one("#qo100-lnb", Input).value or "9361")
step_khz = float(self.query_one("#qo100-step", Input).value or "500")
dwell_ms = int(self.query_one("#qo100-dwell", Input).value or "50")
self.query_one("#qo100-table", FrequencyTable).clear_table()
self._scan_worker = self._do_qo100_scan(lnb_lo, step_khz, dwell_ms)
@work(thread=True)
def _do_qo100_scan(self, lnb_lo: float, step_khz: float,
dwell_ms: int) -> None:
"""Scan QO-100 wideband transponder with optimized parameters."""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
from skywalker_lib import detect_peaks, if_to_rf
try:
self._bridge.ensure_booted()
except Exception:
pass
# Calculate IF range from LO
if_start = _QO100_WB_RF_START - lnb_lo
if_stop = _QO100_WB_RF_STOP - lnb_lo
step_mhz = step_khz / 1000.0
# Validate IF range is within receiver's capabilities
if if_start < 950 or if_stop > 2150:
self.app.call_from_thread(
self._set_phase,
f"IF range {if_start:.0f}-{if_stop:.0f} MHz out of receiver range (950-2150)",
0,
)
self._scanning = False
return
self.app.call_from_thread(
self._set_phase,
f"Scanning IF {if_start:.0f}-{if_stop:.0f} MHz (LO {lnb_lo:.0f})", 0,
)
# Sweep with low SR for better sensitivity to narrow signals
def cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 60
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
if_start, if_stop, step_mhz, dwell_ms=dwell_ms,
sr_ksps=1000, # Lower SR for QO-100 sensitivity
callback=cb,
)
if not self._scanning:
return
self.app.call_from_thread(
self._update_spectrum, freqs, powers, results, lnb_lo,
)
# Peak detection with lower threshold for weak DATV signals
self.app.call_from_thread(self._set_phase, "Detecting QO-100 carriers", 60)
peaks = detect_peaks(freqs, powers, threshold_db=3.0)
# Try blind scan on each peak
for i, (freq, pwr, idx) in enumerate(peaks):
if not self._scanning:
return
freq_khz = int(freq * 1000)
rf_mhz = if_to_rf(freq, lnb_lo)
# Try common QO-100 SRs: 333, 500, 1000, 1500, 2000 ksps
locked = False
locked_sr = 0
for sr_ksps in [1500, 1000, 500, 333, 2000]:
sr_sps = sr_ksps * 1000
bs = self._bridge.blind_scan(freq_khz, sr_sps, sr_sps, 1)
if bs and bs.get("locked"):
locked = True
locked_sr = sr_ksps
break
carrier = {
"if_mhz": freq,
"rf_mhz": rf_mhz,
"sr_ksps": locked_sr,
"power_db": pwr,
"locked": locked,
}
self.app.call_from_thread(self._add_carrier, carrier)
pct = 60 + (i + 1) / len(peaks) * 35
self.app.call_from_thread(self._set_progress, pct)
total = len(peaks)
locked_count = sum(1 for f, p, i in peaks) # simplified
self.app.call_from_thread(
self._set_phase,
f"QO-100 scan complete: {total} carrier{'s' if total != 1 else ''} detected",
100,
)
self._scanning = False
def _set_phase(self, text: str, progress: float) -> None:
if not self.is_mounted:
return
self.query_one("#qo100-phase", Static).update(f"[#00d4aa]{text}[/]")
self.query_one("#qo100-pbar", ProgressBar).update(progress=progress)
def _set_progress(self, pct: float) -> None:
if not self.is_mounted:
return
self.query_one("#qo100-pbar", ProgressBar).update(progress=pct)
def _update_spectrum(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#qo100-spectrum", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _add_carrier(self, carrier: dict) -> None:
if not self.is_mounted:
return
self.query_one("#qo100-table", FrequencyTable).add_transponder(carrier)

View File

@ -382,3 +382,43 @@ StarWarsScreen #sw-container {
background: #000000; background: #000000;
border: round #1a3050; border: round #1a3050;
} }
/* ─── Motor screen ─── */
MotorScreen .jog-row Button {
min-height: 3;
}
MotorScreen #jog-halt {
background: #3a1010;
color: #e04040;
border: round #e04040;
}
MotorScreen #jog-halt:hover {
background: #e04040;
color: #0a0a12;
}
MotorScreen #jog-east,
MotorScreen #jog-west {
background: #1a2a40;
color: #e8a020;
border: round #e8a020;
}
MotorScreen #jog-east:hover,
MotorScreen #jog-west:hover {
background: #e8a020;
color: #0a0a12;
}
/* ─── Survey / QO-100 screen ─── */
SurveyScreen TabbedContent ContentSwitcher {
height: 1fr;
}
SurveyScreen TabPane {
padding: 0;
}