From 6f6abd020a4dffe643a8e564b4a174c4e6b2f6fb Mon Sep 17 00:00:00 2001 From: Olivier Jacques Date: Sun, 26 Jul 2026 11:31:36 +0200 Subject: [PATCH 1/4] fix(pinball): fix clock overlay, add colon blink and complementary colors - Fix frame_layer=1 scenes: clock overlay was erasing animation due to incorrect DotBlt mask semantics. Added overlay_clock_above() that uses pixel-based transparency instead of the font's DotBlt mask. - Add colon blinking during pinball animations (toggles every ~500ms), matching the main clock behavior. - Animation color now defaults to the HSV-complementary palette color (e.g. orange clock -> blue animation) instead of the same color. Added complementary_color() utility in colors.py. - Add scripts/debug_scene.py for .scn rendering analysis and debugging. --- README.md | 2 +- scripts/debug_scene.py | 274 ++++++++++++++++++++++++++++++ tests/test_pinball_plugin.py | 8 +- zeclock/colors.py | 35 ++++ zeclock/overlay.py | 47 +++++ zeclock/plugins/pinball_plugin.py | 32 +++- 6 files changed, 385 insertions(+), 13 deletions(-) create mode 100644 scripts/debug_scene.py diff --git a/README.md b/README.md index 8e67ec2..2c0ac83 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ The full guide covers multi-page plugins, config schemas (auto-generated web UI | **speaker-timer** | `yellow_threshold` | % remaining to switch to orange (default: 20) | | | `red_threshold` | % remaining to switch to red (default: 10) | | **pinball** | `color` | Animation color name (default: `orange`) | -| | `animation_color` | Separate animation color (default: same as `color`) | +| | `animation_color` | Separate animation color (default: complementary of `color`) | | **pong** | `color` | Game color name (default: `orange`) | | **eyes** | `color` | Eyes color name (default: `cyan`) | | **paragliding** | `api_key` | Paraglidable API key (required, free from paraglidable.com) | diff --git a/scripts/debug_scene.py b/scripts/debug_scene.py new file mode 100644 index 0000000..d8df6e1 --- /dev/null +++ b/scripts/debug_scene.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Debug script for analyzing .scn scene rendering issues. + +Usage: + uv run python scripts/debug_scene.py RD0673.scn [--send] + +Loads the scene, dumps frame metadata, saves individual frames as PNG, +and optionally sends them to the virtual DMD for live visualization. +""" + +import argparse +import socket +import struct +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from PIL import Image +from zeclock.readers.scn_reader import Scene, load_scene +from zeclock.overlay import overlay_or_rgb, colorize_grayscale +from zeclock.colors import COLOR_MAP + + +def dump_scene_info(scene: Scene) -> None: + """Print all metadata about the scene.""" + print(f"\n{'='*60}") + print(f"Scene: {scene.path.name}") + print(f"{'='*60}") + print(f" Dimensions: {scene.width}x{scene.height}") + print(f" Frame count: {scene.frame_count}") + print(f" Actual frames loaded: {len(scene.frames)}") + print(f" Frame delay (ms): {scene.frame_delay_ms}") + print( + f" FPS: {1000.0 / scene.frame_delay_ms if scene.frame_delay_ms > 0 else 'N/A':.1f}" + ) + print(f"\n Storyboard:") + print(f" first_frame_delay: {scene.first_frame_delay}") + print(f" first_frame_layer: {scene.first_frame_layer}") + print(f" first_blank: {scene.first_blank}") + print(f" frame_delay_ms: {scene.frame_delay_ms}") + print(f" frame_layer: {scene.frame_layer}") + print(f" last_frame_delay: {scene.last_frame_delay}") + print(f" last_frame_layer: {scene.last_frame_layer}") + print(f" last_blank: {scene.last_blank}") + print(f" clock_style: {scene.clock_style}") + print(f" custom_x: {scene.custom_x}") + print(f" custom_y: {scene.custom_y}") + print() + + +def analyze_frames(scene: Scene, output_dir: Path) -> None: + """Analyze each frame and save as PNG.""" + output_dir.mkdir(parents=True, exist_ok=True) + + print(f" Saving frames to: {output_dir}/") + print( + f" {'Frame':<8} {'Size':<12} {'Mode':<6} {'HasMask':<9} {'MinPx':<6} {'MaxPx':<6} {'MeanPx':<8}" + ) + print(f" {'-'*55}") + + for i, frame in enumerate(scene.frames): + has_mask = hasattr(frame, "mask_data") and frame.mask_data is not None + stats = frame.getextrema() + mean_val = sum(frame.getdata()) / (frame.size[0] * frame.size[1]) + + # Print info for first few frames and last few + if i < 5 or i >= len(scene.frames) - 3: + print( + f" {i:<8} {frame.size[0]}x{frame.size[1]:<6} {frame.mode:<6} " + f"{'Yes' if has_mask else 'No':<9} {stats[0]:<6} {stats[1]:<6} {mean_val:<8.1f}" + ) + elif i == 5: + print(f" ... ({len(scene.frames) - 8} more frames) ...") + + # Save every Nth frame + first and last + if ( + i < 3 + or i >= len(scene.frames) - 2 + or i % max(1, len(scene.frames) // 10) == 0 + ): + # Save raw grayscale + frame.save(output_dir / f"frame_{i:04d}_raw.png") + + # Save colorized version (orange) + color = COLOR_MAP["orange"] + colorized = colorize_grayscale(frame, color) + colorized.save(output_dir / f"frame_{i:04d}_color.png") + + print(f"\n Saved sample frames to {output_dir}/") + + +def render_with_overlay(scene: Scene, output_dir: Path) -> None: + """Render frames with clock overlay like the plugin does.""" + from zeclock.readers import load_font + from zeclock.resources.paths import get_fonts_dir + + fonts_dir = get_fonts_dir() + font_path = fonts_dir / "STANDARD.fnt" + + if not font_path.exists(): + print(f" ⚠️ Font not found at {font_path}, skipping overlay render") + return + + font = load_font(font_path) + display_time = "12:34" + color = COLOR_MAP["orange"] + animation_color = COLOR_MAP["orange"] + + overlay_dir = output_dir / "overlay" + overlay_dir.mkdir(parents=True, exist_ok=True) + + print( + f"\n Rendering with clock overlay (clock_style={scene.clock_style}, " + f"frame_layer={scene.frame_layer})..." + ) + + for i, animation_frame in enumerate(scene.frames): + if ( + i < 3 + or i >= len(scene.frames) - 2 + or i % max(1, len(scene.frames) // 10) == 0 + ): + # Render clock + if scene.clock_style == 1: + # Custom position + text_width = font.get_text_width(display_time) + text_height = font.char_height + x_pos = max( + 0, min(scene.custom_x - (text_width // 2), scene.width - text_width) + ) + y_pos = max( + 0, + min( + scene.custom_y - (text_height // 2), scene.height - text_height + ), + ) + clock_frame = Image.new("L", (scene.width, scene.height), 0) + text_img = font.render_text(display_time, text_width, text_height) + clock_frame.paste(text_img, (x_pos, y_pos)) + else: + clock_frame = font.render_text(display_time, scene.width, scene.height) + + # Merge with DotBlt + if scene.frame_layer == 1: + merged = overlay_or_rgb( + animation_frame, clock_frame, animation_color, color + ) + else: + merged = overlay_or_rgb( + clock_frame, animation_frame, color, animation_color + ) + + merged.save(overlay_dir / f"frame_{i:04d}_merged.png") + + print(f" Saved overlay frames to {overlay_dir}/") + + +def send_to_virtual_dmd( + scene: Scene, host: str = "localhost", port: int = 6789 +) -> None: + """Send frames to the virtual DMD server for live preview.""" + color = COLOR_MAP["orange"] + + print(f"\n Sending frames to virtual DMD at {host}:{port}...") + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + except ConnectionRefusedError: + print(f" ❌ Cannot connect to virtual DMD at {host}:{port}") + print(f" Start it with: uv run python scripts/virtual-dmd.py 6789") + return + + try: + for i, frame in enumerate(scene.frames): + # Colorize to RGB + colorized = colorize_grayscale(frame, color) + width, height = colorized.size + + # Convert to RGB24 bytes + rgb_data = colorized.tobytes() + + # DMDStream header + header = b"DMDStream\x00" + header += struct.pack(">B", 0) # padding + header += struct.pack(">I", 2) # mode=2 (RGB24) + header += struct.pack(">H", width) + header += struct.pack(">H", height) + header += struct.pack(">H", 0) # padding + header += struct.pack(">I", len(rgb_data)) + + sock.sendall(header + rgb_data) + + # Respect frame timing + delay = scene.frame_delay_ms / 1000.0 + time.sleep(delay) + + if i % 20 == 0: + print(f" Frame {i}/{len(scene.frames)}", end="\r") + + print(f" Done! Sent {len(scene.frames)} frames. ") + finally: + sock.close() + + +def main(): + parser = argparse.ArgumentParser(description="Debug .scn scene rendering") + parser.add_argument("scene", help="Scene filename (e.g., RD0673.scn) or full path") + parser.add_argument( + "--send", action="store_true", help="Send frames to virtual DMD" + ) + parser.add_argument( + "--no-overlay", action="store_true", help="Skip overlay rendering" + ) + parser.add_argument( + "--output", + "-o", + type=str, + default=None, + help="Output directory for frame captures", + ) + parser.add_argument("--host", default="localhost", help="Virtual DMD host") + parser.add_argument("--port", type=int, default=6789, help="Virtual DMD port") + args = parser.parse_args() + + # Resolve scene path + scene_path = Path(args.scene) + if not scene_path.exists(): + # Try in animations directory + from zeclock.paths import get_resources_dir + + animations_dir = get_resources_dir() / "animations" + scene_path = animations_dir / args.scene + if not scene_path.exists(): + # Try recursive search + matches = list(animations_dir.glob(f"**/{args.scene}")) + if matches: + scene_path = matches[0] + else: + print(f"❌ Scene not found: {args.scene}") + print(f" Searched in: {animations_dir}") + sys.exit(1) + + # Load scene + print(f"Loading scene: {scene_path}") + scene = load_scene(scene_path, 128, 32) + + # Dump info + dump_scene_info(scene) + + # Output directory + output_dir = ( + Path(args.output) + if args.output + else Path(f"/tmp/zeclock_debug_{scene_path.stem}") + ) + + # Analyze frames + analyze_frames(scene, output_dir) + + # Render with overlay + if not args.no_overlay: + render_with_overlay(scene, output_dir) + + # Send to virtual DMD + if args.send: + send_to_virtual_dmd(scene, args.host, args.port) + + print(f"\n✅ Debug complete. Frames saved to: {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_pinball_plugin.py b/tests/test_pinball_plugin.py index 6679309..c92bb51 100644 --- a/tests/test_pinball_plugin.py +++ b/tests/test_pinball_plugin.py @@ -124,11 +124,13 @@ async def test_animation_color_config_passthrough(self): assert plugin._animation_color == COLOR_MAP["red"] @pytest.mark.asyncio - async def test_animation_color_defaults_to_color(self): - """When animation_color is not set, it defaults to the clock color. + async def test_animation_color_defaults_to_complementary(self): + """When animation_color is not set, it defaults to the complementary color. **Validates: Requirements 5.1** """ + from zeclock.colors import complementary_color + plugin = PinballPlugin() with tempfile.TemporaryDirectory() as tmpdir: @@ -138,7 +140,7 @@ async def test_animation_color_defaults_to_color(self): } await plugin.initialize(config) - assert plugin._animation_color == COLOR_MAP["green"] + assert plugin._animation_color == complementary_color(COLOR_MAP["green"]) @pytest.mark.asyncio async def test_invalid_color_falls_back_to_default(self): diff --git a/zeclock/colors.py b/zeclock/colors.py index 90a9d57..fe43450 100644 --- a/zeclock/colors.py +++ b/zeclock/colors.py @@ -3,6 +3,7 @@ Centralizes the color palette used across the clock, plugins, and rendering. """ +import colorsys from typing import Dict, Tuple # Canonical color palette: name → RGB tuple @@ -22,3 +23,37 @@ # Reverse lookup: RGB tuple → name COLOR_NAMES: Dict[Tuple[int, int, int], str] = {v: k for k, v in COLOR_MAP.items()} + + +def complementary_color(color: Tuple[int, int, int]) -> Tuple[int, int, int]: + """Return the palette color most complementary to the given color. + + Computes the HSV complement (180° hue rotation) then finds the closest + match in COLOR_MAP by Euclidean distance in RGB space. + + Args: + color: RGB tuple (0-255 per channel). + + Returns: + The COLOR_MAP entry closest to the HSV complement of the input. + """ + r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0 + h, s, v = colorsys.rgb_to_hsv(r, g, b) + + # Rotate hue by 180° + h_comp = (h + 0.5) % 1.0 + rc, gc, bc = colorsys.hsv_to_rgb(h_comp, s, v) + target = (int(rc * 255), int(gc * 255), int(bc * 255)) + + # Find closest palette color (Euclidean distance in RGB) + best: Tuple[int, int, int] = COLOR_LIST[0] + best_dist = float("inf") + for candidate in COLOR_LIST: + if candidate == color: + continue # Skip the input color itself + dist = sum((a - b_) ** 2 for a, b_ in zip(target, candidate)) + if dist < best_dist: + best_dist = dist + best = candidate + + return best diff --git a/zeclock/overlay.py b/zeclock/overlay.py index a415268..bf83ca7 100644 --- a/zeclock/overlay.py +++ b/zeclock/overlay.py @@ -643,3 +643,50 @@ def overlay_or_rgb( else: # No mask: overlay replaces everything return overlay_rgb + + +# Threshold for font outline pixels (nibble 0 maps to gray value 2) +_FONT_OUTLINE_THRESHOLD = 2 + + +def overlay_clock_above( + base: Image.Image, + overlay: Image.Image, + base_color: Tuple[int, int, int], + overlay_color: Tuple[int, int, int], +) -> Image.Image: + """Composite clock overlay above animation using pixel-based transparency. + + Used when frame_layer=1 (clock drawn on top of animation). Unlike + overlay_or_rgb which uses the DotBlt bit-mask, this function treats + overlay pixels with value <= _FONT_OUTLINE_THRESHOLD as transparent, + letting the base (animation) show through everywhere the clock has no + visible content. + + This fixes scenes where the clock's DotBlt mask only marks outline + pixels as transparent, leaving the black background opaque and + incorrectly erasing the animation underneath. + + Args: + base: Grayscale animation frame (mode 'L'). + overlay: Grayscale clock frame (mode 'L'). + base_color: RGB color for the animation. + overlay_color: RGB color for the clock text. + + Returns: + Merged RGB PIL Image with clock digits over animation. + """ + width, height = base.size + + # Build transparency mask from overlay pixel values: + # mask=255 (transparent, show base) where overlay pixel <= threshold + # mask=0 (opaque, show overlay) where overlay pixel > threshold + overlay_bytes = overlay.tobytes() + threshold = _FONT_OUTLINE_THRESHOLD + mask_data = bytes(255 if p <= threshold else 0 for p in overlay_bytes) + mask_img = Image.frombytes("L", (width, height), mask_data) + + base_rgb = colorize_grayscale(base, base_color) + overlay_rgb = colorize_grayscale(overlay, overlay_color) + + return Image.composite(base_rgb, overlay_rgb, mask_img) diff --git a/zeclock/plugins/pinball_plugin.py b/zeclock/plugins/pinball_plugin.py index 99fe548..eda0e47 100644 --- a/zeclock/plugins/pinball_plugin.py +++ b/zeclock/plugins/pinball_plugin.py @@ -17,8 +17,8 @@ from PIL import Image from .base import ClockPlugin -from ..colors import COLOR_MAP -from ..overlay import overlay_or_rgb, upscale_2x +from ..colors import COLOR_MAP, complementary_color +from ..overlay import overlay_or_rgb, overlay_clock_above, upscale_2x from ..readers import BitmapFont, Scene, load_font, load_scene logger = logging.getLogger(__name__) @@ -69,7 +69,7 @@ async def initialize(self, config: dict) -> None: Config keys: color (str): Clock color name (default: "orange") - animation_color (str): Animation color name (default: same as color) + animation_color (str): Animation color name (default: complementary) animations_dir (str|Path): Override animations directory path width (int): Display width (default: 128) height (int): Display height (default: 32) @@ -81,8 +81,12 @@ async def initialize(self, config: dict) -> None: color_name = config.get("color", DEFAULT_COLOR) self._color = COLOR_MAP.get(color_name, COLOR_MAP[DEFAULT_COLOR]) - animation_color_name = config.get("animation_color", color_name) - self._animation_color = COLOR_MAP.get(animation_color_name, self._color) + animation_color_name = config.get("animation_color") + if animation_color_name: + self._animation_color = COLOR_MAP.get(animation_color_name, self._color) + else: + # Default: complementary palette color for visual contrast + self._animation_color = complementary_color(self._color) # Allow overriding animations directory (useful for testing) animations_dir = config.get("animations_dir") @@ -220,15 +224,24 @@ def _precompute_in_background(self, scene: Scene, width: int, height: int) -> No scene.frame_delay_ms if scene.frame_delay_ms > 0 else DEFAULT_FRAME_DELAY_MS ) + # Colon blink: toggle every ~500ms (same cadence as the main clock) + blink_period_frames = max(1, int(500 / frame_delay)) + needs_upscale = scene.width != width or scene.height != height computed_frames: List[Image.Image] = [] - for animation_frame in scene.frames: + for frame_idx, animation_frame in enumerate(scene.frames): if needs_upscale: animation_frame = upscale_2x(animation_frame, mode=self._upscale_mode) + # Alternate colon visibility: ":" shown, then " " hidden + colon_visible = (frame_idx // blink_period_frames) % 2 == 0 + current_time = ( + display_time if colon_visible else display_time.replace(":", " ") + ) + merged = self._create_merged_frame( - animation_frame, display_time, scene, width, height + animation_frame, current_time, scene, width, height ) computed_frames.append(merged) @@ -309,8 +322,9 @@ def _create_merged_frame( # Apply DotBlt overlay with dual colors based on frame_layer if scene.frame_layer == 1: - # Clock above animation: animation is base, clock is overlay - merged = overlay_or_rgb( + # Clock above animation: use pixel-based transparency so the + # clock's black background doesn't erase the animation. + merged = overlay_clock_above( animation_frame, clock_frame, self._animation_color, self._color ) else: From 9e17d282128bafbb4a8d69afabdb078b0eae4955 Mon Sep 17 00:00:00 2001 From: Olivier Jacques Date: Sun, 26 Jul 2026 11:44:21 +0200 Subject: [PATCH 2/4] fix(pinball): use clock's current color instead of hardcoded default PluginManager now exposes current_color (set by ZeClock on every color change in auto mode). Pinball plugin reads _current_color from config so it matches the clock's active color at activation time. This ensures the pinball clock overlay uses the same color as the main clock even in auto-rotate mode, rather than always defaulting to orange. --- zeclock/clock.py | 3 +++ zeclock/plugin_manager.py | 9 ++++++++- zeclock/plugins/pinball_plugin.py | 11 +++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/zeclock/clock.py b/zeclock/clock.py index acc2247..dab034b 100644 --- a/zeclock/clock.py +++ b/zeclock/clock.py @@ -284,6 +284,8 @@ async def run(self) -> None: if self.color_mode == "auto" and now - self.last_color_change >= 60: self.color = COLOR_LIST[int(now // 60) % len(COLOR_LIST)] self.last_color_change = now + if self._plugin_manager: + self._plugin_manager.current_color = self.color # Remote control: check for alert overlay (rich: border + icon) if self._command_handler and self._command_handler.has_alert: @@ -524,6 +526,7 @@ async def _init_plugin_system(self) -> None: upscale_mode=self.upscale_mode, font=self.font_name, ) + self._plugin_manager.current_color = self.color try: await self._plugin_manager.discover_and_load() diff --git a/zeclock/plugin_manager.py b/zeclock/plugin_manager.py index e4433df..f274239 100644 --- a/zeclock/plugin_manager.py +++ b/zeclock/plugin_manager.py @@ -9,7 +9,7 @@ import sys import time from pathlib import Path -from typing import Optional +from typing import Optional, Tuple from PIL import Image @@ -65,6 +65,9 @@ def __init__( self.plugin_start_time: float = 0.0 self.last_good_frame: Optional[Image.Image] = None self.init_timeout: float = 10.0 # seconds, configurable for testing + # Current clock color (RGB tuple), updated by ZeClock on each color change. + # Injected into plugin configs as "color" when not explicitly set. + self.current_color: Optional[Tuple[int, int, int]] = None # Set up resources path for PluginHelpers if resources_path is None: @@ -306,6 +309,10 @@ def get_plugin_config_with_helpers(self, plugin_name: str) -> dict: # Snapshot user settings before adding infrastructure keys user_settings = dict(config) + # Inject current clock color when no explicit color is configured + if self.current_color and "color" not in config: + config["_current_color"] = self.current_color + config["_helpers"] = self._helpers config["_upscale_mode"] = self.upscale_mode config["_font"] = self.font_name diff --git a/zeclock/plugins/pinball_plugin.py b/zeclock/plugins/pinball_plugin.py index eda0e47..a46ff72 100644 --- a/zeclock/plugins/pinball_plugin.py +++ b/zeclock/plugins/pinball_plugin.py @@ -78,8 +78,15 @@ async def initialize(self, config: dict) -> None: config: Plugin-specific settings from plugins.yaml. """ # Parse color settings - color_name = config.get("color", DEFAULT_COLOR) - self._color = COLOR_MAP.get(color_name, COLOR_MAP[DEFAULT_COLOR]) + color_name = config.get("color") + if color_name and color_name != "auto": + # Explicit color configured in plugins.yaml + self._color = COLOR_MAP.get(color_name, COLOR_MAP[DEFAULT_COLOR]) + elif "_current_color" in config: + # Use the clock's current color (passed by PluginManager) + self._color = config["_current_color"] + else: + self._color = COLOR_MAP[DEFAULT_COLOR] animation_color_name = config.get("animation_color") if animation_color_name: From 37146280263ba34ab6d71cd4015d33d9be4de621 Mon Sep 17 00:00:00 2001 From: Olivier Jacques Date: Sun, 26 Jul 2026 11:47:27 +0200 Subject: [PATCH 3/4] fix(pinball): read color from default plugin (clock) not ZeClock The clock plugin has its own color palette (includes white) and its own auto-rotate cycle, independent from ZeClock's COLOR_LIST. Previously the pinball got ZeClock's color which was out of sync with what the user actually sees. Now PluginManager._get_default_plugin_color() reads _current_color directly from the default plugin instance, so the pinball clock overlay always matches the color displayed between animations. --- zeclock/plugin_manager.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/zeclock/plugin_manager.py b/zeclock/plugin_manager.py index f274239..d50559e 100644 --- a/zeclock/plugin_manager.py +++ b/zeclock/plugin_manager.py @@ -310,8 +310,10 @@ def get_plugin_config_with_helpers(self, plugin_name: str) -> dict: user_settings = dict(config) # Inject current clock color when no explicit color is configured - if self.current_color and "color" not in config: - config["_current_color"] = self.current_color + if "color" not in config: + color = self._get_default_plugin_color() + if color: + config["_current_color"] = color config["_helpers"] = self._helpers config["_upscale_mode"] = self.upscale_mode @@ -325,6 +327,20 @@ def get_plugin_config_with_helpers(self, plugin_name: str) -> dict: ) return config + def _get_default_plugin_color(self) -> Optional[Tuple[int, int, int]]: + """Get the current color from the default plugin (clock). + + Reads _current_color from the default plugin instance if available. + Falls back to self.current_color (set by ZeClock). + + Returns: + RGB tuple or None if unavailable. + """ + plugin = self.get_default_plugin() + if plugin and hasattr(plugin, "_current_color"): + return plugin._current_color + return self.current_color + def _validate_config_schema(self, plugin: ClockPlugin) -> "Optional[str]": """Validate plugin config against its schema. From 58dd1b8e20871a3dade86cdeb49d7bc1ed38f8a3 Mon Sep 17 00:00:00 2001 From: Olivier Jacques Date: Sun, 26 Jul 2026 11:49:13 +0200 Subject: [PATCH 4/4] chore: remove unused current_color plumbing from ZeClock/PluginManager Color is now read directly from the default plugin instance via _get_default_plugin_color(). The intermediate current_color attribute and its update in the main loop are no longer needed. --- zeclock/clock.py | 3 --- zeclock/plugin_manager.py | 6 +----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/zeclock/clock.py b/zeclock/clock.py index dab034b..acc2247 100644 --- a/zeclock/clock.py +++ b/zeclock/clock.py @@ -284,8 +284,6 @@ async def run(self) -> None: if self.color_mode == "auto" and now - self.last_color_change >= 60: self.color = COLOR_LIST[int(now // 60) % len(COLOR_LIST)] self.last_color_change = now - if self._plugin_manager: - self._plugin_manager.current_color = self.color # Remote control: check for alert overlay (rich: border + icon) if self._command_handler and self._command_handler.has_alert: @@ -526,7 +524,6 @@ async def _init_plugin_system(self) -> None: upscale_mode=self.upscale_mode, font=self.font_name, ) - self._plugin_manager.current_color = self.color try: await self._plugin_manager.discover_and_load() diff --git a/zeclock/plugin_manager.py b/zeclock/plugin_manager.py index d50559e..668a557 100644 --- a/zeclock/plugin_manager.py +++ b/zeclock/plugin_manager.py @@ -65,9 +65,6 @@ def __init__( self.plugin_start_time: float = 0.0 self.last_good_frame: Optional[Image.Image] = None self.init_timeout: float = 10.0 # seconds, configurable for testing - # Current clock color (RGB tuple), updated by ZeClock on each color change. - # Injected into plugin configs as "color" when not explicitly set. - self.current_color: Optional[Tuple[int, int, int]] = None # Set up resources path for PluginHelpers if resources_path is None: @@ -331,7 +328,6 @@ def _get_default_plugin_color(self) -> Optional[Tuple[int, int, int]]: """Get the current color from the default plugin (clock). Reads _current_color from the default plugin instance if available. - Falls back to self.current_color (set by ZeClock). Returns: RGB tuple or None if unavailable. @@ -339,7 +335,7 @@ def _get_default_plugin_color(self) -> Optional[Tuple[int, int, int]]: plugin = self.get_default_plugin() if plugin and hasattr(plugin, "_current_color"): return plugin._current_color - return self.current_color + return None def _validate_config_schema(self, plugin: ClockPlugin) -> "Optional[str]": """Validate plugin config against its schema.