feat: Add software_cycle effect for revolving pad lights

- New software_cycle() method creates rotating light chase across all pads
- Pattern: CENTER → LEFT → RIGHT → CENTER (customizable)
- Speed parameter controls rotation speed
- apply_effect() now supports "cycle" effect type
- Updates module to v1.5.0
This commit is contained in:
2026-04-06 16:36:19 +10:00
parent 3606afd1f8
commit 692c1493b7
+109 -9
View File
@@ -6,6 +6,11 @@ For Moonlight Drive-In Video Player System
Communicates with LEGO Dimensions USB portal (PS3/PS4/Wii U versions)
to detect character and vehicle disc placement/removal events.
v1.5.0 Changes:
- Added software_cycle() method for revolving light chase effect
- Cycle effect rotates colors through CENTER → LEFT → RIGHT pads
- apply_effect() now supports "cycle" effect type
v1.4.0 Changes:
- Added nfc_card_id field: decimal ID matching standard NFC readers
- Calculated from bytes 3-6 of UID as big-endian uint32
@@ -86,7 +91,7 @@ VENDOR_ID = 0x0e6f
PRODUCT_ID = 0x0241
# Module version
__version__ = "1.4.0"
__version__ = "1.5.0"
def get_usb_backend():
@@ -184,6 +189,7 @@ class LegoDimensionsReader:
Provides event-driven tag detection with callbacks for integration
with the Moonlight Drive-In video player system.
v1.5.0: Added software_cycle() for revolving light chase effect.
v1.4.0: TagInfo now includes nfc_card_id matching standard NFC readers.
v1.3.0: No default LED behavior on tag insert/remove.
Callbacks are fully responsible for LED control.
@@ -216,6 +222,7 @@ class LegoDimensionsReader:
self._active_tags: Dict[int, TagInfo] = {}
# Effect control - track running effects per pad
# For cycle effect, we use pad value 99 as a special "all pads" key
self._effect_threads: Dict[int, threading.Thread] = {}
self._effect_stop_flags: Dict[int, threading.Event] = {}
@@ -504,8 +511,23 @@ class LegoDimensionsReader:
def stop_all_effects(self):
"""Stop all running software effects."""
# Stop individual pad effects
for pad in [Pad.CENTER, Pad.LEFT, Pad.RIGHT]:
self.stop_effect(pad)
# Stop cycle effect (uses special key 99)
self.stop_cycle_effect()
def stop_cycle_effect(self):
"""Stop the cycle effect specifically."""
cycle_key = 99 # Special key for cycle effect
if cycle_key in self._effect_stop_flags:
self._effect_stop_flags[cycle_key].set()
if cycle_key in self._effect_threads:
thread = self._effect_threads[cycle_key]
if thread.is_alive():
thread.join(timeout=0.5)
del self._effect_threads[cycle_key]
del self._effect_stop_flags[cycle_key]
def _set_color_raw(self, pad_value: int, r: int, g: int, b: int):
"""Set color without stopping effects (internal use)."""
@@ -596,20 +618,98 @@ class LegoDimensionsReader:
self._effect_threads[pad.value] = thread
thread.start()
def apply_effect(self, pad: Pad, color: List[int], effect: str = "solid"):
def software_cycle(self, color: List[int], speed: float = 0.3,
pattern: Optional[List[Pad]] = None, trail: bool = True,
count: int = 0):
"""
Cycle/revolve lights through pads in sequence.
Creates a rotating light chase effect across all pads.
Args:
color: [R, G, B] values for the cycling light
speed: Seconds each pad stays lit before moving to next
pattern: List of pads in order (default: CENTER, LEFT, RIGHT)
trail: If True, leaves a dim trail. If False, only one pad lit at a time.
count: Number of full cycles (0 = infinite until stopped)
"""
# Stop any existing cycle and individual pad effects
self.stop_cycle_effect()
for pad in [Pad.CENTER, Pad.LEFT, Pad.RIGHT]:
self.stop_effect(pad)
# Default pattern: CENTER -> LEFT -> RIGHT (clockwise-ish)
if pattern is None:
pattern = [Pad.CENTER, Pad.LEFT, Pad.RIGHT]
cycle_key = 99 # Special key for cycle effect
stop_flag = threading.Event()
self._effect_stop_flags[cycle_key] = stop_flag
def _cycle_loop():
cycle_count = 0
current_index = 0
# Calculate dim trail color (25% brightness)
trail_color = [int(c * 0.25) for c in color]
while not stop_flag.is_set():
# Set all pads
for i, pad in enumerate(pattern):
if i == current_index:
# Current pad: full brightness
self._set_color_raw(pad.value, color[0], color[1], color[2])
elif trail:
# Trail: dim color
self._set_color_raw(pad.value, trail_color[0], trail_color[1], trail_color[2])
else:
# No trail: off
self._set_color_raw(pad.value, 0, 0, 0)
# Wait before moving to next pad
if stop_flag.wait(speed):
break
# Move to next pad
current_index = (current_index + 1) % len(pattern)
# Check if completed a full cycle
if current_index == 0:
cycle_count += 1
if count > 0 and cycle_count >= count:
# End on first pad lit
self._set_color_raw(pattern[0].value, color[0], color[1], color[2])
for pad in pattern[1:]:
self._set_color_raw(pad.value, 0, 0, 0)
break
thread = threading.Thread(target=_cycle_loop, daemon=True)
self._effect_threads[cycle_key] = thread
thread.start()
def apply_effect(self, pad: Pad, color: List[int], effect: str = "solid",
speed: float = 1.0):
"""
Apply a named effect to a pad.
Args:
pad: Which pad
pad: Which pad (ignored for "cycle" effect which uses all pads)
color: [R, G, B] values
effect: "solid", "flash", or "pulse"
effect: "solid", "flash", "pulse", or "cycle"
speed: Speed multiplier (lower = faster for flash/pulse,
seconds per step for cycle)
"""
effect = effect.lower()
if effect == "flash":
self.software_flash(pad, color)
if effect == "cycle":
# Cycle uses all pads, speed is seconds per pad
cycle_speed = 0.3 * speed # Default 0.3s, adjustable
self.software_cycle(color, speed=cycle_speed)
elif effect == "flash":
on_time = 0.3 * speed
off_time = 0.3 * speed
self.software_flash(pad, color, on_time=on_time, off_time=off_time)
elif effect == "pulse":
self.software_pulse(pad, color)
self.software_pulse(pad, color, speed=speed)
else:
self.set_pad_color(pad, color)
@@ -834,8 +934,8 @@ if __name__ == "__main__":
print(f"\nLEGO Dimensions Portal Reader v{__version__}")
print(f"Platform: {platform.system()}")
print("="*50)
print("v1.4.0: Now shows NFC Card ID (decimal) for compatibility")
print(" with standard NFC readers and mapping files")
print("v1.5.0: Added 'cycle' effect for revolving lights!")
print(" Set effect: 'cycle' in tag_colors.json")
print("="*50)
reader = LegoDimensionsReader()