diff --git a/firmware/skywalker1.c b/firmware/skywalker1.c
index 2a1a0c4..1078f27 100644
--- a/firmware/skywalker1.c
+++ b/firmware/skywalker1.c
@@ -4,7 +4,9 @@
*
* Stock-compatible vendor commands (0x80-0x94) plus custom
* 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.
*/
@@ -57,6 +59,17 @@
#define SIGNAL_MONITOR 0xB7
#define TUNE_MONITOR 0xB8
#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 */
#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 */
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.
* 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) {
WORD timeout = I2C_TIMEOUT;
while (!(I2CS & bmDONE)) {
- if (--timeout == 0)
+ if (--timeout == 0) {
+ last_error = ERR_I2C_TIMEOUT;
return FALSE;
+ }
}
return TRUE;
}
@@ -154,23 +178,29 @@ static BOOL i2c_combined_read(BYTE addr, BYTE reg, BYTE len, BYTE *buf) {
I2DAT = addr << 1;
if (!i2c_wait_done())
goto fail;
- if (!(I2CS & bmACK))
+ if (!(I2CS & bmACK)) {
+ last_error = ERR_I2C_NAK;
goto fail;
+ }
/* Write register address */
I2DAT = reg;
if (!i2c_wait_done())
goto fail;
- if (!(I2CS & bmACK))
+ if (!(I2CS & bmACK)) {
+ last_error = ERR_I2C_NAK;
goto fail;
+ }
/* REPEATED START + read address */
I2CS |= bmSTART;
I2DAT = (addr << 1) | 1;
if (!i2c_wait_done())
goto fail;
- if (!(I2CS & bmACK))
+ if (!(I2CS & bmACK)) {
+ last_error = ERR_I2C_NAK;
goto fail;
+ }
/* For single byte, set LASTRD before dummy read */
if (len == 1)
@@ -208,12 +238,12 @@ static BOOL i2c_write_timeout(BYTE addr, BYTE reg, BYTE val) {
I2CS |= bmSTART;
I2DAT = addr << 1;
if (!i2c_wait_done()) goto fail;
- if (!(I2CS & bmACK)) goto fail;
+ if (!(I2CS & bmACK)) { last_error = ERR_I2C_NAK; goto fail; }
/* Register address */
I2DAT = reg;
if (!i2c_wait_done()) goto fail;
- if (!(I2CS & bmACK)) goto fail;
+ if (!(I2CS & bmACK)) { last_error = ERR_I2C_NAK; goto fail; }
/* Data byte */
I2DAT = val;
@@ -241,11 +271,11 @@ static BOOL i2c_write_multi_timeout(BYTE addr, BYTE reg, BYTE len,
I2CS |= bmSTART;
I2DAT = addr << 1;
if (!i2c_wait_done()) goto fail;
- if (!(I2CS & bmACK)) goto fail;
+ if (!(I2CS & bmACK)) { last_error = ERR_I2C_NAK; goto fail; }
I2DAT = reg;
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++) {
I2DAT = data[i];
@@ -333,9 +363,12 @@ static BOOL bcm_poll_ready(void) {
if (bcm_direct_read(BCM_REG_CMD, &val)) {
if (!(val & 0x01))
return TRUE;
+ } else {
+ return FALSE; /* I2C error, last_error already set */
}
delay(2);
}
+ last_error = ERR_BCM_TIMEOUT;
return FALSE;
}
@@ -581,6 +614,330 @@ static void diseqc_tone_burst(BYTE sat_b) {
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) ---------- */
/*
@@ -600,9 +957,8 @@ static void diseqc_tone_burst(BYTE sat_b) {
*/
static void do_spectrum_sweep(void) {
static __xdata DWORD start_freq, stop_freq, cur_freq;
- static __xdata WORD step_khz;
- WORD buf_idx;
- BYTE snr_lo, snr_hi;
+ static __xdata WORD step_khz, ss_buf_idx;
+ static __xdata BYTE ss_snr_lo, ss_snr_hi;
/* Parse the 10-byte EP0 payload */
start_freq = (DWORD)EP0BUF[0] |
@@ -618,7 +974,7 @@ static void do_spectrum_sweep(void) {
if (step_khz == 0)
step_khz = 1000;
- buf_idx = 0;
+ ss_buf_idx = 0;
cur_freq = start_freq;
while (cur_freq <= stop_freq) {
@@ -637,24 +993,24 @@ static void do_spectrum_sweep(void) {
delay(10);
/* Read signal strength via indirect register */
- snr_lo = 0;
- snr_hi = 0;
- bcm_indirect_read(0x00, &snr_lo);
- bcm_indirect_read(0x01, &snr_hi);
+ ss_snr_lo = 0;
+ ss_snr_hi = 0;
+ bcm_indirect_read(0x00, &ss_snr_lo);
+ bcm_indirect_read(0x01, &ss_snr_hi);
/* Store u16 LE into EP2 FIFO buffer */
- if (buf_idx < 1024 - 1) {
- EP2FIFOBUF[buf_idx++] = snr_lo;
- EP2FIFOBUF[buf_idx++] = snr_hi;
+ if (ss_buf_idx < 1024 - 1) {
+ EP2FIFOBUF[ss_buf_idx++] = ss_snr_lo;
+ EP2FIFOBUF[ss_buf_idx++] = ss_snr_hi;
}
/* If buffer is nearly full, commit this chunk */
- if (buf_idx >= 512) {
- EP2BCH = MSB(buf_idx);
+ if (ss_buf_idx >= 512) {
+ EP2BCH = MSB(ss_buf_idx);
SYNCDELAY;
- EP2BCL = LSB(buf_idx);
+ EP2BCL = LSB(ss_buf_idx);
SYNCDELAY;
- buf_idx = 0;
+ ss_buf_idx = 0;
/* Wait for the buffer to be taken by host */
while (EP2CS & bmEPFULL)
@@ -665,10 +1021,10 @@ static void do_spectrum_sweep(void) {
}
/* Commit any remaining data */
- if (buf_idx > 0) {
- EP2BCH = MSB(buf_idx);
+ if (ss_buf_idx > 0) {
+ EP2BCH = MSB(ss_buf_idx);
SYNCDELAY;
- EP2BCL = LSB(buf_idx);
+ EP2BCL = LSB(ss_buf_idx);
SYNCDELAY;
}
}
@@ -690,7 +1046,7 @@ static void do_spectrum_sweep(void) {
*/
static BOOL do_blind_scan(void) {
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] |
((DWORD)EP0BUF[1] << 8) |
@@ -737,9 +1093,9 @@ static BOOL do_blind_scan(void) {
delay(100);
/* Check lock */
- lock_val = 0;
- bcm_direct_read(BCM_REG_LOCK, &lock_val);
- if (lock_val & 0x20) {
+ bs_lock_val = 0;
+ bcm_direct_read(BCM_REG_LOCK, &bs_lock_val);
+ if (bs_lock_val & 0x20) {
/* Locked -- report back via EP0 */
EP0BUF[0] = (BYTE)(freq_khz);
EP0BUF[1] = (BYTE)(freq_khz >> 8);
@@ -775,8 +1131,8 @@ static BOOL do_blind_scan(void) {
* EP0BUF[9] = FEC index
*/
static void do_tune(void) {
- BYTE i;
- __xdata BYTE tune_data[12];
+ static __xdata BYTE tune_i;
+ static __xdata BYTE tune_data[13]; /* 12 data + 1 scratch for reg addr */
if (!(config_status & BM_STARTED))
return;
@@ -785,9 +1141,9 @@ static void do_tune(void) {
* Byte-reverse symbol rate (LE->BE) into tune_data[0..3]
* and frequency (LE->BE) into tune_data[4..7]
*/
- for (i = 0; i < 4; i++) {
- tune_data[i] = EP0BUF[3 - i]; /* SR BE */
- tune_data[4 + i] = EP0BUF[7 - i]; /* Freq BE */
+ for (tune_i = 0; tune_i < 4; tune_i++) {
+ tune_data[tune_i] = EP0BUF[3 - tune_i]; /* SR BE */
+ tune_data[4 + tune_i] = EP0BUF[7 - tune_i]; /* Freq BE */
}
/* Modulation type and FEC rate */
@@ -817,10 +1173,8 @@ static void do_tune(void) {
bcm_direct_write(BCM_REG_PAGE, 0x00);
/* Write all configuration data to BCM4500 data register */
- {
- BYTE reg = BCM_REG_DATA;
- i2c_write(BCM4500_ADDR, 1, ®, 12, tune_data);
- }
+ tune_data[12] = BCM_REG_DATA; /* borrow byte past data (safe: 13 bytes in xdata) */
+ i2c_write(BCM4500_ADDR, 1, &tune_data[12], 12, tune_data);
/* Execute indirect write */
bcm_direct_write(BCM_REG_CMD, BCM_CMD_WRITE);
@@ -1044,8 +1398,25 @@ BOOL handle_vendorcommand(BYTE cmd) {
if (wlen == 0) {
/* Tone burst: A if wval==0, B if wval!=0 */
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;
}
@@ -1062,10 +1433,10 @@ BOOL handle_vendorcommand(BYTE cmd) {
/* 0x92: GET_FW_VERS -- return firmware version and build date */
case GET_FW_VERS:
- EP0BUF[0] = 0x00; /* patch -> version 3.02.0 */
- EP0BUF[1] = 0x02; /* minor */
+ EP0BUF[0] = 0x00; /* patch -> version 3.03.0 */
+ EP0BUF[1] = 0x03; /* minor */
EP0BUF[2] = 0x03; /* major */
- EP0BUF[3] = 0x0C; /* day = 12 */
+ EP0BUF[3] = 0x0F; /* day = 15 */
EP0BUF[4] = 0x02; /* month = 2 */
EP0BUF[5] = 0x1A; /* year - 2000 = 26 */
EP0BCH = 0;
@@ -1171,53 +1542,51 @@ BOOL handle_vendorcommand(BYTE cmd) {
* Returns 8 bytes: [write_A6_ok, readback_A6, write_A8_ok, readback_A8,
* readback_A7, direct_read_A6, direct_read_A7, direct_read_A8] */
case 0xB6: {
- BYTE target_reg = (BYTE)wval;
- BYTE diag[8];
- BYTE rb;
+ /* Use shared xdata diag buffer to save DSEG */
+ vc_diag[0] = (BYTE)wval; /* target_reg */
/* 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 */
- rb = 0xEE;
- i2c_combined_read(BCM4500_ADDR, BCM_REG_PAGE, 1, &rb);
- diag[1] = rb;
+ vc_diag[2] = 0xEE;
+ i2c_combined_read(BCM4500_ADDR, BCM_REG_PAGE, 1, &vc_diag[2]);
/* 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 */
- rb = 0xEE;
- i2c_combined_read(BCM4500_ADDR, BCM_REG_CMD, 1, &rb);
- diag[3] = rb;
+ vc_diag[4] = 0xEE;
+ i2c_combined_read(BCM4500_ADDR, BCM_REG_CMD, 1, &vc_diag[4]);
/* Step 5: Small delay for command execution */
delay(2);
/* Step 6: Read 0xA7 (data register) — this is the result */
- rb = 0xEE;
- i2c_combined_read(BCM4500_ADDR, BCM_REG_DATA, 1, &rb);
- diag[4] = rb;
+ vc_diag[5] = 0xEE;
+ i2c_combined_read(BCM4500_ADDR, BCM_REG_DATA, 1, &vc_diag[5]);
/* Step 7: Read back all three control regs for final state */
- rb = 0xEE;
- i2c_combined_read(BCM4500_ADDR, BCM_REG_PAGE, 1, &rb);
- diag[5] = rb;
- rb = 0xEE;
- i2c_combined_read(BCM4500_ADDR, BCM_REG_DATA, 1, &rb);
- diag[6] = rb;
- rb = 0xEE;
- i2c_combined_read(BCM4500_ADDR, BCM_REG_CMD, 1, &rb);
- diag[7] = rb;
+ vc_diag[6] = 0xEE;
+ i2c_combined_read(BCM4500_ADDR, BCM_REG_PAGE, 1, &vc_diag[6]);
+ vc_diag[7] = 0xEE;
+ i2c_combined_read(BCM4500_ADDR, BCM_REG_DATA, 1, &vc_diag[7]);
+
+ EP0BUF[0] = vc_diag[1]; /* write_A6_ok */
+ EP0BUF[1] = vc_diag[2]; /* readback_A6 */
+ EP0BUF[2] = vc_diag[3]; /* write_A8_ok */
+ 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;
EP0BCL = 8;
return TRUE;
@@ -1307,6 +1676,31 @@ BOOL handle_vendorcommand(BYTE cmd) {
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:
return FALSE;
}
@@ -1365,6 +1759,7 @@ void hispeed_isr(void) __interrupt (HISPEED_ISR) {
void main(void) {
config_status = 0;
+ last_error = ERR_OK;
got_sud = FALSE;
REVCTL = 0x03; /* NOAUTOARM + SKIPCOMMIT */
diff --git a/site/astro.config.mjs b/site/astro.config.mjs
index 1bb3a94..af9e467 100644
--- a/site/astro.config.mjs
+++ b/site/astro.config.mjs
@@ -114,12 +114,20 @@ export default defineConfig({
{ label: 'Tuning', slug: 'tools/tuning' },
{ label: 'SkyWalker RF Tool', slug: 'tools/skywalker' },
{ 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: 'Debugging', slug: 'tools/debugging' },
{ label: 'TS Analyzer', slug: 'tools/ts-analyzer' },
{ label: 'Spectrum Analysis', slug: 'tools/spectrum-analysis' },
],
},
+ {
+ label: 'Guides',
+ items: [
+ { label: 'QO-100 DATV Reception', slug: 'guides/qo100-datv' },
+ ],
+ },
{
label: 'Reference',
items: [
diff --git a/site/src/content/docs/guides/qo100-datv.mdx b/site/src/content/docs/guides/qo100-datv.mdx
new file mode 100644
index 0000000..634c1c9
--- /dev/null
+++ b/site/src/content/docs/guides/qo100-datv.mdx
@@ -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.
+
+
+
+## Overview
+
+
+
+ 10491.000 - 10499.500 MHz (8.5 MHz bandwidth). Carries DVB-S DATV stations from amateur operators worldwide.
+
+
+ 10489.750 MHz CW/PSK beacon. Useful for dish pointing and LNB drift calibration.
+
+
+ 25.9 degrees East — visible from Europe, Africa, western Asia, and eastern Americas.
+
+
+ 60 cm in central Europe, 1.2m+ at the edges of the footprint.
+
+
+
+## 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 |
+
+
+
+### 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
+
+
+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
+ ```
+
+
+## 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 (F10). 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
diff --git a/site/src/content/docs/tools/motor.mdx b/site/src/content/docs/tools/motor.mdx
new file mode 100644
index 0000000..ccd428d
--- /dev/null
+++ b/site/src/content/docs/tools/motor.mdx
@@ -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.
+
+
+
+## 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 |
+|-----|--------|
+| Left / h | Jog west |
+| Right / l | Jog east |
+| Space | Halt motor |
+| 1–9 | Recall stored position |
+| s | Store current position (prompts for slot) |
+| g | USALS GotoX (prompts for coordinates) |
+| q | 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
+
+
+
+## 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
+
+
+
+## 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
diff --git a/site/src/content/docs/tools/survey.mdx b/site/src/content/docs/tools/survey.mdx
new file mode 100644
index 0000000..086edd0
--- /dev/null
+++ b/site/src/content/docs/tools/survey.mdx
@@ -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.
+
+
+
+## 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.
+
+
+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/`.
+
+
+### 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 |
+
+
+
+## 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
+```
+
+
+
+## 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
diff --git a/site/src/content/docs/tools/tui.mdx b/site/src/content/docs/tools/tui.mdx
index 65f8764..0776976 100644
--- a/site/src/content/docs/tools/tui.mdx
+++ b/site/src/content/docs/tools/tui.mdx
@@ -5,9 +5,9 @@ description: Interactive terminal dashboard for spectrum analysis, transponder s
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.
-
+

@@ -34,7 +34,7 @@ uv run skywalker-tui --demo
| `--demo` | Synthetic signal data — no SkyWalker-1 USB device needed |
| `--no-splash` | Skip the startup splash screen |
| `--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` |