Add LP5562EVM USB-I2C control tool, document reverse-engineered protocol

Reverse-engineered the MSP430 serial protocol on the LP5562EVM:
- I<addr><reg> reads, O<addr><reg><data> writes, status 00=OK / 02=NAK
- EN pin must be jumpered to VDD (MSP430 doesn't drive it after reset)
- CONFIG (0x0B) is write-only, W_CURRENT (0x09) not writable via bridge
- LED cycling verified: all 4 channels respond to direct PWM control

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-05 11:11:35 -08:00
parent 90eab7990c
commit 5970a21b54
3 changed files with 374 additions and 3 deletions

View File

@@ -1,7 +1,7 @@
# xblink Project Status # xblink Project Status
**Current Milestone**: M1 — LED Smoke Test (code ready, needs USB cable to flash) **Current Milestone**: M1 — LED Smoke Test (I2C verified via EVM USB bridge, XIAO M0 flash pending)
**Last Updated**: 2026-03-03 **Last Updated**: 2026-03-05
--- ---
@@ -23,7 +23,8 @@
- [x] Create `src/led/mod.rs` with re-exports - [x] Create `src/led/mod.rs` with re-exports
- [x] Update `main.rs` with I2C init + GPIO EN + LP5562 direct PWM test - [x] Update `main.rs` with I2C init + GPIO EN + LP5562 direct PWM test
- [x] Verify `cargo build --release` compiles - [x] Verify `cargo build --release` compiles
- [ ] Flash and verify RGBW LED channels on LP5562EVM - [x] Verify LP5562 I2C control via EVM USB-to-I2C bridge (`tools/lp5562_evm.py`)
- [ ] Flash XIAO M0 and verify RGBW LED channels with Rust firmware
### M2: Engine Patterns ### M2: Engine Patterns

View File

@@ -0,0 +1,90 @@
# LP5562EVM MSP430 Serial Protocol (Reverse-Engineered)
**Date**: 2026-03-05
**Status**: Working — I2C read/write verified, LEDs cycling
## Hardware
- **EVM**: LP5562EVM (SNVU203A) with MSP430F5510 USB-to-I2C bridge
- **USB**: CDC device, VID:PID `2047:0300`, appears as `/dev/ttyACM0`
- **Baud**: 115200, 8N1
- **EN pin**: Must be jumpered to VDD externally — MSP430 does NOT drive EN after reset
## Protocol
Commands are ASCII text, no line terminator needed. The MSP430 parses character-by-character and responds after receiving the expected number of hex digits.
### Commands
| Command | Format | Response | Description |
|---------|--------|----------|-------------|
| `?` | `?` | `TI Sep 12 2012 08:19:25\n` | Firmware version |
| `I` (read) | `I<addr2><reg2>` | `SS DD OK\n` | I2C register read |
| `O` (write) | `O<addr2><reg2><data2>` | `SS OK\n` | I2C register write |
| `R` (read) | `R<addr2><reg2>` | `OK\n` | I2C read (no data returned) |
| `A` | `A` | `OK\n` | Unknown (possibly internal) |
- `addr2`: 2-char hex I2C 7-bit address (e.g., `30` for LP5562)
- `reg2`: 2-char hex register address
- `data2`: 2-char hex data byte
- `SS`: 2-char hex status (`00` = success, `02` = I2C NAK / device not responding)
- `DD`: 2-char hex data byte read from device
### Other Command Prefixes
These accept input but are not fully understood:
| Prefix | Chars consumed | Response | Guess |
|--------|---------------|----------|-------|
| `S` | 2 hex chars | (silent) | GPIO/setting control? |
| `V` | 2 hex chars | (silent) | Voltage/value setting? |
| `C` | 6 hex chars | `OK\n` | Config/control? |
| `P` | 8 hex chars | (silent or `OK`) | Program memory? |
### Invalid Commands
Single characters not recognized as command prefixes return `?\n`. Extra characters after a complete command also return `?\n` each.
## LP5562 Register Quirks via EVM Bridge
| Register | Address | Writable | Read-back | Notes |
|----------|---------|----------|-----------|-------|
| ENABLE | 0x00 | Yes | Yes | |
| OP_MODE | 0x01 | Yes | Yes | |
| B/G/R/W_PWM | 0x02-0x05 | Yes | Yes | |
| B/G/R_CURRENT | 0x06-0x08 | Yes | Yes | |
| W_CURRENT | 0x09 | No | Always 0x00 | Write ACKs but no effect |
| CONFIG | 0x0B | Functional | Always 0x00 | Write-only: takes effect but reads 0 |
| STATUS | 0x0C | N/A | Yes | Read-only |
| LED_MAP | 0x70 | Yes | Yes | Default 0x39 (engines mapped) — must set to 0x00 for direct PWM |
## Working Init Sequence
```
O300DFF # Reset (write 0xFF to RESET register)
# Wait 500ms
O300040 # ENABLE = 0x40 (chip_en)
# Wait 1ms (>500us startup)
O300B01 # CONFIG = 0x01 (internal oscillator) — write-only
O307000 # LED_MAP = 0x00 (all channels = I2C direct)
O300632 # B_CURRENT = 0x32 (5mA)
O300732 # G_CURRENT = 0x32
O300832 # R_CURRENT = 0x32
# W_CURRENT left at default 0xAF (0x09 not writable)
O3002FF # B_PWM = 0xFF (full brightness)
```
## LED Hardware on EVM
- **D1**: LRTB_G6SF (Osram RGB LED) — driven by B/G/R channels
- **D2-D5**: LW Q38E (Osram white LEDs) — driven by W channel
- **D7**: LSL296 (red status LED) — driven by MSP430, not LP5562
- All LP5562 LED outputs pass through **P5** header (4x2 dual-row with jumpers)
## Key Discovery: EN Pin
The LP5562 EN pin is controlled by the MSP430's GPIO (EN-UC), connected through the P6 header. After USB reset, the MSP430 holds EN low. The TI Windows GUI software presumably sends a command to assert EN, but we were unable to identify which serial command does this.
**Workaround**: Jumper EN directly to VDD (3.3V) on P1 or P6 header. This is acceptable because in the final xblink design, EN is controlled by a wired-AND circuit (MCU GPIO + hall sensor), not the EVM's MSP430.
Without EN high, all I2C reads return status `02` (NAK) with data `00`.

280
tools/lp5562_evm.py Normal file
View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
LP5562EVM USB-I2C bridge control tool.
Controls the LP5562 LED driver via the EVM's MSP430 USB-to-I2C bridge.
The MSP430 presents as a CDC serial device (/dev/ttyACM0).
Protocol (reverse-engineered):
Read: I<addr2><reg2> → "SS DD OK\n" (SS=00 success, DD=data hex)
Write: O<addr2><reg2><data2> → "SS OK\n" (SS=00 success)
Info: ? → "TI <date>\n"
Prerequisites:
- LP5562 EN pin must be held high (jumper EN to VDD on EVM P1/P6 header).
The MSP430 does NOT drive EN after reset.
- CONFIG register (0x0B) is write-only via this bridge — writes take effect
but reads always return 0x00. Internal oscillator must be explicitly set.
- W_CURRENT (0x09) cannot be written via this bridge (LP5562 quirk or
MSP430 limitation). White channel uses reset default (0xAF = 17.5mA).
Usage:
./lp5562_evm.py dump # Read all registers
./lp5562_evm.py init # Enable chip + internal clock + direct PWM
./lp5562_evm.py color R G B W # Set RGBW PWM values (0-255)
./lp5562_evm.py off # All LEDs off
./lp5562_evm.py cycle # Cycle through RGBW channels
./lp5562_evm.py breathe # Breathing pattern demo
"""
import serial
import sys
import time
PORT = '/dev/ttyACM0'
BAUD = 115200
LP5562_ADDR = 0x30
# LP5562 register map
REG_ENABLE = 0x00
REG_OP_MODE = 0x01
REG_B_PWM = 0x02
REG_G_PWM = 0x03
REG_R_PWM = 0x04
REG_W_PWM = 0x05
REG_B_CURRENT = 0x06
REG_G_CURRENT = 0x07
REG_R_CURRENT = 0x08
REG_W_CURRENT = 0x09
REG_CONFIG = 0x0B
REG_STATUS = 0x0C
REG_RESET = 0x0D
REG_INT_GPO = 0x0E
REG_LED_MAP = 0x70
REG_NAMES = {
0x00: 'ENABLE', 0x01: 'OP_MODE', 0x02: 'B_PWM',
0x03: 'G_PWM', 0x04: 'R_PWM', 0x05: 'W_PWM',
0x06: 'B_CURRENT', 0x07: 'G_CURRENT', 0x08: 'R_CURRENT',
0x09: 'W_CURRENT', 0x0B: 'CONFIG', 0x0C: 'STATUS',
0x0D: 'RESET', 0x0E: 'INT_GPO', 0x70: 'LED_MAP',
}
# ENABLE register bits
CHIP_EN = 0x40
LOG_EN = 0x80
# CONFIG register bits
CLK_INT = 0x01 # Internal oscillator
# OP_MODE: engine modes (2 bits each: [7:6]=E3, [5:4]=E2, [3:2]=E1)
MODE_DISABLED = 0b00
MODE_LOAD = 0b01
MODE_RUN = 0b10
MODE_DIRECT = 0b11
class LP5562EVM:
def __init__(self, port=PORT, baud=BAUD):
self.ser = serial.Serial(port, baud, timeout=0.5)
time.sleep(0.1)
self.ser.reset_input_buffer()
def close(self):
self.ser.close()
def version(self):
self.ser.reset_input_buffer()
self.ser.write(b'?')
time.sleep(0.2)
return self.ser.read(256).decode('ascii', errors='replace').strip()
def read(self, reg):
"""Read LP5562 register. Returns data byte or raises on error."""
self.ser.reset_input_buffer()
self.ser.write(f'I{LP5562_ADDR:02X}{reg:02X}'.encode())
time.sleep(0.15)
resp = self.ser.read(256).decode('ascii', errors='replace').strip()
parts = resp.split()
if len(parts) < 3 or parts[-1] != 'OK':
raise IOError(f"Read reg 0x{reg:02X} failed: {resp}")
status = int(parts[0], 16)
if status != 0:
raise IOError(f"I2C NAK reading reg 0x{reg:02X} (status={status:02X}). Is EN pin high?")
return int(parts[1], 16)
def write(self, reg, val):
"""Write LP5562 register."""
self.ser.reset_input_buffer()
self.ser.write(f'O{LP5562_ADDR:02X}{reg:02X}{val:02X}'.encode())
time.sleep(0.15)
resp = self.ser.read(256).decode('ascii', errors='replace').strip()
parts = resp.split()
if not parts or parts[-1] != 'OK':
raise IOError(f"Write reg 0x{reg:02X}=0x{val:02X} failed: {resp}")
status = int(parts[0], 16)
if status != 0:
raise IOError(f"I2C NAK writing reg 0x{reg:02X} (status={status:02X}). Is EN pin high?")
def dump(self):
"""Read and display all registers."""
print("LP5562 Register Dump")
print("=" * 40)
for reg in sorted(REG_NAMES.keys()):
try:
val = self.read(reg)
name = REG_NAMES[reg]
print(f" 0x{reg:02X} {name:12s} = 0x{val:02X} ({val:3d}) {val:08b}")
except IOError as e:
print(f" 0x{reg:02X} {REG_NAMES[reg]:12s} = ERROR: {e}")
def reset(self):
"""Software reset the LP5562."""
self.write(REG_RESET, 0xFF)
time.sleep(0.5)
def init(self, current_ma=5.0):
"""Initialize LP5562 for direct PWM control."""
# Reset to known state
self.write(REG_RESET, 0xFF)
time.sleep(0.5)
# Enable chip
self.write(REG_ENABLE, CHIP_EN)
time.sleep(0.001) # >500us startup
# Internal oscillator (write-only: reads back as 0x00, but takes effect)
self.write(REG_CONFIG, CLK_INT)
# All LEDs mapped to I2C direct control
self.write(REG_LED_MAP, 0x00)
# Set current for B/G/R (0-255 maps to 0-25.5mA, so 10 = 1mA)
# W_CURRENT (0x09) is not writable via EVM bridge — uses default 0xAF
current_val = min(255, max(0, int(current_ma * 10)))
self.write(REG_B_CURRENT, current_val)
self.write(REG_G_CURRENT, current_val)
self.write(REG_R_CURRENT, current_val)
print(f"LP5562 initialized: chip_en, internal osc, {current_ma:.1f}mA/ch (W=17.5mA default)")
def set_color(self, r=0, g=0, b=0, w=0):
"""Set RGBW PWM values (0-255)."""
self.write(REG_R_PWM, r & 0xFF)
self.write(REG_G_PWM, g & 0xFF)
self.write(REG_B_PWM, b & 0xFF)
self.write(REG_W_PWM, w & 0xFF)
def off(self):
"""All LEDs off."""
self.set_color(0, 0, 0, 0)
def cmd_dump(evm, args):
ver = evm.version()
print(f"EVM firmware: {ver}")
print()
evm.dump()
def cmd_init(evm, args):
current = float(args[0]) if args else 5.0
evm.init(current_ma=current)
evm.dump()
def cmd_color(evm, args):
if len(args) < 3:
print("Usage: color R G B [W]")
return
r, g, b = int(args[0]), int(args[1]), int(args[2])
w = int(args[3]) if len(args) > 3 else 0
evm.set_color(r, g, b, w)
print(f"Set color: R={r} G={g} B={b} W={w}")
def cmd_off(evm, args):
evm.off()
print("All LEDs off")
def cmd_cycle(evm, args):
step = float(args[0]) if args else 0.5
pwm = 255
print(f"Cycling RGBW channels ({step}s per step). Ctrl+C to stop.")
try:
while True:
evm.set_color(0, 0, pwm, 0) # Blue
print(" Blue")
time.sleep(step)
evm.set_color(0, pwm, 0, 0) # Green
print(" Green")
time.sleep(step)
evm.set_color(pwm, 0, 0, 0) # Red
print(" Red")
time.sleep(step)
evm.set_color(0, 0, 0, pwm) # White
print(" White")
time.sleep(step)
evm.set_color(pwm, pwm, pwm, pwm) # All
print(" All on")
time.sleep(step)
evm.off()
print(" Off")
time.sleep(step)
except KeyboardInterrupt:
evm.off()
print("\nStopped.")
def cmd_breathe(evm, args):
"""Software breathing effect (ramp PWM up and down)."""
print("Breathing pattern on blue channel. Ctrl+C to stop.")
try:
while True:
# Ramp up
for i in range(0, 256, 4):
evm.set_color(0, 0, i, 0)
time.sleep(0.02)
# Ramp down
for i in range(255, -1, -4):
evm.set_color(0, 0, max(0, i), 0)
time.sleep(0.02)
time.sleep(0.3)
except KeyboardInterrupt:
evm.off()
print("\nStopped.")
COMMANDS = {
'dump': cmd_dump,
'init': cmd_init,
'color': cmd_color,
'off': cmd_off,
'cycle': cmd_cycle,
'breathe': cmd_breathe,
}
def main():
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
print("Usage: lp5562_evm.py <command> [args...]")
print()
print("Commands:")
print(" dump Read all registers")
print(" init [mA] Enable chip, set current (default 5mA)")
print(" color R G B [W] Set RGBW PWM (0-255)")
print(" off All LEDs off")
print(" cycle [sec] Cycle through RGBW (default 0.5s)")
print(" breathe Software breathing pattern")
sys.exit(1)
evm = LP5562EVM()
try:
COMMANDS[sys.argv[1]](evm, sys.argv[2:])
finally:
evm.close()
if __name__ == '__main__':
main()