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:
@@ -1,7 +1,7 @@
|
||||
# xblink Project Status
|
||||
|
||||
**Current Milestone**: M1 — LED Smoke Test (code ready, needs USB cable to flash)
|
||||
**Last Updated**: 2026-03-03
|
||||
**Current Milestone**: M1 — LED Smoke Test (I2C verified via EVM USB bridge, XIAO M0 flash pending)
|
||||
**Last Updated**: 2026-03-05
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
- [x] Create `src/led/mod.rs` with re-exports
|
||||
- [x] Update `main.rs` with I2C init + GPIO EN + LP5562 direct PWM test
|
||||
- [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
|
||||
|
||||
|
||||
90
docs/plans/2026-03-05-lp5562evm-protocol.md
Normal file
90
docs/plans/2026-03-05-lp5562evm-protocol.md
Normal 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
280
tools/lp5562_evm.py
Normal 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()
|
||||
Reference in New Issue
Block a user