Compare commits

...

9 Commits

Author SHA1 Message Date
0d6facb321 Add experimental I2C debugging and EEPROM analysis tools
One-off diagnostic scripts from experiments 0xD7-0xDB investigating
the I2C BERR deadlock. Documents the systematic elimination of
software-only recovery approaches:

- i2c_host_test.py: Proved 0xA0 register writes cannot drive I2C bus
- i2c_register_test.py: Tested I2C register writability from host
- i2c_recovery_boot.py: Attempted I2C state machine recovery via boot
- eeprom_flash_a0.py: Host-side EEPROM flash attempt (failed)
- boot_ab_test.py / boot_test.py: EEPROM boot reliability testing
- a8_autoclear_test.py: BCM4500 command register auto-clear behavior
- addr_gateway_test.py: BCM3440 gateway address routing analysis
- stock_fw_compare.py / stock_fw_test.py: Stock vs custom fw analysis
2026-02-20 10:57:10 -07:00
97c1000d8b Add stock firmware dump, 8051 disassembler, and analysis notes
- stock_firmware.bin: 15KB dump from working device (v2.13)
- disasm8051.py / v2: Custom 8051 disassemblers for FX2LP firmware
  analysis, used to trace init block loading and I2C sequences
- STARTUP_DISASSEMBLY.md: Annotated startup sequence disassembly
- TODO: Notes on stock vs custom firmware BCM4500 init differences
2026-02-20 10:56:59 -07:00
a12a394099 Add FixedAttenuator class and udev rules for RF test bench
FixedAttenuator supports --attenuator fixed:XX for non-programmable
inline SMA pads (set_db is a no-op returning the fixed value).
Udev rules grant non-root USB access for NanoVNA and HMC472A.
2026-02-20 10:56:36 -07:00
3d2cd477b2 Add EEPROM boot firmware (exp 0xDB) and supporting tools
Firmware: Rewrite skywalker1.c for EEPROM boot experiment — tests
whether I2C hardware controller works after FX2 boot ROM completes
EEPROM load (bypassing the CPUCS restart that triggers BERR).

Tools:
- fw_load.py: Add I2C cleanup stub, pre-halt register flush, improved
  error handling and segment loading
- eeprom_write.py: Add IHX→C2 EEPROM image converter (16KB format
  with length-prefixed segments, checksum)
- eeprom_dump.py: Refactor for cleaner output, better hex display
- skywalker_lib.py: Minor I2C register constant updates

Docs:
- EEPROM-RECOVERY.md: Four recovery options for soft-bricked device
  (SOIC clip, SDA pull-up, desolder, wait-for-timeout)
- Master reference: Updated with EEPROM boot findings

Status: EEPROM flash blocked — stock firmware I2C proxy returns pipe
errors, host-side 0xA0 writes proven unable to drive peripheral bus.
Device boot ROM intermittently hangs on EEPROM I2C read (~3-6% success).
2026-02-20 10:56:21 -07:00
bbdcb243dc Normalize line endings to LF across entire repository
Apply .gitattributes normalization to convert all CRLF line
endings inherited from Windows-origin source files to Unix LF.
175 files, zero content changes.
2026-02-20 10:55:50 -07:00
696d2dd387 Add .gitattributes to enforce LF line endings
Prevents CRLF contamination from Windows-origin files.
All text files normalized to LF; binary extensions excluded.
2026-02-20 10:55:29 -07:00
29df688f28 Fix BCM4500 full boot: strip init block length prefixes, handle gateway poll
Two root causes prevented BCM4500 init block writes from completing:

1. Init block data arrays included length prefix bytes from the stock
   firmware's XDATA format (17-byte blocks at code:0x0B4F). The stock
   firmware reads byte 0 as length and writes bytes 1..N to A7.
   Blocks 0 and 1 had the length prefix (0x06, 0x07) as the first
   data byte, corrupting the DSP's indirect register FIFO.

2. The BCM3440 gateway's A8 register does not clear bit 0 after
   indirect write commands (0x03), even though the BCM4500 processes
   them successfully (confirmed via direct address 0x08 where A8
   transitions from 0x03 → 0x02). bcm_poll_ready() now treats
   gateway timeout as success with a settling delay.

Boot now completes reliably in ~0.96s through all stages:
GPIO → power → reset → PLL/DSP load → init blocks 0,1,2 → 0xFF.
2026-02-19 22:08:44 -07:00
7f1e0cf0d7 Add USB serial transport for HMC472A attenuator control
HMC472ASerial class implements usb-serial-json-v1 protocol over the
ESP32-S3's native USB CDC port. Auto-detection scans /dev/ttyACM* and
probes with the identify command to find the right port.

--attenuator flag now defaults to 'auto' (USB first, HTTP fallback).
Also accepts direct serial port paths or HTTP URLs for explicit control.
2026-02-18 15:06:00 -07:00
d117782dcf Add RF test bench tool for CW injection tests with NanoVNA + HMC472A
New tool (tools/rf_testbench.py) automates five test sequences using a
NanoVNA as a CW source and HMC472A digital attenuator (0-31.5 dB, 0.5 dB
steps via REST API) to characterize the SkyWalker-1 receiver:

- AGC linearity mapping across 64 attenuation steps
- IF band flatness sweep (950-1500 MHz)
- Frequency accuracy via peak detection
- Minimum detectable signal search
- BPSK mode 9 CW probe (Viterbi rate 1/2 K=7)

Includes SKYWALKER_MOCK=1 mode, path-loss calibration from NanoVNA S21
sweeps, and safe-state cleanup (attenuator to max on exit, LNB power
never enabled in direct-input mode).

Also adds Applications & Use Cases guide, RF Test Bench docs page, fixes
h21cm cable loss (was 3x too high), and updates sidebar.
2026-02-17 23:11:09 -07:00
207 changed files with 71017 additions and 62537 deletions

36
.gitattributes vendored Normal file
View File

@ -0,0 +1,36 @@
# Normalize line endings to LF on commit
* text=auto eol=lf
# Force LF for all source files (prevents CRLF contamination)
*.py text eol=lf
*.c text eol=lf
*.h text eol=lf
*.js text eol=lf
*.mjs text eol=lf
*.ts text eol=lf
*.json text eol=lf
*.md text eol=lf
*.mdx text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.cfg text eol=lf
*.txt text eol=lf
*.css text eol=lf
*.astro text eol=lf
*.html text eol=lf
*.svg text eol=lf
Makefile text eol=lf
Dockerfile text eol=lf
*.sh text eol=lf
# Binary files — leave as-is
*.bin binary
*.ihx binary
*.hex binary
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary

8
TODO Normal file
View File

@ -0,0 +1,8 @@
Both stock and our custom firmware report the same status
progression (0x00 → 0x03 → 0x07), but the stock firmware's
BCM4500 initialization actually works (SNR raw ~64000) while
the custom firmware's returns all zeros. The custom
firmware's bcm4500_boot() silently fails somewhere in the
I2C init block writes — it sets BM_STARTED | BM_FW_LOADED
but the hardware isn't actually running. This is a firmware
bug that needs investigation.

144
docs/EEPROM-RECOVERY.md Normal file
View File

@ -0,0 +1,144 @@
# SkyWalker-1 EEPROM Recovery Guide
The device is soft-bricked: the FX2 boot ROM hangs trying to load
corrupted firmware from EEPROM, preventing USB enumeration.
## Symptoms
- Hub shows `0101 power connect []` (D+ pull-up active, no enumeration)
- dmesg: `device descriptor read/8, error -110` (timeout)
- Does not enumerate as bare FX2 (04B4:8613) either
- NanoVNA on same hub works fine (hub hardware is OK)
## Root Cause
The EEPROM (24C128 at I2C 0x51) likely has corrupted boot data. The
FX2LP boot ROM reads the EEPROM at power-up and hangs if the C2 image
has invalid load record lengths or addresses. The boot ROM occupies
the 8051 core, preventing USB control transfer processing.
## Recovery Options (pick one)
### Option A: SOIC Clip + External Programmer (Recommended)
Blank the first byte of the EEPROM so the boot ROM falls back to
bare FX2 enumeration. Then reload via USB.
**Hardware needed:**
- SOIC-8 test clip (Pomona 5250 or similar, ~$5)
- CH341A USB programmer (~$3) or Bus Pirate or any I2C-capable tool
- OR: Raspberry Pi / Arduino with I2C
**Steps:**
1. Power OFF the SkyWalker-1 (unplug USB)
2. Locate the 24C128 EEPROM on the PCB (SOIC-8 package near the FX2)
3. Clip the SOIC clip onto the EEPROM
4. Connect to your I2C programmer (SDA, SCL, VCC, GND)
5. Read and save the EEPROM contents (16KB backup!)
6. Write 0xFF to address 0x0000 (corrupts the C2 magic byte)
7. Remove clip, plug in SkyWalker-1
8. Device should enumerate as bare FX2 (04B4:8613)
9. Load custom firmware via `fw_load.py`
10. Use the custom firmware to write good C2 image back to EEPROM
**With CH341A:**
```bash
# Read backup
flashrom -p ch341a_spi -c "AT24C128" -r eeprom_backup.bin
# Or use i2c-tools if CH341A is in I2C mode:
# i2cdetect -l (find the CH341A bus)
# i2cdump -y <bus> 0x51 b > dump.txt
```
**With Raspberry Pi (I2C):**
```bash
# Enable I2C: raspi-config -> Interfaces -> I2C
# Connect EEPROM: SDA->GPIO2, SCL->GPIO3, VCC->3.3V, GND->GND
i2cdetect -y 1 # Should show 0x51
# Read first byte
i2cget -y 1 0x51 0x00
# Write 0xFF to byte 0 (corrupts C2 header)
i2cset -y 1 0x51 0x00 0xFF
```
### Option B: Hold SDA HIGH During Boot
Prevent the EEPROM from responding by holding SDA HIGH, forcing
the boot ROM to see "no EEPROM" and enumerate as bare FX2.
**Steps:**
1. Locate the SDA test point or EEPROM pin 5 (SDA)
2. Connect a 1kΩ pull-up to 3.3V on SDA
3. Power on the SkyWalker-1
4. If it enumerates as bare FX2 (04B4:8613), load firmware:
```bash
python3 tools/fw_load.py load firmware/build/skywalker1.ihx
```
5. Remove the pull-up
6. Use the loaded firmware to reprogram the EEPROM
**Note:** This only works if the SDA pull-up is strong enough to
override the EEPROM's SDA output. May need to experiment with
pull-up values (470Ω to 4.7kΩ).
### Option C: Desolder EEPROM Pin
Most reliable but requires soldering skill.
1. Lift EEPROM pin 5 (SDA) from the PCB pad
2. Power on → enumerates as bare FX2
3. Load firmware via USB
4. Resolder pin 5
5. Use firmware to reprogram EEPROM with good C2 image
### Option D: Wait + Watch (Long Shot)
If the boot ROM eventually times out on the I2C read, the device
will briefly enumerate as bare FX2. This might take several minutes.
```bash
# Watch for bare FX2 enumeration
sudo dmesg -w | grep -E "04b4|8613|New USB"
# In another terminal, keep power cycling every 5 minutes
while true; do
sudo uhubctl -l 1-5.4.4 -p 3 -a off
sleep 5
sudo uhubctl -l 1-5.4.4 -p 3 -a on
sleep 300 # wait 5 minutes
done
```
If it appears even briefly:
```bash
python3 tools/fw_load.py load firmware/build/skywalker1.ihx --force
```
## After Recovery
Once the device enumerates (as bare FX2 or with loaded firmware):
1. **Load custom firmware to RAM:**
```bash
python3 tools/fw_load.py load firmware/build/skywalker1.ihx
```
2. **Reprogram EEPROM with good C2 image:**
```bash
# The custom firmware needs EEPROM write support first
# (vendor command to relay I2C writes to EEPROM)
python3 tools/eeprom_write.py flash firmware/build/skywalker1_eeprom.bin
```
3. **Or restore stock firmware:**
If you have a backup of the original EEPROM contents, flash that
instead of the custom firmware.
## Prevention
- Never send `BOOT_8PSK (0x89)` with mode 0x84 ("firmware load")
unless you know what data the firmware expects
- Always backup EEPROM before experiments that touch vendor commands
- The stock firmware's I2C proxy (0x83/0x84) may have side effects
on the EEPROM that aren't documented

View File

@ -74,7 +74,8 @@ DVB-S2 is not supported. See [Section 14](#14-dvb-s2-incompatibility).
+--[ I2C EEPROM 0x51 ] +--[ I2C EEPROM 0x51 ]
| |
USB 2.0 HS | I2C Bus (400 kHz) USB 2.0 HS | I2C Bus (400 kHz)
Host PC <----> [ CY7C68013A FX2LP ] <-----> [ BCM4500 Demod 0x08 ] Host PC <----> [ CY7C68013A FX2LP ] <--I2C--> [ BCM3440 Tuner 0x10 ] <--gateway--> [ BCM4500 Demod ]
<--I2C--> [ BCM4500 Direct 0x08 (status only) ]
| 8051 @ 48 MHz | | | 8051 @ 48 MHz | |
| GPIF Engine |<-----------+ 8-bit parallel TS | GPIF Engine |<-----------+ 8-bit parallel TS
| EP2 Bulk IN | | EP2 Bulk IN |
@ -364,24 +365,39 @@ Writing 0x01 to CPUCS halts the CPU. New code is written to RAM. Writing 0x00 re
## 6. BCM4500 Demodulator Interface ## 6. BCM4500 Demodulator Interface
### 6.1 I2C Addressing ### 6.1 I2C Addressing — BCM3440 Tuner Gateway
> **CRITICAL (2025-02-19):** The BCM4500's registers are accessed THROUGH the
> BCM3440 tuner's I2C gateway at address 0x10, NOT directly at 0x08.
> Stock firmware v2.06 disassembly of FUN_CODE_0DDD, FUN_CODE_10F2, and all
> internal register access functions confirms device address 0x10 is used
> for every register read/write.
| Parameter | Value | | Parameter | Value |
|-----------|-------| |-----------|-------|
| 7-bit I2C address | 0x08 | | BCM3440 tuner gateway (7-bit) | **0x10** — all BCM4500 register access |
| 8-bit write address | 0x10 | | BCM3440 wire write / read | 0x20 / 0x21 |
| 8-bit read address | 0x11 | | BCM4500 direct (7-bit) | 0x08 — status byte only, no register addressing |
| BCM4500 wire write / read | 0x10 / 0x11 |
| Bus speed | 400 kHz | | Bus speed | 400 kHz |
| FX2 I2C controller SFRs | I2CS, I2DAT, I2CTL | | FX2 I2C controller SFRs | I2CS, I2DAT, I2CTL |
| Alternate probe addresses (v2.13) | 0x3F, 0x7F | | Alternate probe addresses (v2.13) | 0x3F, 0x7F |
The custom firmware and kernel driver use the 7-bit address 0x08. The stock firmware writes `addr << 1` = 0x10 for write and `(addr << 1) | 1` = 0x11 for read, which is the standard I2C convention for 7-bit address 0x08. The BCM3440 tuner acts as an I2C bridge/gateway: register accesses in the 0xA0+ range sent to the tuner's address (0x10) are transparently forwarded to the BCM4500 demodulator. The BCM4500's own I2C address (0x08) only exposes a single status byte via simple reads — it does NOT support register-addressed reads at that address.
The v2.13 firmware probes addresses 0x7F and 0x3F at startup (INT0 handler) to detect which demodulator variant is present. These may be alternative I2C address configurations or addresses for different demodulator sub-systems. **Stock firmware evidence (functions at wire address 0x20/0x21):**
- `FUN_CODE_0DDD` (init blocks): writes A6/A7/A8 via `LCALL 0x1A81` with R7=0x10
- `FUN_CODE_10F2` (PLL/firmware download): writes A9/AA/AB via `LCALL 0x1A81` with R7=0x10
- `FUN_CODE_15E9` (config mode): writes A0 via device 0x10
- `FUN_CODE_1556` (generic read): combined read `[S][0x20][reg][Sr][0x21][data][P]`
**Stock I2C_READ (0x84) vendor command for BCM4500 (address 0x08):** Simple read only — `[S][0x11][data][P]` — no register address sent. Returns whatever the BCM4500's I2C slave has ready (global status byte). Register address from wIndex is completely ignored for device 0x08 (confirmed by disassembly of `FUN_CODE_2036`).
The v2.13 firmware probes addresses 0x7F and 0x3F at startup (INT0 handler) to detect which demodulator variant is present.
### 6.2 Direct Registers ### 6.2 Direct Registers
Accessed via standard I2C write/read to the BCM4500's device address: Accessed via I2C write/read through the BCM3440 gateway (address 0x10):
| Register | Function | | Register | Function |
|----------|----------| |----------|----------|

View File

@ -0,0 +1,604 @@
# Genpix SkyWalker-1 Firmware Startup Disassembly
**Binary**: `skywalker1_eeprom_full64k.bin` (65536 bytes, RAM image after EEPROM boot)
**CPU**: FX2LP (CY7C68013A) — 8051 core @ 48MHz
**Firmware region**: 0x0000-0x24FF (9472 bytes)
**Compiler**: Keil C51
**USB**: VID=0x09C0 (Cypress), PID=0x0203
---
## Phase 1: Reset Vector (0x0000)
```
0000: 02 18 8D LJMP 0x188D ; -> Keil C51 startup
```
## Phase 2: Keil C51 Startup (0x188D)
### IDATA Clear
```
188D: 78 7F MOV R0,#7Fh ; Start at IDATA address 0x7F
188F: E4 CLR A ; A = 0
1890: F6 MOV @R0,A ; Clear IDATA[R0]
1891: D8 FD DJNZ R0,1890h ; Decrement R0, loop until 0
; Clears IDATA 0x7F..0x01 (127 bytes)
```
### Stack Pointer
```
1893: 75 81 72 MOV SP,#72h ; Stack pointer = 0x72
; Stack grows up: 0x73..0x7F = 13 bytes
```
### XDATA Initialization
```
1896: 02 18 D4 LJMP 18D4h ; Jump to init table interpreter
; Init table interpreter at 0x18D4 (Keil standard STARTUP.A51):
; Reads structured init records from CODE space
; Each record has:
; - Length byte (bits 5:0 = count, bits 7:6 = mode)
; - Mode 00: IDATA/XDATA fill (address + data pairs)
; - Mode 01: Bit initialization
; - Mode 10: XDATA block copy (source addr + destination + data)
; - Zero byte terminates the table
;
; When table exhausted:
1899: 02 09 A7 LJMP 09A7h ; -> main()
```
## Phase 3: main() (0x09A7)
### Clear Global Variables
```
09A7: E4 CLR A ; A = 0
09A8: F5 2D MOV 2Dh,A ; Clear counter/state variables
09AA: F5 2C MOV 2Ch,A ; 4-byte counter at 0x2A-0x2D
09AC: F5 2B MOV 2Bh,A
09AE: F5 2A MOV 2Ah,A
09B0: F5 35 MOV 35h,A ; 4-byte counter at 0x32-0x35
09B2: F5 34 MOV 34h,A
09B4: F5 33 MOV 33h,A
09B6: F5 32 MOV 32h,A
09B8: C2 03 CLR bit_03h ; Clear SUSPEND-pending flag
09BA: C2 01 CLR bit_01h ; Clear SETUP-pending flag
```
### Call hw_init()
```
09BC: 12 13 C3 LCALL 13C3h ; -> hw_init()
```
## Phase 4: hw_init() (0x13C3)
### CPU Configuration
```
13C3: MOV DPTR,#E605h ; REVCTL (FX2LP revision control)
13C6: MOVX A,@DPTR ; Read current value
13C7: ANL A,#FDh ; Clear bit 1 (NOAUTOARM)
13C9: MOVX @DPTR,A ; REVCTL &= ~0x02
13CE: MOV DPTR,#E600h ; CPUCS
13D1: MOVX A,@DPTR
13D2: ANL A,#E5h ; Clear bits 4,3,1 (clock bits)
13D4: ORL A,#10h ; Set bit 4
13D6: MOVX @DPTR,A ; CPUCS = 48MHz clock mode
; NOP x3 (sync delay)
```
### Interface Configuration
```
13DA: MOV DPTR,#E601h ; IFCONFIG
13DD: MOV A,#CAh ; 0xCA = 1100_1010:
; IFCLKSRC=1 (internal)
; 3048MHZ=1 (48MHz)
; IFCLKOE=0 (don't output IFCLK)
; IFCLKPOL=0
; ASYNC=1 (async GPIF)
; GSTATE=0
; IFCFG=10 (GPIF mode)
13DF: MOVX @DPTR,A
```
### GPIF Waveform Init
```
13E3: MOV DPTR,#E6F5h ; Undocumented register
13E6: MOV A,#FFh
13E8: MOVX @DPTR,A
13E9: MOV 0xAF,#07h ; Unknown SFR (FX2-specific)
13F1: LCALL 12EAh ; GPIF waveform configuration:
; Reads waveform data from XDATA 0xE000-0xE08E
; Copies 128 bytes to GPIF waveform RAM via autopointers
; Configures: GPIFCTLCFG, GPIFIDLECS, GPIFIDLECTL,
; GPIFWFSELECT, GPIFADR, CTL states
; PORTCCFG=0xFF (all alt function for GPIF)
; PORTECFG.7=1 (GPIF ready signal)
```
### State Initialization
```
13F4: CLR A
13F5: MOV 6Dh,A ; Status flags byte = 0
; bit 0: init in progress
; bit 1: 8PSK firmware loaded
; bit 3: signature check mode
; bit 6: GPIF configured
; bit 7: streaming active
13F7: CLR bit_05h
13F9: CLR bit_04h
13FB: MOV 68h,A ; Clear more state
13FD: MOV 69h,A
13FF: MOV 66h,A
1401: CLR bit_06h ; GPIF-configured flag = 0
```
### GPIO Configuration
```
1403: MOV DPTR,#E670h ; PORTACFG
1406: MOVX @DPTR,A ; = 0x00 (all GPIO, no alt functions)
1407: MOV IOA,#A4h ; Port A initial: 1010_0100
; PA7=1 (GPIF/transport select?)
; PA5=1 (8PSK_RESET deasserted)
; PA2=1 (LNB voltage select?)
; PA1=0, PA0=input
140A: MOV OEA,#FEh ; Port A direction: 1111_1110
; PA7-PA1 = output
; PA0 = input (lock detect?)
140D: MOV IOD,#F0h ; Port D initial: 1111_0000
; PD7=1 (8PSK bus D7?)
; PD6=1 (8PSK bus D6?)
; PD5=1 (8PSK bus D5?)
; PD4=1 (8PSK bus D4?)
1410: MOV OED,#F0h ; Port D direction: upper 4 output
```
### FIFO Reset Sequence
```
141C: MOV DPTR,#E604h ; FIFORESET
141F: MOV A,#80h ; NAK-ALL = 1 (hold off USB during reset)
1421: MOVX @DPTR,A
1425: MOV A,#02h ; Reset EP2
1427: MOVX @DPTR,A
142B: MOV A,#04h ; Reset EP4
142D: MOVX @DPTR,A
1431: MOV A,#06h ; Reset EP6
1433: MOVX @DPTR,A
1437: MOV A,#08h ; Reset EP8
1439: MOVX @DPTR,A
143D: CLR A ; NAK-ALL = 0 (release)
143E: MOVX @DPTR,A
```
### GPIF Timing
```
1442: MOV DPTR,#E618h ; GPIFHOLDAMOUNT
1445: MOV A,#0Ch ; 12 IFCLK cycles hold
1447: MOVX @DPTR,A
; E619-E61B (FLOWSTATE regs) = 0x00
```
### I2C Configuration
```
1461: MOV DPTR,#E67Ah ; I2CTL
1464: MOVX A,@DPTR
1465: ORL A,#01h ; Set bit 0: 400kHz I2C clock
1467: MOVX @DPTR,A
```
### Timer 2 Setup
```
1468: ANL CKCON,#DFh ; Clear Timer2 clock source bit
146B: MOV T2CON,#04h ; Timer 2: auto-reload mode
146E: MOV RCAP2H,#F8h ; Reload value = 0xF8xx (fast tick)
```
## Phase 5: Descriptor Setup (back in main, 0x09BF)
```
09BF: MOV 0Ch,#12h ; Descriptor table high byte
09C2: MOV 0Dh,#00h ; Descriptor table low byte = 0x1200
; -> USB Device Descriptor
; Multiple descriptor pointer pairs stored in direct RAM:
; [0C:0D] = 0x1200 Device Descriptor
; [14:15] = 0x1212 Device Qualifier
; [0A:0B] = 0x121C Configuration Descriptor
; [12:13] = 0x1254 Other Speed Config
; [16:17] = 0x128C String Descriptors
; [08:09] = 0x12E8 (high-speed descriptor variant?)
; 09E3-0AE0: Copy all descriptors from CODE to XDATA
; Calculates actual XDATA base address
; Adjusts all pointers for runtime location
; Prepares EP configuration from config descriptor
```
## Phase 6: USB Enable (0x0AE1)
```
0AE1: MOV R7,09h / R6,08h ; Pass descriptor base pointer
0AE5: LCALL 1A0Eh ; Configure USB descriptors in FX2
; Reads XDATA desc, calculates checksums
; Sets up descriptor table for USB core
; Checks descriptor type -> sets bit_06h
; Enable USB interrupts
0AE8: SETB EIE.0 ; Enable INT2 (USB interrupt)
0AEA: ORL EICON,#20h ; INT2 edge trigger
0AED: MOV DPTR,#E668h ; INTSETUP
0AF0: MOVX A,@DPTR
0AF1: ORL A,#09h ; Enable autovectoring for INT2 and INT4
0AF3: MOVX @DPTR,A
0AF4: MOV DPTR,#E65Ch ; USBIE (USB Interrupt Enable)
0AF7: MOVX A,@DPTR
0AF8: ORL A,#3Dh ; Enable: SUDAV|SOF|SUTOK|SUSPEND|USBRESET
; 0x3D = 0011_1101
0AFA: MOVX @DPTR,A
0AFB: SETB EA ; Global interrupt enable (IE.7)
```
### USB Re-enumeration
```
0AFD: SETB bit_07h ; Mark "disconnected" state
0AFF: LCALL 0003h ; Call re-enumeration handler:
; Sets USBCS.DISCON=1, USBCS.RENUM=1
; Delay ~375,000 cycles (~7.8ms @ 48MHz)
; Clears USBIRQ (write 0xFF)
; Clears EPIRQ (write 0xFF)
; Clears EXIF.4
; Clears USBCS.DISCON (reconnect)
; After re-enum completes:
0B02: MOV DPTR,#E680h ; USBCS
0B05: MOVX A,@DPTR
0B06: ANL A,#F7h ; Clear DISCON bit (bit 3)
0B08: MOVX @DPTR,A ; *** USB device now visible to host ***
0B09: ANL CKCON,#F8h ; Final timer prescaler setup
```
## Phase 7: Main Loop (0x0B0C)
```
; ---- MAIN POLLING LOOP ----
0B0C: LCALL 2297h ; ep1_poll():
; Check EP1IN ready, arm if needed
; Check FLOW state registers
; Return C=1 if EP1 armed
0B0F: JNB bit_01h,0B17h ; SETUP pending (from SUDAV ISR)?
0B12: LCALL 032Ah ; Yes -> handle_setup()
0B15: CLR bit_01h ; Clear pending flag
0B17: JNB bit_03h,0B0Ch ; SUSPEND pending (from SUSPEND ISR)?
0B1A: LCALL 24DAh ; Check suspend condition
0B1D: JNC 0B0Ch ; False alarm -> continue loop
0B1F: CLR bit_03h
0B21: LCALL 21EDh ; Enter suspend (WAKEUPCS handling)
; ... wakeup recovery code ...
0B3D: LCALL 211Dh ; I2C device polling (demod status?)
; ... loop back to 0B0C ...
```
## Phase 8: SETUP Packet Handler (0x032A)
### Standard Request Dispatch
```
032A: MOV DPTR,#E6B9h ; SETUPDAT[1] = bRequest (alt mapping)
032D: MOVX A,@DPTR ; Read bRequest
032E: CJNE A,#0Ch,0331h ; Compare with 12
0331: JC 0335h ; If bRequest < 12 -> standard USB request
0333: AJMP 05ABh ; Else -> vendor request handler
; Standard USB request jump table (bRequest 0-11):
0335: MOV DPTR,#033Bh
0338: ADD A,ACC ; index * 2 (each AJMP is 2 bytes)
033A: JMP @A+DPTR ; Computed jump
; Table at 033B (12 entries):
; [0] GET_STATUS -> 0403h
; [1] CLEAR_FEATURE -> 04A6h
; [2] (reserved) -> 05ABh (stall)
; [3] SET_FEATURE -> 0536h
; [4] (reserved) -> 05ABh
; [5] SET_ADDRESS -> 05ABh (handled by hardware)
; [6] GET_DESCRIPTOR -> 0353h
; [7] (reserved) -> 05ABh
; [8] GET_CONFIGURATION -> 03FEh
; [9] SET_CONFIGURATION -> 03F4h
; [10] GET_INTERFACE -> 03DBh
; [11] SET_INTERFACE -> 03E5h
```
### Vendor Request Dispatch (0x05AB -> 0x0056)
```
05AB: LCALL 0056h ; Call vendor request dispatcher
05AE: JC 05B7h ; If handled (C=1) -> skip stall
05B0: MOV DPTR,#E6A0h ; EP0CS
05B3: MOVX A,@DPTR
05B4: ORL A,#01h ; Set STALL bit (unhandled request)
05B6: MOVX @DPTR,A
05B7: MOV DPTR,#E6A0h ; EP0CS
05BA: MOVX A,@DPTR
05BB: ORL A,#80h ; Set HSNAK bit (handshake)
05BD: MOVX @DPTR,A
05BE: RET
```
### Vendor Dispatch at 0x0056
```
; NOTE: E6B8-E6BF maps to SETUPDAT[0]-SETUPDAT[7] (alternate XDATA addresses)
; This is an undocumented but valid FX2LP mapping.
0056: MOV DPTR,#E6B8h ; SETUPDAT[0] = bmRequestType
0059: MOVX A,@DPTR
005A: JB ACC.6,005Fh ; Test bit 6: vendor request flag
005D: CLR C ; Not vendor -> return unhandled
005E: RET
005F: MOV DPTR,#E6B9h ; SETUPDAT[1] = bRequest
0062: MOVX A,@DPTR
0063: ADD A,#80h ; Remap: bRequest 0x80 -> index 0x00
; bRequest 0x89 -> index 0x09
; bRequest 0x9D -> index 0x1D
0065: CJNE A,#1Eh,0068h ; Check if index < 30
0068: JC 006Ch ; In range -> use jump table
006A: AJMP 0326h ; Out of range -> return unhandled (C=0)
006C: MOV DPTR,#0076h ; Jump table base
006F: ADD A,ACC ; index * 2 (AJMP = 2 bytes each)
0071: JNC 0075h ; Handle page crossing
0073: INC DPH
0075: JMP @A+DPTR ; Computed jump into vendor table
```
### Vendor Command Jump Table (0x0076)
| bRequest | Index | Target | Function |
|----------|-------|--------|----------|
| 0x80 | 0 | 00B2h | GET_STATUS — returns status byte `6Dh` via EP0 |
| 0x81 | 1 | 0326h | (unhandled) |
| 0x82 | 2 | 0326h | (unhandled) |
| 0x83 | 3 | 00F1h | EP0_SETUP_READ — sets up descriptor read via SUDPTR |
| 0x84 | 4 | 0102h | EP0_DATA_WRITE — host writes data to EP0 |
| 0x85 | 5 | 0110h | EP0_DATA_READ — host reads data from EP0 |
| 0x86 | 6 | 012Eh | I2C_WRITE — write I2C data block |
| 0x87 | 7 | 0140h | EP0_MULTI_READ — multi-byte EP0 read |
| 0x88 | 8 | 0326h | (unhandled) |
| **0x89** | **9** | **00C4h** | **BOOT_8PSK** — boot the 8PSK demodulator |
| 0x8A | 10 | 019Ch | I2C_READ (with status check) |
| 0x8B | 11 | 01CBh | I2C_WRITE (variant) |
| 0x8C | 12 | 01DDh | I2C_READ (variant) |
| 0x8D | 13 | 01EFh | DiSEqC / LNB control? |
| 0x8E | 14 | 0326h | (unhandled) |
| 0x8F | 15 | 01FCh | GPIF flow control / read register |
| 0x90 | 16 | 020Bh | TUNE — set frequency / symbol rate |
| 0x91 | 17 | 022Ch | TUNE_STATUS — check lock |
| 0x92 | 18 | 024Ah | SET_TRANSPORT — configure TS stream |
| 0x93 | 19 | 026Fh | GET_SIGNAL_QUALITY — read SNR/BER |
| 0x94 | 20 | 01B9h | I2C bus control |
| 0x95 | 21 | 02DFh | GET_DEMOD_STATUS |
| 0x96 | 22 | 02B4h | I2C_ADDR_SELECT |
| 0x97 | 23 | 02C1h | RAW_REGISTER_READ |
| 0x98 | 24 | 02CBh | GET_EP0_BUFFER |
| 0x99-9C | 25-28 | 0326h | (unhandled) |
| 0x9D | 29 | 02FAh | FIRMWARE_VERSION? |
## BOOT_8PSK (0x89) Handler: Complete Trace
### Entry Point (0x00C4)
```
00C4: JNB bit_06h,00DAh ; Is GPIF configured?
; [If GPIF configured]:
00C7: MOV DPTR,#E6BAh ; SETUPDAT[2] = wValueL
00CA: MOVX A,@DPTR
00CB: ADD A,#FFh ; Test if wValue == 0 (ADD #FF sets C if A>0)
00CD: MOV bit_07h,C ; bit_07h = (wValue != 0) = "load-from-host" flag
00CF: LCALL 1D4Fh ; -> boot_8psk_dispatch()
; [If NOT configured]:
00DA: CLR bit_07h ; Force "no load" mode
00DC: LCALL 1D4Fh ; -> boot_8psk_dispatch()
; [Common exit]:
00E5: CLR A
00E6: MOV DPTR,#E68Ah ; EP0BCH = 0
00E9: MOVX @DPTR,A
00EA: MOV DPTR,#E68Bh ; EP0BCL = 1
00ED: INC A
00EE: MOVX @DPTR,A ; Send 1-byte response (success/fail in EP0BUF[0])
00EF: AJMP 0328h ; SETB C; RET (command handled)
```
### boot_8psk_dispatch (0x1D4F)
```
1D4F: MOV A,6Dh ; Read status flags
1D51: RRC A ; bit 0 (init flag) -> C -> bit_08h
1D52: MOV bit_08h,C ; bit_08h = "currently booted" state
1D54: ORL IOD,#E0h ; PD7:PD5 = 1 (8PSK bus enable)
1D57: JNB bit_07h,1D93h ; If NOT "load from host" -> error path
1D5A: JB bit_08h,1DA5h ; If already booted -> return success
; --- Signature Check ---
1D5D: LCALL 16B8h ; Check 8PSK demod chip signature:
; Read descriptor type byte from XDATA
; Switch IFCONFIG to port mode (0xC0)
; Set OEB=0 (Port B = input)
; Set PA7=1, PA6=1 (select chip)
; Read Port B:
; Type 3: expect IOB=0xA5
; Type 4: expect IOB=0x5A
; Type 5: expect IOB=0x5B
; Type 6: expect IOB=0x5C
; Restore IFCONFIG
; Return C=1 if signature match
1D60: JNC 1DA5h ; Signature fail -> return with current state
; --- Begin 8PSK Boot ---
1D62: ANL IOA,#DFh ; PA5=0 (assert 8PSK reset)
1D65: ANL 6Dh,#BFh ; Clear "streaming" flag in 6Dh
1D68: CLR bit_09h ; Clear streaming-active flag
1D6A: LCALL 1919h ; GPIF abort + FIFO cleanup:
; EP2FIFOPFH, GPIFABORT, GPIFCTRL
; Wait for GPIF idle
; Reset EP2 FIFO
; --- Configure for programming ---
1D6D: MOV A,IOA
1D6F: ANL A,#FBh ; PA2=0
1D71: ORL A,#02h ; PA1=1 (select programming mode)
1D73: MOV IOA,A
1D75: MOV R7,#1Eh / R6,#00h
1D79: LCALL 1DFBh ; Delay ~30 * (CPUCS-based divisor) cycles
; --- Deassert reset ---
1D7C: ORL IOA,#20h ; PA5=1 (deassert 8PSK reset)
1D7F: ORL 6Dh,#01h ; Set "init in progress" flag
; --- Load 8PSK firmware ---
1D82: LCALL 10F2h ; load_8psk_firmware():
; I2C read from demod (addr 0x51) to get segment table
; Loop: read segment header (addr, length)
; I2C write firmware data blocks to demod
; Retry up to 5x on write failures
; Return C=0 on success, C=1 on failure
1D85: JC 1D93h ; If load failed -> error
; --- Boot init blocks ---
1D87: LCALL 0DDDh ; boot_init_blocks():
; 3 iterations (init blocks 0,1,2)
; Each: wait_for_ready() then I2C write
; Sends register configuration to demod
1D8A: JC 1D93h ; If init failed -> error
1D8C: SETB bit_08h ; Mark "8PSK booted"
1D8E: ORL IOD,#E0h ; PD7:PD5 = 1 (8PSK bus active)
1D91: SJMP 1DA5h ; -> success exit
; --- Error path ---
1D93: ANL 6Dh,#BCh ; Clear bits 0,1,6 in status
1D96: CLR bit_09h ; Clear streaming flag
1D98: LCALL 1919h ; GPIF abort + cleanup
1D9B: MOV A,IOA
1D9D: ANL A,#FDh ; PA1=0
1D9F: ORL A,#04h ; PA2=1 (restore normal GPIO)
1DA1: MOV IOA,A
1DA3: CLR bit_08h ; Mark "not booted"
; --- Return ---
1DA5: MOV C,bit_08h ; Return C = boot success
1DA7: RET
```
## USB Interrupt Vector Table (0x1600)
The FX2LP USB autovector table at 0x1600. Each entry is a 3-byte LJMP + 1 NOP (4 bytes per slot). The hardware indexes into this table via INT2IVEC.
| Offset | IRQ Source | Target | Status |
|--------|-----------------|--------|--------|
| +0x00 | SUDAV | 22E4h | **Active** — sets bit_01h, clears USBIRQ |
| +0x04 | SOF | 239Fh | **Active** |
| +0x08 | SUTOK | 2389h | **Active** |
| +0x0C | SUSPEND | 22FCh | **Active** — sets bit_03h |
| +0x10 | USBRESET | 1FB6h | **Active** |
| +0x14 | HISPEED | 1EC7h | **Active** |
| +0x18 | EP0ACK | 0032h | Stub (RETI) |
| +0x24 | EP0OUT | 0FFFh | **Active** |
| +0x28+ | EP1-8, IBN, etc | 24E0h+ | All stubs |
## USB Descriptors (0x1200)
```
Device Descriptor (18 bytes):
bLength: 18
bDescriptorType: 1 (DEVICE)
bcdUSB: 02.00
bDeviceClass: 0xFF (vendor-specific)
bDeviceSubClass: 0xFF
bDeviceProtocol: 0x00
bMaxPacketSize0: 64
idVendor: 0x09C0 (Cypress Semiconductor)
idProduct: 0x0203
bcdDevice: 0x0001
iManufacturer: 1
iProduct: 2
iSerialNumber: 3
bNumConfigurations: 1
```
## Bit-Addressable Flag Map
| Bit | Name/Purpose |
|-------|-------------|
| bit_00h | Wakeup/resume active |
| bit_01h | SETUP packet pending (from SUDAV ISR) |
| bit_02h | System initialized |
| bit_03h | SUSPEND pending (from SUSPEND ISR) |
| bit_04h | (unused/reserved) |
| bit_05h | (unused/reserved) |
| bit_06h | GPIF descriptor present |
| bit_07h | USB disconnect / "load-from-host" flag (dual use) |
| bit_08h | 8PSK demod booted successfully |
| bit_09h | Streaming active (GPIF/TS transfer in progress) |
| bit_0Ah | Autopointer direction flag |
## Direct RAM Variable Map
| Address | Name/Purpose |
|---------|-------------|
| 08h:09h | Descriptor pointer 7 (high-speed variant?) |
| 0Ah:0Bh | Configuration descriptor pointer |
| 0Ch:0Dh | Device descriptor pointer (0x1200) |
| 0Eh:0Fh | (adjusted descriptor offset) |
| 10h:11h | (adjusted descriptor offset) |
| 12h:13h | Other-speed config descriptor pointer |
| 14h:15h | Device qualifier descriptor pointer |
| 16h:17h | String descriptor pointer |
| 22h-25h | 32-bit counter/offset (XDATA copy) |
| 26h-29h | 32-bit counter (descriptor calculation) |
| 2Ah-2Dh | 32-bit counter (main state) |
| 32h-35h | 32-bit counter (main state) |
| 36h:37h | USB descriptor base (passed to 1A0E) |
| 38h | Descriptor iteration counter |
| 3Bh | Boot init block index / temp |
| 3Ch | I2C temp / ready flag |
| 3Dh:3Eh | wait_for_ready countdown |
| 3Fh:40h | Delay counter / retry counter |
| 45h:46h | I2C buffer pointer high:low |
| 49h-4Bh | Autopointer save area |
| 4Ch:4Dh | Saved IRQ state |
| 4Eh-50h | EP buffer config |
| 66h | (state variable) |
| 68h:69h | (state variables) |
| 6Dh | Master status flags byte |
| 6Fh:70h | Secondary wait counter |
## Key Observations for RAM-Load Boot Failure
1. **IDATA cleared on reset**: The startup clears IDATA 0x01-0x7F. All bit-addressable flags and direct RAM variables start at 0. This is critical — if you're loading into RAM without resetting the CPU, these won't be cleared.
2. **XDATA init from CODE space**: The Keil startup copies initialization data from a table in CODE space to XDATA. If your RAM load doesn't include this init data at the correct CODE-relative offset, XDATA won't be initialized properly.
3. **CPUCS clock switch**: The firmware switches to 48MHz immediately in hw_init(). If CPUCS is already set (from a previous boot), the second write may cause timing issues.
4. **USB re-enumeration**: The firmware explicitly disconnects (USBCS.DISCON=1), waits ~8ms, then reconnects. If you're loading via USB, this disconnect cycle will kill your USB connection.
5. **E6B8-E6BF SETUPDAT mapping**: The firmware uses an alternate XDATA mapping for SETUPDAT at 0xE6B8 instead of the standard 0xE6A5. Both mappings are valid, but your host-side code must account for this if doing SETUP data inspection.
6. **Interrupt vector overwrite risk**: The reset vector at 0x0000 and all interrupt vectors (0x0003-0x006B) contain actual code, not just jump stubs. The INT0 vector at 0x0003 contains the USB re-enumeration handler. Loading code that overwrites these vectors will break USB.
7. **GPIF waveform data**: The GPIF config at 0x12EA reads waveform data from XDATA 0xE000-0xE08E. This data must be present in XDATA before hw_init() runs. If it's missing (because the EEPROM-to-XDATA copy didn't happen), GPIF will be misconfigured.
---
*Generated from firmware analysis of skywalker1_eeprom_full64k.bin*
*Disassembly tool: disasm8051.py (custom 8051 disassembler)*

660
firmware-dump/disasm8051.py Normal file
View File

@ -0,0 +1,660 @@
#!/usr/bin/env python3
"""8051 disassembler for Genpix SkyWalker-1 firmware analysis.
Produces annotated disassembly of the FX2LP (8051-based) firmware,
focusing on the Keil C51 startup sequence and USB initialization.
"""
import sys
import re
OPCODES = {}
def op(code, mnem, length, desc=""):
OPCODES[code] = (mnem, length, desc)
# NOP
op(0x00, "NOP", 1)
# AJMP/ACALL (page 0-7)
for page in range(8):
op(0x01 + page*0x20, f"AJMP {{a11:{page}}}", 2)
op(0x11 + page*0x20, f"ACALL {{a11:{page}}}", 2)
op(0x02, "LJMP {a16}", 3)
op(0x12, "LCALL {a16}", 3)
op(0x03, "RR A", 1)
op(0x13, "RRC A", 1)
op(0x23, "RL A", 1)
op(0x33, "RLC A", 1)
# INC
op(0x04, "INC A", 1)
op(0x05, "INC {d}", 2)
op(0x06, "INC @R0", 1)
op(0x07, "INC @R1", 1)
for i in range(8): op(0x08+i, f"INC R{i}", 1)
# JBC, JB, JNB, JC, JNC, JZ, JNZ
op(0x10, "JBC {bit},{r8}", 3)
op(0x20, "JB {bit},{r8}", 3)
op(0x30, "JNB {bit},{r8}", 3)
op(0x40, "JC {r8}", 2)
op(0x50, "JNC {r8}", 2)
op(0x60, "JZ {r8}", 2)
op(0x70, "JNZ {r8}", 2)
# DEC
op(0x14, "DEC A", 1)
op(0x15, "DEC {d}", 2)
op(0x16, "DEC @R0", 1)
op(0x17, "DEC @R1", 1)
for i in range(8): op(0x18+i, f"DEC R{i}", 1)
# ADD
op(0x24, "ADD A,#{imm}", 2)
op(0x25, "ADD A,{d}", 2)
op(0x26, "ADD A,@R0", 1)
op(0x27, "ADD A,@R1", 1)
for i in range(8): op(0x28+i, f"ADD A,R{i}", 1)
# ADDC
op(0x34, "ADDC A,#{imm}", 2)
op(0x35, "ADDC A,{d}", 2)
op(0x36, "ADDC A,@R0", 1)
op(0x37, "ADDC A,@R1", 1)
for i in range(8): op(0x38+i, f"ADDC A,R{i}", 1)
# ORL
op(0x42, "ORL {d},A", 2)
op(0x43, "ORL {d},#{imm}", 3)
op(0x44, "ORL A,#{imm}", 2)
op(0x45, "ORL A,{d}", 2)
op(0x46, "ORL A,@R0", 1)
op(0x47, "ORL A,@R1", 1)
for i in range(8): op(0x48+i, f"ORL A,R{i}", 1)
# ANL
op(0x52, "ANL {d},A", 2)
op(0x53, "ANL {d},#{imm}", 3)
op(0x54, "ANL A,#{imm}", 2)
op(0x55, "ANL A,{d}", 2)
op(0x56, "ANL A,@R0", 1)
op(0x57, "ANL A,@R1", 1)
for i in range(8): op(0x58+i, f"ANL A,R{i}", 1)
# XRL
op(0x62, "XRL {d},A", 2)
op(0x63, "XRL {d},#{imm}", 3)
op(0x64, "XRL A,#{imm}", 2)
op(0x65, "XRL A,{d}", 2)
op(0x66, "XRL A,@R0", 1)
op(0x67, "XRL A,@R1", 1)
for i in range(8): op(0x68+i, f"XRL A,R{i}", 1)
# ORL C, ANL C
op(0x72, "ORL C,{bit}", 2)
op(0xA0, "ORL C,/{bit}", 2)
op(0x82, "ANL C,{bit}", 2)
op(0xB0, "ANL C,/{bit}", 2)
# MOV bit
op(0x92, "MOV {bit},C", 2)
op(0xA2, "MOV C,{bit}", 2)
# MOV direct
op(0x75, "MOV {d},#{imm}", 3)
op(0x85, "MOV {d2},{d1}", 3)
op(0xE5, "MOV A,{d}", 2)
op(0xF5, "MOV {d},A", 2)
op(0xA5, "DB A5h", 1)
# MOV A,#imm
op(0x74, "MOV A,#{imm}", 2)
# MOV A,@Ri / MOV @Ri,A
op(0xE6, "MOV A,@R0", 1)
op(0xE7, "MOV A,@R1", 1)
op(0xF6, "MOV @R0,A", 1)
op(0xF7, "MOV @R1,A", 1)
# MOV A,Rn / MOV Rn,A
for i in range(8):
op(0xE8+i, f"MOV A,R{i}", 1)
op(0xF8+i, f"MOV R{i},A", 1)
# MOV Rn,#imm
for i in range(8): op(0x78+i, f"MOV R{i},#{{imm}}", 2)
# MOV Rn,direct
for i in range(8): op(0xA8+i, f"MOV R{i},{{d}}", 2)
# MOV direct,Rn
for i in range(8): op(0x88+i, f"MOV {{d}},R{i}", 2)
op(0x86, "MOV {d},@R0", 2)
op(0x87, "MOV {d},@R1", 2)
op(0xA6, "MOV @R0,{d}", 2)
op(0xA7, "MOV @R1,{d}", 2)
op(0x76, "MOV @R0,#{imm}", 2)
op(0x77, "MOV @R1,#{imm}", 2)
# MOV DPTR,#imm16
op(0x90, "MOV DPTR,#{imm16}", 3)
# MOVC
op(0x83, "MOVC A,@A+PC", 1)
op(0x93, "MOVC A,@A+DPTR", 1)
# MOVX
op(0xE0, "MOVX A,@DPTR", 1)
op(0xE2, "MOVX A,@R0", 1)
op(0xE3, "MOVX A,@R1", 1)
op(0xF0, "MOVX @DPTR,A", 1)
op(0xF2, "MOVX @R0,A", 1)
op(0xF3, "MOVX @R1,A", 1)
# PUSH/POP
op(0xC0, "PUSH {d}", 2)
op(0xD0, "POP {d}", 2)
# XCH
op(0xC5, "XCH A,{d}", 2)
op(0xC6, "XCH A,@R0", 1)
op(0xC7, "XCH A,@R1", 1)
for i in range(8): op(0xC8+i, f"XCH A,R{i}", 1)
# XCHD
op(0xD6, "XCHD A,@R0", 1)
op(0xD7, "XCHD A,@R1", 1)
# DJNZ
op(0xD5, "DJNZ {d},{r8}", 3)
for i in range(8): op(0xD8+i, f"DJNZ R{i},{{r8}}", 2)
# CJNE
op(0xB4, "CJNE A,#{imm},{r8}", 3)
op(0xB5, "CJNE A,{d},{r8}", 3)
op(0xB6, "CJNE @R0,#{imm},{r8}", 3)
op(0xB7, "CJNE @R1,#{imm},{r8}", 3)
for i in range(8): op(0xB8+i, f"CJNE R{i},#{{imm}},{{r8}}", 3)
# SJMP
op(0x80, "SJMP {r8}", 2)
# JMP @A+DPTR
op(0x73, "JMP @A+DPTR", 1)
# RET, RETI
op(0x22, "RET", 1)
op(0x32, "RETI", 1)
# CLR, SETB, CPL
op(0xC2, "CLR {bit}", 2)
op(0xC3, "CLR C", 1)
op(0xD2, "SETB {bit}", 2)
op(0xD3, "SETB C", 1)
op(0xB2, "CPL {bit}", 2)
op(0xB3, "CPL C", 1)
op(0xE4, "CLR A", 1)
op(0xF4, "CPL A", 1)
# MUL, DIV, DA, SWAP
op(0xA4, "MUL AB", 1)
op(0x84, "DIV AB", 1)
op(0xD4, "DA A", 1)
op(0xC4, "SWAP A", 1)
# SUBB
op(0x94, "SUBB A,#{imm}", 2)
op(0x95, "SUBB A,{d}", 2)
op(0x96, "SUBB A,@R0", 1)
op(0x97, "SUBB A,@R1", 1)
for i in range(8): op(0x98+i, f"SUBB A,R{i}", 1)
# INC DPTR
op(0xA3, "INC DPTR", 1)
# ============ SFR / XDATA / BIT names ============
SFR_NAMES = {
0x80: "IOA", 0x81: "SP", 0x82: "DPL", 0x83: "DPH",
0x84: "DPL1", 0x85: "DPH1", 0x86: "DPS", 0x87: "PCON",
0x88: "TCON", 0x89: "TMOD", 0x8A: "TL0", 0x8B: "TL1",
0x8C: "TH0", 0x8D: "TH1", 0x8E: "CKCON",
0x90: "IOB", 0x91: "EXIF",
0x98: "SCON0", 0x99: "SBUF0",
0x9A: "AUTOPTRH1", 0x9B: "AUTOPTRL1",
0x9C: "AUTOPTRH2", 0x9D: "AUTOPTRL2", 0x9E: "AUTOPTRSETUP",
0xA0: "IOC", 0xA8: "IE",
0xAA: "EP2468STAT", 0xAB: "EP24FIFOFLGS", 0xAC: "EP68FIFOFLGS",
0xB0: "IOD", 0xB1: "IOE",
0xB2: "OEA", 0xB3: "OEB", 0xB4: "OEC", 0xB5: "OED", 0xB6: "OEE",
0xC0: "SCON1", 0xC1: "SBUF1",
0xC8: "T2CON", 0xCA: "RCAP2L", 0xCB: "RCAP2H", 0xCC: "TL2", 0xCD: "TH2",
0xD0: "PSW", 0xD8: "EICON",
0xE0: "ACC", 0xE8: "EIE", 0xF0: "B", 0xF8: "EIP",
}
XREG_NAMES = {
0xE600: "CPUCS", 0xE601: "IFCONFIG", 0xE602: "PINFLAGSAB",
0xE603: "PINFLAGSCD", 0xE604: "FIFORESET", 0xE609: "REVCTL",
0xE60A: "GPIFTRIG", 0xE60B: "GPIFSGLDATH", 0xE60C: "GPIFSGLDATLX",
0xE610: "FLOWSTATE", 0xE611: "FLOWLOGIC",
0xE618: "GPIFHOLDAMOUNT",
0xE620: "EP2CFG", 0xE621: "EP4CFG", 0xE622: "EP6CFG", 0xE623: "EP8CFG",
0xE624: "EP2FIFOCFG", 0xE625: "EP4FIFOCFG",
0xE626: "EP6FIFOCFG", 0xE627: "EP8FIFOCFG",
0xE628: "EP2AUTOINLENH", 0xE629: "EP2AUTOINLENL",
0xE630: "EP2FIFOPFH", 0xE631: "EP2FIFOPFL",
0xE640: "EP2ISOINPKTS",
0xE648: "INPKTEND", 0xE649: "OUTPKTEND",
0xE650: "EP2FIFOIE", 0xE651: "EP2FIFOIRQ",
0xE65C: "USBIE", 0xE65D: "USBIRQ",
0xE65E: "EPIE", 0xE65F: "EPIRQ",
0xE660: "GPIFIE", 0xE661: "GPIFIRQ",
0xE662: "USBERRIE", 0xE663: "USBERRIRQ",
0xE666: "INT2IVEC", 0xE667: "INT4IVEC", 0xE668: "INTSETUP",
0xE670: "PORTACFG", 0xE671: "PORTCCFG", 0xE672: "PORTECFG",
0xE678: "I2CS", 0xE679: "I2DAT", 0xE67A: "I2CTL",
0xE67B: "XAUTODAT1", 0xE67C: "XAUTODAT2",
0xE680: "USBCS", 0xE681: "SUSPEND", 0xE682: "WAKEUPCS",
0xE683: "TOGCTL",
0xE684: "USBFRAMEH", 0xE685: "USBFRAMEL", 0xE686: "MICROFRAME",
0xE687: "FNADDR",
0xE68A: "EP0BCH", 0xE68B: "EP0BCL",
0xE68C: "EP1OUTBC", 0xE68D: "EP1INBC",
0xE68F: "EP1INCS",
0xE690: "EP2CS", 0xE691: "EP4CS", 0xE692: "EP6CS", 0xE693: "EP8CS",
0xE694: "EP2FIFOFLGS", 0xE695: "EP4FIFOFLGS",
0xE696: "EP6FIFOFLGS", 0xE697: "EP8FIFOFLGS",
0xE698: "EP2BCH", 0xE699: "EP2BCL",
0xE6A0: "EP0CS", 0xE6A1: "EP0STAT", 0xE6A2: "EP0STALLBITS",
0xE6A3: "CLRTOGS",
0xE6A5: "SETUPDAT[0]", 0xE6A6: "SETUPDAT[1]",
0xE6A7: "SETUPDAT[2]", 0xE6A8: "SETUPDAT[3]",
0xE6A9: "SETUPDAT[4]", 0xE6AA: "SETUPDAT[5]",
0xE6AB: "SETUPDAT[6]", 0xE6AC: "SETUPDAT[7]",
0xE6C0: "GPIFWFSELECT", 0xE6C1: "GPIFIDLECS",
0xE6C2: "GPIFIDLECTL", 0xE6C3: "GPIFCTLCFG",
0xE6C4: "GPIFADRH", 0xE6C5: "GPIFADRL",
0xE6CE: "GPIFREADYCFG", 0xE6CF: "GPIFREADYSTAT",
0xE6D0: "GPIFABORT",
0xE6F8: "SUDPTRH", 0xE6F9: "SUDPTRL", 0xE6FA: "SUDPTRCTL",
0xE740: "EP0BUF[0]",
0xE780: "EP1OUTBUF[0]",
0xE7C0: "EP1INBUF[0]",
}
BIT_NAMES = {
0xD0: "PSW.P", 0xD1: "PSW.1", 0xD2: "PSW.OV", 0xD3: "PSW.RS0",
0xD4: "PSW.RS1", 0xD5: "PSW.F0", 0xD6: "PSW.AC", 0xD7: "PSW.CY",
0xA8: "EX0", 0xA9: "ET0", 0xAA: "EX1", 0xAB: "ET1",
0xAC: "ES0", 0xAD: "ET2", 0xAE: "ES1", 0xAF: "EA",
0x80: "IOA.0", 0x81: "IOA.1", 0x82: "IOA.2", 0x83: "IOA.3",
0x84: "IOA.4", 0x85: "IOA.5", 0x86: "IOA.6", 0x87: "IOA.7",
0x90: "IOB.0", 0x91: "IOB.1", 0x92: "IOB.2", 0x93: "IOB.3",
0x94: "IOB.4", 0x95: "IOB.5", 0x96: "IOB.6", 0x97: "IOB.7",
0x88: "IT0", 0x89: "IE0", 0x8A: "IT1", 0x8B: "IE1",
0x8C: "TR0", 0x8D: "TF0", 0x8E: "TR1", 0x8F: "TF1",
0x98: "RI_0", 0x99: "TI_0",
0xD8: "EICON.0", 0xDB: "INT6", 0xDC: "RESI", 0xDD: "ERESI",
}
KNOWN_LABELS = {
0x0000: "reset_vector",
0x0003: "int0_vector",
0x000B: "timer0_vector",
0x0013: "int1_vector",
0x001B: "timer1_vector",
0x0023: "serial0_vector",
0x002B: "timer2_vector",
0x0033: "resume_vector",
0x003B: "serial1_vector",
0x0043: "usb_int2_vector",
0x004B: "i2c_int3_vector",
0x0053: "gpif_int4_vector",
0x005B: "int5_vector",
0x0063: "int6_vector",
0x099A: "tune_function",
0x0DDD: "boot_init_blocks",
0x0EE9: "tune_init_blocks",
0x1200: "usb_device_desc",
0x1556: "i2c_combined_read",
0x188D: "keil_startup",
0x1A81: "i2c_write",
0x1D87: "boot_8psk",
0x2000: "wait_for_ready",
}
def sfr_name(addr):
if addr in SFR_NAMES:
return SFR_NAMES[addr]
if addr >= 0x80:
return f"SFR_{addr:02X}h"
return f"{addr:02X}h"
def bit_name(addr):
if addr in BIT_NAMES:
return BIT_NAMES[addr]
if addr >= 0x80:
base = addr & 0xF8
bit = addr & 0x07
if base in SFR_NAMES:
return f"{SFR_NAMES[base]}.{bit}"
return f"bit_{addr:02X}h"
def xreg_name(addr):
if addr in XREG_NAMES:
return XREG_NAMES[addr]
if 0xE740 <= addr <= 0xE77F:
return f"EP0BUF[{addr-0xE740}]"
if 0xE780 <= addr <= 0xE7BF:
return f"EP1OUTBUF[{addr-0xE780}]"
if 0xE7C0 <= addr <= 0xE7FF:
return f"EP1INBUF[{addr-0xE7C0}]"
if 0xE600 <= addr <= 0xE6FF:
return f"XSFR_{addr:04X}h"
return None
def disasm_one(data, pc):
"""Returns (mnem, hex_str, length, comment, branch_target)."""
if pc >= len(data):
return ("???", "", 1, "", None)
opc = data[pc]
entry = OPCODES.get(opc)
if entry is None:
return (f"DB {opc:02X}h", f"{opc:02X}", 1, "; UNKNOWN", None)
mnem_fmt, length, desc = entry
if pc + length > len(data):
return (f"DB {opc:02X}h", f"{opc:02X}", 1, "; TRUNCATED", None)
raw_bytes = data[pc:pc+length]
hex_str = " ".join(f"{b:02X}" for b in raw_bytes)
comment = ""
branch_target = None
mnem = mnem_fmt
# Resolve address fields
if "{a16}" in mnem:
addr16 = (data[pc+1] << 8) | data[pc+2]
label = KNOWN_LABELS.get(addr16)
mnem = mnem.replace("{a16}", label if label else f"{addr16:04X}h")
branch_target = addr16
m = re.search(r'\{a11:(\d+)\}', mnem)
if m:
page = int(m.group(1))
addr11 = (page << 8) | data[pc+1] | ((pc + 2) & 0xF800)
label = KNOWN_LABELS.get(addr11)
mnem = re.sub(r'\{a11:\d+\}', label if label else f"{addr11:04X}h", mnem)
branch_target = addr11
if "{r8}" in mnem:
rel_byte = data[pc + length - 1]
rel = rel_byte if rel_byte < 0x80 else rel_byte - 256
target = (pc + length + rel) & 0xFFFF
label = KNOWN_LABELS.get(target)
mnem = mnem.replace("{r8}", label if label else f"{target:04X}h")
branch_target = target
# Direct addressing - handle the special MOV d2,d1 case
if "{d2}" in mnem and "{d1}" in mnem:
src = data[pc+1]
dst = data[pc+2]
mnem = mnem.replace("{d1}", sfr_name(src) if src >= 0x20 else f"{src:02X}h")
mnem = mnem.replace("{d2}", sfr_name(dst) if dst >= 0x20 else f"{dst:02X}h")
elif "{d}" in mnem:
d = data[pc+1]
mnem = mnem.replace("{d}", sfr_name(d) if d >= 0x20 else f"{d:02X}h")
if "{bit}" in mnem:
b = data[pc+1]
mnem = mnem.replace("{bit}", bit_name(b))
if "{imm16}" in mnem:
imm16 = (data[pc+1] << 8) | data[pc+2]
xname = xreg_name(imm16)
if xname:
mnem = mnem.replace("{imm16}", f"{imm16:04X}h")
comment = f"; = {xname}"
elif imm16 in KNOWN_LABELS:
mnem = mnem.replace("{imm16}", f"{imm16:04X}h")
comment = f"; -> {KNOWN_LABELS[imm16]}"
else:
mnem = mnem.replace("{imm16}", f"{imm16:04X}h")
if "{imm}" in mnem:
# Determine which byte is the immediate
if length == 3 and opc in (0x43, 0x53, 0x63):
imm = data[pc+2] # ORL/ANL/XRL d,#imm
elif length == 3 and opc == 0x75:
imm = data[pc+2] # MOV d,#imm
elif length == 3 and opc == 0xB4:
imm = data[pc+1] # CJNE A,#imm,r8
elif length == 3 and 0xB6 <= opc <= 0xBF:
imm = data[pc+1] # CJNE @Ri/#imm or CJNE Rn,#imm
elif length == 3 and opc == 0xD5:
# DJNZ d,r8 - no imm, but {d} already handled
imm = 0 # shouldn't happen
else:
imm = data[pc + length - 1]
mnem = mnem.replace("{imm}", f"{imm:02X}h")
return (mnem, hex_str, length, comment, branch_target)
def disasm_range(data, start, end, title=""):
lines = []
if title:
lines.append(f"\n{'='*80}")
lines.append(f" {title}")
lines.append(f"{'='*80}")
pc = start
while pc < end and pc < len(data):
if pc in KNOWN_LABELS:
lines.append(f"\n {KNOWN_LABELS[pc]}:")
mnem, hex_str, length, comment, target = disasm_one(data, pc)
addr_str = f"{pc:04X}"
hex_padded = f"{hex_str:<12s}"
line = f" {addr_str}: {hex_padded} {mnem}"
if comment:
line = f"{line:<58s} {comment}"
lines.append(line)
pc += length
return "\n".join(lines)
def track_dptr_accesses(data, start, end):
"""Track all MOVX accesses with DPTR context."""
results = []
pc = start
dptr = None
while pc < end and pc < len(data):
opc = data[pc]
entry = OPCODES.get(opc)
if entry is None:
pc += 1
continue
_, length, _ = entry
if pc + length > len(data):
break
if opc == 0x90: # MOV DPTR,#imm16
dptr = (data[pc+1] << 8) | data[pc+2]
elif opc in (0xE0, 0xF0) and dptr is not None:
xname = xreg_name(dptr)
direction = "READ" if opc == 0xE0 else "WRITE"
results.append((pc, dptr, xname, direction))
elif opc == 0xA3: # INC DPTR
if dptr is not None:
dptr += 1
pc += length
return results
def main():
fw_path = "/home/rpm/claude/ham/satellite/genpix/skywalker-1/firmware-dump/skywalker1_eeprom_full64k.bin"
with open(fw_path, "rb") as f:
data = f.read()
out = []
out.append("=" * 80)
out.append(" GENPIX SKYWALKER-1 FIRMWARE DISASSEMBLY")
out.append(" FX2LP (CY7C68013A) 8051 Core - Keil C51 Compiled")
out.append(" Firmware size: 9472 bytes (0x0000-0x24FF)")
out.append(" Binary: 65536 bytes total (RAM image)")
out.append("=" * 80)
# ==== SECTION 1: Reset + Interrupt Vectors ====
out.append(disasm_range(data, 0x0000, 0x006B,
"SECTION 1: Reset Vector & Interrupt Vector Table (0x0000-0x006A)"))
# ==== SECTION 2: Keil C51 Startup ====
out.append(disasm_range(data, 0x188D, 0x1950,
"SECTION 2: Keil C51 Startup (STARTUP.A51) @ 0x188D"))
# Find main() by tracing startup
pc = 0x188D
main_addr = None
for _ in range(500):
if pc >= len(data) - 2:
break
opc = data[pc]
entry = OPCODES.get(opc)
if entry is None:
break
_, length, _ = entry
if opc == 0x02: # LJMP
addr = (data[pc+1] << 8) | data[pc+2]
# The startup has two LJMPs: one internal loop, one to main
# main() is the last LJMP before we hit data/different code
if addr < 0x188D: # Jump backward = likely to main()
main_addr = addr
pc += length
if main_addr:
KNOWN_LABELS[main_addr] = "main"
out.append(f"\n >>> Keil startup jumps to main() at {main_addr:04X}h")
# ==== SECTION 3: main() ====
if main_addr:
# Disassemble main() - generous range to cover init
out.append(disasm_range(data, main_addr, min(main_addr + 400, 0x2500),
f"SECTION 3: main() @ {main_addr:04X}h (first ~400 bytes)"))
# ==== SECTION 4: Vendor command dispatch ====
out.append("\n" + "=" * 80)
out.append(" SECTION 4: USB Vendor Command Dispatch")
out.append("=" * 80)
# Find all reads of SETUPDAT[1] (bRequest)
for pc in range(0x2500):
if pc + 2 < len(data) and data[pc] == 0x90 and data[pc+1] == 0xE6 and data[pc+2] == 0xA6:
out.append(f"\n SETUPDAT[1] read at {pc:04X}h:")
ctx_start = max(0, pc - 16)
ctx_end = min(pc + 120, 0x2500)
out.append(disasm_range(data, ctx_start, ctx_end,
f" Dispatch context @ {pc:04X}h"))
# ==== SECTION 5: Find all CJNE A,#xx comparisons (command dispatch) ====
out.append("\n" + "=" * 80)
out.append(" SECTION 5: All CJNE A,#xx Instructions (Command Dispatch Table)")
out.append("=" * 80)
for pc in range(0x2500):
if pc + 2 < len(data) and data[pc] == 0xB4:
imm = data[pc+1]
rel = data[pc+2]
if rel >= 0x80:
rel -= 256
target = (pc + 3 + rel) & 0xFFFF
out.append(f" {pc:04X}: CJNE A,#{imm:02X}h,{target:04X}h ; bRequest?=0x{imm:02X} skip->{target:04X}h")
# ==== SECTION 6: boot_8psk ====
out.append(disasm_range(data, 0x1D87, min(0x1D87 + 250, 0x2500),
"SECTION 6: boot_8psk() @ 0x1D87"))
# ==== SECTION 7: SFR access map ====
out.append("\n" + "=" * 80)
out.append(" SECTION 7: All XDATA SFR Accesses (DPTR-tracked)")
out.append("=" * 80)
accesses = track_dptr_accesses(data, 0x0000, 0x2500)
for pc, addr, name, direction in accesses:
name_str = name if name else f"XDATA_{addr:04X}h"
out.append(f" {pc:04X}: {direction:5s} [{addr:04X}h] {name_str}")
# ==== SECTION 8: Key functions ====
out.append(disasm_range(data, 0x0DDD, 0x0DDD + 120,
"SECTION 8: boot_init_blocks() @ 0x0DDD"))
out.append(disasm_range(data, 0x1A81, 0x1A81 + 150,
"SECTION 9: i2c_write() @ 0x1A81"))
out.append(disasm_range(data, 0x1556, 0x1556 + 150,
"SECTION 10: i2c_combined_read() @ 0x1556"))
out.append(disasm_range(data, 0x2000, 0x2000 + 120,
"SECTION 11: wait_for_ready() @ 0x2000"))
out.append(disasm_range(data, 0x099A, 0x099A + 200,
"SECTION 12: tune_function() @ 0x099A"))
# ==== SECTION 13: USB Descriptors ====
out.append("\n" + "=" * 80)
out.append(" SECTION 13: USB Descriptors @ 0x1200")
out.append("=" * 80)
desc = data[0x1200:0x1212]
out.append(f" Device Descriptor (18 bytes):")
out.append(f" bLength: {desc[0]} (0x{desc[0]:02X})")
out.append(f" bDescriptorType: {desc[1]} (DEVICE)")
out.append(f" bcdUSB: {desc[3]:02X}.{desc[2]:02X}")
out.append(f" bDeviceClass: 0x{desc[4]:02X} (vendor-specific)")
out.append(f" bDeviceSubClass: 0x{desc[5]:02X}")
out.append(f" bDeviceProtocol: 0x{desc[6]:02X}")
out.append(f" bMaxPacketSize0: {desc[7]} bytes")
vid = desc[8] | (desc[9] << 8)
pid = desc[10] | (desc[11] << 8)
out.append(f" idVendor: 0x{vid:04X} (Cypress)")
out.append(f" idProduct: 0x{pid:04X}")
bcd = desc[12] | (desc[13] << 8)
out.append(f" bcdDevice: {bcd:04X}")
out.append(f" iManufacturer: {desc[14]}")
out.append(f" iProduct: {desc[15]}")
out.append(f" iSerialNumber: {desc[16]}")
out.append(f" bNumConfigurations: {desc[17]}")
# Raw descriptor dump
out.append(f"\n Raw descriptor area 0x1200-0x1300:")
for off in range(0, 0x100, 16):
addr = 0x1200 + off
chunk = data[addr:addr+16]
hexl = " ".join(f"{b:02X}" for b in chunk)
asciil = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
out.append(f" {addr:04X}: {hexl} {asciil}")
# ==== SECTION 14: Hex dump of interesting code regions ====
out.append("\n" + "=" * 80)
out.append(" SECTION 14: Raw Hex - Code Space Near USB ISR Targets")
out.append("=" * 80)
# Show hex around the USB interrupt vector dispatch
# The USB ISR at 0x0043 likely jumps somewhere
for start_addr in [0x0043, 0x004B]:
out.append(f"\n Hex dump at {start_addr:04X}h:")
for off in range(0, 48, 16):
addr = start_addr + off
chunk = data[addr:addr+16]
hexl = " ".join(f"{b:02X}" for b in chunk)
out.append(f" {addr:04X}: {hexl}")
print("\n".join(out))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,660 @@
#!/usr/bin/env python3
"""8051 disassembler for Genpix SkyWalker-1 firmware analysis.
Produces annotated disassembly of the FX2LP (8051-based) firmware,
focusing on the Keil C51 startup sequence and USB initialization.
"""
import sys
import re
OPCODES = {}
def op(code, mnem, length, desc=""):
OPCODES[code] = (mnem, length, desc)
# NOP
op(0x00, "NOP", 1)
# AJMP/ACALL (page 0-7)
for page in range(8):
op(0x01 + page*0x20, f"AJMP {{a11:{page}}}", 2)
op(0x11 + page*0x20, f"ACALL {{a11:{page}}}", 2)
op(0x02, "LJMP {a16}", 3)
op(0x12, "LCALL {a16}", 3)
op(0x03, "RR A", 1)
op(0x13, "RRC A", 1)
op(0x23, "RL A", 1)
op(0x33, "RLC A", 1)
# INC
op(0x04, "INC A", 1)
op(0x05, "INC {d}", 2)
op(0x06, "INC @R0", 1)
op(0x07, "INC @R1", 1)
for i in range(8): op(0x08+i, f"INC R{i}", 1)
# JBC, JB, JNB, JC, JNC, JZ, JNZ
op(0x10, "JBC {bit},{r8}", 3)
op(0x20, "JB {bit},{r8}", 3)
op(0x30, "JNB {bit},{r8}", 3)
op(0x40, "JC {r8}", 2)
op(0x50, "JNC {r8}", 2)
op(0x60, "JZ {r8}", 2)
op(0x70, "JNZ {r8}", 2)
# DEC
op(0x14, "DEC A", 1)
op(0x15, "DEC {d}", 2)
op(0x16, "DEC @R0", 1)
op(0x17, "DEC @R1", 1)
for i in range(8): op(0x18+i, f"DEC R{i}", 1)
# ADD
op(0x24, "ADD A,#{imm}", 2)
op(0x25, "ADD A,{d}", 2)
op(0x26, "ADD A,@R0", 1)
op(0x27, "ADD A,@R1", 1)
for i in range(8): op(0x28+i, f"ADD A,R{i}", 1)
# ADDC
op(0x34, "ADDC A,#{imm}", 2)
op(0x35, "ADDC A,{d}", 2)
op(0x36, "ADDC A,@R0", 1)
op(0x37, "ADDC A,@R1", 1)
for i in range(8): op(0x38+i, f"ADDC A,R{i}", 1)
# ORL
op(0x42, "ORL {d},A", 2)
op(0x43, "ORL {d},#{imm}", 3)
op(0x44, "ORL A,#{imm}", 2)
op(0x45, "ORL A,{d}", 2)
op(0x46, "ORL A,@R0", 1)
op(0x47, "ORL A,@R1", 1)
for i in range(8): op(0x48+i, f"ORL A,R{i}", 1)
# ANL
op(0x52, "ANL {d},A", 2)
op(0x53, "ANL {d},#{imm}", 3)
op(0x54, "ANL A,#{imm}", 2)
op(0x55, "ANL A,{d}", 2)
op(0x56, "ANL A,@R0", 1)
op(0x57, "ANL A,@R1", 1)
for i in range(8): op(0x58+i, f"ANL A,R{i}", 1)
# XRL
op(0x62, "XRL {d},A", 2)
op(0x63, "XRL {d},#{imm}", 3)
op(0x64, "XRL A,#{imm}", 2)
op(0x65, "XRL A,{d}", 2)
op(0x66, "XRL A,@R0", 1)
op(0x67, "XRL A,@R1", 1)
for i in range(8): op(0x68+i, f"XRL A,R{i}", 1)
# ORL C, ANL C
op(0x72, "ORL C,{bit}", 2)
op(0xA0, "ORL C,/{bit}", 2)
op(0x82, "ANL C,{bit}", 2)
op(0xB0, "ANL C,/{bit}", 2)
# MOV bit
op(0x92, "MOV {bit},C", 2)
op(0xA2, "MOV C,{bit}", 2)
# MOV direct
op(0x75, "MOV {d},#{imm}", 3)
op(0x85, "MOV {d2},{d1}", 3)
op(0xE5, "MOV A,{d}", 2)
op(0xF5, "MOV {d},A", 2)
op(0xA5, "DB A5h", 1)
# MOV A,#imm
op(0x74, "MOV A,#{imm}", 2)
# MOV A,@Ri / MOV @Ri,A
op(0xE6, "MOV A,@R0", 1)
op(0xE7, "MOV A,@R1", 1)
op(0xF6, "MOV @R0,A", 1)
op(0xF7, "MOV @R1,A", 1)
# MOV A,Rn / MOV Rn,A
for i in range(8):
op(0xE8+i, f"MOV A,R{i}", 1)
op(0xF8+i, f"MOV R{i},A", 1)
# MOV Rn,#imm
for i in range(8): op(0x78+i, f"MOV R{i},#{{imm}}", 2)
# MOV Rn,direct
for i in range(8): op(0xA8+i, f"MOV R{i},{{d}}", 2)
# MOV direct,Rn
for i in range(8): op(0x88+i, f"MOV {{d}},R{i}", 2)
op(0x86, "MOV {d},@R0", 2)
op(0x87, "MOV {d},@R1", 2)
op(0xA6, "MOV @R0,{d}", 2)
op(0xA7, "MOV @R1,{d}", 2)
op(0x76, "MOV @R0,#{imm}", 2)
op(0x77, "MOV @R1,#{imm}", 2)
# MOV DPTR,#imm16
op(0x90, "MOV DPTR,#{imm16}", 3)
# MOVC
op(0x83, "MOVC A,@A+PC", 1)
op(0x93, "MOVC A,@A+DPTR", 1)
# MOVX
op(0xE0, "MOVX A,@DPTR", 1)
op(0xE2, "MOVX A,@R0", 1)
op(0xE3, "MOVX A,@R1", 1)
op(0xF0, "MOVX @DPTR,A", 1)
op(0xF2, "MOVX @R0,A", 1)
op(0xF3, "MOVX @R1,A", 1)
# PUSH/POP
op(0xC0, "PUSH {d}", 2)
op(0xD0, "POP {d}", 2)
# XCH
op(0xC5, "XCH A,{d}", 2)
op(0xC6, "XCH A,@R0", 1)
op(0xC7, "XCH A,@R1", 1)
for i in range(8): op(0xC8+i, f"XCH A,R{i}", 1)
# XCHD
op(0xD6, "XCHD A,@R0", 1)
op(0xD7, "XCHD A,@R1", 1)
# DJNZ
op(0xD5, "DJNZ {d},{r8}", 3)
for i in range(8): op(0xD8+i, f"DJNZ R{i},{{r8}}", 2)
# CJNE
op(0xB4, "CJNE A,#{imm},{r8}", 3)
op(0xB5, "CJNE A,{d},{r8}", 3)
op(0xB6, "CJNE @R0,#{imm},{r8}", 3)
op(0xB7, "CJNE @R1,#{imm},{r8}", 3)
for i in range(8): op(0xB8+i, f"CJNE R{i},#{{imm}},{{r8}}", 3)
# SJMP
op(0x80, "SJMP {r8}", 2)
# JMP @A+DPTR
op(0x73, "JMP @A+DPTR", 1)
# RET, RETI
op(0x22, "RET", 1)
op(0x32, "RETI", 1)
# CLR, SETB, CPL
op(0xC2, "CLR {bit}", 2)
op(0xC3, "CLR C", 1)
op(0xD2, "SETB {bit}", 2)
op(0xD3, "SETB C", 1)
op(0xB2, "CPL {bit}", 2)
op(0xB3, "CPL C", 1)
op(0xE4, "CLR A", 1)
op(0xF4, "CPL A", 1)
# MUL, DIV, DA, SWAP
op(0xA4, "MUL AB", 1)
op(0x84, "DIV AB", 1)
op(0xD4, "DA A", 1)
op(0xC4, "SWAP A", 1)
# SUBB
op(0x94, "SUBB A,#{imm}", 2)
op(0x95, "SUBB A,{d}", 2)
op(0x96, "SUBB A,@R0", 1)
op(0x97, "SUBB A,@R1", 1)
for i in range(8): op(0x98+i, f"SUBB A,R{i}", 1)
# INC DPTR
op(0xA3, "INC DPTR", 1)
# ============ SFR / XDATA / BIT names ============
SFR_NAMES = {
0x80: "IOA", 0x81: "SP", 0x82: "DPL", 0x83: "DPH",
0x84: "DPL1", 0x85: "DPH1", 0x86: "DPS", 0x87: "PCON",
0x88: "TCON", 0x89: "TMOD", 0x8A: "TL0", 0x8B: "TL1",
0x8C: "TH0", 0x8D: "TH1", 0x8E: "CKCON",
0x90: "IOB", 0x91: "EXIF",
0x98: "SCON0", 0x99: "SBUF0",
0x9A: "AUTOPTRH1", 0x9B: "AUTOPTRL1",
0x9C: "AUTOPTRH2", 0x9D: "AUTOPTRL2", 0x9E: "AUTOPTRSETUP",
0xA0: "IOC", 0xA8: "IE",
0xAA: "EP2468STAT", 0xAB: "EP24FIFOFLGS", 0xAC: "EP68FIFOFLGS",
0xB0: "IOD", 0xB1: "IOE",
0xB2: "OEA", 0xB3: "OEB", 0xB4: "OEC", 0xB5: "OED", 0xB6: "OEE",
0xC0: "SCON1", 0xC1: "SBUF1",
0xC8: "T2CON", 0xCA: "RCAP2L", 0xCB: "RCAP2H", 0xCC: "TL2", 0xCD: "TH2",
0xD0: "PSW", 0xD8: "EICON",
0xE0: "ACC", 0xE8: "EIE", 0xF0: "B", 0xF8: "EIP",
}
XREG_NAMES = {
0xE600: "CPUCS", 0xE601: "IFCONFIG", 0xE602: "PINFLAGSAB",
0xE603: "PINFLAGSCD", 0xE604: "FIFORESET", 0xE609: "REVCTL",
0xE60A: "GPIFTRIG", 0xE60B: "GPIFSGLDATH", 0xE60C: "GPIFSGLDATLX",
0xE610: "FLOWSTATE", 0xE611: "FLOWLOGIC",
0xE618: "GPIFHOLDAMOUNT",
0xE620: "EP2CFG", 0xE621: "EP4CFG", 0xE622: "EP6CFG", 0xE623: "EP8CFG",
0xE624: "EP2FIFOCFG", 0xE625: "EP4FIFOCFG",
0xE626: "EP6FIFOCFG", 0xE627: "EP8FIFOCFG",
0xE628: "EP2AUTOINLENH", 0xE629: "EP2AUTOINLENL",
0xE630: "EP2FIFOPFH", 0xE631: "EP2FIFOPFL",
0xE640: "EP2ISOINPKTS",
0xE648: "INPKTEND", 0xE649: "OUTPKTEND",
0xE650: "EP2FIFOIE", 0xE651: "EP2FIFOIRQ",
0xE65C: "USBIE", 0xE65D: "USBIRQ",
0xE65E: "EPIE", 0xE65F: "EPIRQ",
0xE660: "GPIFIE", 0xE661: "GPIFIRQ",
0xE662: "USBERRIE", 0xE663: "USBERRIRQ",
0xE666: "INT2IVEC", 0xE667: "INT4IVEC", 0xE668: "INTSETUP",
0xE670: "PORTACFG", 0xE671: "PORTCCFG", 0xE672: "PORTECFG",
0xE678: "I2CS", 0xE679: "I2DAT", 0xE67A: "I2CTL",
0xE67B: "XAUTODAT1", 0xE67C: "XAUTODAT2",
0xE680: "USBCS", 0xE681: "SUSPEND", 0xE682: "WAKEUPCS",
0xE683: "TOGCTL",
0xE684: "USBFRAMEH", 0xE685: "USBFRAMEL", 0xE686: "MICROFRAME",
0xE687: "FNADDR",
0xE68A: "EP0BCH", 0xE68B: "EP0BCL",
0xE68C: "EP1OUTBC", 0xE68D: "EP1INBC",
0xE68F: "EP1INCS",
0xE690: "EP2CS", 0xE691: "EP4CS", 0xE692: "EP6CS", 0xE693: "EP8CS",
0xE694: "EP2FIFOFLGS", 0xE695: "EP4FIFOFLGS",
0xE696: "EP6FIFOFLGS", 0xE697: "EP8FIFOFLGS",
0xE698: "EP2BCH", 0xE699: "EP2BCL",
0xE6A0: "EP0CS", 0xE6A1: "EP0STAT", 0xE6A2: "EP0STALLBITS",
0xE6A3: "CLRTOGS",
0xE6A5: "SETUPDAT[0]", 0xE6A6: "SETUPDAT[1]",
0xE6A7: "SETUPDAT[2]", 0xE6A8: "SETUPDAT[3]",
0xE6A9: "SETUPDAT[4]", 0xE6AA: "SETUPDAT[5]",
0xE6AB: "SETUPDAT[6]", 0xE6AC: "SETUPDAT[7]",
0xE6C0: "GPIFWFSELECT", 0xE6C1: "GPIFIDLECS",
0xE6C2: "GPIFIDLECTL", 0xE6C3: "GPIFCTLCFG",
0xE6C4: "GPIFADRH", 0xE6C5: "GPIFADRL",
0xE6CE: "GPIFREADYCFG", 0xE6CF: "GPIFREADYSTAT",
0xE6D0: "GPIFABORT",
0xE6F8: "SUDPTRH", 0xE6F9: "SUDPTRL", 0xE6FA: "SUDPTRCTL",
0xE740: "EP0BUF[0]",
0xE780: "EP1OUTBUF[0]",
0xE7C0: "EP1INBUF[0]",
}
BIT_NAMES = {
0xD0: "PSW.P", 0xD1: "PSW.1", 0xD2: "PSW.OV", 0xD3: "PSW.RS0",
0xD4: "PSW.RS1", 0xD5: "PSW.F0", 0xD6: "PSW.AC", 0xD7: "PSW.CY",
0xA8: "EX0", 0xA9: "ET0", 0xAA: "EX1", 0xAB: "ET1",
0xAC: "ES0", 0xAD: "ET2", 0xAE: "ES1", 0xAF: "EA",
0x80: "IOA.0", 0x81: "IOA.1", 0x82: "IOA.2", 0x83: "IOA.3",
0x84: "IOA.4", 0x85: "IOA.5", 0x86: "IOA.6", 0x87: "IOA.7",
0x90: "IOB.0", 0x91: "IOB.1", 0x92: "IOB.2", 0x93: "IOB.3",
0x94: "IOB.4", 0x95: "IOB.5", 0x96: "IOB.6", 0x97: "IOB.7",
0x88: "IT0", 0x89: "IE0", 0x8A: "IT1", 0x8B: "IE1",
0x8C: "TR0", 0x8D: "TF0", 0x8E: "TR1", 0x8F: "TF1",
0x98: "RI_0", 0x99: "TI_0",
0xD8: "EICON.0", 0xDB: "INT6", 0xDC: "RESI", 0xDD: "ERESI",
}
KNOWN_LABELS = {
0x0000: "reset_vector",
0x0003: "int0_vector",
0x000B: "timer0_vector",
0x0013: "int1_vector",
0x001B: "timer1_vector",
0x0023: "serial0_vector",
0x002B: "timer2_vector",
0x0033: "resume_vector",
0x003B: "serial1_vector",
0x0043: "usb_int2_vector",
0x004B: "i2c_int3_vector",
0x0053: "gpif_int4_vector",
0x005B: "int5_vector",
0x0063: "int6_vector",
0x099A: "tune_function",
0x0DDD: "boot_init_blocks",
0x0EE9: "tune_init_blocks",
0x1200: "usb_device_desc",
0x1556: "i2c_combined_read",
0x188D: "keil_startup",
0x1A81: "i2c_write",
0x1D87: "boot_8psk",
0x2000: "wait_for_ready",
}
def sfr_name(addr):
if addr in SFR_NAMES:
return SFR_NAMES[addr]
if addr >= 0x80:
return f"SFR_{addr:02X}h"
return f"{addr:02X}h"
def bit_name(addr):
if addr in BIT_NAMES:
return BIT_NAMES[addr]
if addr >= 0x80:
base = addr & 0xF8
bit = addr & 0x07
if base in SFR_NAMES:
return f"{SFR_NAMES[base]}.{bit}"
return f"bit_{addr:02X}h"
def xreg_name(addr):
if addr in XREG_NAMES:
return XREG_NAMES[addr]
if 0xE740 <= addr <= 0xE77F:
return f"EP0BUF[{addr-0xE740}]"
if 0xE780 <= addr <= 0xE7BF:
return f"EP1OUTBUF[{addr-0xE780}]"
if 0xE7C0 <= addr <= 0xE7FF:
return f"EP1INBUF[{addr-0xE7C0}]"
if 0xE600 <= addr <= 0xE6FF:
return f"XSFR_{addr:04X}h"
return None
def disasm_one(data, pc):
"""Returns (mnem, hex_str, length, comment, branch_target)."""
if pc >= len(data):
return ("???", "", 1, "", None)
opc = data[pc]
entry = OPCODES.get(opc)
if entry is None:
return (f"DB {opc:02X}h", f"{opc:02X}", 1, "; UNKNOWN", None)
mnem_fmt, length, desc = entry
if pc + length > len(data):
return (f"DB {opc:02X}h", f"{opc:02X}", 1, "; TRUNCATED", None)
raw_bytes = data[pc:pc+length]
hex_str = " ".join(f"{b:02X}" for b in raw_bytes)
comment = ""
branch_target = None
mnem = mnem_fmt
# Resolve address fields
if "{a16}" in mnem:
addr16 = (data[pc+1] << 8) | data[pc+2]
label = KNOWN_LABELS.get(addr16)
mnem = mnem.replace("{a16}", label if label else f"{addr16:04X}h")
branch_target = addr16
m = re.search(r'\{a11:(\d+)\}', mnem)
if m:
page = int(m.group(1))
addr11 = (page << 8) | data[pc+1] | ((pc + 2) & 0xF800)
label = KNOWN_LABELS.get(addr11)
mnem = re.sub(r'\{a11:\d+\}', label if label else f"{addr11:04X}h", mnem)
branch_target = addr11
if "{r8}" in mnem:
rel_byte = data[pc + length - 1]
rel = rel_byte if rel_byte < 0x80 else rel_byte - 256
target = (pc + length + rel) & 0xFFFF
label = KNOWN_LABELS.get(target)
mnem = mnem.replace("{r8}", label if label else f"{target:04X}h")
branch_target = target
# Direct addressing - handle the special MOV d2,d1 case
if "{d2}" in mnem and "{d1}" in mnem:
src = data[pc+1]
dst = data[pc+2]
mnem = mnem.replace("{d1}", sfr_name(src) if src >= 0x20 else f"{src:02X}h")
mnem = mnem.replace("{d2}", sfr_name(dst) if dst >= 0x20 else f"{dst:02X}h")
elif "{d}" in mnem:
d = data[pc+1]
mnem = mnem.replace("{d}", sfr_name(d) if d >= 0x20 else f"{d:02X}h")
if "{bit}" in mnem:
b = data[pc+1]
mnem = mnem.replace("{bit}", bit_name(b))
if "{imm16}" in mnem:
imm16 = (data[pc+1] << 8) | data[pc+2]
xname = xreg_name(imm16)
if xname:
mnem = mnem.replace("{imm16}", f"{imm16:04X}h")
comment = f"; = {xname}"
elif imm16 in KNOWN_LABELS:
mnem = mnem.replace("{imm16}", f"{imm16:04X}h")
comment = f"; -> {KNOWN_LABELS[imm16]}"
else:
mnem = mnem.replace("{imm16}", f"{imm16:04X}h")
if "{imm}" in mnem:
# Determine which byte is the immediate
if length == 3 and opc in (0x43, 0x53, 0x63):
imm = data[pc+2] # ORL/ANL/XRL d,#imm
elif length == 3 and opc == 0x75:
imm = data[pc+2] # MOV d,#imm
elif length == 3 and opc == 0xB4:
imm = data[pc+1] # CJNE A,#imm,r8
elif length == 3 and 0xB6 <= opc <= 0xBF:
imm = data[pc+1] # CJNE @Ri/#imm or CJNE Rn,#imm
elif length == 3 and opc == 0xD5:
# DJNZ d,r8 - no imm, but {d} already handled
imm = 0 # shouldn't happen
else:
imm = data[pc + length - 1]
mnem = mnem.replace("{imm}", f"{imm:02X}h")
return (mnem, hex_str, length, comment, branch_target)
def disasm_range(data, start, end, title=""):
lines = []
if title:
lines.append(f"\n{'='*80}")
lines.append(f" {title}")
lines.append(f"{'='*80}")
pc = start
while pc < end and pc < len(data):
if pc in KNOWN_LABELS:
lines.append(f"\n {KNOWN_LABELS[pc]}:")
mnem, hex_str, length, comment, target = disasm_one(data, pc)
addr_str = f"{pc:04X}"
hex_padded = f"{hex_str:<12s}"
line = f" {addr_str}: {hex_padded} {mnem}"
if comment:
line = f"{line:<58s} {comment}"
lines.append(line)
pc += length
return "\n".join(lines)
def track_dptr_accesses(data, start, end):
"""Track all MOVX accesses with DPTR context."""
results = []
pc = start
dptr = None
while pc < end and pc < len(data):
opc = data[pc]
entry = OPCODES.get(opc)
if entry is None:
pc += 1
continue
_, length, _ = entry
if pc + length > len(data):
break
if opc == 0x90: # MOV DPTR,#imm16
dptr = (data[pc+1] << 8) | data[pc+2]
elif opc in (0xE0, 0xF0) and dptr is not None:
xname = xreg_name(dptr)
direction = "READ" if opc == 0xE0 else "WRITE"
results.append((pc, dptr, xname, direction))
elif opc == 0xA3: # INC DPTR
if dptr is not None:
dptr += 1
pc += length
return results
def main():
fw_path = "/home/rpm/claude/ham/satellite/genpix/skywalker-1/firmware-dump/skywalker1_eeprom_full64k.bin"
with open(fw_path, "rb") as f:
data = f.read()
out = []
out.append("=" * 80)
out.append(" GENPIX SKYWALKER-1 FIRMWARE DISASSEMBLY")
out.append(" FX2LP (CY7C68013A) 8051 Core - Keil C51 Compiled")
out.append(" Firmware size: 9472 bytes (0x0000-0x24FF)")
out.append(" Binary: 65536 bytes total (RAM image)")
out.append("=" * 80)
# ==== SECTION 1: Reset + Interrupt Vectors ====
out.append(disasm_range(data, 0x0000, 0x006B,
"SECTION 1: Reset Vector & Interrupt Vector Table (0x0000-0x006A)"))
# ==== SECTION 2: Keil C51 Startup ====
out.append(disasm_range(data, 0x188D, 0x1950,
"SECTION 2: Keil C51 Startup (STARTUP.A51) @ 0x188D"))
# Find main() by tracing startup
pc = 0x188D
main_addr = None
for _ in range(500):
if pc >= len(data) - 2:
break
opc = data[pc]
entry = OPCODES.get(opc)
if entry is None:
break
_, length, _ = entry
if opc == 0x02: # LJMP
addr = (data[pc+1] << 8) | data[pc+2]
# The startup has two LJMPs: one internal loop, one to main
# main() is the last LJMP before we hit data/different code
if addr < 0x188D: # Jump backward = likely to main()
main_addr = addr
pc += length
if main_addr:
KNOWN_LABELS[main_addr] = "main"
out.append(f"\n >>> Keil startup jumps to main() at {main_addr:04X}h")
# ==== SECTION 3: main() ====
if main_addr:
# Disassemble main() - generous range to cover init
out.append(disasm_range(data, main_addr, min(main_addr + 400, 0x2500),
f"SECTION 3: main() @ {main_addr:04X}h (first ~400 bytes)"))
# ==== SECTION 4: Vendor command dispatch ====
out.append("\n" + "=" * 80)
out.append(" SECTION 4: USB Vendor Command Dispatch")
out.append("=" * 80)
# Find all reads of SETUPDAT[1] (bRequest)
for pc in range(0x2500):
if pc + 2 < len(data) and data[pc] == 0x90 and data[pc+1] == 0xE6 and data[pc+2] == 0xA6:
out.append(f"\n SETUPDAT[1] read at {pc:04X}h:")
ctx_start = max(0, pc - 16)
ctx_end = min(pc + 120, 0x2500)
out.append(disasm_range(data, ctx_start, ctx_end,
f" Dispatch context @ {pc:04X}h"))
# ==== SECTION 5: Find all CJNE A,#xx comparisons (command dispatch) ====
out.append("\n" + "=" * 80)
out.append(" SECTION 5: All CJNE A,#xx Instructions (Command Dispatch Table)")
out.append("=" * 80)
for pc in range(0x2500):
if pc + 2 < len(data) and data[pc] == 0xB4:
imm = data[pc+1]
rel = data[pc+2]
if rel >= 0x80:
rel -= 256
target = (pc + 3 + rel) & 0xFFFF
out.append(f" {pc:04X}: CJNE A,#{imm:02X}h,{target:04X}h ; bRequest?=0x{imm:02X} skip->{target:04X}h")
# ==== SECTION 6: boot_8psk ====
out.append(disasm_range(data, 0x1D87, min(0x1D87 + 250, 0x2500),
"SECTION 6: boot_8psk() @ 0x1D87"))
# ==== SECTION 7: SFR access map ====
out.append("\n" + "=" * 80)
out.append(" SECTION 7: All XDATA SFR Accesses (DPTR-tracked)")
out.append("=" * 80)
accesses = track_dptr_accesses(data, 0x0000, 0x2500)
for pc, addr, name, direction in accesses:
name_str = name if name else f"XDATA_{addr:04X}h"
out.append(f" {pc:04X}: {direction:5s} [{addr:04X}h] {name_str}")
# ==== SECTION 8: Key functions ====
out.append(disasm_range(data, 0x0DDD, 0x0DDD + 120,
"SECTION 8: boot_init_blocks() @ 0x0DDD"))
out.append(disasm_range(data, 0x1A81, 0x1A81 + 150,
"SECTION 9: i2c_write() @ 0x1A81"))
out.append(disasm_range(data, 0x1556, 0x1556 + 150,
"SECTION 10: i2c_combined_read() @ 0x1556"))
out.append(disasm_range(data, 0x2000, 0x2000 + 120,
"SECTION 11: wait_for_ready() @ 0x2000"))
out.append(disasm_range(data, 0x099A, 0x099A + 200,
"SECTION 12: tune_function() @ 0x099A"))
# ==== SECTION 13: USB Descriptors ====
out.append("\n" + "=" * 80)
out.append(" SECTION 13: USB Descriptors @ 0x1200")
out.append("=" * 80)
desc = data[0x1200:0x1212]
out.append(f" Device Descriptor (18 bytes):")
out.append(f" bLength: {desc[0]} (0x{desc[0]:02X})")
out.append(f" bDescriptorType: {desc[1]} (DEVICE)")
out.append(f" bcdUSB: {desc[3]:02X}.{desc[2]:02X}")
out.append(f" bDeviceClass: 0x{desc[4]:02X} (vendor-specific)")
out.append(f" bDeviceSubClass: 0x{desc[5]:02X}")
out.append(f" bDeviceProtocol: 0x{desc[6]:02X}")
out.append(f" bMaxPacketSize0: {desc[7]} bytes")
vid = desc[8] | (desc[9] << 8)
pid = desc[10] | (desc[11] << 8)
out.append(f" idVendor: 0x{vid:04X} (Cypress)")
out.append(f" idProduct: 0x{pid:04X}")
bcd = desc[12] | (desc[13] << 8)
out.append(f" bcdDevice: {bcd:04X}")
out.append(f" iManufacturer: {desc[14]}")
out.append(f" iProduct: {desc[15]}")
out.append(f" iSerialNumber: {desc[16]}")
out.append(f" bNumConfigurations: {desc[17]}")
# Raw descriptor dump
out.append(f"\n Raw descriptor area 0x1200-0x1300:")
for off in range(0, 0x100, 16):
addr = 0x1200 + off
chunk = data[addr:addr+16]
hexl = " ".join(f"{b:02X}" for b in chunk)
asciil = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
out.append(f" {addr:04X}: {hexl} {asciil}")
# ==== SECTION 14: Hex dump of interesting code regions ====
out.append("\n" + "=" * 80)
out.append(" SECTION 14: Raw Hex - Code Space Near USB ISR Targets")
out.append("=" * 80)
# Show hex around the USB interrupt vector dispatch
# The USB ISR at 0x0043 likely jumps somewhere
for start_addr in [0x0043, 0x004B]:
out.append(f"\n Hex dump at {start_addr:04X}h:")
for off in range(0, 48, 16):
addr = start_addr + off
chunk = data[addr:addr+16]
hexl = " ".join(f"{b:02X}" for b in chunk)
out.append(f" {addr:04X}: {hexl}")
print("\n".join(out))
if __name__ == "__main__":
main()

Binary file not shown.

View File

@ -9,4 +9,8 @@ CODE_SIZE=--code-size 0x3c00
include $(FX2LIBDIR)lib/fx2.mk include $(FX2LIBDIR)lib/fx2.mk
load: $(BUILDDIR)/$(BASENAME).bix load: $(BUILDDIR)/$(BASENAME).bix
../tools/fw_load.py $(BUILDDIR)/$(BASENAME).bix ../tools/fw_load.py load $(BUILDDIR)/$(BASENAME).ihx
eeprom: $(BUILDDIR)/$(BASENAME).ihx
python3 ../tools/eeprom_write.py convert $(BUILDDIR)/$(BASENAME).ihx \
-o $(BUILDDIR)/$(BASENAME)_eeprom.bin

File diff suppressed because it is too large Load Diff

View File

@ -122,6 +122,7 @@ export default defineConfig({
{ 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: 'Hydrogen 21 cm', slug: 'tools/h21cm' }, { label: 'Hydrogen 21 cm', slug: 'tools/h21cm' },
{ label: 'RF Test Bench', slug: 'tools/rf-testbench' },
{ label: 'Beacon Logger', slug: 'tools/beacon-logger' }, { label: 'Beacon Logger', slug: 'tools/beacon-logger' },
{ label: 'Arc Survey', slug: 'tools/arc-survey' }, { label: 'Arc Survey', slug: 'tools/arc-survey' },
{ label: 'MCP Server', slug: 'tools/mcp-server' }, { label: 'MCP Server', slug: 'tools/mcp-server' },
@ -131,6 +132,7 @@ export default defineConfig({
{ {
label: 'Guides', label: 'Guides',
items: [ items: [
{ label: 'Applications & Use Cases', slug: 'guides/applications' },
{ label: 'QO-100 DATV Reception', slug: 'guides/qo100-datv' }, { label: 'QO-100 DATV Reception', slug: 'guides/qo100-datv' },
{ label: "Experimenter's Roadmap", slug: 'guides/experimenter-roadmap' }, { label: "Experimenter's Roadmap", slug: 'guides/experimenter-roadmap' },
], ],

View File

@ -0,0 +1,322 @@
---
title: Applications & Use Cases
description: What can the SkyWalker-1 actually do? Satellite TV, multi-standard signal analysis, radio astronomy, RF measurement, and more.
---
import { Aside, Badge, Card, CardGrid, Tabs, TabItem } from '@astrojs/starlight/components';
The SkyWalker-1 shipped as a DVB-S satellite TV receiver. With [custom firmware](/firmware/custom-v305/) and
the reverse-engineered USB/I2C interface, it becomes something more interesting: a programmable RF instrument
covering 950-2150 MHz with ten demodulation modes, 256 Ksps to 30 Msps symbol rates, and full Python control.
Here's what you can actually do with it.
## Satellite TV Reception
The obvious one. The SkyWalker-1 receives free-to-air (FTA) DVB-S content — unencrypted satellite television
and radio that anyone with a dish can watch.
### What's Up There
<Tabs>
<TabItem label="Ku-Band">
Most FTA content in North America lives on Ku-band satellites. A standard 30-36 inch dish and
universal LNB is all you need.
| Satellite | Position | What's On It |
|-----------|----------|-------------|
| Galaxy 19 | 97.0&deg;W | The FTA motherlode. ~135+ channels: Chinese, Korean, South Asian, religious, shopping, some English |
| Galaxy 16 | 99.0&deg;W | Religious programming, international |
| SES-2 | 87.0&deg;W | International, government |
| AMC-18 | 105.0&deg;W | Mixed FTA and encrypted |
Typical tuning parameters: 11836 MHz V-pol, 20770 ksps, DVB-S QPSK FEC 3/4.
</TabItem>
<TabItem label="C-Band">
C-band requires a larger dish (6-12 feet) and a C-band LNB, but carries content that never made it
to Ku-band — including DigiCipher II muxes that the SkyWalker-1 uniquely supports.
| Satellite | Position | What's On It |
|-----------|----------|-------------|
| AMC-18 | 105.0&deg;W | DCII cable distribution, some FTA |
| SES-2 | 87.0&deg;W | International, government feeds |
| Galaxy 16 | 99.0&deg;W | Mixed distribution |
The FCC C-band transition compressed services into the upper 3.98-4.2 GHz range. Additional
spectrum auctions are proposed for 2027 — C-band FTA is on borrowed time.
</TabItem>
</Tabs>
### FTA Resources
Current channel listings change frequently. These sites track what's active:
- [LyngSat](https://www.lyngsat.com/) — comprehensive transponder and channel database
- [SatExpat](https://www.satexpat.com/) — FTA channel listings with satellite footprints
- [FTAList](https://ftalist.com/) — North American FTA community and channel guide
<Aside type="caution" title="DVB-S2 is not supported">
An increasing percentage of satellite content uses **DVB-S2**, which relies on LDPC forward error correction
instead of the Reed-Solomon/Viterbi scheme the BCM4500 implements. The SkyWalker-1 can detect DVB-S2
carriers as RF energy (they show up in spectrum sweeps), but it cannot demodulate or decode them.
If a transponder listing says "DVB-S2" or "8PSK" (in the DVB-S2 sense, not Turbo 8PSK), it won't work.
</Aside>
## Multi-Standard Signal Analysis
This is where the SkyWalker-1 becomes genuinely rare hardware. The BCM4500 demodulates standards that are
nearly extinct in available consumer equipment — standards that are still actively broadcasting.
<CardGrid>
<Card title="DigiCipher II" icon="rocket">
Cable headend distribution format (Comcast HITS, Motorola). One of very few modern devices with DCII
support. "Zero Key" unencrypted services are directly receivable.
</Card>
<Card title="DSS" icon="star">
Digital Satellite Service — legacy DirecTV format with 127-byte transport packets (vs 188-byte DVB).
Extraordinarily rare outside DirecTV hardware.
</Card>
<Card title="Turbo 8PSK" icon="setting">
DISH Network transponder format. Encrypted content, but demodulator lock and transport stream capture
work — useful for signal analysis and protocol research.
</Card>
<Card title="Turbo QPSK" icon="open-book">
Earlier turbo-coded variant. Better spectral efficiency than standard DVB-S QPSK, still used on
some distribution paths.
</Card>
</CardGrid>
### Why This Matters
These standards are still active on-air, but the hardware to receive them is disappearing. Off-the-shelf
satellite receivers dropped DCII and DSS support years ago. The SkyWalker-1, through its BCM4500 demodulator,
retains these capabilities — making it a **preservation and research tool** for signal formats that will
eventually go silent.
The [TS Analyzer](/tools/ts-analyzer/) can parse transport streams from all supported modulation types,
making it possible to compare DVB-S, DCII, and DSS packet structures side by side.
See [BCM4500 Demodulator](/bcm4500/demodulator/) for register-level details on how each modulation type
is configured.
## Wild Feed & Backhaul Hunting
Satellite transponders carry more than scheduled programming. Temporary unencrypted uplinks — "wild feeds" —
appear and disappear throughout the day:
- **Live news remotes**: Raw camera feeds from field reporters, unedited and uncensored
- **Sports backhauls**: Stadium camera feeds before production mixing
- **Network distribution**: Programs fed to affiliates before air time
- **Event coverage**: Press conferences, hearings, launches
The SkyWalker-1's blind scan capability and wide symbol rate range (256 Ksps - 30 Msps) make it well-suited
for finding these transient signals. The [Carrier Survey](/tools/survey/) tool automates the sweep-and-lock
cycle across a full satellite.
<Aside type="tip" title="Community resource">
[Rick's Satellite Wildfeed Forum](https://www.satelliteguys.us/xen/forums/wild-feeds.42/) on SatelliteGuys
is the primary community hub for reporting and tracking wild feeds on North American satellites.
</Aside>
## Radio Astronomy
The 950-2150 MHz IF range — or, without an LNB, the direct input range — overlaps with several
astrophysically interesting frequencies. The BCM4500's AGC registers respond to any RF energy at the
tuned frequency, regardless of whether it carries a demodulatable signal.
### Hydrogen 21 cm
<Badge text="tools/h21cm.py" variant="note" />
Neutral hydrogen emits at **1420.405 MHz** — directly in the IF range with no LNB. Connect an L-band
antenna (patch, helical, or horn) to the F-connector and the SkyWalker-1 becomes a hydrogen line
radiometer. The velocity-dispersed emission from the Milky Way's spiral arms is detectable even
with the BCM4500's ~346 kHz resolution bandwidth.
See [Hydrogen 21 cm Radiometer](/tools/h21cm/) for the full tool reference.
### Ku-Band Solar Observation
Point a standard satellite dish + LNB at the Sun. At 10-12 GHz, solar thermal emission produces a
detectable **6+ dB rise** above the cold-sky background. Solar flares produce wideband bursts that
are even more dramatic.
The SkyWalker-1's advantage over an RTL-SDR here is bandwidth: the 30 Msps sweep capability covers
a much wider swath of spectrum (~30 MHz effective) compared to the RTL-SDR's ~2.4 MHz, making it
easier to detect and characterize broadband solar events.
Use the [Spectrum Analysis](/tools/spectrum-analysis/) sweep mode to build solar emission profiles.
### Moon Thermal Emission
The Moon is a calibrated thermal source at microwave frequencies. Measuring its emission relative to
cold sky provides a reference point for system noise temperature estimation — a standard radio
astronomy calibration technique.
## RF Test & Measurement
The custom firmware turns the SkyWalker-1 into a basic but useful L-band test instrument.
### L-Band Spectrum Analyzer
<Badge text="tools/skywalker.py spectrum" variant="note" />
Sweep 950-2150 MHz in configurable steps, recording AGC power at each frequency. Not calibrated
to absolute dBm, but relative measurements are consistent enough for transponder identification,
interference detection, and comparative analysis.
See [Spectrum Analysis](/tools/spectrum-analysis/) for sweep techniques and interpretation.
### CW Injection Test Bench
<Badge text="tools/rf_testbench.py" variant="note" />
Connect a NanoVNA as a CW source through an [HMC472A digital attenuator](https://hmc472.l.zmesh.systems/)
to the SkyWalker-1's F-connector. The `rf_testbench.py` tool automates five test sequences:
AGC linearity mapping, IF band flatness, frequency accuracy, minimum detectable signal, and
BPSK mode 9 probing. The HMC472A provides 0-31.5 dB of programmable attenuation in 0.5 dB
steps via its REST API, giving precision level control without swapping fixed pads.
See [RF Test Bench](/tools/rf-testbench/) for hardware setup, calibration, and test descriptions.
### LNB Characterization
Measure gain flatness across the IF band by sweeping a known satellite's transponder plan and
comparing received power levels. Track LO drift over temperature by monitoring a stable carrier's
frequency offset over 24 hours with the [Beacon Logger](/tools/beacon-logger/).
The I2C-exposed tuner and demodulator registers make internal signal chain parameters directly
readable — something most consumer receivers hide completely.
### Transponder Fingerprinting
Each satellite transponder has unique RF characteristics: center frequency, symbol rate, rolloff,
power level, modulation type. The [Carrier Survey](/tools/survey/) tool builds a catalog of these
parameters. Over time, this creates a fingerprint database useful for satellite identification
and change detection.
### 5G Interference Monitoring
The FCC's C-band auction reallocated 3.7-3.98 GHz to 5G operators. Spillover from 5G base stations
into the satellite C-band (3.98-4.2 GHz) is an increasing concern for satellite operators and
earth station licensees. With a C-band LNB, the SkyWalker-1 can sweep the IF band and detect
interference signatures.
## Propagation Science & Weather
Long-duration signal monitoring produces datasets that map directly to atmospheric physics.
### Rain Fade Analysis
<Badge text="tools/beacon_logger.py" variant="note" />
Lock onto a stable Ku-band transponder and log SNR at 1 Hz for days or weeks. Ku-band signals
attenuate predictably in rain — the ITU-R P.618 model describes the relationship between rainfall
rate and attenuation at specific frequencies. Real measurement data validates (or challenges)
these models for your specific location and dish geometry.
### Diurnal Thermal Effects
LNB gain varies with temperature. A 24-hour beacon log correlated with ambient temperature data
reveals the thermal gain coefficient of your specific LNB — useful for separating real propagation
events from equipment drift.
### Link Budget Validation
Compare long-term average received signal levels against calculated link budgets (EIRP, free space
loss, atmospheric absorption, antenna gain, system noise temperature). The gap between prediction
and measurement is where engineering meets reality.
See [Beacon Logger](/tools/beacon-logger/) for unattended multi-day logging with auto-relock.
## Education & Research
The SkyWalker-1 exposes the complete satellite signal chain from RF input to MPEG-2 transport stream
output, with every intermediate stage accessible over I2C.
### University Lab Platform
A single SkyWalker-1 + dish + LNB covers a semester of satellite communications topics with
live signals:
| Topic | What's Observable |
|-------|------------------|
| QPSK/8PSK demodulation | Lock status, constellation quality via SNR |
| Forward error correction | Viterbi, Reed-Solomon, Turbo code — switchable by modulation type |
| Link budgets | Real measurements vs. theoretical calculations |
| MPEG-2 transport streams | Live PSI/SI table parsing, PID analysis |
| Spectrum analysis | Transponder identification from raw power sweeps |
| Antenna pointing | Signal strength vs. azimuth/elevation in real time |
### Transport Stream Protocol Research
The SkyWalker-1's multi-standard support makes it uniquely suited for comparative protocol analysis:
- **DVB-S**: 188-byte MPEG-2 TS packets, standard PID structure
- **DigiCipher II**: Motorola proprietary transport, conditional access
- **DSS**: 127-byte packets — shorter than DVB, different header format
Tools like [TSDuck](https://tsduck.io/) and dvbsnoop can parse captured streams. The [TS Analyzer](/tools/ts-analyzer/)
handles the initial capture and PSI extraction.
### Accessible Signal Chain
The I2C bus provides direct read access to tuner, demodulator, and FEC status registers. Students can
observe the AGC settling, watch the demodulator acquire lock, and read error correction statistics —
the internal workings of the signal chain, visible in real time. See [I2C Bus Architecture](/i2c/bus-architecture/)
and [Signal Monitoring](/bcm4500/signal-monitoring/) for register details.
## What's NOT Compatible
Setting honest expectations is more valuable than overselling.
<Aside type="danger" title="Hardware limitations">
The following are common requests that the SkyWalker-1 **cannot** fulfill. Understanding these
boundaries prevents wasted time and money on incompatible setups.
</Aside>
| Signal / Application | Why Not |
|---------------------|---------|
| **DVB-S2** | Incompatible FEC — uses LDPC instead of Reed-Solomon/Viterbi. This is a growing percentage of satellite content. |
| **GOES weather satellite imagery** | LRIT uses BPSK/CCSDS (not DVB-S), GRB uses DVB-S2. Cannot decode imagery. However, the BCM4500's BPSK mode 9 uses the same inner FEC (Viterbi rate 1/2 K=7) as LRIT — the signal chain gets four stages deep before breaking at RS decoder block size and CCSDS framing. The LRIT carrier at 1694.1 MHz is within the [direct input range](/tools/h21cm/#antenna-setup) and can be used for antenna alignment and propagation monitoring. See [RF Test Bench](/tools/rf-testbench/) for BPSK mode 9 probing. |
| **QO-100 from North America** | Es'hail-2 is at 25.9&deg;E — visible from Europe, Africa, and the Middle East, but not North America. See [QO-100 DATV Reception](/guides/qo100-datv/) for coverage details. |
| **Military/government feeds** | Encrypted and increasingly DVB-S2 or proprietary modulation. |
| **ATSC / DVB-T terrestrial** | Completely different modulation family (OFDM), different frequency band. |
| **Analog satellite TV** | The BCM4500 is a digital demodulator. Analog satellite is also effectively extinct. |
## Modulation Support Reference
<Tabs>
<TabItem label="By Standard">
| Modulation | Standard | Typical Use | FTA Content? |
|-----------|----------|-------------|-------------|
| DVB-S QPSK | DVB-S EN 300 421 | Free-to-air satellite TV worldwide | Yes — most FTA content |
| Turbo QPSK | Proprietary (Comstream) | Distribution, some DISH | Rare |
| Turbo 8PSK | Proprietary | DISH Network | No — encrypted |
| DCII Combo | Motorola DigiCipher II | Cable headend distribution | Some ("Zero Key") |
| DCII Split I | Motorola DigiCipher II | Cable headend distribution | Some |
| DCII Split Q | Motorola DigiCipher II | Cable headend distribution | Some |
| DCII Offset QPSK | Motorola DigiCipher II | Cable headend distribution | Some |
| DSS QPSK | Hughes DSS | Legacy DirecTV | No — service winding down |
</TabItem>
<TabItem label="By Receiver Use">
| What You Want to Do | Modulation to Select | Symbol Rate Range |
|--------------------|--------------------|------------------|
| Watch FTA satellite TV | DVB-S QPSK | 2-30 Msps |
| Analyze DISH Network signals | Turbo 8PSK | 20-30 Msps |
| Receive DCII cable distribution | DCII Combo/Split/Offset | 2-30 Msps |
| Study DSS transport format | DSS QPSK | 20 Msps typical |
| Hydrogen 21 cm (no LNB) | N/A — AGC power only | Any (for carrier lock attempt) |
| Spectrum sweep / signal detection | N/A — AGC power only | Set during tune, not critical |
</TabItem>
</Tabs>
## See Also
- [RF Specifications](/hardware/rf-specifications/) — frequency range, symbol rate limits, LNB power
- [BCM4500 Demodulator](/bcm4500/demodulator/) — register-level modulation configuration
- [Spectrum Analysis](/tools/spectrum-analysis/) — sweep techniques and transponder scanning
- [RF Test Bench](/tools/rf-testbench/) — CW injection testing with NanoVNA + HMC472A
- [Experimenter's Roadmap](/guides/experimenter-roadmap/) — future experiment tiers and creative applications
- [MCP Server](/tools/mcp-server/) — programmatic access to all hardware functions

View File

@ -3,13 +3,63 @@ title: Hydrogen 21 cm Radiometer
description: Detect neutral hydrogen emission at 1420.405 MHz using the SkyWalker-1 as an L-band radiometer. description: Detect neutral hydrogen emission at 1420.405 MHz using the SkyWalker-1 as an L-band radiometer.
--- ---
import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components'; import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
The `h21cm.py` tool turns the SkyWalker-1 into a hydrogen line radiometer. Neutral hydrogen The `h21cm.py` tool turns the SkyWalker-1 into a hydrogen line radiometer. Neutral hydrogen
atoms emit radiation at **1420.405 MHz** when the electron's spin flips relative to the proton — atoms emit radiation at **1420.405 MHz** when the electron's spin flips relative to the proton —
the most fundamental spectral line in radio astronomy, and it falls directly in the IF range. the most fundamental spectral line in radio astronomy, and it falls directly in the IF range.
No LNB is needed. Connect an L-band antenna directly to the F-connector. ## Antenna Setup
The SkyWalker-1 normally receives satellite TV through an LNB (Low Noise Block downconverter) mounted
at the focal point of a dish. For hydrogen line work, the LNB must be **removed or bypassed entirely** —
it would block the signal. An LNB's waveguide feed is dimensioned for Ku-band wavelengths (~2.5 cm)
and its internal filters reject everything outside the 10.7-12.75 GHz range. At 1420 MHz (wavelength
~21 cm), nothing gets through.
Instead, connect an L-band antenna directly to the SkyWalker-1's F-connector with coaxial cable.
The tool disables LNB power automatically, so there's no voltage on the cable.
### Antenna Options
<CardGrid>
<Card title="Horn Antenna" icon="rocket">
The classic radio astronomy choice. A tin-can "cantenna" or sheet-metal pyramidal horn provides
10-15 dBi gain with predictable, calculable performance. Easy to build from hardware store materials.
A circular waveguide horn from a ~15 cm diameter can works well at 1420 MHz.
</Card>
<Card title="Dish + L-Band Feed" icon="star">
Reuse your satellite dish — replace the LNB with a 1420 MHz feed (dipole + reflector, or a small
horn at the focal point). The dish surface accuracy matters less at 21 cm wavelength than at Ku-band,
so even mesh dishes work fine. This gives the highest gain of any option here.
</Card>
<Card title="Helical Antenna" icon="setting">
A helical antenna is circularly polarized and offers good gain in a compact package. Hydrogen emission
is unpolarized, so a circularly-polarized antenna captures half the power (~3 dB penalty vs linear),
but helix construction is forgiving and well-documented for L-band.
</Card>
<Card title="Patch Antenna" icon="open-book">
Commercial L-band patch antennas (GPS antennas at 1575 MHz are close) are compact and cheap. Lower
gain than the other options (~5-7 dBi), and narrow bandwidth may not cover the full hydrogen emission
profile. Fine for a first detection attempt.
</Card>
</CardGrid>
<Aside type="note" title="Impedance mismatch">
The SkyWalker-1's F-connector is 75&Omega;. Most L-band antennas and amateur radio feedlines are 50&Omega;.
The resulting 1.5:1 VSWR costs about **0.2 dB** of mismatch loss — negligible for AGC power detection.
No matching network is needed. Use an SMA-to-F adapter if your antenna has an SMA connector.
</Aside>
### Cable and Connectors
Run 75&Omega; coax (RG-6 is standard satellite TV cable) from the antenna to the SkyWalker-1. At 1420 MHz,
cable loss matters more than impedance mismatch — keep runs under 10 meters if possible. RG-6 loses
roughly **0.2 dB per meter** at 1.4 GHz (about 6 dB per 100 feet), so a 10m run costs ~2 dB.
Shorter is better.
If your antenna uses 50&Omega; connectors (SMA, N-type), a simple adapter to F-type is fine. The 0.2 dB
impedance mismatch is far less than a meter of extra cable.
## Quick Start ## Quick Start

View File

@ -0,0 +1,260 @@
---
title: RF Test Bench
description: Automated CW injection testing with NanoVNA, HMC472A digital attenuator, and SkyWalker-1 receiver.
---
import { Aside, Badge, Card, CardGrid, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
<Badge text="tools/rf_testbench.py" variant="note" />
The `rf_testbench.py` tool turns a NanoVNA, an HMC472A digital attenuator, and the SkyWalker-1 into
an automated RF test bench. It injects CW signals at known frequencies and power levels, then
measures the receiver's response — characterizing AGC linearity, IF band flatness, frequency
accuracy, sensitivity, and BPSK mode 9 behavior.
## Hardware Setup
<Steps>
1. **NanoVNA CH0 output** (SMA) connects to a **DC blocker** (SMA inline, required)
2. **DC blocker output** connects to the **HMC472A RF IN** (SMA)
3. **HMC472A RF OUT** (SMA) connects via **SMA-to-F adapter** to the **SkyWalker-1 F-connector**
4. **HMC472A ESP32-S2 controller** connected to your network (WiFi) — reachable at `http://attenuator.local`
5. **NanoVNA** connected via USB (for mcnanovna automation) or operated via touchscreen (manual mode)
</Steps>
```
NanoVNA CH0 ──→ DC Blocker ──→ HMC472A (0-31.5 dB) ──→ SMA-to-F ──→ SkyWalker-1
(SMA) (SMA) REST API control adapter (F-type)
http://attenuator.local
```
### Components
| Component | Purpose | Notes |
|-----------|---------|-------|
| NanoVNA-H (9 kHz-1.5 GHz) | CW signal source | Output ~-15 dBm at max power. Overlaps SkyWalker-1 IF band at 950-1500 MHz |
| DC Blocker (SMA inline) | Protect NanoVNA from LNB voltage | Required — even though the tool disables LNB power, this prevents accidental damage |
| HMC472A attenuator module | Precision level control | 0-31.5 dB in 0.5 dB steps, controlled via ESP32-S2 REST API |
| SMA-to-F adapter | Connector transition | 50-to-75 ohm mismatch is ~0.2 dB — negligible |
<Aside type="danger" title="DC blocker is required">
The SkyWalker-1 can supply 13-18V DC through the F-connector for LNB power. Although `rf_testbench.py`
disables LNB power on startup, a bug, power glitch, or running a different tool without disconnecting
could send DC voltage backward through the signal path. The DC blocker prevents this from reaching
the HMC472A and NanoVNA.
</Aside>
### HMC472A Attenuator
The [HMC472A digital attenuator](https://hmc472.l.zmesh.systems/) provides programmable signal level
control via its ESP32-S2 REST API:
- **Range**: 0 to 31.5 dB in 0.5 dB steps (64 discrete settings)
- **Bandwidth**: DC to 3.8 GHz (covers the full SkyWalker-1 IF range)
- **Insertion loss**: 1.4-1.9 dB typical
- **Control**: HTTP REST — `POST /set {"attenuation_db": 10.5}`
- **Switching speed**: 60 ns (faster than any measurement cycle)
The tool communicates with the attenuator at `http://attenuator.local` by default. Override with
`--attenuator http://10.0.0.50` if your device has a different address.
### NanoVNA Frequency Overlap
The NanoVNA-H (HW3.7) covers 9 kHz to 1.5 GHz. The SkyWalker-1's IF range is 950-2150 MHz.
The **overlapping usable range is 950-1500 MHz** — the lower portion of the IF band. This is
sufficient for characterizing the tuner and AGC, and includes the 1420 MHz hydrogen line region.
For testing above 1500 MHz, a different signal source (bladeRF, signal generator) would be needed.
## Calibration
Before running quantitative tests, characterize the signal path loss:
<Steps>
1. Disconnect the SkyWalker-1 end of the cable
2. Connect: **NanoVNA CH0** → DC blocker → HMC472A (set to 0 dB) → cable → **NanoVNA CH1**
3. Run an S21 sweep from 950 to 1500 MHz using mcnanovna or the NanoVNA touchscreen
4. Export as CSV with columns `freq_mhz` and `s21_db` (or `frequency_hz` and `loss_db`)
5. Pass to `rf_testbench.py` with `--cal path_loss.csv`
</Steps>
The tool interpolates the measured path loss at each test frequency and subtracts it from AGC
readings. Without a calibration file, raw AGC values are still reported — useful for relative
measurements but not calibrated to absolute power.
<Aside type="tip" title="HMC472A insertion loss">
The HMC472A adds 1.4-1.9 dB of insertion loss even at 0 dB attenuation setting. The calibration
sweep captures this automatically since the signal passes through the attenuator during the S21
measurement.
</Aside>
## Prerequisites
- **SkyWalker-1** with [custom firmware v3.02+](/firmware/custom-v302/) (for `tune_monitor` command)
- **HMC472A** attenuator with ESP32-S2 controller on the network
- **NanoVNA-H** (manual mode works with any VNA; auto mode requires [mcnanovna](https://git.supported.systems/rf/mcnanovna))
- **Python 3.10+** with `pyusb` installed
- **DC blocker** (SMA inline)
- **SMA-to-F adapter**
## Test Descriptions
### AGC Power Linearity
```bash
python tools/rf_testbench.py agc-linearity --freq 1200
```
Injects CW at a fixed frequency while sweeping the HMC472A from 0 to 31.5 dB in 0.5 dB steps.
At each attenuation level, the SkyWalker-1 reports AGC1, AGC2, and derived power. This maps the
**AGC transfer function** — how the receiver's automatic gain control responds to known changes
in input power.
The output shows whether the AGC is linear, where it saturates, and its effective dynamic range.
With 64 measurement points across 31.5 dB, the resolution is high enough to reveal nonlinearities
in the BCM3440 tuner's gain control loop.
### IF Band Flatness
```bash
python tools/rf_testbench.py band-flatness --start 950 --stop 1500 --step 10
```
Sweeps the NanoVNA CW frequency across the IF band while keeping the HMC472A at a fixed
attenuation (10 dB default). At each frequency, the SkyWalker-1 tunes and reads AGC power.
The result reveals:
- **Tuner gain slope**: the BCM3440 may have more gain at some frequencies than others
- **Passband ripple**: resonances or nulls in the IF filter chain
- **Cable/path frequency response**: if a calibration file is loaded, this is subtracted out
### Frequency Accuracy
```bash
python tools/rf_testbench.py freq-accuracy --freqs 1000,1100,1200,1300,1400
```
At each test frequency, the NanoVNA injects CW while the SkyWalker-1 runs a narrow spectrum
sweep (+/- 5 MHz) around the expected frequency. The detected power peak is compared against the
injected frequency.
This characterizes the **BCM3440 tuner's frequency accuracy** — how much the actual tuned
frequency differs from the commanded frequency. The error may be systematic (constant offset)
or frequency-dependent.
### Minimum Detectable Signal
```bash
python tools/rf_testbench.py mds --freq 1200
```
First measures the noise floor with maximum attenuation (31.5 dB). Then injects CW and steps
the HMC472A from 0 dB upward in 1 dB increments until the signal drops below 3-sigma above
the noise floor.
The attenuation level where the signal disappears, combined with the NanoVNA output power
(~-15 dBm), gives an approximate **minimum detectable signal level** in dBm.
### BPSK Mode 9 CW Probe
```bash
python tools/rf_testbench.py bpsk-probe --freq 1200
```
An exploratory test that tunes the SkyWalker-1 in **BPSK mode (index 9)** — the same Viterbi
rate 1/2 K=7 inner FEC used by GOES LRIT. A CW carrier has no modulation, so the demodulator
shouldn't acquire lock, but the AGC and carrier recovery behavior is informative.
Tests several symbol rates (293,883 sps matching LRIT, plus 500K, 1M, and 5M) and compares
against QPSK mode 0 at the same frequency. This establishes a baseline for what mode 9 reports
with an unmodulated carrier — useful context for future modulated-signal experiments with a
bladeRF.
## Options
| Flag | Default | Description |
|------|---------|-------------|
| `--attenuator` | `http://attenuator.local` | HMC472A REST API base URL |
| `--nanovna` | `auto` | NanoVNA control: `auto` (mcnanovna) or `manual` (prompted) |
| `--cal` | — | Path loss calibration CSV file |
| `--settle` | 200 | Settle time in ms after changing attenuation |
| `--output` / `-o` | — | CSV output file |
| `--verbose` / `-v` | — | Show raw USB traffic |
### Per-Test Options
| Test | Flag | Default | Description |
|------|------|---------|-------------|
| `agc-linearity` | `--freq` | 1200 | Test frequency in MHz |
| `band-flatness` | `--start` | 950 | Start frequency in MHz |
| `band-flatness` | `--stop` | 1500 | Stop frequency in MHz |
| `band-flatness` | `--step` | 10 | Frequency step in MHz |
| `freq-accuracy` | `--freqs` | 1000,1100,1200,1300,1400 | Comma-separated test frequencies |
| `mds` | `--freq` | 1200 | Test frequency in MHz |
| `bpsk-probe` | `--freq` | 1200 | Test frequency in MHz |
## CSV Output Format
All tests write the same CSV format when `--output` is specified:
| Column | Description |
|--------|-------------|
| `timestamp` | ISO 8601 UTC timestamp |
| `test_name` | Test identifier (agc_linearity, band_flatness, freq_accuracy, mds, bpsk_probe) |
| `freq_mhz` | Frequency in MHz |
| `atten_db` | HMC472A attenuation setting in dB |
| `agc1` | BCM3440 AGC1 register value |
| `agc2` | BCM3440 AGC2 register value |
| `power_db` | Derived power estimate in dB (relative) |
| `snr_raw` | Raw SNR register value |
| `snr_db` | SNR in dB |
| `locked` | Demodulator lock status |
| `lock_raw` | Raw lock status byte |
| `status` | Status byte |
| `notes` | Test-specific metadata |
## Interpreting Results
### AGC Linearity Curves
A well-behaved AGC should show a roughly linear relationship between attenuation (dB) and AGC
register value. Look for:
- **Linear region**: Where AGC tracks input power changes 1:1 in dB — this is the useful
measurement range
- **Saturation**: Where adding more signal doesn't change AGC — the tuner's front end is
compressing
- **Noise floor**: Where reducing signal doesn't change AGC — the receiver's internal noise
dominates
### Band Flatness
Ideal response is flat across the band. In practice:
- **1-3 dB variation** across 950-1500 MHz is typical for a consumer-grade tuner
- **Sharp dips** may indicate cable resonances or connector issues
- **Systematic slope** (gain increasing or decreasing with frequency) is common and can be
corrected in post-processing
### Frequency Error
Consumer satellite tuners typically have **50-200 kHz frequency accuracy**. A consistent offset
suggests LO error in the BCM3440. Frequency-dependent error suggests tuning nonlinearity.
## Mock Mode
Run with `SKYWALKER_MOCK=1` for testing without hardware:
```bash
SKYWALKER_MOCK=1 python tools/rf_testbench.py agc-linearity --freq 1200 --nanovna manual
```
Mock mode uses built-in simulated responses for the SkyWalker-1 and HMC472A. The NanoVNA prompts
are skipped. Useful for verifying command structure and CSV output format.
## See Also
- [Spectrum Analysis](/tools/spectrum-analysis/) — frequency sweep techniques
- [Hydrogen 21 cm](/tools/h21cm/) — direct L-band input mode (same RF path concept)
- [Signal Monitoring](/bcm4500/signal-monitoring/) — AGC and SNR register details
- [HMC472A Documentation](https://hmc472.l.zmesh.systems/) — attenuator module reference
- [Applications & Use Cases](/guides/applications/) — RF test and measurement context

View File

@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Test whether BCM4500 register A8 auto-clears after write commands.
If A8 auto-clears to 0x00 (or 0x02) after writing 0x03, then
bcm_poll_ready() would always return TRUE, masking the fact that
the DSP isn't processing commands.
Uses enhanced 0xB6 diagnostic:
wValue = page, wIndex = A8 command (0 = default READ)
Returns 8 bytes:
[0] write_A6_ok
[1] A6 readback
[2] write_A8_ok
[3] A8 IMMEDIATE readback (no delay)
[4] A8 after 2ms delay
[5] A7 data result
[6] A6 final state
[7] echo: command sent
"""
import sys
import time
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_I2C_DIAG = 0xB6
CMD_I2C_RAW_READ = 0xB5
BCM4500_ADDR = 0x08
sw = SkyWalker1()
sw.open()
print('=== A8 Auto-Clear Test ===')
print(f'Firmware: {sw.get_fw_version()}')
# Boot with full sequence
print('\n--- Booting BCM4500 (full boot) ---')
result = sw._vendor_in(0x89, value=1, index=0, length=3)
cfg, stage = result[0], result[1]
print(f' Config: 0x{cfg:02X}, Stage: 0x{stage:02X}')
# Read A8 default state before any commands
print('\n--- A8 default state (after boot, before diagnostic) ---')
a8_default = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=0xA8, length=1)
print(f' A8 default: 0x{a8_default[0]:02X} (bit0={a8_default[0] & 0x01})')
# Test 1: READ command (0x01) — this was the previous behavior
print('\n=== Test 1: A8 with READ command (0x01) ===')
diag1 = sw._vendor_in(CMD_I2C_DIAG, value=0x06, index=0x01, length=8)
print(f' A6 write ok: {diag1[0]}')
print(f' A6 readback: 0x{diag1[1]:02X}')
print(f' A8 write ok: {diag1[2]}')
print(f' A8 IMMEDIATE: 0x{diag1[3]:02X} (bit0={diag1[3] & 0x01})')
print(f' A8 after 2ms: 0x{diag1[4]:02X} (bit0={diag1[4] & 0x01})')
print(f' A7 data: 0x{diag1[5]:02X}')
print(f' A6 final: 0x{diag1[6]:02X}')
print(f' Command sent: 0x{diag1[7]:02X}')
# Reset A8 back to default by reading
time.sleep(0.01)
# Test 2: WRITE command (0x03) — the one used by init blocks
print('\n=== Test 2: A8 with WRITE command (0x03) ===')
diag2 = sw._vendor_in(CMD_I2C_DIAG, value=0x06, index=0x03, length=8)
print(f' A6 write ok: {diag2[0]}')
print(f' A6 readback: 0x{diag2[1]:02X}')
print(f' A8 write ok: {diag2[2]}')
print(f' A8 IMMEDIATE: 0x{diag2[3]:02X} (bit0={diag2[3] & 0x01})')
print(f' A8 after 2ms: 0x{diag2[4]:02X} (bit0={diag2[4] & 0x01})')
print(f' A7 data: 0x{diag2[5]:02X}')
print(f' A6 final: 0x{diag2[6]:02X}')
print(f' Command sent: 0x{diag2[7]:02X}')
# Test 3: Try other A8 values
print('\n=== Test 3: Various A8 values ===')
for cmd in [0x00, 0x02, 0x04, 0xFF]:
time.sleep(0.01)
diag = sw._vendor_in(CMD_I2C_DIAG, value=0x00, index=cmd, length=8)
print(f' CMD=0x{cmd:02X}: A8_imm=0x{diag[3]:02X} A8_2ms=0x{diag[4]:02X} '
f'A7=0x{diag[5]:02X}')
# Analysis
print('\n=== Analysis ===')
if diag2[3] != 0x03 or diag2[4] != 0x03:
print(f' !! A8 AUTO-CLEARS after WRITE command!')
print(f' Wrote 0x03, immediate={diag2[3]:02X}, after_2ms={diag2[4]:02X}')
if (diag2[3] & 0x01) == 0 or (diag2[4] & 0x01) == 0:
print(f' bcm_poll_ready() sees bit0=0 → always returns TRUE')
print(f' Init blocks appear to succeed but DSP never processes them!')
else:
print(f' A8 retains WRITE command (0x03). No auto-clear.')
if (diag1[3] == 0x01) and (diag2[3] == 0x03):
print(f' Both READ and WRITE commands stick. DSP is genuinely not processing.')
sw.close()
print('\n=== Done ===')

128
tools/addr_gateway_test.py Normal file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""BCM4500 I2C address gateway verification test.
Compares register reads at address 0x08 (BCM4500 direct) vs 0x10
(BCM3440 tuner gateway) to confirm the stock firmware disassembly
finding: all BCM4500 register access goes through the tuner at 0x10.
HYPOTHESIS:
- Reads at 0x08 return the same status byte for all registers
- Reads at 0x10 return different, register-specific values
- Writes at 0x10 actually reach BCM4500 registers
- The indirect register protocol (A6/A7/A8) works at 0x10
Run with custom firmware v3.02+ (needs 0xB5 raw I2C read).
"""
import sys
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_I2C_RAW_READ = 0xB5
ADDR_DIRECT = 0x08 # BCM4500 direct
ADDR_GATEWAY = 0x10 # BCM3440 tuner gateway
REGS = [0xA0, 0xA2, 0xA4, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB]
sw = SkyWalker1()
sw.open()
print('=== BCM4500 I2C Address Gateway Test ===')
print(f'Firmware: {sw.get_fw_version()}')
# Boot first
print('\n--- Booting BCM4500 ---')
result = sw._vendor_in(0x89, value=1, index=0, length=3)
cfg, stage = result[0], result[1]
print(f' Config: 0x{cfg:02X}, Stage: 0x{stage:02X}')
# Test 1: Read registers via direct address (0x08)
print(f'\n=== Test 1: Registers via direct address 0x{ADDR_DIRECT:02X} (BCM4500) ===')
direct_vals = {}
for reg in REGS:
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=ADDR_DIRECT, index=reg, length=1)
direct_vals[reg] = data[0]
print(f' 0x{reg:02X}: 0x{data[0]:02X}')
except Exception as e:
direct_vals[reg] = None
print(f' 0x{reg:02X}: FAILED ({e})')
# Test 2: Read registers via gateway address (0x10)
print(f'\n=== Test 2: Registers via gateway address 0x{ADDR_GATEWAY:02X} (BCM3440) ===')
gw_vals = {}
for reg in REGS:
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=ADDR_GATEWAY, index=reg, length=1)
gw_vals[reg] = data[0]
print(f' 0x{reg:02X}: 0x{data[0]:02X}')
except Exception as e:
gw_vals[reg] = None
print(f' 0x{reg:02X}: FAILED ({e})')
# Test 3: Read tuner-specific registers (low range) at 0x10
print(f'\n=== Test 3: BCM3440 tuner registers (0x00-0x0F) at 0x{ADDR_GATEWAY:02X} ===')
for reg in range(0x00, 0x10):
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=ADDR_GATEWAY, index=reg, length=1)
print(f' 0x{reg:02X}: 0x{data[0]:02X}', end='')
except Exception:
print(f' 0x{reg:02X}: FAIL', end='')
if (reg + 1) % 8 == 0:
print()
# Test 4: Write to A6 via gateway, then read back
print(f'\n=== Test 4: Write A6=0x42 via gateway, read back ===')
try:
# Write using vendor OUT (need to construct this)
# Use bcm_direct_write equivalent through 0xB2 (RAW_DEMOD_WRITE)
# Actually, we need a raw I2C write command...
# Let's use the B6 diagnostic which writes A6 and reads back
diag = sw._vendor_in(0xB6, value=0x42, index=0x01, length=8)
print(f' B6 diag (via current FW addr):')
print(f' A6 write ok: {diag[0]}')
print(f' A6 readback: 0x{diag[1]:02X}')
print(f' A8 write ok: {diag[2]}')
print(f' A8 immediate: 0x{diag[3]:02X}')
print(f' A8 after 2ms: 0x{diag[4]:02X}')
print(f' A7 data: 0x{diag[5]:02X}')
print(f' A6 final: 0x{diag[6]:02X}')
print(f' Cmd sent: 0x{diag[7]:02X}')
except Exception as e:
print(f' FAILED: {e}')
# Analysis
print('\n' + '=' * 60)
print('ANALYSIS')
print('=' * 60)
direct_unique = set(v for v in direct_vals.values() if v is not None)
gw_unique = set(v for v in gw_vals.values() if v is not None)
print(f'\nDirect (0x{ADDR_DIRECT:02X}): {len(direct_unique)} unique values: '
f'{", ".join(f"0x{v:02X}" for v in sorted(direct_unique))}')
print(f'Gateway (0x{ADDR_GATEWAY:02X}): {len(gw_unique)} unique values: '
f'{", ".join(f"0x{v:02X}" for v in sorted(gw_unique))}')
if len(direct_unique) <= 2 and len(gw_unique) > 2:
print('\n >> HYPOTHESIS CONFIRMED!')
print(' >> Direct 0x08 returns same status for all regs')
print(' >> Gateway 0x10 returns register-specific values')
print(' >> FIX: BCM4500_ADDR should be 0x10 (tuner gateway)')
elif len(gw_unique) <= 2:
print('\n >> Gateway also returns uniform values -- may need different timing')
print(' >> or the tuner gateway requires initialization first')
else:
print('\n >> Unexpected results -- check values above')
# Show side-by-side comparison
print('\n Reg Direct(0x08) Gateway(0x10) Different?')
for reg in REGS:
d = direct_vals.get(reg)
g = gw_vals.get(reg)
d_str = f'0x{d:02X}' if d is not None else 'FAIL'
g_str = f'0x{g:02X}' if g is not None else 'FAIL'
diff = ' <--' if d != g else ''
print(f' 0x{reg:02X} {d_str:>12} {g_str:>13} {diff}')
sw.close()
print('\n=== Done ===')

194
tools/boot_ab_test.py Normal file
View File

@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""A/B test: compare BCM4500 register state with and without firmware download.
Uses wIndex boot flags on BOOT_8PSK (0x89):
bit 0 (0x01): skip EEPROM firmware download
bit 1 (0x02): add 200ms DSP startup delay after download
bit 2 (0x04): skip register init blocks
Test matrix:
A) wIndex=0x01 skip FW download, init blocks only
B) wIndex=0x00 full boot (FW download + init blocks)
C) wIndex=0x02 full boot + 200ms DSP delay
D) wIndex=0x04 FW download only, skip init blocks
"""
import sys
import time
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_RAW_DEMOD_READ = 0xB1
CMD_I2C_RAW_READ = 0xB5
CMD_GET_PLL_DIAG = 0xBF
BCM4500_ADDR = 0x08
# Key direct registers
KEY_REGS = [
(0xA0, 'CFG_MODE'),
(0xA2, 'STATUS'),
(0xA4, 'LOCK'),
(0xA6, 'PAGE'),
(0xA7, 'DATA'),
(0xA8, 'CMD'),
(0xA9, 'PLL_A9'),
(0xAA, 'PLL_AA'),
(0xAB, 'PLL_AB'),
]
def read_key_regs(sw, label):
"""Read key BCM4500 registers via raw I2C (ground truth)."""
print(f' --- {label} ---')
results = {}
for reg, name in KEY_REGS:
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=reg, length=1)
val = data[0]
results[reg] = val
marker = ''
if val == 0x00:
marker = ' (zero)'
elif val == 0x02:
marker = ' << non-zero!'
elif val != 0x00:
marker = ' << non-zero!'
print(f' 0x{reg:02X} ({name:8s}): 0x{val:02X}{marker}')
except Exception as e:
results[reg] = None
print(f' 0x{reg:02X} ({name:8s}): FAILED ({e})')
nonzero = sum(1 for v in results.values() if v and v != 0)
print(f' Non-zero registers: {nonzero}/{len(results)}')
return results
def boot_with_flags(sw, wval=1, flags=0, label=''):
"""Send BOOT_8PSK with wValue and wIndex (boot flags)."""
print(f'\n{"="*60}')
print(f'BOOT: wValue={wval}, wIndex=0x{flags:02X}{label}')
print(f'{"="*60}')
# Send boot command: wValue=boot mode, wIndex=flags
try:
result = sw._vendor_in(0x89, value=wval, index=flags, length=3)
cfg = result[0]
stage = result[1]
bits = []
if cfg & 0x01: bits.append('Started')
if cfg & 0x02: bits.append('FW_Loaded')
if cfg & 0x04: bits.append('Intersil')
if cfg & 0x08: bits.append('DVBmode')
print(f' Config: 0x{cfg:02X} ({" | ".join(bits) if bits else "none"})')
print(f' Boot stage: 0x{stage:02X}' +
(' (COMPLETE)' if stage == 0xFF else f' (stopped at {stage})'))
except Exception as e:
print(f' Boot command failed: {e}')
return None
# PLL diagnostics
try:
pd = sw._vendor_in(CMD_GET_PLL_DIAG, value=0, index=0, length=10)
print(f' PLL diag: eeprom={"Y" if pd[0] else "N"} '
f'blocks={pd[2]} '
f'exit={"OK" if pd[6]==1 else "FAIL" if pd[6]==0 else "skip"} '
f'result={"OK" if pd[7]==1 else "FAIL"}')
except Exception:
pass
return read_key_regs(sw, 'Registers immediately after boot')
def main():
sw = SkyWalker1()
sw.open()
print('=== BCM4500 Boot A/B Test ===')
print(f'Firmware: {sw.get_fw_version()}')
# Pre-boot register state (BCM4500 may still be in stock-firmware state)
print('\n' + '='*60)
print('PRE-BOOT: Register state before any boot command')
print('='*60)
pre = read_key_regs(sw, 'Before boot')
# Shutdown first to establish baseline
print('\n--- Shutting down BCM4500 ---')
sw._vendor_in(0x89, value=0, index=0, length=3)
time.sleep(0.5)
# ===== TEST A: Init blocks only (no firmware download) =====
regs_a = boot_with_flags(sw, wval=1, flags=0x01,
label='Init blocks ONLY (skip FW download)')
# Read again after 200ms
time.sleep(0.2)
regs_a2 = read_key_regs(sw, 'After 200ms settle (test A)')
# Shutdown and wait
print('\n--- Shutting down BCM4500 ---')
sw._vendor_in(0x89, value=0, index=0, length=3)
time.sleep(0.5)
# ===== TEST B: Full boot (firmware download + init blocks) =====
regs_b = boot_with_flags(sw, wval=1, flags=0x00,
label='Full boot (FW download + init blocks)')
# Read again after 200ms
time.sleep(0.2)
regs_b2 = read_key_regs(sw, 'After 200ms settle (test B)')
# Shutdown and wait
print('\n--- Shutting down BCM4500 ---')
sw._vendor_in(0x89, value=0, index=0, length=3)
time.sleep(0.5)
# ===== TEST C: Full boot + 200ms DSP startup delay =====
regs_c = boot_with_flags(sw, wval=1, flags=0x02,
label='Full boot + 200ms DSP delay')
regs_c2 = read_key_regs(sw, 'Registers (test C)')
# Shutdown and wait
print('\n--- Shutting down BCM4500 ---')
sw._vendor_in(0x89, value=0, index=0, length=3)
time.sleep(0.5)
# ===== TEST D: Firmware download only (no init blocks) =====
regs_d = boot_with_flags(sw, wval=1, flags=0x04,
label='FW download ONLY (skip init blocks)')
time.sleep(0.2)
regs_d2 = read_key_regs(sw, 'After 200ms settle (test D)')
# ===== SUMMARY =====
print('\n' + '='*60)
print('SUMMARY: Register 0xA2 (STATUS) across tests')
print('='*60)
def val_str(regs, reg=0xA2):
if regs is None:
return 'FAIL'
v = regs.get(reg)
if v is None:
return 'FAIL'
return f'0x{v:02X}'
print(f' Pre-boot (stock FW state): {val_str(pre)}')
print(f' A) Init only, immediate: {val_str(regs_a)}')
print(f' A) Init only, +200ms: {val_str(regs_a2)}')
print(f' B) Full boot, immediate: {val_str(regs_b)}')
print(f' B) Full boot, +200ms: {val_str(regs_b2)}')
print(f' C) Full boot + DSP delay: {val_str(regs_c)}')
print(f' D) FW only, +200ms: {val_str(regs_d2)}')
print()
print('Expected: A should show 0x02 (previous working state)')
print(' B should show 0x00 (current broken state)')
print(' C tests if DSP just needs more startup time')
print(' D isolates firmware download effect')
sw.close()
print('\n=== Done ===')
if __name__ == '__main__':
main()

154
tools/boot_deep_verify.py Normal file
View File

@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Deep verification: full register dump + indirect register test.
Checks whether:
1. Direct registers (0xA0-0xBF) are truly all 0x02 or vary
2. Indirect registers respond (proves DSP core is running)
3. Signal monitoring works after boot
4. Boot with vs without FW download produces different indirect reg values
"""
import sys
import time
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_RAW_DEMOD_READ = 0xB1
CMD_I2C_RAW_READ = 0xB5
BCM4500_ADDR = 0x08
def full_direct_dump(sw, label):
"""Read all direct registers 0xA0-0xBF via raw I2C."""
print(f'\n --- {label}: Direct Register Dump 0xA0-0xBF ---')
values = {}
for reg in range(0xA0, 0xC0):
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=reg, length=1)
values[reg] = data[0]
except Exception:
values[reg] = None
# Print in 2 rows of 16
for base in [0xA0, 0xB0]:
row = []
for reg in range(base, base + 16):
v = values.get(reg)
row.append(f'{v:02X}' if v is not None else '??')
print(f' {base:02X}: {" ".join(row)}')
unique = set(v for v in values.values() if v is not None)
print(f' Unique values: {sorted(f"0x{v:02X}" for v in unique)}')
return values
def indirect_reg_test(sw, label):
"""Read indirect registers to test DSP core responsiveness.
Uses 0xB1 with wIndex=0 (indirect mode): wValue=page."""
print(f'\n --- {label}: Indirect Register Test ---')
# Key DSP pages: 0x00 (config), 0x06 (acq), 0x07 (AGC),
# 0x0A (Viterbi), 0x0F (transport)
pages = [0x00, 0x01, 0x06, 0x07, 0x0A, 0x0F, 0x10, 0x20]
results = {}
for page in pages:
try:
data = sw._vendor_in(CMD_RAW_DEMOD_READ, value=page,
index=0, length=1)
val = data[0]
results[page] = val
marker = ' << DSP alive!' if val != 0 else ''
print(f' Page 0x{page:02X}: 0x{val:02X}{marker}')
except Exception as e:
results[page] = None
print(f' Page 0x{page:02X}: FAILED ({e})')
nonzero = sum(1 for v in results.values() if v and v != 0)
print(f' Non-zero pages: {nonzero}/{len(pages)}')
return results
def boot_and_test(sw, flags, label):
"""Boot with given flags and run full diagnostics."""
print(f'\n{"="*60}')
print(f'TEST: {label} (wIndex=0x{flags:02X})')
print(f'{"="*60}')
# Shutdown + wait
sw._vendor_in(0x89, value=0, index=0, length=3)
time.sleep(0.5)
# Boot
result = sw._vendor_in(0x89, value=1, index=flags, length=3)
cfg, stage = result[0], result[1]
bits = []
if cfg & 0x01: bits.append('Started')
if cfg & 0x02: bits.append('FW_Loaded')
print(f' Config: 0x{cfg:02X} ({" | ".join(bits) if bits else "none"})')
print(f' Stage: 0x{stage:02X}' +
(' (COMPLETE)' if stage == 0xFF else f' (at {stage})'))
# Full register dumps
direct = full_direct_dump(sw, label)
indirect = indirect_reg_test(sw, label)
# Wait and re-test indirect (DSP may need startup time)
time.sleep(0.5)
indirect2 = indirect_reg_test(sw, f'{label} +500ms')
# Signal monitor
print(f'\n --- Signal Monitor ---')
try:
sig = sw.signal_monitor()
print(f' {sig}')
except Exception as e:
print(f' Failed: {e}')
return direct, indirect, indirect2
def main():
sw = SkyWalker1()
sw.open()
print('=== BCM4500 Deep Boot Verification ===')
print(f'Firmware: {sw.get_fw_version()}')
# Test 1: Init blocks only (no FW download)
d1, i1, i1b = boot_and_test(sw, 0x01,
'Init blocks only (skip FW download)')
# Test 2: Full boot (FW download + init blocks)
d2, i2, i2b = boot_and_test(sw, 0x00,
'Full boot (FW download + init blocks)')
# Compare indirect registers between the two modes
print(f'\n{"="*60}')
print('COMPARISON: Indirect Registers (init-only vs full boot)')
print('='*60)
pages = [0x00, 0x01, 0x06, 0x07, 0x0A, 0x0F, 0x10, 0x20]
for page in pages:
v1 = i1b.get(page)
v2 = i2b.get(page)
v1s = f'0x{v1:02X}' if v1 is not None else '??'
v2s = f'0x{v2:02X}' if v2 is not None else '??'
diff = ' << DIFFERENT!' if v1 != v2 else ''
print(f' Page 0x{page:02X}: init-only={v1s} full={v2s}{diff}')
# Compare direct registers
print(f'\nDirect register comparison (0xA0-0xBF):')
diffs = []
for reg in range(0xA0, 0xC0):
v1 = d1.get(reg)
v2 = d2.get(reg)
if v1 != v2:
diffs.append(f' 0x{reg:02X}: init-only=0x{v1:02X} full=0x{v2:02X}')
if diffs:
print('\n'.join(diffs))
else:
print(' No differences (all registers identical)')
sw.close()
print('\n=== Done ===')
if __name__ == '__main__':
main()

189
tools/boot_reg_probe.py Normal file
View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Probe BCM4500 register behavior after boot.
1. Multi-byte reads via 0xB5 (do adjacent registers differ?)
2. Step-by-step indirect read via 0xB6 diagnostic
3. Write a register and read back (is the BCM alive or echoing?)
4. Try tuning to see if the signal path works
"""
import sys
import time
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_I2C_RAW_READ = 0xB5
CMD_I2C_DIAG = 0xB6
CMD_RAW_DEMOD_READ = 0xB1
CMD_RAW_DEMOD_WRITE = 0xB2
BCM4500_ADDR = 0x08
sw = SkyWalker1()
sw.open()
print('=== BCM4500 Register Probe ===')
print(f'Firmware: {sw.get_fw_version()}')
# Boot with full sequence
print('\n--- Booting BCM4500 (full boot) ---')
result = sw._vendor_in(0x89, value=1, index=0, length=3)
cfg, stage = result[0], result[1]
print(f' Config: 0x{cfg:02X}, Stage: 0x{stage:02X}')
# ============================================================
# TEST 1: Multi-byte read — read 16 bytes starting at 0xA0
# ============================================================
print('\n=== Test 1: Multi-byte read (16 bytes from 0xA0) ===')
print('If BCM4500 truly maps different registers, bytes should differ.')
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=0xA0, length=16)
hex_str = ' '.join(f'{b:02X}' for b in data)
print(f' 0xA0-0xAF: {hex_str}')
unique = set(data)
print(f' Unique values: {sorted(f"0x{v:02X}" for v in unique)}')
if len(unique) == 1:
print(f' ALL SAME VALUE: 0x{data[0]:02X} — BCM4500 may be returning')
print(f' a fixed status byte, not true register contents.')
except Exception as e:
print(f' Failed: {e}')
# Read second block 0xB0-0xBF
try:
data2 = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=0xB0, length=16)
hex_str = ' '.join(f'{b:02X}' for b in data2)
print(f' 0xB0-0xBF: {hex_str}')
except Exception as e:
print(f' Failed: {e}')
# ============================================================
# TEST 2: Write-then-read — does the BCM4500 retain writes?
# ============================================================
print('\n=== Test 2: Write-then-read (register 0xA6 PAGE) ===')
print('Write 0x42 to 0xA6, read back. If we get 0x42, register works.')
print('If we get 0x02, the chip may be ignoring writes.')
# Read current value
try:
before = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=0xA6, length=1)
print(f' Before write: 0xA6 = 0x{before[0]:02X}')
except Exception as e:
print(f' Read failed: {e}')
# Write 0x42 to 0xA6 via 0xB1 direct write
try:
# 0xB2: RAW_DEMOD_WRITE — wValue=register, wIndex=data
sw._vendor_in(CMD_RAW_DEMOD_WRITE, value=0xA6, index=0x42, length=0)
except Exception:
# 0xB2 might not return data
pass
time.sleep(0.01)
# Read back
try:
after = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=0xA6, length=1)
print(f' After write 0x42: 0xA6 = 0x{after[0]:02X}')
if after[0] == 0x42:
print(f' >> REGISTER WORKS — BCM4500 is alive and accepting writes!')
elif after[0] == 0x02:
print(f' >> Still 0x02 — chip may be ignoring writes or returning status')
else:
print(f' >> Got 0x{after[0]:02X} — unexpected')
except Exception as e:
print(f' Read failed: {e}')
# Restore 0xA6 to 0x00
try:
sw._vendor_in(CMD_RAW_DEMOD_WRITE, value=0xA6, index=0x00, length=0)
except Exception:
pass
# ============================================================
# TEST 3: Step-by-step indirect read via 0xB6
# ============================================================
print('\n=== Test 3: Step-by-step indirect read (0xB6) ===')
print('Reading indirect register page 0x06 (acquisition config)')
try:
diag = sw._vendor_in(CMD_I2C_DIAG, value=0x06, index=0, length=8)
labels = [
'Write 0xA6 ok', # [0]
'Readback 0xA6', # [1]
'Write 0xA8 ok', # [2]
'Readback 0xA8', # [3]
'Readback 0xA7', # [4] — the indirect register data
'Direct read 0xA6', # [5]
'Direct read 0xA7', # [6]
'Direct read 0xA8', # [7]
]
for i, label in enumerate(labels):
ok_marker = ''
if i in [0, 2]:
ok_marker = ' (success)' if diag[i] == 0x01 else ' (FAILED!)' if diag[i] == 0x00 else ''
print(f' [{i}] {label:20s}: 0x{diag[i]:02X}{ok_marker}')
print()
if diag[0] == 0x01 and diag[2] == 0x01:
print(' Writes to A6/A8 succeeded.')
if diag[1] == 0x06:
print(f' A6 readback = 0x06 — page register WORKS.')
else:
print(f' A6 readback = 0x{diag[1]:02X} — expected 0x06!')
if diag[4] != 0x00:
print(f' A7 (indirect data) = 0x{diag[4]:02X} — DSP responding!')
else:
print(f' A7 (indirect data) = 0x00 — DSP may not be running.')
else:
print(' Write step FAILED — I2C issue.')
except Exception as e:
print(f' Diagnostic failed: {e}')
# Try a few more pages
for page in [0x00, 0x07, 0x0F]:
try:
diag = sw._vendor_in(CMD_I2C_DIAG, value=page, index=0, length=8)
a6_ok = 'ok' if diag[0] == 1 else 'FAIL'
a8_ok = 'ok' if diag[2] == 1 else 'FAIL'
print(f' Page 0x{page:02X}: A6={a6_ok} A6_rb=0x{diag[1]:02X} '
f'A8={a8_ok} A8_rb=0x{diag[3]:02X} '
f'A7_data=0x{diag[4]:02X} '
f'direct=[A6=0x{diag[5]:02X} A7=0x{diag[6]:02X} A8=0x{diag[7]:02X}]')
except Exception as e:
print(f' Page 0x{page:02X}: {e}')
# ============================================================
# TEST 4: Read registers 0x00-0x9F (below the A0 range)
# ============================================================
print('\n=== Test 4: Registers OUTSIDE 0xA0-0xBF range ===')
print('If BCM4500 returns 0x02 for everything, it might do so for ALL addresses.')
for reg in [0x00, 0x10, 0x50, 0x80, 0x90, 0x9F, 0xC0, 0xD0, 0xFF]:
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=reg, length=1)
print(f' Reg 0x{reg:02X}: 0x{data[0]:02X}')
except Exception as e:
print(f' Reg 0x{reg:02X}: FAILED ({e})')
# ============================================================
# TEST 5: Quick tune test (no dish needed — just check AGC)
# ============================================================
print('\n=== Test 5: Tune to 1200 MHz / 27500 ksps (AGC check) ===')
try:
# Enable Intersil (LNB controller)
sw._vendor_in(0x8A, value=1, index=0, length=1)
time.sleep(0.1)
print(' Intersil enabled')
# Tune: 1200 MHz, 27500 ksps, QPSK
result = sw.tune_monitor(freq_mhz=1200, sr_ksps=27500, mod_index=0, dwell_ms=200)
print(f' Tune result: {result}')
if result.get('agc1', 0) > 0 or result.get('agc2', 0) > 0:
print(' >> AGC non-zero — tuner + demod signal path is alive!')
else:
print(' >> AGC=0 — signal path not working (or tuner not configured)')
except Exception as e:
print(f' Tune failed: {e}')
sw.close()
print('\n=== Done ===')

177
tools/boot_test.py Normal file
View File

@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Quick boot test for PLL config firmware."""
import sys
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_RAW_DEMOD_READ = 0xB1
CMD_I2C_SCAN = 0xB4
CMD_GET_PLL_DIAG = 0xBF
sw = SkyWalker1()
sw.open()
print('=== SkyWalker-1 Custom Firmware Boot Test ===')
print()
# Check firmware version
ver = sw.get_fw_version()
print(f'Firmware version: {ver}')
# Check config status
cfg = sw.get_config()
print(f'Config status: 0x{cfg:02X}')
bits = []
if cfg & 0x01: bits.append('Started')
if cfg & 0x02: bits.append('FW_Loaded')
if cfg & 0x04: bits.append('Intersil')
if cfg & 0x08: bits.append('DVBmode')
if cfg & 0x10: bits.append('22kHz')
if cfg & 0x20: bits.append('18V')
if cfg & 0x40: bits.append('DCtuned')
if cfg & 0x80: bits.append('Armed')
print(f' Flags: {" | ".join(bits) if bits else "(none)"}')
print()
# Check last error before boot
err = sw.get_last_error()
print(f'Last error (pre-boot): 0x{err:02X}')
print()
# Boot the BCM4500
print('--- Booting BCM4500 ---')
boot_result = sw.boot()
print(f'Boot result: {boot_result}')
cfg = sw.get_config()
print(f'Config after boot: 0x{cfg:02X}')
bits = []
if cfg & 0x01: bits.append('Started')
if cfg & 0x02: bits.append('FW_Loaded')
if cfg & 0x04: bits.append('Intersil')
if cfg & 0x08: bits.append('DVBmode')
if cfg & 0x10: bits.append('22kHz')
if cfg & 0x20: bits.append('18V')
if cfg & 0x40: bits.append('DCtuned')
if cfg & 0x80: bits.append('Armed')
print(f' Flags: {" | ".join(bits) if bits else "(none)"}')
err = sw.get_last_error()
ERR_NAMES = {
0x00: 'ERR_OK',
0x01: 'ERR_I2C_TIMEOUT',
0x02: 'ERR_I2C_NAK',
0x03: 'ERR_I2C_ARB_LOST',
0x04: 'ERR_BCM_NOT_READY (PLL config failed)',
0x05: 'ERR_BCM_TIMEOUT',
}
print(f'Last error after boot: 0x{err:02X} = {ERR_NAMES.get(err, "unknown")}')
print()
# PLL config diagnostics
print('--- PLL Config Diagnostics ---')
try:
pd = sw._vendor_in(CMD_GET_PLL_DIAG, value=0, index=0, length=10)
print(f' EEPROM present: {"YES" if pd[0] else "NO"}')
print(f' First block count: 0x{pd[1]:02X}' + (' (sentinel=0, no PLL data!)' if pd[1] == 0 else
(' (not reached)' if pd[1] == 0xFF else f' ({pd[1]} AB bytes)')))
print(f' Blocks written: {pd[2]}')
print(f' Last A9 value: 0x{pd[3]:02X}' + (' (none)' if pd[3] == 0xFF else ''))
print(f' Last AA value: 0x{pd[4]:02X}' + (' (none)' if pd[4] == 0xFF else ''))
print(f' Last AB count: 0x{pd[5]:02X}' + (' (none)' if pd[5] == 0xFF else ''))
print(f' Config mode exit: {"OK" if pd[6] == 1 else ("FAIL" if pd[6] == 0 else "not reached")}')
print(f' Overall PLL result: {"SUCCESS" if pd[7] == 1 else "FAILED"}')
print(f' Boot stage: 0x{pd[8]:02X}' + (' (all complete)' if pd[8] == 0xFF else f' (stopped at stage {pd[8]})'))
print(f' Last error: 0x{pd[9]:02X}')
except Exception as e:
print(f' PLL diag failed: {e}')
print()
# I2C bus scan
print('--- I2C Bus Scan ---')
try:
bitmap = sw._vendor_in(CMD_I2C_SCAN, value=0, index=0, length=16)
found = []
for byte_idx in range(16):
for bit_idx in range(8):
if bitmap[byte_idx] & (1 << bit_idx):
addr = byte_idx * 8 + bit_idx
found.append(addr)
labels = {0x08: 'BCM4500', 0x10: 'BCM3440', 0x51: 'EEPROM'}
for addr in found:
label = labels.get(addr, '')
print(f' 0x{addr:02X} {label}')
if not found:
print(' (no devices found!)')
except Exception as e:
print(f' Scan failed: {e}')
print()
# Try reading signal
print('--- Signal Check ---')
try:
sig = sw.signal_monitor()
print(f'Signal monitor: {sig}')
except Exception as e:
print(f'Signal monitor failed: {e}')
# Read BCM4500 direct registers via 0xB1 vendor command
# wValue=register address, wIndex=1 for direct read mode
print()
print('--- BCM4500 Direct Register Reads ---')
key_regs = [
(0xA0, 'CFG_MODE'),
(0xA2, 'STATUS'),
(0xA4, 'LOCK'),
(0xA9, 'PLL_A9'),
(0xAA, 'PLL_AA'),
(0xAB, 'PLL_AB'),
]
for reg, label in key_regs:
try:
data = sw._vendor_in(CMD_RAW_DEMOD_READ, value=reg, index=1, length=1)
val = data[0]
print(f' 0x{reg:02X} ({label:8s}): 0x{val:02X}')
except Exception as e:
print(f' 0x{reg:02X} ({label:8s}): FAILED ({e})')
# Indirect register reads via 0xB1 with wIndex=0 (indirect mode)
# wValue=page, wIndex=0. Only meaningful if the DSP core is running.
print()
print('--- BCM4500 Indirect Register Reads (DSP core test) ---')
for page in [0x06, 0x07, 0x0F]:
try:
data = sw._vendor_in(CMD_RAW_DEMOD_READ, value=page, index=0, length=1)
val = data[0]
alive = ' << DSP responding' if val != 0 else ''
print(f' Page 0x{page:02X}: 0x{val:02X}{alive}')
except Exception as e:
print(f' Page 0x{page:02X}: FAILED ({e})')
# Cross-check: read key registers via 0xB5 (raw I2C, writes into EP0BUF directly)
# This bypasses the 0xB1 handler's val variable entirely
CMD_I2C_RAW_READ = 0xB5
BCM4500_ADDR = 0x08
print()
print('--- Cross-check via 0xB5 Raw I2C Read (BCM4500 @ 0x08) ---')
for reg, label in key_regs:
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR, index=reg, length=1)
val = data[0]
tag = ' ** I2C FAIL (0xFF) **' if val == 0xFF else ''
print(f' 0x{reg:02X} ({label:8s}): 0x{val:02X}{tag}')
except Exception as e:
print(f' 0x{reg:02X} ({label:8s}): FAILED ({e})')
# Full register dump 0xA0-0xBF via 0xB5 raw I2C (ground truth)
print()
print('--- Full Direct Register Dump 0xA0-0xBF (via 0xB5 raw I2C) ---')
for reg in range(0xA0, 0xC0):
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR, index=reg, length=1)
val = data[0]
print(f' 0x{reg:02X}: 0x{val:02X}')
except Exception as e:
print(f' 0x{reg:02X}: FAIL')
sw.close()
print()
print('=== Test Complete ===')

112
tools/eeprom_deep_scan.py Normal file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Deep EEPROM scan — find the real PLL data location."""
import sys
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_EEPROM_READ = 0xC0
sw = SkyWalker1()
sw.open()
def ee(addr, length):
return sw._vendor_in(CMD_EEPROM_READ, value=addr, index=length, length=length)
def hex_line(addr, data):
hex_str = ' '.join(f'{b:02X}' for b in data)
return f'{addr:04X}: {hex_str}'
# Dump 0x3F00-0x4100 (area around the boundary — zero gap between FX2 and BCM firmware?)
print('=== Region around FX2 firmware end (0x2530) ===')
for addr in range(0x2530, 0x2600, 16):
d = ee(addr, 16)
print(f' {hex_line(addr, d)}')
print()
print('=== Region 0x3F00-0x4080 (end of first 16KB, start of second) ===')
for addr in range(0x3F00, 0x4080, 16):
d = ee(addr, 16)
print(f' {hex_line(addr, d)}')
# Check the END of the AT24C256 (0x7F00-0x7FFF)
print()
print('=== End of EEPROM 0x7F00-0x7FFF ===')
for addr in range(0x7F00, 0x8000, 16):
d = ee(addr, 16)
print(f' {hex_line(addr, d)}')
# Parse the second image at 0x4000 to find its end
print()
print('=== Parsing 0x4000 as C2-like records ===')
offset = 0x4000
# First check if it's a C2 header
header = ee(0x4000, 4)
print(f' Header bytes: {header.hex(" ")}')
# Could be: [len_hi, len_lo, addr_hi, addr_lo]
rec_len = (header[0] << 8) | header[1]
rec_addr = (header[2] << 8) | header[3]
print(f' As record: len={rec_len} (0x{rec_len:04X}), addr=0x{rec_addr:04X}')
print()
# Try parsing as load records (same format as C2 but without the 8-byte header)
offset = 0x4000
print(' Attempting record parse:')
total_size = 0
while offset < 0x7000:
hdr = ee(offset, 4)
rlen = (hdr[0] << 8) | hdr[1]
raddr = (hdr[2] << 8) | hdr[3]
if rlen == 0x8001:
print(f' [{total_size}] END MARKER at 0x{offset:04X} → entry=0x{raddr:04X}')
offset += 4
break
elif rlen == 0 or rlen > 0x4000:
print(f' [{total_size}] STOP at 0x{offset:04X}: len=0x{rlen:04X} addr=0x{raddr:04X}')
# Dump surrounding bytes
d = ee(offset, 32)
print(f' Bytes: {d.hex(" ")}')
offset += 4
break
end = raddr + rlen - 1
print(f' [{total_size}] {rlen:5d} bytes at EEPROM 0x{offset:04X} → 0x{raddr:04X}-0x{end:04X}')
offset += 4 + rlen
total_size += rlen
if total_size > 30000:
print(' (aborting, too much data)')
break
print(f' Second image ends at: 0x{offset:04X}')
print()
# Check immediately after second image for PLL data
print(f'=== After second image: 0x{offset:04X}-0x{offset+256:04X} ===')
for addr in range(offset, offset + 256, 16):
d = ee(addr, 16)
print(f' {hex_line(addr, d)}')
# Also check for PLL blocks at this offset
print()
print(f'=== PLL block scan starting at 0x{offset:04X} ===')
for addr in range(offset, offset + 400, 20):
block = ee(addr, 20)
count = block[0]
if count == 0:
print(f' 0x{addr:04X}: [sentinel count=0]')
break
elif 1 <= count <= 16:
ab = block[4:4 + count]
print(f' 0x{addr:04X}: count={count} A9=0x{block[1]:02X} AA=0x{block[2]:02X} '
f'unused=0x{block[3]:02X} AB=[{ab.hex(" ")}]')
else:
print(f' 0x{addr:04X}: NOT PLL (first byte=0x{count:02X})')
# But continue scanning
pass
sw.close()
print()
print('=== Done ===')

View File

@ -1,251 +1,157 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Genpix SkyWalker-1 EEPROM firmware dump tool. Genpix SkyWalker-1 EEPROM exploration tool.
Reads the Cypress FX2 boot EEPROM via the I2C_READ vendor command. Reads the calibration EEPROM (AT24Cxxx at I2C addr 0x51) via the custom
Protocol: I2C_READ (0x84), wValue=0x51, wIndex=offset, length=chunk_size firmware's EEPROM_READ (0xC0) vendor command. This uses 16-bit addressing
directly, bypassing the stock firmware's single-byte I2C_READ protocol.
The EEPROM contains firmware in Cypress C2 IIC boot format: Primary purpose: Find where PLL configuration data is stored so the
- Header: C2 VID_L VID_H PID_L PID_H DID_L DID_H CONFIG bcm4500_load_pll_config() function reads from the correct address.
- Records: LEN_H LEN_L ADDR_H ADDR_L DATA[LEN]
- End: 80 01 ENTRY_H ENTRY_L (reset vector)
""" """
import usb.core, usb.util, sys, struct import sys
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
VENDOR_ID = 0x09C0 CMD_EEPROM_READ = 0xC0
PRODUCT_ID = 0x0203
I2C_READ = 0x84 sw = SkyWalker1()
EEPROM_SLAVE = 0x51 sw.open()
def find_device(): def eeprom_read(addr, length):
dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID) """Read bytes from EEPROM at 16-bit address.
if dev is None: wValue = address, wIndex = length."""
print("SkyWalker-1 not found") return sw._vendor_in(CMD_EEPROM_READ, value=addr, index=length, length=length)
sys.exit(1)
return dev
def detach_driver(dev): def hex_dump(addr, data):
intf_num = None """Print hex dump with ASCII sidebar."""
for cfg in dev: for i in range(0, len(data), 16):
for intf in cfg: chunk = data[i:i + 16]
if dev.is_kernel_driver_active(intf.bInterfaceNumber): hex_str = ' '.join(f'{b:02X}' for b in chunk)
try: ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
dev.detach_kernel_driver(intf.bInterfaceNumber) print(f' {addr + i:04X}: {hex_str:<48s} |{ascii_str}|')
intf_num = intf.bInterfaceNumber
except usb.core.USBError as e:
print(f"Cannot detach driver: {e}")
print("Try: sudo modprobe -r dvb_usb_gp8psk")
sys.exit(1)
try:
dev.set_configuration()
except:
pass
return intf_num
def eeprom_read(dev, offset, length=64): print('=== EEPROM Exploration ===')
"""Read from EEPROM at given offset.""" print()
# wIndex holds the EEPROM byte offset (16-bit, so max 64KB)
return dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
I2C_READ, EEPROM_SLAVE, offset, length, 2000)
# Step 1: Determine EEPROM size by aliasing detection
print('--- Size Detection ---')
data_0000 = eeprom_read(0x0000, 16)
data_4000 = eeprom_read(0x4000, 16)
data_8000 = eeprom_read(0x8000, 16)
print(f' 0x0000: {data_0000.hex(" ")}')
print(f' 0x4000: {data_4000.hex(" ")}')
print(f' 0x8000: {data_8000.hex(" ")}')
if data_0000 == data_4000:
print(' Result: 0x4000 ALIASES to 0x0000 → AT24C128 (16KB)')
eeprom_size = 16384
elif data_0000 == data_8000:
print(' Result: 0x8000 aliases to 0x0000 → AT24C256 (32KB)')
eeprom_size = 32768
else:
print(' Result: All different → AT24C512+ (64KB+)')
eeprom_size = 65536
print()
def parse_c2_header(data): # Step 2: Dump first 512 bytes (FX2 boot firmware header + data)
"""Parse Cypress C2 boot EEPROM header.""" print('--- EEPROM 0x0000-0x01FF (C2 boot header region) ---')
if data[0] != 0xC2: for addr in range(0x0000, 0x0200, 64):
print(f" Not a C2 EEPROM (first byte: 0x{data[0]:02X})") data = eeprom_read(addr, 64)
return None hex_dump(addr, data)
print()
vid = data[2] << 8 | data[1] # Step 3: Scan for PLL-like 20-byte blocks
pid = data[4] << 8 | data[3] # Format: [count(1-16), A9_val, AA_val, unused_byte, AB_data[count], padding...]
did = data[6] << 8 | data[5] # Sentinel: count=0
config = data[7] print('--- Scanning for PLL config blocks ---')
print(' Format: [count, A9, AA, unused, AB_data[count]]')
print(' Sentinel: count=0')
print()
print(f" Format: C2 (Large EEPROM, code loads to internal RAM)") # Scan the entire EEPROM in 20-byte strides
print(f" VID: 0x{vid:04X} {'(Genpix)' if vid == 0x09C0 else ''}") pll_candidates = []
print(f" PID: 0x{pid:04X} {'(SkyWalker-1)' if pid == 0x0203 else ''}") for addr in range(0, min(eeprom_size, 0x4000), 20):
print(f" DID: 0x{did:04X}") data = eeprom_read(addr, 20)
print(f" Config: 0x{config:02X}", end="") count = data[0]
# Look for potential sentinel (count=0) preceded by valid blocks
config_flags = [] if count == 0 and addr > 0:
if config & 0x40: # Check if previous 20 bytes looked like PLL data
config_flags.append("400kHz I2C") prev = eeprom_read(addr - 20, 20)
if config & 0x04: if 1 <= prev[0] <= 16:
config_flags.append("disconnect") pll_candidates.append({
if config_flags: 'sentinel_addr': addr,
print(f" ({', '.join(config_flags)})") 'last_block_addr': addr - 20,
else: 'last_count': prev[0],
print() 'last_a9': prev[1],
'last_aa': prev[2],
return {"vid": vid, "pid": pid, "did": did, "config": config}
def parse_records(data, offset=8):
"""Parse C2 load records from EEPROM data."""
records = []
while offset < len(data) - 4:
rec_len = (data[offset] << 8) | data[offset + 1]
rec_addr = (data[offset + 2] << 8) | data[offset + 3]
if rec_len == 0x8001:
# End marker - rec_addr is the entry point (reset vector)
records.append({
"type": "end",
"entry_point": rec_addr,
"offset": offset
}) })
break
elif rec_len == 0 or rec_len > 0x4000:
records.append({
"type": "invalid",
"raw_len": rec_len,
"offset": offset
})
break
rec_data = data[offset + 4:offset + 4 + rec_len] if pll_candidates:
records.append({ print(' Found sentinel(s):')
"type": "data", for c in pll_candidates:
"length": rec_len, print(f' Sentinel at 0x{c["sentinel_addr"]:04X}')
"load_addr": rec_addr, print(f' Last block at 0x{c["last_block_addr"]:04X}: '
"data": bytes(rec_data), f'count={c["last_count"]} A9=0x{c["last_a9"]:02X} AA=0x{c["last_aa"]:02X}')
"offset": offset # Walk backwards to find start of PLL data
}) start = c['last_block_addr']
offset += 4 + rec_len while start >= 20:
prev = eeprom_read(start - 20, 20)
return records if 1 <= prev[0] <= 16:
start -= 20
def main():
import argparse
parser = argparse.ArgumentParser(description="Dump SkyWalker-1 EEPROM firmware")
parser.add_argument('-o', '--output', default='skywalker1_eeprom.bin',
help='Output file for raw EEPROM dump')
parser.add_argument('--extract', action='store_true',
help='Also extract firmware as flat binary')
parser.add_argument('--max-size', type=int, default=16384,
help='Maximum EEPROM size to read (default: 16384)')
args = parser.parse_args()
print("Genpix SkyWalker-1 EEPROM Dump")
print("=" * 40)
dev = find_device()
print(f"Found device: Bus {dev.bus} Addr {dev.address}")
intf = detach_driver(dev)
try:
# Read EEPROM
chunk_size = 64 # Max reliable USB control transfer
eeprom = bytearray()
consecutive_ff = 0
print(f"\nReading EEPROM (max {args.max_size} bytes)...")
for offset in range(0, args.max_size, chunk_size):
# wIndex only goes up to 0xFFFF, which covers 64KB EEPROMs
data = eeprom_read(dev, offset, chunk_size)
if data is None:
print(f"\n Read failed at offset 0x{offset:04X}")
break
chunk = bytes(data)
eeprom.extend(chunk)
# Check for end of data
if all(b == 0xFF for b in chunk):
consecutive_ff += 1
if consecutive_ff >= 4:
print(f"\r End of data at 0x{len(eeprom):04X} (0xFF padding) ")
break
else: else:
consecutive_ff = 0 break
print(f' PLL data likely starts at: 0x{start:04X}')
# Dump the PLL blocks
print(f' PLL block dump:')
for baddr in range(start, c['sentinel_addr'] + 20, 20):
block = eeprom_read(baddr, 20)
cnt = block[0]
if cnt == 0:
print(f' 0x{baddr:04X}: [sentinel count=0]')
break
ab = block[4:4 + cnt]
print(f' 0x{baddr:04X}: count={cnt} A9=0x{block[1]:02X} '
f'AA=0x{block[2]:02X} unused=0x{block[3]:02X} '
f'AB=[{ab.hex(" ")}]')
else:
print(' No PLL sentinel found in first 16KB!')
print(' Dumping any 20-byte-aligned blocks with count 1-16:')
for addr in range(0, min(eeprom_size, 0x1000), 20):
data = eeprom_read(addr, 20)
count = data[0]
if 1 <= count <= 16:
ab = data[4:4 + count]
print(f' 0x{addr:04X}: count={count} A9=0x{data[1]:02X} '
f'AA=0x{data[2]:02X} unused=0x{data[3]:02X} '
f'AB=[{ab.hex(" ")}]')
print()
if offset % 1024 == 0: # Step 4: Dump around the 16KB boundary (where our code expects PLL data)
print(f"\r 0x{offset:04X} / 0x{args.max_size:04X} ", end="", flush=True) if eeprom_size > 16384:
print('--- EEPROM 0x3FE0-0x4060 (16KB boundary) ---')
for addr in range(0x3FE0, 0x4060, 64):
data = eeprom_read(addr, 64)
hex_dump(addr, data)
print()
print(f"\r Read {len(eeprom)} bytes total ") # Step 5: Check for 0xFF regions (empty/erased)
print('--- Empty region scan ---')
last_was_ff = False
for addr in range(0, min(eeprom_size, 0x4000), 64):
data = eeprom_read(addr, 64)
is_ff = all(b == 0xFF for b in data)
if is_ff and not last_was_ff:
print(f' 0xFF starts at 0x{addr:04X}')
last_was_ff = True
elif not is_ff and last_was_ff:
print(f' Data resumes at 0x{addr:04X}')
last_was_ff = False
if last_was_ff:
print(f' 0xFF continues to end of scanned region')
# Save raw EEPROM sw.close()
with open(args.output, 'wb') as f: print()
f.write(eeprom) print('=== Done ===')
print(f" Saved raw EEPROM to: {args.output}")
# Parse header
print(f"\n{'=' * 40}")
print("EEPROM Header:")
header = parse_c2_header(eeprom)
if header:
# Parse load records
print(f"\nLoad Records:")
records = parse_records(eeprom)
total_code = 0
entry_point = None
for i, rec in enumerate(records):
if rec["type"] == "data":
end_addr = rec["load_addr"] + rec["length"] - 1
preview = rec["data"][:8].hex(' ')
print(f" [{i}] {rec['length']:5d} bytes -> "
f"0x{rec['load_addr']:04X}-0x{end_addr:04X} "
f"[{preview}...]")
total_code += rec["length"]
elif rec["type"] == "end":
entry_point = rec["entry_point"]
print(f" [{i}] END MARKER -> entry point: 0x{entry_point:04X}")
else:
print(f" [{i}] INVALID (raw_len=0x{rec['raw_len']:04X}) "
f"at EEPROM offset 0x{rec['offset']:04X}")
print(f"\n Total firmware: {total_code} bytes in "
f"{sum(1 for r in records if r['type'] == 'data')} records")
if entry_point:
print(f" Entry point: 0x{entry_point:04X} (LJMP target after boot)")
# Extract flat binary
if args.extract and records:
# Build memory image
mem = bytearray(0x10000) # 64KB address space
for b in range(len(mem)):
mem[b] = 0xFF
for rec in records:
if rec["type"] == "data":
addr = rec["load_addr"]
mem[addr:addr + rec["length"]] = rec["data"]
# Find actual used range
min_addr = min(r["load_addr"] for r in records if r["type"] == "data")
max_addr = max(r["load_addr"] + r["length"]
for r in records if r["type"] == "data")
flat_file = args.output.replace('.bin', '_flat.bin')
with open(flat_file, 'wb') as f:
f.write(mem[min_addr:max_addr])
print(f"\n Flat binary: {flat_file}")
print(f" Address range: 0x{min_addr:04X}-0x{max_addr:04X} "
f"({max_addr - min_addr} bytes)")
# Also save full 64KB image for Ghidra
full_file = args.output.replace('.bin', '_full64k.bin')
with open(full_file, 'wb') as f:
f.write(mem)
print(f" Full 64K image: {full_file} (for Ghidra, load at 0x0000)")
finally:
if intf is not None:
try:
usb.util.release_interface(dev, intf)
dev.attach_kernel_driver(intf)
print("\nRe-attached kernel driver")
except:
print("\nNote: run 'sudo modprobe dvb_usb_gp8psk' to reload")
if __name__ == '__main__':
main()

736
tools/eeprom_flash_a0.py Normal file
View File

@ -0,0 +1,736 @@
#!/usr/bin/env python3
"""
EEPROM flash via host-side I2C orchestration (boot ROM 0xA0).
Writes C2 firmware images to the SkyWalker-1 EEPROM by driving the
FX2LP's I2C controller from the host while the CPU is halted.
Background:
- Stock firmware I2C proxy (0x83/0x84) returns pipe errors
- Custom firmware via RAM: I2CS BERR (0xF6) on CPUCS restart
- Pre-halt I2C flush (0x90) + halt I2CS = 0x01 (clean/idle)
- Boot ROM 0xA0 can read/write I2C registers at 0xE678-0xE67A
- We drive the I2C controller from the host, one register write at
a time, to program the EEPROM
Strategy:
1. Pre-halt flush: GET_SIGNAL_LOCK (0x90) finishes stock firmware I2C
2. Halt CPU: CPUCS = 0x01 (I2C controller stays idle)
3. Write I2C registers via 0xA0 to orchestrate EEPROM write
4. Power-cycle to boot from new EEPROM image
"""
import usb.core, usb.util, sys, time, os, subprocess, argparse
# USB IDs
SKYWALKER_VID = 0x09C0
SKYWALKER_PID = 0x0203
# FX2LP register addresses (XDATA space)
CPUCS_ADDR = 0xE600
I2CS_ADDR = 0xE678
I2DAT_ADDR = 0xE679
I2CTL_ADDR = 0xE67A
# I2CS bit masks
bmSTART = 0x80
bmSTOP = 0x40
bmLASTRD = 0x20
bmID1 = 0x10
bmID0 = 0x08
bmBERR = 0x04
bmACK = 0x02
bmDONE = 0x01
# EEPROM parameters (24C128)
EEPROM_I2C_ADDR = 0x51 # 7-bit address
EEPROM_PAGE_SIZE = 64 # bytes per page write
EEPROM_SIZE = 16384 # 16KB total
EEPROM_WRITE_MS = 5 # max internal write cycle time
# Boot ROM vendor request
A0_REQUEST = 0xA0
def i2cs_str(val):
"""Human-readable I2CS register decode."""
flags = []
if val & bmSTART: flags.append('START')
if val & bmSTOP: flags.append('STOP')
if val & bmLASTRD: flags.append('LASTRD')
if val & bmBERR: flags.append('BERR')
if val & bmACK: flags.append('ACK')
if val & bmDONE: flags.append('DONE')
id_val = (val >> 3) & 0x03
state = {0: 'idle', 1: 'data', 2: 'addr-wait', 3: 'busy'}[id_val]
return f"0x{val:02X} [{' '.join(flags) if flags else 'clear'}] state={state}"
def find_device():
"""Find SkyWalker-1 on USB."""
dev = usb.core.find(idVendor=SKYWALKER_VID, idProduct=SKYWALKER_PID)
if dev is None:
print("ERROR: SkyWalker-1 not found on USB")
sys.exit(1)
print(f" Device: Bus {dev.bus} Addr {dev.address}")
return dev
def detach_driver(dev):
"""Detach kernel driver if attached."""
for cfg in dev:
for intf in cfg:
if dev.is_kernel_driver_active(intf.bInterfaceNumber):
try:
dev.detach_kernel_driver(intf.bInterfaceNumber)
except usb.core.USBError as e:
print(f" Cannot detach driver: {e}")
print(" Try: sudo modprobe -r dvb_usb_gp8psk")
sys.exit(1)
try:
dev.set_configuration()
except:
pass
# ── FX2 register access via 0xA0 ─────────────────────────────────
def a0_read(dev, addr, length=1):
"""Read from FX2 XDATA address space via boot ROM 0xA0."""
return bytes(dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
A0_REQUEST, addr, 0, length, 2000))
def a0_write(dev, addr, data):
"""Write to FX2 XDATA address space via boot ROM 0xA0."""
if isinstance(data, int):
data = bytes([data])
dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_OUT,
A0_REQUEST, addr, 0, data, 2000)
# ── Host-side I2C primitives ─────────────────────────────────────
def i2c_read_status(dev):
"""Read I2CS register."""
return a0_read(dev, I2CS_ADDR, 1)[0]
def i2c_wait_done(dev, timeout_ms=50):
"""Poll I2CS for DONE bit. Returns (success, i2cs_value)."""
deadline = time.time() + timeout_ms / 1000.0
while time.time() < deadline:
val = i2c_read_status(dev)
if val & bmDONE:
return True, val
if val & bmBERR:
return False, val
time.sleep(0.001) # 1ms between polls
return False, val
def i2c_start(dev):
"""Assert I2C START condition."""
a0_write(dev, I2CS_ADDR, bmSTART)
def i2c_stop(dev):
"""Assert I2C STOP condition and wait for completion."""
a0_write(dev, I2CS_ADDR, bmSTOP)
deadline = time.time() + 0.050
while time.time() < deadline:
val = i2c_read_status(dev)
if not (val & bmSTOP):
return True, val
time.sleep(0.001)
return False, val
def i2c_write_byte(dev, byte_val):
"""Write a byte to I2DAT, wait for DONE. Returns (ack, i2cs)."""
a0_write(dev, I2DAT_ADDR, byte_val)
ok, status = i2c_wait_done(dev)
if not ok:
return False, status
return bool(status & bmACK), status
def i2c_read_byte(dev, last=False):
"""Read a byte from I2DAT. Set last=True for NACK (last byte)."""
if last:
a0_write(dev, I2CS_ADDR, bmLASTRD)
# Reading I2DAT triggers the next SCL clock cycle
val = a0_read(dev, I2DAT_ADDR, 1)[0]
ok, status = i2c_wait_done(dev)
return val, ok, status
# ── Higher-level I2C operations ──────────────────────────────────
def i2c_probe(dev, addr_7bit):
"""Probe an I2C device. Returns True if it ACKs its address."""
i2c_start(dev)
ack, status = i2c_write_byte(dev, addr_7bit << 1)
i2c_stop(dev)
return ack
def eeprom_write_page(dev, mem_addr, data):
"""Write up to EEPROM_PAGE_SIZE bytes to EEPROM at mem_addr.
EEPROM protocol: START + slave_W + addr_H + addr_L + data... + STOP
Then wait for internal write cycle (ACK polling or fixed delay).
"""
if len(data) > EEPROM_PAGE_SIZE:
raise ValueError(f"Page write max {EEPROM_PAGE_SIZE} bytes, got {len(data)}")
addr_h = (mem_addr >> 8) & 0xFF
addr_l = mem_addr & 0xFF
# START
i2c_start(dev)
# Slave address (write)
ack, status = i2c_write_byte(dev, EEPROM_I2C_ADDR << 1)
if not ack:
i2c_stop(dev)
return False, "no ACK on slave address"
# Memory address high byte
ack, status = i2c_write_byte(dev, addr_h)
if not ack:
i2c_stop(dev)
return False, "no ACK on addr_H"
# Memory address low byte
ack, status = i2c_write_byte(dev, addr_l)
if not ack:
i2c_stop(dev)
return False, "no ACK on addr_L"
# Data bytes
for i, byte_val in enumerate(data):
ack, status = i2c_write_byte(dev, byte_val)
if not ack:
i2c_stop(dev)
return False, f"no ACK on data byte {i} (0x{byte_val:02X})"
# STOP (initiates EEPROM internal write cycle)
i2c_stop(dev)
# Wait for write cycle via ACK polling
# The EEPROM NACKs its address during the write cycle, then ACKs
# when the cycle completes. Timeout after 20ms.
deadline = time.time() + 0.020
while time.time() < deadline:
time.sleep(0.001)
i2c_start(dev)
ack, status = i2c_write_byte(dev, EEPROM_I2C_ADDR << 1)
if ack:
i2c_stop(dev)
return True, "ok"
i2c_stop(dev)
return False, "write cycle timeout (no ACK after 20ms)"
def eeprom_read_bytes(dev, mem_addr, length):
"""Read bytes from EEPROM.
Protocol: START + slave_W + addr_H + addr_L +
rSTART + slave_R + data[0] ... data[n-1] + NACK + STOP
"""
addr_h = (mem_addr >> 8) & 0xFF
addr_l = mem_addr & 0xFF
# Write phase: set address pointer
i2c_start(dev)
ack, status = i2c_write_byte(dev, EEPROM_I2C_ADDR << 1)
if not ack:
i2c_stop(dev)
return None, "no ACK on slave address (write phase)"
ack, status = i2c_write_byte(dev, addr_h)
if not ack:
i2c_stop(dev)
return None, "no ACK on addr_H"
ack, status = i2c_write_byte(dev, addr_l)
if not ack:
i2c_stop(dev)
return None, "no ACK on addr_L"
# Read phase: repeated START + slave_R
i2c_start(dev)
ack, status = i2c_write_byte(dev, (EEPROM_I2C_ADDR << 1) | 1)
if not ack:
i2c_stop(dev)
return None, "no ACK on slave address (read phase)"
# Read data bytes
result = bytearray()
for i in range(length):
is_last = (i == length - 1)
val, ok, status = i2c_read_byte(dev, last=is_last)
if not ok:
i2c_stop(dev)
return bytes(result), f"read failed at byte {i}"
result.append(val)
# Dummy read to complete the last byte cycle
_ = a0_read(dev, I2DAT_ADDR, 1)
# STOP
i2c_stop(dev)
return bytes(result), "ok"
# ── Device preparation ───────────────────────────────────────────
def prepare_device(dev, verbose=False):
"""Pre-halt flush, halt CPU, verify I2C controller is clean.
Returns True if I2C controller is ready for host-side operations.
"""
print("\n Phase 1: Prepare I2C controller")
print(" " + "" * 40)
# Step 1: Pre-halt I2C flush
print(" Sending GET_SIGNAL_LOCK (0x90) to flush I2C...")
try:
lock = dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
0x90, 0, 0, 1, 2000)
print(f" Response: 0x{lock[0]:02X}")
except usb.core.USBError as e:
print(f" 0x90 failed: {e}")
# Try alternative flush commands
try:
cfg = dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
0x80, 0, 0, 1, 2000)
print(f" Fallback 0x80 response: 0x{cfg[0]:02X}")
except usb.core.USBError:
print(" No flush commands worked — proceeding anyway")
# Step 2: Halt CPU
print(" Halting CPU (CPUCS = 0x01)...")
a0_write(dev, CPUCS_ADDR, 0x01)
time.sleep(0.010)
cpucs = a0_read(dev, CPUCS_ADDR, 1)[0]
if not (cpucs & 0x01):
print(f" CPUCS readback: 0x{cpucs:02X} — halt may have failed")
return False
print(f" CPU halted (CPUCS = 0x{cpucs:02X})")
# Step 3: Read I2C controller state
i2cs = i2c_read_status(dev)
i2ctl = a0_read(dev, I2CTL_ADDR, 1)[0]
print(f" I2CS: {i2cs_str(i2cs)}")
print(f" I2CTL: 0x{i2ctl:02X} ({'400kHz' if i2ctl & 1 else '100kHz'})")
if i2cs & bmBERR:
print(" BERR is set — I2C controller is stuck")
print(" This usually means the CPU was restarted after halt")
return False
# Step 4: Set I2C speed to 400kHz
if not (i2ctl & 0x01):
print(" Setting I2CTL = 0x01 (400kHz)...")
a0_write(dev, I2CTL_ADDR, 0x01)
i2ctl2 = a0_read(dev, I2CTL_ADDR, 1)[0]
print(f" I2CTL readback: 0x{i2ctl2:02X}")
# Step 5: Quick I2C bus probe
print("\n Phase 2: I2C bus probe")
print(" " + "" * 40)
addrs_to_probe = [
(0x51, "EEPROM (24C128)"),
(0x50, "EEPROM alt addr"),
(0x08, "BCM4500 demod"),
(0x10, "BCM3440 tuner"),
]
found_eeprom = False
for addr, name in addrs_to_probe:
ack = i2c_probe(dev, addr)
status_after = i2c_read_status(dev)
result = "ACK" if ack else "NACK"
marker = " <--" if ack and addr in (0x50, 0x51) else ""
print(f" 0x{addr:02X} {name:20s}: {result:4s} "
f"(I2CS={i2cs_str(status_after)}){marker}")
if ack and addr in (0x50, 0x51):
found_eeprom = True
# Check for BERR after each probe
if status_after & bmBERR:
print(f" BERR after probe — host-side I2C may not work")
return False
if verbose:
# Read a few bytes from EEPROM to verify reads work
if found_eeprom:
print("\n Phase 2b: EEPROM read test")
print(" " + "" * 40)
data, msg = eeprom_read_bytes(dev, 0x0000, 8)
if data:
print(f" EEPROM[0x0000..0x0007]: {data.hex(' ')}")
if data[0] == 0xC2:
print(f" First byte is 0xC2 — valid C2 boot header!")
else:
print(f" First byte is 0x{data[0]:02X} — not a C2 header")
else:
print(f" Read failed: {msg}")
return False
if not found_eeprom:
print(" No EEPROM found at 0x50 or 0x51")
return False
print(f"\n I2C controller ready for EEPROM operations")
return True
# ── Subcommands ──────────────────────────────────────────────────
def cmd_probe(args):
"""Test host-side I2C by probing the bus."""
print("SkyWalker-1 Host-Side I2C Probe (0xA0)")
print("=" * 45)
dev = find_device()
detach_driver(dev)
ok = prepare_device(dev, verbose=True)
if not ok:
print("\n FAIL: Host-side I2C does not work")
print(" The 0xA0 vendor request may not trigger I2C hardware,")
print(" or the I2C bus is in a bad state.")
sys.exit(1)
else:
print("\n SUCCESS: Host-side I2C is working!")
print(" EEPROM detected and readable via boot ROM 0xA0")
def cmd_read(args):
"""Read EEPROM contents via host-side I2C."""
size = args.size
print("SkyWalker-1 EEPROM Read (host-side I2C)")
print("=" * 45)
dev = find_device()
detach_driver(dev)
ok = prepare_device(dev, verbose=False)
if not ok:
print("\n FAIL: Cannot prepare I2C")
sys.exit(1)
print(f"\n Reading {size} bytes from EEPROM...")
chunk_size = 32 # read in small chunks (each byte = 2+ USB transfers)
data = bytearray()
errors = 0
for offset in range(0, size, chunk_size):
remaining = min(chunk_size, size - offset)
chunk, msg = eeprom_read_bytes(dev, offset, remaining)
if chunk is None:
print(f"\n Read failed at 0x{offset:04X}: {msg}")
errors += 1
data.extend(b'\xff' * remaining)
else:
data.extend(chunk)
if offset % 256 == 0 or offset + remaining >= size:
pct = (offset + remaining) * 100 // size
print(f"\r Read: 0x{offset + remaining:04X}/{size:04X} [{pct:3d}%]",
end="", flush=True)
print()
if errors:
print(f"\n {errors} read error(s)")
# Hex dump
if args.hex_dump:
print(f"\n EEPROM contents (first {min(256, len(data))} bytes):")
for i in range(0, min(256, len(data)), 16):
row = data[i:i + 16]
hex_part = ' '.join(f'{b:02X}' for b in row)
ascii_part = ''.join(
chr(b) if 0x20 <= b < 0x7F else '.' for b in row)
print(f" {i:04X}: {hex_part:<48s} {ascii_part}")
# Check C2 header
if len(data) >= 8 and data[0] == 0xC2:
vid = data[2] << 8 | data[1]
pid = data[4] << 8 | data[3]
print(f"\n C2 header: VID=0x{vid:04X} PID=0x{pid:04X} "
f"CONFIG=0x{data[7]:02X}")
if args.output:
with open(args.output, 'wb') as f:
f.write(data)
print(f"\n Saved to: {args.output}")
def cmd_write(args):
"""Write a C2 firmware image to EEPROM via host-side I2C."""
image_path = args.file
if not os.path.exists(image_path):
print(f"File not found: {image_path}")
sys.exit(1)
with open(image_path, 'rb') as f:
image = f.read()
print("SkyWalker-1 EEPROM Flash (host-side I2C)")
print("=" * 45)
# Validate image
if len(image) < 8 or image[0] != 0xC2:
print(f" Not a C2 image (first byte: 0x{image[0]:02X})")
sys.exit(1)
if len(image) > EEPROM_SIZE:
print(f" Image too large: {len(image)} > {EEPROM_SIZE}")
sys.exit(1)
vid = image[2] << 8 | image[1]
pid = image[4] << 8 | image[3]
config = image[7]
print(f" Image: {image_path}")
print(f" Size: {len(image)} bytes ({len(image)*100//EEPROM_SIZE}% of EEPROM)")
print(f" VID: 0x{vid:04X} PID: 0x{pid:04X} CONFIG: 0x{config:02X}")
dev = find_device()
detach_driver(dev)
ok = prepare_device(dev, verbose=True)
if not ok:
print("\n FAIL: Cannot prepare I2C — aborting")
sys.exit(1)
# Backup current EEPROM first (just the header to be safe)
if not args.no_backup:
print(f"\n Backing up EEPROM header...")
hdr, msg = eeprom_read_bytes(dev, 0x0000, 8)
if hdr:
print(f" Current header: {hdr.hex(' ')}")
if hdr[0] == 0xC2:
cur_vid = hdr[2] << 8 | hdr[1]
cur_pid = hdr[4] << 8 | hdr[3]
print(f" Current: VID=0x{cur_vid:04X} PID=0x{cur_pid:04X}")
else:
print(f" Header read failed: {msg}")
if not args.force:
print(" Use --force to proceed without backup verification")
sys.exit(1)
# Dry run?
if args.dry_run:
pages = (len(image) + EEPROM_PAGE_SIZE - 1) // EEPROM_PAGE_SIZE
print(f"\n DRY RUN: would write {len(image)} bytes in {pages} pages")
return
# Write confirmation
print(f"\n *** WRITING {len(image)} BYTES TO EEPROM ***")
print(f" This replaces the boot firmware. A bad write = bricked device.")
print(f" Press Ctrl+C within 3 seconds to abort.")
try:
for i in range(3, 0, -1):
print(f"\r Starting in {i}... ", end="", flush=True)
time.sleep(1)
print("\r Writing now... ")
except KeyboardInterrupt:
print("\n Aborted.")
return
# Write image in page-sized chunks
total_pages = (len(image) + EEPROM_PAGE_SIZE - 1) // EEPROM_PAGE_SIZE
write_errors = 0
start_time = time.time()
for page_num in range(total_pages):
offset = page_num * EEPROM_PAGE_SIZE
end = min(offset + EEPROM_PAGE_SIZE, len(image))
chunk = image[offset:end]
pct = (page_num + 1) * 100 // total_pages
elapsed = time.time() - start_time
rate = (offset + len(chunk)) / elapsed if elapsed > 0 else 0
eta = (len(image) - offset - len(chunk)) / rate if rate > 0 else 0
print(f"\r Write: 0x{offset:04X}/0x{len(image):04X} "
f"[{pct:3d}%] {rate:.0f} B/s ETA {eta:.0f}s ",
end="", flush=True)
ok, msg = eeprom_write_page(dev, offset, chunk)
if not ok:
print(f"\n Write error at 0x{offset:04X}: {msg}")
write_errors += 1
if write_errors >= 3:
print("\n Too many errors — aborting")
print(" *** EEPROM STATE UNKNOWN ***")
sys.exit(1)
elapsed = time.time() - start_time
print(f"\r Write: 0x{len(image):04X}/0x{len(image):04X} "
f"[100%] done in {elapsed:.1f}s ")
if write_errors:
print(f"\n WARNING: {write_errors} write error(s)")
# Verify
print(f"\n Verifying ({len(image)} bytes)...")
verify_chunk_size = 32
mismatches = 0
first_mismatch = None
for offset in range(0, len(image), verify_chunk_size):
remaining = min(verify_chunk_size, len(image) - offset)
chunk, msg = eeprom_read_bytes(dev, offset, remaining)
if chunk is None:
print(f"\n Verify read failed at 0x{offset:04X}: {msg}")
mismatches += remaining
continue
for i, (expected, got) in enumerate(zip(image[offset:offset+remaining], chunk)):
if expected != got:
if first_mismatch is None:
first_mismatch = offset + i
mismatches += 1
if mismatches <= 8:
print(f"\n Mismatch at 0x{offset+i:04X}: "
f"wrote 0x{expected:02X} read 0x{got:02X}")
if offset % 256 == 0 or offset + remaining >= len(image):
pct = (offset + remaining) * 100 // len(image)
print(f"\r Verify: 0x{offset+remaining:04X}/0x{len(image):04X} "
f"[{pct:3d}%]", end="", flush=True)
print()
if mismatches == 0:
print(f"\n VERIFIED: all {len(image)} bytes match")
print(f" Flash complete in {elapsed:.1f}s")
print(f"\n Power-cycle the device to boot the new firmware.")
if args.power_cycle:
do_power_cycle()
else:
print(f"\n VERIFY FAILED: {mismatches} byte(s) differ "
f"(first at 0x{first_mismatch:04X})")
if mismatches > 8:
print(f" ({mismatches - 8} additional mismatches not shown)")
print(f"\n *** DO NOT POWER CYCLE ***")
print(f" Re-run this tool to retry, or use an external programmer.")
sys.exit(1)
def do_power_cycle():
"""Power-cycle SkyWalker-1 via uhubctl."""
print(f"\n Power-cycling via uhubctl...")
try:
# Off
r = subprocess.run(
['sudo', 'uhubctl', '-l', '1-5.4.4', '-p', '3', '-a', 'off'],
capture_output=True, text=True, timeout=10)
if r.returncode != 0:
print(f" uhubctl off failed: {r.stderr.strip()}")
print(f" Manually unplug and replug the device.")
return
print(f" Port 3 powered off")
time.sleep(2)
# On
r = subprocess.run(
['sudo', 'uhubctl', '-l', '1-5.4.4', '-p', '3', '-a', 'on'],
capture_output=True, text=True, timeout=10)
if r.returncode != 0:
print(f" uhubctl on failed: {r.stderr.strip()}")
return
print(f" Port 3 powered on")
print(f" Waiting for device to enumerate...")
time.sleep(3)
dev = usb.core.find(idVendor=SKYWALKER_VID, idProduct=SKYWALKER_PID)
if dev:
print(f" Device found: Bus {dev.bus} Addr {dev.address}")
print(f" New firmware is running!")
else:
print(f" Device not found yet — may need a few more seconds")
except FileNotFoundError:
print(f" uhubctl not found — manually power-cycle the device")
except subprocess.TimeoutExpired:
print(f" uhubctl timed out")
def cmd_power_cycle(args):
"""Power-cycle the SkyWalker-1 via uhubctl."""
print("SkyWalker-1 Power Cycle")
print("=" * 45)
do_power_cycle()
def main():
parser = argparse.ArgumentParser(
description="SkyWalker-1 EEPROM flash via host-side I2C (boot ROM 0xA0)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
This tool drives the FX2LP's I2C controller from the host while the
CPU is halted. It works around the stock firmware's broken I2C proxy
and the BERR bug triggered by CPUCS restart.
examples:
%(prog)s probe # test if host-side I2C works
%(prog)s read -o backup.bin # backup current EEPROM
%(prog)s write firmware_eeprom.bin # flash new C2 image
%(prog)s write firmware_eeprom.bin -P # flash + auto power-cycle
%(prog)s power-cycle # just power-cycle the device
""")
sub = parser.add_subparsers(dest='command', required=True)
# probe
sub.add_parser('probe', help='Test host-side I2C bus access')
# read
p_read = sub.add_parser('read', help='Read EEPROM contents')
p_read.add_argument('-o', '--output', help='Save to file')
p_read.add_argument('--size', type=int, default=EEPROM_SIZE,
help=f'Bytes to read (default: {EEPROM_SIZE})')
p_read.add_argument('--hex', dest='hex_dump', action='store_true',
help='Show hex dump')
# write
p_write = sub.add_parser('write', help='Flash C2 image to EEPROM')
p_write.add_argument('file', help='C2 firmware image (.bin)')
p_write.add_argument('--dry-run', action='store_true',
help='Show what would happen without writing')
p_write.add_argument('--no-backup', action='store_true',
help='Skip header backup check')
p_write.add_argument('--force', action='store_true',
help='Continue despite warnings')
p_write.add_argument('-P', '--power-cycle', action='store_true',
help='Auto power-cycle after successful flash')
# power-cycle
sub.add_parser('power-cycle', help='Power-cycle via uhubctl')
args = parser.parse_args()
dispatch = {
'probe': cmd_probe,
'read': cmd_read,
'write': cmd_write,
'power-cycle': cmd_power_cycle,
}
dispatch[args.command](args)
if __name__ == '__main__':
main()

108
tools/eeprom_pll_find.py Normal file
View File

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Find the exact EEPROM address where PLL config starts.
The stock firmware's FUN_CODE_10F2 presumably finds the PLL data by
computing an offset from the C2 boot firmware structure. This script
parses the C2 records to find where the firmware ends, then checks
if PLL data immediately follows.
"""
import sys
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_EEPROM_READ = 0xC0
sw = SkyWalker1()
sw.open()
def eeprom_read(addr, length):
return sw._vendor_in(CMD_EEPROM_READ, value=addr, index=length, length=length)
print('=== C2 Firmware Structure Analysis ===')
print()
# Read C2 header (8 bytes)
header = eeprom_read(0x0000, 8)
print(f'Header: {header.hex(" ")}')
assert header[0] == 0xC2, 'Not a C2 EEPROM!'
vid = header[2] << 8 | header[1]
pid = header[4] << 8 | header[3]
print(f' VID: 0x{vid:04X} PID: 0x{pid:04X}')
print()
# Parse C2 load records
offset = 8
records = []
print('C2 Load Records:')
while offset < 0x8000:
hdr = eeprom_read(offset, 4)
rec_len = (hdr[0] << 8) | hdr[1]
rec_addr = (hdr[2] << 8) | hdr[3]
if rec_len == 0x8001:
print(f' [{len(records)}] END MARKER at EEPROM 0x{offset:04X} → entry=0x{rec_addr:04X}')
records.append({'type': 'end', 'offset': offset, 'entry': rec_addr})
offset += 4
break
elif rec_len == 0 or rec_len > 0x4000:
print(f' [{len(records)}] INVALID at EEPROM 0x{offset:04X}: len=0x{rec_len:04X}')
records.append({'type': 'invalid', 'offset': offset})
offset += 4
break
end_addr = rec_addr + rec_len - 1
print(f' [{len(records)}] {rec_len:5d} bytes at EEPROM 0x{offset:04X} → RAM 0x{rec_addr:04X}-0x{end_addr:04X}')
records.append({'type': 'data', 'offset': offset, 'len': rec_len, 'addr': rec_addr})
offset += 4 + rec_len
print(f'\nFirmware ends at EEPROM offset: 0x{offset:04X}')
print()
# Check what's right after the firmware
print(f'--- Data immediately after firmware (0x{offset:04X}) ---')
for addr in range(offset, offset + 128, 16):
data = eeprom_read(addr, 16)
hex_str = ' '.join(f'{b:02X}' for b in data)
print(f' {addr:04X}: {hex_str}')
print()
# Now check: does PLL data start right after the C2 firmware?
print(f'--- PLL block check starting at 0x{offset:04X} ---')
for addr in range(offset, offset + 200, 20):
block = eeprom_read(addr, 20)
count = block[0]
if count == 0:
print(f' 0x{addr:04X}: [sentinel count=0]')
break
elif 1 <= count <= 16:
ab = block[4:4 + count]
print(f' 0x{addr:04X}: count={count} A9=0x{block[1]:02X} AA=0x{block[2]:02X} '
f'unused=0x{block[3]:02X} AB=[{ab.hex(" ")}]')
else:
print(f' 0x{addr:04X}: count=0x{count:02X} (invalid, not PLL data)')
break
print()
# Also check the known PLL location from the scan
print('--- Confirmed PLL data at 0x125C (from EEPROM scan) ---')
for addr in range(0x125C, 0x12C0, 20):
block = eeprom_read(addr, 20)
count = block[0]
if count == 0:
print(f' 0x{addr:04X}: [sentinel count=0]')
break
elif 1 <= count <= 16:
ab = block[4:4 + count]
print(f' 0x{addr:04X}: count={count} A9=0x{block[1]:02X} AA=0x{block[2]:02X} '
f'unused=0x{block[3]:02X} AB=[{ab.hex(" ")}]')
else:
print(f' 0x{addr:04X}: NOT PLL (count=0x{count:02X})')
break
sw.close()
print()
print('=== Done ===')

View File

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Find the count=0 sentinel in the BCM4500 firmware data at 0x4000+."""
import sys
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_EEPROM_READ = 0xC0
sw = SkyWalker1()
sw.open()
def ee(addr, length):
return sw._vendor_in(CMD_EEPROM_READ, value=addr, index=length, length=length)
print('=== Scanning for count=0 sentinel from 0x4000 ===')
print()
blocks = 0
total_bytes = 0
addr = 0x4000
while addr < 0x8000:
data = ee(addr, 20)
count = data[0]
if count == 0:
print(f' SENTINEL FOUND at 0x{addr:04X} after {blocks} blocks ({total_bytes} payload bytes)')
break
if count > 16:
print(f' INVALID count=0x{count:02X} at 0x{addr:04X} after {blocks} blocks')
# Show context
for ctx_addr in range(max(0x4000, addr - 60), addr + 60, 20):
d = ee(ctx_addr, 20)
marker = ' ← INVALID' if ctx_addr == addr else ''
print(f' 0x{ctx_addr:04X}: count={d[0]:3d} A9=0x{d[1]:02X} AA=0x{d[2]:02X}{marker}')
break
# Valid block
a9 = data[1]
aa = data[2]
ab_bytes = count
total_bytes += ab_bytes
if blocks < 5 or blocks % 100 == 0:
ab = data[4:4 + min(count, 8)]
print(f' Block {blocks:4d} @ 0x{addr:04X}: count={count:2d} A9=0x{a9:02X} AA=0x{aa:02X} '
f'AB=[{ab.hex(" ")}{"..." if count > 8 else ""}]')
blocks += 1
addr += 20
else:
print(f' NO SENTINEL found before 0x8000 ({blocks} blocks scanned)')
print()
print(f'Summary: {blocks} blocks, {total_bytes} payload bytes')
print(f'Address range: 0x4000 - 0x{addr:04X}')
# Show the sentinel and what follows
if addr < 0x8000:
print()
print(f'--- Data around sentinel at 0x{addr:04X} ---')
for a in range(max(0x4000, addr - 40), addr + 60, 20):
d = ee(a, 20)
cnt = d[0]
if cnt == 0:
print(f' 0x{a:04X}: [SENTINEL count=0] rest: {d[1:].hex(" ")}')
elif 1 <= cnt <= 16:
print(f' 0x{a:04X}: count={cnt} A9=0x{d[1]:02X} AA=0x{d[2]:02X}')
else:
print(f' 0x{a:04X}: [non-PLL: 0x{cnt:02X}] {d[:16].hex(" ")}')
sw.close()
print()
print('=== Done ===')

View File

@ -33,6 +33,132 @@ WRITE_CYCLE_MS = 10 # Max internal write cycle time per page
MAX_EEPROM_SIZE = 16384 # 128Kbit / 16KB max addressable via wIndex MAX_EEPROM_SIZE = 16384 # 128Kbit / 16KB max addressable via wIndex
# -- Intel HEX parser (shared with fw_load.py) --
def parse_ihx(data):
"""Parse an Intel HEX file. Returns list of (address, bytes) segments."""
segments = []
base_addr = 0
for raw_line in data.splitlines():
line = raw_line.strip()
if not line:
continue
if isinstance(line, bytes):
line = line.decode('ascii', errors='replace')
if not line.startswith(':'):
continue
hex_str = line[1:]
if len(hex_str) < 10:
continue
try:
raw = bytes.fromhex(hex_str)
except ValueError:
continue
byte_count = raw[0]
addr = (raw[1] << 8) | raw[2]
rec_type = raw[3]
rec_data = raw[4:4 + byte_count]
if rec_type == 0x00:
full_addr = base_addr + addr
segments.append((full_addr, bytes(rec_data)))
elif rec_type == 0x01:
break
elif rec_type == 0x02:
base_addr = ((rec_data[0] << 8) | rec_data[1]) << 4
elif rec_type == 0x04:
base_addr = ((rec_data[0] << 8) | rec_data[1]) << 16
return segments
def coalesce_segments(segments):
"""Merge adjacent/overlapping segments into contiguous blocks."""
if not segments:
return []
sorted_segs = sorted(segments, key=lambda s: s[0])
merged = []
cur_addr, cur_data = sorted_segs[0]
cur_data = bytearray(cur_data)
for addr, data in sorted_segs[1:]:
cur_end = cur_addr + len(cur_data)
if addr <= cur_end:
overlap = cur_end - addr
if overlap >= 0:
cur_data.extend(data[overlap:] if overlap < len(data) else b'')
else:
merged.append((cur_addr, bytes(cur_data)))
cur_addr = addr
cur_data = bytearray(data)
merged.append((cur_addr, bytes(cur_data)))
return merged
def create_c2_image(segments, vid=0x09C0, pid=0x0203, did=0x0000, config=0x40):
"""Create a Cypress C2 EEPROM boot image from code segments.
C2 format:
Header (8 bytes): C2 VID_L VID_H PID_L PID_H DID_L DID_H CONFIG
Records: LEN_H LEN_L ADDR_H ADDR_L DATA[LEN]
End: 80 01 E6 00 00 (write CPUCS=0x00 to release CPU)
CONFIG byte:
bit 6: 1 = 400kHz I2C (used during EEPROM load)
bit 2: 1 = disconnect (don't drive I2C after load)
"""
image = bytearray()
# Header
image.append(0xC2)
image.append(vid & 0xFF)
image.append((vid >> 8) & 0xFF)
image.append(pid & 0xFF)
image.append((pid >> 8) & 0xFF)
image.append(did & 0xFF)
image.append((did >> 8) & 0xFF)
image.append(config & 0xFF)
# Data records — filter out SFR region (0xE000+)
skipped = 0
for addr, data in segments:
if addr >= 0xE000:
skipped += len(data)
continue
# Truncate if segment extends into SFR region
end = addr + len(data)
if end > 0xE000:
data = data[:0xE000 - addr]
skipped += end - 0xE000
length = len(data)
if length == 0:
continue
# Split large segments (boot ROM may have record size limits)
chunk_max = 1023 # conservative limit
offset = 0
while offset < length:
chunk_len = min(chunk_max, length - offset)
chunk_addr = addr + offset
image.append((chunk_len >> 8) & 0xFF)
image.append(chunk_len & 0xFF)
image.append((chunk_addr >> 8) & 0xFF)
image.append(chunk_addr & 0xFF)
image.extend(data[offset:offset + chunk_len])
offset += chunk_len
# End marker: write 0x00 to CPUCS (0xE600)
image.extend([0x80, 0x01, 0xE6, 0x00, 0x00])
return bytes(image), skipped
def find_device(): def find_device():
dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID) dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
if dev is None: if dev is None:
@ -353,6 +479,72 @@ def cmd_verify(args):
print("\nNote: run 'sudo modprobe dvb_usb_gp8psk' to reload") print("\nNote: run 'sudo modprobe dvb_usb_gp8psk' to reload")
def cmd_convert(args):
"""Convert Intel HEX (.ihx) to Cypress C2 EEPROM format (.bin)."""
if not os.path.exists(args.file):
print(f"File not found: {args.file}")
sys.exit(1)
with open(args.file, 'rb') as f:
raw = f.read()
print("Genpix SkyWalker-1 IHX → C2 Converter")
print("=" * 40)
# Parse IHX
segments = parse_ihx(raw)
if not segments:
print(" No code segments found in IHX file")
sys.exit(1)
segments = coalesce_segments(segments)
total_code = sum(len(d) for _, d in segments)
min_addr = min(a for a, _ in segments)
max_addr = max(a + len(d) - 1 for a, d in segments)
print(f"\nInput: {args.file}")
print(f" Segments: {len(segments)}")
print(f" Code size: {total_code} bytes")
print(f" Address: 0x{min_addr:04X} - 0x{max_addr:04X}")
# Create C2 image
vid = int(args.vid, 0) if args.vid else VENDOR_ID
pid = int(args.pid, 0) if args.pid else PRODUCT_ID
did = int(args.did, 0) if args.did else 0x0000
config = int(args.config, 0) if args.config else 0x40
image, skipped = create_c2_image(segments, vid, pid, did, config)
if skipped:
print(f" Skipped: {skipped} bytes (SFR region 0xE000+)")
# Validate the image we just created
header, records = validate_c2_image(image, "generated")
if header is None:
print(" INTERNAL ERROR: generated image failed validation")
sys.exit(1)
print(f"\nOutput: {args.output}")
print(f" Image size: {len(image)} bytes")
print(f" EEPROM use: {len(image) * 100 / MAX_EEPROM_SIZE:.1f}% "
f"of {MAX_EEPROM_SIZE} bytes")
print("\nC2 Header:")
print_c2_header(header)
print("\nLoad Records:")
print_c2_records(records)
if len(image) > MAX_EEPROM_SIZE:
print(f"\n WARNING: image ({len(image)} bytes) exceeds EEPROM "
f"capacity ({MAX_EEPROM_SIZE} bytes)")
sys.exit(1)
with open(args.output, 'wb') as f:
f.write(image)
print(f"\n Written: {args.output} ({len(image)} bytes)")
print(f" Flash with: python {sys.argv[0]} flash {args.output}")
def cmd_flash(args): def cmd_flash(args):
"""Write a C2-format .bin file to the EEPROM.""" """Write a C2-format .bin file to the EEPROM."""
if not os.path.exists(args.file): if not os.path.exists(args.file):
@ -548,6 +740,21 @@ def main():
help='Compare .bin file against EEPROM') help='Compare .bin file against EEPROM')
p_verify.add_argument('file', help='C2 firmware image (.bin)') p_verify.add_argument('file', help='C2 firmware image (.bin)')
# convert
p_convert = sub.add_parser('convert',
help='Convert Intel HEX (.ihx) to C2 EEPROM format')
p_convert.add_argument('file', help='Input firmware file (.ihx or .hex)')
p_convert.add_argument('-o', '--output', default=None,
help='Output C2 image (.bin). Default: <basename>_eeprom.bin')
p_convert.add_argument('--vid', default=None,
help=f'USB VID (default: 0x{VENDOR_ID:04X})')
p_convert.add_argument('--pid', default=None,
help=f'USB PID (default: 0x{PRODUCT_ID:04X})')
p_convert.add_argument('--did', default=None,
help='USB DID (default: 0x0000)')
p_convert.add_argument('--config', default=None,
help='C2 CONFIG byte (default: 0x40 = 400kHz I2C)')
# flash # flash
p_flash = sub.add_parser('flash', p_flash = sub.add_parser('flash',
help='Write C2 firmware image to EEPROM') help='Write C2 firmware image to EEPROM')
@ -561,12 +768,19 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Default output filename for convert
if args.command == 'convert' and args.output is None:
base = os.path.splitext(args.file)[0]
args.output = base + '_eeprom.bin'
if args.command == 'info': if args.command == 'info':
cmd_info(args) cmd_info(args)
elif args.command == 'backup': elif args.command == 'backup':
cmd_backup(args) cmd_backup(args)
elif args.command == 'verify': elif args.command == 'verify':
cmd_verify(args) cmd_verify(args)
elif args.command == 'convert':
cmd_convert(args)
elif args.command == 'flash': elif args.command == 'flash':
cmd_flash(args) cmd_flash(args)

View File

@ -316,6 +316,312 @@ def write_segments(dev, segments, verbose=False):
return total return total
# -- I2C controller cleanup --
# FX2LP I2C controller XDATA registers (accessible via vendor request 0xA0)
I2CS_ADDR = 0xE678 # I2C Control/Status
I2DAT_ADDR = 0xE679 # I2C Data
I2CTL_ADDR = 0xE67A # I2C Control
# I2CS bit definitions
I2CS_START = 0x80
I2CS_STOP = 0x40
I2CS_LASTRD = 0x20
I2CS_ID1 = 0x10
I2CS_ID0 = 0x08
I2CS_BERR = 0x04
I2CS_ACK = 0x02
I2CS_DONE = 0x01
def i2cs_decode(val):
"""Decode I2CS register value into human-readable string."""
flags = []
for bit, name in [(7, 'START'), (6, 'STOP'), (5, 'LASTRD'),
(4, 'ID1'), (3, 'ID0'), (2, 'BERR'),
(1, 'ACK'), (0, 'DONE')]:
if val & (1 << bit):
flags.append(name)
id_val = (val >> 3) & 0x03
state = {0: 'idle', 1: 'data', 2: 'addr-wait', 3: 'busy'}[id_val]
return f"0x{val:02X} ({' | '.join(flags) if flags else 'idle'}) [state={state}]"
def build_i2c_cleanup_stub():
"""Build a tiny 8051 stub that terminates any stuck I2C transaction.
USB vendor request 0xA0 can READ I2C registers but WRITES are ignored
by the I2C controller during CPU halt (the peripheral only recognizes
8051-initiated XDATA writes). So we need the 8051 itself to do the
cleanup.
The stub:
1. Reads I2CS
2. If BERR: clears it
3. If mid-transaction (ID bits): reads I2DAT, sends STOP
4. If residual flags: sends STOP
5. Loops up to 10 times
6. Stores diagnostics at XDATA 0x3C00-0x3C07
7. Enters infinite loop (host halts CPU after reading diagnostics)
Diagnostics layout:
0x3C00: 0xAA = stub started
0x3C01: I2CS at entry (before any cleanup)
0x3C02: I2CS after cleanup (should be 0x00)
0x3C03: iteration count (how many loops needed)
0x3C04: I2DAT value read during cleanup
0x3C05: 0xDD = stub completed successfully
"""
# 8051 machine code — hand-assembled for clarity
code = []
# 0x0000: LJMP 0x0010 (skip interrupt vector area)
code += [0x02, 0x00, 0x10]
# Pad to 0x0010
while len(code) < 0x10:
code += [0x00]
# ========== 0x0010: Main code ==========
# --- Marker: stub started (0xAA → 0x3C00) ---
code += [0x74, 0xAA] # MOV A, #0xAA
code += [0x90, 0x3C, 0x00] # MOV DPTR, #0x3C00
code += [0xF0] # MOVX @DPTR, A
# --- Read initial I2CS → 0x3C01 ---
code += [0x90, 0xE6, 0x78] # MOV DPTR, #0xE678 (I2CS)
code += [0xE0] # MOVX A, @DPTR
code += [0x90, 0x3C, 0x01] # MOV DPTR, #0x3C01
code += [0xF0] # MOVX @DPTR, A
# --- Init loop counter R0=0 ---
code += [0x78, 0x00] # MOV R0, #0
# ========== CLEANUP LOOP ==========
loop_top = len(code)
# Increment loop counter
code += [0x08] # INC R0
# Read I2CS
code += [0x90, 0xE6, 0x78] # MOV DPTR, #0xE678 (I2CS)
code += [0xE0] # MOVX A, @DPTR (A = I2CS)
# If I2CS == 0x00 (idle), jump to done
code += [0x60] # JZ done (offset filled later)
jz_done_pc = len(code)
code += [0x00] # placeholder
# Check BERR (bit 2): if set, clear it
code += [0x30, 0xE2] # JNB ACC.2, skip_berr
jnb_berr_pc = len(code)
code += [0x00] # placeholder
# Clear BERR: write 0x04 to I2CS
code += [0x74, 0x04] # MOV A, #0x04
code += [0x90, 0xE6, 0x78] # MOV DPTR, #0xE678
code += [0xF0] # MOVX @DPTR, A
# Small delay
code += [0x79, 100] # MOV R1, #100
code += [0xD9, 0xFE] # DJNZ R1, $-2
# Jump to loop check
code += [0x80] # SJMP loop_check
sjmp_check1_pc = len(code)
code += [0x00] # placeholder
skip_berr = len(code)
# Check ID bits (bits 3,4): if either set, flush I2DAT + STOP
code += [0x54, 0x18] # ANL A, #0x18 (mask ID bits)
code += [0x60] # JZ skip_id
jz_skip_id_pc = len(code)
code += [0x00] # placeholder
# Read I2DAT (flush pending data)
code += [0x90, 0xE6, 0x79] # MOV DPTR, #0xE679 (I2DAT)
code += [0xE0] # MOVX A, @DPTR
code += [0x90, 0x3C, 0x04] # MOV DPTR, #0x3C04
code += [0xF0] # MOVX @DPTR, A (save I2DAT)
# Small delay
code += [0x79, 100] # MOV R1, #100
code += [0xD9, 0xFE] # DJNZ R1, $-2
# Send STOP: write 0x40 to I2CS
code += [0x74, 0x40] # MOV A, #0x40
code += [0x90, 0xE6, 0x78] # MOV DPTR, #0xE678
code += [0xF0] # MOVX @DPTR, A
# Longer delay for STOP to complete
code += [0x7A, 10] # MOV R2, #10
code += [0x79, 250] # MOV R1, #250
code += [0xD9, 0xFE] # DJNZ R1, $-2
code += [0xDA, 0xFC] # DJNZ R2, $-4
# Jump to loop check
code += [0x80] # SJMP loop_check
sjmp_check2_pc = len(code)
code += [0x00] # placeholder
skip_id = len(code)
# Residual flags — send STOP anyway
code += [0x74, 0x40] # MOV A, #0x40
code += [0x90, 0xE6, 0x78] # MOV DPTR, #0xE678
code += [0xF0] # MOVX @DPTR, A
code += [0x7A, 10] # MOV R2, #10
code += [0x79, 250] # MOV R1, #250
code += [0xD9, 0xFE] # DJNZ R1, $-2
code += [0xDA, 0xFC] # DJNZ R2, $-4
loop_check = len(code)
# Loop up to 10 times
code += [0xB8, 10] # CJNE R0, #10, loop_top
cjne_pc = len(code)
code += [(loop_top - (cjne_pc + 1)) & 0xFF]
# ========== DONE ==========
done = len(code)
# Store final I2CS → 0x3C02
code += [0x90, 0xE6, 0x78] # MOV DPTR, #0xE678
code += [0xE0] # MOVX A, @DPTR
code += [0x90, 0x3C, 0x02] # MOV DPTR, #0x3C02
code += [0xF0] # MOVX @DPTR, A
# Store iteration count → 0x3C03
code += [0xE8] # MOV A, R0
code += [0x90, 0x3C, 0x03] # MOV DPTR, #0x3C03
code += [0xF0] # MOVX @DPTR, A
# Marker: stub done (0xDD → 0x3C05)
code += [0x74, 0xDD] # MOV A, #0xDD
code += [0x90, 0x3C, 0x05] # MOV DPTR, #0x3C05
code += [0xF0] # MOVX @DPTR, A
# Infinite loop
code += [0x80, 0xFE] # SJMP $ (loop forever)
# ========== Patch jump offsets ==========
# All relative jump offsets: target - (offset_byte_position + 1)
# because 8051 PC points to the NEXT instruction when the branch executes.
code[jz_done_pc] = (done - (jz_done_pc + 1)) & 0xFF
code[jnb_berr_pc] = (skip_berr - (jnb_berr_pc + 1)) & 0xFF
code[sjmp_check1_pc] = (loop_check - (sjmp_check1_pc + 1)) & 0xFF
code[jz_skip_id_pc] = (skip_id - (jz_skip_id_pc + 1)) & 0xFF
code[sjmp_check2_pc] = (loop_check - (sjmp_check2_pc + 1)) & 0xFF
return bytes(code)
def i2c_cleanup(dev):
"""Attempt host-side I2C controller recovery after CPU halt.
After halting the stock firmware mid-I2C-transaction, I2CS reads 0x1A
(mid-transaction, no BERR). BERR (0xF6) only appears on CPU restart.
Strategy: write STOP to I2CS from the host via 0xA0 vendor request
BEFORE restarting the CPU. If the I2C controller accepts host writes,
the pending transaction ends cleanly and our firmware gets a working
I2C controller.
Even if the controller doesn't process STOP while halted, latching the
bit in the register may cause the hardware to execute it on CPU restart,
preventing BERR from being set.
"""
print(f"\n I2C controller recovery (host-side):")
# 1. Read initial state
i2cs = fx2_ram_read(dev, I2CS_ADDR, 1)
if not i2cs:
print(f" I2CS read failed — skipping recovery")
return
i2cs_val = i2cs[0]
print(f" I2CS initial: {i2cs_decode(i2cs_val)}")
if i2cs_val == 0x00:
print(f" I2C controller idle — no recovery needed")
return
# Also read I2CTL for diagnostics
i2ctl = fx2_ram_read(dev, I2CTL_ADDR, 1)
if i2ctl:
speed = "400kHz" if i2ctl[0] & 0x01 else "100kHz"
print(f" I2CTL: 0x{i2ctl[0]:02X} ({speed})")
berr_set = bool(i2cs_val & I2CS_BERR)
if berr_set:
print(f" BERR already set at halt time — unusual")
# Try clearing BERR (write bit 2)
print(f" Writing 0x04 to I2CS (BERR clear)...")
fx2_ram_write(dev, I2CS_ADDR, bytes([I2CS_BERR]))
time.sleep(0.010)
i2cs2 = fx2_ram_read(dev, I2CS_ADDR, 1)
if i2cs2:
print(f" I2CS after: {i2cs_decode(i2cs2[0])}")
return
# 2. Mid-transaction — attempt recovery
id_bits = (i2cs_val >> 3) & 0x03
print(f" Transaction state: ID={id_bits} ({'idle' if id_bits == 0 else 'active'})")
# Strategy A: Read I2DAT to flush pending byte, then STOP
print(f" [A] Flushing I2DAT + STOP...")
i2dat = fx2_ram_read(dev, I2DAT_ADDR, 1)
if i2dat:
print(f" I2DAT read: 0x{i2dat[0]:02X}")
time.sleep(0.005)
fx2_ram_write(dev, I2CS_ADDR, bytes([I2CS_STOP]))
time.sleep(0.010)
i2cs_a = fx2_ram_read(dev, I2CS_ADDR, 1)
if i2cs_a:
print(f" I2CS after: {i2cs_decode(i2cs_a[0])}")
if i2cs_a[0] == 0x00 or (i2cs_a[0] & I2CS_DONE):
print(f" ✓ Recovery may have worked!")
return
# Strategy B: LASTRD + STOP (end read transaction cleanly)
print(f" [B] LASTRD + STOP...")
fx2_ram_write(dev, I2CS_ADDR, bytes([I2CS_LASTRD | I2CS_STOP]))
time.sleep(0.010)
i2cs_b = fx2_ram_read(dev, I2CS_ADDR, 1)
if i2cs_b:
print(f" I2CS after: {i2cs_decode(i2cs_b[0])}")
if i2cs_b[0] == 0x00 or (i2cs_b[0] & I2CS_DONE):
print(f" ✓ Recovery may have worked!")
return
# Strategy C: Just STOP again (in case controller needed time)
print(f" [C] Retry STOP...")
fx2_ram_write(dev, I2CS_ADDR, bytes([I2CS_STOP]))
time.sleep(0.020)
i2cs_c = fx2_ram_read(dev, I2CS_ADDR, 1)
if i2cs_c:
print(f" I2CS after: {i2cs_decode(i2cs_c[0])}")
# If BERR appeared during recovery attempts, try to clear it
if i2cs_c and (i2cs_c[0] & I2CS_BERR):
print(f" [D] BERR appeared — attempting clear...")
fx2_ram_write(dev, I2CS_ADDR, bytes([I2CS_BERR]))
time.sleep(0.010)
i2cs_d = fx2_ram_read(dev, I2CS_ADDR, 1)
if i2cs_d:
print(f" I2CS after: {i2cs_decode(i2cs_d[0])}")
# Final state
time.sleep(0.010)
i2cs_final = fx2_ram_read(dev, I2CS_ADDR, 1)
if i2cs_final:
print(f" I2CS final: {i2cs_decode(i2cs_final[0])}")
if i2cs_final[0] == 0x00:
print(f" ✓ I2C controller recovered!")
elif not (i2cs_final[0] & I2CS_BERR):
print(f" ~ I2C controller not idle but no BERR — STOP may be latched")
else:
print(f" ✗ BERR persists — host-side recovery did not work")
# -- Subcommand handlers -- # -- Subcommand handlers --
def cmd_load(args): def cmd_load(args):
@ -372,9 +678,56 @@ def cmd_load(args):
try: try:
# Step 1: Halt CPU # Step 1: Halt CPU
if not args.no_reset: if not args.no_reset:
if args.settle_delay > 0:
print(f"\n Settle delay: waiting {args.settle_delay}s for stock "
f"firmware I2C to finish...")
time.sleep(args.settle_delay)
print(f" Settle complete.")
# Pre-halt I2C flush: send vendor requests to the stock firmware
# that trigger I2C operations. After the firmware completes the
# I2C transaction (including STOP), the controller should be idle.
# We then immediately halt before any new I2C operation starts.
#
# Try multiple approaches — the stock firmware may support
# different subsets of the gp8psk vendor request protocol.
print("\n Pre-halt I2C flush...")
i2c_flushed = False
# Approach 1: GET_SIGNAL_LOCK (0x90) — reads BCM4500 via I2C
try:
lock_data = dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
0x90, 0, 0, 1, 2000)
print(f" 0x90 GET_SIGNAL_LOCK: 0x{lock_data[0]:02X} (I2C to BCM4500)")
i2c_flushed = True
except usb.core.USBError:
print(f" 0x90 not supported")
# Approach 2: I2C_READ (0x84) with shifted address
if not i2c_flushed:
try:
eeprom = dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
0x84, 0xA2, 0, 1, 2000) # addr<<1 per gp8psk
print(f" 0x84 I2C_READ: 0x{eeprom[0]:02X} (EEPROM)")
i2c_flushed = True
except usb.core.USBError:
print(f" 0x84 not supported")
# Approach 3: GET_8PSK_CONFIG (0x80) — may trigger I2C indirectly
if not i2c_flushed:
try:
cfg = dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
0x80, 0, 0, 1, 2000)
print(f" 0x80 GET_8PSK_CONFIG: 0x{cfg[0]:02X}")
i2c_flushed = True
except usb.core.USBError:
print(f" 0x80 not supported")
if not i2c_flushed:
print(f" No stock vendor requests succeeded — halt may catch mid-I2C")
print("\n[1/3] Halting CPU (CPUCS = 0x01)...") print("\n[1/3] Halting CPU (CPUCS = 0x01)...")
cpu_halt(dev) cpu_halt(dev)
time.sleep(0.05) time.sleep(0.01) # minimal delay — I2C should be idle from flush
# Verify halt # Verify halt
readback = fx2_ram_read(dev, CPUCS_ADDR, 1) readback = fx2_ram_read(dev, CPUCS_ADDR, 1)
@ -387,7 +740,15 @@ def cmd_load(args):
else: else:
print("\n[1/3] Skipping CPU reset (--no-reset)") print("\n[1/3] Skipping CPU reset (--no-reset)")
# Step 2: Load segments # Step 1.5: I2C controller cleanup (two-stage boot)
# The stock firmware's I2C polling is almost certainly interrupted
# by our CPUCS halt. The I2C controller runs independently — it
# enters a stuck state that causes BERR (I2CS=0xF6) on CPU restart.
# Must run BEFORE firmware load since the cleanup stub uses 0x0000.
if not args.no_reset:
i2c_cleanup(dev)
# Step 2: Load segments (CPU is halted from cleanup or step 1)
step = "2/3" if not args.no_reset else "2/2" step = "2/3" if not args.no_reset else "2/2"
print(f"\n[{step}] Loading {len(segments)} segment(s) into RAM...") print(f"\n[{step}] Loading {len(segments)} segment(s) into RAM...")
written = write_segments(dev, segments, verbose=args.verbose) written = write_segments(dev, segments, verbose=args.verbose)
@ -563,6 +924,9 @@ Power-cycle the device to restore the factory-programmed firmware.
help="Show detailed transfer progress") help="Show detailed transfer progress")
p_load.add_argument('--force', action='store_true', p_load.add_argument('--force', action='store_true',
help="Allow loading to unknown VID/PID devices") help="Allow loading to unknown VID/PID devices")
p_load.add_argument('--settle-delay', type=float, default=0, metavar='SECONDS',
help="Wait N seconds before halting CPU (lets stock firmware "
"finish I2C init — may avoid I2C BERR on restart)")
# reset # reset
p_reset = sub.add_parser('reset', p_reset = sub.add_parser('reset',

196
tools/i2c_host_test.py Normal file
View File

@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Test I2C communication from host side while FX2LP CPU is halted.
When the CPU is halted (CPUCS=1), I2CS=0x0A (clean state). When the CPU
runs (CPUCS=0), I2CS=0xF6 (stuck). This script tests whether the I2C
controller is functional during CPU halt by attempting to read the boot
EEPROM header (which MUST contain 0xC0 or 0xC2).
Uses USB vendor command 0xA0 to read/write XDATA registers directly.
"""
import sys
import time
import usb.core
import usb.util
# XDATA register addresses
I2CS_ADDR = 0xE678
I2DAT_ADDR = 0xE679
I2CTL_ADDR = 0xE67A
CPUCS_ADDR = 0xE600
# I2CS bit masks
bmSTART = 0x80
bmSTOP = 0x40
bmLASTRD = 0x20
bmBERR = 0x04
bmACK = 0x02
bmDONE = 0x01
EEPROM_ADDR_W = 0xA2 # 0x51 << 1 | 0 (write)
EEPROM_ADDR_R = 0xA3 # 0x51 << 1 | 1 (read)
def fx2_read(dev, addr, length=1):
"""Read XDATA register(s) via USB vendor command 0xA0."""
return dev.ctrl_transfer(0xC0, 0xA0, addr, 0, length, timeout=1000)
def fx2_write(dev, addr, data):
"""Write XDATA register(s) via USB vendor command 0xA0."""
dev.ctrl_transfer(0x40, 0xA0, addr, 0, data, timeout=1000)
def i2cs_str(val):
"""Decode I2CS register value."""
flags = []
if val & 0x80: flags.append('START')
if val & 0x40: flags.append('STOP')
if val & 0x20: flags.append('LASTRD')
if val & 0x04: flags.append('BERR')
if val & 0x02: flags.append('ACK')
if val & 0x01: flags.append('DONE')
return f"0x{val:02X} ({' | '.join(flags) if flags else 'idle'})"
def wait_done(dev, timeout_ms=100):
"""Poll I2CS for DONE bit."""
deadline = time.monotonic() + timeout_ms / 1000.0
while time.monotonic() < deadline:
i2cs = fx2_read(dev, I2CS_ADDR, 1)[0]
if i2cs & bmDONE:
return True, i2cs
time.sleep(0.001)
return False, i2cs
def main():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("SkyWalker-1 not found")
sys.exit(1)
print("SkyWalker-1 I2C Host-Side Test")
print("=" * 50)
# Step 1: Halt CPU
print("\n[1] Halting CPU (CPUCS=1)...")
fx2_write(dev, CPUCS_ADDR, bytes([0x01]))
time.sleep(0.05)
# Step 2: Read I2C state
i2cs = fx2_read(dev, I2CS_ADDR, 1)[0]
i2ctl = fx2_read(dev, I2CTL_ADDR, 1)[0]
print(f" I2CS = {i2cs_str(i2cs)}")
print(f" I2CTL = 0x{i2ctl:02X}")
if i2cs == 0xF6:
print(" WARNING: I2CS stuck at 0xF6 even during CPU halt!")
print(" I2C test will likely fail.")
# Step 3: Try to write I2CTL and read back
print("\n[2] Testing register writability...")
fx2_write(dev, I2CTL_ADDR, bytes([0x01])) # 400kHz
time.sleep(0.01)
i2ctl_rb = fx2_read(dev, I2CTL_ADDR, 1)[0]
print(f" Wrote I2CTL=0x01, read back 0x{i2ctl_rb:02X}", end="")
if i2ctl_rb == 0x01:
print(" (write works!)")
else:
print(f" (write IGNORED — register is read-only during halt)")
# Step 4: Try hardware I2C — read EEPROM header byte at address 0x0000
print("\n[3] Attempting EEPROM read via hardware I2C...")
print(" Target: EEPROM 0x51, address 0x0000 (boot header)")
# Issue START
print("\n [START] Writing I2CS = 0x80...")
fx2_write(dev, I2CS_ADDR, bytes([bmSTART]))
done, i2cs = wait_done(dev, 200)
print(f" I2CS = {i2cs_str(i2cs)}, DONE={'YES' if done else 'NO'}")
if not done:
print(" START didn't complete. Trying alternative: write I2DAT first...")
# Some controllers need I2DAT written before START can proceed
# Write EEPROM address (0xA2 = 0x51 write)
fx2_write(dev, I2DAT_ADDR, bytes([EEPROM_ADDR_W]))
time.sleep(0.01)
i2cs = fx2_read(dev, I2CS_ADDR, 1)[0]
print(f" After I2DAT=0xA2: I2CS = {i2cs_str(i2cs)}")
if not done:
# Try the Cypress-documented sequence: START is issued by
# writing the slave address to I2DAT AFTER writing START to I2CS
print("\n Trying standard Cypress I2C sequence...")
# Re-issue START
fx2_write(dev, I2CS_ADDR, bytes([bmSTART]))
time.sleep(0.001)
# Write slave address — this should clock the address byte
fx2_write(dev, I2DAT_ADDR, bytes([EEPROM_ADDR_W]))
done, i2cs = wait_done(dev, 200)
print(f" After START + I2DAT=0xA2: I2CS = {i2cs_str(i2cs)}, DONE={'YES' if done else 'NO'}")
if done:
ack = 'ACK' if (i2cs & bmACK) else 'NAK'
print(f" Address phase: {ack}")
if done:
# Write EEPROM address bytes (16-bit address: 0x0000)
print("\n Writing address 0x0000...")
fx2_write(dev, I2DAT_ADDR, bytes([0x00])) # addr high
done, i2cs = wait_done(dev, 200)
ack = 'ACK' if (i2cs & bmACK) else 'NAK'
print(f" Addr high: {ack}, DONE={'YES' if done else 'NO'}")
if done:
fx2_write(dev, I2DAT_ADDR, bytes([0x00])) # addr low
done, i2cs = wait_done(dev, 200)
ack = 'ACK' if (i2cs & bmACK) else 'NAK'
print(f" Addr low: {ack}, DONE={'YES' if done else 'NO'}")
if done:
# Re-START for read phase
print("\n Re-START for read phase...")
fx2_write(dev, I2CS_ADDR, bytes([bmSTART]))
time.sleep(0.001)
fx2_write(dev, I2DAT_ADDR, bytes([EEPROM_ADDR_R]))
done, i2cs = wait_done(dev, 200)
ack = 'ACK' if (i2cs & bmACK) else 'NAK'
print(f" Read addr: {ack}, DONE={'YES' if done else 'NO'}")
if done:
# Read first byte (and only byte — set LASTRD + STOP)
print("\n Reading first byte (LASTRD + STOP)...")
fx2_write(dev, I2CS_ADDR, bytes([bmLASTRD]))
time.sleep(0.001)
# Dummy read to trigger byte transfer
dummy = fx2_read(dev, I2DAT_ADDR, 1)[0]
done, i2cs = wait_done(dev, 200)
if done:
data = fx2_read(dev, I2DAT_ADDR, 1)[0]
fx2_write(dev, I2CS_ADDR, bytes([bmSTOP]))
print(f" Boot header byte: 0x{data:02X}", end="")
if data == 0xC0:
print(" (C0 = no renumerate)")
elif data == 0xC2:
print(" (C2 = renumerate)")
else:
print(f" (unexpected!)")
print("\n *** EEPROM READ SUCCESSFUL! ***")
else:
print(f" Read DONE timeout. I2CS = {i2cs_str(i2cs)}")
# Final state
i2cs_final = fx2_read(dev, I2CS_ADDR, 1)[0]
print(f"\n[4] Final I2CS = {i2cs_str(i2cs_final)}")
# Try STOP to clean up
fx2_write(dev, I2CS_ADDR, bytes([bmSTOP]))
time.sleep(0.01)
i2cs_stop = fx2_read(dev, I2CS_ADDR, 1)[0]
print(f" After STOP: I2CS = {i2cs_str(i2cs_stop)}")
# Release CPU
print("\n[5] Releasing CPU (CPUCS=0)...")
fx2_write(dev, CPUCS_ADDR, bytes([0x00]))
time.sleep(0.5)
print(" CPU released. Device will re-enumerate.")
if __name__ == '__main__':
main()

259
tools/i2c_recovery_boot.py Normal file
View File

@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Two-stage boot with I2C slave recovery.
The FX2LP I2C controller enters a stuck state (I2CS=0xF6) when the CPU
restarts after being halted mid-I2C-transaction. The stock firmware was
interrupted by fw_load.py's CPUCS halt while an I2C transfer was in
progress. The slave (BCM4500/BCM3440) is still driving SDA LOW, waiting
for clock pulses to finish its byte.
When CPUCS goes back to 0:
1. I2C pull-ups reconnect
2. Controller detects SDA LOW (slave still holding)
3. Controller enters permanent BERR state (0xF6)
4. All subsequent I2C operations fail
Fix: two-stage boot process:
Stage 1: Upload a tiny slave recovery stub, run it briefly to release
the stuck slave (32 SCL pulses @ ~10us + STOP), then halt again.
Stage 2: Upload the real firmware. The I2C bus is now clean, so when
CPUCS goes to 0, the controller initializes correctly.
"""
import sys
import time
import usb.core
import usb.util
I2CS_ADDR = 0xE678
I2CTL_ADDR = 0xE67A
CPUCS_ADDR = 0xE600
def fx2_read(dev, addr, n=1):
return dev.ctrl_transfer(0xC0, 0xA0, addr, 0, n, timeout=1000)
def fx2_write(dev, addr, data):
dev.ctrl_transfer(0x40, 0xA0, addr, 0, data, timeout=1000)
def i2cs_str(v):
flags = []
for bit, name in [(7,'START'),(6,'STOP'),(5,'LASTRD'),
(4,'ID1'),(3,'ID0'),(2,'BERR'),(1,'ACK'),(0,'DONE')]:
if v & (1 << bit): flags.append(name)
return f"0x{v:02X} ({' | '.join(flags) if flags else 'idle'})"
def build_recovery_stub():
"""Build an 8051 stub that holds BCM4500 in reset to release the I2C bus.
The I2C controller (I2CS=0xF6 BERR) has exclusive control of PA0 (SDA)
and PA1 (SCL). GPIO operations on those pins are OVERRIDDEN by the I2C
engine bit-bang SCL clocking has NO EFFECT on the actual bus.
Strategy: assert BCM4500 RESET (PA5 LOW) and KEEP it asserted. The
BCM4500's I2C interface goes high-impedance, releasing SDA. Then when
the host halts and loads real firmware, CPUCS restart will find SDA=HIGH
and the I2C controller will initialize cleanly (no BERR).
The real firmware handles BCM_RESET de-assertion during its normal init.
CRITICAL: Do NOT modify OEA bits 0-1 the I2C controller owns those.
Diagnostics in XDATA:
0x3C00: 0xAA = stub started
0x3C01: initial OEA
0x3C02: initial IOA
0x3C03: I2CS at boot (via XDATA 0xE678)
0x3C04: IOA after asserting BCM4500 reset (~50ms hold)
0x3C05: 0xDD = stub complete, looping with BCM4500 held in reset
"""
code = []
# 0x0000: LJMP main_start (to 0x0010)
code += [0x02, 0x00, 0x10] # LJMP 0x0010
# 0x0003-0x000F: padding
while len(code) < 0x10:
code += [0x00]
# ========== 0x0010: Main code ==========
# --- Marker: stub started (0xAA → 0x3C00) ---
code += [0x74, 0xAA] # MOV A, #0xAA
code += [0x90, 0x3C, 0x00] # MOV DPTR, #0x3C00
code += [0xF0] # MOVX @DPTR, A
# --- Capture initial OEA → 0x3C01 ---
code += [0xE5, 0xB2] # MOV A, OEA
code += [0x90, 0x3C, 0x01] # MOV DPTR, #0x3C01
code += [0xF0] # MOVX @DPTR, A
# --- Capture initial IOA → 0x3C02 ---
code += [0xE5, 0x80] # MOV A, IOA
code += [0x90, 0x3C, 0x02] # MOV DPTR, #0x3C02
code += [0xF0] # MOVX @DPTR, A
# --- Capture initial I2CS → 0x3C03 ---
code += [0x90, 0xE6, 0x78] # MOV DPTR, #0xE678
code += [0xE0] # MOVX A, @DPTR
code += [0x90, 0x3C, 0x03] # MOV DPTR, #0x3C03
code += [0xF0] # MOVX @DPTR, A
# --- Assert BCM4500 RESET: PA5 LOW, make output ---
# PA5 = IOA bit 5, bit address 0x85 (IOA base 0x80 + 5)
# ORL OEA with only bit 5 — do NOT touch bits 0-1 (I2C engine)
code += [0xC2, 0x85] # CLR PA5 (assert reset)
code += [0x43, 0xB2, 0x20] # ORL OEA, #0x20 (PA5 = output)
# Hold reset for ~50ms
# R2=250, R1=240: 250*240*3 = 180,000 cycles = ~3.75ms
# R0=15: 15 * 3.75ms = ~56ms
code += [0x78, 15] # MOV R0, #15
reset_outer = len(code)
code += [0x7A, 250] # MOV R2, #250
reset_mid = len(code)
code += [0x79, 240] # MOV R1, #240
code += [0xD9, 0xFE] # DJNZ R1, $-2
djnz2_pc = len(code) + 2
code += [0xDA, (reset_mid - djnz2_pc) & 0xFF] # DJNZ R2, reset_mid
djnz0_pc = len(code) + 2
code += [0xD8, (reset_outer - djnz0_pc) & 0xFF] # DJNZ R0, reset_outer
# --- Capture IOA during reset → 0x3C04 ---
code += [0xE5, 0x80] # MOV A, IOA
code += [0x90, 0x3C, 0x04] # MOV DPTR, #0x3C04
code += [0xF0] # MOVX @DPTR, A
# --- Marker: stub complete (0xDD → 0x3C05) ---
# BCM4500 stays in reset! Do NOT de-assert.
code += [0x74, 0xDD] # MOV A, #0xDD
code += [0x90, 0x3C, 0x05] # MOV DPTR, #0x3C05
code += [0xF0] # MOVX @DPTR, A
# Infinite loop — BCM4500 held in reset
code += [0x80, 0xFE] # SJMP $ (loop forever)
return bytes(code)
def main():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("SkyWalker-1 not found")
sys.exit(1)
print("Two-Stage Boot: I2C Slave Recovery (v2 — with markers)")
print("=" * 55)
# Stage 0: Initial state
print(f"\n[0] Device found: Bus {dev.bus} Addr {dev.address}")
# Stage 1: Halt CPU
print("\n[1] Halting CPU...")
fx2_write(dev, CPUCS_ADDR, bytes([0x01]))
time.sleep(0.05)
i2cs = fx2_read(dev, I2CS_ADDR, 1)[0]
print(f" I2CS during halt = {i2cs_str(i2cs)}")
# Clear XDATA diagnostic area first (so stale data doesn't confuse us)
print(" Clearing XDATA 0x3C00-0x3C0F...")
fx2_write(dev, 0x3C00, bytes([0x00] * 16))
# Stage 2: Upload stub
print("\n[2] Uploading slave recovery stub...")
stub = build_recovery_stub()
print(f" Stub size: {len(stub)} bytes")
fx2_write(dev, 0x0000, stub)
# Verify upload by reading back first 16 bytes
readback = fx2_read(dev, 0x0000, 16)
match = all(readback[i] == stub[i] for i in range(16))
print(f" Upload verify (first 16): {'MATCH' if match else 'MISMATCH'}")
if not match:
print(f" Expected: {' '.join(f'{b:02X}' for b in stub[:16])}")
print(f" Got: {' '.join(f'{b:02X}' for b in readback)}")
# Stage 3: Run stub (~50ms reset hold + ~100ms settle + margin)
print("\n[3] Running stub (500ms)...")
fx2_write(dev, CPUCS_ADDR, bytes([0x00]))
time.sleep(0.5)
# Stage 4: Halt and read diagnostics
print("\n[4] Halting CPU after recovery...")
fx2_write(dev, CPUCS_ADDR, bytes([0x01]))
time.sleep(0.05)
# Read all diagnostic bytes
diag = fx2_read(dev, 0x3C00, 8)
i2cs_halt = fx2_read(dev, I2CS_ADDR, 1)[0]
print(f"\n Raw XDATA 0x3C00-0x3C07:")
print(f" {' '.join(f'{b:02X}' for b in diag)}")
# Decode diagnostics
marker_start = diag[0] # 0x3C00: expect 0xAA
oea_init = diag[1] # 0x3C01: initial OEA
ioa_init = diag[2] # 0x3C02: initial IOA
i2cs_init = diag[3] # 0x3C03: initial I2CS
ioa_during_rst = diag[4] # 0x3C04: IOA during BCM4500 reset
marker_done = diag[5] # 0x3C05: expect 0xDD
def bus_str(ioa):
sda = 'H' if ioa & 1 else 'L'
scl = 'H' if ioa & 2 else 'L'
rst = 'H' if ioa & 0x20 else 'L'
return f"SDA={sda} SCL={scl} RST={rst}"
print(f"\n Markers:")
print(f" Start (0xAA?): 0x{marker_start:02X} {'' if marker_start == 0xAA else '✗ STUB DID NOT EXECUTE'}")
print(f" Done (0xDD?): 0x{marker_done:02X} {'' if marker_done == 0xDD else ''}")
if marker_start != 0xAA:
print(f"\n FATAL: Stub never started!")
else:
print(f"\n Initial state:")
print(f" OEA = 0x{oea_init:02X}")
print(f" IOA = 0x{ioa_init:02X} {bus_str(ioa_init)}")
print(f" I2CS = {i2cs_str(i2cs_init)}")
print(f"\n BCM4500 held in RESET (~50ms):")
print(f" IOA = 0x{ioa_during_rst:02X} {bus_str(ioa_during_rst)}")
sda_rst = 'H' if ioa_during_rst & 1 else 'L'
if sda_rst == 'H':
print(f" ✓ SDA HIGH with BCM4500 in reset")
else:
print(f" ✗ SDA still LOW — not the BCM4500 holding it")
print(f"\n I2CS during halt: {i2cs_str(i2cs_halt)}")
print(f" BCM4500 is held in RESET (PA5 driven LOW)")
# Stage 5: Now load real firmware with BCM4500 still in reset
# The real firmware's init sequence will de-assert BCM_RESET.
# Since SDA is HIGH (slave in reset), the I2C controller should
# initialize cleanly when CPUCS restarts for the real firmware.
print(f"\n[5] BCM4500 held in reset. Loading real firmware...")
print(f" The CPUCS restart for firmware load will re-init I2C controller.")
print(f" With BCM4500 in reset (SDA=HIGH), I2CS should come up clean.")
import subprocess
import os
fw_path = os.path.join(os.path.dirname(__file__), '..', 'firmware', 'build', 'skywalker1.ihx')
fw_path = os.path.abspath(fw_path)
fw_load = os.path.join(os.path.dirname(__file__), 'fw_load.py')
if os.path.exists(fw_path):
print(f"\n Running: python3 {fw_load} load {fw_path} --wait 5")
result = subprocess.run(
['python3', fw_load, 'load', fw_path, '--wait', '5'],
capture_output=True, text=True, timeout=30
)
print(result.stdout)
if result.stderr:
print(f" stderr: {result.stderr}")
else:
print(f"\n Firmware not found: {fw_path}")
print(f" Build with: cd firmware && make")
print(f" Then run: python3 tools/fw_load.py load {fw_path} --wait 5")
if __name__ == '__main__':
main()

126
tools/i2c_register_test.py Normal file
View File

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Quick diagnostic: test if 0xA0 writes to I2C registers trigger hardware."""
import usb.core, usb.util, time
VID, PID = 0x09C0, 0x0203
A0 = 0xA0
I2CS = 0xE678; I2DAT = 0xE679; I2CTL = 0xE67A; CPUCS = 0xE600
dev = usb.core.find(idVendor=VID, idProduct=PID)
for cfg in dev:
for intf in cfg:
if dev.is_kernel_driver_active(intf.bInterfaceNumber):
dev.detach_kernel_driver(intf.bInterfaceNumber)
try:
dev.set_configuration()
except:
pass
def a0r(addr, n=1):
return bytes(dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN, A0, addr, 0, n, 2000))
def a0w(addr, val):
dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_OUT, A0, addr, 0, bytes([val]), 2000)
# Pre-halt flush
try:
lock = dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN, 0x90, 0, 0, 1, 2000)
print(f"Pre-halt flush (0x90): 0x{lock[0]:02X}")
except Exception as e:
print(f"Flush failed: {e}")
# Halt
a0w(CPUCS, 0x01)
time.sleep(0.01)
print(f"CPUCS after halt: 0x{a0r(CPUCS)[0]:02X}")
print(f"\n=== Register State After Halt ===")
i2cs_orig = a0r(I2CS)[0]
i2ctl_orig = a0r(I2CTL)[0]
print(f"I2CS: 0x{i2cs_orig:02X}")
print(f"I2CTL: 0x{i2ctl_orig:02X}")
# Test 1: I2CTL writability
print(f"\n=== Test 1: I2CTL writability ===")
print(f"I2CTL before: 0x{a0r(I2CTL)[0]:02X}")
a0w(I2CTL, 0x00)
time.sleep(0.001)
v = a0r(I2CTL)[0]
print(f"I2CTL after 0x00 write: 0x{v:02X} {'CHANGED' if v == 0x00 else 'unchanged'}")
a0w(I2CTL, 0x01)
time.sleep(0.001)
v = a0r(I2CTL)[0]
print(f"I2CTL after 0x01 write: 0x{v:02X}")
# Test 2: Write START to I2CS, read back
print(f"\n=== Test 2: I2CS START ===")
print(f"I2CS before START: 0x{a0r(I2CS)[0]:02X}")
a0w(I2CS, 0x80) # bmSTART
for i in range(5):
val = a0r(I2CS)[0]
print(f" I2CS read {i}: 0x{val:02X}")
time.sleep(0.002)
# Test 3: Write to I2DAT
print(f"\n=== Test 3: I2DAT write (EEPROM addr) ===")
print(f"I2CS before I2DAT write: 0x{a0r(I2CS)[0]:02X}")
a0w(I2DAT, 0xA2) # EEPROM 0x51<<1
for i in range(5):
val = a0r(I2CS)[0]
print(f" I2CS read {i}: 0x{val:02X}")
time.sleep(0.002)
# Test 4: Read I2DAT
print(f"\n=== Test 4: I2DAT read ===")
dat = a0r(I2DAT)[0]
print(f"I2DAT read: 0x{dat:02X}")
print(f"I2CS after I2DAT read: 0x{a0r(I2CS)[0]:02X}")
# Test 5: Write STOP
print(f"\n=== Test 5: I2CS STOP ===")
a0w(I2CS, 0x40) # bmSTOP
for i in range(3):
val = a0r(I2CS)[0]
print(f" I2CS read {i}: 0x{val:02X}")
time.sleep(0.002)
# Test 6: Write arbitrary values to I2CS
print(f"\n=== Test 6: I2CS raw write/read ===")
for test_val in [0x00, 0xFF, 0x04, 0x80]:
a0w(I2CS, test_val)
time.sleep(0.001)
rb = a0r(I2CS)[0]
changed = "CHANGED" if rb != i2cs_orig else "unchanged"
print(f" Wrote 0x{test_val:02X}, read 0x{rb:02X} {changed}")
# Test 7: XDATA RAM write/read (control test)
print(f"\n=== Test 7: XDATA RAM control test ===")
TEST_ADDR = 0x3C00
orig = a0r(TEST_ADDR)[0]
a0w(TEST_ADDR, 0xBE)
time.sleep(0.001)
readback = a0r(TEST_ADDR)[0]
ok = "OK" if readback == 0xBE else "FAIL"
print(f"RAM[0x3C00]: wrote 0xBE, read 0x{readback:02X} {ok}")
# Test 8: Try writing 0x04 to I2CS (BERR clear bit)
print(f"\n=== Test 8: BERR clear bit ===")
a0w(I2CS, 0x04)
time.sleep(0.001)
v = a0r(I2CS)[0]
print(f"After writing 0x04: I2CS=0x{v:02X}")
# Summary
print(f"\n=== Summary ===")
final_i2cs = a0r(I2CS)[0]
print(f"Final I2CS: 0x{final_i2cs:02X} (started at 0x{i2cs_orig:02X})")
if final_i2cs == i2cs_orig:
print("I2CS NEVER CHANGED -- host-side I2C register writes ignored by hardware")
print("\nImplication: 0xA0 writes reach XDATA address space but the I2C")
print("controller only responds to 8051 MOVX instructions, not USB engine writes.")
print("The boot ROM's 0xA0 handler uses a different bus path for register access.")
else:
print("I2CS DID CHANGE -- host-side I2C register writes may work!")

View File

@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""BCM4500 indirect register loopback test.
Write a known value via indirect write, then read it back.
Tests multiple approaches:
1. Multi-byte A6+A7+A8 in one transaction (0xB2 uses bcm_indirect_write)
2. Separate writes via 0xB6 diagnostic
3. Read via 0xB6 with various delays
4. Read using 0xB1 (bcm_indirect_read wrapper)
If we can write-then-read a value back, the DSP command processor works.
If not, we need to look at the I2C transaction structure more carefully.
"""
import sys
import time
sys.path.insert(0, 'tools')
from skywalker_lib import SkyWalker1
CMD_RAW_DEMOD_READ = 0xB1
CMD_RAW_DEMOD_WRITE = 0xB2
CMD_I2C_RAW_READ = 0xB5
CMD_I2C_DIAG = 0xB6
BCM4500_ADDR = 0x08
sw = SkyWalker1()
sw.open()
print('=== BCM4500 Indirect Register Loopback Test ===')
print(f'Firmware: {sw.get_fw_version()}')
# Boot with full sequence
print('\n--- Booting BCM4500 (full boot) ---')
result = sw._vendor_in(0x89, value=1, index=0, length=3)
cfg, stage = result[0], result[1]
print(f' Config: 0x{cfg:02X}, Stage: 0x{stage:02X}')
time.sleep(0.1)
# ============================================================
# Test 1: Read default indirect register values
# ============================================================
print('\n=== Test 1: Default indirect register values (0xB1) ===')
for page in [0x00, 0x01, 0x06, 0x07, 0x0A, 0x0F]:
try:
data = sw._vendor_in(CMD_RAW_DEMOD_READ, value=page, index=0, length=1)
print(f' Page 0x{page:02X}: 0x{data[0]:02X}')
except Exception as e:
print(f' Page 0x{page:02X}: FAILED ({e})')
# ============================================================
# Test 2: Write via 0xB2 (multi-byte A6+A7+A8), then read via 0xB1
# ============================================================
print('\n=== Test 2: Write 0x42 to page 0x00, read back (0xB2 write, 0xB1 read) ===')
try:
# 0xB2: bcm_indirect_write(page=0x00, val=0x42)
# Writes A6=0x00, A7=0x42, A8=0x03 in one 3-byte I2C transaction
sw._vendor_in(CMD_RAW_DEMOD_WRITE, value=0x00, index=0x42, length=0)
except Exception:
pass # May not return data
time.sleep(0.05)
# Read back via 0xB1 (bcm_indirect_read with 1ms delay)
try:
data = sw._vendor_in(CMD_RAW_DEMOD_READ, value=0x00, index=0, length=1)
print(f' Read back page 0x00: 0x{data[0]:02X}')
if data[0] == 0x42:
print(' >> LOOPBACK SUCCESS! DSP is processing commands!')
elif data[0] == 0x00:
print(' >> Got 0x00 — either DSP not running or write/read protocol broken')
else:
print(f' >> Unexpected: 0x{data[0]:02X}')
except Exception as e:
print(f' Read failed: {e}')
# ============================================================
# Test 3: Direct register reads before/after indirect write
# ============================================================
print('\n=== Test 3: Direct register state before/after indirect commands ===')
print(' Before indirect write:')
for reg in [0xA6, 0xA7, 0xA8]:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=reg, length=1)
print(f' 0x{reg:02X}: 0x{data[0]:02X}')
# Write via 0xB2
try:
sw._vendor_in(CMD_RAW_DEMOD_WRITE, value=0x06, index=0xAB, length=0)
except Exception:
pass
time.sleep(0.01)
print(' After indirect write (page=0x06, data=0xAB):')
for reg in [0xA6, 0xA7, 0xA8]:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=reg, length=1)
print(f' 0x{reg:02X}: 0x{data[0]:02X}')
# ============================================================
# Test 4: Manual step-by-step with individual I2C writes
# ============================================================
print('\n=== Test 4: Manual indirect read using individual raw I2C writes ===')
print(' Writing A6=0x06 via direct I2C write...')
# We don't have a raw I2C write command, but we can use the 0xB6 diagnostic
# which does individual writes and reads for us.
# Test 4a: 0xB6 with READ command (wIndex=0x01)
print('\n 4a: 0xB6 diagnostic — READ page 0x06:')
diag = sw._vendor_in(CMD_I2C_DIAG, value=0x06, index=0x01, length=8)
print(f' A6 write ok: {diag[0]}')
print(f' A6 readback: 0x{diag[1]:02X}')
print(f' A8 write ok: {diag[2]}')
print(f' A8 immediate: 0x{diag[3]:02X}')
print(f' A8 after 2ms: 0x{diag[4]:02X}')
print(f' A7 data: 0x{diag[5]:02X}')
print(f' A6 final: 0x{diag[6]:02X}')
# ============================================================
# Test 5: Try reading A7 with longer delays
# ============================================================
print('\n=== Test 5: Indirect read with longer delays ===')
print(' Maybe the DSP needs more time to process the command...')
# Use 0xB6 to write A6=0x06 and A8=0x01
sw._vendor_in(CMD_I2C_DIAG, value=0x06, index=0x01, length=8)
for delay_ms in [10, 50, 100, 500]:
time.sleep(delay_ms / 1000.0)
# Read A7 via raw I2C
try:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=0xA7, length=1)
print(f' After {delay_ms:4d}ms: A7=0x{data[0]:02X}')
except Exception as e:
print(f' After {delay_ms:4d}ms: FAILED ({e})')
# ============================================================
# Test 6: Direct register dump after all tests
# ============================================================
print('\n=== Test 6: Register state after all tests ===')
print(' A0-AF:')
for reg in range(0xA0, 0xB0):
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=reg, length=1)
print(f' 0x{reg:02X}=0x{data[0]:02X}', end='')
if (reg % 8) == 7:
print()
print()
# ============================================================
# Test 7: Power cycle BCM4500 and check pre-command register state
# ============================================================
print('\n=== Test 7: Reboot and check BEFORE any indirect commands ===')
sw._vendor_in(0x89, value=0, index=0, length=3) # shutdown
time.sleep(0.5)
sw._vendor_in(0x89, value=1, index=0, length=3) # full boot
time.sleep(0.1)
print(' Fresh boot — direct reg reads (no indirect commands issued):')
for reg in [0xA0, 0xA2, 0xA4, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB]:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=reg, length=1)
print(f' 0x{reg:02X}: 0x{data[0]:02X}')
# Now issue ONE indirect read command and check if registers change
print('\n After ONE indirect read (page 0x06):')
diag = sw._vendor_in(CMD_I2C_DIAG, value=0x06, index=0x01, length=8)
print(f' A7 data: 0x{diag[5]:02X} (this is the indirect read result)')
# Check if direct registers changed
print(' Direct register check after indirect command:')
for reg in [0xA0, 0xA2, 0xA4, 0xA6, 0xA7, 0xA8]:
data = sw._vendor_in(CMD_I2C_RAW_READ, value=BCM4500_ADDR,
index=reg, length=1)
print(f' 0x{reg:02X}: 0x{data[0]:02X}')
sw.close()
print('\n=== Done ===')

986
tools/rf_testbench.py Normal file
View File

@ -0,0 +1,986 @@
#!/usr/bin/env python3
"""
RF Test Bench CW injection tests with NanoVNA + HMC472A + SkyWalker-1.
Injects CW signals from a NanoVNA-H through a programmable HMC472A digital
attenuator into the SkyWalker-1 receiver. Runs automated test sequences to
characterize AGC linearity, IF band flatness, frequency accuracy, minimum
detectable signal, and BPSK mode 9 behavior.
Hardware setup:
NanoVNA CH0 DC Blocker HMC472A SMA-to-F SkyWalker-1
The HMC472A (0-31.5 dB, 0.5 dB steps) is controlled via USB serial (preferred)
or REST API. The NanoVNA provides CW at a fixed frequency, controlled either
via mcnanovna or manually. The SkyWalker-1 measures AGC, SNR, and lock status.
Usage:
python rf_testbench.py agc-linearity --freq 1200
python rf_testbench.py band-flatness --start 950 --stop 1500 --step 10
python rf_testbench.py freq-accuracy --freqs 1000,1200,1400
python rf_testbench.py mds --freq 1200
python rf_testbench.py bpsk-probe --freq 1200
python rf_testbench.py --help
"""
import sys
import os
import argparse
import csv
import json
import time
from datetime import datetime, timezone
from urllib.request import urlopen, Request
from urllib.error import URLError
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, MODULATIONS
# --- HMC472A REST client ---
class HMC472A:
"""Control the HMC472A digital attenuator via its ESP32-S2 REST API."""
def __init__(self, base_url: str = "http://attenuator.local"):
self.base_url = base_url.rstrip("/")
def _get(self, path: str, retries: int = 3) -> dict:
url = f"{self.base_url}{path}"
req = Request(url)
last_err: OSError = OSError("no attempts made")
for attempt in range(retries):
try:
with urlopen(req, timeout=5) as resp:
return json.loads(resp.read())
except (URLError, OSError) as e:
last_err = e
if attempt < retries - 1:
time.sleep(0.2 * (attempt + 1))
raise last_err
def _post(self, path: str, data: dict, retries: int = 3) -> dict:
url = f"{self.base_url}{path}"
body = json.dumps(data).encode()
req = Request(url, data=body, method="POST",
headers={"Content-Type": "application/json"})
last_err: OSError = OSError("no attempts made")
for attempt in range(retries):
try:
with urlopen(req, timeout=5) as resp:
return json.loads(resp.read())
except (URLError, OSError) as e:
last_err = e
if attempt < retries - 1:
time.sleep(0.2 * (attempt + 1))
raise last_err
def status(self) -> dict:
return self._get("/status")
def set_db(self, attenuation_db: float) -> dict:
clamped = max(0.0, min(31.5, attenuation_db))
rounded = round(clamped * 2) / 2 # Snap to 0.5 dB steps
return self._post("/set", {"attenuation_db": rounded})
def config(self) -> dict:
return self._get("/config")
# --- HMC472A USB serial client ---
class HMC472ASerial:
"""Control the HMC472A digital attenuator via USB CDC serial.
Uses the usb-serial-json-v1 protocol: one JSON object per newline-
terminated line in each direction. Requires pyserial.
"""
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
import serial
self.ser = serial.Serial(port, baudrate, timeout=timeout)
self.ser.reset_input_buffer()
def close(self):
if self.ser and self.ser.is_open:
self.ser.close()
def _cmd(self, command: dict) -> dict:
line = json.dumps(command, separators=(",", ":")) + "\n"
self.ser.write(line.encode())
self.ser.flush()
resp_line = self.ser.readline()
if not resp_line:
raise TimeoutError("no response from HMC472A")
resp = json.loads(resp_line)
if not resp.get("ok"):
raise RuntimeError(resp.get("error", "unknown error"))
return resp
def status(self) -> dict:
return self._cmd({"cmd": "status"})
def set_db(self, attenuation_db: float) -> dict:
clamped = max(0.0, min(31.5, attenuation_db))
rounded = round(clamped * 2) / 2
return self._cmd({"cmd": "set", "db": rounded})
def config(self) -> dict:
return self._cmd({"cmd": "config"})
def identify(self) -> dict:
return self._cmd({"cmd": "identify"})
def detect_hmc472a_serial() -> str | None:
"""Scan /dev/ttyACM* ports for an HMC472A responding to identify.
Returns the port path if found, None otherwise.
"""
import glob
try:
import serial
except ImportError:
return None
ports = sorted(glob.glob("/dev/ttyACM*"))
for port in ports:
try:
ser = serial.Serial(port, 115200, timeout=0.5)
ser.reset_input_buffer()
ser.write(b'{"cmd":"identify"}\n')
ser.flush()
resp_line = ser.readline()
ser.close()
if resp_line:
resp = json.loads(resp_line)
if resp.get("device") == "hmc472a-attenuator":
return port
except (OSError, json.JSONDecodeError, ValueError):
continue
return None
class MockSkyWalker1:
"""Lightweight mock SkyWalker-1 for rf_testbench testing."""
def __init__(self, verbose=False):
self.verbose = verbose
self._freq_khz = 0
def open(self):
pass
def close(self):
pass
def ensure_booted(self):
pass
def start_intersil(self, on=True):
pass
def tune_monitor(self, symbol_rate_sps=1000000, freq_khz=1200000,
mod_index=0, fec_index=5, dwell_ms=10):
self._freq_khz = freq_khz
# Simulate AGC response: higher freq → slightly lower power
base_agc1 = 1200 + (freq_khz - 1200000) // 100
return {
"snr_raw": 180, "snr_db": 7.8, "snr_pct": 39.0,
"agc1": max(100, base_agc1), "agc2": 750,
"power_db": -46.1 - (freq_khz - 1200000) / 500000,
"locked": False, "lock": 0x00, "status": 0x01,
"dwell_ms": dwell_ms,
}
def signal_monitor(self):
return {
"snr_raw": 200, "snr_db": 8.5, "snr_pct": 42.5,
"agc1": 1200, "agc2": 800, "power_db": -45.3,
"locked": False, "lock": 0x00, "status": 0x01,
}
def sweep_spectrum(self, start_mhz, stop_mhz, step_mhz=5.0,
dwell_ms=15, sr_ksps=1000, mod_index=0, fec_index=5):
n = int((stop_mhz - start_mhz) / step_mhz) + 1
freqs = [start_mhz + i * step_mhz for i in range(n)]
powers = [-50.0 + 3.0 * (1.0 - abs(f - 1200) / 300) for f in freqs]
raw = [{"agc1": 1200, "agc2": 750, "power_db": p,
"snr_raw": 0, "snr_db": 0, "locked": False,
"lock": 0, "status": 0} for p in powers]
return freqs, powers, raw
def _make_mock_skywalker(verbose=False):
sw = MockSkyWalker1(verbose=verbose)
sw.open()
sw.ensure_booted()
return sw
class FixedAttenuator:
"""Stand-in for a fixed (non-programmable) inline attenuator.
Reports the declared fixed attenuation for every set_db() call.
Used when testing with a fixed pad instead of the HMC472A.
"""
def __init__(self, fixed_db: float = 20.0):
self._fixed_db = fixed_db
def status(self) -> dict:
return {"attenuation_db": self._fixed_db, "step": 0,
"version": "fixed-pad", "note": "non-programmable"}
def set_db(self, attenuation_db: float) -> dict:
# Can't change a fixed pad — just return what it is
return self.status()
def config(self) -> dict:
return {"db_min": self._fixed_db, "db_max": self._fixed_db,
"db_step": 0, "version": "fixed-pad",
"hostname": f"fixed-{self._fixed_db:.1f}dB"}
class MockHMC472A:
"""Mock attenuator for testing without hardware."""
def __init__(self, base_url: str = "http://mock.local"):
self.base_url = base_url
self._db = 0.0
def status(self) -> dict:
step = int(self._db * 2)
return {"attenuation_db": self._db, "step": step, "version": "mock"}
def set_db(self, attenuation_db: float) -> dict:
self._db = max(0.0, min(31.5, round(attenuation_db * 2) / 2))
return self.status()
def config(self) -> dict:
return {"db_min": 0.0, "db_max": 31.5, "db_step": 0.5,
"version": "mock", "hostname": "mock-attenuator"}
# --- NanoVNA control ---
def try_import_nanovna():
"""Try to import mcnanovna for automated NanoVNA control."""
try:
from mcnanovna.nanovna import NanoVNA
return NanoVNA
except ImportError:
return None
class MockNanoVNA:
"""Mock NanoVNA for testing without hardware."""
def cw(self, frequency_hz: int = 0, power: int = 3):
pass
def manual_nanovna_set(freq_mhz: float, power: int = 3) -> None:
"""Prompt the user to manually set NanoVNA CW frequency."""
print(f"\n >>> Set NanoVNA to CW at {freq_mhz:.3f} MHz, power={power}")
input(" Press Enter when ready...")
# --- CSV output ---
CSV_COLUMNS = [
"timestamp", "test_name", "freq_mhz", "atten_db",
"agc1", "agc2", "power_db", "snr_raw", "snr_db",
"locked", "lock_raw", "status", "notes",
]
def open_csv(path: str):
f = open(path, "w", newline="")
writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS)
writer.writeheader()
return f, writer
def write_row(writer, csv_file, test_name: str, freq_mhz: float,
atten_db: float, result: dict, notes: str = ""):
writer.writerow({
"timestamp": datetime.now(timezone.utc).isoformat(),
"test_name": test_name,
"freq_mhz": f"{freq_mhz:.3f}",
"atten_db": f"{atten_db:.1f}",
"agc1": result.get("agc1", 0),
"agc2": result.get("agc2", 0),
"power_db": f"{result.get('power_db', 0):.2f}",
"snr_raw": result.get("snr_raw", 0),
"snr_db": f"{result.get('snr_db', 0):.2f}",
"locked": result.get("locked", False),
"lock_raw": f"0x{result.get('lock', 0):02X}",
"status": f"0x{result.get('status', 0):02X}",
"notes": notes,
})
if csv_file:
csv_file.flush()
# --- Calibration ---
def load_cal_file(path: str) -> dict:
"""Load a NanoVNA S21 path-loss calibration CSV.
Expects columns: frequency_hz (or freq_mhz), s21_db (or loss_db).
Returns dict mapping freq_mhz -> loss_db (positive = loss).
"""
cal = {}
with open(path) as f:
reader = csv.DictReader(f)
for row in reader:
if "freq_mhz" in row:
freq = float(row["freq_mhz"])
elif "frequency_hz" in row:
freq = float(row["frequency_hz"]) / 1e6
else:
continue
if "loss_db" in row:
loss = float(row["loss_db"])
elif "s21_db" in row:
loss = -float(row["s21_db"]) # S21 is negative, loss is positive
else:
continue
cal[freq] = loss
return cal
def interpolate_loss(cal: dict, freq_mhz: float) -> float:
"""Interpolate path loss at a frequency from cal data."""
if not cal:
return 0.0
freqs = sorted(cal.keys())
if freq_mhz <= freqs[0]:
return cal[freqs[0]]
if freq_mhz >= freqs[-1]:
return cal[freqs[-1]]
for i in range(len(freqs) - 1):
if freqs[i] <= freq_mhz <= freqs[i + 1]:
f0, f1 = freqs[i], freqs[i + 1]
t = (freq_mhz - f0) / (f1 - f0)
return cal[f0] + t * (cal[f1] - cal[f0])
return 0.0
# --- Test: AGC Power Linearity ---
def test_agc_linearity(sw, atten, nanovna, freq_mhz: float,
writer, csv_file, cal: dict, settle_ms: int) -> list:
"""Sweep attenuator from 0 to 31.5 dB and record AGC at each step.
Maps the AGC transfer function: how AGC register values respond to
known changes in input power.
"""
print(f"\n=== AGC Linearity Test at {freq_mhz:.1f} MHz ===")
results = []
path_loss = interpolate_loss(cal, freq_mhz)
if path_loss > 0:
print(f" Calibrated path loss: {path_loss:.1f} dB")
# Set NanoVNA to CW at the test frequency
if nanovna:
nanovna.cw(frequency_hz=int(freq_mhz * 1e6), power=3)
print(f" NanoVNA CW: {freq_mhz:.3f} MHz, power=3")
else:
manual_nanovna_set(freq_mhz, power=3)
# Tune SkyWalker-1 to the frequency
freq_khz = int(freq_mhz * 1000)
print(f"\n {'Atten dB':>9} {'AGC1':>6} {'AGC2':>6} {'Power dB':>9} "
f"{'SNR raw':>8} {'Lock':>5}")
print(f" {'' * 9} {'' * 6} {'' * 6} {'' * 9} {'' * 8} {'' * 5}")
# Sweep in 0.5 dB steps from 0 to 31.5 dB (64 steps)
# Use integer step counter to avoid IEEE 754 float accumulation drift
for step in range(64): # 0, 1, 2, ... 63 → 0.0, 0.5, 1.0, ... 31.5
atten_db = step * 0.5
atten.set_db(atten_db)
time.sleep(settle_ms / 1000.0)
result = sw.tune_monitor(
symbol_rate_sps=1000000, freq_khz=freq_khz,
mod_index=0, fec_index=5, dwell_ms=50
)
locked = "Y" if result.get("locked") else "N"
print(f" {atten_db:9.1f} {result['agc1']:6d} {result['agc2']:6d} "
f"{result['power_db']:9.2f} {result['snr_raw']:8d} {locked:>5}")
effective_atten = atten_db + path_loss
note = f"effective_atten={effective_atten:.1f}dB"
if writer:
write_row(writer, csv_file, "agc_linearity", freq_mhz, atten_db,
result, note)
results.append({"atten_db": atten_db, **result})
# Summary
if results:
agc1_min = min(r["agc1"] for r in results)
agc1_max = max(r["agc1"] for r in results)
print(f"\n AGC1 range: {agc1_min} - {agc1_max} "
f"(delta={agc1_max - agc1_min}) over 31.5 dB sweep")
return results
# --- Test: IF Band Flatness ---
def test_band_flatness(sw, atten, nanovna, start_mhz: float,
stop_mhz: float, step_mhz: float,
writer, csv_file, cal: dict, settle_ms: int) -> list:
"""Sweep CW across the IF band and record AGC at each frequency.
Reveals tuner gain slope, passband ripple, and the IF filter response.
"""
atten_db = 10.0 # Fixed attenuation — mid-range for good dynamic range
print(f"\n=== IF Band Flatness: {start_mhz:.0f}-{stop_mhz:.0f} MHz, "
f"step={step_mhz:.1f} MHz ===")
print(f" HMC472A fixed at {atten_db:.1f} dB")
atten.set_db(atten_db)
results = []
# Use integer step counter to avoid float accumulation drift
n_steps = int(round((stop_mhz - start_mhz) / step_mhz)) + 1
print(f"\n {'Step':>5} {'Freq MHz':>9} {'AGC1':>6} {'AGC2':>6} "
f"{'Power dB':>9} {'PathLoss':>9} {'Corr dB':>8}")
print(f" {'' * 5} {'' * 9} {'' * 6} {'' * 6} "
f"{'' * 9} {'' * 9} {'' * 8}")
for step_num in range(n_steps):
freq_mhz = start_mhz + step_num * step_mhz
# Set NanoVNA CW
if nanovna:
nanovna.cw(frequency_hz=int(freq_mhz * 1e6), power=3)
else:
manual_nanovna_set(freq_mhz, power=3)
time.sleep(settle_ms / 1000.0)
# Tune SkyWalker-1
freq_khz = int(freq_mhz * 1000)
result = sw.tune_monitor(
symbol_rate_sps=1000000, freq_khz=freq_khz,
mod_index=0, fec_index=5, dwell_ms=50
)
path_loss = interpolate_loss(cal, freq_mhz)
corrected = result["power_db"] + path_loss
print(f" {step_num + 1:5d} {freq_mhz:9.1f} {result['agc1']:6d} "
f"{result['agc2']:6d} {result['power_db']:9.2f} "
f"{path_loss:9.1f} {corrected:8.2f}")
note = f"path_loss={path_loss:.1f}dB corrected={corrected:.2f}dB"
if writer:
write_row(writer, csv_file, "band_flatness", freq_mhz, atten_db,
result, note)
results.append({"freq_mhz": freq_mhz, "corrected_db": corrected, **result})
# Summary
if results:
powers = [r["corrected_db"] for r in results]
ripple = max(powers) - min(powers)
print(f"\n Band flatness: {ripple:.2f} dB ripple "
f"(min={min(powers):.2f}, max={max(powers):.2f})")
return results
# --- Test: Frequency Accuracy ---
def test_freq_accuracy(sw, atten, nanovna, test_freqs: list,
writer, csv_file, settle_ms: int) -> list:
"""Inject CW at known frequencies, sweep SkyWalker-1 around each one.
Compares detected peak vs. injected frequency to characterize the
BCM3440 tuner's frequency accuracy.
"""
print(f"\n=== Frequency Accuracy Test ===")
atten_db = 10.0
atten.set_db(atten_db)
results = []
sweep_span_mhz = 10.0 # Sweep +/- 5 MHz around each test freq
sweep_step_mhz = 1.0
for inject_freq in test_freqs:
print(f"\n Injecting CW at {inject_freq:.3f} MHz...")
if nanovna:
nanovna.cw(frequency_hz=int(inject_freq * 1e6), power=3)
else:
manual_nanovna_set(inject_freq, power=3)
time.sleep(settle_ms / 1000.0)
# Sweep around the expected frequency
sweep_start = inject_freq - sweep_span_mhz / 2
sweep_stop = inject_freq + sweep_span_mhz / 2
freqs, powers, raw = sw.sweep_spectrum(
sweep_start, sweep_stop,
step_mhz=sweep_step_mhz, dwell_ms=50,
sr_ksps=1000, mod_index=0, fec_index=5,
)
# Find peak
if powers:
peak_idx = max(range(len(powers)), key=lambda i: powers[i])
peak_freq = freqs[peak_idx]
peak_power = powers[peak_idx]
error_mhz = peak_freq - inject_freq
error_khz = error_mhz * 1000
print(f" Injected: {inject_freq:.3f} MHz "
f"Detected peak: {peak_freq:.3f} MHz "
f"Error: {error_khz:+.0f} kHz")
result_entry = {
"inject_freq_mhz": inject_freq,
"peak_freq_mhz": peak_freq,
"error_khz": error_khz,
"peak_power_db": peak_power,
}
results.append(result_entry)
if writer:
peak_result = raw[peak_idx] if isinstance(raw[peak_idx], dict) else {
"agc1": 0, "agc2": 0, "power_db": peak_power,
"snr_raw": 0, "snr_db": 0,
"locked": False, "lock": 0, "status": 0,
}
write_row(writer, csv_file, "freq_accuracy", inject_freq,
atten_db, peak_result,
f"peak={peak_freq:.3f}MHz error={error_khz:+.0f}kHz")
# Summary
if results:
errors = [r["error_khz"] for r in results]
mean_err = sum(errors) / len(errors)
max_err = max(abs(e) for e in errors)
print(f"\n Mean frequency error: {mean_err:+.0f} kHz")
print(f" Max absolute error: {max_err:.0f} kHz")
return results
# --- Test: Minimum Detectable Signal ---
def test_mds(sw, atten, nanovna, freq_mhz: float,
writer, csv_file, settle_ms: int) -> dict:
"""Find the minimum detectable signal level.
Measures noise floor with NanoVNA off (or max attenuation), then
increases attenuation from 0 until the CW signal is indistinguishable
from noise.
"""
print(f"\n=== Minimum Detectable Signal at {freq_mhz:.1f} MHz ===")
freq_khz = int(freq_mhz * 1000)
# Step 1: measure noise floor (max attenuation)
print(" Measuring noise floor (31.5 dB attenuation)...")
atten.set_db(31.5)
time.sleep(0.2)
noise_readings = []
for _ in range(10):
r = sw.tune_monitor(1000000, freq_khz, 0, 5, 50)
noise_readings.append(r["power_db"])
time.sleep(0.05)
noise_floor = sum(noise_readings) / len(noise_readings)
noise_std = (sum((x - noise_floor) ** 2 for x in noise_readings)
/ len(noise_readings)) ** 0.5
threshold = noise_floor + max(3.0 * noise_std, 1.0) # 3-sigma above noise
print(f" Noise floor: {noise_floor:.2f} dB (std={noise_std:.3f})")
print(f" Detection threshold: {threshold:.2f} dB (noise + 3sigma)")
# Step 2: inject CW and increase attenuation until signal disappears
if nanovna:
nanovna.cw(frequency_hz=int(freq_mhz * 1e6), power=3)
print(f" NanoVNA CW: {freq_mhz:.3f} MHz, power=3")
else:
manual_nanovna_set(freq_mhz, power=3)
print(f"\n {'Atten dB':>9} {'Power dB':>9} {'Above noise':>12} {'Detected':>9}")
print(f" {'' * 9} {'' * 9} {'' * 12} {'' * 9}")
mds_atten = None
# 1 dB steps: 0, 1, 2, ... 31 (32 steps)
for step in range(32):
atten_db = float(step)
atten.set_db(atten_db)
time.sleep(settle_ms / 1000.0)
# Average 5 readings for stability
readings = []
r = sw.tune_monitor(1000000, freq_khz, 0, 5, 50)
readings.append(r["power_db"])
for _ in range(4):
time.sleep(0.02)
r = sw.tune_monitor(1000000, freq_khz, 0, 5, 50)
readings.append(r["power_db"])
avg_power = sum(readings) / len(readings)
above_noise = avg_power - noise_floor
detected = avg_power > threshold
marker = "YES" if detected else "---"
print(f" {atten_db:9.1f} {avg_power:9.2f} {above_noise:+12.2f} "
f"{marker:>9}")
if writer:
# Use averaged power instead of last single reading
avg_result = dict(r)
avg_result["power_db"] = avg_power
write_row(writer, csv_file, "mds", freq_mhz, atten_db,
avg_result,
f"avg={avg_power:.2f} noise={noise_floor:.2f} "
f"detected={'Y' if detected else 'N'}")
if not detected and mds_atten is None:
mds_atten = atten_db
result = {
"freq_mhz": freq_mhz,
"noise_floor_db": noise_floor,
"noise_std": noise_std,
"threshold_db": threshold,
"mds_atten_db": mds_atten,
}
if mds_atten is not None:
print(f"\n Signal lost at {mds_atten:.1f} dB attenuation")
print(f" (NanoVNA output ~-15 dBm minus {mds_atten:.1f} dB path = "
f"~{-15 - mds_atten:.0f} dBm at receiver)")
else:
print(f"\n Signal detected at all attenuation levels (0-31.5 dB)")
print(f" Need more attenuation to find MDS")
return result
# --- Test: BPSK Mode 9 CW Probe ---
def test_bpsk_probe(sw, atten, nanovna, freq_mhz: float,
writer, csv_file, settle_ms: int) -> dict:
"""Probe BPSK mode 9 response to an unmodulated CW carrier.
BPSK mode (index 9) uses Viterbi rate 1/2 K=7 the same inner FEC
as GOES LRIT. A CW carrier has no modulation, so the demodulator
shouldn't lock, but the AGC and carrier recovery behavior reveals
how mode 9 handles a clean carrier.
"""
print(f"\n=== BPSK Mode 9 CW Probe at {freq_mhz:.1f} MHz ===")
bpsk_index = MODULATIONS["bpsk"][0] # Mode 9
freq_khz = int(freq_mhz * 1000)
atten_db = 10.0
atten.set_db(atten_db)
if nanovna:
nanovna.cw(frequency_hz=int(freq_mhz * 1e6), power=3)
else:
manual_nanovna_set(freq_mhz, power=3)
time.sleep(settle_ms / 1000.0)
# Test with different symbol rates typical of LRIT-like signals
test_rates = [293883, 500000, 1000000, 5000000]
print(f"\n {'SR (sps)':>10} {'AGC1':>6} {'AGC2':>6} {'Power dB':>9} "
f"{'SNR raw':>8} {'SNR dB':>7} {'Lock':>6} {'Status':>8}")
print(f" {'' * 10} {'' * 6} {'' * 6} {'' * 9} "
f"{'' * 8} {'' * 7} {'' * 6} {'' * 8}")
results = []
for sr in test_rates:
# FEC 1/2 (index 0) for BPSK mode
result = sw.tune_monitor(sr, freq_khz, bpsk_index, 0, dwell_ms=100)
locked = "Y" if result.get("locked") else "N"
print(f" {sr:10d} {result['agc1']:6d} {result['agc2']:6d} "
f"{result['power_db']:9.2f} {result['snr_raw']:8d} "
f"{result['snr_db']:7.2f} {locked:>6} "
f"0x{result.get('status', 0):02X}")
if writer:
write_row(writer, csv_file, "bpsk_probe", freq_mhz, atten_db,
result, f"mode=bpsk sr={sr} fec=1/2")
results.append({"symbol_rate": sr, **result})
# Compare with QPSK mode 0 at same settings
print(f"\n Reference: QPSK mode 0 at same frequency")
ref = sw.tune_monitor(1000000, freq_khz, 0, 5, dwell_ms=100)
ref_locked = "Y" if ref.get("locked") else "N"
print(f" {'1000000':>10} {ref['agc1']:6d} {ref['agc2']:6d} "
f"{ref['power_db']:9.2f} {ref['snr_raw']:8d} "
f"{ref['snr_db']:7.2f} {ref_locked:>6} "
f"0x{ref.get('status', 0):02X}")
return {"bpsk_results": results, "qpsk_reference": ref}
# --- Main ---
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="rf_testbench.py",
description="CW injection test bench: NanoVNA + HMC472A + SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s agc-linearity --freq 1200
%(prog)s band-flatness --start 950 --stop 1500 --step 10
%(prog)s freq-accuracy --freqs 1000,1200,1400
%(prog)s mds --freq 1200
%(prog)s bpsk-probe --freq 1200
%(prog)s band-flatness --attenuator /dev/ttyACM1
%(prog)s agc-linearity --attenuator http://attenuator.local --freq 1200
hardware setup:
NanoVNA CH0 DC Blocker HMC472A (0-31.5 dB) SMA-to-F SkyWalker-1
The HMC472A is controlled via USB serial (preferred) or HTTP REST API.
Use --attenuator auto (default) to auto-detect USB, falling back to HTTP.
The NanoVNA provides CW, controlled via mcnanovna or manually.
LNB power is disabled (direct L-band input mode).
""",
)
parser.add_argument("-v", "--verbose", action="store_true",
help="Show raw USB traffic")
parser.add_argument("-o", "--output", type=str, default=None,
help="CSV output file path")
parser.add_argument("--cal", type=str, default=None,
help="Path loss calibration CSV (NanoVNA S21 sweep)")
parser.add_argument("--attenuator", type=str, default="auto",
help="HMC472A connection: 'auto' (USB then HTTP), "
"/dev/ttyACMx (USB serial), http://host (REST), "
"or 'fixed:20' for a non-programmable pad "
"(default: auto)")
parser.add_argument("--nanovna", choices=["auto", "manual"],
default="auto",
help="NanoVNA control mode (default: auto via mcnanovna)")
parser.add_argument("--settle", type=int, default=200,
help="Settle time in ms after changing attenuation "
"(default: 200)")
sub = parser.add_subparsers(dest="test", required=True)
# AGC linearity
p_agc = sub.add_parser("agc-linearity",
help="Sweep attenuation at fixed freq, map AGC curve")
p_agc.add_argument("--freq", type=float, default=1200.0,
help="Test frequency in MHz (default: 1200)")
# Band flatness
p_band = sub.add_parser("band-flatness",
help="Sweep CW across IF band, measure AGC response")
p_band.add_argument("--start", type=float, default=950.0,
help="Start frequency in MHz (default: 950)")
p_band.add_argument("--stop", type=float, default=1500.0,
help="Stop frequency in MHz (default: 1500)")
p_band.add_argument("--step", type=float, default=10.0,
help="Frequency step in MHz (default: 10)")
# Frequency accuracy
p_freq = sub.add_parser("freq-accuracy",
help="Inject CW at known freqs, measure error")
p_freq.add_argument("--freqs", type=str, default="1000,1100,1200,1300,1400",
help="Comma-separated test frequencies in MHz")
# Minimum detectable signal
p_mds = sub.add_parser("mds",
help="Find minimum detectable signal level")
p_mds.add_argument("--freq", type=float, default=1200.0,
help="Test frequency in MHz (default: 1200)")
# BPSK mode 9 probe
p_bpsk = sub.add_parser("bpsk-probe",
help="Probe BPSK mode 9 with CW carrier")
p_bpsk.add_argument("--freq", type=float, default=1200.0,
help="Test frequency in MHz (default: 1200)")
return parser
def _connect_attenuator(target: str):
"""Connect to HMC472A via auto-detect, USB serial, HTTP REST, or fixed pad.
Args:
target: "auto", "fixed:XX" (dB), /dev/ttyACM* (USB), or http://... (REST)
"""
# Fixed attenuator mode (non-programmable inline pad)
if target.startswith("fixed:"):
try:
fixed_db = float(target.split(":", 1)[1])
except ValueError:
print(f"HMC472A: invalid fixed value '{target}' (use fixed:20)")
sys.exit(1)
atten = FixedAttenuator(fixed_db)
print(f"HMC472A: fixed {fixed_db:.1f} dB pad (non-programmable)")
return atten
# Auto-detect: try USB serial first, fall back to HTTP
if target == "auto":
port = detect_hmc472a_serial()
if port:
print(f"HMC472A: auto-detected USB serial on {port}")
target = port
else:
print("HMC472A: no USB device found, trying HTTP...")
target = "http://attenuator.local"
# USB serial path
if target.startswith("/dev/"):
try:
atten = HMC472ASerial(target)
info = atten.identify()
print(f"HMC472A: USB serial on {target} "
f"(v{info.get('version', '?')}, "
f"protocol {info.get('protocol', '?')})")
return atten
except ImportError:
print("HMC472A: pyserial not installed (pip install pyserial)")
sys.exit(1)
except (OSError, TimeoutError) as e:
print(f"HMC472A: cannot open {target} ({e})")
sys.exit(1)
# HTTP REST API
atten = HMC472A(target)
try:
cfg = atten.config()
print(f"HMC472A: HTTP on {target} ({cfg.get('hostname', '?')}, "
f"v{cfg.get('version', '?')})")
return atten
except (URLError, OSError) as e:
print(f"HMC472A: cannot reach {target} ({e})")
print(" Use --attenuator /dev/ttyACMx (USB) or http://host (HTTP)")
sys.exit(1)
def main():
parser = build_parser()
args = parser.parse_args()
# Mock mode for testing without hardware
mock_mode = os.environ.get("SKYWALKER_MOCK")
# Set up HMC472A attenuator
if mock_mode:
atten = MockHMC472A()
print("HMC472A: mock mode")
else:
atten = _connect_attenuator(args.attenuator)
# Set up NanoVNA
nanovna = None
if mock_mode:
nanovna = MockNanoVNA()
print("NanoVNA: mock mode")
elif args.nanovna == "auto":
NanoVNA = try_import_nanovna()
if NanoVNA:
try:
nanovna = NanoVNA()
print(f"NanoVNA: auto mode (mcnanovna)")
except Exception as e:
print(f"NanoVNA: mcnanovna failed ({e}), falling back to manual")
else:
print("NanoVNA: mcnanovna not installed, using manual mode")
print(" Install: uv pip install -e /path/to/mcnanovna")
else:
print("NanoVNA: manual mode (you'll be prompted to set frequencies)")
# Load calibration
cal = {}
if args.cal:
cal = load_cal_file(args.cal)
print(f"Calibration: loaded {len(cal)} points from {args.cal}")
# Open CSV output
csv_file = None
writer = None
if args.output:
csv_file, writer = open_csv(args.output)
# Open SkyWalker-1
# SAFETY: Boot demodulator WITHOUT enabling LNB power. ensure_booted()
# transiently enables LNB voltage (13-18V on the F-connector), which
# would travel backward through the attenuator toward the NanoVNA.
# The DC blocker protects against this, but code should never rely on
# external protection it cannot verify.
if mock_mode:
sw = _make_mock_skywalker(args.verbose)
print("SkyWalker-1: mock mode")
else:
sw = SkyWalker1(verbose=args.verbose)
sw.open()
# Ensure LNB power is OFF before booting demodulator
sw.start_intersil(on=False)
status = sw.get_config()
if not (status & 0x01):
sw.boot(on=True)
time.sleep(0.5)
status = sw.get_config()
if not (status & 0x01):
print("ERROR: Device failed to start")
sys.exit(1)
print("SkyWalker-1: booted (LNB power kept OFF)")
# Confirm LNB power disabled — direct input mode
sw.start_intersil(on=False)
print("LNB power disabled (direct input mode)")
print()
try:
if args.test == "agc-linearity":
test_agc_linearity(sw, atten, nanovna, args.freq,
writer, csv_file, cal, args.settle)
elif args.test == "band-flatness":
test_band_flatness(sw, atten, nanovna, args.start, args.stop,
args.step, writer, csv_file, cal, args.settle)
elif args.test == "freq-accuracy":
freqs = [float(f) for f in args.freqs.split(",")]
test_freq_accuracy(sw, atten, nanovna, freqs,
writer, csv_file, args.settle)
elif args.test == "mds":
test_mds(sw, atten, nanovna, args.freq,
writer, csv_file, args.settle)
elif args.test == "bpsk-probe":
test_bpsk_probe(sw, atten, nanovna, args.freq,
writer, csv_file, args.settle)
except KeyboardInterrupt:
print("\n\nInterrupted by operator.")
finally:
# Safe state: maximum attenuation before releasing hardware
try:
atten.set_db(31.5)
print("Attenuator set to 31.5 dB (safe state)")
except Exception:
pass # best-effort on cleanup path
if csv_file:
csv_file.flush()
csv_file.close()
print(f"\nData saved to {args.output}")
if not mock_mode:
sw.close()
if __name__ == "__main__":
main()

View File

@ -377,8 +377,12 @@ class SkyWalker1:
# -- Power and boot -- # -- Power and boot --
def boot(self, on: bool = True) -> int: def boot(self, on: bool = True) -> int:
"""Power on/off the 8PSK demodulator.""" """Power on/off the 8PSK demodulator.
data = self._vendor_in(CMD_BOOT_8PSK, value=int(on), length=1)
Custom firmware returns 3 bytes: [config_status, boot_stage, debug].
Stock firmware returns 1 byte. Request 3 to handle both.
"""
data = self._vendor_in(CMD_BOOT_8PSK, value=int(on), length=3)
return data[0] return data[0]
def start_intersil(self, on: bool = True) -> int: def start_intersil(self, on: bool = True) -> int:

210
tools/stock_fw_compare.py Normal file
View File

@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Compare BCM4500 register behavior: stock EEPROM firmware vs custom firmware.
USAGE:
1. Power cycle the SkyWalker-1 (unplug/replug USB)
2. Run this script IMMEDIATELY (before loading custom FW)
3. The script tests registers under stock firmware first,
then loads custom firmware and tests again.
If stock firmware shows different register behavior, the issue is
in our custom firmware's boot sequence.
"""
import sys
import time
import usb.core
sys.path.insert(0, 'tools')
VID = 0x09C0
PID = 0x0203
BCM4500_ADDR = 0x08
# ============================================================
# Raw USB helpers (work with any firmware)
# ============================================================
def vendor_in(dev, cmd, value=0, index=0, length=1):
"""Send a vendor IN request and return the response bytes."""
return dev.ctrl_transfer(
0xC0, # bmRequestType: vendor, device-to-host
cmd, # bRequest
value, # wValue
index, # wIndex
length # wLength
)
def vendor_out(dev, cmd, value=0, index=0, data=None):
"""Send a vendor OUT request."""
dev.ctrl_transfer(
0x40, # bmRequestType: vendor, host-to-device
cmd, # bRequest
value, # wValue
index, # wIndex
data if data else b''
)
def read_bcm_reg(dev, reg):
"""Read one BCM4500 register via stock-compatible I2C read (0xB5).
This might not exist on stock firmware, so we use the
stock READ_8PSK_REG (0x81) as a fallback."""
try:
data = vendor_in(dev, 0xB5, value=BCM4500_ADDR, index=reg, length=1)
return data[0]
except Exception:
return None
def read_bcm_reg_stock(dev, reg):
"""Read BCM4500 register via stock READ_8PSK_REG (0x81).
wValue = register address, returns 1 byte."""
try:
data = vendor_in(dev, 0x81, value=reg, index=0, length=1)
return data[0]
except Exception:
return None
def read_all_key_regs(dev, method='0xB5'):
"""Read key BCM4500 registers."""
regs = [0xA0, 0xA2, 0xA4, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB]
results = {}
for reg in regs:
if method == '0xB5':
val = read_bcm_reg(dev, reg)
else:
val = read_bcm_reg_stock(dev, reg)
results[reg] = val
v_str = f'0x{val:02X}' if val is not None else 'FAIL'
print(f' 0x{reg:02X}: {v_str}')
return results
# ============================================================
# MAIN
# ============================================================
print('=== Stock vs Custom Firmware BCM4500 Comparison ===')
print()
# Find the device
dev = usb.core.find(idVendor=VID, idProduct=PID)
if dev is None:
print('ERROR: SkyWalker-1 not found!')
print('Make sure to power cycle the device first (stock firmware must be running)')
sys.exit(1)
print(f'Found SkyWalker-1: Bus {dev.bus} Addr {dev.address}')
print(f' VID=0x{dev.idVendor:04X} PID=0x{dev.idProduct:04X}')
# Try to get firmware version (our custom command)
try:
fw = vendor_in(dev, 0x80, value=0, index=0, length=3)
print(f' Firmware response (0x80): {list(fw)}')
except Exception as e:
print(f' Firmware version (0x80): {e}')
# ============================================================
# Phase 1: Test under stock firmware (before boot command)
# ============================================================
print('\n' + '='*60)
print('PHASE 1: Stock firmware — BEFORE boot command')
print('='*60)
print('\n Registers via 0x81 (READ_8PSK_REG):')
regs_stock_pre_81 = {}
for reg in [0xA0, 0xA2, 0xA4, 0xA6, 0xA7, 0xA8]:
val = read_bcm_reg_stock(dev, reg)
regs_stock_pre_81[reg] = val
v_str = f'0x{val:02X}' if val is not None else 'FAIL'
print(f' 0x{reg:02X}: {v_str}')
print('\n Registers via 0xB5 (I2C_RAW_READ) — may fail on stock FW:')
regs_stock_pre_b5 = {}
for reg in [0xA0, 0xA2, 0xA4, 0xA6, 0xA7, 0xA8]:
val = read_bcm_reg(dev, reg)
regs_stock_pre_b5[reg] = val
v_str = f'0x{val:02X}' if val is not None else 'N/A (stock FW lacks 0xB5)'
print(f' 0x{reg:02X}: {v_str}')
# ============================================================
# Phase 2: Boot BCM4500 under stock firmware
# ============================================================
print('\n' + '='*60)
print('PHASE 2: Stock firmware — boot BCM4500 (0x89)')
print('='*60)
try:
result = vendor_in(dev, 0x89, value=1, index=0, length=3)
print(f' Boot result: [{result[0]:02X}, {result[1]:02X}, {result[2]:02X}]')
cfg = result[0]
bits = []
if cfg & 0x01: bits.append('Started')
if cfg & 0x02: bits.append('FW_Loaded')
if cfg & 0x04: bits.append('Intersil')
if cfg & 0x08: bits.append('DVBmode')
print(f' Config: 0x{cfg:02X} ({" | ".join(bits) if bits else "none"})')
print(f' Stage: 0x{result[1]:02X}')
except Exception as e:
print(f' Boot failed: {e}')
time.sleep(0.5)
print('\n Registers via 0x81 after boot:')
regs_stock_post_81 = {}
for reg in [0xA0, 0xA2, 0xA4, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB]:
val = read_bcm_reg_stock(dev, reg)
regs_stock_post_81[reg] = val
v_str = f'0x{val:02X}' if val is not None else 'FAIL'
marker = ''
if val is not None and reg in regs_stock_pre_81:
if regs_stock_pre_81.get(reg) != val:
marker = f' (was 0x{regs_stock_pre_81[reg]:02X})' if regs_stock_pre_81[reg] is not None else ''
print(f' 0x{reg:02X}: {v_str}{marker}')
# Try indirect read via stock firmware's 0x81 with indirect addressing
# (0x81 might support indirect reads differently)
print('\n Testing indirect register reads via 0x81:')
for page in [0x00, 0x06, 0x07, 0x0F]:
# Stock firmware might use a different convention for indirect reads
# Try reading page register values
val = read_bcm_reg_stock(dev, page)
v_str = f'0x{val:02X}' if val is not None else 'FAIL'
print(f' Page 0x{page:02X} via 0x81: {v_str}')
# Try signal monitor
print('\n Signal status:')
try:
sig = vendor_in(dev, 0x82, value=0, index=0, length=4)
print(f' GET_8PSK_SIGNAL (0x82): {list(sig)}')
except Exception as e:
print(f' 0x82: {e}')
try:
lock = vendor_in(dev, 0x83, value=0, index=0, length=1)
print(f' GET_8PSK_LOCK (0x83): 0x{lock[0]:02X}')
except Exception as e:
print(f' 0x83: {e}')
# ============================================================
# Summary
# ============================================================
print('\n' + '='*60)
print('SUMMARY')
print('='*60)
def compare_regs(label, regs):
vals = set(v for v in regs.values() if v is not None)
if len(vals) == 1:
print(f' {label}: ALL = 0x{list(vals)[0]:02X}')
elif len(vals) == 0:
print(f' {label}: ALL FAILED')
else:
print(f' {label}: Mixed: {", ".join(f"0x{v:02X}" for v in sorted(vals))}')
compare_regs('Stock FW before boot (0x81)', regs_stock_pre_81)
compare_regs('Stock FW after boot (0x81)', regs_stock_post_81)
print()
print('If stock FW shows DIFFERENT register values (not all 0x02),')
print('then the BCM4500 is truly functional under stock FW and our')
print('custom boot sequence is missing something.')
print()
print('If stock FW also shows all 0x02, then the register behavior')
print('is normal and the 0x02 IS the legitimate power-on value.')
print('\n=== Done ===')

342
tools/stock_fw_test.py Normal file
View File

@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Stock firmware BCM4500 boot diagnostic.
Runs the stock v2.06 firmware boot sequence (same as kernel gp8psk driver),
then performs comprehensive register dumps to capture the BCM4500 state
including the critical A9/AA/AB PLL registers written by FUN_CODE_10F2
(which our custom firmware currently skips).
Also scans the I2C bus to find device 0x51 (calibration EEPROM) that the
stock firmware reads PLL configuration data from.
Usage:
python tools/stock_fw_test.py [--dump-all] [--i2c-scan]
Requirements:
- SkyWalker-1 connected via USB (stock firmware loaded)
- pyusb installed
"""
import sys
import struct
try:
import usb.core
import usb.util
except ImportError:
print("pyusb required: pip install pyusb")
sys.exit(1)
# USB IDs
VID = 0x09C0
PID = 0x0203
# Stock firmware vendor commands (same as kernel driver)
GET_8PSK_CONFIG = 0x80
I2C_WRITE = 0x83
I2C_READ = 0x84
ARM_TRANSFER = 0x85
TUNE_8PSK = 0x86
GET_SIGNAL_STRENGTH = 0x87
BOOT_8PSK = 0x89
START_INTERSIL = 0x8A
SET_LNB_VOLTAGE = 0x8B
GET_SIGNAL_LOCK = 0x90
GET_FW_VERS = 0x92
# Config status bits
BM_STARTED = 0x01
BM_FW_LOADED = 0x02
BM_INTERSIL = 0x04
# I2C addresses (7-bit)
BCM4500_ADDR = 0x08
BCM3440_ADDR = 0x10
EEPROM_ADDR = 0x51 # Calibration EEPROM found in FUN_CODE_10F2 disassembly
def vendor_in(dev, cmd, value=0, index=0, length=1):
"""Send a vendor IN control transfer (device -> host)."""
return bytes(dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
cmd, value, index, length, 2000))
def vendor_out(dev, cmd, value=0, index=0, data=None):
"""Send a vendor OUT control transfer (host -> device)."""
dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_OUT,
cmd, value, index, data or b'', 2000)
def decode_config(cfg):
"""Decode config status byte to human-readable string."""
bits = []
names = [
(0x01, "Started"), (0x02, "FW_Loaded"), (0x04, "Intersil"),
(0x08, "DVBmode"), (0x10, "22kHz"), (0x20, "18V"),
(0x40, "DCtuned"), (0x80, "Armed"),
]
for mask, name in names:
if cfg & mask:
bits.append(name)
return " | ".join(bits) if bits else "(none)"
def i2c_read(dev, addr, reg, length=1):
"""Read from I2C device via stock firmware 0x84 command."""
return vendor_in(dev, I2C_READ, value=addr, index=reg, length=length)
def i2c_scan(dev, start=0x03, end=0x77):
"""Scan I2C bus for responding devices using stock firmware I2C_READ."""
found = []
for addr in range(start, end + 1):
try:
val = i2c_read(dev, addr, 0x00, length=1)
found.append((addr, val[0]))
except usb.core.USBError:
pass
return found
def dump_bcm4500_direct_regs(dev):
"""Read all BCM4500 direct registers 0xA0-0xBF after boot.
These are the PLL/config registers. FUN_CODE_10F2 writes to A0, A9, AA, AB
during boot from calibration EEPROM data. Our custom firmware skips this.
"""
regs = {}
for reg in range(0xA0, 0xC0):
try:
val = i2c_read(dev, BCM4500_ADDR, reg, length=1)
regs[reg] = val[0]
except usb.core.USBError:
regs[reg] = None
return regs
def main():
dump_all = "--dump-all" in sys.argv
do_scan = "--i2c-scan" in sys.argv or dump_all
print("=" * 60)
print("Stock Firmware BCM4500 Boot Diagnostic")
print("=" * 60)
# --- Step 0: Find device ---
dev = usb.core.find(idVendor=VID, idProduct=PID)
if dev is None:
print("\nERROR: SkyWalker-1 not found on USB")
sys.exit(1)
print(f"\nFound SkyWalker-1: Bus {dev.bus} Addr {dev.address}")
product = usb.util.get_string(dev, dev.iProduct) if dev.iProduct else "?"
serial = usb.util.get_string(dev, dev.iSerialNumber) if dev.iSerialNumber else "?"
print(f" Product: {product}")
print(f" Serial: {serial}")
# Detach kernel driver if attached
for cfg in dev:
for intf in cfg:
if dev.is_kernel_driver_active(intf.bInterfaceNumber):
dev.detach_kernel_driver(intf.bInterfaceNumber)
try:
dev.set_configuration()
except Exception:
pass
# --- Step 1: Pre-boot state ---
print("\n--- Step 1: Pre-boot state ---")
cfg = vendor_in(dev, GET_8PSK_CONFIG, length=1)
print(f" Config: 0x{cfg[0]:02X} = {decode_config(cfg[0])}")
try:
ver = vendor_in(dev, GET_FW_VERS, length=6)
print(f" FW Version: {ver[2]}.{ver[0]:02d}.{ver[1]}")
except Exception as e:
print(f" FW Version: read failed ({e})")
# --- Step 2: Boot BCM4500 (kernel driver sequence) ---
print("\n--- Step 2: BOOT_8PSK (0x89, wValue=1) ---")
try:
result = vendor_in(dev, BOOT_8PSK, value=1, length=1)
print(f" Boot response: 0x{result[0]:02X} = {decode_config(result[0])}")
except usb.core.USBError as e:
print(f" Boot FAILED: {e}")
try:
result = vendor_in(dev, BOOT_8PSK, value=1, length=3)
print(f" Boot response (3 bytes): {result.hex(' ')}")
except usb.core.USBError as e2:
print(f" Boot also failed with 3 bytes: {e2}")
sys.exit(1)
cfg = vendor_in(dev, GET_8PSK_CONFIG, length=1)
started = bool(cfg[0] & BM_STARTED)
fw_loaded = bool(cfg[0] & BM_FW_LOADED)
print(f" Config after boot: 0x{cfg[0]:02X} = {decode_config(cfg[0])}")
print(f" Started: {started}, FW Loaded: {fw_loaded}")
# --- Step 3: Enable LNB power supply (Intersil) ---
print("\n--- Step 3: START_INTERSIL (0x8A, wValue=1) ---")
try:
result = vendor_in(dev, START_INTERSIL, value=1, length=1)
print(f" Intersil response: 0x{result[0]:02X} = {decode_config(result[0])}")
except usb.core.USBError as e:
print(f" Intersil FAILED: {e}")
# --- Step 4: Cancel pending MPEG transfers ---
print("\n--- Step 4: ARM_TRANSFER (0x85, wValue=0) ---")
try:
vendor_out(dev, ARM_TRANSFER, value=0)
print(" OK")
except usb.core.USBError as e:
print(f" Failed: {e}")
cfg = vendor_in(dev, GET_8PSK_CONFIG, length=1)
print(f" Final config: 0x{cfg[0]:02X} = {decode_config(cfg[0])}")
# --- Step 5: Signal strength (indirect register reads) ---
print("\n--- Step 5: Signal reads ---")
sig_all_zero = True
try:
sig = vendor_in(dev, GET_SIGNAL_STRENGTH, length=6)
snr_raw = struct.unpack_from('<H', sig, 0)[0]
sig_all_zero = all(b == 0 for b in sig)
print(f" Signal strength (0x87): {sig.hex(' ')}")
print(f" SNR raw: 0x{snr_raw:04X} ({snr_raw}), all_zero: {sig_all_zero}")
except usb.core.USBError as e:
print(f" Signal strength read failed: {e}")
try:
lock = vendor_in(dev, GET_SIGNAL_LOCK, length=1)
print(f" Lock status (0x90): 0x{lock[0]:02X} {'LOCKED' if lock[0] else 'no lock'}")
except usb.core.USBError as e:
print(f" Lock status read failed: {e}")
# --- Step 6: FULL BCM4500 direct register dump (0xA0-0xBF) ---
# This captures the PLL/clock values written by FUN_CODE_10F2
print("\n--- Step 6: BCM4500 direct registers 0xA0-0xBF (post-boot) ---")
print(" (A0=config_mode, A9/AA/AB=PLL from EEPROM, A6/A7/A8=indirect)")
regs = dump_bcm4500_direct_regs(dev)
for reg in sorted(regs.keys()):
val = regs[reg]
if val is None:
print(f" 0x{reg:02X}: FAILED")
continue
# Check for echo pattern (dead core symptom)
echo_expected = min(reg & 0xFE, 0x7E) if reg > 0x7E else (reg & 0xFE)
markers = []
if val == echo_expected:
markers.append("ECHO")
if reg in (0xA9, 0xAA, 0xAB):
markers.append("★ PLL")
if reg == 0xA0:
markers.append("CONFIG_MODE")
tag = f" ({', '.join(markers)})" if markers else ""
print(f" 0x{reg:02X}: 0x{val:02X}{tag}")
# Highlight the critical PLL values
a9 = regs.get(0xA9)
aa = regs.get(0xAA)
ab = regs.get(0xAB)
a0 = regs.get(0xA0)
print(f"\n *** Critical PLL registers ***")
print(f" A0 (config mode): 0x{a0:02X}" if a0 is not None else " A0: FAILED")
print(f" A9 (PLL div?): 0x{a9:02X}" if a9 is not None else " A9: FAILED")
print(f" AA (PLL div?): 0x{aa:02X}" if aa is not None else " AA: FAILED")
print(f" AB (PLL cfg?): 0x{ab:02X}" if ab is not None else " AB: FAILED")
# Try multi-byte read of AB (FUN_CODE_10F2 writes variable-length data here)
print("\n AB multi-byte read (up to 8 bytes):")
try:
ab_multi = i2c_read(dev, BCM4500_ADDR, 0xAB, length=8)
print(f" {ab_multi.hex(' ')}")
except usb.core.USBError as e:
print(f" FAILED: {e}")
# --- Step 7: Other known registers ---
print("\n--- Step 7: Other BCM4500 registers ---")
other_regs = [(0xF0, "F0"), (0xF8, "F8"), (0xF9, "F9")]
for reg, name in other_regs:
try:
val = i2c_read(dev, BCM4500_ADDR, reg, length=1)
echo = min(reg & 0xFE, 0x7E)
marker = " (ECHO)" if val[0] == echo else ""
print(f" Reg 0x{name}: 0x{val[0]:02X}{marker}")
except usb.core.USBError as e:
print(f" Reg 0x{name}: FAILED ({e})")
# --- Step 8: BCM3440 tuner control read ---
print("\n--- Step 8: BCM3440 tuner control read (@ 0x10) ---")
tuner_ok = False
try:
val = i2c_read(dev, BCM3440_ADDR, 0x00, length=4)
tuner_ok = not all(b == 0 for b in val)
print(f" Tuner regs 0x00-0x03: {val.hex(' ')} {'(OK)' if tuner_ok else '(all zero!)'}")
except usb.core.USBError as e:
print(f" Tuner read failed: {e}")
# --- Step 9: I2C bus scan ---
if do_scan:
print("\n--- Step 9: I2C bus scan (0x03-0x77) ---")
print(" Scanning for all responding devices...")
devices = i2c_scan(dev)
if devices:
for addr, first_byte in devices:
label = ""
if addr == BCM4500_ADDR:
label = " ← BCM4500 demod"
elif addr == BCM3440_ADDR:
label = " ← BCM3440 tuner"
elif addr == 0x28:
label = " ← EEPROM? (0x51 wire >> 1)"
elif 0x50 <= addr <= 0x57:
label = " ← EEPROM range"
elif addr == EEPROM_ADDR:
label = " ← device 0x51 from FUN_CODE_10F2!"
print(f" 0x{addr:02X} (7-bit) = 0x{addr << 1:02X}/{addr << 1 | 1:02X} (wire) "
f"first byte: 0x{first_byte:02X}{label}")
else:
print(" No devices found!")
# Try reading from device 0x51 specifically (calibration EEPROM)
print("\n --- Device 0x51 probe (calibration EEPROM) ---")
for try_addr in [EEPROM_ADDR, 0x50, 0x28, 0x29]:
try:
val = i2c_read(dev, try_addr, 0x00, length=16)
print(f" Addr 0x{try_addr:02X} reg 0x00 (16 bytes): {val.hex(' ')}")
# If we get data, try reading more
if not all(b == 0xFF for b in val):
val2 = i2c_read(dev, try_addr, 0x00, length=64)
print(f" Addr 0x{try_addr:02X} reg 0x00 (64 bytes):")
for row in range(0, len(val2), 16):
chunk = val2[row:row+16]
print(f" +{row:02X}: {chunk.hex(' ')}")
except usb.core.USBError:
print(f" Addr 0x{try_addr:02X}: no response")
else:
print("\n (use --i2c-scan or --dump-all for I2C bus scan)")
# --- Summary ---
print("\n" + "=" * 60)
# Check for echo pattern on critical registers
pll_echo = (a9 is not None and a9 == 0xA8) or (aa is not None and aa == 0xAA)
if not sig_all_zero:
print("BCM4500 CORE IS ALIVE under stock firmware!")
print(" → Problem is in our custom firmware boot sequence")
if a9 is not None and aa is not None:
print(f" → PLL values to replicate: A9=0x{a9:02X} AA=0x{aa:02X} AB=0x{ab:02X}")
elif pll_echo:
print("BCM4500 CORE IS DEAD (echo pattern on PLL registers)")
print(" → FUN_CODE_10F2 may not have run, or EEPROM missing")
else:
print("BCM4500 CORE STATUS UNCERTAIN")
print(" → Check register values above for non-echo data")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,24 @@
# RF Test Bench udev rules
# Install: sudo cp udev/99-rf-testbench.rules /etc/udev/rules.d/ && sudo udevadm control --reload-rules
#
# Provides non-root access and stable /dev symlinks for:
# /dev/skywalker1 - Genpix SkyWalker-1 DVB-S receiver (USB bulk device, not serial)
# /dev/nanovna - NanoVNA-H vector network analyzer (ttyACM)
# /dev/attenuator - HMC472A digital attenuator on ESP32-S3 (ttyACM)
# --- Genpix SkyWalker-1 (09c0:0203) ---
# Custom firmware: Product="SkyWalker-1 Custom", Serial="0001"
# Stock firmware: Product="Genpix SkyWalker-1", Serial="00857"
# Kernel dvb_usb_gp8psk driver blacklisted in /etc/modprobe.d/blacklist-gp8psk.conf
SUBSYSTEM=="usb", ATTR{idVendor}=="09c0", ATTR{idProduct}=="0203", MODE="0666", SYMLINK+="skywalker1"
# Cypress FX2 bare/unprogrammed (04b4:8613) - for recovery/development
SUBSYSTEM=="usb", ATTR{idVendor}=="04b4", ATTR{idProduct}=="8613", MODE="0666"
# --- NanoVNA-H (0483:5740) ---
# Match on model to avoid hitting other STM32 CDC devices
SUBSYSTEM=="tty", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", ATTRS{product}=="NanoVNA-H", MODE="0666", SYMLINK+="nanovna"
# --- HMC472A attenuator on ESP32-S3 native USB CDC ---
# Espressif VID=303a, PID=1001 (USB JTAG/serial), match on product string
SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{product}=="hmc472a-attenuator", MODE="0666", SYMLINK+="attenuator"