#!/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()