diff --git a/portal_config_gui.py b/portal_config_gui.py new file mode 100644 index 0000000..7f65200 --- /dev/null +++ b/portal_config_gui.py @@ -0,0 +1,1057 @@ +#!/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("", lambda e: self._on_rgb_change()) + entry.bind("", 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("<>", 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("", self._on_tag_double_click) + self.tag_tree.bind("<>", 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()