Files
lego-dimensions-reader/portal_config_gui.py
jessikitty 13f0ae59f3 feat: Add Portal Configuration GUI v1.1.0 with speed control
- Tag theme editor with color picker and RGB sliders
- Effect type selection: Solid, Flash, Pulse
- Speed control slider (0.2x fast to 3.0x slow) for Flash/Pulse effects
- Tag list with search/filter functionality
- Live portal connection and tag detection
- JSON config file import/export
- Video path assignments for Moonlight integration
- Real-time LED preview testing
- Default theme configuration
2026-02-21 18:34:34 +11:00

1058 lines
40 KiB
Python

#!/usr/bin/env python3
"""
LEGO Dimensions Portal Configuration GUI
A graphical tool for managing:
- Tag to video/folder assignments
- Lighting theme creation and editing
- Color selection with visual preview
- Effect type configuration (Solid, Flash, Pulse)
Requirements:
pip install pyusb libusb-package
tkinter (usually included with Python)
"""
import tkinter as tk
from tkinter import ttk, colorchooser, filedialog, messagebox
import json
import os
import sys
from typing import Dict, List, Optional, Tuple
import threading
import time
# Try to import the portal reader
try:
from lego_dimensions_reader import (
LegoDimensionsReader,
TagInfo,
Pad,
COLORS,
__version__ as READER_VERSION
)
READER_AVAILABLE = True
except ImportError:
READER_AVAILABLE = False
READER_VERSION = "N/A"
# Application version
__version__ = "1.1.0"
# Default config file paths
DEFAULT_TAG_COLORS_PATH = "tag_colors.json"
DEFAULT_VIDEO_MAPPINGS_PATH = "video_mappings.json"
class ColorPreview(tk.Canvas):
"""A canvas widget that displays a color preview."""
def __init__(self, parent, color: List[int] = [0, 255, 255], size: int = 40, **kwargs):
super().__init__(parent, width=size, height=size, **kwargs)
self.size = size
self._color = color
self._draw_color()
def _draw_color(self):
"""Draw the color on the canvas."""
self.delete("all")
hex_color = f"#{self._color[0]:02x}{self._color[1]:02x}{self._color[2]:02x}"
self.create_rectangle(2, 2, self.size-2, self.size-2,
fill=hex_color, outline="gray50", width=2)
@property
def color(self) -> List[int]:
return self._color
@color.setter
def color(self, value: List[int]):
self._color = value
self._draw_color()
class TagEntry:
"""Represents a single tag configuration entry."""
def __init__(self, legacy_key: str, name: str = "", color: List[int] = None,
effect: str = "solid", speed: float = 1.0, video_path: str = ""):
self.legacy_key = legacy_key
self.name = name
self.color = color or [0, 255, 255]
self.effect = effect
self.speed = speed # Speed multiplier: 0.2=very fast, 1.0=normal, 3.0=slow
self.video_path = video_path
class ThemeEditor(tk.Toplevel):
"""Dialog for editing a tag's lighting theme."""
def __init__(self, parent, tag_entry: TagEntry, preset_colors: Dict[str, List[int]] = None):
super().__init__(parent)
self.title(f"Edit Theme - {tag_entry.name or tag_entry.legacy_key}")
self.tag_entry = tag_entry
self.preset_colors = preset_colors or {}
self.result = None
# Current editing values
self.current_color = tag_entry.color.copy()
self.current_effect = tk.StringVar(value=tag_entry.effect)
self.current_name = tk.StringVar(value=tag_entry.name)
self.current_speed = tk.DoubleVar(value=getattr(tag_entry, 'speed', 1.0))
self._create_widgets()
# Make dialog modal
self.transient(parent)
self.grab_set()
# Center on parent
self.geometry("+%d+%d" % (parent.winfo_x() + 50, parent.winfo_y() + 50))
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.wait_window(self)
def _create_widgets(self):
"""Create the dialog widgets."""
main_frame = ttk.Frame(self, padding="10")
main_frame.grid(row=0, column=0, sticky="nsew")
# Name entry
name_frame = ttk.LabelFrame(main_frame, text="Tag Name", padding="5")
name_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 10))
ttk.Entry(name_frame, textvariable=self.current_name, width=30).grid(
row=0, column=0, sticky="ew", padx=5)
name_frame.columnconfigure(0, weight=1)
# Color selection
color_frame = ttk.LabelFrame(main_frame, text="Color", padding="5")
color_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 10))
# Color preview
preview_frame = ttk.Frame(color_frame)
preview_frame.grid(row=0, column=0, columnspan=2, pady=5)
self.color_preview = ColorPreview(preview_frame, self.current_color, size=80)
self.color_preview.pack()
# RGB sliders
rgb_frame = ttk.Frame(color_frame)
rgb_frame.grid(row=1, column=0, columnspan=2, pady=5)
self.r_var = tk.IntVar(value=self.current_color[0])
self.g_var = tk.IntVar(value=self.current_color[1])
self.b_var = tk.IntVar(value=self.current_color[2])
for i, (label, var, color) in enumerate([
("R:", self.r_var, "#FF0000"),
("G:", self.g_var, "#00FF00"),
("B:", self.b_var, "#0000FF")
]):
ttk.Label(rgb_frame, text=label).grid(row=i, column=0, padx=(0, 5))
scale = ttk.Scale(rgb_frame, from_=0, to=255, variable=var,
orient="horizontal", length=150,
command=lambda v, c=i: self._on_rgb_change())
scale.grid(row=i, column=1, padx=5)
entry = ttk.Entry(rgb_frame, textvariable=var, width=5)
entry.grid(row=i, column=2, padx=5)
entry.bind("<Return>", lambda e: self._on_rgb_change())
entry.bind("<FocusOut>", lambda e: self._on_rgb_change())
# Color picker button
ttk.Button(color_frame, text="Color Picker...",
command=self._pick_color).grid(row=2, column=0, pady=5, padx=5)
# Preset colors dropdown
if self.preset_colors:
preset_frame = ttk.Frame(color_frame)
preset_frame.grid(row=2, column=1, pady=5, padx=5)
ttk.Label(preset_frame, text="Preset:").pack(side="left", padx=(0, 5))
self.preset_var = tk.StringVar()
preset_combo = ttk.Combobox(preset_frame, textvariable=self.preset_var,
values=list(self.preset_colors.keys()), width=18)
preset_combo.pack(side="left")
preset_combo.bind("<<ComboboxSelected>>", self._on_preset_select)
# Effect selection
effect_frame = ttk.LabelFrame(main_frame, text="Effect Type", padding="5")
effect_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 10))
effects = [
("Solid", "solid", "Constant color, no animation"),
("Flash", "flash", "Blink on/off repeatedly"),
("Pulse", "pulse", "Smooth fade in/out")
]
for i, (label, value, desc) in enumerate(effects):
rb = ttk.Radiobutton(effect_frame, text=label, value=value,
variable=self.current_effect)
rb.grid(row=i, column=0, sticky="w", padx=5)
ttk.Label(effect_frame, text=desc, foreground="gray50").grid(
row=i, column=1, sticky="w", padx=10)
# Speed control (for flash/pulse effects)
speed_frame = ttk.LabelFrame(main_frame, text="Effect Speed", padding="5")
speed_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 10))
speed_labels_frame = ttk.Frame(speed_frame)
speed_labels_frame.pack(fill="x")
ttk.Label(speed_labels_frame, text="Fast").pack(side="left")
ttk.Label(speed_labels_frame, text="Slow").pack(side="right")
self.speed_scale = ttk.Scale(
speed_frame, from_=0.2, to=3.0, orient="horizontal",
variable=self.current_speed, length=200
)
self.speed_scale.pack(fill="x", padx=5)
self.speed_value_label = ttk.Label(speed_frame, text=f"Speed: {self.current_speed.get():.1f}x")
self.speed_value_label.pack()
# Update label when slider moves
self.current_speed.trace_add('write', self._on_speed_change)
ttk.Label(speed_frame, text="(Only affects Flash and Pulse effects)",
foreground="gray50").pack()
# Buttons
btn_frame = ttk.Frame(main_frame)
btn_frame.grid(row=4, column=0, columnspan=2, pady=(10, 0))
ttk.Button(btn_frame, text="Save", command=self._on_save).pack(side="left", padx=5)
ttk.Button(btn_frame, text="Cancel", command=self._on_cancel).pack(side="left", padx=5)
def _on_rgb_change(self):
"""Handle RGB slider/entry changes."""
try:
self.current_color = [
max(0, min(255, self.r_var.get())),
max(0, min(255, self.g_var.get())),
max(0, min(255, self.b_var.get()))
]
self.color_preview.color = self.current_color
except tk.TclError:
pass
def _pick_color(self):
"""Open system color picker dialog."""
initial = f"#{self.current_color[0]:02x}{self.current_color[1]:02x}{self.current_color[2]:02x}"
result = colorchooser.askcolor(color=initial, title="Choose Color")
if result[0]:
r, g, b = [int(c) for c in result[0]]
self.current_color = [r, g, b]
self.r_var.set(r)
self.g_var.set(g)
self.b_var.set(b)
self.color_preview.color = self.current_color
def _on_preset_select(self, event):
"""Handle preset color selection."""
preset_name = self.preset_var.get()
if preset_name in self.preset_colors:
color = self.preset_colors[preset_name]
self.current_color = color.copy()
self.r_var.set(color[0])
self.g_var.set(color[1])
self.b_var.set(color[2])
self.color_preview.color = self.current_color
def _on_speed_change(self, *args):
"""Handle speed slider changes."""
try:
speed = self.current_speed.get()
self.speed_value_label.config(text=f"Speed: {speed:.1f}x")
except tk.TclError:
pass
def _on_save(self):
"""Save changes and close dialog."""
self.result = {
'name': self.current_name.get().strip(),
'color': self.current_color,
'effect': self.current_effect.get(),
'speed': round(self.current_speed.get(), 2)
}
self.destroy()
def _on_cancel(self):
"""Cancel and close dialog."""
self.result = None
self.destroy()
class PortalConfigGUI(tk.Tk):
"""Main application window."""
def __init__(self):
super().__init__()
self.title(f"LEGO Dimensions Portal Configurator v{__version__}")
self.geometry("900x650")
# Data storage
self.tag_colors_path = DEFAULT_TAG_COLORS_PATH
self.video_mappings_path = DEFAULT_VIDEO_MAPPINGS_PATH
self.tags: Dict[str, TagEntry] = {}
self.preset_colors: Dict[str, List[int]] = {}
self.default_theme: Dict = {'color': [0, 255, 255], 'effect': 'solid', 'speed': 1.0, 'name': 'Default'}
# Portal reader (optional)
self.reader: Optional[LegoDimensionsReader] = None
self.reader_running = False
# Unsaved changes flag
self.unsaved_changes = False
self._create_menu()
self._create_widgets()
self._create_status_bar()
# Load config files if they exist
self._load_configs()
# Protocol for window close
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _create_menu(self):
"""Create the application menu bar."""
menubar = tk.Menu(self)
self.config(menu=menubar)
# File menu
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Open Tag Config...", command=self._open_tag_config)
file_menu.add_command(label="Save Tag Config", command=self._save_tag_config)
file_menu.add_command(label="Save Tag Config As...", command=self._save_tag_config_as)
file_menu.add_separator()
file_menu.add_command(label="Open Video Mappings...", command=self._open_video_mappings)
file_menu.add_command(label="Save Video Mappings", command=self._save_video_mappings)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=self._on_close)
# Portal menu
portal_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Portal", menu=portal_menu)
portal_menu.add_command(label="Connect Portal", command=self._connect_portal)
portal_menu.add_command(label="Disconnect Portal", command=self._disconnect_portal)
portal_menu.add_separator()
portal_menu.add_command(label="Test All Colors", command=self._test_all_colors)
# Help menu
help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Help", menu=help_menu)
help_menu.add_command(label="About", command=self._show_about)
def _create_widgets(self):
"""Create the main window widgets."""
# Main paned window
paned = ttk.PanedWindow(self, orient="horizontal")
paned.pack(fill="both", expand=True, padx=5, pady=5)
# Left panel - Tag list
left_frame = ttk.Frame(paned, width=350)
paned.add(left_frame, weight=1)
# Tag list header
header_frame = ttk.Frame(left_frame)
header_frame.pack(fill="x", pady=(0, 5))
ttk.Label(header_frame, text="Tags", font=("", 12, "bold")).pack(side="left")
# Search/filter
self.search_var = tk.StringVar()
self.search_var.trace("w", self._on_search_change)
search_entry = ttk.Entry(header_frame, textvariable=self.search_var, width=20)
search_entry.pack(side="right", padx=5)
ttk.Label(header_frame, text="Search:").pack(side="right")
# Tag treeview
tree_frame = ttk.Frame(left_frame)
tree_frame.pack(fill="both", expand=True)
columns = ("name", "effect", "color")
self.tag_tree = ttk.Treeview(tree_frame, columns=columns, show="headings",
selectmode="browse")
self.tag_tree.heading("name", text="Name")
self.tag_tree.heading("effect", text="Effect")
self.tag_tree.heading("color", text="Color")
self.tag_tree.column("name", width=150)
self.tag_tree.column("effect", width=60)
self.tag_tree.column("color", width=80)
scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tag_tree.yview)
self.tag_tree.configure(yscrollcommand=scrollbar.set)
self.tag_tree.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Tag tree bindings
self.tag_tree.bind("<Double-1>", self._on_tag_double_click)
self.tag_tree.bind("<<TreeviewSelect>>", self._on_tag_select)
# Tag list buttons
btn_frame = ttk.Frame(left_frame)
btn_frame.pack(fill="x", pady=(5, 0))
ttk.Button(btn_frame, text="Edit Theme", command=self._edit_selected_tag).pack(side="left", padx=2)
ttk.Button(btn_frame, text="Assign Video", command=self._assign_video).pack(side="left", padx=2)
ttk.Button(btn_frame, text="Delete", command=self._delete_selected_tag).pack(side="left", padx=2)
# Right panel - Details and preview
right_frame = ttk.Frame(paned, width=400)
paned.add(right_frame, weight=1)
# Tag details
details_frame = ttk.LabelFrame(right_frame, text="Tag Details", padding="10")
details_frame.pack(fill="x", pady=(0, 10))
# Details grid
detail_labels = [
("Legacy Key:", "legacy_key_label"),
("Name:", "name_label"),
("Color:", "color_label"),
("Effect:", "effect_label"),
("Speed:", "speed_label"),
("Video:", "video_label"),
]
for i, (text, attr) in enumerate(detail_labels):
ttk.Label(details_frame, text=text).grid(row=i, column=0, sticky="e", padx=(0, 5), pady=2)
label = ttk.Label(details_frame, text="-")
label.grid(row=i, column=1, sticky="w", pady=2)
setattr(self, attr, label)
# Color preview
ttk.Label(details_frame, text="Preview:").grid(row=len(detail_labels), column=0,
sticky="ne", padx=(0, 5), pady=5)
self.detail_color_preview = ColorPreview(details_frame, [128, 128, 128], size=60)
self.detail_color_preview.grid(row=len(detail_labels), column=1, sticky="w", pady=5)
# Portal control
portal_frame = ttk.LabelFrame(right_frame, text="Portal Control", padding="10")
portal_frame.pack(fill="x", pady=(0, 10))
# Portal status
status_frame = ttk.Frame(portal_frame)
status_frame.pack(fill="x", pady=(0, 5))
ttk.Label(status_frame, text="Status:").pack(side="left")
self.portal_status_label = ttk.Label(status_frame, text="Disconnected", foreground="red")
self.portal_status_label.pack(side="left", padx=5)
reader_info = f"Reader: v{READER_VERSION}" if READER_AVAILABLE else "Reader: Not available"
ttk.Label(status_frame, text=reader_info, foreground="gray50").pack(side="right")
# Portal buttons
portal_btn_frame = ttk.Frame(portal_frame)
portal_btn_frame.pack(fill="x", pady=5)
self.connect_btn = ttk.Button(portal_btn_frame, text="Connect",
command=self._connect_portal)
self.connect_btn.pack(side="left", padx=2)
ttk.Button(portal_btn_frame, text="Test Selected",
command=self._test_selected_color).pack(side="left", padx=2)
ttk.Button(portal_btn_frame, text="Lights Off",
command=self._lights_off).pack(side="left", padx=2)
# New tag detection
detect_frame = ttk.LabelFrame(right_frame, text="Tag Detection", padding="10")
detect_frame.pack(fill="x", pady=(0, 10))
self.detect_label = ttk.Label(detect_frame,
text="Connect portal and place a tag to detect new tags")
self.detect_label.pack(fill="x")
self.detected_tag_info = ttk.Label(detect_frame, text="", foreground="blue")
self.detected_tag_info.pack(fill="x", pady=5)
ttk.Button(detect_frame, text="Add Detected Tag",
command=self._add_detected_tag, state="disabled").pack(pady=5)
self.add_detected_btn = detect_frame.winfo_children()[-1]
# Default theme editor
default_frame = ttk.LabelFrame(right_frame, text="Default Theme", padding="10")
default_frame.pack(fill="x")
default_inner = ttk.Frame(default_frame)
default_inner.pack(fill="x")
ttk.Label(default_inner, text="Color:").pack(side="left")
self.default_color_preview = ColorPreview(default_inner, [0, 255, 255], size=30)
self.default_color_preview.pack(side="left", padx=5)
self.default_effect_var = tk.StringVar(value="solid")
effect_combo = ttk.Combobox(default_inner, textvariable=self.default_effect_var,
values=["solid", "flash", "pulse"], width=8, state="readonly")
effect_combo.pack(side="left", padx=5)
ttk.Button(default_inner, text="Edit",
command=self._edit_default_theme).pack(side="left", padx=5)
def _create_status_bar(self):
"""Create the status bar."""
status_frame = ttk.Frame(self)
status_frame.pack(fill="x", side="bottom")
self.status_label = ttk.Label(status_frame, text="Ready", relief="sunken")
self.status_label.pack(fill="x", padx=2, pady=2)
def _set_status(self, message: str):
"""Update status bar message."""
self.status_label.config(text=message)
def _load_configs(self):
"""Load configuration files."""
# Try to load tag colors
if os.path.exists(self.tag_colors_path):
self._load_tag_colors(self.tag_colors_path)
# Try to load video mappings
if os.path.exists(self.video_mappings_path):
self._load_video_mappings(self.video_mappings_path)
def _load_tag_colors(self, filepath: str):
"""Load tag colors from JSON file."""
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
self.tag_colors_path = filepath
# Load default theme
if 'default' in data:
self.default_theme = data['default']
self.default_color_preview.color = self.default_theme.get('color', [0, 255, 255])
self.default_effect_var.set(self.default_theme.get('effect', 'solid'))
# Load preset colors
if '_preset_colors' in data:
self.preset_colors = {k: v for k, v in data['_preset_colors'].items()
if not k.startswith('_')}
# Load tags
self.tags.clear()
if 'tags' in data:
for legacy_key, tag_data in data['tags'].items():
self.tags[legacy_key] = TagEntry(
legacy_key=legacy_key,
name=tag_data.get('name', ''),
color=tag_data.get('color', [0, 255, 255]),
effect=tag_data.get('effect', 'solid'),
speed=tag_data.get('speed', 1.0)
)
self._refresh_tag_list()
self._set_status(f"Loaded {len(self.tags)} tags from {filepath}")
except Exception as e:
messagebox.showerror("Error", f"Failed to load tag colors: {e}")
def _load_video_mappings(self, filepath: str):
"""Load video mappings from JSON file."""
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
self.video_mappings_path = filepath
# Match video paths to tags by UID
mappings = data.get('mappings', data)
for uid, video_path in mappings.items():
if not uid.startswith('_'):
# Try to find matching tag
for tag in self.tags.values():
if tag.legacy_key == uid:
tag.video_path = video_path
break
self._refresh_tag_list()
self._set_status(f"Loaded video mappings from {filepath}")
except Exception as e:
# Video mappings are optional, don't show error
pass
def _save_tag_config(self):
"""Save tag colors to current file."""
if not self.tag_colors_path:
self._save_tag_config_as()
return
self._save_tag_colors(self.tag_colors_path)
def _save_tag_config_as(self):
"""Save tag colors to new file."""
filepath = filedialog.asksaveasfilename(
defaultextension=".json",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
initialfile="tag_colors.json"
)
if filepath:
self._save_tag_colors(filepath)
self.tag_colors_path = filepath
def _save_tag_colors(self, filepath: str):
"""Save tag colors to JSON file."""
try:
data = {
"_comment": "Tag color mappings for LEGO Dimensions Portal Client",
"_instructions": [
"Map legacy keys to colors and effects",
"Effects: 'solid', 'pulse', 'flash'",
"Colors: [R, G, B] values 0-255",
"Speed: 0.2 (fast) to 3.0 (slow), 1.0 = normal",
"Run the portal client to discover tag legacy keys"
],
"default": self.default_theme,
"tags": {},
"_preset_colors": self.preset_colors or {
"red": [255, 0, 0],
"green": [0, 255, 0],
"blue": [0, 0, 255],
"white": [255, 255, 255],
"yellow": [255, 255, 0],
"cyan": [0, 255, 255],
"magenta": [255, 0, 255],
"orange": [255, 128, 0],
"purple": [128, 0, 255]
}
}
for legacy_key, tag in self.tags.items():
data['tags'][legacy_key] = {
'color': tag.color,
'effect': tag.effect,
'speed': tag.speed,
'name': tag.name
}
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4)
self.unsaved_changes = False
self._set_status(f"Saved {len(self.tags)} tags to {filepath}")
except Exception as e:
messagebox.showerror("Error", f"Failed to save tag colors: {e}")
def _save_video_mappings(self):
"""Save video mappings to JSON file."""
filepath = filedialog.asksaveasfilename(
defaultextension=".json",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
initialfile="video_mappings.json"
)
if not filepath:
return
try:
mappings = {}
for tag in self.tags.values():
if tag.video_path:
mappings[tag.legacy_key] = tag.video_path
data = {
"_comment": "Map LEGO Dimensions tag UIDs to video file paths",
"mappings": mappings
}
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
self.video_mappings_path = filepath
self._set_status(f"Saved {len(mappings)} video mappings to {filepath}")
except Exception as e:
messagebox.showerror("Error", f"Failed to save video mappings: {e}")
def _open_tag_config(self):
"""Open a tag config file."""
filepath = filedialog.askopenfilename(
filetypes=[("JSON files", "*.json"), ("All files", "*.*")]
)
if filepath:
self._load_tag_colors(filepath)
def _open_video_mappings(self):
"""Open a video mappings file."""
filepath = filedialog.askopenfilename(
filetypes=[("JSON files", "*.json"), ("All files", "*.*")]
)
if filepath:
self._load_video_mappings(filepath)
def _refresh_tag_list(self):
"""Refresh the tag treeview."""
# Clear existing items
for item in self.tag_tree.get_children():
self.tag_tree.delete(item)
# Get search filter
search_term = self.search_var.get().lower()
# Add tags
for legacy_key, tag in sorted(self.tags.items(), key=lambda x: x[1].name.lower() or x[0]):
if search_term and search_term not in tag.name.lower() and search_term not in legacy_key:
continue
color_hex = f"#{tag.color[0]:02X}{tag.color[1]:02X}{tag.color[2]:02X}"
self.tag_tree.insert("", "end", iid=legacy_key, values=(
tag.name or f"Tag {legacy_key[:8]}...",
tag.effect.capitalize(),
color_hex
))
def _on_search_change(self, *args):
"""Handle search text change."""
self._refresh_tag_list()
def _on_tag_select(self, event):
"""Handle tag selection."""
selection = self.tag_tree.selection()
if not selection:
return
legacy_key = selection[0]
tag = self.tags.get(legacy_key)
if not tag:
return
# Update detail labels
self.legacy_key_label.config(text=legacy_key)
self.name_label.config(text=tag.name or "(unnamed)")
self.color_label.config(text=f"RGB({tag.color[0]}, {tag.color[1]}, {tag.color[2]})")
self.effect_label.config(text=tag.effect.capitalize())
self.speed_label.config(text=f"{getattr(tag, 'speed', 1.0):.1f}x")
self.video_label.config(text=tag.video_path or "(none)")
# Update color preview
self.detail_color_preview.color = tag.color
def _on_tag_double_click(self, event):
"""Handle double-click on tag."""
self._edit_selected_tag()
def _edit_selected_tag(self):
"""Edit the selected tag's theme."""
selection = self.tag_tree.selection()
if not selection:
messagebox.showinfo("Info", "Please select a tag to edit")
return
legacy_key = selection[0]
tag = self.tags.get(legacy_key)
if not tag:
return
# Open theme editor dialog
editor = ThemeEditor(self, tag, self.preset_colors)
if editor.result:
tag.name = editor.result['name']
tag.color = editor.result['color']
tag.effect = editor.result['effect']
tag.speed = editor.result.get('speed', 1.0)
self.unsaved_changes = True
self._refresh_tag_list()
# Re-select and update details
self.tag_tree.selection_set(legacy_key)
self._on_tag_select(None)
self._set_status(f"Updated theme for {tag.name or legacy_key}")
def _assign_video(self):
"""Assign a video file to the selected tag."""
selection = self.tag_tree.selection()
if not selection:
messagebox.showinfo("Info", "Please select a tag to assign video")
return
legacy_key = selection[0]
tag = self.tags.get(legacy_key)
if not tag:
return
filepath = filedialog.askopenfilename(
title="Select Video File",
filetypes=[
("Video files", "*.mp4 *.mkv *.avi *.mov *.webm"),
("All files", "*.*")
]
)
if filepath:
tag.video_path = filepath
self.unsaved_changes = True
self._on_tag_select(None)
self._set_status(f"Assigned video to {tag.name or legacy_key}")
def _delete_selected_tag(self):
"""Delete the selected tag."""
selection = self.tag_tree.selection()
if not selection:
return
legacy_key = selection[0]
tag = self.tags.get(legacy_key)
if messagebox.askyesno("Confirm Delete",
f"Delete tag '{tag.name or legacy_key}'?"):
del self.tags[legacy_key]
self.unsaved_changes = True
self._refresh_tag_list()
self._set_status(f"Deleted tag {legacy_key}")
def _edit_default_theme(self):
"""Edit the default theme."""
default_tag = TagEntry(
legacy_key="default",
name="Default",
color=self.default_theme.get('color', [0, 255, 255]),
effect=self.default_theme.get('effect', 'solid'),
speed=self.default_theme.get('speed', 1.0)
)
editor = ThemeEditor(self, default_tag, self.preset_colors)
if editor.result:
self.default_theme = {
'name': 'Default',
'color': editor.result['color'],
'effect': editor.result['effect'],
'speed': editor.result.get('speed', 1.0)
}
self.default_color_preview.color = editor.result['color']
self.default_effect_var.set(editor.result['effect'])
self.unsaved_changes = True
self._set_status("Updated default theme")
def _connect_portal(self):
"""Connect to the portal."""
if not READER_AVAILABLE:
messagebox.showerror("Error",
"Portal reader module not available.\n"
"Ensure lego_dimensions_reader.py is in the same directory.")
return
if self.reader and self.reader.is_connected:
self._disconnect_portal()
try:
self.reader = LegoDimensionsReader()
self.reader.on_tag_insert = self._on_tag_detected
self.reader.on_tag_remove = self._on_tag_removed
self.reader.on_connect = self._on_portal_connect
self.reader.on_disconnect = self._on_portal_disconnect
self.reader.start()
except Exception as e:
messagebox.showerror("Connection Error", str(e))
def _disconnect_portal(self):
"""Disconnect from the portal."""
if self.reader:
self.reader.disconnect()
self.reader = None
self.portal_status_label.config(text="Disconnected", foreground="red")
self.connect_btn.config(text="Connect")
self._set_status("Portal disconnected")
def _on_portal_connect(self):
"""Handle portal connection."""
self.portal_status_label.config(text="Connected", foreground="green")
self.connect_btn.config(text="Disconnect")
self._set_status("Portal connected - place tags to detect")
def _on_portal_disconnect(self):
"""Handle portal disconnection."""
self.portal_status_label.config(text="Disconnected", foreground="red")
self.connect_btn.config(text="Connect")
def _on_tag_detected(self, tag: TagInfo):
"""Handle tag detection from portal."""
legacy_key = str(tag.legacy_key)
if legacy_key in self.tags:
# Known tag - apply theme
tag_config = self.tags[legacy_key]
self.detected_tag_info.config(
text=f"Detected: {tag_config.name or legacy_key} (known)",
foreground="green"
)
# Apply the theme with speed-based timing
if self.reader:
speed = getattr(tag_config, 'speed', 1.0)
base_time = 0.2 # Base timing in seconds
on_time = base_time * speed
off_time = base_time * speed
self.reader.set_effect(
tag.pad, tag_config.effect, tag_config.color,
on_time=on_time, off_time=off_time
)
else:
# Unknown tag - store for adding
self._last_detected_tag = tag
self.detected_tag_info.config(
text=f"NEW TAG: {legacy_key}\nPad: {tag.pad.name}",
foreground="blue"
)
self.add_detected_btn.config(state="normal")
# Apply default theme with speed
if self.reader:
speed = self.default_theme.get('speed', 1.0)
base_time = 0.2
on_time = base_time * speed
off_time = base_time * speed
self.reader.set_effect(
tag.pad,
self.default_theme.get('effect', 'solid'),
self.default_theme.get('color', [0, 255, 255]),
on_time=on_time, off_time=off_time
)
def _on_tag_removed(self, tag: TagInfo):
"""Handle tag removal from portal."""
self.detected_tag_info.config(text="Tag removed", foreground="gray50")
# Turn off pad
if self.reader:
self.reader.stop_effect(tag.pad)
self.reader.set_pad_color(tag.pad, COLORS['OFF'])
def _add_detected_tag(self):
"""Add the last detected tag to the list."""
if not hasattr(self, '_last_detected_tag'):
return
tag = self._last_detected_tag
legacy_key = str(tag.legacy_key)
# Create new tag entry with default theme
self.tags[legacy_key] = TagEntry(
legacy_key=legacy_key,
name="",
color=self.default_theme.get('color', [0, 255, 255]).copy(),
effect=self.default_theme.get('effect', 'solid'),
speed=self.default_theme.get('speed', 1.0)
)
self.unsaved_changes = True
self._refresh_tag_list()
# Select and edit the new tag
self.tag_tree.selection_set(legacy_key)
self._on_tag_select(None)
# Disable button until next new tag
self.add_detected_btn.config(state="disabled")
del self._last_detected_tag
self._set_status(f"Added new tag {legacy_key}")
# Prompt to edit
if messagebox.askyesno("Edit Tag", "Edit the new tag's name and theme?"):
self._edit_selected_tag()
def _test_selected_color(self):
"""Test the selected tag's color on the portal."""
if not self.reader or not self.reader.is_connected:
messagebox.showinfo("Info", "Portal not connected")
return
selection = self.tag_tree.selection()
if not selection:
# Use default theme
color = self.default_theme.get('color', [0, 255, 255])
effect = self.default_theme.get('effect', 'solid')
speed = self.default_theme.get('speed', 1.0)
else:
tag = self.tags.get(selection[0])
if not tag:
return
color = tag.color
effect = tag.effect
speed = getattr(tag, 'speed', 1.0)
# Apply to center pad with speed-based timing
base_time = 0.2
on_time = base_time * speed
off_time = base_time * speed
self.reader.set_effect(Pad.CENTER, effect, color, on_time=on_time, off_time=off_time)
self._set_status(f"Testing: {effect} at {speed:.1f}x with RGB{tuple(color)}")
def _lights_off(self):
"""Turn off all portal lights."""
if self.reader and self.reader.is_connected:
for pad in [Pad.CENTER, Pad.LEFT, Pad.RIGHT]:
self.reader.stop_effect(pad)
self.reader.set_pad_color(pad, COLORS['OFF'])
self._set_status("All lights off")
def _test_all_colors(self):
"""Test all colors on the portal."""
if not self.reader or not self.reader.is_connected:
messagebox.showinfo("Info", "Portal not connected")
return
def cycle():
colors = [
COLORS['RED'], COLORS['GREEN'], COLORS['BLUE'],
COLORS['YELLOW'], COLORS['CYAN'], COLORS['MAGENTA'],
COLORS['WHITE']
]
for color in colors:
if self.reader and self.reader.is_connected:
self.reader.set_pad_color(Pad.ALL, color)
time.sleep(0.5)
if self.reader and self.reader.is_connected:
self.reader.set_pad_color(Pad.ALL, COLORS['OFF'])
threading.Thread(target=cycle, daemon=True).start()
self._set_status("Testing all colors...")
def _show_about(self):
"""Show about dialog."""
messagebox.showinfo(
"About",
f"LEGO Dimensions Portal Configurator\n"
f"Version {__version__}\n\n"
f"Reader Module: {'v' + READER_VERSION if READER_AVAILABLE else 'Not available'}\n\n"
f"A tool for configuring tag themes and\n"
f"video mappings for the Moonlight Drive-In\n"
f"video player system."
)
def _on_close(self):
"""Handle window close."""
if self.unsaved_changes:
result = messagebox.askyesnocancel(
"Unsaved Changes",
"You have unsaved changes. Save before exiting?"
)
if result is None: # Cancel
return
if result: # Yes
self._save_tag_config()
self._disconnect_portal()
self.destroy()
def main():
"""Main entry point."""
app = PortalConfigGUI()
app.mainloop()
if __name__ == "__main__":
main()