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).
This commit is contained in:
Ryan Malloy 2026-02-20 10:56:21 -07:00
parent bbdcb243dc
commit 3d2cd477b2
8 changed files with 5621 additions and 4440 deletions

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

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,16 @@
FX2LIBDIR=fx2lib/ FX2LIBDIR=fx2lib/
BASENAME=skywalker1 BASENAME=skywalker1
SOURCES=skywalker1.c SOURCES=skywalker1.c
A51_SOURCES=dscr.a51 A51_SOURCES=dscr.a51
VID=0x09C0 VID=0x09C0
PID=0x0203 PID=0x0203
CODE_SIZE=--code-size 0x3c00 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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff