|
|
@@ -288,10 +288,11 @@ app = FastAPI(lifespan=lifespan)
|
|
|
|
|
|
# Add CORS middleware to allow cross-origin requests from other Dune Weaver frontends
|
|
|
# This enables multi-table control from a single frontend
|
|
|
+# Note: allow_credentials must be False when allow_origins=["*"] (browser security requirement)
|
|
|
app.add_middleware(
|
|
|
CORSMiddleware,
|
|
|
allow_origins=["*"], # Allow all origins for local network access
|
|
|
- allow_credentials=True,
|
|
|
+ allow_credentials=False,
|
|
|
allow_methods=["*"],
|
|
|
allow_headers=["*"],
|
|
|
)
|
|
|
@@ -722,6 +723,50 @@ async def get_all_settings():
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+@app.get("/api/manifest.webmanifest", tags=["settings"])
|
|
|
+async def get_dynamic_manifest():
|
|
|
+ """
|
|
|
+ Get a dynamically generated web manifest.
|
|
|
+
|
|
|
+ Returns manifest with custom icons and app name if custom branding is configured,
|
|
|
+ otherwise returns defaults.
|
|
|
+ """
|
|
|
+ # Determine icon paths based on whether custom logo exists
|
|
|
+ if state.custom_logo:
|
|
|
+ icon_base = "/static/custom"
|
|
|
+ else:
|
|
|
+ icon_base = "/static"
|
|
|
+
|
|
|
+ # Use custom app name or default
|
|
|
+ app_name = state.app_name or "Dune Weaver"
|
|
|
+
|
|
|
+ return {
|
|
|
+ "name": app_name,
|
|
|
+ "short_name": app_name,
|
|
|
+ "description": "Control your kinetic sand table",
|
|
|
+ "icons": [
|
|
|
+ {
|
|
|
+ "src": f"{icon_base}/android-chrome-192x192.png",
|
|
|
+ "sizes": "192x192",
|
|
|
+ "type": "image/png",
|
|
|
+ "purpose": "any"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "src": f"{icon_base}/android-chrome-512x512.png",
|
|
|
+ "sizes": "512x512",
|
|
|
+ "type": "image/png",
|
|
|
+ "purpose": "any"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "start_url": "/",
|
|
|
+ "scope": "/",
|
|
|
+ "display": "standalone",
|
|
|
+ "orientation": "any",
|
|
|
+ "theme_color": "#0a0a0a",
|
|
|
+ "background_color": "#0a0a0a",
|
|
|
+ "categories": ["utilities", "entertainment"]
|
|
|
+ }
|
|
|
+
|
|
|
@app.patch("/api/settings", tags=["settings"])
|
|
|
async def update_settings(settings_update: SettingsUpdate):
|
|
|
"""
|
|
|
@@ -730,7 +775,7 @@ async def update_settings(settings_update: SettingsUpdate):
|
|
|
Only include the categories and fields you want to update.
|
|
|
All fields are optional - only provided values will be updated.
|
|
|
|
|
|
- Example: {"app": {"name": "My Sand Table"}, "auto_play": {"enabled": true}}
|
|
|
+ Example: {"app": {"name": "Dune Weaver"}, "auto_play": {"enabled": true}}
|
|
|
"""
|
|
|
updated_categories = []
|
|
|
requires_restart = False
|
|
|
@@ -748,8 +793,8 @@ async def update_settings(settings_update: SettingsUpdate):
|
|
|
# Connection settings
|
|
|
if settings_update.connection:
|
|
|
if settings_update.connection.preferred_port is not None:
|
|
|
- port = settings_update.connection.preferred_port
|
|
|
- state.preferred_port = None if port in ("", "none") else port
|
|
|
+ # Store exactly what frontend sends: "__auto__", "__none__", or specific port
|
|
|
+ state.preferred_port = settings_update.connection.preferred_port
|
|
|
updated_categories.append("connection")
|
|
|
|
|
|
# Pattern settings
|
|
|
@@ -920,6 +965,17 @@ async def update_settings(settings_update: SettingsUpdate):
|
|
|
class TableInfoUpdate(BaseModel):
|
|
|
name: Optional[str] = None
|
|
|
|
|
|
+class KnownTableAdd(BaseModel):
|
|
|
+ id: str
|
|
|
+ name: str
|
|
|
+ url: str
|
|
|
+ host: Optional[str] = None
|
|
|
+ port: Optional[int] = None
|
|
|
+ version: Optional[str] = None
|
|
|
+
|
|
|
+class KnownTableUpdate(BaseModel):
|
|
|
+ name: Optional[str] = None
|
|
|
+
|
|
|
@app.get("/api/table-info", tags=["multi-table"])
|
|
|
async def get_table_info():
|
|
|
"""
|
|
|
@@ -942,7 +998,7 @@ async def update_table_info(update: TableInfoUpdate):
|
|
|
The table ID is immutable after generation.
|
|
|
"""
|
|
|
if update.name is not None:
|
|
|
- state.table_name = update.name.strip() or "My Sand Table"
|
|
|
+ state.table_name = update.name.strip() or "Dune Weaver"
|
|
|
state.save()
|
|
|
logger.info(f"Table name updated to: {state.table_name}")
|
|
|
|
|
|
@@ -952,6 +1008,83 @@ async def update_table_info(update: TableInfoUpdate):
|
|
|
"name": state.table_name
|
|
|
}
|
|
|
|
|
|
+@app.get("/api/known-tables", tags=["multi-table"])
|
|
|
+async def get_known_tables():
|
|
|
+ """
|
|
|
+ Get list of known remote tables.
|
|
|
+
|
|
|
+ These are tables that have been manually added and are persisted
|
|
|
+ for multi-table management.
|
|
|
+ """
|
|
|
+ return {"tables": state.known_tables}
|
|
|
+
|
|
|
+@app.post("/api/known-tables", tags=["multi-table"])
|
|
|
+async def add_known_table(table: KnownTableAdd):
|
|
|
+ """
|
|
|
+ Add a known remote table.
|
|
|
+
|
|
|
+ This persists the table information so it's available across
|
|
|
+ browser sessions and devices.
|
|
|
+ """
|
|
|
+ # Check if table with same ID already exists
|
|
|
+ existing_ids = [t.get("id") for t in state.known_tables]
|
|
|
+ if table.id in existing_ids:
|
|
|
+ raise HTTPException(status_code=400, detail="Table with this ID already exists")
|
|
|
+
|
|
|
+ # Check if table with same URL already exists
|
|
|
+ existing_urls = [t.get("url") for t in state.known_tables]
|
|
|
+ if table.url in existing_urls:
|
|
|
+ raise HTTPException(status_code=400, detail="Table with this URL already exists")
|
|
|
+
|
|
|
+ new_table = {
|
|
|
+ "id": table.id,
|
|
|
+ "name": table.name,
|
|
|
+ "url": table.url,
|
|
|
+ }
|
|
|
+ if table.host:
|
|
|
+ new_table["host"] = table.host
|
|
|
+ if table.port:
|
|
|
+ new_table["port"] = table.port
|
|
|
+ if table.version:
|
|
|
+ new_table["version"] = table.version
|
|
|
+
|
|
|
+ state.known_tables.append(new_table)
|
|
|
+ state.save()
|
|
|
+ logger.info(f"Added known table: {table.name} ({table.url})")
|
|
|
+
|
|
|
+ return {"success": True, "table": new_table}
|
|
|
+
|
|
|
+@app.delete("/api/known-tables/{table_id}", tags=["multi-table"])
|
|
|
+async def remove_known_table(table_id: str):
|
|
|
+ """
|
|
|
+ Remove a known remote table by ID.
|
|
|
+ """
|
|
|
+ original_count = len(state.known_tables)
|
|
|
+ state.known_tables = [t for t in state.known_tables if t.get("id") != table_id]
|
|
|
+
|
|
|
+ if len(state.known_tables) == original_count:
|
|
|
+ raise HTTPException(status_code=404, detail="Table not found")
|
|
|
+
|
|
|
+ state.save()
|
|
|
+ logger.info(f"Removed known table: {table_id}")
|
|
|
+
|
|
|
+ return {"success": True}
|
|
|
+
|
|
|
+@app.patch("/api/known-tables/{table_id}", tags=["multi-table"])
|
|
|
+async def update_known_table(table_id: str, update: KnownTableUpdate):
|
|
|
+ """
|
|
|
+ Update a known remote table's name.
|
|
|
+ """
|
|
|
+ for table in state.known_tables:
|
|
|
+ if table.get("id") == table_id:
|
|
|
+ if update.name is not None:
|
|
|
+ table["name"] = update.name.strip()
|
|
|
+ state.save()
|
|
|
+ logger.info(f"Updated known table {table_id}: name={update.name}")
|
|
|
+ return {"success": True, "table": table}
|
|
|
+
|
|
|
+ raise HTTPException(status_code=404, detail="Table not found")
|
|
|
+
|
|
|
# ============================================================================
|
|
|
# Individual Settings Endpoints (Deprecated - use /api/settings instead)
|
|
|
# ============================================================================
|
|
|
@@ -1253,29 +1386,51 @@ async def debug_serial_send(request: DebugSerialCommand):
|
|
|
await asyncio.to_thread(ser.write, command.encode())
|
|
|
await asyncio.to_thread(ser.flush)
|
|
|
|
|
|
- # Read response lines with timeout
|
|
|
+ # Read response with timeout - use read() for more reliable data capture
|
|
|
responses = []
|
|
|
start_time = time.time()
|
|
|
- original_timeout = ser.timeout
|
|
|
- ser.timeout = 0.1 # Short timeout for reading
|
|
|
+ buffer = ""
|
|
|
+
|
|
|
+ # Small delay to let response arrive
|
|
|
+ await asyncio.sleep(0.05)
|
|
|
|
|
|
while time.time() - start_time < request.timeout:
|
|
|
try:
|
|
|
- line = await asyncio.to_thread(ser.readline)
|
|
|
- if line:
|
|
|
- decoded = line.decode('utf-8', errors='replace').strip()
|
|
|
- if decoded:
|
|
|
- responses.append(decoded)
|
|
|
- # Check for ok/error to know command completed
|
|
|
- if decoded.lower() in ['ok', 'error'] or decoded.lower().startswith('error:'):
|
|
|
- break
|
|
|
+ # Read all available bytes
|
|
|
+ waiting = ser.in_waiting
|
|
|
+ if waiting > 0:
|
|
|
+ data = await asyncio.to_thread(ser.read, waiting)
|
|
|
+ if data:
|
|
|
+ buffer += data.decode('utf-8', errors='replace')
|
|
|
+
|
|
|
+ # Process complete lines from buffer
|
|
|
+ while '\n' in buffer:
|
|
|
+ line, buffer = buffer.split('\n', 1)
|
|
|
+ line = line.strip()
|
|
|
+ if line:
|
|
|
+ responses.append(line)
|
|
|
+ # Check for ok/error to know command completed
|
|
|
+ if line.lower() in ['ok', 'error'] or line.lower().startswith('error:'):
|
|
|
+ # Give a tiny bit more time for any trailing data
|
|
|
+ await asyncio.sleep(0.02)
|
|
|
+ # Read any remaining data
|
|
|
+ if ser.in_waiting > 0:
|
|
|
+ extra = await asyncio.to_thread(ser.read, ser.in_waiting)
|
|
|
+ if extra:
|
|
|
+ for extra_line in extra.decode('utf-8', errors='replace').strip().split('\n'):
|
|
|
+ if extra_line.strip():
|
|
|
+ responses.append(extra_line.strip())
|
|
|
+ break
|
|
|
else:
|
|
|
- # No data, small delay
|
|
|
- await asyncio.sleep(0.05)
|
|
|
- except:
|
|
|
+ # No data waiting, small delay
|
|
|
+ await asyncio.sleep(0.02)
|
|
|
+ except Exception as read_error:
|
|
|
+ logger.warning(f"Read error: {read_error}")
|
|
|
break
|
|
|
|
|
|
- ser.timeout = original_timeout
|
|
|
+ # Add any remaining buffer content
|
|
|
+ if buffer.strip():
|
|
|
+ responses.append(buffer.strip())
|
|
|
|
|
|
return {
|
|
|
"success": True,
|
|
|
@@ -1503,26 +1658,39 @@ async def get_theta_rho_coordinates(request: GetCoordinatesRequest):
|
|
|
# Normalize file path for cross-platform compatibility and remove prefixes
|
|
|
file_name = normalize_file_path(request.file_name)
|
|
|
file_path = os.path.join(THETA_RHO_DIR, file_name)
|
|
|
-
|
|
|
+
|
|
|
+ # Check if we can use cached coordinates (already loaded for current playback)
|
|
|
+ # This avoids re-parsing large files (2MB+) which can cause issues on Pi Zero 2W
|
|
|
+ current_file = state.current_playing_file
|
|
|
+ if current_file and state._current_coordinates:
|
|
|
+ # Normalize current file path for comparison
|
|
|
+ current_normalized = normalize_file_path(current_file)
|
|
|
+ if current_normalized == file_name:
|
|
|
+ logger.debug(f"Using cached coordinates for {file_name}")
|
|
|
+ return {
|
|
|
+ "success": True,
|
|
|
+ "coordinates": state._current_coordinates,
|
|
|
+ "total_points": len(state._current_coordinates)
|
|
|
+ }
|
|
|
+
|
|
|
# Check file existence asynchronously
|
|
|
exists = await asyncio.to_thread(os.path.exists, file_path)
|
|
|
if not exists:
|
|
|
raise HTTPException(status_code=404, detail=f"File {file_name} not found")
|
|
|
|
|
|
- # Parse the theta-rho file in a separate process for CPU-intensive work
|
|
|
- # This prevents blocking the motion control thread
|
|
|
- loop = asyncio.get_running_loop()
|
|
|
- coordinates = await loop.run_in_executor(pool_module.get_pool(), parse_theta_rho_file, file_path)
|
|
|
-
|
|
|
+ # Parse the theta-rho file in a thread (not process) to avoid memory pressure
|
|
|
+ # on resource-constrained devices like Pi Zero 2W
|
|
|
+ coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
|
|
|
+
|
|
|
if not coordinates:
|
|
|
raise HTTPException(status_code=400, detail="No valid coordinates found in file")
|
|
|
-
|
|
|
+
|
|
|
return {
|
|
|
"success": True,
|
|
|
"coordinates": coordinates,
|
|
|
"total_points": len(coordinates)
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error getting coordinates for {request.file_name}: {str(e)}")
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
@@ -1584,9 +1752,111 @@ async def stop_execution():
|
|
|
if not (state.conn.is_connected() if state.conn else False):
|
|
|
logger.warning("Attempted to stop without a connection")
|
|
|
raise HTTPException(status_code=400, detail="Connection not established")
|
|
|
- await pattern_manager.stop_actions()
|
|
|
+ success = await pattern_manager.stop_actions()
|
|
|
+ if not success:
|
|
|
+ raise HTTPException(status_code=500, detail="Stop timed out - use force_stop")
|
|
|
return {"success": True}
|
|
|
|
|
|
+@app.post("/force_stop")
|
|
|
+async def force_stop():
|
|
|
+ """Force stop all pattern execution and clear all state. Use when normal stop doesn't work."""
|
|
|
+ logger.info("Force stop requested - clearing all pattern state")
|
|
|
+
|
|
|
+ # Set stop flag first
|
|
|
+ state.stop_requested = True
|
|
|
+ state.pause_requested = False
|
|
|
+
|
|
|
+ # Clear all pattern-related state
|
|
|
+ state.current_playing_file = None
|
|
|
+ state.execution_progress = None
|
|
|
+ state.is_running = False
|
|
|
+ state.is_clearing = False
|
|
|
+ state.is_homing = False
|
|
|
+ state.current_playlist = None
|
|
|
+ state.current_playlist_index = None
|
|
|
+ state.playlist_mode = None
|
|
|
+ state.pause_time_remaining = 0
|
|
|
+
|
|
|
+ # Wake up any waiting tasks
|
|
|
+ try:
|
|
|
+ pattern_manager.get_pause_event().set()
|
|
|
+ except:
|
|
|
+ pass
|
|
|
+
|
|
|
+ # Stop motion controller and clear its queue
|
|
|
+ if pattern_manager.motion_controller.running:
|
|
|
+ pattern_manager.motion_controller.command_queue.put(
|
|
|
+ pattern_manager.MotionCommand('stop')
|
|
|
+ )
|
|
|
+
|
|
|
+ # Force release pattern lock by recreating it
|
|
|
+ pattern_manager.pattern_lock = None # Will be recreated on next use
|
|
|
+
|
|
|
+ logger.info("Force stop completed - all pattern state cleared")
|
|
|
+ return {"success": True, "message": "Force stop completed"}
|
|
|
+
|
|
|
+@app.post("/soft_reset")
|
|
|
+async def soft_reset():
|
|
|
+ """Send Ctrl+X soft reset to the controller (DLC32/ESP32). Requires re-homing after."""
|
|
|
+ if not (state.conn and state.conn.is_connected()):
|
|
|
+ logger.warning("Attempted to soft reset without a connection")
|
|
|
+ raise HTTPException(status_code=400, detail="Connection not established")
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Stop any running patterns first
|
|
|
+ await pattern_manager.stop_actions()
|
|
|
+
|
|
|
+ # Access the underlying serial object directly for more reliable reset
|
|
|
+ # This bypasses the connection abstraction which may have buffering issues
|
|
|
+ from modules.connection.connection_manager import SerialConnection
|
|
|
+ if isinstance(state.conn, SerialConnection) and state.conn.ser:
|
|
|
+ state.conn.ser.reset_input_buffer() # Clear any pending data
|
|
|
+ state.conn.ser.write(b'\x18') # Ctrl+X as bytes
|
|
|
+ state.conn.ser.flush()
|
|
|
+ logger.info(f"Soft reset command (Ctrl+X) sent directly via serial to {state.port}")
|
|
|
+ else:
|
|
|
+ # Fallback for WebSocket or other connection types
|
|
|
+ state.conn.send('\x18')
|
|
|
+ logger.info("Soft reset command (Ctrl+X) sent via connection abstraction")
|
|
|
+
|
|
|
+ # Mark as needing homing since position is now unknown
|
|
|
+ state.is_homed = False
|
|
|
+
|
|
|
+ return {"success": True, "message": "Soft reset sent. Homing required."}
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Error sending soft reset: {e}")
|
|
|
+ raise HTTPException(status_code=500, detail=str(e))
|
|
|
+
|
|
|
+@app.post("/controller_restart")
|
|
|
+async def controller_restart():
|
|
|
+ """Send $System/Control=RESTART to restart the FluidNC controller."""
|
|
|
+ if not (state.conn and state.conn.is_connected()):
|
|
|
+ logger.warning("Attempted to restart controller without a connection")
|
|
|
+ raise HTTPException(status_code=400, detail="Connection not established")
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Stop any running patterns first
|
|
|
+ await pattern_manager.stop_actions()
|
|
|
+
|
|
|
+ # Send the FluidNC restart command
|
|
|
+ from modules.connection.connection_manager import SerialConnection
|
|
|
+ restart_cmd = "$System/Control=RESTART\n"
|
|
|
+ if isinstance(state.conn, SerialConnection) and state.conn.ser:
|
|
|
+ state.conn.ser.write(restart_cmd.encode())
|
|
|
+ state.conn.ser.flush()
|
|
|
+ logger.info(f"Controller restart command sent via serial to {state.port}")
|
|
|
+ else:
|
|
|
+ state.conn.send(restart_cmd)
|
|
|
+ logger.info("Controller restart command sent via connection abstraction")
|
|
|
+
|
|
|
+ # Mark as needing homing since position is now unknown
|
|
|
+ state.is_homed = False
|
|
|
+
|
|
|
+ return {"success": True, "message": "Controller restart command sent. Homing required."}
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Error sending controller restart: {e}")
|
|
|
+ raise HTTPException(status_code=500, detail=str(e))
|
|
|
+
|
|
|
@app.post("/send_home")
|
|
|
async def send_home():
|
|
|
try:
|
|
|
@@ -1680,6 +1950,9 @@ async def move_to_center():
|
|
|
|
|
|
check_homing_in_progress()
|
|
|
|
|
|
+ # Clear stop_requested to ensure manual move works after pattern stop
|
|
|
+ state.stop_requested = False
|
|
|
+
|
|
|
logger.info("Moving device to center position")
|
|
|
await pattern_manager.reset_theta()
|
|
|
await pattern_manager.move_polar(0, 0)
|
|
|
@@ -1699,6 +1972,9 @@ async def move_to_perimeter():
|
|
|
|
|
|
check_homing_in_progress()
|
|
|
|
|
|
+ # Clear stop_requested to ensure manual move works after pattern stop
|
|
|
+ state.stop_requested = False
|
|
|
+
|
|
|
await pattern_manager.reset_theta()
|
|
|
await pattern_manager.move_polar(0, 1)
|
|
|
return {"success": True}
|
|
|
@@ -1771,6 +2047,63 @@ async def preview_thr(request: DeleteFileRequest):
|
|
|
logger.error(f"Failed to generate or serve preview for {request.file_name}: {str(e)}")
|
|
|
raise HTTPException(status_code=500, detail=f"Failed to serve preview image: {str(e)}")
|
|
|
|
|
|
+@app.get("/api/pattern_history/{pattern_name:path}")
|
|
|
+async def get_pattern_history(pattern_name: str):
|
|
|
+ """Get the most recent execution history for a pattern.
|
|
|
+
|
|
|
+ Returns the last completed execution time and speed for the given pattern.
|
|
|
+ """
|
|
|
+ from modules.core.pattern_manager import get_pattern_execution_history
|
|
|
+
|
|
|
+ # Get just the filename if a full path was provided
|
|
|
+ filename = os.path.basename(pattern_name)
|
|
|
+ if not filename.endswith('.thr'):
|
|
|
+ filename = f"{filename}.thr"
|
|
|
+
|
|
|
+ history = get_pattern_execution_history(filename)
|
|
|
+ if history:
|
|
|
+ return history
|
|
|
+ return {"actual_time_seconds": None, "actual_time_formatted": None, "speed": None, "timestamp": None}
|
|
|
+
|
|
|
+@app.get("/api/pattern_history_all")
|
|
|
+async def get_all_pattern_history():
|
|
|
+ """Get execution history for all patterns in a single request.
|
|
|
+
|
|
|
+ Returns a dict mapping pattern names to their most recent execution history.
|
|
|
+ """
|
|
|
+ from modules.core.pattern_manager import EXECUTION_LOG_FILE
|
|
|
+ import json
|
|
|
+
|
|
|
+ if not os.path.exists(EXECUTION_LOG_FILE):
|
|
|
+ return {}
|
|
|
+
|
|
|
+ try:
|
|
|
+ history_map = {}
|
|
|
+ with open(EXECUTION_LOG_FILE, 'r') as f:
|
|
|
+ for line in f:
|
|
|
+ line = line.strip()
|
|
|
+ if not line:
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ entry = json.loads(line)
|
|
|
+ # Only consider fully completed patterns
|
|
|
+ if entry.get('completed', False):
|
|
|
+ pattern_name = entry.get('pattern_name')
|
|
|
+ if pattern_name:
|
|
|
+ # Keep the most recent match (last one in file wins)
|
|
|
+ history_map[pattern_name] = {
|
|
|
+ "actual_time_seconds": entry.get('actual_time_seconds'),
|
|
|
+ "actual_time_formatted": entry.get('actual_time_formatted'),
|
|
|
+ "speed": entry.get('speed'),
|
|
|
+ "timestamp": entry.get('timestamp')
|
|
|
+ }
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ continue
|
|
|
+ return history_map
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Failed to read execution time log: {e}")
|
|
|
+ return {}
|
|
|
+
|
|
|
@app.get("/preview/{encoded_filename}")
|
|
|
async def serve_preview(encoded_filename: str):
|
|
|
"""Serve a preview image for a pattern file."""
|
|
|
@@ -1817,6 +2150,9 @@ async def send_coordinate(request: CoordinateRequest):
|
|
|
|
|
|
check_homing_in_progress()
|
|
|
|
|
|
+ # Clear stop_requested to ensure manual move works after pattern stop
|
|
|
+ state.stop_requested = False
|
|
|
+
|
|
|
try:
|
|
|
logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
|
|
|
await pattern_manager.move_polar(request.theta, request.rho)
|
|
|
@@ -1894,7 +2230,10 @@ async def get_playlist(name: str):
|
|
|
|
|
|
playlist = playlist_manager.get_playlist(name)
|
|
|
if not playlist:
|
|
|
- raise HTTPException(status_code=404, detail=f"Playlist '{name}' not found")
|
|
|
+ # Auto-create empty playlist if not found
|
|
|
+ logger.info(f"Playlist '{name}' not found, creating empty playlist")
|
|
|
+ playlist_manager.create_playlist(name, [])
|
|
|
+ playlist = {"name": name, "files": []}
|
|
|
|
|
|
return playlist
|
|
|
|
|
|
@@ -2064,8 +2403,8 @@ async def set_led_config(request: LEDConfigRequest):
|
|
|
old_gpio_pin = state.dw_led_gpio_pin
|
|
|
old_pixel_order = state.dw_led_pixel_order
|
|
|
hardware_changed = (
|
|
|
- old_gpio_pin != (request.gpio_pin or 12) or
|
|
|
- old_pixel_order != (request.pixel_order or "GRB")
|
|
|
+ old_gpio_pin != (request.gpio_pin or 18) or
|
|
|
+ old_pixel_order != (request.pixel_order or "RGB")
|
|
|
)
|
|
|
|
|
|
# Stop existing DW LED controller if hardware settings changed
|
|
|
@@ -2078,10 +2417,13 @@ async def set_led_config(request: LEDConfigRequest):
|
|
|
logger.info("LED controller stopped successfully")
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error stopping LED controller: {e}")
|
|
|
+ # Clear the reference and give hardware time to release
|
|
|
+ state.led_controller = None
|
|
|
+ await asyncio.sleep(0.5)
|
|
|
|
|
|
state.dw_led_num_leds = request.num_leds or 60
|
|
|
- state.dw_led_gpio_pin = request.gpio_pin or 12
|
|
|
- state.dw_led_pixel_order = request.pixel_order or "GRB"
|
|
|
+ state.dw_led_gpio_pin = request.gpio_pin or 18
|
|
|
+ state.dw_led_pixel_order = request.pixel_order or "RGB"
|
|
|
state.dw_led_brightness = request.brightness or 35
|
|
|
state.wled_ip = None
|
|
|
|
|
|
@@ -2221,6 +2563,43 @@ async def reorder_playlist(request: dict):
|
|
|
|
|
|
return {"success": True}
|
|
|
|
|
|
+@app.post("/add_to_queue")
|
|
|
+async def add_to_queue(request: dict):
|
|
|
+ """Add a pattern to the current playlist queue.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ pattern: The pattern file path to add (e.g., 'circle.thr' or 'subdirectory/pattern.thr')
|
|
|
+ position: 'next' to play after current pattern, 'end' to add to end of queue
|
|
|
+ """
|
|
|
+ if not state.current_playlist:
|
|
|
+ raise HTTPException(status_code=400, detail="No playlist is currently running")
|
|
|
+
|
|
|
+ pattern = request.get("pattern")
|
|
|
+ position = request.get("position", "end") # 'next' or 'end'
|
|
|
+
|
|
|
+ if not pattern:
|
|
|
+ raise HTTPException(status_code=400, detail="pattern is required")
|
|
|
+
|
|
|
+ # Verify the pattern file exists
|
|
|
+ pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, pattern)
|
|
|
+ if not os.path.exists(pattern_path):
|
|
|
+ raise HTTPException(status_code=404, detail="Pattern file not found")
|
|
|
+
|
|
|
+ playlist = list(state.current_playlist)
|
|
|
+ current_index = state.current_playlist_index
|
|
|
+
|
|
|
+ if position == "next":
|
|
|
+ # Insert right after the current pattern
|
|
|
+ insert_index = current_index + 1
|
|
|
+ else:
|
|
|
+ # Add to end
|
|
|
+ insert_index = len(playlist)
|
|
|
+
|
|
|
+ playlist.insert(insert_index, pattern)
|
|
|
+ state.current_playlist = playlist
|
|
|
+
|
|
|
+ return {"success": True, "position": insert_index}
|
|
|
+
|
|
|
@app.get("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
|
|
|
async def get_custom_clear_patterns():
|
|
|
"""Get the currently configured custom clear patterns."""
|
|
|
@@ -2327,26 +2706,26 @@ CUSTOM_BRANDING_DIR = os.path.join("static", "custom")
|
|
|
ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
|
|
|
MAX_LOGO_SIZE = 5 * 1024 * 1024 # 5MB
|
|
|
|
|
|
-def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
|
|
|
- """Generate a circular-cropped favicon from the uploaded logo using PIL.
|
|
|
+def generate_favicon_from_logo(logo_path: str, output_dir: str) -> bool:
|
|
|
+ """Generate circular favicons with transparent background from the uploaded logo.
|
|
|
+
|
|
|
+ Creates:
|
|
|
+ - favicon.ico (multi-size: 256, 128, 64, 48, 32, 16)
|
|
|
+ - favicon-16x16.png, favicon-32x32.png, favicon-96x96.png, favicon-128x128.png
|
|
|
|
|
|
- Creates a multi-size ICO file (16x16, 32x32, 48x48) with circular crop.
|
|
|
Returns True on success, False on failure.
|
|
|
"""
|
|
|
try:
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
|
|
- def create_circular_image(img, size):
|
|
|
- """Create a circular-cropped image at the specified size."""
|
|
|
- # Resize to target size
|
|
|
+ def create_circular_transparent(img, size):
|
|
|
+ """Create circular image with transparent background."""
|
|
|
resized = img.resize((size, size), Image.Resampling.LANCZOS)
|
|
|
|
|
|
- # Create circular mask
|
|
|
mask = Image.new('L', (size, size), 0)
|
|
|
draw = ImageDraw.Draw(mask)
|
|
|
draw.ellipse((0, 0, size - 1, size - 1), fill=255)
|
|
|
|
|
|
- # Apply circular mask - create transparent background
|
|
|
output = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
|
|
output.paste(resized, (0, 0), mask)
|
|
|
return output
|
|
|
@@ -2363,16 +2742,25 @@ def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
|
|
|
top = (height - min_dim) // 2
|
|
|
img = img.crop((left, top, left + min_dim, top + min_dim))
|
|
|
|
|
|
- # Create circular images at each favicon size
|
|
|
- sizes = [48, 32, 16]
|
|
|
- circular_images = [create_circular_image(img, size) for size in sizes]
|
|
|
-
|
|
|
- # Save as ICO - first image is the main one, rest are appended
|
|
|
- circular_images[0].save(
|
|
|
- favicon_path,
|
|
|
+ # Generate circular favicon PNGs with transparent background
|
|
|
+ png_sizes = {
|
|
|
+ "favicon-16x16.png": 16,
|
|
|
+ "favicon-32x32.png": 32,
|
|
|
+ "favicon-96x96.png": 96,
|
|
|
+ "favicon-128x128.png": 128,
|
|
|
+ }
|
|
|
+ for filename, size in png_sizes.items():
|
|
|
+ icon = create_circular_transparent(img, size)
|
|
|
+ icon.save(os.path.join(output_dir, filename), format='PNG')
|
|
|
+
|
|
|
+ # Generate high-resolution favicon.ico
|
|
|
+ ico_sizes = [256, 128, 64, 48, 32, 16]
|
|
|
+ ico_images = [create_circular_transparent(img, s) for s in ico_sizes]
|
|
|
+ ico_images[0].save(
|
|
|
+ os.path.join(output_dir, "favicon.ico"),
|
|
|
format='ICO',
|
|
|
- append_images=circular_images[1:],
|
|
|
- sizes=[(s, s) for s in sizes]
|
|
|
+ append_images=ico_images[1:],
|
|
|
+ sizes=[(s, s) for s in ico_sizes]
|
|
|
)
|
|
|
|
|
|
return True
|
|
|
@@ -2380,6 +2768,51 @@ def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
|
|
|
logger.error(f"Failed to generate favicon: {str(e)}")
|
|
|
return False
|
|
|
|
|
|
+def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
|
|
|
+ """Generate square PWA app icons from the uploaded logo.
|
|
|
+
|
|
|
+ Creates square icons (no circular crop) - OS will apply its own mask.
|
|
|
+
|
|
|
+ Generates:
|
|
|
+ - apple-touch-icon.png (180x180)
|
|
|
+ - android-chrome-192x192.png (192x192)
|
|
|
+ - android-chrome-512x512.png (512x512)
|
|
|
+
|
|
|
+ Returns True on success, False on failure.
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ from PIL import Image
|
|
|
+
|
|
|
+ with Image.open(logo_path) as img:
|
|
|
+ # Convert to RGBA if needed
|
|
|
+ if img.mode != 'RGBA':
|
|
|
+ img = img.convert('RGBA')
|
|
|
+
|
|
|
+ # Crop to square (center crop)
|
|
|
+ width, height = img.size
|
|
|
+ min_dim = min(width, height)
|
|
|
+ left = (width - min_dim) // 2
|
|
|
+ top = (height - min_dim) // 2
|
|
|
+ img = img.crop((left, top, left + min_dim, top + min_dim))
|
|
|
+
|
|
|
+ # Generate square icons at each required size
|
|
|
+ icon_sizes = {
|
|
|
+ "apple-touch-icon.png": 180,
|
|
|
+ "android-chrome-192x192.png": 192,
|
|
|
+ "android-chrome-512x512.png": 512,
|
|
|
+ }
|
|
|
+
|
|
|
+ for filename, size in icon_sizes.items():
|
|
|
+ resized = img.resize((size, size), Image.Resampling.LANCZOS)
|
|
|
+ icon_path = os.path.join(output_dir, filename)
|
|
|
+ resized.save(icon_path, format='PNG')
|
|
|
+ logger.info(f"Generated PWA icon: {filename}")
|
|
|
+
|
|
|
+ return True
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Failed to generate PWA icons: {str(e)}")
|
|
|
+ return False
|
|
|
+
|
|
|
@app.post("/api/upload-logo", tags=["settings"])
|
|
|
async def upload_logo(file: UploadFile = File(...)):
|
|
|
"""Upload a custom logo image.
|
|
|
@@ -2429,22 +2862,24 @@ async def upload_logo(file: UploadFile = File(...)):
|
|
|
with open(file_path, "wb") as f:
|
|
|
f.write(content)
|
|
|
|
|
|
- # Generate favicon from logo (for non-SVG files)
|
|
|
+ # Generate favicon and PWA icons from logo (for non-SVG files)
|
|
|
favicon_generated = False
|
|
|
+ pwa_icons_generated = False
|
|
|
if file_ext != ".svg":
|
|
|
- favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
|
|
|
- favicon_generated = generate_favicon_from_logo(file_path, favicon_path)
|
|
|
+ favicon_generated = generate_favicon_from_logo(file_path, CUSTOM_BRANDING_DIR)
|
|
|
+ pwa_icons_generated = generate_pwa_icons_from_logo(file_path, CUSTOM_BRANDING_DIR)
|
|
|
|
|
|
# Update state
|
|
|
state.custom_logo = filename
|
|
|
state.save()
|
|
|
|
|
|
- logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}")
|
|
|
+ logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}, PWA icons generated: {pwa_icons_generated}")
|
|
|
return {
|
|
|
"success": True,
|
|
|
"filename": filename,
|
|
|
"url": f"/static/custom/{filename}",
|
|
|
- "favicon_generated": favicon_generated
|
|
|
+ "favicon_generated": favicon_generated,
|
|
|
+ "pwa_icons_generated": pwa_icons_generated
|
|
|
}
|
|
|
|
|
|
except HTTPException:
|
|
|
@@ -2455,7 +2890,7 @@ async def upload_logo(file: UploadFile = File(...)):
|
|
|
|
|
|
@app.delete("/api/custom-logo", tags=["settings"])
|
|
|
async def delete_custom_logo():
|
|
|
- """Remove custom logo and favicon, reverting to defaults."""
|
|
|
+ """Remove custom logo, favicon, and PWA icons, reverting to defaults."""
|
|
|
try:
|
|
|
if state.custom_logo:
|
|
|
# Remove logo
|
|
|
@@ -2463,14 +2898,33 @@ async def delete_custom_logo():
|
|
|
if os.path.exists(logo_path):
|
|
|
os.remove(logo_path)
|
|
|
|
|
|
- # Remove generated favicon
|
|
|
- favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
|
|
|
- if os.path.exists(favicon_path):
|
|
|
- os.remove(favicon_path)
|
|
|
+ # Remove generated favicons
|
|
|
+ favicon_files = [
|
|
|
+ "favicon.ico",
|
|
|
+ "favicon-16x16.png",
|
|
|
+ "favicon-32x32.png",
|
|
|
+ "favicon-96x96.png",
|
|
|
+ "favicon-128x128.png",
|
|
|
+ ]
|
|
|
+ for favicon_name in favicon_files:
|
|
|
+ favicon_path = os.path.join(CUSTOM_BRANDING_DIR, favicon_name)
|
|
|
+ if os.path.exists(favicon_path):
|
|
|
+ os.remove(favicon_path)
|
|
|
+
|
|
|
+ # Remove generated PWA icons
|
|
|
+ pwa_icons = [
|
|
|
+ "apple-touch-icon.png",
|
|
|
+ "android-chrome-192x192.png",
|
|
|
+ "android-chrome-512x512.png",
|
|
|
+ ]
|
|
|
+ for icon_name in pwa_icons:
|
|
|
+ icon_path = os.path.join(CUSTOM_BRANDING_DIR, icon_name)
|
|
|
+ if os.path.exists(icon_path):
|
|
|
+ os.remove(icon_path)
|
|
|
|
|
|
state.custom_logo = None
|
|
|
state.save()
|
|
|
- logger.info("Custom logo and favicon removed")
|
|
|
+ logger.info("Custom logo, favicon, and PWA icons removed")
|
|
|
return {"success": True}
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error removing logo: {str(e)}")
|
|
|
@@ -2647,6 +3101,15 @@ async def preview_thr_batch(request: dict):
|
|
|
|
|
|
async def process_single_file(file_name):
|
|
|
"""Process a single file and return its preview data."""
|
|
|
+ # Check in-memory cache first (for current and next playing patterns)
|
|
|
+ normalized_for_cache = normalize_file_path(file_name)
|
|
|
+ if state._current_preview and state._current_preview[0] == normalized_for_cache:
|
|
|
+ logger.debug(f"Using cached preview for current: {file_name}")
|
|
|
+ return file_name, state._current_preview[1]
|
|
|
+ if state._next_preview and state._next_preview[0] == normalized_for_cache:
|
|
|
+ logger.debug(f"Using cached preview for next: {file_name}")
|
|
|
+ return file_name, state._next_preview[1]
|
|
|
+
|
|
|
# Acquire semaphore to limit concurrent processing
|
|
|
async with get_preview_semaphore():
|
|
|
t1 = time.time()
|
|
|
@@ -2679,9 +3142,8 @@ async def preview_thr_batch(request: dict):
|
|
|
last_coord_obj = metadata.get('last_coordinate')
|
|
|
else:
|
|
|
logger.debug(f"Metadata cache miss for {file_name}, parsing file")
|
|
|
- # Use process pool for CPU-intensive parsing
|
|
|
- loop = asyncio.get_running_loop()
|
|
|
- coordinates = await loop.run_in_executor(pool_module.get_pool(), parse_theta_rho_file, pattern_file_path)
|
|
|
+ # Use thread pool to avoid memory pressure on resource-constrained devices
|
|
|
+ coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_file_path)
|
|
|
first_coord = coordinates[0] if coordinates else None
|
|
|
last_coord = coordinates[-1] if coordinates else None
|
|
|
first_coord_obj = {"x": first_coord[0], "y": first_coord[1]} if first_coord else None
|
|
|
@@ -2695,6 +3157,24 @@ async def preview_thr_batch(request: dict):
|
|
|
"first_coordinate": first_coord_obj,
|
|
|
"last_coordinate": last_coord_obj
|
|
|
}
|
|
|
+
|
|
|
+ # Cache preview for current/next pattern to speed up subsequent requests
|
|
|
+ current_file = state.current_playing_file
|
|
|
+ if current_file:
|
|
|
+ current_normalized = normalize_file_path(current_file)
|
|
|
+ if normalized_file_name == current_normalized:
|
|
|
+ state._current_preview = (normalized_file_name, result)
|
|
|
+ logger.debug(f"Cached preview for current: {file_name}")
|
|
|
+ elif state.current_playlist:
|
|
|
+ # Check if this is the next pattern in playlist
|
|
|
+ playlist = state.current_playlist
|
|
|
+ idx = state.current_playlist_index
|
|
|
+ if idx is not None and idx + 1 < len(playlist):
|
|
|
+ next_file = normalize_file_path(playlist[idx + 1])
|
|
|
+ if normalized_file_name == next_file:
|
|
|
+ state._next_preview = (normalized_file_name, result)
|
|
|
+ logger.debug(f"Cached preview for next: {file_name}")
|
|
|
+
|
|
|
logger.debug(f"Processed {file_name} in {time.time() - t1:.2f}s")
|
|
|
return file_name, result
|
|
|
except Exception as e:
|