Fix flash_pad with software-based implementation (v1.2.1)
- Replace broken hardware 0xC8 flash command with software-based blinking - Hardware flash causes color corruption on some portals - Software flash uses rapid solid color toggling (works reliably) - Add Windows USB backend setup for proper libusb detection - Add proper endpoint discovery with fallbacks - Add timeout/pipe error detection helpers
This commit is contained in:
@@ -7,24 +7,63 @@ Communicates with LEGO Dimensions USB portal (PS3/PS4/Wii U versions)
|
|||||||
to detect character and vehicle disc placement/removal events.
|
to detect character and vehicle disc placement/removal events.
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
pip install pyusb
|
pip install pyusb libusb-package
|
||||||
|
|
||||||
Windows Driver Setup:
|
Windows Driver Setup:
|
||||||
1. Download Zadig from https://zadig.akeo.ie/
|
1. Download Zadig from https://zadig.akeo.ie/
|
||||||
2. Connect the LEGO Dimensions portal
|
2. Connect the LEGO Dimensions portal
|
||||||
3. Run Zadig as administrator
|
3. Run Zadig as administrator
|
||||||
4. Select Options → List All Devices
|
4. Select Options -> List All Devices
|
||||||
5. Choose "LEGO READER V2.10" from the dropdown
|
5. Choose "LEGO READER V2.10" from the dropdown
|
||||||
6. Select WinUSB as the target driver
|
6. Select WinUSB as the target driver
|
||||||
7. Click "Replace Driver"
|
7. Click "Replace Driver"
|
||||||
|
|
||||||
|
Windows libusb Setup:
|
||||||
|
The libusb-package provides pre-compiled libusb-1.0.dll.
|
||||||
|
Alternatively, download from https://libusb.info/ and place
|
||||||
|
libusb-1.0.dll in C:\\Windows\\System32
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
# Windows-specific backend setup - must be done BEFORE importing usb.core
|
||||||
|
def _setup_windows_backend():
|
||||||
|
"""Configure libusb backend for Windows."""
|
||||||
|
if platform.system() != 'Windows':
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try libusb-package first (recommended)
|
||||||
|
try:
|
||||||
|
import libusb_package
|
||||||
|
return
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Search for libusb DLL in common locations
|
||||||
|
dll_paths = [
|
||||||
|
os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'System32'),
|
||||||
|
os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'SysWOW64'),
|
||||||
|
os.path.dirname(sys.executable),
|
||||||
|
os.getcwd(),
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in dll_paths:
|
||||||
|
dll_file = os.path.join(path, 'libusb-1.0.dll')
|
||||||
|
if os.path.exists(dll_file):
|
||||||
|
os.environ['PATH'] = path + os.pathsep + os.environ.get('PATH', '')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run setup before USB imports
|
||||||
|
_setup_windows_backend()
|
||||||
|
|
||||||
import usb.core
|
import usb.core
|
||||||
import usb.util
|
import usb.util
|
||||||
|
import usb.backend.libusb1
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from typing import Callable, Optional, Dict, Any
|
from typing import Callable, Optional, Dict, Any
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -35,7 +74,26 @@ VENDOR_ID = 0x0e6f
|
|||||||
PRODUCT_ID = 0x0241
|
PRODUCT_ID = 0x0241
|
||||||
|
|
||||||
# Module version
|
# Module version
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.2.1"
|
||||||
|
|
||||||
|
|
||||||
|
def get_usb_backend():
|
||||||
|
"""Get the appropriate USB backend for the current platform."""
|
||||||
|
if platform.system() == 'Windows':
|
||||||
|
# Try libusb-package first
|
||||||
|
try:
|
||||||
|
import libusb_package
|
||||||
|
return libusb_package.get_libusb1_backend()
|
||||||
|
except (ImportError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try default libusb1 backend
|
||||||
|
try:
|
||||||
|
return usb.backend.libusb1.get_backend()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None # Use default backend
|
||||||
|
|
||||||
|
|
||||||
class Pad(Enum):
|
class Pad(Enum):
|
||||||
@@ -117,6 +175,11 @@ class LegoDimensionsReader:
|
|||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# USB endpoints (will be discovered on connect)
|
||||||
|
self._endpoint_in = None
|
||||||
|
self._endpoint_out = None
|
||||||
|
self._interface = None
|
||||||
|
|
||||||
# Active tags currently on pads (pad_num -> TagInfo)
|
# Active tags currently on pads (pad_num -> TagInfo)
|
||||||
self._active_tags: Dict[int, TagInfo] = {}
|
self._active_tags: Dict[int, TagInfo] = {}
|
||||||
|
|
||||||
@@ -136,7 +199,7 @@ class LegoDimensionsReader:
|
|||||||
# Try to load from JSON file if provided
|
# Try to load from JSON file if provided
|
||||||
if db_path and os.path.exists(db_path):
|
if db_path and os.path.exists(db_path):
|
||||||
try:
|
try:
|
||||||
with open(db_path, 'r') as f:
|
with open(db_path, 'r', encoding='utf-8') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return {int(k): v for k, v in data.get('characters', {}).items()}
|
return {int(k): v for k, v in data.get('characters', {}).items()}
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -232,7 +295,7 @@ class LegoDimensionsReader:
|
|||||||
"""Load vehicle ID database."""
|
"""Load vehicle ID database."""
|
||||||
if db_path and os.path.exists(db_path):
|
if db_path and os.path.exists(db_path):
|
||||||
try:
|
try:
|
||||||
with open(db_path, 'r') as f:
|
with open(db_path, 'r', encoding='utf-8') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return {int(k): v for k, v in data.get('vehicles', {}).items()}
|
return {int(k): v for k, v in data.get('vehicles', {}).items()}
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -323,35 +386,96 @@ class LegoDimensionsReader:
|
|||||||
82: "Niffler",
|
82: "Niffler",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _is_timeout_error(self, error: usb.core.USBError) -> bool:
|
||||||
|
"""Check if error is a timeout (normal during polling)."""
|
||||||
|
error_str = str(error).lower()
|
||||||
|
# Windows timeout codes vary: -116, -7, 110, or string
|
||||||
|
return (
|
||||||
|
error.errno in (-116, -7, 110, None) or
|
||||||
|
'timeout' in error_str or
|
||||||
|
'operation timed out' in error_str or
|
||||||
|
'timed out' in error_str
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_pipe_error(self, error: usb.core.USBError) -> bool:
|
||||||
|
"""Check if error indicates device disconnection."""
|
||||||
|
return error.errno == -9 or 'pipe' in str(error).lower()
|
||||||
|
|
||||||
def connect(self) -> bool:
|
def connect(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Establish connection to LEGO Dimensions portal.
|
Establish connection to LEGO Dimensions portal.
|
||||||
Returns True if connection successful.
|
Returns True if connection successful.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self.dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
|
# Get platform-appropriate backend
|
||||||
|
backend = get_usb_backend()
|
||||||
|
|
||||||
|
self.dev = usb.core.find(
|
||||||
|
idVendor=VENDOR_ID,
|
||||||
|
idProduct=PRODUCT_ID,
|
||||||
|
backend=backend
|
||||||
|
)
|
||||||
|
|
||||||
if self.dev is None:
|
if self.dev is None:
|
||||||
raise ConnectionError(
|
error_msg = (
|
||||||
"LEGO Dimensions Portal not found.\n"
|
"LEGO Dimensions Portal not found.\n"
|
||||||
"Ensure:\n"
|
"Ensure:\n"
|
||||||
" 1. Portal is connected via USB\n"
|
" 1. Portal is connected via USB\n"
|
||||||
" 2. Using PS3/PS4/Wii U portal (NOT Xbox)\n"
|
" 2. Using PS3/PS4/Wii U portal (NOT Xbox)\n"
|
||||||
" 3. WinUSB driver installed via Zadig"
|
" 3. WinUSB driver installed via Zadig"
|
||||||
)
|
)
|
||||||
|
if platform.system() == 'Windows':
|
||||||
|
error_msg += (
|
||||||
|
"\n 4. libusb installed: pip install libusb-package\n"
|
||||||
|
" 5. Or libusb-1.0.dll in System32"
|
||||||
|
)
|
||||||
|
raise ConnectionError(error_msg)
|
||||||
|
|
||||||
# Detach kernel driver if active (Linux)
|
# Detach kernel driver if active (Linux only)
|
||||||
try:
|
try:
|
||||||
if self.dev.is_kernel_driver_active(0):
|
if self.dev.is_kernel_driver_active(0):
|
||||||
self.dev.detach_kernel_driver(0)
|
self.dev.detach_kernel_driver(0)
|
||||||
except (AttributeError, usb.core.USBError):
|
except (AttributeError, usb.core.USBError, NotImplementedError):
|
||||||
pass # Not available on Windows
|
pass # Not available on Windows
|
||||||
|
|
||||||
# Configure device
|
# Configure device
|
||||||
self.dev.set_configuration()
|
try:
|
||||||
|
self.dev.set_configuration()
|
||||||
|
except usb.core.USBError as e:
|
||||||
|
# Ignore "Entity not found" - device already configured
|
||||||
|
if 'entity not found' not in str(e).lower():
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Get configuration and interface
|
||||||
|
cfg = self.dev.get_active_configuration()
|
||||||
|
intf = cfg[(0, 0)]
|
||||||
|
self._interface = intf.bInterfaceNumber
|
||||||
|
|
||||||
|
# Claim interface (required on Windows)
|
||||||
|
try:
|
||||||
|
usb.util.claim_interface(self.dev, self._interface)
|
||||||
|
except usb.core.USBError as e:
|
||||||
|
if 'resource busy' not in str(e).lower():
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Find endpoints dynamically
|
||||||
|
self._endpoint_out = usb.util.find_descriptor(
|
||||||
|
intf,
|
||||||
|
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
|
||||||
|
)
|
||||||
|
self._endpoint_in = usb.util.find_descriptor(
|
||||||
|
intf,
|
||||||
|
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback to hardcoded endpoints if discovery fails
|
||||||
|
if self._endpoint_out is None:
|
||||||
|
self._endpoint_out = 0x01
|
||||||
|
if self._endpoint_in is None:
|
||||||
|
self._endpoint_in = 0x81
|
||||||
|
|
||||||
# Send initialization command
|
# Send initialization command
|
||||||
self.dev.write(1, TOYPAD_INIT)
|
self._write_raw(TOYPAD_INIT)
|
||||||
|
|
||||||
# Get device info
|
# Get device info
|
||||||
try:
|
try:
|
||||||
@@ -367,8 +491,10 @@ class LegoDimensionsReader:
|
|||||||
|
|
||||||
except usb.core.USBError as e:
|
except usb.core.USBError as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "Access denied" in error_msg or "insufficient permissions" in error_msg.lower():
|
if "access denied" in error_msg.lower() or "insufficient permissions" in error_msg.lower():
|
||||||
print("ERROR: Access denied. Try running as Administrator or check Zadig drivers.")
|
print("ERROR: Access denied. Try running as Administrator or check Zadig drivers.")
|
||||||
|
elif "no backend" in error_msg.lower():
|
||||||
|
print("ERROR: No USB backend found. Install libusb-package: pip install libusb-package")
|
||||||
if self.on_error:
|
if self.on_error:
|
||||||
self.on_error(e)
|
self.on_error(e)
|
||||||
return False
|
return False
|
||||||
@@ -384,10 +510,21 @@ class LegoDimensionsReader:
|
|||||||
try:
|
try:
|
||||||
# Turn off all LEDs
|
# Turn off all LEDs
|
||||||
self.set_pad_color(Pad.ALL, COLORS['OFF'])
|
self.set_pad_color(Pad.ALL, COLORS['OFF'])
|
||||||
|
|
||||||
|
# Release interface (Windows)
|
||||||
|
if self._interface is not None:
|
||||||
|
try:
|
||||||
|
usb.util.release_interface(self.dev, self._interface)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
usb.util.dispose_resources(self.dev)
|
usb.util.dispose_resources(self.dev)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.dev = None
|
self.dev = None
|
||||||
|
self._endpoint_in = None
|
||||||
|
self._endpoint_out = None
|
||||||
|
self._interface = None
|
||||||
if self.on_disconnect:
|
if self.on_disconnect:
|
||||||
self.on_disconnect()
|
self.on_disconnect()
|
||||||
|
|
||||||
@@ -395,6 +532,39 @@ class LegoDimensionsReader:
|
|||||||
"""Calculate checksum (sum of bytes mod 256)"""
|
"""Calculate checksum (sum of bytes mod 256)"""
|
||||||
return sum(command) % 256
|
return sum(command) % 256
|
||||||
|
|
||||||
|
def _write_raw(self, data: list):
|
||||||
|
"""Write raw data to device."""
|
||||||
|
if not self.dev:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
endpoint = self._endpoint_out
|
||||||
|
if hasattr(endpoint, 'bEndpointAddress'):
|
||||||
|
endpoint = endpoint.bEndpointAddress
|
||||||
|
if endpoint is None:
|
||||||
|
endpoint = 0x01
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self.dev.write(endpoint, data)
|
||||||
|
except usb.core.USBError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _read_raw(self, size: int = 32, timeout: int = 100) -> Optional[list]:
|
||||||
|
"""Read raw data from device."""
|
||||||
|
if not self.dev:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
endpoint = self._endpoint_in
|
||||||
|
if hasattr(endpoint, 'bEndpointAddress'):
|
||||||
|
endpoint = endpoint.bEndpointAddress
|
||||||
|
if endpoint is None:
|
||||||
|
endpoint = 0x81
|
||||||
|
|
||||||
|
return list(self.dev.read(endpoint, size, timeout=timeout))
|
||||||
|
except usb.core.USBError:
|
||||||
|
return None
|
||||||
|
|
||||||
def _send_command(self, command: list):
|
def _send_command(self, command: list):
|
||||||
"""Send a command to the portal with checksum and padding."""
|
"""Send a command to the portal with checksum and padding."""
|
||||||
if not self.dev:
|
if not self.dev:
|
||||||
@@ -407,11 +577,7 @@ class LegoDimensionsReader:
|
|||||||
while len(message) < 32:
|
while len(message) < 32:
|
||||||
message.append(0x00)
|
message.append(0x00)
|
||||||
|
|
||||||
try:
|
self._write_raw(message)
|
||||||
with self._lock:
|
|
||||||
self.dev.write(1, message)
|
|
||||||
except usb.core.USBError:
|
|
||||||
pass # Ignore write errors
|
|
||||||
|
|
||||||
def set_pad_color(self, pad: Pad, color: list):
|
def set_pad_color(self, pad: Pad, color: list):
|
||||||
"""
|
"""
|
||||||
@@ -427,22 +593,47 @@ class LegoDimensionsReader:
|
|||||||
def flash_pad(self, pad: Pad, color: list, on_time: int = 10,
|
def flash_pad(self, pad: Pad, color: list, on_time: int = 10,
|
||||||
off_time: int = 10, count: int = 5):
|
off_time: int = 10, count: int = 5):
|
||||||
"""
|
"""
|
||||||
Flash a pad with a color.
|
Flash a pad with a color using software-based blinking.
|
||||||
|
|
||||||
|
Note: Hardware flash command (0xC8) is unreliable on some portals,
|
||||||
|
so this uses rapid solid color toggling instead.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pad: Which pad to flash
|
pad: Which pad to flash
|
||||||
color: [R, G, B] values
|
color: [R, G, B] values
|
||||||
on_time: Ticks for LED on state (1 tick ≈ 50ms)
|
on_time: Ticks for LED on state (1 tick ~ 50ms)
|
||||||
off_time: Ticks for LED off state
|
off_time: Ticks for LED off state
|
||||||
count: Number of flashes (255 = infinite)
|
count: Number of flashes (255 = infinite, capped at 50)
|
||||||
"""
|
"""
|
||||||
command = [
|
# Convert ticks to seconds (1 tick ≈ 50ms)
|
||||||
0x55, 0x0e, 0xc8, 0x06, pad.value,
|
on_secs = on_time * 0.05
|
||||||
color[0], color[1], color[2],
|
off_secs = off_time * 0.05
|
||||||
on_time, off_time, count,
|
|
||||||
0, 0, 0, 0, 0 # Return color (off)
|
# Cap infinite flashes to reasonable number for software implementation
|
||||||
]
|
actual_count = min(count, 50) if count == 255 else count
|
||||||
self._send_command(command)
|
|
||||||
|
# Determine which pads to flash
|
||||||
|
if pad == Pad.ALL:
|
||||||
|
pads_to_flash = [Pad.CENTER, Pad.LEFT, Pad.RIGHT]
|
||||||
|
else:
|
||||||
|
pads_to_flash = [pad]
|
||||||
|
|
||||||
|
def _flash_thread():
|
||||||
|
for _ in range(actual_count):
|
||||||
|
if not self._running and not self.dev:
|
||||||
|
break
|
||||||
|
# ON
|
||||||
|
for p in pads_to_flash:
|
||||||
|
self.set_pad_color(p, color)
|
||||||
|
time.sleep(on_secs)
|
||||||
|
# OFF
|
||||||
|
for p in pads_to_flash:
|
||||||
|
self.set_pad_color(p, COLORS['OFF'])
|
||||||
|
time.sleep(off_secs)
|
||||||
|
|
||||||
|
# Run flash in background thread so it doesn't block
|
||||||
|
flash_thread = threading.Thread(target=_flash_thread, daemon=True)
|
||||||
|
flash_thread.start()
|
||||||
|
|
||||||
def fade_pad(self, pad: Pad, color: list, speed: int = 10, count: int = 1):
|
def fade_pad(self, pad: Pad, color: list, speed: int = 10, count: int = 1):
|
||||||
"""
|
"""
|
||||||
@@ -481,7 +672,13 @@ class LegoDimensionsReader:
|
|||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
# Read from IN endpoint with timeout
|
# Read from IN endpoint with timeout
|
||||||
in_packet = self.dev.read(0x81, 32, timeout=100)
|
endpoint = self._endpoint_in
|
||||||
|
if hasattr(endpoint, 'bEndpointAddress'):
|
||||||
|
endpoint = endpoint.bEndpointAddress
|
||||||
|
if endpoint is None:
|
||||||
|
endpoint = 0x81
|
||||||
|
|
||||||
|
in_packet = self.dev.read(endpoint, 32, timeout=100)
|
||||||
bytelist = list(in_packet)
|
bytelist = list(in_packet)
|
||||||
|
|
||||||
if not bytelist:
|
if not bytelist:
|
||||||
@@ -530,8 +727,12 @@ class LegoDimensionsReader:
|
|||||||
self.on_tag_remove(tag_info)
|
self.on_tag_remove(tag_info)
|
||||||
|
|
||||||
except usb.core.USBError as e:
|
except usb.core.USBError as e:
|
||||||
if e.errno == 110 or "timeout" in str(e).lower(): # Timeout - normal
|
if self._is_timeout_error(e):
|
||||||
continue
|
continue # Timeout is normal
|
||||||
|
elif self._is_pipe_error(e):
|
||||||
|
if self._running and self.on_error:
|
||||||
|
self.on_error(ConnectionError("Portal disconnected"))
|
||||||
|
break
|
||||||
elif self._running:
|
elif self._running:
|
||||||
if self.on_error:
|
if self.on_error:
|
||||||
self.on_error(e)
|
self.on_error(e)
|
||||||
@@ -727,24 +928,25 @@ def encrypt_character_id(uid: bytes, character_id: int) -> bytes:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
def on_insert(tag: TagInfo):
|
def on_insert(tag: TagInfo):
|
||||||
print(f"\n{'='*50}")
|
print(f"\n{'='*50}")
|
||||||
print(f"✓ TAG PLACED on {tag.pad.name} pad")
|
print(f"[+] TAG PLACED on {tag.pad.name} pad")
|
||||||
print(f" UID: {tag.uid_hex}")
|
print(f" UID: {tag.uid_hex}")
|
||||||
print(f" Password: {generate_password(tag.uid).hex().upper()}")
|
print(f" Password: {generate_password(tag.uid).hex().upper()}")
|
||||||
print(f"{'='*50}")
|
print(f"{'='*50}")
|
||||||
|
|
||||||
def on_remove(tag: TagInfo):
|
def on_remove(tag: TagInfo):
|
||||||
print(f"\n✗ TAG REMOVED from {tag.pad.name} pad")
|
print(f"\n[-] TAG REMOVED from {tag.pad.name} pad")
|
||||||
|
|
||||||
def on_error(e: Exception):
|
def on_error(e: Exception):
|
||||||
print(f"\n⚠ ERROR: {e}")
|
print(f"\n[!] ERROR: {e}")
|
||||||
|
|
||||||
def on_connect():
|
def on_connect():
|
||||||
print("✓ Portal connected and initialized")
|
print("[+] Portal connected and initialized")
|
||||||
|
|
||||||
def on_disconnect():
|
def on_disconnect():
|
||||||
print("✗ Portal disconnected")
|
print("[-] Portal disconnected")
|
||||||
|
|
||||||
print(f"\nLEGO Dimensions Portal Reader v{__version__}")
|
print(f"\nLEGO Dimensions Portal Reader v{__version__}")
|
||||||
|
print(f"Platform: {platform.system()}")
|
||||||
print("="*50)
|
print("="*50)
|
||||||
|
|
||||||
reader = LegoDimensionsReader()
|
reader = LegoDimensionsReader()
|
||||||
|
|||||||
Reference in New Issue
Block a user