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

|

|
||||||
|
|
||||||
@ -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
377
tools/carrier_catalog.py
Normal 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
541
tools/motor.py
Executable 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
773
tools/qo100.py
Executable 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
239
tools/signal_analysis.py
Normal 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,
|
||||||
|
}
|
||||||
@ -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
455
tools/survey.py
Normal 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
440
tools/survey_engine.py
Normal 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
|
||||||
@ -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 == '-':
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
411
tui/src/skywalker_tui/screens/motor.py
Normal file
411
tui/src/skywalker_tui/screens/motor.py
Normal 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")
|
||||||
593
tui/src/skywalker_tui/screens/survey.py
Normal file
593
tui/src/skywalker_tui/screens/survey.py
Normal 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)
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user