- 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
1058 lines
40 KiB
Python
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()
|