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
|
# 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
|
||||||
|
|
||||||
|
|||||||
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