Kaynağa Gözat

Add event-driven stop/skip for instant interrupt response

Previously, stop and skip requests during scheduled pauses were ignored
or delayed up to 1 second due to polling-based checks. This caused the
UI to appear frozen when users tried to skip or stop during Still Sands
periods.

Changes:
- Add asyncio.Event backing to stop_requested/skip_requested flags
- Add wait_for_interrupt() method for instant event-driven waiting
- Fix in-pattern pause loop to respond to stop/skip immediately
- Fix post-pattern scheduled pause to respond to skip (not just stop)
- Increase DEFAULT_WORKERS from 1 to 3 for better parallelism
- Remove deprecated .cursorrules file (replaced by .claude/CLAUDE.md)

The property setters automatically sync flags and events, so existing
code using state.stop_requested = True works unchanged while async
code now gets instant wake-up via events.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 2 hafta önce
ebeveyn
işleme
72ac7669e2

+ 0 - 86
.cursorrules

@@ -1,86 +0,0 @@
-You are an expert in Python, FastAPI, and scalable API development.
-
-Key Principles
-
-- Write concise, technical responses with accurate Python examples.
-- Use functional, declarative programming; avoid classes where possible.
-- Prefer iteration and modularization over code duplication.
-- Use descriptive variable names with auxiliary verbs (e.g., is_active, has_permission).
-- Use lowercase with underscores for directories and files (e.g., routers/user_routes.py).
-- Favor named exports for routes and utility functions.
-- Use the Receive an Object, Return an Object (RORO) pattern.
-
-Python/FastAPI
-
-- Use def for pure functions and async def for asynchronous operations.
-- Use type hints for all function signatures. Prefer Pydantic models over raw dictionaries for input validation.
-- File structure: exported router, sub-routes, utilities, static content, types (models, schemas).
-- Avoid unnecessary curly braces in conditional statements.
-- For single-line statements in conditionals, omit curly braces.
-- Use concise, one-line syntax for simple conditional statements (e.g., if condition: do_something()).
-
-Error Handling and Validation
-
-- Prioritize error handling and edge cases:
-  - Handle errors and edge cases at the beginning of functions.
-  - Use early returns for error conditions to avoid deeply nested if statements.
-  - Place the happy path last in the function for improved readability.
-  - Avoid unnecessary else statements; use the if-return pattern instead.
-  - Use guard clauses to handle preconditions and invalid states early.
-  - Implement proper error logging and user-friendly error messages.
-  - Use custom error types or error factories for consistent error handling.
-
-Dependencies
-
-- FastAPI
-- Pydantic v2
-- Async database libraries like asyncpg or aiomysql
-
-FastAPI-Specific Guidelines
-
-- Use functional components (plain functions) and Pydantic models for input validation and response schemas.
-- Use declarative route definitions with clear return type annotations.
-- Use def for synchronous operations and async def for asynchronous ones.
-- Minimize @app.on_event("startup") and @app.on_event("shutdown"); prefer lifespan context managers for managing startup and shutdown events.
-- Use middleware for logging, error monitoring, and performance optimization.
-- Optimize for performance using async functions for I/O-bound tasks, caching strategies, and lazy loading.
-- Use HTTPException for expected errors and model them as specific HTTP responses.
-- Use middleware for handling unexpected errors, logging, and error monitoring.
-- Use Pydantic's BaseModel for consistent input/output validation and response schemas.
-
-Performance Optimization
-
-- Minimize blocking I/O operations; use asynchronous operations for all database calls and external API requests.
-- Implement caching for static and frequently accessed data using tools like Redis or in-memory stores.
-- Optimize data serialization and deserialization with Pydantic.
-- Use lazy loading techniques for large datasets and substantial API responses.
-
-Key Conventions
-
-1. Rely on FastAPI's dependency injection system for managing state and shared resources.
-2. Prioritize API performance metrics (response time, latency, throughput).
-3. Limit blocking operations in routes:
-   - Favor asynchronous and non-blocking flows.
-   - Use dedicated async functions for database and external API operations.
-   - Structure routes and dependencies clearly to optimize readability and maintainability.
-
-Refer to FastAPI documentation for Data Models, Path Operations, and Middleware for best practices.
-
-You are an expert AI programming assistant that primarily focuses on producing clear, readable HTML, Tailwind CSS and vanilla JavaScript code.
-
-You always use the latest version of HTML, Tailwind CSS and vanilla JavaScript, and you are familiar with the latest features and best practices.
-
-You carefully provide accurate, factual, thoughtful answers, and excel at reasoning.
-
-- Follow the user's requirements carefully & to the letter.
-- Confirm, then write code!
-- Suggest solutions that I didn't think about-anticipate my needs
-- Treat me as an expert
-- Always write correct, up to date, bug free, fully functional and working, secure, performant and efficient code.
-- Focus on readability over being performant.
-- Fully implement all requested functionality.
-- Leave NO todo's, placeholders or missing pieces.
-- Be concise. Minimize any other prose.
-- Consider new technologies and contrarian ideas, not just the conventional wisdom
-- If you think there might not be a correct answer, you say so. If you do not know the answer, say so instead of guessing.
-- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make. 

+ 102 - 9
modules/core/pattern_manager.py

@@ -16,7 +16,7 @@ from modules.led.led_controller import effect_playing, effect_idle
 from modules.led.idle_timeout_manager import idle_timeout_manager
 from modules.led.idle_timeout_manager import idle_timeout_manager
 import queue
 import queue
 from dataclasses import dataclass
 from dataclasses import dataclass
-from typing import Optional, Callable
+from typing import Optional, Callable, Literal
 
 
 # Configure logging
 # Configure logging
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -28,6 +28,54 @@ os.makedirs(THETA_RHO_DIR, exist_ok=True)
 # Execution time log file (JSON Lines format - one JSON object per line)
 # Execution time log file (JSON Lines format - one JSON object per line)
 EXECUTION_LOG_FILE = './execution_times.jsonl'
 EXECUTION_LOG_FILE = './execution_times.jsonl'
 
 
+
+async def wait_with_interrupt(
+    condition_fn: Callable[[], bool],
+    check_stop: bool = True,
+    check_skip: bool = True,
+    interval: float = 1.0,
+) -> Literal['completed', 'stopped', 'skipped']:
+    """
+    Wait while condition_fn() returns True, with instant interrupt support.
+
+    Uses asyncio.Event for instant response to stop/skip requests rather than
+    polling at fixed intervals. This ensures users get immediate feedback when
+    pressing stop or skip buttons.
+
+    Args:
+        condition_fn: Function that returns True while waiting should continue
+        check_stop: Whether to respond to stop requests (default True)
+        check_skip: Whether to respond to skip requests (default True)
+        interval: How often to re-check condition_fn in seconds (default 1.0)
+
+    Returns:
+        'completed' - condition_fn() returned False (normal completion)
+        'stopped' - stop was requested
+        'skipped' - skip was requested
+
+    Example:
+        result = await wait_with_interrupt(
+            lambda: state.pause_requested or is_in_scheduled_pause_period()
+        )
+        if result == 'stopped':
+            return  # Exit pattern execution
+        if result == 'skipped':
+            break  # Skip to next pattern
+    """
+    while condition_fn():
+        result = await state.wait_for_interrupt(
+            timeout=interval,
+            check_stop=check_stop,
+            check_skip=check_skip,
+        )
+        if result == 'stopped':
+            return 'stopped'
+        if result == 'skipped':
+            return 'skipped'
+        # 'timeout' means we should re-check condition_fn
+    return 'completed'
+
+
 def log_execution_time(pattern_name: str, table_type: str, speed: int, actual_time: float,
 def log_execution_time(pattern_name: str, table_type: str, speed: int, actual_time: float,
                        total_coordinates: int, was_completed: bool):
                        total_coordinates: int, was_completed: bool):
     """Log pattern execution time to JSON Lines file for analysis.
     """Log pattern execution time to JSON Lines file for analysis.
@@ -904,16 +952,57 @@ async def _execute_pattern_internal(file_path):
                 wled_was_off_for_scheduled = scheduled_pause and state.scheduled_pause_control_wled and not manual_pause
                 wled_was_off_for_scheduled = scheduled_pause and state.scheduled_pause_control_wled and not manual_pause
 
 
                 # Wait until both manual pause is released AND we're outside scheduled pause period
                 # Wait until both manual pause is released AND we're outside scheduled pause period
+                # Also check for stop/skip requests to allow immediate interruption
+                interrupted = False
                 while state.pause_requested or is_in_scheduled_pause_period():
                 while state.pause_requested or is_in_scheduled_pause_period():
+                    # Check for stop/skip first
+                    if state.stop_requested:
+                        logger.info("Stop requested during pause, exiting")
+                        interrupted = True
+                        break
+                    if state.skip_requested:
+                        logger.info("Skip requested during pause, skipping pattern")
+                        interrupted = True
+                        break
+
                     if state.pause_requested:
                     if state.pause_requested:
-                        # For manual pause, wait directly on the event for immediate response
-                        # The while loop re-checks state after wake to handle rapid pause/resume
-                        await get_pause_event().wait()
+                        # For manual pause, wait on multiple events for immediate response
+                        # Wake on: resume, stop, or skip
+                        pause_event = get_pause_event()
+                        stop_event = state.get_stop_event()
+                        skip_event = state.get_skip_event()
+
+                        wait_tasks = [asyncio.create_task(pause_event.wait(), name='pause')]
+                        if stop_event:
+                            wait_tasks.append(asyncio.create_task(stop_event.wait(), name='stop'))
+                        if skip_event:
+                            wait_tasks.append(asyncio.create_task(skip_event.wait(), name='skip'))
+
+                        try:
+                            done, pending = await asyncio.wait(
+                                wait_tasks, return_when=asyncio.FIRST_COMPLETED
+                            )
+                        finally:
+                            for task in pending:
+                                task.cancel()
+                            for task in pending:
+                                try:
+                                    await task
+                                except asyncio.CancelledError:
+                                    pass
                     else:
                     else:
-                        # For scheduled pause only, check periodically
-                        await asyncio.sleep(1)
+                        # For scheduled pause, use wait_for_interrupt for instant response
+                        result = await state.wait_for_interrupt(timeout=1.0)
+                        if result in ('stopped', 'skipped'):
+                            interrupted = True
+                            break
 
 
                 total_pause_time += time.time() - pause_start  # Add pause duration
                 total_pause_time += time.time() - pause_start  # Add pause duration
+
+                if interrupted:
+                    # Exit the coordinate loop if we were interrupted
+                    break
+
                 logger.info("Execution resumed...")
                 logger.info("Execution resumed...")
                 if state.led_controller:
                 if state.led_controller:
                     # Turn LED controller back on if it was turned off for scheduled pause
                     # Turn LED controller back on if it was turned off for scheduled pause
@@ -1124,10 +1213,14 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                         await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
                         start_idle_led_timeout()
 
 
-                    while is_in_scheduled_pause_period() and not state.stop_requested:
-                        await asyncio.sleep(1)
+                    # Wait for scheduled pause to end, but allow stop/skip to interrupt
+                    result = await wait_with_interrupt(
+                        is_in_scheduled_pause_period,
+                        check_stop=True,
+                        check_skip=True,
+                    )
 
 
-                    if not state.stop_requested:
+                    if result == 'completed':
                         logger.info("Still Sands period ended. Resuming playlist...")
                         logger.info("Still Sands period ended. Resuming playlist...")
                         if state.led_controller:
                         if state.led_controller:
                             if wled_was_off_for_scheduled:
                             if wled_was_off_for_scheduled:

+ 5 - 7
modules/core/process_pool.py

@@ -6,7 +6,7 @@ Provides a single ProcessPoolExecutor shared across modules to:
 - Configure CPU affinity to keep workers off CPU 0 (reserved for motion/LED)
 - Configure CPU affinity to keep workers off CPU 0 (reserved for motion/LED)
 
 
 Environment variables:
 Environment variables:
-- POOL_WORKERS: Override worker count (default: 1 for RAM conservation)
+- POOL_WORKERS: Override worker count (default: 3)
 """
 """
 import logging
 import logging
 import os
 import os
@@ -19,17 +19,15 @@ logger = logging.getLogger(__name__)
 _pool: Optional[ProcessPoolExecutor] = None
 _pool: Optional[ProcessPoolExecutor] = None
 _shutdown_in_progress: bool = False
 _shutdown_in_progress: bool = False
 
 
-# Default to 1 worker to conserve RAM on low-memory devices (Pi Zero 2 W has only 512MB)
-DEFAULT_WORKERS = 1
+# Default to 3 workers for parallel processing
+DEFAULT_WORKERS = 3
 
 
 
 
 def _get_worker_count() -> int:
 def _get_worker_count() -> int:
     """Calculate worker count for the process pool.
     """Calculate worker count for the process pool.
 
 
-    Uses POOL_WORKERS env var if set, otherwise defaults to 1 worker
-    to conserve RAM on memory-constrained devices like Pi Zero 2 W.
-
-    For systems with more RAM, set POOL_WORKERS=2 or POOL_WORKERS=3.
+    Uses POOL_WORKERS env var if set, otherwise defaults to 3 workers.
+    For memory-constrained devices (Pi Zero 2 W), set POOL_WORKERS=1.
     """
     """
     env_workers = os.environ.get('POOL_WORKERS')
     env_workers = os.environ.get('POOL_WORKERS')
     if env_workers is not None:
     if env_workers is not None:

+ 134 - 2
modules/core/state.py

@@ -1,9 +1,11 @@
 # state.py
 # state.py
+import asyncio
 import threading
 import threading
 import json
 import json
 import os
 import os
 import logging
 import logging
 import uuid
 import uuid
+from typing import Optional, Literal
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -23,8 +25,14 @@ class AppState:
         self._current_playlist = None
         self._current_playlist = None
         self._current_playlist_name = None  # New variable for playlist name
         self._current_playlist_name = None  # New variable for playlist name
         
         
+        # Execution control flags (with event support for async waiting)
+        self._stop_requested = False
+        self._skip_requested = False
+        self._stop_event: Optional[asyncio.Event] = None
+        self._skip_event: Optional[asyncio.Event] = None
+        self._event_loop: Optional[asyncio.AbstractEventLoop] = None
+
         # Regular state variables
         # Regular state variables
-        self.stop_requested = False
         self.pause_condition = threading.Condition()
         self.pause_condition = threading.Condition()
         self.execution_progress = None
         self.execution_progress = None
         self.is_clearing = False
         self.is_clearing = False
@@ -93,7 +101,6 @@ class AppState:
         self.dw_led_idle_timeout_enabled = False  # Enable automatic LED turn off after idle period
         self.dw_led_idle_timeout_enabled = False  # Enable automatic LED turn off after idle period
         self.dw_led_idle_timeout_minutes = 30  # Idle timeout duration in minutes
         self.dw_led_idle_timeout_minutes = 30  # Idle timeout duration in minutes
         self.dw_led_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
         self.dw_led_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
-        self.skip_requested = False
         self.table_type = None
         self.table_type = None
         self.table_type_override = None  # User override for table type detection
         self.table_type_override = None  # User override for table type detection
         self._playlist_mode = "loop"
         self._playlist_mode = "loop"
@@ -249,6 +256,131 @@ class AppState:
     def clear_pattern_speed(self, value):
     def clear_pattern_speed(self, value):
         self._clear_pattern_speed = value
         self._clear_pattern_speed = value
 
 
+    # --- Execution Control Properties (stop/skip with event support) ---
+
+    def _ensure_events(self):
+        """Lazily create asyncio.Event objects in the current event loop."""
+        try:
+            loop = asyncio.get_running_loop()
+        except RuntimeError:
+            # No running loop - skip event creation (sync code path)
+            return
+
+        # Recreate events if the event loop changed
+        if self._event_loop != loop:
+            self._event_loop = loop
+            self._stop_event = asyncio.Event()
+            self._skip_event = asyncio.Event()
+            # Sync event state with current flags
+            if self._stop_requested:
+                self._stop_event.set()
+            if self._skip_requested:
+                self._skip_event.set()
+
+    @property
+    def stop_requested(self) -> bool:
+        return self._stop_requested
+
+    @stop_requested.setter
+    def stop_requested(self, value: bool):
+        self._stop_requested = value
+        self._ensure_events()
+        if self._stop_event:
+            if value:
+                self._stop_event.set()
+            else:
+                self._stop_event.clear()
+
+    @property
+    def skip_requested(self) -> bool:
+        return self._skip_requested
+
+    @skip_requested.setter
+    def skip_requested(self, value: bool):
+        self._skip_requested = value
+        self._ensure_events()
+        if self._skip_event:
+            if value:
+                self._skip_event.set()
+            else:
+                self._skip_event.clear()
+
+    def get_stop_event(self) -> Optional[asyncio.Event]:
+        """Get the stop event for async waiting. Returns None if no event loop."""
+        self._ensure_events()
+        return self._stop_event
+
+    def get_skip_event(self) -> Optional[asyncio.Event]:
+        """Get the skip event for async waiting. Returns None if no event loop."""
+        self._ensure_events()
+        return self._skip_event
+
+    async def wait_for_interrupt(
+        self,
+        timeout: float = 1.0,
+        check_stop: bool = True,
+        check_skip: bool = True,
+    ) -> Literal['timeout', 'stopped', 'skipped']:
+        """
+        Wait for a stop/skip interrupt or timeout.
+
+        This provides instant response to stop/skip requests by waiting on
+        asyncio.Event objects rather than polling flags.
+
+        Args:
+            timeout: Maximum time to wait in seconds
+            check_stop: Whether to check for stop requests
+            check_skip: Whether to check for skip requests
+
+        Returns:
+            'stopped' if stop was requested
+            'skipped' if skip was requested
+            'timeout' if timeout elapsed without interrupt
+        """
+        # Quick flag check first (handles edge cases and sync code)
+        if check_stop and self._stop_requested:
+            return 'stopped'
+        if check_skip and self._skip_requested:
+            return 'skipped'
+
+        self._ensure_events()
+
+        # Build list of event wait tasks
+        tasks = []
+        if check_stop and self._stop_event:
+            tasks.append(asyncio.create_task(self._stop_event.wait(), name='stop'))
+        if check_skip and self._skip_event:
+            tasks.append(asyncio.create_task(self._skip_event.wait(), name='skip'))
+
+        if not tasks:
+            # No events available, fall back to simple sleep
+            await asyncio.sleep(timeout)
+            return 'timeout'
+
+        # Add timeout task
+        timeout_task = asyncio.create_task(asyncio.sleep(timeout), name='timeout')
+        tasks.append(timeout_task)
+
+        try:
+            done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
+        finally:
+            # Cancel all pending tasks
+            for task in pending:
+                task.cancel()
+            # Await cancelled tasks to suppress warnings
+            for task in pending:
+                try:
+                    await task
+                except asyncio.CancelledError:
+                    pass
+
+        # Check which event fired (flags are authoritative)
+        if check_stop and self._stop_requested:
+            return 'stopped'
+        if check_skip and self._skip_requested:
+            return 'skipped'
+        return 'timeout'
+
     def to_dict(self):
     def to_dict(self):
         """Return a dictionary representation of the state."""
         """Return a dictionary representation of the state."""
         return {
         return {