diff --git a/docs/STATUS.md b/docs/STATUS.md index 26c9cb7..0c1189c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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 diff --git a/docs/plans/2026-03-05-lp5562evm-protocol.md b/docs/plans/2026-03-05-lp5562evm-protocol.md new file mode 100644 index 0000000..bacdd3d --- /dev/null +++ b/docs/plans/2026-03-05-lp5562evm-protocol.md @@ -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` | `SS DD OK\n` | I2C register read | +| `O` (write) | `O` | `SS OK\n` | I2C register write | +| `R` (read) | `R` | `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`. diff --git a/tools/lp5562_evm.py b/tools/lp5562_evm.py new file mode 100644 index 0000000..923096f --- /dev/null +++ b/tools/lp5562_evm.py @@ -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 → "SS DD OK\n" (SS=00 success, DD=data hex) + Write: O → "SS OK\n" (SS=00 success) + Info: ? → "TI \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 [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()