mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-16 09:38:38 +00:00
Compare commits
4 Commits
098a738891
...
update-rgb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c53e4995c4 | ||
|
|
a0f19d8972 | ||
|
|
4f126d6133 | ||
|
|
5dde1125e9 |
16
README.md
16
README.md
@@ -1,10 +1,5 @@
|
|||||||
# LEDMatrix
|
# LEDMatrix
|
||||||
[](LICENSE)
|
|
||||||
[](https://discord.gg/RdrC37rEag)
|
|
||||||
[](https://github.com/ChuckBuilds/ledmatrix)
|
|
||||||
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||||
|
|
||||||
|
|
||||||
## Welcome to LEDMatrix!
|
## Welcome to LEDMatrix!
|
||||||
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
||||||
|
|
||||||
@@ -132,15 +127,10 @@ The system supports live, recent, and upcoming game information for multiple spo
|
|||||||
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
||||||
|
|
||||||
### Raspberry Pi
|
### Raspberry Pi
|
||||||
- Raspberry Pi Zero's don't have enough processing power for this project.
|
- Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output.
|
||||||
- **Raspberry Pi 3B, 4, or 5**
|
- **Raspberry Pi 3B or 4 (NOT RPi 5!)**
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
||||||
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
|
|
||||||
```bash
|
|
||||||
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
|
||||||
```
|
|
||||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
|
||||||
|
|
||||||
|
|
||||||
### RGB Matrix Bonnet / HAT
|
### RGB Matrix Bonnet / HAT
|
||||||
@@ -592,7 +582,7 @@ These settings control runtime behavior and GPIO timing:
|
|||||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||||
- **Raspberry Pi 3**: Use 3
|
- **Raspberry Pi 3**: Use 3
|
||||||
- **Raspberry Pi 4**: Use 4
|
- **Raspberry Pi 4**: Use 4
|
||||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
- **Raspberry Pi 5**: Use 5 (or higher if needed)
|
||||||
- **Raspberry Pi Zero/1**: Use 1-2
|
- **Raspberry Pi Zero/1**: Use 1-2
|
||||||
- Incorrect values can cause display corruption, flickering, or system instability
|
- Incorrect values can cause display corruption, flickering, or system instability
|
||||||
- If you experience issues, try adjusting this value up or down by 1
|
- If you experience issues, try adjusting this value up or down by 1
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
{
|
{
|
||||||
"web_display_autostart": true,
|
"web_display_autostart": true,
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"mode": "per-day",
|
"mode": "per-day",
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00",
|
"end_time": "23:00",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": {
|
"monday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"tuesday": {
|
"tuesday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"wednesday": {
|
"wednesday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"thursday": {
|
"thursday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"friday": {
|
"friday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"saturday": {
|
"saturday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"sunday": {
|
"sunday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
}
|
}
|
||||||
@@ -51,46 +51,46 @@
|
|||||||
"end_time": "07:00",
|
"end_time": "07:00",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": {
|
"monday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"tuesday": {
|
"tuesday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"wednesday": {
|
"wednesday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"thursday": {
|
"thursday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"friday": {
|
"friday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"saturday": {
|
"saturday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"sunday": {
|
"sunday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timezone": "America/New_York",
|
"timezone": "America/Chicago",
|
||||||
"location": {
|
"location": {
|
||||||
"city": "Tampa",
|
"city": "Dallas",
|
||||||
"state": "Florida",
|
"state": "Texas",
|
||||||
"country": "US"
|
"country": "US"
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
|
|||||||
@@ -36,17 +36,9 @@ if [ -r /proc/device-tree/model ]; then
|
|||||||
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
||||||
echo "Detected device: $DEVICE_MODEL"
|
echo "Detected device: $DEVICE_MODEL"
|
||||||
else
|
else
|
||||||
DEVICE_MODEL=""
|
|
||||||
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
|
|
||||||
IS_PI5=0
|
|
||||||
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
|
|
||||||
IS_PI5=1
|
|
||||||
echo "Raspberry Pi 5 detected — will verify RP1 library support."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
||||||
echo ""
|
echo ""
|
||||||
echo "Checking operating system requirements..."
|
echo "Checking operating system requirements..."
|
||||||
@@ -791,28 +783,9 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
|||||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||||
echo "-----------------------------------------------------"
|
echo "-----------------------------------------------------"
|
||||||
|
|
||||||
# On Pi 5, also check that the installed library has rp1_rio support.
|
# If already installed and not forcing rebuild, skip expensive build
|
||||||
# A library built before Pi 5 support was added imports fine but maps to the
|
|
||||||
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
|
|
||||||
_HAS_RP1=0
|
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
|
||||||
_HAS_RP1=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
_SKIP_BUILD=0
|
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
||||||
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
|
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||||
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
|
|
||||||
echo " Forcing rebuild to get Pi 5 RP1 support..."
|
|
||||||
else
|
|
||||||
_SKIP_BUILD=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_SKIP_BUILD" = "1" ]; then
|
|
||||||
_skip_suffix=""
|
|
||||||
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
|
|
||||||
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
|
||||||
else
|
else
|
||||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
@@ -879,17 +852,6 @@ except Exception as e:
|
|||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
echo "✓ rpi-rgb-led-matrix installed and verified"
|
echo "✓ rpi-rgb-led-matrix installed and verified"
|
||||||
# Pi 5: confirm the freshly-built library has rp1_rio support
|
|
||||||
if [ "$IS_PI5" = "1" ]; then
|
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
|
||||||
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
|
|
||||||
else
|
|
||||||
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
|
|
||||||
echo " Try updating the submodule and rebuilding:"
|
|
||||||
echo " git submodule update --remote rpi-rgb-led-matrix-master"
|
|
||||||
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "✗ rpi-rgb-led-matrix import test failed"
|
echo "✗ rpi-rgb-led-matrix import test failed"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
138
plugin-repos/march-madness/config_schema.json
Normal file
138
plugin-repos/march-madness/config_schema.json
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "March Madness Plugin Configuration",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Enable the March Madness tournament display"
|
||||||
|
},
|
||||||
|
"leagues": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Tournament Leagues",
|
||||||
|
"description": "Which NCAA tournaments to display",
|
||||||
|
"properties": {
|
||||||
|
"ncaam": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show NCAA Men's Tournament games"
|
||||||
|
},
|
||||||
|
"ncaaw": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show NCAA Women's Tournament games"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"favorite_teams": {
|
||||||
|
"type": "array",
|
||||||
|
"title": "Favorite Teams",
|
||||||
|
"description": "Team abbreviations to highlight (e.g., DUKE, UNC). Leave empty to show all teams equally.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"uniqueItems": true,
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"display_options": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Display Options",
|
||||||
|
"x-collapsed": true,
|
||||||
|
"properties": {
|
||||||
|
"show_seeds": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show tournament seeds (1-16) next to team names"
|
||||||
|
},
|
||||||
|
"show_round_logos": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show round logo separators between game groups"
|
||||||
|
},
|
||||||
|
"highlight_upsets": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Highlight upset winners (higher seed beating lower seed) in gold"
|
||||||
|
},
|
||||||
|
"show_bracket_progress": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show which teams are still alive in each region"
|
||||||
|
},
|
||||||
|
"scroll_speed": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 1.0,
|
||||||
|
"minimum": 0.5,
|
||||||
|
"maximum": 5.0,
|
||||||
|
"description": "Scroll speed (pixels per frame)"
|
||||||
|
},
|
||||||
|
"scroll_delay": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0.02,
|
||||||
|
"minimum": 0.001,
|
||||||
|
"maximum": 0.1,
|
||||||
|
"description": "Delay between scroll frames (seconds)"
|
||||||
|
},
|
||||||
|
"target_fps": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 120,
|
||||||
|
"minimum": 30,
|
||||||
|
"maximum": 200,
|
||||||
|
"description": "Target frames per second"
|
||||||
|
},
|
||||||
|
"loop": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Loop the scroll continuously"
|
||||||
|
},
|
||||||
|
"dynamic_duration": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Automatically adjust display duration based on content width"
|
||||||
|
},
|
||||||
|
"min_duration": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 30,
|
||||||
|
"minimum": 10,
|
||||||
|
"maximum": 300,
|
||||||
|
"description": "Minimum display duration in seconds"
|
||||||
|
},
|
||||||
|
"max_duration": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 300,
|
||||||
|
"minimum": 30,
|
||||||
|
"maximum": 600,
|
||||||
|
"description": "Maximum display duration in seconds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"data_settings": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Data Settings",
|
||||||
|
"x-collapsed": true,
|
||||||
|
"properties": {
|
||||||
|
"update_interval": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 300,
|
||||||
|
"minimum": 60,
|
||||||
|
"maximum": 3600,
|
||||||
|
"description": "How often to refresh tournament data (seconds). Automatically shortens to 60s when live games are detected."
|
||||||
|
},
|
||||||
|
"request_timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 30,
|
||||||
|
"minimum": 5,
|
||||||
|
"maximum": 60,
|
||||||
|
"description": "API request timeout in seconds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["enabled"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-propertyOrder": ["enabled", "leagues", "favorite_teams", "display_options", "data_settings"]
|
||||||
|
}
|
||||||
910
plugin-repos/march-madness/manager.py
Normal file
910
plugin-repos/march-madness/manager.py
Normal file
@@ -0,0 +1,910 @@
|
|||||||
|
"""March Madness Plugin — NCAA Tournament bracket tracker for LED Matrix.
|
||||||
|
|
||||||
|
Displays a horizontally-scrolling ticker of NCAA Tournament games grouped by
|
||||||
|
round, with seeds, round logos, live scores, and upset highlighting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytz
|
||||||
|
import requests
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
except ImportError:
|
||||||
|
ScrollHelper = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SCOREBOARD_URLS = {
|
||||||
|
"ncaam": "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard",
|
||||||
|
"ncaaw": "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard",
|
||||||
|
}
|
||||||
|
|
||||||
|
ROUND_ORDER = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6}
|
||||||
|
|
||||||
|
ROUND_DISPLAY_NAMES = {
|
||||||
|
"NCG": "Championship",
|
||||||
|
"F4": "Final Four",
|
||||||
|
"E8": "Elite Eight",
|
||||||
|
"S16": "Sweet Sixteen",
|
||||||
|
"R32": "Round of 32",
|
||||||
|
"R64": "Round of 64",
|
||||||
|
}
|
||||||
|
|
||||||
|
ROUND_LOGO_FILES = {
|
||||||
|
"NCG": "CHAMPIONSHIP.png",
|
||||||
|
"F4": "FINAL_4.png",
|
||||||
|
"E8": "ELITE_8.png",
|
||||||
|
"S16": "SWEET_16.png",
|
||||||
|
"R32": "ROUND_32.png",
|
||||||
|
"R64": "ROUND_64.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
REGION_ORDER = {"E": 0, "W": 1, "S": 2, "MW": 3, "": 4}
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
COLOR_WHITE = (255, 255, 255)
|
||||||
|
COLOR_GOLD = (255, 215, 0)
|
||||||
|
COLOR_GRAY = (160, 160, 160)
|
||||||
|
COLOR_DIM = (100, 100, 100)
|
||||||
|
COLOR_RED = (255, 60, 60)
|
||||||
|
COLOR_GREEN = (60, 200, 60)
|
||||||
|
COLOR_BLACK = (0, 0, 0)
|
||||||
|
COLOR_DARK_BG = (20, 20, 20)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plugin Class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MarchMadnessPlugin(BasePlugin):
|
||||||
|
"""NCAA March Madness tournament bracket tracker."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
display_manager: Any,
|
||||||
|
cache_manager: Any,
|
||||||
|
plugin_manager: Any,
|
||||||
|
):
|
||||||
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
|
|
||||||
|
# Config
|
||||||
|
leagues_config = config.get("leagues", {})
|
||||||
|
self.show_ncaam: bool = leagues_config.get("ncaam", True)
|
||||||
|
self.show_ncaaw: bool = leagues_config.get("ncaaw", True)
|
||||||
|
self.favorite_teams: List[str] = [t.upper() for t in config.get("favorite_teams", [])]
|
||||||
|
|
||||||
|
display_options = config.get("display_options", {})
|
||||||
|
self.show_seeds: bool = display_options.get("show_seeds", True)
|
||||||
|
self.show_round_logos: bool = display_options.get("show_round_logos", True)
|
||||||
|
self.highlight_upsets: bool = display_options.get("highlight_upsets", True)
|
||||||
|
self.show_bracket_progress: bool = display_options.get("show_bracket_progress", True)
|
||||||
|
self.scroll_speed: float = display_options.get("scroll_speed", 1.0)
|
||||||
|
self.scroll_delay: float = display_options.get("scroll_delay", 0.02)
|
||||||
|
self.target_fps: int = display_options.get("target_fps", 120)
|
||||||
|
self.loop: bool = display_options.get("loop", True)
|
||||||
|
self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True)
|
||||||
|
self.min_duration: int = display_options.get("min_duration", 30)
|
||||||
|
self.max_duration: int = display_options.get("max_duration", 300)
|
||||||
|
if self.min_duration > self.max_duration:
|
||||||
|
self.logger.warning(
|
||||||
|
f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values"
|
||||||
|
)
|
||||||
|
self.min_duration, self.max_duration = self.max_duration, self.min_duration
|
||||||
|
|
||||||
|
data_settings = config.get("data_settings", {})
|
||||||
|
self.update_interval: int = data_settings.get("update_interval", 300)
|
||||||
|
self.request_timeout: int = data_settings.get("request_timeout", 30)
|
||||||
|
|
||||||
|
# Scrolling flag for display controller
|
||||||
|
self.enable_scrolling = True
|
||||||
|
|
||||||
|
# State
|
||||||
|
self.games_data: List[Dict] = []
|
||||||
|
self.ticker_image: Optional[Image.Image] = None
|
||||||
|
self.last_update: float = 0
|
||||||
|
self.dynamic_duration: float = 60
|
||||||
|
self.total_scroll_width: int = 0
|
||||||
|
self._display_start_time: Optional[float] = None
|
||||||
|
self._end_reached_logged: bool = False
|
||||||
|
self._update_lock = threading.Lock()
|
||||||
|
self._has_live_games: bool = False
|
||||||
|
self._cached_dynamic_duration: Optional[float] = None
|
||||||
|
self._duration_cache_time: float = 0
|
||||||
|
|
||||||
|
# Display dimensions
|
||||||
|
self.display_width: int = self.display_manager.matrix.width
|
||||||
|
self.display_height: int = self.display_manager.matrix.height
|
||||||
|
|
||||||
|
# HTTP session with retry
|
||||||
|
self.session = requests.Session()
|
||||||
|
retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
|
||||||
|
self.session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||||
|
self.headers = {"User-Agent": "LEDMatrix/2.0"}
|
||||||
|
|
||||||
|
# ScrollHelper
|
||||||
|
if ScrollHelper:
|
||||||
|
self.scroll_helper = ScrollHelper(self.display_width, self.display_height, logger=self.logger)
|
||||||
|
if hasattr(self.scroll_helper, "set_frame_based_scrolling"):
|
||||||
|
self.scroll_helper.set_frame_based_scrolling(True)
|
||||||
|
self.scroll_helper.set_scroll_speed(self.scroll_speed)
|
||||||
|
self.scroll_helper.set_scroll_delay(self.scroll_delay)
|
||||||
|
if hasattr(self.scroll_helper, "set_target_fps"):
|
||||||
|
self.scroll_helper.set_target_fps(self.target_fps)
|
||||||
|
self.scroll_helper.set_dynamic_duration_settings(
|
||||||
|
enabled=self.dynamic_duration_enabled,
|
||||||
|
min_duration=self.min_duration,
|
||||||
|
max_duration=self.max_duration,
|
||||||
|
buffer=0.1,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.scroll_helper = None
|
||||||
|
self.logger.warning("ScrollHelper not available")
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
self.fonts = self._load_fonts()
|
||||||
|
|
||||||
|
# Logos
|
||||||
|
self._round_logos: Dict[str, Image.Image] = {}
|
||||||
|
self._team_logo_cache: Dict[str, Optional[Image.Image]] = {}
|
||||||
|
self._march_madness_logo: Optional[Image.Image] = None
|
||||||
|
self._load_round_logos()
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"MarchMadnessPlugin initialized — NCAAM: {self.show_ncaam}, "
|
||||||
|
f"NCAAW: {self.show_ncaaw}, favorites: {self.favorite_teams}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Fonts
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
|
||||||
|
fonts = {}
|
||||||
|
try:
|
||||||
|
fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
||||||
|
except IOError:
|
||||||
|
fonts["score"] = ImageFont.load_default()
|
||||||
|
try:
|
||||||
|
fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||||
|
except IOError:
|
||||||
|
fonts["time"] = ImageFont.load_default()
|
||||||
|
try:
|
||||||
|
fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||||
|
except IOError:
|
||||||
|
fonts["detail"] = ImageFont.load_default()
|
||||||
|
return fonts
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Logo loading
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_round_logos(self) -> None:
|
||||||
|
logo_dir = Path("assets/sports/ncaa_logos")
|
||||||
|
for round_key, filename in ROUND_LOGO_FILES.items():
|
||||||
|
path = logo_dir / filename
|
||||||
|
try:
|
||||||
|
img = Image.open(path).convert("RGBA")
|
||||||
|
# Resize to fit display height
|
||||||
|
target_h = self.display_height - 4
|
||||||
|
ratio = target_h / img.height
|
||||||
|
target_w = int(img.width * ratio)
|
||||||
|
self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.logger.warning(f"Could not load round logo {filename}: {e}")
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(f"Unexpected error loading round logo {filename}")
|
||||||
|
|
||||||
|
# March Madness logo
|
||||||
|
mm_path = logo_dir / "MARCH_MADNESS.png"
|
||||||
|
try:
|
||||||
|
img = Image.open(mm_path).convert("RGBA")
|
||||||
|
target_h = self.display_height - 4
|
||||||
|
ratio = target_h / img.height
|
||||||
|
target_w = int(img.width * ratio)
|
||||||
|
self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.logger.warning(f"Could not load March Madness logo: {e}")
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Unexpected error loading March Madness logo")
|
||||||
|
|
||||||
|
def _get_team_logo(self, abbr: str) -> Optional[Image.Image]:
|
||||||
|
if abbr in self._team_logo_cache:
|
||||||
|
return self._team_logo_cache[abbr]
|
||||||
|
logo_dir = Path("assets/sports/ncaa_logos")
|
||||||
|
path = logo_dir / f"{abbr}.png"
|
||||||
|
try:
|
||||||
|
img = Image.open(path).convert("RGBA")
|
||||||
|
target_h = self.display_height - 6
|
||||||
|
ratio = target_h / img.height
|
||||||
|
target_w = int(img.width * ratio)
|
||||||
|
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||||
|
self._team_logo_cache[abbr] = img
|
||||||
|
return img
|
||||||
|
except (FileNotFoundError, OSError, ValueError):
|
||||||
|
self._team_logo_cache[abbr] = None
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(f"Unexpected error loading team logo for {abbr}")
|
||||||
|
self._team_logo_cache[abbr] = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Data fetching
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _is_tournament_window(self) -> bool:
|
||||||
|
today = datetime.now(pytz.utc)
|
||||||
|
return (3, 10) <= (today.month, today.day) <= (4, 10)
|
||||||
|
|
||||||
|
def _fetch_tournament_data(self) -> List[Dict]:
|
||||||
|
"""Fetch tournament games from ESPN scoreboard API."""
|
||||||
|
all_games: List[Dict] = []
|
||||||
|
|
||||||
|
leagues = []
|
||||||
|
if self.show_ncaam:
|
||||||
|
leagues.append("ncaam")
|
||||||
|
if self.show_ncaaw:
|
||||||
|
leagues.append("ncaaw")
|
||||||
|
|
||||||
|
for league_key in leagues:
|
||||||
|
url = SCOREBOARD_URLS.get(league_key)
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache_key = f"march_madness_{league_key}_scoreboard"
|
||||||
|
cache_max_age = 60 if self._has_live_games else self.update_interval
|
||||||
|
cached = self.cache_manager.get(cache_key, max_age=cache_max_age)
|
||||||
|
if cached:
|
||||||
|
all_games.extend(cached)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# NCAA basketball scoreboard without dates param returns current games
|
||||||
|
params = {"limit": 1000, "groups": 100}
|
||||||
|
resp = self.session.get(url, params=params, headers=self.headers, timeout=self.request_timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
events = data.get("events", [])
|
||||||
|
|
||||||
|
league_games = []
|
||||||
|
for event in events:
|
||||||
|
game = self._parse_event(event, league_key)
|
||||||
|
if game:
|
||||||
|
league_games.append(game)
|
||||||
|
|
||||||
|
self.cache_manager.set(cache_key, league_games)
|
||||||
|
self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games")
|
||||||
|
all_games.extend(league_games)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(f"Error fetching {league_key} tournament data")
|
||||||
|
|
||||||
|
return all_games
|
||||||
|
|
||||||
|
def _parse_event(self, event: Dict, league_key: str) -> Optional[Dict]:
|
||||||
|
"""Parse an ESPN event into a game dict."""
|
||||||
|
competitions = event.get("competitions", [])
|
||||||
|
if not competitions:
|
||||||
|
return None
|
||||||
|
comp = competitions[0]
|
||||||
|
|
||||||
|
# Confirm tournament game
|
||||||
|
comp_type = comp.get("type", {})
|
||||||
|
is_tournament = comp_type.get("abbreviation") == "TRNMNT"
|
||||||
|
notes = comp.get("notes", [])
|
||||||
|
headline = ""
|
||||||
|
if notes:
|
||||||
|
headline = notes[0].get("headline", "")
|
||||||
|
if not is_tournament and "Championship" in headline:
|
||||||
|
is_tournament = True
|
||||||
|
if not is_tournament:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = comp.get("status", {}).get("type", {})
|
||||||
|
state = status.get("state", "pre")
|
||||||
|
status_detail = status.get("shortDetail", "")
|
||||||
|
|
||||||
|
# Teams
|
||||||
|
competitors = comp.get("competitors", [])
|
||||||
|
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
|
||||||
|
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
|
||||||
|
if not home_team or not away_team:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_abbr = home_team.get("team", {}).get("abbreviation", "???")
|
||||||
|
away_abbr = away_team.get("team", {}).get("abbreviation", "???")
|
||||||
|
home_score = home_team.get("score", "0")
|
||||||
|
away_score = away_team.get("score", "0")
|
||||||
|
|
||||||
|
# Seeds
|
||||||
|
home_seed = home_team.get("curatedRank", {}).get("current", 0)
|
||||||
|
away_seed = away_team.get("curatedRank", {}).get("current", 0)
|
||||||
|
if home_seed >= 99:
|
||||||
|
home_seed = 0
|
||||||
|
if away_seed >= 99:
|
||||||
|
away_seed = 0
|
||||||
|
|
||||||
|
# Round and region
|
||||||
|
tournament_round = self._parse_round(headline)
|
||||||
|
tournament_region = self._parse_region(headline)
|
||||||
|
|
||||||
|
# Date/time
|
||||||
|
date_str = event.get("date", "")
|
||||||
|
start_time_utc = None
|
||||||
|
game_date = ""
|
||||||
|
game_time = ""
|
||||||
|
try:
|
||||||
|
if date_str.endswith("Z"):
|
||||||
|
date_str = date_str.replace("Z", "+00:00")
|
||||||
|
dt = datetime.fromisoformat(date_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
||||||
|
else:
|
||||||
|
start_time_utc = dt.astimezone(pytz.UTC)
|
||||||
|
local = start_time_utc.astimezone(pytz.timezone("US/Eastern"))
|
||||||
|
game_date = local.strftime("%-m/%-d")
|
||||||
|
game_time = local.strftime("%-I:%M%p").replace("AM", "am").replace("PM", "pm")
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Period / clock for live games
|
||||||
|
period = 0
|
||||||
|
clock = ""
|
||||||
|
period_text = ""
|
||||||
|
is_halftime = False
|
||||||
|
if state == "in":
|
||||||
|
status_obj = comp.get("status", {})
|
||||||
|
period = status_obj.get("period", 0)
|
||||||
|
clock = status_obj.get("displayClock", "")
|
||||||
|
detail_lower = status_detail.lower()
|
||||||
|
uses_quarters = league_key == "ncaaw" or "quarter" in detail_lower or detail_lower.startswith("q")
|
||||||
|
if period <= (4 if uses_quarters else 2):
|
||||||
|
period_text = f"Q{period}" if uses_quarters else f"H{period}"
|
||||||
|
else:
|
||||||
|
ot_num = period - (4 if uses_quarters else 2)
|
||||||
|
period_text = f"OT{ot_num}" if ot_num > 1 else "OT"
|
||||||
|
if "halftime" in detail_lower:
|
||||||
|
is_halftime = True
|
||||||
|
elif state == "post":
|
||||||
|
period_text = status.get("shortDetail", "Final")
|
||||||
|
if "Final" not in period_text:
|
||||||
|
period_text = "Final"
|
||||||
|
|
||||||
|
# Determine winner and upset
|
||||||
|
is_final = state == "post"
|
||||||
|
is_upset = False
|
||||||
|
winner_side = ""
|
||||||
|
if is_final:
|
||||||
|
try:
|
||||||
|
h = int(float(home_score))
|
||||||
|
a = int(float(away_score))
|
||||||
|
if h > a:
|
||||||
|
winner_side = "home"
|
||||||
|
if home_seed > away_seed > 0:
|
||||||
|
is_upset = True
|
||||||
|
elif a > h:
|
||||||
|
winner_side = "away"
|
||||||
|
if away_seed > home_seed > 0:
|
||||||
|
is_upset = True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": event.get("id", ""),
|
||||||
|
"league": league_key,
|
||||||
|
"home_abbr": home_abbr,
|
||||||
|
"away_abbr": away_abbr,
|
||||||
|
"home_score": str(home_score),
|
||||||
|
"away_score": str(away_score),
|
||||||
|
"home_seed": home_seed,
|
||||||
|
"away_seed": away_seed,
|
||||||
|
"tournament_round": tournament_round,
|
||||||
|
"tournament_region": tournament_region,
|
||||||
|
"state": state,
|
||||||
|
"is_final": is_final,
|
||||||
|
"is_live": state == "in",
|
||||||
|
"is_upcoming": state == "pre",
|
||||||
|
"is_halftime": is_halftime,
|
||||||
|
"period": period,
|
||||||
|
"period_text": period_text,
|
||||||
|
"clock": clock,
|
||||||
|
"status_detail": status_detail,
|
||||||
|
"game_date": game_date,
|
||||||
|
"game_time": game_time,
|
||||||
|
"start_time_utc": start_time_utc,
|
||||||
|
"is_upset": is_upset,
|
||||||
|
"winner_side": winner_side,
|
||||||
|
"headline": headline,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_round(headline: str) -> str:
|
||||||
|
hl = headline.lower()
|
||||||
|
if "national championship" in hl:
|
||||||
|
return "NCG"
|
||||||
|
if "final four" in hl:
|
||||||
|
return "F4"
|
||||||
|
if "elite 8" in hl or "elite eight" in hl:
|
||||||
|
return "E8"
|
||||||
|
if "sweet 16" in hl or "sweet sixteen" in hl:
|
||||||
|
return "S16"
|
||||||
|
if "2nd round" in hl or "second round" in hl:
|
||||||
|
return "R32"
|
||||||
|
if "1st round" in hl or "first round" in hl:
|
||||||
|
return "R64"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_region(headline: str) -> str:
|
||||||
|
if "East Region" in headline:
|
||||||
|
return "E"
|
||||||
|
if "West Region" in headline:
|
||||||
|
return "W"
|
||||||
|
if "South Region" in headline:
|
||||||
|
return "S"
|
||||||
|
if "Midwest Region" in headline:
|
||||||
|
return "MW"
|
||||||
|
m = re.search(r"Regional (\d+)", headline)
|
||||||
|
if m:
|
||||||
|
return f"R{m.group(1)}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Game processing
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _process_games(self, games: List[Dict]) -> Dict[str, List[Dict]]:
|
||||||
|
"""Group games by round, sorted by round significance then region/seed."""
|
||||||
|
grouped: Dict[str, List[Dict]] = {}
|
||||||
|
for game in games:
|
||||||
|
rnd = game.get("tournament_round", "")
|
||||||
|
grouped.setdefault(rnd, []).append(game)
|
||||||
|
|
||||||
|
# Sort each round's games by region then seed matchup
|
||||||
|
for rnd, round_games in grouped.items():
|
||||||
|
round_games.sort(
|
||||||
|
key=lambda g: (
|
||||||
|
REGION_ORDER.get(g.get("tournament_region", ""), 4),
|
||||||
|
min(g.get("away_seed", 99), g.get("home_seed", 99)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Rendering
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _draw_text_with_outline(
|
||||||
|
self,
|
||||||
|
draw: ImageDraw.Draw,
|
||||||
|
text: str,
|
||||||
|
xy: tuple,
|
||||||
|
font: ImageFont.FreeTypeFont,
|
||||||
|
fill: tuple = COLOR_WHITE,
|
||||||
|
outline: tuple = COLOR_BLACK,
|
||||||
|
) -> None:
|
||||||
|
x, y = xy
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
if dx or dy:
|
||||||
|
draw.text((x + dx, y + dy), text, font=font, fill=outline)
|
||||||
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
def _create_round_separator(self, round_key: str) -> Image.Image:
|
||||||
|
"""Create a separator tile for a tournament round."""
|
||||||
|
height = self.display_height
|
||||||
|
name = ROUND_DISPLAY_NAMES.get(round_key, round_key)
|
||||||
|
font = self.fonts["time"]
|
||||||
|
|
||||||
|
# Measure text
|
||||||
|
tmp = Image.new("RGB", (1, 1))
|
||||||
|
tmp_draw = ImageDraw.Draw(tmp)
|
||||||
|
text_width = int(tmp_draw.textlength(name, font=font))
|
||||||
|
|
||||||
|
# Logo on each side
|
||||||
|
logo = self._round_logos.get(round_key, self._march_madness_logo)
|
||||||
|
logo_w = logo.width if logo else 0
|
||||||
|
padding = 6
|
||||||
|
|
||||||
|
total_w = padding + logo_w + padding + text_width + padding + logo_w + padding
|
||||||
|
total_w = max(total_w, 80)
|
||||||
|
|
||||||
|
img = Image.new("RGB", (total_w, height), COLOR_DARK_BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Draw logos
|
||||||
|
x = padding
|
||||||
|
if logo:
|
||||||
|
logo_y = (height - logo.height) // 2
|
||||||
|
img.paste(logo, (x, logo_y), logo)
|
||||||
|
x += logo_w + padding
|
||||||
|
|
||||||
|
# Draw round name
|
||||||
|
text_y = (height - 8) // 2 # 8px font
|
||||||
|
self._draw_text_with_outline(draw, name, (x, text_y), font, fill=COLOR_GOLD)
|
||||||
|
x += text_width + padding
|
||||||
|
|
||||||
|
if logo:
|
||||||
|
logo_y = (height - logo.height) // 2
|
||||||
|
img.paste(logo, (x, logo_y), logo)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
def _create_game_tile(self, game: Dict) -> Image.Image:
|
||||||
|
"""Create a single game tile for the scrolling ticker."""
|
||||||
|
height = self.display_height
|
||||||
|
font_score = self.fonts["score"]
|
||||||
|
font_time = self.fonts["time"]
|
||||||
|
font_detail = self.fonts["detail"]
|
||||||
|
|
||||||
|
# Load team logos
|
||||||
|
away_logo = self._get_team_logo(game["away_abbr"])
|
||||||
|
home_logo = self._get_team_logo(game["home_abbr"])
|
||||||
|
logo_w = 0
|
||||||
|
if away_logo:
|
||||||
|
logo_w = max(logo_w, away_logo.width)
|
||||||
|
if home_logo:
|
||||||
|
logo_w = max(logo_w, home_logo.width)
|
||||||
|
if logo_w == 0:
|
||||||
|
logo_w = 24
|
||||||
|
|
||||||
|
# Build text elements
|
||||||
|
away_seed_str = f"({game['away_seed']})" if self.show_seeds and game.get("away_seed", 0) > 0 else ""
|
||||||
|
home_seed_str = f"({game['home_seed']})" if self.show_seeds and game.get("home_seed", 0) > 0 else ""
|
||||||
|
away_text = f"{away_seed_str}{game['away_abbr']}"
|
||||||
|
home_text = f"{game['home_abbr']}{home_seed_str}"
|
||||||
|
|
||||||
|
# Measure text widths
|
||||||
|
tmp = Image.new("RGB", (1, 1))
|
||||||
|
tmp_draw = ImageDraw.Draw(tmp)
|
||||||
|
away_text_w = int(tmp_draw.textlength(away_text, font=font_detail))
|
||||||
|
home_text_w = int(tmp_draw.textlength(home_text, font=font_detail))
|
||||||
|
|
||||||
|
# Center content: status line
|
||||||
|
if game["is_live"]:
|
||||||
|
if game["is_halftime"]:
|
||||||
|
status_text = "Halftime"
|
||||||
|
else:
|
||||||
|
status_text = f"{game['period_text']} {game['clock']}".strip()
|
||||||
|
elif game["is_final"]:
|
||||||
|
status_text = game.get("period_text", "Final")
|
||||||
|
else:
|
||||||
|
status_text = f"{game['game_date']} {game['game_time']}".strip()
|
||||||
|
|
||||||
|
status_w = int(tmp_draw.textlength(status_text, font=font_time))
|
||||||
|
|
||||||
|
# Score line (for live/final)
|
||||||
|
score_text = ""
|
||||||
|
if game["is_live"] or game["is_final"]:
|
||||||
|
score_text = f"{game['away_score']}-{game['home_score']}"
|
||||||
|
score_w = int(tmp_draw.textlength(score_text, font=font_score)) if score_text else 0
|
||||||
|
|
||||||
|
# Calculate tile width
|
||||||
|
h_pad = 4
|
||||||
|
center_w = max(status_w, score_w, 40)
|
||||||
|
tile_w = h_pad + logo_w + h_pad + away_text_w + h_pad + center_w + h_pad + home_text_w + h_pad + logo_w + h_pad
|
||||||
|
|
||||||
|
img = Image.new("RGB", (tile_w, height), COLOR_BLACK)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Paste away logo
|
||||||
|
x = h_pad
|
||||||
|
if away_logo:
|
||||||
|
logo_y = (height - away_logo.height) // 2
|
||||||
|
img.paste(away_logo, (x, logo_y), away_logo)
|
||||||
|
x += logo_w + h_pad
|
||||||
|
|
||||||
|
# Away team text (seed + abbr)
|
||||||
|
is_fav_away = game["away_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||||
|
away_color = COLOR_GOLD if is_fav_away else COLOR_WHITE
|
||||||
|
if game["is_final"] and game["winner_side"] == "away" and self.highlight_upsets and game["is_upset"]:
|
||||||
|
away_color = COLOR_GOLD
|
||||||
|
team_text_y = (height - 6) // 2 - 5 # Upper half
|
||||||
|
self._draw_text_with_outline(draw, away_text, (x, team_text_y), font_detail, fill=away_color)
|
||||||
|
x += away_text_w + h_pad
|
||||||
|
|
||||||
|
# Center block
|
||||||
|
center_x = x
|
||||||
|
center_mid = center_x + center_w // 2
|
||||||
|
|
||||||
|
# Status text (top center of center block)
|
||||||
|
status_x = center_mid - status_w // 2
|
||||||
|
status_y = 2
|
||||||
|
status_color = COLOR_GREEN if game["is_live"] else COLOR_GRAY
|
||||||
|
self._draw_text_with_outline(draw, status_text, (status_x, status_y), font_time, fill=status_color)
|
||||||
|
|
||||||
|
# Score (bottom center of center block, for live/final)
|
||||||
|
if score_text:
|
||||||
|
score_x = center_mid - score_w // 2
|
||||||
|
score_y = height - 13
|
||||||
|
# Upset highlighting
|
||||||
|
if game["is_final"] and game["is_upset"] and self.highlight_upsets:
|
||||||
|
score_color = COLOR_GOLD
|
||||||
|
elif game["is_live"]:
|
||||||
|
score_color = COLOR_WHITE
|
||||||
|
else:
|
||||||
|
score_color = COLOR_WHITE
|
||||||
|
self._draw_text_with_outline(draw, score_text, (score_x, score_y), font_score, fill=score_color)
|
||||||
|
|
||||||
|
# Date for final games (below score)
|
||||||
|
if game["is_final"] and game.get("game_date"):
|
||||||
|
date_w = int(draw.textlength(game["game_date"], font=font_detail))
|
||||||
|
date_x = center_mid - date_w // 2
|
||||||
|
date_y = height - 6
|
||||||
|
self._draw_text_with_outline(draw, game["game_date"], (date_x, date_y), font_detail, fill=COLOR_DIM)
|
||||||
|
|
||||||
|
x = center_x + center_w + h_pad
|
||||||
|
|
||||||
|
# Home team text
|
||||||
|
is_fav_home = game["home_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||||
|
home_color = COLOR_GOLD if is_fav_home else COLOR_WHITE
|
||||||
|
if game["is_final"] and game["winner_side"] == "home" and self.highlight_upsets and game["is_upset"]:
|
||||||
|
home_color = COLOR_GOLD
|
||||||
|
self._draw_text_with_outline(draw, home_text, (x, team_text_y), font_detail, fill=home_color)
|
||||||
|
x += home_text_w + h_pad
|
||||||
|
|
||||||
|
# Paste home logo
|
||||||
|
if home_logo:
|
||||||
|
logo_y = (height - home_logo.height) // 2
|
||||||
|
img.paste(home_logo, (x, logo_y), home_logo)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
def _create_ticker_image(self) -> None:
|
||||||
|
"""Build the full scrolling ticker image from game tiles."""
|
||||||
|
if not self.games_data:
|
||||||
|
self.ticker_image = None
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.clear_cache()
|
||||||
|
return
|
||||||
|
|
||||||
|
grouped = self._process_games(self.games_data)
|
||||||
|
content_items: List[Image.Image] = []
|
||||||
|
|
||||||
|
# Order rounds by significance (most important first)
|
||||||
|
sorted_rounds = sorted(grouped.keys(), key=lambda r: ROUND_ORDER.get(r, 6))
|
||||||
|
|
||||||
|
for rnd in sorted_rounds:
|
||||||
|
games = grouped[rnd]
|
||||||
|
if not games:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add round separator
|
||||||
|
if self.show_round_logos and rnd:
|
||||||
|
separator = self._create_round_separator(rnd)
|
||||||
|
content_items.append(separator)
|
||||||
|
|
||||||
|
# Add game tiles
|
||||||
|
for game in games:
|
||||||
|
tile = self._create_game_tile(game)
|
||||||
|
content_items.append(tile)
|
||||||
|
|
||||||
|
if not content_items:
|
||||||
|
self.ticker_image = None
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.clear_cache()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.scroll_helper:
|
||||||
|
self.ticker_image = None
|
||||||
|
return
|
||||||
|
|
||||||
|
gap_width = 16
|
||||||
|
|
||||||
|
# Use ScrollHelper to create the scrolling image
|
||||||
|
self.ticker_image = self.scroll_helper.create_scrolling_image(
|
||||||
|
content_items=content_items,
|
||||||
|
item_gap=gap_width,
|
||||||
|
element_gap=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.total_scroll_width = self.scroll_helper.total_scroll_width
|
||||||
|
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Ticker image created: {self.ticker_image.width}px wide, "
|
||||||
|
f"{len(self.games_data)} games, dynamic_duration={self.dynamic_duration:.0f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Plugin lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
"""Fetch and process tournament data."""
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
# Use shorter interval if live games detected
|
||||||
|
interval = 60 if self._has_live_games else self.update_interval
|
||||||
|
if current_time - self.last_update < interval:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._update_lock:
|
||||||
|
self.last_update = current_time
|
||||||
|
|
||||||
|
if not self._is_tournament_window():
|
||||||
|
self.logger.debug("Outside tournament window, skipping fetch")
|
||||||
|
self.games_data = []
|
||||||
|
self.ticker_image = None
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.clear_cache()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
games = self._fetch_tournament_data()
|
||||||
|
self._has_live_games = any(g["is_live"] for g in games)
|
||||||
|
self.games_data = games
|
||||||
|
self._create_ticker_image()
|
||||||
|
self.logger.info(
|
||||||
|
f"Updated: {len(games)} games, "
|
||||||
|
f"live={self._has_live_games}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Update error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def display(self, force_clear: bool = False) -> None:
|
||||||
|
"""Render one scroll frame."""
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if force_clear or self._display_start_time is None:
|
||||||
|
self._display_start_time = time.time()
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.reset_scroll()
|
||||||
|
self._end_reached_logged = False
|
||||||
|
|
||||||
|
if not self.games_data or self.ticker_image is None:
|
||||||
|
self._display_fallback()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.scroll_helper:
|
||||||
|
self._display_fallback()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.loop or not self.scroll_helper.is_scroll_complete():
|
||||||
|
self.scroll_helper.update_scroll_position()
|
||||||
|
elif not self._end_reached_logged:
|
||||||
|
self.logger.info("Scroll complete")
|
||||||
|
self._end_reached_logged = True
|
||||||
|
|
||||||
|
visible = self.scroll_helper.get_visible_portion()
|
||||||
|
if visible is None:
|
||||||
|
self._display_fallback()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||||
|
|
||||||
|
matrix_w = self.display_manager.matrix.width
|
||||||
|
matrix_h = self.display_manager.matrix.height
|
||||||
|
if not hasattr(self.display_manager, "image") or self.display_manager.image is None:
|
||||||
|
self.display_manager.image = Image.new("RGB", (matrix_w, matrix_h), COLOR_BLACK)
|
||||||
|
self.display_manager.image.paste(visible, (0, 0))
|
||||||
|
self.display_manager.update_display()
|
||||||
|
self.scroll_helper.log_frame_rate()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Display error: {e}", exc_info=True)
|
||||||
|
self._display_fallback()
|
||||||
|
|
||||||
|
def _display_fallback(self) -> None:
|
||||||
|
w = self.display_manager.matrix.width
|
||||||
|
h = self.display_manager.matrix.height
|
||||||
|
img = Image.new("RGB", (w, h), COLOR_BLACK)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
if self._is_tournament_window():
|
||||||
|
text = "No games"
|
||||||
|
else:
|
||||||
|
text = "Off-season"
|
||||||
|
|
||||||
|
text_w = int(draw.textlength(text, font=self.fonts["time"]))
|
||||||
|
text_x = (w - text_w) // 2
|
||||||
|
text_y = (h - 8) // 2
|
||||||
|
draw.text((text_x, text_y), text, font=self.fonts["time"], fill=COLOR_GRAY)
|
||||||
|
|
||||||
|
# Show March Madness logo if available
|
||||||
|
if self._march_madness_logo:
|
||||||
|
logo_y = (h - self._march_madness_logo.height) // 2
|
||||||
|
img.paste(self._march_madness_logo, (2, logo_y), self._march_madness_logo)
|
||||||
|
|
||||||
|
self.display_manager.image = img
|
||||||
|
self.display_manager.update_display()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Duration / cycle management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_display_duration(self) -> float:
|
||||||
|
current_time = time.time()
|
||||||
|
if self._cached_dynamic_duration is not None:
|
||||||
|
cache_age = current_time - self._duration_cache_time
|
||||||
|
if cache_age < 5.0:
|
||||||
|
return self._cached_dynamic_duration
|
||||||
|
|
||||||
|
self._cached_dynamic_duration = self.dynamic_duration
|
||||||
|
self._duration_cache_time = current_time
|
||||||
|
return self.dynamic_duration
|
||||||
|
|
||||||
|
def supports_dynamic_duration(self) -> bool:
|
||||||
|
if not self.enabled:
|
||||||
|
return False
|
||||||
|
return self.dynamic_duration_enabled
|
||||||
|
|
||||||
|
def is_cycle_complete(self) -> bool:
|
||||||
|
if not self.supports_dynamic_duration():
|
||||||
|
return True
|
||||||
|
if self._display_start_time is not None and self.dynamic_duration > 0:
|
||||||
|
elapsed = time.time() - self._display_start_time
|
||||||
|
if elapsed >= self.dynamic_duration:
|
||||||
|
return True
|
||||||
|
if not self.loop and self.scroll_helper and self.scroll_helper.is_scroll_complete():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reset_cycle_state(self) -> None:
|
||||||
|
super().reset_cycle_state()
|
||||||
|
self._display_start_time = None
|
||||||
|
self._end_reached_logged = False
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.reset_scroll()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Vegas mode
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_vegas_content(self):
|
||||||
|
if not self.games_data:
|
||||||
|
return None
|
||||||
|
tiles = []
|
||||||
|
for game in self.games_data:
|
||||||
|
tiles.append(self._create_game_tile(game))
|
||||||
|
return tiles if tiles else None
|
||||||
|
|
||||||
|
def get_vegas_content_type(self) -> str:
|
||||||
|
return "multi"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Info / cleanup
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_info(self) -> Dict:
|
||||||
|
info = super().get_info()
|
||||||
|
info["total_games"] = len(self.games_data)
|
||||||
|
info["has_live_games"] = self._has_live_games
|
||||||
|
info["dynamic_duration"] = self.dynamic_duration
|
||||||
|
info["tournament_window"] = self._is_tournament_window()
|
||||||
|
return info
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
self.games_data = []
|
||||||
|
self.ticker_image = None
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.clear_cache()
|
||||||
|
self._team_logo_cache.clear()
|
||||||
|
if self.session:
|
||||||
|
self.session.close()
|
||||||
|
self.session = None
|
||||||
|
super().cleanup()
|
||||||
37
plugin-repos/march-madness/manifest.json
Normal file
37
plugin-repos/march-madness/manifest.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": "march-madness",
|
||||||
|
"name": "March Madness",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting",
|
||||||
|
"author": "ChuckBuilds",
|
||||||
|
"category": "sports",
|
||||||
|
"tags": [
|
||||||
|
"ncaa",
|
||||||
|
"basketball",
|
||||||
|
"march-madness",
|
||||||
|
"tournament",
|
||||||
|
"bracket",
|
||||||
|
"scrolling"
|
||||||
|
],
|
||||||
|
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
||||||
|
"branch": "main",
|
||||||
|
"plugin_path": "plugins/march-madness",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"ledmatrix_min": "2.0.0",
|
||||||
|
"released": "2026-02-16"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stars": 0,
|
||||||
|
"downloads": 0,
|
||||||
|
"last_updated": "2026-02-16",
|
||||||
|
"verified": true,
|
||||||
|
"screenshot": "",
|
||||||
|
"display_modes": [
|
||||||
|
"march_madness"
|
||||||
|
],
|
||||||
|
"dependencies": {},
|
||||||
|
"entry_point": "manager.py",
|
||||||
|
"class_name": "MarchMadnessPlugin"
|
||||||
|
}
|
||||||
5
plugin-repos/march-madness/requirements.txt
Normal file
5
plugin-repos/march-madness/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
requests>=2.33.0
|
||||||
|
urllib3>=1.26.0
|
||||||
|
Pillow>=12.2.0
|
||||||
|
pytz>=2022.1
|
||||||
|
numpy>=1.24.0
|
||||||
@@ -22,6 +22,5 @@
|
|||||||
"Pillow>=10.0.0",
|
"Pillow>=10.0.0",
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
"requests>=2.31.0"
|
"requests>=2.31.0"
|
||||||
],
|
]
|
||||||
"local_only": true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,9 +67,8 @@ def main():
|
|||||||
print(" 📍 Will run on: http://0.0.0.0:5000")
|
print(" 📍 Will run on: http://0.0.0.0:5000")
|
||||||
print(" ⏹️ Press Ctrl+C to stop")
|
print(" ⏹️ Press Ctrl+C to stop")
|
||||||
|
|
||||||
# Run the app (debug mode controlled by env var to satisfy security scanners)
|
# Run the app (this should start the server)
|
||||||
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
app.run(host='0.0.0.0', port=5000, debug=_debug)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n ⏹️ Server stopped by user")
|
print("\n ⏹️ Server stopped by user")
|
||||||
|
|||||||
@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
try:
|
try:
|
||||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||||
manifest = json.loads(manifest_raw)
|
manifest = json.loads(manifest_raw)
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||||
return False, "Invalid manifest.json", {}
|
return False, f"Invalid manifest.json: {e}", {}
|
||||||
|
|
||||||
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
||||||
return False, "Invalid manifest structure", {}
|
return False, "Invalid manifest structure", {}
|
||||||
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
return True, "", result_manifest
|
return True, "", result_manifest
|
||||||
except zipfile.BadZipFile:
|
except zipfile.BadZipFile:
|
||||||
return False, "File is not a valid ZIP archive", {}
|
return False, "File is not a valid ZIP archive", {}
|
||||||
except OSError:
|
except OSError as e:
|
||||||
return False, "Could not read backup", {}
|
return False, f"Could not read backup: {e}", {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ class DisplayHelper:
|
|||||||
PIL Image with no data message
|
PIL Image with no data message
|
||||||
"""
|
"""
|
||||||
img = self.create_base_image((0, 0, 0))
|
img = self.create_base_image((0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||||
|
|
||||||
|
|||||||
@@ -823,7 +823,7 @@ class DisplayController:
|
|||||||
scroll_h = getattr(plugin_instance, 'scroll_helper', None)
|
scroll_h = getattr(plugin_instance, 'scroll_helper', None)
|
||||||
if scroll_h is not None:
|
if scroll_h is not None:
|
||||||
follower_frame = scroll_h.get_portion_at(scroll_h.scroll_position + offset)
|
follower_frame = scroll_h.get_portion_at(scroll_h.scroll_position + offset)
|
||||||
except Exception: # nosec B110 - scroll_helper.get_portion_at is optional; skip on error
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3. Mirror fallback — static plugins (clock, weather) show same frame
|
# 3. Mirror fallback — static plugins (clock, weather) show same frame
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
|
||||||
if os.getenv("EMULATOR", "false") == "true":
|
if os.getenv("EMULATOR", "false") == "true":
|
||||||
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
||||||
else:
|
else:
|
||||||
@@ -60,7 +58,6 @@ class DisplayManager:
|
|||||||
|
|
||||||
def _setup_matrix(self):
|
def _setup_matrix(self):
|
||||||
"""Initialize the RGB matrix with configuration settings."""
|
"""Initialize the RGB matrix with configuration settings."""
|
||||||
_init_error_str = None
|
|
||||||
try:
|
try:
|
||||||
# Allow callers (e.g., web UI) to force non-hardware fallback mode
|
# Allow callers (e.g., web UI) to force non-hardware fallback mode
|
||||||
if getattr(self, '_force_fallback', False):
|
if getattr(self, '_force_fallback', False):
|
||||||
@@ -90,7 +87,7 @@ class DisplayManager:
|
|||||||
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
|
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
|
||||||
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
|
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
|
||||||
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
|
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
|
||||||
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
|
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 2)
|
||||||
|
|
||||||
# Disable internal privilege dropping - we manage this via systemd or remain root
|
# Disable internal privilege dropping - we manage this via systemd or remain root
|
||||||
# This prevents the library from dropping to 'daemon' user which breaks file permissions
|
# This prevents the library from dropping to 'daemon' user which breaks file permissions
|
||||||
@@ -110,10 +107,9 @@ class DisplayManager:
|
|||||||
options.rp1_rio = runtime_config.get('rp1_rio')
|
options.rp1_rio = runtime_config.get('rp1_rio')
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"rp1_rio is set in config but the installed rgbmatrix library does "
|
"rp1_rio is set in config but the current RGBMatrixOptions "
|
||||||
"not support it — the library was likely built without Pi 5 RP1 "
|
"implementation does not support it (RGBMatrixEmulator or older "
|
||||||
"support (mmap to 0x3f000000 instead of RP1 chip). "
|
"library version) — value will be ignored"
|
||||||
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
||||||
@@ -145,7 +141,6 @@ class DisplayManager:
|
|||||||
self._draw_test_pattern()
|
self._draw_test_pattern()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_init_error_str = str(e)
|
|
||||||
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
|
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
|
||||||
# Create a fallback image for web preview using configured dimensions when available
|
# Create a fallback image for web preview using configured dimensions when available
|
||||||
self.matrix = None
|
self.matrix = None
|
||||||
@@ -169,38 +164,9 @@ class DisplayManager:
|
|||||||
except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup
|
except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup
|
||||||
# Best-effort; ignore drawing errors in fallback
|
# Best-effort; ignore drawing errors in fallback
|
||||||
pass
|
pass
|
||||||
logger.error(
|
logger.error(f"Matrix initialization failed, using fallback mode with size {fallback_width}x{fallback_height}. Error: {e}")
|
||||||
f"Matrix initialization failed — running in fallback/simulation mode "
|
|
||||||
f"(size {fallback_width}x{fallback_height}). Error: {e}. "
|
|
||||||
"On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest "
|
|
||||||
"submodule (re-run first_time_install.sh). gpio_slowdown of 2–3 is typical for Pi 5 PIO mode."
|
|
||||||
)
|
|
||||||
# Do not raise here; allow fallback mode so web preview and non-hardware environments work
|
# Do not raise here; allow fallback mode so web preview and non-hardware environments work
|
||||||
|
|
||||||
# Write hardware status file so the web UI can surface init failures
|
|
||||||
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str}
|
|
||||||
_status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
|
|
||||||
try:
|
|
||||||
if os.path.islink(_status_path):
|
|
||||||
logger.warning("Skipping hardware status write: %s is a symlink", _status_path)
|
|
||||||
else:
|
|
||||||
_fd, _tmp_path = tempfile.mkstemp(dir="/tmp", prefix=".led_hw_") # nosec B108
|
|
||||||
try:
|
|
||||||
with os.fdopen(_fd, "w") as _f:
|
|
||||||
json.dump(_hw_status, _f)
|
|
||||||
_f.flush()
|
|
||||||
os.fsync(_f.fileno())
|
|
||||||
os.chmod(_tmp_path, 0o644)
|
|
||||||
os.replace(_tmp_path, _status_path)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
os.unlink(_tmp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.error("Failed to write hardware status file", exc_info=True)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def width(self):
|
def width(self):
|
||||||
"""Get the display width."""
|
"""Get the display width."""
|
||||||
@@ -781,8 +747,8 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
self.image = Image.new('RGB', (self.width, self.height))
|
self.image = Image.new('RGB', (self.width, self.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
except Exception:
|
||||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
pass
|
||||||
# Reset the singleton state when cleaning up
|
# Reset the singleton state when cleaning up
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
DisplayManager._initialized = False
|
DisplayManager._initialized = False
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
|||||||
Extracted from PluginManager to improve separation of concerns.
|
Extracted from PluginManager to improve separation of concerns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -70,11 +68,6 @@ class PluginLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Path to plugin directory or None if not found
|
Path to plugin directory or None if not found
|
||||||
"""
|
"""
|
||||||
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
|
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
|
||||||
if not plugin_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Strategy 1: Use mapping from discovery
|
# Strategy 1: Use mapping from discovery
|
||||||
if plugin_directories and plugin_id in plugin_directories:
|
if plugin_directories and plugin_id in plugin_directories:
|
||||||
plugin_dir = plugin_directories[plugin_id]
|
plugin_dir = plugin_directories[plugin_id]
|
||||||
@@ -82,16 +75,14 @@ class PluginLoader:
|
|||||||
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
||||||
return plugin_dir
|
return plugin_dir
|
||||||
|
|
||||||
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
|
# Strategy 2: Direct paths
|
||||||
plugins_dir_resolved = plugins_dir.resolve()
|
plugin_dir = plugins_dir / plugin_id
|
||||||
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
|
if plugin_dir.exists():
|
||||||
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
|
return plugin_dir
|
||||||
try:
|
|
||||||
_candidate.relative_to(plugins_dir_resolved)
|
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
|
||||||
except ValueError:
|
if plugin_dir.exists():
|
||||||
continue
|
return plugin_dir
|
||||||
if _candidate.exists():
|
|
||||||
return _candidate
|
|
||||||
|
|
||||||
# Strategy 3: Case-insensitive search
|
# Strategy 3: Case-insensitive search
|
||||||
normalized_id = plugin_id.lower()
|
normalized_id = plugin_id.lower()
|
||||||
@@ -139,7 +130,6 @@ class PluginLoader:
|
|||||||
self,
|
self,
|
||||||
plugin_dir: Path,
|
plugin_dir: Path,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
plugins_dir: Optional[Path] = None,
|
|
||||||
timeout: int = 300
|
timeout: int = 300
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -148,67 +138,25 @@ class PluginLoader:
|
|||||||
Args:
|
Args:
|
||||||
plugin_dir: Plugin directory path
|
plugin_dir: Plugin directory path
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
plugins_dir: Trusted base plugins directory for path containment check
|
|
||||||
timeout: Installation timeout in seconds
|
timeout: Installation timeout in seconds
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if dependencies installed or not needed, False on error
|
True if dependencies installed or not needed, False on error
|
||||||
"""
|
"""
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
if not plugin_id:
|
if not requirements_file.exists():
|
||||||
return False
|
|
||||||
|
|
||||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
|
||||||
|
|
||||||
if plugins_dir is not None:
|
|
||||||
# Validate plugin_dir is within the trusted plugins base directory.
|
|
||||||
# os.path.realpath + startswith is the CodeQL-recognised sanitiser
|
|
||||||
# pattern for path-injection (py/path-injection).
|
|
||||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
|
||||||
if not plugin_dir_real.startswith(plugins_dir_real + os.sep):
|
|
||||||
self.logger.error(
|
|
||||||
"Plugin dir for %s is outside the plugins directory, skipping deps",
|
|
||||||
plugin_id,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
elif not os.path.isdir(plugin_dir_real):
|
|
||||||
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
|
||||||
return False
|
|
||||||
|
|
||||||
requirements_file = os.path.join(plugin_dir_real, "requirements.txt")
|
|
||||||
marker_file = os.path.join(plugin_dir_real, ".dependencies_installed")
|
|
||||||
|
|
||||||
if not os.path.isfile(requirements_file):
|
|
||||||
return True # No dependencies needed
|
return True # No dependencies needed
|
||||||
|
|
||||||
try:
|
# Check if already installed
|
||||||
with open(requirements_file, 'rb') as fh:
|
marker_path = plugin_dir / ".dependencies_installed"
|
||||||
current_hash = hashlib.sha256(fh.read()).hexdigest()
|
if marker_path.exists():
|
||||||
except OSError as e:
|
self.logger.debug("Dependencies already installed for %s", plugin_id)
|
||||||
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Skip if requirements.txt hasn't changed since last install
|
|
||||||
if os.path.isfile(marker_file):
|
|
||||||
try:
|
|
||||||
with open(marker_file, 'r', encoding='utf-8') as fh:
|
|
||||||
stored_hash = fh.read().strip()
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.warning(
|
|
||||||
"Could not read dependency marker for %s (%s), will reinstall dependencies",
|
|
||||||
plugin_id, e
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if stored_hash == current_hash:
|
|
||||||
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
|
|
||||||
return True
|
return True
|
||||||
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
@@ -216,37 +164,17 @@ class PluginLoader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
try:
|
# Mark as installed
|
||||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
marker_path.touch()
|
||||||
fh.write(current_hash)
|
# Set proper file permissions after creating marker
|
||||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
|
||||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
stderr = result.stderr or ""
|
|
||||||
# uninstall-no-record-file means the package is already present at the
|
|
||||||
# system level (e.g. installed via dnf/apt without a pip RECORD file).
|
|
||||||
# pip can't replace it, but it IS installed — write the marker so we
|
|
||||||
# don't retry on every restart.
|
|
||||||
if "uninstall-no-record-file" in stderr:
|
|
||||||
self.logger.warning(
|
|
||||||
"Dependencies for %s include system-managed packages (no pip RECORD). "
|
|
||||||
"Assuming they are satisfied: %s",
|
|
||||||
plugin_id, stderr.strip()
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
|
||||||
fh.write(current_hash)
|
|
||||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
|
||||||
return True
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Dependency installation returned non-zero exit code for %s: %s",
|
"Dependency installation returned non-zero exit code for %s: %s",
|
||||||
plugin_id,
|
plugin_id,
|
||||||
stderr
|
result.stderr
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
@@ -421,20 +349,9 @@ class PluginLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Loaded module or None on error
|
Loaded module or None on error
|
||||||
"""
|
"""
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
entry_file = plugin_dir / entry_point
|
||||||
if not plugin_id:
|
|
||||||
raise PluginError("Invalid plugin ID")
|
|
||||||
try:
|
|
||||||
plugin_dir_resolved = plugin_dir.resolve(strict=True)
|
|
||||||
except OSError:
|
|
||||||
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
|
|
||||||
entry_file = (plugin_dir_resolved / entry_point).resolve()
|
|
||||||
try:
|
|
||||||
entry_file.relative_to(plugin_dir_resolved)
|
|
||||||
except ValueError:
|
|
||||||
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
|
|
||||||
if not entry_file.exists():
|
if not entry_file.exists():
|
||||||
error_msg = f"Entry point file not found for plugin {plugin_id}"
|
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
|
||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||||
|
|
||||||
@@ -584,8 +501,7 @@ class PluginLoader:
|
|||||||
display_manager: Any,
|
display_manager: Any,
|
||||||
cache_manager: Any,
|
cache_manager: Any,
|
||||||
plugin_manager: Any,
|
plugin_manager: Any,
|
||||||
install_deps: bool = True,
|
install_deps: bool = True
|
||||||
plugins_dir: Optional[Path] = None,
|
|
||||||
) -> Tuple[Any, Any]:
|
) -> Tuple[Any, Any]:
|
||||||
"""
|
"""
|
||||||
Complete plugin loading process.
|
Complete plugin loading process.
|
||||||
@@ -599,7 +515,6 @@ class PluginLoader:
|
|||||||
cache_manager: Cache manager instance
|
cache_manager: Cache manager instance
|
||||||
plugin_manager: Plugin manager instance
|
plugin_manager: Plugin manager instance
|
||||||
install_deps: Whether to install dependencies
|
install_deps: Whether to install dependencies
|
||||||
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (plugin_instance, module)
|
Tuple of (plugin_instance, module)
|
||||||
@@ -609,12 +524,7 @@ class PluginLoader:
|
|||||||
"""
|
"""
|
||||||
# Install dependencies if needed
|
# Install dependencies if needed
|
||||||
if install_deps:
|
if install_deps:
|
||||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
self.install_dependencies(plugin_dir, plugin_id)
|
||||||
raise PluginError(
|
|
||||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
context={'plugin_dir': str(plugin_dir)},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load module
|
# Load module
|
||||||
entry_point = manifest.get('entry_point', 'manager.py')
|
entry_point = manifest.get('entry_point', 'manager.py')
|
||||||
|
|||||||
@@ -350,8 +350,7 @@ class PluginManager:
|
|||||||
display_manager=self.display_manager,
|
display_manager=self.display_manager,
|
||||||
cache_manager=self.cache_manager,
|
cache_manager=self.cache_manager,
|
||||||
plugin_manager=self,
|
plugin_manager=self,
|
||||||
install_deps=True,
|
install_deps=True
|
||||||
plugins_dir=self.plugins_dir,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store module
|
# Store module
|
||||||
|
|||||||
@@ -185,19 +185,13 @@ class StateReconciliation:
|
|||||||
message=f"Reconciliation failed: {str(e)}"
|
message=f"Reconciliation failed: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Top-level config keys that are NOT plugins.
|
# Top-level config keys that are NOT plugins
|
||||||
# Includes both config.json structural keys and config_secrets.json top-level
|
|
||||||
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
|
|
||||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
_SYSTEM_CONFIG_KEYS = frozenset({
|
||||||
'web_display_autostart', 'timezone', 'location', 'display',
|
'web_display_autostart', 'timezone', 'location', 'display',
|
||||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||||
'dim_schedule', 'network', 'system', 'schedule',
|
'dim_schedule', 'network', 'system', 'schedule',
|
||||||
# Multi-display sync config (config.json structural key)
|
|
||||||
'sync',
|
|
||||||
# Secrets file top-level keys (merged in by load_config)
|
|
||||||
'github', 'youtube',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
@@ -347,8 +341,8 @@ class StateReconciliation:
|
|||||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||||
fix_action=FixAction.AUTO_FIX,
|
fix_action=FixAction.AUTO_FIX,
|
||||||
current_state={'enabled': state_mgr_enabled},
|
current_state={'enabled': config_enabled},
|
||||||
expected_state={'enabled': config_enabled},
|
expected_state={'enabled': state_mgr_enabled},
|
||||||
can_auto_fix=True
|
can_auto_fix=True
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -371,23 +365,15 @@ class StateReconciliation:
|
|||||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||||
|
|
||||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||||
# config.json is the user-editable source of truth for enabled state.
|
# Sync enabled state from state manager to config
|
||||||
# Bring the state manager in sync with config rather than the reverse,
|
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||||
# so that manual config edits (or the state left behind after an
|
config = self.config_manager.load_config()
|
||||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
if inconsistency.plugin_id not in config:
|
||||||
config_enabled = inconsistency.expected_state.get('enabled')
|
config[inconsistency.plugin_id] = {}
|
||||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
config[inconsistency.plugin_id]['enabled'] = expected_enabled
|
||||||
if success:
|
self.config_manager.save_config(config)
|
||||||
self.logger.info(
|
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
|
||||||
f"Fixed: Synced state manager enabled={config_enabled} for "
|
return True
|
||||||
f"{inconsistency.plugin_id} to match config"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Failed to sync state manager enabled={config_enabled} for "
|
|
||||||
f"{inconsistency.plugin_id}"
|
|
||||||
)
|
|
||||||
return success
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ Handles plugin discovery, installation, updates, and uninstallation
|
|||||||
from both the official registry and custom GitHub repositories.
|
from both the official registry and custom GitHub repositories.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
|
||||||
import zipfile
|
import zipfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import requests
|
import requests
|
||||||
@@ -22,8 +20,6 @@ from pathlib import Path
|
|||||||
from typing import List, Dict, Optional, Any, Tuple
|
from typing import List, Dict, Optional, Any, Tuple
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from src.common.permission_utils import sudo_remove_directory
|
from src.common.permission_utils import sudo_remove_directory
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -104,10 +100,6 @@ class PluginStoreManager:
|
|||||||
# handlers. Bumping the cached-entry timestamp on failure serves
|
# handlers. Bumping the cached-entry timestamp on failure serves
|
||||||
# the stale payload cheaply until the backoff expires.
|
# the stale payload cheaply until the backoff expires.
|
||||||
self._failure_backoff_seconds = 60
|
self._failure_backoff_seconds = 60
|
||||||
# Prevents concurrent callers from each firing a network request when
|
|
||||||
# the registry cache expires. Only one thread fetches; others wait and
|
|
||||||
# then get the result from the warm cache (double-checked locking).
|
|
||||||
self._registry_fetch_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Ensure plugins directory exists
|
# Ensure plugins directory exists
|
||||||
self.plugins_dir.mkdir(exist_ok=True)
|
self.plugins_dir.mkdir(exist_ok=True)
|
||||||
@@ -359,8 +351,7 @@ class PluginStoreManager:
|
|||||||
# Extract owner/repo from URL
|
# Extract owner/repo from URL
|
||||||
try:
|
try:
|
||||||
# Handle different URL formats
|
# Handle different URL formats
|
||||||
_parsed_url = urlparse(repo_url)
|
if 'github.com' in repo_url:
|
||||||
if _parsed_url.hostname in ('github.com', 'www.github.com'):
|
|
||||||
parts = repo_url.strip('/').split('/')
|
parts = repo_url.strip('/').split('/')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
owner = parts[-2]
|
owner = parts[-2]
|
||||||
@@ -524,8 +515,7 @@ class PluginStoreManager:
|
|||||||
registry_urls = []
|
registry_urls = []
|
||||||
|
|
||||||
# Extract owner/repo from URL
|
# Extract owner/repo from URL
|
||||||
_parsed_repo_url = urlparse(repo_url)
|
if 'github.com' in repo_url:
|
||||||
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
|
|
||||||
parts = repo_url.split('/')
|
parts = repo_url.split('/')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
owner = parts[-2]
|
owner = parts[-2]
|
||||||
@@ -585,15 +575,6 @@ class PluginStoreManager:
|
|||||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
|
|
||||||
with self._registry_fetch_lock:
|
|
||||||
# Re-check inside the lock — a concurrent caller that was waiting
|
|
||||||
# may have already populated the cache while we blocked.
|
|
||||||
current_time = time.time()
|
|
||||||
if (self.registry_cache and self.registry_cache_time and
|
|
||||||
not force_refresh and
|
|
||||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
|
||||||
return self.registry_cache
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||||
@@ -780,8 +761,7 @@ class PluginStoreManager:
|
|||||||
try:
|
try:
|
||||||
# Convert repo URL to raw content URL
|
# Convert repo URL to raw content URL
|
||||||
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
|
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
|
||||||
_parsed_manifest_url = urlparse(repo_url)
|
if 'github.com' in repo_url:
|
||||||
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
|
|
||||||
# Handle different URL formats
|
# Handle different URL formats
|
||||||
repo_url = repo_url.rstrip('/')
|
repo_url = repo_url.rstrip('/')
|
||||||
if repo_url.endswith('.git'):
|
if repo_url.endswith('.git'):
|
||||||
@@ -1756,12 +1736,6 @@ class PluginStoreManager:
|
|||||||
timeout=300
|
timeout=300
|
||||||
)
|
)
|
||||||
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
||||||
# Write hash marker so plugin_loader skips redundant pip run on next startup
|
|
||||||
try:
|
|
||||||
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
|
|
||||||
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
|
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
|
|||||||
@@ -151,18 +151,6 @@ class WiFiManager:
|
|||||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||||
|
|
||||||
# Once per process: remove a stale force-AP flag left by a prior crash.
|
|
||||||
# Guard with a class-level flag so the nmcli AP-state check only runs
|
|
||||||
# once even though WiFiManager is instantiated per-request.
|
|
||||||
if not WiFiManager._startup_cleanup_done:
|
|
||||||
WiFiManager._startup_cleanup_done = True
|
|
||||||
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
|
|
||||||
try:
|
|
||||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
|
||||||
logger.debug("Removed stale force-AP flag on startup (AP not active)")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(f"Could not remove stale force-AP flag: {exc}")
|
|
||||||
|
|
||||||
def _show_led_message(self, message: str, duration: int = 5):
|
def _show_led_message(self, message: str, duration: int = 5):
|
||||||
"""
|
"""
|
||||||
Show a WiFi status message on the LED display.
|
Show a WiFi status message on the LED display.
|
||||||
@@ -486,10 +474,7 @@ class WiFiManager:
|
|||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
if '/' in line:
|
if '/' in line:
|
||||||
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
|
ip_address = line.split('/')[0].strip()
|
||||||
# bare "x.x.x.x/prefix" is also accepted defensively.
|
|
||||||
_, sep, rest = line.partition(':')
|
|
||||||
ip_address = (rest if sep else line).split('/')[0].strip()
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# Final fallback: Get signal strength by matching SSID in WiFi list
|
# Final fallback: Get signal strength by matching SSID in WiFi list
|
||||||
@@ -515,13 +500,6 @@ class WiFiManager:
|
|||||||
|
|
||||||
# Check if AP mode is active
|
# Check if AP mode is active
|
||||||
ap_active = self._is_ap_mode_active()
|
ap_active = self._is_ap_mode_active()
|
||||||
# wlan0 shows as "connected" in AP mode; clear client-station fields so
|
|
||||||
# callers don't mistake the AP for an outbound WiFi connection.
|
|
||||||
if ap_active and wifi_connected:
|
|
||||||
wifi_connected = False
|
|
||||||
ssid = None
|
|
||||||
ip_address = None
|
|
||||||
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
|
|
||||||
|
|
||||||
return WiFiStatus(
|
return WiFiStatus(
|
||||||
connected=wifi_connected,
|
connected=wifi_connected,
|
||||||
@@ -712,10 +690,6 @@ class WiFiManager:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
|
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
|
||||||
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
|
|
||||||
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
|
|
||||||
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
|
|
||||||
_startup_cleanup_done: bool = False
|
|
||||||
|
|
||||||
def _validate_ap_config(self) -> Tuple[str, int]:
|
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||||
@@ -1393,7 +1367,7 @@ class WiFiManager:
|
|||||||
logger.error(f"Failed to restore original connection: {original_ssid}")
|
logger.error(f"Failed to restore original connection: {original_ssid}")
|
||||||
# Trigger AP mode as last resort
|
# Trigger AP mode as last resort
|
||||||
self._show_led_message("Enabling AP mode...", duration=5)
|
self._show_led_message("Enabling AP mode...", duration=5)
|
||||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
ap_success, ap_msg = self.enable_ap_mode()
|
||||||
if ap_success:
|
if ap_success:
|
||||||
logger.info("AP mode enabled as failsafe")
|
logger.info("AP mode enabled as failsafe")
|
||||||
return False, "Connection failed and restoration failed. AP mode enabled."
|
return False, "Connection failed and restoration failed. AP mode enabled."
|
||||||
@@ -1405,7 +1379,7 @@ class WiFiManager:
|
|||||||
elif not success:
|
elif not success:
|
||||||
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
||||||
self._show_led_message("Enabling AP mode...", duration=5)
|
self._show_led_message("Enabling AP mode...", duration=5)
|
||||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
ap_success, ap_msg = self.enable_ap_mode()
|
||||||
if ap_success:
|
if ap_success:
|
||||||
logger.info("AP mode enabled as failsafe")
|
logger.info("AP mode enabled as failsafe")
|
||||||
return False, "Connection failed. AP mode enabled."
|
return False, "Connection failed. AP mode enabled."
|
||||||
@@ -1426,7 +1400,7 @@ class WiFiManager:
|
|||||||
logger.error(f"Failed to restore after exception: {restore_error}")
|
logger.error(f"Failed to restore after exception: {restore_error}")
|
||||||
# Last resort: enable AP mode
|
# Last resort: enable AP mode
|
||||||
try:
|
try:
|
||||||
self.enable_ap_mode(force=True)
|
self.enable_ap_mode()
|
||||||
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
||||||
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
@@ -1490,27 +1464,24 @@ class WiFiManager:
|
|||||||
# Show LED message
|
# Show LED message
|
||||||
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
||||||
|
|
||||||
# Find existing NM connection for this SSID.
|
# First, check if connection already exists and try to activate it
|
||||||
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
|
# NetworkManager connection names might not match SSID exactly, so search by SSID
|
||||||
# so list all wifi connections then query each one's SSID individually.
|
check_result = subprocess.run(
|
||||||
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
|
||||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
capture_output=True,
|
||||||
capture_output=True, text=True, timeout=5
|
text=True,
|
||||||
|
timeout=5
|
||||||
)
|
)
|
||||||
|
|
||||||
existing_conn_name = None
|
existing_conn_name = None
|
||||||
if list_result.returncode == 0:
|
if check_result.returncode == 0:
|
||||||
for line in list_result.stdout.strip().split('\n'):
|
for line in check_result.stdout.strip().split('\n'):
|
||||||
if ':' not in line:
|
if ':' in line:
|
||||||
continue
|
|
||||||
parts = line.split(':')
|
parts = line.split(':')
|
||||||
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
|
if len(parts) >= 2:
|
||||||
continue
|
|
||||||
conn_name = parts[0].strip()
|
conn_name = parts[0].strip()
|
||||||
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
|
||||||
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
if conn_ssid == ssid:
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
|
||||||
existing_conn_name = conn_name
|
existing_conn_name = conn_name
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1884,7 +1855,7 @@ class WiFiManager:
|
|||||||
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
def enable_ap_mode(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Enable access point mode
|
Enable access point mode
|
||||||
|
|
||||||
@@ -1906,29 +1877,20 @@ class WiFiManager:
|
|||||||
if not self._ensure_wifi_radio_enabled():
|
if not self._ensure_wifi_radio_enabled():
|
||||||
return False, "WiFi radio is disabled and could not be enabled"
|
return False, "WiFi radio is disabled and could not be enabled"
|
||||||
|
|
||||||
# Check if WiFi is connected (skip when force=True)
|
# Check if WiFi is connected
|
||||||
status = self.get_wifi_status()
|
status = self.get_wifi_status()
|
||||||
if not force and status.connected:
|
if status.connected:
|
||||||
return False, "Cannot enable AP mode while WiFi is connected"
|
return False, "Cannot enable AP mode while WiFi is connected"
|
||||||
|
|
||||||
# Check if Ethernet is connected (skip when force=True)
|
# Check if Ethernet is connected
|
||||||
if not force and self._is_ethernet_connected():
|
if self._is_ethernet_connected():
|
||||||
return False, "Cannot enable AP mode while Ethernet is connected"
|
return False, "Cannot enable AP mode while Ethernet is connected"
|
||||||
|
|
||||||
if force:
|
|
||||||
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
|
|
||||||
|
|
||||||
# Try hostapd/dnsmasq first (captive portal mode)
|
# Try hostapd/dnsmasq first (captive portal mode)
|
||||||
if self.has_hostapd and self.has_dnsmasq:
|
if self.has_hostapd and self.has_dnsmasq:
|
||||||
result = self._enable_ap_mode_hostapd()
|
result = self._enable_ap_mode_hostapd()
|
||||||
if result[0]:
|
if result[0]:
|
||||||
self._ap_enabled_at = time.time()
|
self._ap_enabled_at = time.time()
|
||||||
if force:
|
|
||||||
try:
|
|
||||||
self._FORCE_AP_FLAG_PATH.touch()
|
|
||||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||||
@@ -1938,12 +1900,6 @@ class WiFiManager:
|
|||||||
result = self._enable_ap_mode_nmcli_hotspot()
|
result = self._enable_ap_mode_nmcli_hotspot()
|
||||||
if result[0]:
|
if result[0]:
|
||||||
self._ap_enabled_at = time.time()
|
self._ap_enabled_at = time.time()
|
||||||
if force:
|
|
||||||
try:
|
|
||||||
self._FORCE_AP_FLAG_PATH.touch()
|
|
||||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||||
@@ -2135,14 +2091,8 @@ class WiFiManager:
|
|||||||
self._clear_led_message()
|
self._clear_led_message()
|
||||||
return False, "AP started but captive-portal redirect setup failed"
|
return False, "AP started but captive-portal redirect setup failed"
|
||||||
|
|
||||||
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
|
# Verify the AP is actually running
|
||||||
status = {}
|
|
||||||
for _attempt in range(5):
|
|
||||||
status = self._get_ap_status_nmcli()
|
status = self._get_ap_status_nmcli()
|
||||||
if status.get('active'):
|
|
||||||
break
|
|
||||||
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
|
|
||||||
time.sleep(2)
|
|
||||||
if status.get('active'):
|
if status.get('active'):
|
||||||
ip = status.get('ip', '192.168.4.1')
|
ip = status.get('ip', '192.168.4.1')
|
||||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||||
@@ -2340,7 +2290,6 @@ class WiFiManager:
|
|||||||
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||||
|
|
||||||
self._ap_enabled_at = None
|
self._ap_enabled_at = None
|
||||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
|
||||||
logger.info("AP mode disabled successfully")
|
logger.info("AP mode disabled successfully")
|
||||||
return True, "AP mode disabled"
|
return True, "AP mode disabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2529,29 +2478,22 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to enable AP mode: {message}")
|
logger.warning(f"Failed to enable AP mode: {message}")
|
||||||
elif not should_have_ap and ap_active:
|
elif not should_have_ap and ap_active:
|
||||||
# Should not have AP but do - check if it was manually force-enabled
|
# Should not have AP but do - disable AP mode
|
||||||
force_active = self._FORCE_AP_FLAG_PATH.exists()
|
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
|
||||||
|
if status.connected or ethernet_connected:
|
||||||
|
success, message = self.disable_ap_mode()
|
||||||
|
if success:
|
||||||
if status.connected:
|
if status.connected:
|
||||||
# WiFi connected: always disable AP (user successfully configured WiFi)
|
|
||||||
success, message = self.disable_ap_mode()
|
|
||||||
if success:
|
|
||||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||||
self._disconnected_checks = 0
|
elif ethernet_connected:
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
|
||||||
elif ethernet_connected and not force_active:
|
|
||||||
# Ethernet connected, AP not manually forced: auto-disable
|
|
||||||
success, message = self.disable_ap_mode()
|
|
||||||
if success:
|
|
||||||
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
||||||
self._disconnected_checks = 0
|
self._disconnected_checks = 0 # Reset counter
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
||||||
elif ethernet_connected and force_active:
|
|
||||||
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
|
|
||||||
elif not auto_enable:
|
elif not auto_enable:
|
||||||
|
# AP is active but auto_enable is disabled - this means it was manually enabled
|
||||||
|
# Don't disable it automatically, let it stay active
|
||||||
logger.debug("AP mode is active (manually enabled), keeping active")
|
logger.debug("AP mode is active (manually enabled), keeping active")
|
||||||
|
|
||||||
# Idle-timeout check: disable AP if no client has connected within the window.
|
# Idle-timeout check: disable AP if no client has connected within the window.
|
||||||
|
|||||||
@@ -1,342 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/base_classes/api_extractors.py
|
|
||||||
|
|
||||||
Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor,
|
|
||||||
SoccerAPIExtractor, and the shared _extract_common_details logic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import pytest
|
|
||||||
from src.base_classes.api_extractors import (
|
|
||||||
ESPNFootballExtractor,
|
|
||||||
ESPNBaseballExtractor,
|
|
||||||
ESPNHockeyExtractor,
|
|
||||||
SoccerAPIExtractor,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared test data factories
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF",
|
|
||||||
home_score: str = "14", away_score: str = "7",
|
|
||||||
date_str: str = "2024-01-15T20:00:00Z",
|
|
||||||
include_situation: bool = False,
|
|
||||||
situation: dict | None = None,
|
|
||||||
status_detail: str = "2nd Qtr 8:42",
|
|
||||||
period: int = 2) -> dict:
|
|
||||||
"""Build a minimal ESPN-style game event dict."""
|
|
||||||
comp_status = {
|
|
||||||
"type": {
|
|
||||||
"state": state,
|
|
||||||
"shortDetail": status_detail,
|
|
||||||
"detail": status_detail,
|
|
||||||
"name": "STATUS_IN_PROGRESS",
|
|
||||||
},
|
|
||||||
"period": period,
|
|
||||||
"displayClock": "8:42",
|
|
||||||
}
|
|
||||||
comp = {
|
|
||||||
"status": comp_status,
|
|
||||||
"competitors": [
|
|
||||||
{
|
|
||||||
"homeAway": "home",
|
|
||||||
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
|
|
||||||
"score": home_score,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"homeAway": "away",
|
|
||||||
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
|
|
||||||
"score": away_score,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if include_situation:
|
|
||||||
comp["situation"] = situation or {}
|
|
||||||
return {
|
|
||||||
"id": "test-game-1",
|
|
||||||
"date": date_str,
|
|
||||||
"competitions": [comp],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_logger() -> logging.Logger:
|
|
||||||
return logging.getLogger("test_extractor")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNFootballExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNFootballExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = ESPNFootballExtractor(_make_logger())
|
|
||||||
|
|
||||||
def test_extract_live_game_basic_fields(self):
|
|
||||||
event = _make_espn_event(state="in", home_score="14", away_score="7")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["home_abbr"] == "KC"
|
|
||||||
assert result["away_abbr"] == "BUF"
|
|
||||||
assert result["home_score"] == "14"
|
|
||||||
assert result["away_score"] == "7"
|
|
||||||
assert result["is_live"] is True
|
|
||||||
assert result["is_final"] is False
|
|
||||||
assert result["is_upcoming"] is False
|
|
||||||
|
|
||||||
def test_extract_final_game(self):
|
|
||||||
event = _make_espn_event(state="post")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_final"] is True
|
|
||||||
assert result["is_live"] is False
|
|
||||||
|
|
||||||
def test_extract_upcoming_game(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_upcoming"] is True
|
|
||||||
|
|
||||||
def test_sport_specific_fields_default_when_pregame(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "down" in fields
|
|
||||||
assert "distance" in fields
|
|
||||||
assert "possession" in fields
|
|
||||||
assert "is_redzone" in fields
|
|
||||||
assert fields["is_redzone"] is False
|
|
||||||
|
|
||||||
def test_sport_specific_fields_live_with_situation(self):
|
|
||||||
situation = {
|
|
||||||
"down": 3,
|
|
||||||
"distance": 7,
|
|
||||||
"possession": "KC",
|
|
||||||
"isRedZone": True,
|
|
||||||
"homeTimeouts": 2,
|
|
||||||
"awayTimeouts": 1,
|
|
||||||
}
|
|
||||||
event = _make_espn_event(state="in", include_situation=True, situation=situation)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["down"] == 3
|
|
||||||
assert fields["distance"] == 7
|
|
||||||
assert fields["is_redzone"] is True
|
|
||||||
assert fields["home_timeouts"] == 2
|
|
||||||
assert fields["away_timeouts"] == 1
|
|
||||||
|
|
||||||
def test_scoring_event_detected(self):
|
|
||||||
# situation must be non-empty (truthy) for the live block to execute
|
|
||||||
situation = {"down": 1, "distance": 10}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in",
|
|
||||||
include_situation=True,
|
|
||||||
situation=situation,
|
|
||||||
status_detail="touchdown scored",
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "touchdown" in fields.get("scoring_event", "").lower()
|
|
||||||
|
|
||||||
def test_returns_none_on_empty_event(self):
|
|
||||||
assert self.extractor.extract_game_details({}) is None
|
|
||||||
|
|
||||||
def test_returns_none_when_teams_missing(self):
|
|
||||||
event = {
|
|
||||||
"id": "x",
|
|
||||||
"date": "2024-01-15T20:00:00Z",
|
|
||||||
"competitions": [
|
|
||||||
{
|
|
||||||
"status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}},
|
|
||||||
"competitors": [], # no competitors
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
assert self.extractor.extract_game_details(event) is None
|
|
||||||
|
|
||||||
def test_date_z_suffix_parsed(self):
|
|
||||||
event = _make_espn_event(date_str="2024-01-15T20:00:00Z")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
# Should not raise and should return a result
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
def test_id_propagated(self):
|
|
||||||
event = _make_espn_event()
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result["id"] == "test-game-1"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNBaseballExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNBaseballExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = ESPNBaseballExtractor(_make_logger())
|
|
||||||
|
|
||||||
def test_extract_live_game(self):
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", home_abbr="NYY", away_abbr="BOS",
|
|
||||||
home_score="3", away_score="2"
|
|
||||||
)
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["home_abbr"] == "NYY"
|
|
||||||
assert result["is_live"] is True
|
|
||||||
|
|
||||||
def test_baseball_sport_fields_defaults(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "inning" in fields
|
|
||||||
assert "outs" in fields
|
|
||||||
assert "bases" in fields
|
|
||||||
assert "strikes" in fields
|
|
||||||
assert "balls" in fields
|
|
||||||
|
|
||||||
def test_baseball_sport_fields_live(self):
|
|
||||||
situation = {
|
|
||||||
"inning": 7,
|
|
||||||
"outs": 2,
|
|
||||||
"bases": "110",
|
|
||||||
"strikes": 2,
|
|
||||||
"balls": 3,
|
|
||||||
"pitcher": "Smith",
|
|
||||||
"batter": "Jones",
|
|
||||||
}
|
|
||||||
event = _make_espn_event(state="in", include_situation=True, situation=situation)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["inning"] == 7
|
|
||||||
assert fields["outs"] == 2
|
|
||||||
assert fields["strikes"] == 2
|
|
||||||
assert fields["pitcher"] == "Smith"
|
|
||||||
|
|
||||||
def test_returns_none_on_empty(self):
|
|
||||||
assert self.extractor.extract_game_details({}) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNHockeyExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNHockeyExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = ESPNHockeyExtractor(_make_logger())
|
|
||||||
|
|
||||||
def test_extract_live_game(self):
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", home_abbr="BOS", away_abbr="TOR",
|
|
||||||
home_score="2", away_score="1"
|
|
||||||
)
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_live"] is True
|
|
||||||
|
|
||||||
def test_hockey_period_text_p1(self):
|
|
||||||
situation = {"isPowerPlay": False}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=1
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "P1"
|
|
||||||
|
|
||||||
def test_hockey_period_text_p2(self):
|
|
||||||
situation = {"isPowerPlay": False} # non-empty so the live block executes
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=2
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "P2"
|
|
||||||
|
|
||||||
def test_hockey_period_text_p3(self):
|
|
||||||
situation = {"isPowerPlay": False}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=3
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "P3"
|
|
||||||
|
|
||||||
def test_hockey_period_text_ot(self):
|
|
||||||
situation = {"isPowerPlay": False}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=4
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "OT1"
|
|
||||||
|
|
||||||
def test_hockey_power_play(self):
|
|
||||||
situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8}
|
|
||||||
event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["power_play"] is True
|
|
||||||
assert fields["shots_on_goal"]["home"] == 12
|
|
||||||
assert fields["shots_on_goal"]["away"] == 8
|
|
||||||
|
|
||||||
def test_hockey_fields_defaults_pregame(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "period" in fields
|
|
||||||
assert "power_play" in fields
|
|
||||||
assert fields["power_play"] is False
|
|
||||||
|
|
||||||
def test_returns_none_on_empty(self):
|
|
||||||
assert self.extractor.extract_game_details({}) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SoccerAPIExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSoccerAPIExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = SoccerAPIExtractor(_make_logger())
|
|
||||||
|
|
||||||
def _make_soccer_event(self, is_live: bool = True) -> dict:
|
|
||||||
return {
|
|
||||||
"id": "soccer-1",
|
|
||||||
"home_team": {"abbreviation": "ARS", "name": "Arsenal"},
|
|
||||||
"away_team": {"abbreviation": "CHE", "name": "Chelsea"},
|
|
||||||
"home_score": "2",
|
|
||||||
"away_score": "1",
|
|
||||||
"status": "LIVE",
|
|
||||||
"is_live": is_live,
|
|
||||||
"is_final": not is_live,
|
|
||||||
"is_upcoming": False,
|
|
||||||
"half": "1",
|
|
||||||
"stoppage_time": "2",
|
|
||||||
"home_yellow_cards": 1,
|
|
||||||
"away_yellow_cards": 2,
|
|
||||||
"home_red_cards": 0,
|
|
||||||
"away_red_cards": 0,
|
|
||||||
"home_possession": 55,
|
|
||||||
"away_possession": 45,
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_extract_live_game(self):
|
|
||||||
event = self._make_soccer_event(is_live=True)
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["home_abbr"] == "ARS"
|
|
||||||
assert result["away_abbr"] == "CHE"
|
|
||||||
assert result["is_live"] is True
|
|
||||||
|
|
||||||
def test_sport_specific_cards(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["cards"]["home_yellow"] == 1
|
|
||||||
assert fields["cards"]["away_yellow"] == 2
|
|
||||||
assert fields["cards"]["home_red"] == 0
|
|
||||||
|
|
||||||
def test_sport_specific_possession(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["possession"]["home"] == 55
|
|
||||||
assert fields["possession"]["away"] == 45
|
|
||||||
|
|
||||||
def test_sport_specific_half(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["half"] == "1"
|
|
||||||
|
|
||||||
def test_scores_as_strings(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result["home_score"] == "2"
|
|
||||||
assert result["away_score"] == "1"
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/background_data_service.py
|
|
||||||
|
|
||||||
Covers BackgroundDataService: submit_fetch_request, get_result,
|
|
||||||
is_request_complete, get_request_status, cancel_request, get_statistics,
|
|
||||||
_cleanup_completed_requests, shutdown, and get_background_service singleton.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch, Mock
|
|
||||||
from concurrent.futures import Future
|
|
||||||
|
|
||||||
from src.background_data_service import (
|
|
||||||
BackgroundDataService,
|
|
||||||
FetchStatus,
|
|
||||||
FetchResult,
|
|
||||||
FetchRequest,
|
|
||||||
get_background_service,
|
|
||||||
shutdown_background_service,
|
|
||||||
)
|
|
||||||
import src.background_data_service as bds_module
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def reset_global_service():
|
|
||||||
"""Ensure each test starts with no global singleton."""
|
|
||||||
shutdown_background_service()
|
|
||||||
yield
|
|
||||||
shutdown_background_service()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_cache_manager():
|
|
||||||
m = MagicMock()
|
|
||||||
m.get.return_value = None
|
|
||||||
m.set.return_value = None
|
|
||||||
m.generate_sport_cache_key.return_value = "test_key"
|
|
||||||
return m
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def service(mock_cache_manager):
|
|
||||||
svc = BackgroundDataService(mock_cache_manager, max_workers=2, request_timeout=5)
|
|
||||||
yield svc
|
|
||||||
svc.shutdown(wait=False)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Initialisation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestInitialisation:
|
|
||||||
def test_stats_zeroed(self, service):
|
|
||||||
stats = service.get_statistics()
|
|
||||||
assert stats["total_requests"] == 0
|
|
||||||
assert stats["completed_requests"] == 0
|
|
||||||
assert stats["failed_requests"] == 0
|
|
||||||
|
|
||||||
def test_no_active_requests(self, service):
|
|
||||||
assert len(service.active_requests) == 0
|
|
||||||
|
|
||||||
def test_not_shutdown(self, service):
|
|
||||||
assert service._shutdown is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cache hit path
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCacheHit:
|
|
||||||
def test_cache_hit_returns_request_id(self, service, mock_cache_manager):
|
|
||||||
mock_cache_manager.get.return_value = {"events": [{"id": "1"}]}
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024,
|
|
||||||
url="https://example.com/nfl",
|
|
||||||
cache_key="nfl_key",
|
|
||||||
)
|
|
||||||
assert req_id is not None
|
|
||||||
# Request should be immediately complete due to cache hit
|
|
||||||
result = service.get_result(req_id)
|
|
||||||
assert result is not None
|
|
||||||
assert result.success is True
|
|
||||||
assert result.cached is True
|
|
||||||
|
|
||||||
def test_cache_hit_increments_stat(self, service, mock_cache_manager):
|
|
||||||
mock_cache_manager.get.return_value = {"events": []}
|
|
||||||
service.submit_fetch_request(sport="nba", year=2024, url="https://x.com", cache_key="k")
|
|
||||||
stats = service.get_statistics()
|
|
||||||
assert stats["cached_hits"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Actual fetch path (mocked HTTP)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFetchPath:
|
|
||||||
def _valid_payload(self) -> dict:
|
|
||||||
return {"events": [{"id": "g1"}, {"id": "g2"}]}
|
|
||||||
|
|
||||||
def test_successful_fetch_completes(self, service, mock_cache_manager):
|
|
||||||
mock_resp = Mock()
|
|
||||||
mock_resp.json.return_value = self._valid_payload()
|
|
||||||
mock_resp.raise_for_status.return_value = None
|
|
||||||
|
|
||||||
with patch.object(service.session, "get", return_value=mock_resp):
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024,
|
|
||||||
url="https://example.com/nfl",
|
|
||||||
cache_key="nfl_test",
|
|
||||||
)
|
|
||||||
# Wait for the background thread
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
result = service.get_result(req_id)
|
|
||||||
assert result is not None
|
|
||||||
assert result.success is True
|
|
||||||
assert result.data == self._valid_payload()
|
|
||||||
|
|
||||||
def test_failed_fetch_records_error(self, service, mock_cache_manager):
|
|
||||||
with patch.object(service.session, "get", side_effect=Exception("network error")):
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nba", year=2024,
|
|
||||||
url="https://example.com/nba",
|
|
||||||
cache_key="nba_test",
|
|
||||||
max_retries=0,
|
|
||||||
)
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
result = service.get_result(req_id)
|
|
||||||
assert result is not None
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
|
|
||||||
def test_cache_miss_increments_stat(self, service, mock_cache_manager):
|
|
||||||
mock_resp = Mock()
|
|
||||||
mock_resp.json.return_value = self._valid_payload()
|
|
||||||
mock_resp.raise_for_status.return_value = None
|
|
||||||
|
|
||||||
with patch.object(service.session, "get", return_value=mock_resp):
|
|
||||||
service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024, url="https://x.com", cache_key="new_key",
|
|
||||||
)
|
|
||||||
stats = service.get_statistics()
|
|
||||||
assert stats["cache_misses"] == 1
|
|
||||||
|
|
||||||
def test_callback_called_on_success(self, service, mock_cache_manager):
|
|
||||||
callback = Mock()
|
|
||||||
mock_resp = Mock()
|
|
||||||
mock_resp.json.return_value = self._valid_payload()
|
|
||||||
mock_resp.raise_for_status.return_value = None
|
|
||||||
|
|
||||||
with patch.object(service.session, "get", return_value=mock_resp):
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024, url="https://x.com",
|
|
||||||
cache_key="cb_key", callback=callback, max_retries=0,
|
|
||||||
)
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
callback.assert_called_once()
|
|
||||||
call_arg = callback.call_args[0][0]
|
|
||||||
assert isinstance(call_arg, FetchResult)
|
|
||||||
|
|
||||||
def test_data_cached_after_successful_fetch(self, service, mock_cache_manager):
|
|
||||||
mock_resp = Mock()
|
|
||||||
mock_resp.json.return_value = self._valid_payload()
|
|
||||||
mock_resp.raise_for_status.return_value = None
|
|
||||||
|
|
||||||
with patch.object(service.session, "get", return_value=mock_resp):
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024, url="https://x.com", cache_key="cache_after_key",
|
|
||||||
)
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
mock_cache_manager.set.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Request status / cancel
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestRequestStatusAndCancel:
|
|
||||||
def test_unknown_request_status_is_none(self, service):
|
|
||||||
assert service.get_request_status("nonexistent") is None
|
|
||||||
|
|
||||||
def test_cancel_active_request(self, service, mock_cache_manager):
|
|
||||||
# Manually insert an active request
|
|
||||||
req = FetchRequest(
|
|
||||||
id="r1", sport="nfl", year=2024,
|
|
||||||
cache_key="k", url="https://x.com",
|
|
||||||
)
|
|
||||||
req.status = FetchStatus.PENDING
|
|
||||||
service.active_requests["r1"] = req
|
|
||||||
result = service.cancel_request("r1")
|
|
||||||
assert result is True
|
|
||||||
assert "r1" not in service.active_requests
|
|
||||||
|
|
||||||
def test_cancel_nonexistent_request(self, service):
|
|
||||||
assert service.cancel_request("does-not-exist") is False
|
|
||||||
|
|
||||||
def test_is_request_complete_false_for_active(self, service, mock_cache_manager):
|
|
||||||
req = FetchRequest(
|
|
||||||
id="r2", sport="mlb", year=2024,
|
|
||||||
cache_key="k2", url="https://x.com",
|
|
||||||
)
|
|
||||||
service.active_requests["r2"] = req
|
|
||||||
assert service.is_request_complete("r2") is False
|
|
||||||
|
|
||||||
def test_is_request_complete_true_for_done(self, service):
|
|
||||||
result = FetchResult(request_id="r3", success=True)
|
|
||||||
service.completed_requests["r3"] = result
|
|
||||||
assert service.is_request_complete("r3") is True
|
|
||||||
|
|
||||||
def test_get_result_returns_none_for_unknown(self, service):
|
|
||||||
assert service.get_result("unknown") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shutdown
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestShutdown:
|
|
||||||
def test_shutdown_sets_flag(self, service):
|
|
||||||
service.shutdown(wait=False)
|
|
||||||
assert service._shutdown is True
|
|
||||||
|
|
||||||
def test_submit_after_shutdown_raises(self, service, mock_cache_manager):
|
|
||||||
service.shutdown(wait=False)
|
|
||||||
with pytest.raises(RuntimeError, match="shutting down"):
|
|
||||||
service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024, url="https://x.com", cache_key="k"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cleanup
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCleanup:
|
|
||||||
def test_cleanup_removes_old_requests(self, service):
|
|
||||||
old_result = FetchResult(request_id="old", success=True)
|
|
||||||
old_result.completed_at = time.time() - 7200 # 2 hours ago
|
|
||||||
service.completed_requests["old"] = old_result
|
|
||||||
service._last_completed_requests_cleanup = 0 # force cleanup
|
|
||||||
removed = service._cleanup_completed_requests(force=True)
|
|
||||||
assert removed >= 1
|
|
||||||
assert "old" not in service.completed_requests
|
|
||||||
|
|
||||||
def test_cleanup_respects_interval(self, service):
|
|
||||||
old_result = FetchResult(request_id="r", success=True)
|
|
||||||
old_result.completed_at = time.time() - 7200
|
|
||||||
service.completed_requests["r"] = old_result
|
|
||||||
# Cleanup interval not passed, should skip
|
|
||||||
service._last_completed_requests_cleanup = time.time()
|
|
||||||
removed = service._cleanup_completed_requests(force=False)
|
|
||||||
assert removed == 0
|
|
||||||
|
|
||||||
def test_size_limit_enforcement(self, service):
|
|
||||||
service._max_completed_requests = 3
|
|
||||||
for i in range(5):
|
|
||||||
result = FetchResult(request_id=str(i), success=True)
|
|
||||||
result.completed_at = time.time() - (5 - i) * 100 # oldest first
|
|
||||||
service.completed_requests[str(i)] = result
|
|
||||||
service._last_completed_requests_cleanup = 0
|
|
||||||
service._cleanup_completed_requests(force=True)
|
|
||||||
assert len(service.completed_requests) <= 3
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Singleton get_background_service
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetBackgroundService:
|
|
||||||
def test_first_call_requires_cache_manager(self):
|
|
||||||
with pytest.raises(ValueError, match="cache_manager is required"):
|
|
||||||
get_background_service()
|
|
||||||
|
|
||||||
def test_creates_singleton(self, mock_cache_manager):
|
|
||||||
svc1 = get_background_service(mock_cache_manager)
|
|
||||||
svc2 = get_background_service()
|
|
||||||
assert svc1 is svc2
|
|
||||||
|
|
||||||
def test_shutdown_clears_singleton(self, mock_cache_manager):
|
|
||||||
get_background_service(mock_cache_manager)
|
|
||||||
shutdown_background_service()
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
get_background_service()
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/base_classes/data_sources.py
|
|
||||||
|
|
||||||
Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource.
|
|
||||||
All HTTP calls are mocked to avoid network access.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, date
|
|
||||||
from unittest.mock import MagicMock, patch, Mock
|
|
||||||
import pytest
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource
|
|
||||||
|
|
||||||
|
|
||||||
def _make_logger() -> logging.Logger:
|
|
||||||
return logging.getLogger("test_data_sources")
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_response(json_data: dict, status_code: int = 200):
|
|
||||||
resp = Mock(spec=requests.Response)
|
|
||||||
resp.status_code = status_code
|
|
||||||
resp.json.return_value = json_data
|
|
||||||
resp.raise_for_status = Mock()
|
|
||||||
if status_code >= 400:
|
|
||||||
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNDataSource
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNDataSource:
|
|
||||||
def setup_method(self):
|
|
||||||
self.source = ESPNDataSource(_make_logger())
|
|
||||||
|
|
||||||
def test_get_headers(self):
|
|
||||||
headers = self.source.get_headers()
|
|
||||||
assert headers["Accept"] == "application/json"
|
|
||||||
assert "LEDMatrix" in headers["User-Agent"]
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_live_events(self):
|
|
||||||
live_event = {
|
|
||||||
"competitions": [{"status": {"type": {"state": "in"}}}]
|
|
||||||
}
|
|
||||||
non_live_event = {
|
|
||||||
"competitions": [{"status": {"type": {"state": "pre"}}}]
|
|
||||||
}
|
|
||||||
payload = {"events": [live_event, non_live_event]}
|
|
||||||
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("football", "nfl")
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0] is live_event
|
|
||||||
|
|
||||||
def test_fetch_live_games_empty_when_none_live(self):
|
|
||||||
payload = {"events": [
|
|
||||||
{"competitions": [{"status": {"type": {"state": "post"}}}]}
|
|
||||||
]}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("football", "nfl")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("network failure")):
|
|
||||||
result = self.source.fetch_live_games("football", "nfl")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_all_events(self):
|
|
||||||
events = [{"id": "1"}, {"id": "2"}]
|
|
||||||
payload = {"events": events}
|
|
||||||
start = datetime(2024, 1, 1)
|
|
||||||
end = datetime(2024, 1, 7)
|
|
||||||
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_schedule("football", "nfl", (start, end))
|
|
||||||
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("timeout")):
|
|
||||||
result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now()))
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_standings_success(self):
|
|
||||||
payload = {"standings": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_standings("football", "nfl")
|
|
||||||
assert result == payload
|
|
||||||
|
|
||||||
def test_fetch_standings_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("error")):
|
|
||||||
result = self.source.fetch_standings("football", "nfl")
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
def test_base_url_set_correctly(self):
|
|
||||||
assert "espn.com" in self.source.base_url
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MLBAPIDataSource
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestMLBAPIDataSource:
|
|
||||||
def setup_method(self):
|
|
||||||
self.source = MLBAPIDataSource(_make_logger())
|
|
||||||
|
|
||||||
def test_fetch_live_games_filters_live(self):
|
|
||||||
live_game = {"status": {"abstractGameState": "Live"}}
|
|
||||||
final_game = {"status": {"abstractGameState": "Final"}}
|
|
||||||
payload = {"dates": [{"games": [live_game, final_game]}]}
|
|
||||||
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("baseball", "mlb")
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0] is live_game
|
|
||||||
|
|
||||||
def test_fetch_live_games_empty_dates(self):
|
|
||||||
payload = {"dates": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("baseball", "mlb")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_live_games("baseball", "mlb")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_schedule_aggregates_all_dates(self):
|
|
||||||
payload = {
|
|
||||||
"dates": [
|
|
||||||
{"games": [{"id": "1"}, {"id": "2"}]},
|
|
||||||
{"games": [{"id": "3"}]},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
|
|
||||||
assert len(result) == 3
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_standings_success(self):
|
|
||||||
payload = {"records": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_standings("baseball", "mlb")
|
|
||||||
assert result == payload
|
|
||||||
|
|
||||||
def test_fetch_standings_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_standings("baseball", "mlb")
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SoccerAPIDataSource
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSoccerAPIDataSource:
|
|
||||||
def setup_method(self):
|
|
||||||
self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123")
|
|
||||||
|
|
||||||
def test_headers_include_api_key(self):
|
|
||||||
headers = self.source.get_headers()
|
|
||||||
assert headers["X-Auth-Token"] == "test-key-123"
|
|
||||||
|
|
||||||
def test_headers_without_api_key(self):
|
|
||||||
source = SoccerAPIDataSource(_make_logger())
|
|
||||||
headers = source.get_headers()
|
|
||||||
assert "X-Auth-Token" not in headers
|
|
||||||
|
|
||||||
def test_fetch_live_games_success(self):
|
|
||||||
payload = {"matches": [{"id": "m1"}, {"id": "m2"}]}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("soccer", "eng.1")
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_live_games("soccer", "eng.1")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_schedule_success(self):
|
|
||||||
payload = {"matches": [{"id": "m1"}]}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
|
|
||||||
assert len(result) == 1
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_standings_success(self):
|
|
||||||
payload = {"standings": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_standings("soccer", "PL")
|
|
||||||
assert result == payload
|
|
||||||
|
|
||||||
def test_fetch_standings_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_standings("soccer", "PL")
|
|
||||||
assert result == {}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/common/game_helper.py
|
|
||||||
|
|
||||||
Covers GameHelper: extract_game_details, filter_*, sort_games_by_time,
|
|
||||||
process_games, get_game_summary, and all private helpers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import pytest
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
|
|
||||||
from src.common.game_helper import GameHelper
|
|
||||||
|
|
||||||
|
|
||||||
def _make_logger() -> logging.Logger:
|
|
||||||
return logging.getLogger("test_game_helper")
|
|
||||||
|
|
||||||
|
|
||||||
def _make_espn_event(
|
|
||||||
state: str = "in",
|
|
||||||
home_abbr: str = "LAL",
|
|
||||||
away_abbr: str = "BOS",
|
|
||||||
home_score: str = "105",
|
|
||||||
away_score: str = "98",
|
|
||||||
date_str: str = "2024-01-15T20:00:00Z",
|
|
||||||
period: int = 4,
|
|
||||||
status_name: str = "STATUS_IN_PROGRESS",
|
|
||||||
home_record: str = "30-10",
|
|
||||||
away_record: str = "25-15",
|
|
||||||
event_id: str = "game-1",
|
|
||||||
) -> dict:
|
|
||||||
return {
|
|
||||||
"id": event_id,
|
|
||||||
"date": date_str,
|
|
||||||
"competitions": [
|
|
||||||
{
|
|
||||||
"status": {
|
|
||||||
"type": {
|
|
||||||
"state": state,
|
|
||||||
"shortDetail": "Q4 2:30",
|
|
||||||
"name": status_name,
|
|
||||||
},
|
|
||||||
"period": period,
|
|
||||||
"displayClock": "2:30",
|
|
||||||
},
|
|
||||||
"competitors": [
|
|
||||||
{
|
|
||||||
"homeAway": "home",
|
|
||||||
"id": "h1",
|
|
||||||
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
|
|
||||||
"score": home_score,
|
|
||||||
"records": [{"summary": home_record}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"homeAway": "away",
|
|
||||||
"id": "a1",
|
|
||||||
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
|
|
||||||
"score": away_score,
|
|
||||||
"records": [{"summary": away_record}],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def helper():
|
|
||||||
return GameHelper(timezone_str="UTC", logger=_make_logger())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# extract_game_details
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestExtractGameDetails:
|
|
||||||
def test_live_game(self, helper):
|
|
||||||
event = _make_espn_event(state="in")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_live"] is True
|
|
||||||
assert result["is_final"] is False
|
|
||||||
assert result["is_upcoming"] is False
|
|
||||||
|
|
||||||
def test_final_game(self, helper):
|
|
||||||
event = _make_espn_event(state="post")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["is_final"] is True
|
|
||||||
|
|
||||||
def test_upcoming_game(self, helper):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["is_upcoming"] is True
|
|
||||||
|
|
||||||
def test_halftime_detection(self, helper):
|
|
||||||
event = _make_espn_event(state="halftime", status_name="STATUS_HALFTIME")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["is_halftime"] is True
|
|
||||||
|
|
||||||
def test_basic_fields_present(self, helper):
|
|
||||||
event = _make_espn_event()
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
for key in ("id", "home_abbr", "away_abbr", "home_score", "away_score",
|
|
||||||
"home_record", "away_record", "start_time_utc"):
|
|
||||||
assert key in result
|
|
||||||
|
|
||||||
def test_team_abbreviations(self, helper):
|
|
||||||
event = _make_espn_event(home_abbr="MIA", away_abbr="PHX")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["home_abbr"] == "MIA"
|
|
||||||
assert result["away_abbr"] == "PHX"
|
|
||||||
|
|
||||||
def test_scores_as_strings(self, helper):
|
|
||||||
event = _make_espn_event(home_score="110", away_score="99")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["home_score"] == "110"
|
|
||||||
assert result["away_score"] == "99"
|
|
||||||
|
|
||||||
def test_returns_none_on_empty(self, helper):
|
|
||||||
assert helper.extract_game_details({}) is None
|
|
||||||
assert helper.extract_game_details(None) is None
|
|
||||||
|
|
||||||
def test_returns_none_when_no_competitors(self, helper):
|
|
||||||
event = _make_espn_event()
|
|
||||||
event["competitions"][0]["competitors"] = []
|
|
||||||
assert helper.extract_game_details(event) is None
|
|
||||||
|
|
||||||
def test_date_z_suffix_parsed(self, helper):
|
|
||||||
event = _make_espn_event(date_str="2024-06-01T19:30:00Z")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["start_time_utc"] is not None
|
|
||||||
assert result["start_time_utc"].tzinfo is not None
|
|
||||||
|
|
||||||
def test_zero_zero_record_suppressed(self, helper):
|
|
||||||
event = _make_espn_event(home_record="0-0", away_record="0-0-0")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["home_record"] == ""
|
|
||||||
assert result["away_record"] == ""
|
|
||||||
|
|
||||||
def test_basketball_sport_fields(self, helper):
|
|
||||||
event = _make_espn_event(period=3)
|
|
||||||
result = helper.extract_game_details(event, sport="basketball")
|
|
||||||
assert result["period_text"] == "Q3"
|
|
||||||
assert "clock" in result
|
|
||||||
|
|
||||||
def test_basketball_overtime_period(self, helper):
|
|
||||||
event = _make_espn_event(period=5)
|
|
||||||
result = helper.extract_game_details(event, sport="basketball")
|
|
||||||
assert result["period_text"] == "OT1"
|
|
||||||
|
|
||||||
def test_football_sport_fields(self, helper):
|
|
||||||
event = _make_espn_event(period=2)
|
|
||||||
result = helper.extract_game_details(event, sport="football")
|
|
||||||
assert result["period_text"] == "Q2"
|
|
||||||
|
|
||||||
def test_hockey_sport_fields_period_1(self, helper):
|
|
||||||
event = _make_espn_event(period=1)
|
|
||||||
result = helper.extract_game_details(event, sport="hockey")
|
|
||||||
assert result["period_text"] == "P1"
|
|
||||||
|
|
||||||
def test_hockey_sport_fields_ot(self, helper):
|
|
||||||
event = _make_espn_event(period=4)
|
|
||||||
result = helper.extract_game_details(event, sport="hockey")
|
|
||||||
assert result["period_text"] == "OT1"
|
|
||||||
|
|
||||||
def test_baseball_sport_fields(self, helper):
|
|
||||||
event = _make_espn_event(period=7)
|
|
||||||
result = helper.extract_game_details(event, sport="baseball")
|
|
||||||
assert result["period_text"] == "INN 7"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Filter methods
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFilterMethods:
|
|
||||||
def _make_games(self):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
return [
|
|
||||||
{"is_live": True, "is_final": False, "is_upcoming": False, "home_abbr": "LAL", "away_abbr": "BOS", "start_time_utc": now},
|
|
||||||
{"is_live": False, "is_final": True, "is_upcoming": False, "home_abbr": "MIA", "away_abbr": "PHX", "start_time_utc": now - timedelta(hours=3)},
|
|
||||||
{"is_live": False, "is_final": False, "is_upcoming": True, "home_abbr": "DAL", "away_abbr": "CHI", "start_time_utc": now + timedelta(hours=2)},
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_filter_live_games(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_live_games(games)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["home_abbr"] == "LAL"
|
|
||||||
|
|
||||||
def test_filter_final_games(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_final_games(games)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["home_abbr"] == "MIA"
|
|
||||||
|
|
||||||
def test_filter_upcoming_games(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_upcoming_games(games)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["home_abbr"] == "DAL"
|
|
||||||
|
|
||||||
def test_filter_favorite_teams_match(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_favorite_teams(games, ["LAL"])
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["home_abbr"] == "LAL"
|
|
||||||
|
|
||||||
def test_filter_favorite_teams_empty_list_returns_all(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_favorite_teams(games, [])
|
|
||||||
assert len(result) == 3
|
|
||||||
|
|
||||||
def test_filter_favorite_teams_away_match(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_favorite_teams(games, ["BOS"])
|
|
||||||
assert len(result) == 1
|
|
||||||
|
|
||||||
def test_filter_recent_games_within_window(self, helper):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
games = [
|
|
||||||
{"start_time_utc": now - timedelta(days=2), "is_final": True},
|
|
||||||
{"start_time_utc": now - timedelta(days=10), "is_final": True},
|
|
||||||
]
|
|
||||||
result = helper.filter_recent_games(games, days_back=7)
|
|
||||||
assert len(result) == 1
|
|
||||||
|
|
||||||
def test_filter_recent_games_all_within(self, helper):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
games = [
|
|
||||||
{"start_time_utc": now - timedelta(days=1)},
|
|
||||||
{"start_time_utc": now - timedelta(days=3)},
|
|
||||||
]
|
|
||||||
result = helper.filter_recent_games(games, days_back=7)
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_sort_games_ascending(self, helper):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
games = [
|
|
||||||
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
|
|
||||||
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
|
|
||||||
]
|
|
||||||
result = helper.sort_games_by_time(games)
|
|
||||||
assert result[0]["id"] == "early"
|
|
||||||
|
|
||||||
def test_sort_games_descending(self, helper):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
games = [
|
|
||||||
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
|
|
||||||
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
|
|
||||||
]
|
|
||||||
result = helper.sort_games_by_time(games, reverse=True)
|
|
||||||
assert result[0]["id"] == "late"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# process_games
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestProcessGames:
|
|
||||||
def test_processes_valid_events(self, helper):
|
|
||||||
events = [
|
|
||||||
_make_espn_event(event_id="1"),
|
|
||||||
_make_espn_event(event_id="2"),
|
|
||||||
]
|
|
||||||
result = helper.process_games(events)
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_skips_invalid_events(self, helper):
|
|
||||||
events = [
|
|
||||||
_make_espn_event(event_id="1"),
|
|
||||||
{}, # invalid
|
|
||||||
]
|
|
||||||
result = helper.process_games(events)
|
|
||||||
assert len(result) == 1
|
|
||||||
|
|
||||||
def test_empty_events(self, helper):
|
|
||||||
assert helper.process_games([]) == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_game_summary
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetGameSummary:
|
|
||||||
def test_live_summary(self, helper):
|
|
||||||
game = {
|
|
||||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
|
||||||
"home_score": "105", "away_score": "98",
|
|
||||||
"status_text": "Q4 2:30",
|
|
||||||
"is_live": True, "is_final": False,
|
|
||||||
}
|
|
||||||
summary = helper.get_game_summary(game)
|
|
||||||
assert "BOS" in summary
|
|
||||||
assert "LAL" in summary
|
|
||||||
assert "98" in summary
|
|
||||||
assert "105" in summary
|
|
||||||
|
|
||||||
def test_final_summary(self, helper):
|
|
||||||
game = {
|
|
||||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
|
||||||
"home_score": "110", "away_score": "102",
|
|
||||||
"status_text": "Final",
|
|
||||||
"is_live": False, "is_final": True,
|
|
||||||
}
|
|
||||||
summary = helper.get_game_summary(game)
|
|
||||||
assert "Final" in summary
|
|
||||||
|
|
||||||
def test_upcoming_summary(self, helper):
|
|
||||||
game = {
|
|
||||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
|
||||||
"home_score": "0", "away_score": "0",
|
|
||||||
"status_text": "7:30 PM",
|
|
||||||
"is_live": False, "is_final": False,
|
|
||||||
}
|
|
||||||
summary = helper.get_game_summary(game)
|
|
||||||
assert "7:30 PM" in summary
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/plugin_system/health_monitor.py
|
|
||||||
|
|
||||||
Covers PluginHealthMonitor: get_plugin_health_status, get_plugin_health_metrics,
|
|
||||||
get_all_plugin_health, _get_recovery_suggestions, start/stop_monitoring,
|
|
||||||
register_health_check.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from src.plugin_system.health_monitor import (
|
|
||||||
PluginHealthMonitor,
|
|
||||||
HealthStatus,
|
|
||||||
HealthMetrics,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_health_tracker(
|
|
||||||
summary: dict | None = None,
|
|
||||||
all_summaries: dict | None = None,
|
|
||||||
):
|
|
||||||
"""Return a mock PluginHealthTracker."""
|
|
||||||
tracker = MagicMock()
|
|
||||||
tracker.get_health_summary.return_value = summary
|
|
||||||
tracker.get_all_health_summaries.return_value = all_summaries or {}
|
|
||||||
return tracker
|
|
||||||
|
|
||||||
|
|
||||||
def _healthy_summary() -> dict:
|
|
||||||
return {
|
|
||||||
"success_rate": 100.0,
|
|
||||||
"circuit_state": "closed",
|
|
||||||
"consecutive_failures": 0,
|
|
||||||
"total_failures": 0,
|
|
||||||
"total_successes": 50,
|
|
||||||
"last_success_time": datetime.now().isoformat(),
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _degraded_summary() -> dict:
|
|
||||||
return {
|
|
||||||
"success_rate": 40.0, # 60% error rate
|
|
||||||
"circuit_state": "closed",
|
|
||||||
"consecutive_failures": 3,
|
|
||||||
"total_failures": 6,
|
|
||||||
"total_successes": 4,
|
|
||||||
"last_success_time": None,
|
|
||||||
"last_error": "timeout occurred",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _unhealthy_summary() -> dict:
|
|
||||||
return {
|
|
||||||
"success_rate": 10.0, # 90% error rate
|
|
||||||
"circuit_state": "open",
|
|
||||||
"consecutive_failures": 10,
|
|
||||||
"total_failures": 9,
|
|
||||||
"total_successes": 1,
|
|
||||||
"last_success_time": None,
|
|
||||||
"last_error": "ImportError: missing module",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def monitor():
|
|
||||||
tracker = _make_health_tracker(_healthy_summary())
|
|
||||||
return PluginHealthMonitor(health_tracker=tracker)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_plugin_health_status
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetPluginHealthStatus:
|
|
||||||
def test_healthy_status(self):
|
|
||||||
tracker = _make_health_tracker(_healthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_a")
|
|
||||||
assert status == HealthStatus.HEALTHY
|
|
||||||
|
|
||||||
def test_degraded_status(self):
|
|
||||||
tracker = _make_health_tracker(_degraded_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_b")
|
|
||||||
assert status == HealthStatus.DEGRADED
|
|
||||||
|
|
||||||
def test_unhealthy_status(self):
|
|
||||||
tracker = _make_health_tracker(_unhealthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_c")
|
|
||||||
assert status == HealthStatus.UNHEALTHY
|
|
||||||
|
|
||||||
def test_open_circuit_breaker_is_unhealthy(self):
|
|
||||||
summary = _healthy_summary()
|
|
||||||
summary["circuit_state"] = "open"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_d")
|
|
||||||
assert status == HealthStatus.UNHEALTHY
|
|
||||||
|
|
||||||
def test_unknown_when_no_tracker(self):
|
|
||||||
monitor = PluginHealthMonitor(health_tracker=None)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_e")
|
|
||||||
assert status == HealthStatus.UNKNOWN
|
|
||||||
|
|
||||||
def test_unknown_when_no_summary(self):
|
|
||||||
tracker = _make_health_tracker(None)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_f")
|
|
||||||
assert status == HealthStatus.UNKNOWN
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_plugin_health_metrics
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetPluginHealthMetrics:
|
|
||||||
def test_healthy_metrics(self):
|
|
||||||
tracker = _make_health_tracker(_healthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
|
||||||
assert isinstance(metrics, HealthMetrics)
|
|
||||||
assert metrics.status == HealthStatus.HEALTHY
|
|
||||||
assert metrics.success_rate == pytest.approx(1.0)
|
|
||||||
assert metrics.error_rate == pytest.approx(0.0)
|
|
||||||
|
|
||||||
def test_degraded_metrics(self):
|
|
||||||
tracker = _make_health_tracker(_degraded_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_b")
|
|
||||||
assert metrics.status == HealthStatus.DEGRADED
|
|
||||||
assert metrics.consecutive_failures == 3
|
|
||||||
|
|
||||||
def test_unhealthy_metrics(self):
|
|
||||||
tracker = _make_health_tracker(_unhealthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_c")
|
|
||||||
assert metrics.status == HealthStatus.UNHEALTHY
|
|
||||||
assert metrics.circuit_breaker_state == "open"
|
|
||||||
assert metrics.last_error is not None
|
|
||||||
|
|
||||||
def test_metrics_without_tracker(self):
|
|
||||||
monitor = PluginHealthMonitor(health_tracker=None)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_d")
|
|
||||||
assert metrics.status == HealthStatus.UNKNOWN
|
|
||||||
assert metrics.plugin_id == "plugin_d"
|
|
||||||
|
|
||||||
def test_metrics_without_summary(self):
|
|
||||||
tracker = _make_health_tracker(None)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_e")
|
|
||||||
assert metrics.status == HealthStatus.UNKNOWN
|
|
||||||
|
|
||||||
def test_last_successful_update_parsed(self):
|
|
||||||
summary = _healthy_summary()
|
|
||||||
summary["last_success_time"] = "2024-06-01T12:00:00"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
|
||||||
assert metrics.last_successful_update is not None
|
|
||||||
assert isinstance(metrics.last_successful_update, datetime)
|
|
||||||
|
|
||||||
def test_invalid_last_success_time_handled(self):
|
|
||||||
summary = _healthy_summary()
|
|
||||||
summary["last_success_time"] = "not-a-date"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
# Should not raise
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
|
||||||
assert metrics.last_successful_update is None
|
|
||||||
|
|
||||||
def test_total_successes_failures(self):
|
|
||||||
tracker = _make_health_tracker(_degraded_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_b")
|
|
||||||
assert metrics.total_failures == 6
|
|
||||||
assert metrics.total_successes == 4
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_all_plugin_health
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetAllPluginHealth:
|
|
||||||
def test_returns_empty_without_tracker(self):
|
|
||||||
monitor = PluginHealthMonitor(health_tracker=None)
|
|
||||||
result = monitor.get_all_plugin_health()
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
def test_returns_metrics_for_each_plugin(self):
|
|
||||||
all_summaries = {
|
|
||||||
"plugin_a": _healthy_summary(),
|
|
||||||
"plugin_b": _degraded_summary(),
|
|
||||||
}
|
|
||||||
tracker = MagicMock()
|
|
||||||
tracker.get_all_health_summaries.return_value = all_summaries
|
|
||||||
tracker.get_health_summary.side_effect = lambda pid: all_summaries.get(pid)
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
result = monitor.get_all_plugin_health()
|
|
||||||
assert "plugin_a" in result
|
|
||||||
assert "plugin_b" in result
|
|
||||||
assert isinstance(result["plugin_a"], HealthMetrics)
|
|
||||||
|
|
||||||
def test_returns_empty_when_no_summaries(self):
|
|
||||||
tracker = _make_health_tracker(all_summaries={})
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
result = monitor.get_all_plugin_health()
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _get_recovery_suggestions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetRecoverySuggestions:
|
|
||||||
def test_healthy_plugin_suggestion(self):
|
|
||||||
tracker = _make_health_tracker(_healthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", _healthy_summary(), HealthStatus.HEALTHY)
|
|
||||||
assert any("healthy" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_unhealthy_suggestions(self):
|
|
||||||
tracker = _make_health_tracker(_unhealthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", _unhealthy_summary(), HealthStatus.UNHEALTHY)
|
|
||||||
assert len(suggestions) > 0
|
|
||||||
assert any("unhealthy" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_open_circuit_breaker_suggestion(self):
|
|
||||||
summary = _unhealthy_summary()
|
|
||||||
summary["circuit_state"] = "open"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
|
||||||
assert any("circuit" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_timeout_error_suggestion(self):
|
|
||||||
summary = _degraded_summary()
|
|
||||||
summary["last_error"] = "connection timeout occurred"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.DEGRADED)
|
|
||||||
assert any("timeout" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_import_error_suggestion(self):
|
|
||||||
summary = _unhealthy_summary()
|
|
||||||
summary["last_error"] = "ImportError: missing module"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
|
||||||
assert any("dependencies" in s.lower() or "import" in s.lower() or "missing" in s.lower()
|
|
||||||
for s in suggestions)
|
|
||||||
|
|
||||||
def test_permission_error_suggestion(self):
|
|
||||||
summary = _unhealthy_summary()
|
|
||||||
summary["last_error"] = "permission denied to access resource"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
|
||||||
assert any("permission" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_degraded_suggestions_include_error_rate(self):
|
|
||||||
tracker = _make_health_tracker(_degraded_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", _degraded_summary(), HealthStatus.DEGRADED)
|
|
||||||
assert any("%" in s for s in suggestions)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# start / stop monitoring
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestMonitorLifecycle:
|
|
||||||
def test_start_monitoring(self, monitor):
|
|
||||||
monitor.start_monitoring()
|
|
||||||
try:
|
|
||||||
assert monitor._monitor_thread is not None
|
|
||||||
assert monitor._monitor_thread.is_alive()
|
|
||||||
finally:
|
|
||||||
monitor.stop_monitoring()
|
|
||||||
|
|
||||||
def test_stop_monitoring(self, monitor):
|
|
||||||
monitor.start_monitoring()
|
|
||||||
monitor.stop_monitoring()
|
|
||||||
# Thread should no longer be alive
|
|
||||||
assert not monitor._monitor_thread.is_alive()
|
|
||||||
|
|
||||||
def test_double_start_no_duplicate_threads(self, monitor):
|
|
||||||
monitor.start_monitoring()
|
|
||||||
try:
|
|
||||||
thread1 = monitor._monitor_thread
|
|
||||||
monitor.start_monitoring() # should be idempotent
|
|
||||||
assert monitor._monitor_thread is thread1
|
|
||||||
finally:
|
|
||||||
monitor.stop_monitoring()
|
|
||||||
|
|
||||||
def test_register_health_check(self, monitor):
|
|
||||||
callback = MagicMock()
|
|
||||||
monitor.register_health_check(callback)
|
|
||||||
assert callback in monitor._health_check_callbacks
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/logo_downloader.py
|
|
||||||
|
|
||||||
Focuses on the pure/static methods that don't require network calls:
|
|
||||||
normalize_abbreviation, get_logo_filename_variations, get_logo_directory,
|
|
||||||
ensure_logo_directory, and the download_missing_logo function path
|
|
||||||
(with HTTP mocked).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch, Mock, MagicMock
|
|
||||||
|
|
||||||
from src.logo_downloader import LogoDownloader
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# normalize_abbreviation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestNormalizeAbbreviation:
|
|
||||||
def test_basic_lowercase(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("lal")
|
|
||||||
assert result == "LAL"
|
|
||||||
|
|
||||||
def test_uppercases(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("bos")
|
|
||||||
assert result == "BOS"
|
|
||||||
|
|
||||||
def test_ampersand_replaced(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("TA&M")
|
|
||||||
assert "&" not in result
|
|
||||||
assert "AND" in result
|
|
||||||
|
|
||||||
def test_forward_slash_replaced(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("A/B")
|
|
||||||
assert "/" not in result
|
|
||||||
|
|
||||||
def test_empty_returns_empty(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("")
|
|
||||||
assert result == ""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_logo_filename_variations
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetLogoFilenameVariations:
|
|
||||||
def test_returns_list(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("LAL")
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
def test_includes_png(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("KC")
|
|
||||||
filenames = " ".join(result)
|
|
||||||
assert ".png" in filenames
|
|
||||||
|
|
||||||
def test_includes_original(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("LAL")
|
|
||||||
assert any("LAL" in f for f in result)
|
|
||||||
|
|
||||||
def test_ampersand_variation(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("TA&M")
|
|
||||||
# Should produce at least the normalized version
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
def test_empty_string_no_crash(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("")
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_logo_directory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetLogoDirectory:
|
|
||||||
def test_known_sport_returns_string(self):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
result = downloader.get_logo_directory("nfl")
|
|
||||||
assert isinstance(result, str)
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
def test_known_sport_nba(self):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
result = downloader.get_logo_directory("nba")
|
|
||||||
assert "nba" in result.lower() or "sports" in result.lower()
|
|
||||||
|
|
||||||
def test_unknown_sport_returns_string(self):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
result = downloader.get_logo_directory("unknown_sport_xyz")
|
|
||||||
assert isinstance(result, str)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ensure_logo_directory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestEnsureLogoDirectory:
|
|
||||||
def test_creates_writable_directory(self, tmp_path):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
test_dir = str(tmp_path / "logos" / "nfl")
|
|
||||||
result = downloader.ensure_logo_directory(test_dir)
|
|
||||||
assert result is True
|
|
||||||
assert Path(test_dir).is_dir()
|
|
||||||
|
|
||||||
def test_existing_writable_directory(self, tmp_path):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
test_dir = str(tmp_path)
|
|
||||||
result = downloader.ensure_logo_directory(test_dir)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_returns_false_when_write_test_fails(self, tmp_path):
|
|
||||||
"""Simulate a directory that exists but raises PermissionError on write."""
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
test_dir = str(tmp_path / "logos")
|
|
||||||
|
|
||||||
import builtins
|
|
||||||
original_open = builtins.open
|
|
||||||
|
|
||||||
def mock_open(path, *args, **kwargs):
|
|
||||||
if ".write_test" in str(path):
|
|
||||||
raise PermissionError("no write access")
|
|
||||||
return original_open(path, *args, **kwargs)
|
|
||||||
|
|
||||||
with patch("builtins.open", side_effect=mock_open):
|
|
||||||
result = downloader.ensure_logo_directory(test_dir)
|
|
||||||
assert result is False
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/common/scroll_helper.py
|
|
||||||
|
|
||||||
Covers ScrollHelper: create_scrolling_image, update_scroll_position,
|
|
||||||
get_visible_portion, calculate_dynamic_duration, set_* methods,
|
|
||||||
reset_scroll, clear_cache, get_scroll_info.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import time
|
|
||||||
from unittest.mock import patch
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
|
||||||
|
|
||||||
|
|
||||||
DISPLAY_W = 64
|
|
||||||
DISPLAY_H = 32
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def helper():
|
|
||||||
return ScrollHelper(display_width=DISPLAY_W, display_height=DISPLAY_H)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_image(width: int = 64, height: int = 32, color=(255, 0, 0)) -> Image.Image:
|
|
||||||
img = Image.new("RGB", (width, height), color)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# __init__ / initial state
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestScrollHelperInit:
|
|
||||||
def test_initial_scroll_position(self, helper):
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_initial_scroll_complete_false(self, helper):
|
|
||||||
assert helper.scroll_complete is False
|
|
||||||
|
|
||||||
def test_display_dimensions(self, helper):
|
|
||||||
assert helper.display_width == DISPLAY_W
|
|
||||||
assert helper.display_height == DISPLAY_H
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# create_scrolling_image
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCreateScrollingImage:
|
|
||||||
def test_empty_content_returns_blank_image(self, helper):
|
|
||||||
result = helper.create_scrolling_image([])
|
|
||||||
assert isinstance(result, Image.Image)
|
|
||||||
assert helper.total_scroll_width == 0
|
|
||||||
|
|
||||||
def test_single_item_creates_image(self, helper):
|
|
||||||
img = _make_image(width=100)
|
|
||||||
result = helper.create_scrolling_image([img])
|
|
||||||
assert isinstance(result, Image.Image)
|
|
||||||
assert result.width > DISPLAY_W # includes leading gap
|
|
||||||
|
|
||||||
def test_multiple_items_wider_image(self, helper):
|
|
||||||
items = [_make_image(width=50), _make_image(width=50)]
|
|
||||||
result = helper.create_scrolling_image(items)
|
|
||||||
# Should be wider than two items alone
|
|
||||||
assert result.width > 100
|
|
||||||
|
|
||||||
def test_scroll_position_reset(self, helper):
|
|
||||||
helper.scroll_position = 500.0
|
|
||||||
helper.create_scrolling_image([_make_image()])
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_cached_array_set(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image()])
|
|
||||||
assert helper.cached_array is not None
|
|
||||||
|
|
||||||
def test_scroll_complete_reset(self, helper):
|
|
||||||
helper.scroll_complete = True
|
|
||||||
helper.create_scrolling_image([_make_image()])
|
|
||||||
assert helper.scroll_complete is False
|
|
||||||
|
|
||||||
def test_total_scroll_width_matches_image(self, helper):
|
|
||||||
img = _make_image(width=200)
|
|
||||||
result = helper.create_scrolling_image([img])
|
|
||||||
assert helper.total_scroll_width == result.width
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# set_scrolling_image
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSetScrollingImage:
|
|
||||||
def test_sets_cached_image(self, helper):
|
|
||||||
img = _make_image(width=200)
|
|
||||||
helper.set_scrolling_image(img)
|
|
||||||
assert helper.cached_image is img
|
|
||||||
|
|
||||||
def test_sets_cached_array(self, helper):
|
|
||||||
img = _make_image(width=200)
|
|
||||||
helper.set_scrolling_image(img)
|
|
||||||
assert helper.cached_array is not None
|
|
||||||
|
|
||||||
def test_scroll_width_matches_image(self, helper):
|
|
||||||
img = _make_image(width=300)
|
|
||||||
helper.set_scrolling_image(img)
|
|
||||||
assert helper.total_scroll_width == 300
|
|
||||||
|
|
||||||
def test_none_clears_cache(self, helper):
|
|
||||||
helper.set_scrolling_image(_make_image())
|
|
||||||
helper.set_scrolling_image(None)
|
|
||||||
assert helper.cached_image is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# update_scroll_position (time-based mode)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestUpdateScrollPosition:
|
|
||||||
def test_position_advances_over_time(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=200)])
|
|
||||||
helper.scroll_speed = 100.0 # 100 px/s
|
|
||||||
helper.last_update_time = time.time() - 0.1 # pretend 100ms elapsed
|
|
||||||
initial = helper.scroll_position
|
|
||||||
helper.update_scroll_position()
|
|
||||||
assert helper.scroll_position > initial
|
|
||||||
|
|
||||||
def test_no_advance_without_image(self, helper):
|
|
||||||
helper.update_scroll_position() # no image, should not crash
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_zero_width_content_stays_zero(self, helper):
|
|
||||||
helper.create_scrolling_image([]) # empty → width 0
|
|
||||||
helper.update_scroll_position()
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_scroll_complete_clamped(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=100)])
|
|
||||||
# Force position past the end
|
|
||||||
helper.scroll_position = helper.total_scroll_width + 50
|
|
||||||
helper.total_distance_scrolled = helper.total_scroll_width + 50
|
|
||||||
helper.update_scroll_position()
|
|
||||||
assert helper.scroll_complete is True
|
|
||||||
assert helper.scroll_position <= helper.total_scroll_width
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_visible_portion
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetVisiblePortion:
|
|
||||||
def test_returns_none_without_image(self, helper):
|
|
||||||
assert helper.get_visible_portion() is None
|
|
||||||
|
|
||||||
def test_returns_image_sized_to_display(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=200)])
|
|
||||||
visible = helper.get_visible_portion()
|
|
||||||
assert visible is not None
|
|
||||||
assert visible.width == DISPLAY_W
|
|
||||||
assert visible.height == DISPLAY_H
|
|
||||||
|
|
||||||
def test_different_positions_give_different_images(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=300)])
|
|
||||||
img1 = helper.get_visible_portion()
|
|
||||||
helper.scroll_position = 50
|
|
||||||
img2 = helper.get_visible_portion()
|
|
||||||
# Images should differ (colour from scrolled content)
|
|
||||||
# Just verify both are valid PIL images with correct size
|
|
||||||
assert img1.width == img2.width == DISPLAY_W
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# reset_scroll / clear_cache
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestResetAndClear:
|
|
||||||
def test_reset_restores_position(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=200)])
|
|
||||||
helper.scroll_position = 100.0
|
|
||||||
helper.reset_scroll()
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_reset_clears_complete_flag(self, helper):
|
|
||||||
helper.scroll_complete = True
|
|
||||||
helper.reset_scroll()
|
|
||||||
assert helper.scroll_complete is False
|
|
||||||
|
|
||||||
def test_reset_alias(self, helper):
|
|
||||||
helper.scroll_position = 50.0
|
|
||||||
helper.reset()
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_clear_cache(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image()])
|
|
||||||
helper.clear_cache()
|
|
||||||
assert helper.cached_image is None
|
|
||||||
assert helper.cached_array is None
|
|
||||||
assert helper.total_scroll_width == 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# calculate_dynamic_duration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCalculateDynamicDuration:
|
|
||||||
def test_returns_min_when_disabled(self, helper):
|
|
||||||
helper.dynamic_duration_enabled = False
|
|
||||||
helper.min_duration = 30
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert result == 30
|
|
||||||
|
|
||||||
def test_returns_min_when_no_content(self, helper):
|
|
||||||
helper.total_scroll_width = 0
|
|
||||||
helper.min_duration = 30
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert result == 30
|
|
||||||
|
|
||||||
def test_respects_min_duration(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=50)])
|
|
||||||
helper.min_duration = 60
|
|
||||||
helper.max_duration = 300
|
|
||||||
helper.scroll_speed = 500.0 # very fast → very short time
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert result >= 60
|
|
||||||
|
|
||||||
def test_respects_max_duration(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=5000)])
|
|
||||||
helper.min_duration = 10
|
|
||||||
helper.max_duration = 60
|
|
||||||
helper.scroll_speed = 1.0 # very slow → very long time
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert result <= 60
|
|
||||||
|
|
||||||
def test_time_based_calculation(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=200)])
|
|
||||||
helper.scroll_speed = 100.0
|
|
||||||
helper.min_duration = 1
|
|
||||||
helper.max_duration = 600
|
|
||||||
helper.frame_based_scrolling = False
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert isinstance(result, int)
|
|
||||||
assert result > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# set_* configuration methods
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSetMethods:
|
|
||||||
def test_set_scroll_speed_time_based(self, helper):
|
|
||||||
helper.frame_based_scrolling = False
|
|
||||||
helper.set_scroll_speed(50.0)
|
|
||||||
assert helper.scroll_speed == 50.0
|
|
||||||
|
|
||||||
def test_set_scroll_speed_clamped_low(self, helper):
|
|
||||||
helper.frame_based_scrolling = False
|
|
||||||
helper.set_scroll_speed(0.0)
|
|
||||||
assert helper.scroll_speed >= 1.0
|
|
||||||
|
|
||||||
def test_set_scroll_speed_clamped_high(self, helper):
|
|
||||||
helper.frame_based_scrolling = False
|
|
||||||
helper.set_scroll_speed(10000.0)
|
|
||||||
assert helper.scroll_speed <= 500.0
|
|
||||||
|
|
||||||
def test_set_scroll_delay(self, helper):
|
|
||||||
helper.set_scroll_delay(0.05)
|
|
||||||
assert helper.scroll_delay == 0.05
|
|
||||||
|
|
||||||
def test_set_scroll_delay_clamped(self, helper):
|
|
||||||
helper.set_scroll_delay(0.0001)
|
|
||||||
assert helper.scroll_delay >= 0.001
|
|
||||||
|
|
||||||
def test_set_target_fps(self, helper):
|
|
||||||
helper.set_target_fps(60.0)
|
|
||||||
assert helper.target_fps == 60.0
|
|
||||||
|
|
||||||
def test_set_target_fps_clamped(self, helper):
|
|
||||||
helper.set_target_fps(1000.0)
|
|
||||||
assert helper.target_fps <= 200.0
|
|
||||||
|
|
||||||
def test_set_sub_pixel_scrolling(self, helper):
|
|
||||||
helper.set_sub_pixel_scrolling(True)
|
|
||||||
assert helper.sub_pixel_scrolling is True
|
|
||||||
helper.set_sub_pixel_scrolling(False)
|
|
||||||
assert helper.sub_pixel_scrolling is False
|
|
||||||
|
|
||||||
def test_set_frame_based_scrolling(self, helper):
|
|
||||||
helper.set_frame_based_scrolling(True)
|
|
||||||
assert helper.frame_based_scrolling is True
|
|
||||||
|
|
||||||
def test_set_dynamic_duration_settings(self, helper):
|
|
||||||
helper.set_dynamic_duration_settings(enabled=True, min_duration=20, max_duration=120, buffer=0.2)
|
|
||||||
assert helper.dynamic_duration_enabled is True
|
|
||||||
assert helper.min_duration == 20
|
|
||||||
assert helper.max_duration == 120
|
|
||||||
assert helper.duration_buffer == pytest.approx(0.2)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_scroll_info
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetScrollInfo:
|
|
||||||
def test_returns_dict(self, helper):
|
|
||||||
info = helper.get_scroll_info()
|
|
||||||
assert isinstance(info, dict)
|
|
||||||
|
|
||||||
def test_required_keys(self, helper):
|
|
||||||
info = helper.get_scroll_info()
|
|
||||||
for key in ("scroll_position", "total_distance_scrolled", "scroll_speed",
|
|
||||||
"scroll_complete", "dynamic_duration"):
|
|
||||||
assert key in info
|
|
||||||
|
|
||||||
def test_scroll_position_reflected(self, helper):
|
|
||||||
helper.scroll_position = 42.0
|
|
||||||
info = helper.get_scroll_info()
|
|
||||||
assert info["scroll_position"] == 42.0
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/common/utils.py
|
|
||||||
|
|
||||||
Covers all pure utility functions: normalize_team_abbreviation, format_time,
|
|
||||||
format_date, get_timezone, validate_dimensions, parse_team_abbreviation,
|
|
||||||
format_score, format_period, is_live_game, is_final_game, is_upcoming_game,
|
|
||||||
sanitize_filename, truncate_text, parse_boolean.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import pytz
|
|
||||||
|
|
||||||
from src.common.utils import (
|
|
||||||
normalize_team_abbreviation,
|
|
||||||
format_time,
|
|
||||||
format_date,
|
|
||||||
get_timezone,
|
|
||||||
validate_dimensions,
|
|
||||||
parse_team_abbreviation,
|
|
||||||
format_score,
|
|
||||||
format_period,
|
|
||||||
is_live_game,
|
|
||||||
is_final_game,
|
|
||||||
is_upcoming_game,
|
|
||||||
sanitize_filename,
|
|
||||||
truncate_text,
|
|
||||||
parse_boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# normalize_team_abbreviation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestNormalizeTeamAbbreviation:
|
|
||||||
def test_basic_uppercase(self):
|
|
||||||
assert normalize_team_abbreviation("lal") == "LAL"
|
|
||||||
|
|
||||||
def test_strips_spaces(self):
|
|
||||||
assert normalize_team_abbreviation(" KC ") == "KC"
|
|
||||||
|
|
||||||
def test_replaces_ampersand(self):
|
|
||||||
assert normalize_team_abbreviation("TA&M") == "TAANDM"
|
|
||||||
|
|
||||||
def test_removes_internal_spaces(self):
|
|
||||||
assert normalize_team_abbreviation("A B") == "AB"
|
|
||||||
|
|
||||||
def test_removes_hyphens(self):
|
|
||||||
assert normalize_team_abbreviation("A-B") == "AB"
|
|
||||||
|
|
||||||
def test_empty_string_returns_empty(self):
|
|
||||||
assert normalize_team_abbreviation("") == ""
|
|
||||||
|
|
||||||
def test_none_returns_empty(self):
|
|
||||||
assert normalize_team_abbreviation(None) == ""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# format_time / format_date
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFormatTime:
|
|
||||||
def _utc_dt(self, hour=20, minute=30):
|
|
||||||
return datetime(2024, 1, 15, hour, minute, 0, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
def test_formats_utc_to_utc(self):
|
|
||||||
dt = self._utc_dt(20, 30)
|
|
||||||
result = format_time(dt, timezone_str="UTC")
|
|
||||||
# 20:30 UTC → "8:30PM" (leading zero stripped)
|
|
||||||
assert "8:30PM" in result or "8:30 PM" in result or result != ""
|
|
||||||
|
|
||||||
def test_naive_datetime_treated_as_utc(self):
|
|
||||||
dt = datetime(2024, 1, 15, 12, 0, 0) # naive
|
|
||||||
result = format_time(dt, timezone_str="UTC")
|
|
||||||
assert result != ""
|
|
||||||
|
|
||||||
def test_invalid_timezone_returns_empty(self):
|
|
||||||
dt = self._utc_dt()
|
|
||||||
result = format_time(dt, timezone_str="Invalid/TZ")
|
|
||||||
assert result == ""
|
|
||||||
|
|
||||||
def test_eastern_timezone(self):
|
|
||||||
dt = self._utc_dt(20, 0) # 8 PM UTC = 3 PM ET
|
|
||||||
result = format_time(dt, timezone_str="America/New_York")
|
|
||||||
assert result != ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestFormatDate:
|
|
||||||
def test_formats_date(self):
|
|
||||||
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
|
|
||||||
result = format_date(dt, timezone_str="UTC")
|
|
||||||
assert "June" in result or "15" in result
|
|
||||||
|
|
||||||
def test_naive_datetime(self):
|
|
||||||
dt = datetime(2024, 3, 10, 12, 0, 0)
|
|
||||||
result = format_date(dt, timezone_str="UTC")
|
|
||||||
assert result != ""
|
|
||||||
|
|
||||||
def test_invalid_timezone_returns_empty(self):
|
|
||||||
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
|
|
||||||
result = format_date(dt, timezone_str="BadZone/Here")
|
|
||||||
assert result == ""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_timezone
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetTimezone:
|
|
||||||
def test_valid_timezone(self):
|
|
||||||
tz = get_timezone("America/New_York")
|
|
||||||
assert tz is not None
|
|
||||||
|
|
||||||
def test_utc(self):
|
|
||||||
tz = get_timezone("UTC")
|
|
||||||
assert tz is pytz.utc or str(tz) == "UTC"
|
|
||||||
|
|
||||||
def test_invalid_returns_utc(self):
|
|
||||||
tz = get_timezone("Not/ATimezone")
|
|
||||||
assert tz is pytz.utc
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# validate_dimensions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestValidateDimensions:
|
|
||||||
def test_valid(self):
|
|
||||||
assert validate_dimensions(64, 32) is True
|
|
||||||
|
|
||||||
def test_zero_width(self):
|
|
||||||
assert validate_dimensions(0, 32) is False
|
|
||||||
|
|
||||||
def test_zero_height(self):
|
|
||||||
assert validate_dimensions(64, 0) is False
|
|
||||||
|
|
||||||
def test_negative(self):
|
|
||||||
assert validate_dimensions(-1, 32) is False
|
|
||||||
|
|
||||||
def test_too_large(self):
|
|
||||||
assert validate_dimensions(1001, 32) is False
|
|
||||||
|
|
||||||
def test_max_valid(self):
|
|
||||||
assert validate_dimensions(1000, 1000) is True
|
|
||||||
|
|
||||||
def test_non_integer(self):
|
|
||||||
assert validate_dimensions("64", 32) is False # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# parse_team_abbreviation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestParseTeamAbbreviation:
|
|
||||||
def test_empty_string(self):
|
|
||||||
assert parse_team_abbreviation("") == ""
|
|
||||||
|
|
||||||
def test_none_returns_empty(self):
|
|
||||||
assert parse_team_abbreviation(None) == ""
|
|
||||||
|
|
||||||
def test_extracts_uppercase(self):
|
|
||||||
result = parse_team_abbreviation("LAL")
|
|
||||||
assert result == "LAL"
|
|
||||||
|
|
||||||
def test_fallback_first_three(self):
|
|
||||||
# text without recognisable 2-4 char uppercase block
|
|
||||||
result = parse_team_abbreviation("ab")
|
|
||||||
assert len(result) <= 3
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# format_score
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFormatScore:
|
|
||||||
def test_format_score(self):
|
|
||||||
assert format_score(14, 7) == "7-14"
|
|
||||||
|
|
||||||
def test_format_score_strings(self):
|
|
||||||
assert format_score("21", "14") == "14-21"
|
|
||||||
|
|
||||||
def test_zero_zero(self):
|
|
||||||
assert format_score(0, 0) == "0-0"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# format_period
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFormatPeriod:
|
|
||||||
def test_basketball_q1(self):
|
|
||||||
assert format_period(1, "basketball") == "Q1"
|
|
||||||
|
|
||||||
def test_basketball_q4(self):
|
|
||||||
assert format_period(4, "basketball") == "Q4"
|
|
||||||
|
|
||||||
def test_basketball_ot1(self):
|
|
||||||
assert format_period(5, "basketball") == "OT1"
|
|
||||||
|
|
||||||
def test_basketball_ot2(self):
|
|
||||||
assert format_period(6, "basketball") == "OT2"
|
|
||||||
|
|
||||||
def test_football_q1(self):
|
|
||||||
assert format_period(1, "football") == "Q1"
|
|
||||||
|
|
||||||
def test_football_ot(self):
|
|
||||||
assert format_period(5, "football") == "OT1"
|
|
||||||
|
|
||||||
def test_hockey_p1(self):
|
|
||||||
assert format_period(1, "hockey") == "P1"
|
|
||||||
|
|
||||||
def test_hockey_p3(self):
|
|
||||||
assert format_period(3, "hockey") == "P3"
|
|
||||||
|
|
||||||
def test_hockey_ot(self):
|
|
||||||
assert format_period(4, "hockey") == "OT1"
|
|
||||||
|
|
||||||
def test_baseball_inning(self):
|
|
||||||
assert format_period(7, "baseball") == "INN 7"
|
|
||||||
|
|
||||||
def test_unknown_sport(self):
|
|
||||||
result = format_period(2, "unknown")
|
|
||||||
assert "2" in result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# is_live_game / is_final_game / is_upcoming_game
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGameStatusHelpers:
|
|
||||||
def test_is_live_game_true(self):
|
|
||||||
assert is_live_game("In Progress") is True
|
|
||||||
assert is_live_game("halftime") is True
|
|
||||||
assert is_live_game("overtime") is True
|
|
||||||
|
|
||||||
def test_is_live_game_false(self):
|
|
||||||
assert is_live_game("Final") is False
|
|
||||||
assert is_live_game("Scheduled") is False
|
|
||||||
|
|
||||||
def test_is_final_game_true(self):
|
|
||||||
assert is_final_game("Final") is True
|
|
||||||
assert is_final_game("COMPLETED") is True
|
|
||||||
|
|
||||||
def test_is_final_game_false(self):
|
|
||||||
assert is_final_game("In Progress") is False
|
|
||||||
|
|
||||||
def test_is_upcoming_game_true(self):
|
|
||||||
assert is_upcoming_game("Scheduled") is True
|
|
||||||
assert is_upcoming_game("upcoming") is True
|
|
||||||
|
|
||||||
def test_is_upcoming_game_false(self):
|
|
||||||
assert is_upcoming_game("Final") is False
|
|
||||||
assert is_upcoming_game("In Progress") is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# sanitize_filename
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSanitizeFilename:
|
|
||||||
def test_removes_invalid_chars(self):
|
|
||||||
result = sanitize_filename('file<>:"/\\|?*.txt')
|
|
||||||
assert "<" not in result
|
|
||||||
assert ">" not in result
|
|
||||||
assert ":" not in result
|
|
||||||
|
|
||||||
def test_collapses_underscores(self):
|
|
||||||
result = sanitize_filename("file___name")
|
|
||||||
assert "__" not in result
|
|
||||||
|
|
||||||
def test_strips_leading_trailing(self):
|
|
||||||
result = sanitize_filename("_file_")
|
|
||||||
assert not result.startswith("_")
|
|
||||||
assert not result.endswith("_")
|
|
||||||
|
|
||||||
def test_normal_filename_unchanged(self):
|
|
||||||
result = sanitize_filename("my_logo")
|
|
||||||
assert result == "my_logo"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# truncate_text
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestTruncateText:
|
|
||||||
def test_no_truncation_needed(self):
|
|
||||||
assert truncate_text("hello", 10) == "hello"
|
|
||||||
|
|
||||||
def test_truncation_adds_suffix(self):
|
|
||||||
result = truncate_text("hello world", 8)
|
|
||||||
assert result.endswith("...")
|
|
||||||
assert len(result) == 8
|
|
||||||
|
|
||||||
def test_exact_length(self):
|
|
||||||
assert truncate_text("hello", 5) == "hello"
|
|
||||||
|
|
||||||
def test_custom_suffix(self):
|
|
||||||
result = truncate_text("hello world", 8, suffix="~")
|
|
||||||
assert result.endswith("~")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# parse_boolean
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestParseBoolean:
|
|
||||||
def test_true_bool(self):
|
|
||||||
assert parse_boolean(True) is True
|
|
||||||
|
|
||||||
def test_false_bool(self):
|
|
||||||
assert parse_boolean(False) is False
|
|
||||||
|
|
||||||
def test_int_1(self):
|
|
||||||
assert parse_boolean(1) is True
|
|
||||||
|
|
||||||
def test_int_0(self):
|
|
||||||
assert parse_boolean(0) is False
|
|
||||||
|
|
||||||
def test_string_true(self):
|
|
||||||
for val in ("true", "True", "TRUE", "1", "yes", "on", "enabled"):
|
|
||||||
assert parse_boolean(val) is True, f"Expected True for {val!r}"
|
|
||||||
|
|
||||||
def test_string_false(self):
|
|
||||||
for val in ("false", "False", "0", "no", "off", "disabled"):
|
|
||||||
assert parse_boolean(val) is False, f"Expected False for {val!r}"
|
|
||||||
|
|
||||||
def test_none_returns_false(self):
|
|
||||||
assert parse_boolean(None) is False # type: ignore[arg-type]
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/vegas_mode/config.py
|
|
||||||
|
|
||||||
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
|
|
||||||
is_plugin_included, get_ordered_plugins, validate, update.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from src.vegas_mode.config import VegasModeConfig
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Default construction
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestVegasModeConfigDefaults:
|
|
||||||
def test_default_disabled(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.enabled is False
|
|
||||||
|
|
||||||
def test_default_scroll_speed(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.scroll_speed == 50.0
|
|
||||||
|
|
||||||
def test_default_separator_width(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.separator_width == 32
|
|
||||||
|
|
||||||
def test_default_target_fps(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.target_fps == 125
|
|
||||||
|
|
||||||
def test_default_plugin_order_empty(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.plugin_order == []
|
|
||||||
|
|
||||||
def test_default_excluded_plugins_empty(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert len(cfg.excluded_plugins) == 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# from_config
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFromConfig:
|
|
||||||
def _cfg(self, **kwargs) -> dict:
|
|
||||||
return {"display": {"vegas_scroll": kwargs}}
|
|
||||||
|
|
||||||
def test_enabled_flag(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(enabled=True))
|
|
||||||
assert cfg.enabled is True
|
|
||||||
|
|
||||||
def test_scroll_speed(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(scroll_speed=80.0))
|
|
||||||
assert cfg.scroll_speed == 80.0
|
|
||||||
|
|
||||||
def test_separator_width(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(separator_width=16))
|
|
||||||
assert cfg.separator_width == 16
|
|
||||||
|
|
||||||
def test_plugin_order(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(plugin_order=["a", "b", "c"]))
|
|
||||||
assert cfg.plugin_order == ["a", "b", "c"]
|
|
||||||
|
|
||||||
def test_excluded_plugins(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(excluded_plugins=["x", "y"]))
|
|
||||||
assert "x" in cfg.excluded_plugins
|
|
||||||
assert "y" in cfg.excluded_plugins
|
|
||||||
|
|
||||||
def test_target_fps(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(target_fps=60))
|
|
||||||
assert cfg.target_fps == 60
|
|
||||||
|
|
||||||
def test_buffer_ahead(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(buffer_ahead=3))
|
|
||||||
assert cfg.buffer_ahead == 3
|
|
||||||
|
|
||||||
def test_min_max_cycle_duration(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(min_cycle_duration=30, max_cycle_duration=120))
|
|
||||||
assert cfg.min_cycle_duration == 30
|
|
||||||
assert cfg.max_cycle_duration == 120
|
|
||||||
|
|
||||||
def test_defaults_when_missing(self):
|
|
||||||
cfg = VegasModeConfig.from_config({})
|
|
||||||
assert cfg.enabled is False
|
|
||||||
assert cfg.scroll_speed == 50.0
|
|
||||||
|
|
||||||
def test_frame_based_scrolling(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(frame_based_scrolling=False))
|
|
||||||
assert cfg.frame_based_scrolling is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# to_dict
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestToDict:
|
|
||||||
def test_roundtrip(self):
|
|
||||||
original = VegasModeConfig(
|
|
||||||
enabled=True,
|
|
||||||
scroll_speed=75.0,
|
|
||||||
separator_width=24,
|
|
||||||
plugin_order=["a", "b"],
|
|
||||||
excluded_plugins={"z"},
|
|
||||||
target_fps=100,
|
|
||||||
)
|
|
||||||
d = original.to_dict()
|
|
||||||
assert d["enabled"] is True
|
|
||||||
assert d["scroll_speed"] == 75.0
|
|
||||||
assert d["separator_width"] == 24
|
|
||||||
assert d["plugin_order"] == ["a", "b"]
|
|
||||||
assert "z" in d["excluded_plugins"]
|
|
||||||
assert d["target_fps"] == 100
|
|
||||||
|
|
||||||
def test_excluded_plugins_is_list(self):
|
|
||||||
cfg = VegasModeConfig(excluded_plugins={"x"})
|
|
||||||
d = cfg.to_dict()
|
|
||||||
assert isinstance(d["excluded_plugins"], list)
|
|
||||||
|
|
||||||
def test_all_keys_present(self):
|
|
||||||
d = VegasModeConfig().to_dict()
|
|
||||||
for key in ("enabled", "scroll_speed", "separator_width", "plugin_order",
|
|
||||||
"excluded_plugins", "target_fps", "buffer_ahead",
|
|
||||||
"frame_based_scrolling", "scroll_delay",
|
|
||||||
"dynamic_duration_enabled", "min_cycle_duration", "max_cycle_duration"):
|
|
||||||
assert key in d
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_frame_interval
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetFrameInterval:
|
|
||||||
def test_125fps(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=125)
|
|
||||||
assert abs(cfg.get_frame_interval() - 1.0 / 125) < 1e-9
|
|
||||||
|
|
||||||
def test_60fps(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=60)
|
|
||||||
assert abs(cfg.get_frame_interval() - 1.0 / 60) < 1e-6
|
|
||||||
|
|
||||||
def test_zero_fps_guarded(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=0)
|
|
||||||
# Should not raise ZeroDivisionError (max(1, fps) guard)
|
|
||||||
result = cfg.get_frame_interval()
|
|
||||||
assert result == 1.0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# is_plugin_included
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestIsPluginIncluded:
|
|
||||||
def test_not_excluded_is_included(self):
|
|
||||||
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
|
|
||||||
assert cfg.is_plugin_included("good_plugin") is True
|
|
||||||
|
|
||||||
def test_excluded_plugin_not_included(self):
|
|
||||||
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
|
|
||||||
assert cfg.is_plugin_included("bad_plugin") is False
|
|
||||||
|
|
||||||
def test_empty_exclusions_all_included(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.is_plugin_included("anything") is True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_ordered_plugins
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetOrderedPlugins:
|
|
||||||
def test_natural_order_when_no_order_configured(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert result == ["a", "b", "c"]
|
|
||||||
|
|
||||||
def test_explicit_order_followed(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=["c", "a", "b"])
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert result == ["c", "a", "b"]
|
|
||||||
|
|
||||||
def test_unavailable_plugins_skipped(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=["c", "x", "a"])
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert "x" not in result
|
|
||||||
assert result[:2] == ["c", "a"]
|
|
||||||
|
|
||||||
def test_excluded_plugins_removed(self):
|
|
||||||
cfg = VegasModeConfig(excluded_plugins={"b"})
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert "b" not in result
|
|
||||||
|
|
||||||
def test_unordered_available_appended(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=["a"])
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert result[0] == "a"
|
|
||||||
assert "b" in result
|
|
||||||
assert "c" in result
|
|
||||||
|
|
||||||
def test_empty_available(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=["a"])
|
|
||||||
result = cfg.get_ordered_plugins([])
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# validate
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestValidate:
|
|
||||||
def test_valid_config_no_errors(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert errors == []
|
|
||||||
|
|
||||||
def test_scroll_speed_too_low(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=0.5)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("scroll_speed" in e for e in errors)
|
|
||||||
|
|
||||||
def test_scroll_speed_too_high(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=300.0)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("scroll_speed" in e for e in errors)
|
|
||||||
|
|
||||||
def test_separator_width_negative(self):
|
|
||||||
cfg = VegasModeConfig(separator_width=-1)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("separator_width" in e for e in errors)
|
|
||||||
|
|
||||||
def test_separator_width_too_large(self):
|
|
||||||
cfg = VegasModeConfig(separator_width=200)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("separator_width" in e for e in errors)
|
|
||||||
|
|
||||||
def test_target_fps_too_low(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=10)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("target_fps" in e for e in errors)
|
|
||||||
|
|
||||||
def test_target_fps_too_high(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=300)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("target_fps" in e for e in errors)
|
|
||||||
|
|
||||||
def test_buffer_ahead_too_low(self):
|
|
||||||
cfg = VegasModeConfig(buffer_ahead=0)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("buffer_ahead" in e for e in errors)
|
|
||||||
|
|
||||||
def test_buffer_ahead_too_high(self):
|
|
||||||
cfg = VegasModeConfig(buffer_ahead=10)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("buffer_ahead" in e for e in errors)
|
|
||||||
|
|
||||||
def test_multiple_errors_returned(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=0.1, target_fps=5)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert len(errors) >= 2
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# update
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestUpdate:
|
|
||||||
def _wrap(self, **kwargs) -> dict:
|
|
||||||
return {"display": {"vegas_scroll": kwargs}}
|
|
||||||
|
|
||||||
def test_update_enabled(self):
|
|
||||||
cfg = VegasModeConfig(enabled=False)
|
|
||||||
cfg.update(self._wrap(enabled=True))
|
|
||||||
assert cfg.enabled is True
|
|
||||||
|
|
||||||
def test_update_scroll_speed(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=50.0)
|
|
||||||
cfg.update(self._wrap(scroll_speed=90.0))
|
|
||||||
assert cfg.scroll_speed == 90.0
|
|
||||||
|
|
||||||
def test_update_separator_width(self):
|
|
||||||
cfg = VegasModeConfig(separator_width=32)
|
|
||||||
cfg.update(self._wrap(separator_width=8))
|
|
||||||
assert cfg.separator_width == 8
|
|
||||||
|
|
||||||
def test_update_plugin_order(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=[])
|
|
||||||
cfg.update(self._wrap(plugin_order=["x", "y"]))
|
|
||||||
assert cfg.plugin_order == ["x", "y"]
|
|
||||||
|
|
||||||
def test_update_excluded_plugins(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
cfg.update(self._wrap(excluded_plugins=["skip_me"]))
|
|
||||||
assert "skip_me" in cfg.excluded_plugins
|
|
||||||
|
|
||||||
def test_update_ignores_missing_keys(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=50.0)
|
|
||||||
cfg.update(self._wrap(target_fps=80)) # only fps, not speed
|
|
||||||
assert cfg.scroll_speed == 50.0
|
|
||||||
assert cfg.target_fps == 80
|
|
||||||
|
|
||||||
def test_empty_update_no_change(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=50.0)
|
|
||||||
cfg.update({})
|
|
||||||
assert cfg.scroll_speed == 50.0
|
|
||||||
@@ -2,11 +2,8 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -25,9 +22,6 @@ from src.plugin_system.state_manager import PluginStateManager
|
|||||||
from src.plugin_system.operation_history import OperationHistory
|
from src.plugin_system.operation_history import OperationHistory
|
||||||
from src.plugin_system.health_monitor import PluginHealthMonitor
|
from src.plugin_system.health_monitor import PluginHealthMonitor
|
||||||
|
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
|
||||||
_SYSTEMCTL = shutil.which('systemctl')
|
|
||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.urandom(24)
|
app.secret_key = os.urandom(24)
|
||||||
@@ -210,8 +204,20 @@ def serve_plugin_asset(plugin_id, filename):
|
|||||||
# Use send_from_directory to serve the file
|
# Use send_from_directory to serve the file
|
||||||
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
|
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
# Log the exception with full traceback server-side
|
||||||
|
import traceback
|
||||||
app.logger.exception('Error serving plugin asset file')
|
app.logger.exception('Error serving plugin asset file')
|
||||||
|
|
||||||
|
# Return generic error message to client (avoid leaking internal details)
|
||||||
|
# Only include detailed error information when in debug mode
|
||||||
|
if app.debug:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'traceback': traceback.format_exc()
|
||||||
|
}), 500
|
||||||
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Internal server error'
|
'message': 'Internal server error'
|
||||||
@@ -336,25 +342,35 @@ def not_found_error(error):
|
|||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
"""Handle 500 errors."""
|
"""Handle 500 errors."""
|
||||||
|
import traceback
|
||||||
|
error_details = traceback.format_exc()
|
||||||
|
|
||||||
|
# Log the error
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('web_interface')
|
logger = logging.getLogger('web_interface')
|
||||||
logger.error("Internal server error", exc_info=True)
|
logger.error(f"Internal server error: {error}", exc_info=True)
|
||||||
|
|
||||||
|
# Return user-friendly error (hide internal details in production)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'error_code': 'INTERNAL_ERROR',
|
'error_code': 'INTERNAL_ERROR',
|
||||||
'message': 'An internal error occurred; see logs for details',
|
'message': 'An internal error occurred',
|
||||||
|
'details': error_details if app.debug else None
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
def handle_exception(error):
|
def handle_exception(error):
|
||||||
"""Handle all unhandled exceptions."""
|
"""Handle all unhandled exceptions."""
|
||||||
|
import traceback
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('web_interface')
|
logger = logging.getLogger('web_interface')
|
||||||
logger.error("Unhandled exception", exc_info=True)
|
logger.error(f"Unhandled exception: {error}", exc_info=True)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'error_code': 'UNKNOWN_ERROR',
|
'error_code': 'UNKNOWN_ERROR',
|
||||||
'message': 'An error occurred; see logs for details',
|
'message': str(error) if app.debug else 'An error occurred',
|
||||||
|
'details': traceback.format_exc() if app.debug else None
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
# Captive portal redirect middleware
|
# Captive portal redirect middleware
|
||||||
@@ -419,53 +435,13 @@ def add_security_headers(response):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
class _StreamBroadcaster:
|
# SSE helper function
|
||||||
"""Fan-out broadcaster: one background generator thread pushes to all SSE clients.
|
def sse_response(generator_func):
|
||||||
|
"""Helper to create SSE responses"""
|
||||||
This means N browser tabs share one generator instead of each running their own,
|
def generate():
|
||||||
keeping PIL encodes / subprocess forks constant regardless of how many tabs are open.
|
for data in generator_func():
|
||||||
"""
|
yield f"data: {json.dumps(data)}\n\n"
|
||||||
|
return Response(generate(), mimetype='text/event-stream')
|
||||||
def __init__(self, generator_factory):
|
|
||||||
self._generator_factory = generator_factory
|
|
||||||
self._clients: set = set()
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
|
|
||||||
def subscribe(self) -> queue.Queue:
|
|
||||||
q: queue.Queue = queue.Queue(maxsize=5)
|
|
||||||
with self._lock:
|
|
||||||
self._clients.add(q)
|
|
||||||
if not (self._thread and self._thread.is_alive()):
|
|
||||||
self._thread = threading.Thread(target=self._broadcast, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
return q
|
|
||||||
|
|
||||||
def unsubscribe(self, q: queue.Queue) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._clients.discard(q)
|
|
||||||
|
|
||||||
def _broadcast(self):
|
|
||||||
for data in self._generator_factory():
|
|
||||||
with self._lock:
|
|
||||||
if not self._clients:
|
|
||||||
# No subscribers — exit so the thread doesn't spin indefinitely.
|
|
||||||
# subscribe() will restart it when a new client arrives.
|
|
||||||
break
|
|
||||||
for q in self._clients:
|
|
||||||
try:
|
|
||||||
q.put_nowait(data)
|
|
||||||
except queue.Full:
|
|
||||||
# Client is reading too slowly; drop the oldest item and
|
|
||||||
# deliver the latest so the queue never stalls the client.
|
|
||||||
try:
|
|
||||||
q.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
q.put_nowait(data)
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# System status generator for SSE
|
# System status generator for SSE
|
||||||
def system_status_generator():
|
def system_status_generator():
|
||||||
@@ -496,13 +472,12 @@ def system_status_generator():
|
|||||||
# Check if display service is running (cached to avoid per-client subprocess forks)
|
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
||||||
if _SYSTEMCTL:
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'],
|
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
||||||
capture_output=True, text=True, timeout=2)
|
capture_output=True, text=True, timeout=2)
|
||||||
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||||
except (subprocess.SubprocessError, OSError) as e:
|
except (subprocess.SubprocessError, OSError):
|
||||||
app.logger.warning("systemctl status check failed: %s", e)
|
pass
|
||||||
_ledmatrix_service_cache['timestamp'] = now
|
_ledmatrix_service_cache['timestamp'] = now
|
||||||
service_active = _ledmatrix_service_cache['active']
|
service_active = _ledmatrix_service_cache['active']
|
||||||
|
|
||||||
@@ -517,8 +492,7 @@ def system_status_generator():
|
|||||||
}
|
}
|
||||||
yield status
|
yield status
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error("SSE generator error", exc_info=True)
|
yield {'error': str(e)}
|
||||||
yield {'error': 'An error occurred; see server logs'}
|
|
||||||
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
|
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
|
||||||
|
|
||||||
# Display preview generator for SSE
|
# Display preview generator for SSE
|
||||||
@@ -581,8 +555,7 @@ def display_preview_generator():
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error("SSE generator error", exc_info=True)
|
yield {'error': str(e)}
|
||||||
yield {'error': 'An error occurred; see server logs'}
|
|
||||||
|
|
||||||
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
|
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
|
||||||
|
|
||||||
@@ -594,13 +567,8 @@ def logs_generator():
|
|||||||
# Get recent logs from journalctl (simplified version)
|
# Get recent logs from journalctl (simplified version)
|
||||||
# Note: User should be in systemd-journal group to read logs without sudo
|
# Note: User should be in systemd-journal group to read logs without sudo
|
||||||
try:
|
try:
|
||||||
if not _JOURNALCTL:
|
|
||||||
yield {'timestamp': time.time(), 'logs': 'journalctl not found; cannot read logs'}
|
|
||||||
time.sleep(60)
|
|
||||||
continue
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[_JOURNALCTL, '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
['journalctl', '-u', 'ledmatrix.service', '-n', '50', '--no-pager'],
|
||||||
'-n', '50', '--no-pager', '--output=short-iso'],
|
|
||||||
capture_output=True, text=True, timeout=5
|
capture_output=True, text=True, timeout=5
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -616,7 +584,7 @@ def logs_generator():
|
|||||||
# No logs available
|
# No logs available
|
||||||
logs_data = {
|
logs_data = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'logs': 'No logs available from ledmatrix or ledmatrix-web service'
|
'logs': 'No logs available from ledmatrix service'
|
||||||
}
|
}
|
||||||
yield logs_data
|
yield logs_data
|
||||||
else:
|
else:
|
||||||
@@ -630,68 +598,36 @@ def logs_generator():
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
# Timeout - just skip this update
|
# Timeout - just skip this update
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception as e:
|
||||||
app.logger.error("Error running journalctl", exc_info=True)
|
|
||||||
error_data = {
|
error_data = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'logs': 'Error running journalctl; see server logs'
|
'logs': f'Error running journalctl: {str(e)}'
|
||||||
}
|
}
|
||||||
yield error_data
|
yield error_data
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
app.logger.error("Unexpected error in logs generator", exc_info=True)
|
|
||||||
error_data = {
|
error_data = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'logs': 'Unexpected error in logs generator; see server logs'
|
'logs': f'Unexpected error in logs generator: {str(e)}'
|
||||||
}
|
}
|
||||||
yield error_data
|
yield error_data
|
||||||
|
|
||||||
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
|
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
|
||||||
|
|
||||||
# One broadcaster per stream — shared across all SSE clients
|
|
||||||
_stats_broadcaster = _StreamBroadcaster(system_status_generator)
|
|
||||||
_display_broadcaster = _StreamBroadcaster(display_preview_generator)
|
|
||||||
_logs_broadcaster = _StreamBroadcaster(logs_generator)
|
|
||||||
|
|
||||||
|
|
||||||
def _sse_stream(broadcaster: _StreamBroadcaster) -> Response:
|
|
||||||
"""Return a streaming SSE response backed by a shared broadcaster."""
|
|
||||||
q = broadcaster.subscribe()
|
|
||||||
|
|
||||||
def generate():
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
data = q.get(timeout=30)
|
|
||||||
yield f"data: {json.dumps(data)}\n\n"
|
|
||||||
except queue.Empty:
|
|
||||||
# Send an SSE comment heartbeat to keep the connection alive
|
|
||||||
# through proxies that close idle connections.
|
|
||||||
yield ": heartbeat\n\n"
|
|
||||||
except GeneratorExit:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
broadcaster.unsubscribe(q)
|
|
||||||
|
|
||||||
return Response(generate(), mimetype='text/event-stream')
|
|
||||||
|
|
||||||
|
|
||||||
# SSE endpoints
|
# SSE endpoints
|
||||||
@app.route('/api/v3/stream/stats')
|
@app.route('/api/v3/stream/stats')
|
||||||
def stream_stats():
|
def stream_stats():
|
||||||
return _sse_stream(_stats_broadcaster)
|
return sse_response(system_status_generator)
|
||||||
|
|
||||||
@app.route('/api/v3/stream/display')
|
@app.route('/api/v3/stream/display')
|
||||||
def stream_display():
|
def stream_display():
|
||||||
return _sse_stream(_display_broadcaster)
|
return sse_response(display_preview_generator)
|
||||||
|
|
||||||
@app.route('/api/v3/stream/logs')
|
@app.route('/api/v3/stream/logs')
|
||||||
def stream_logs():
|
def stream_logs():
|
||||||
return _sse_stream(_logs_broadcaster)
|
return sse_response(logs_generator)
|
||||||
|
|
||||||
# Exempt SSE streams from CSRF and apply a generous rate limit.
|
# Exempt SSE streams from CSRF and add rate limiting
|
||||||
# SSE connections are long-lived HTTP requests, not repeated API calls, so the
|
|
||||||
# tight "20 per minute" default would be exhausted quickly on reconnects.
|
|
||||||
if csrf:
|
if csrf:
|
||||||
csrf.exempt(stream_stats)
|
csrf.exempt(stream_stats)
|
||||||
csrf.exempt(stream_display)
|
csrf.exempt(stream_display)
|
||||||
@@ -699,9 +635,9 @@ if csrf:
|
|||||||
# Note: api_v3 blueprint is exempted above after registration
|
# Note: api_v3 blueprint is exempted above after registration
|
||||||
|
|
||||||
if limiter:
|
if limiter:
|
||||||
limiter.limit("200 per minute")(stream_stats)
|
limiter.limit("20 per minute")(stream_stats)
|
||||||
limiter.limit("200 per minute")(stream_display)
|
limiter.limit("20 per minute")(stream_display)
|
||||||
limiter.limit("200 per minute")(stream_logs)
|
limiter.limit("20 per minute")(stream_logs)
|
||||||
|
|
||||||
# Main route - redirect to v3 interface as default
|
# Main route - redirect to v3 interface as default
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@@ -780,41 +716,6 @@ def _run_startup_reconciliation() -> None:
|
|||||||
"manual 'Reconcile' action to resolve.",
|
"manual 'Reconcile' action to resolve.",
|
||||||
len(result.inconsistencies_manual),
|
len(result.inconsistencies_manual),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write status file so the web UI can surface unresolved issues as a
|
|
||||||
# banner without the user having to read journalctl. Mirrors the
|
|
||||||
# hw_status pattern (/tmp/led_matrix_hw_status.json).
|
|
||||||
import json as _json, tempfile as _tempfile, os as _os
|
|
||||||
_recon_status = {
|
|
||||||
"done": True,
|
|
||||||
"successful": result.reconciliation_successful,
|
|
||||||
"fixed_count": len(result.inconsistencies_fixed),
|
|
||||||
"unresolved": [
|
|
||||||
{
|
|
||||||
"plugin_id": inc.plugin_id,
|
|
||||||
"type": inc.inconsistency_type.value,
|
|
||||||
"description": inc.description,
|
|
||||||
}
|
|
||||||
for inc in result.inconsistencies_manual
|
|
||||||
],
|
|
||||||
}
|
|
||||||
_recon_path = _os.path.join(_tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
|
||||||
_tmp = None
|
|
||||||
try:
|
|
||||||
if not _os.path.islink(_recon_path):
|
|
||||||
_fd, _tmp = _tempfile.mkstemp(dir=_tempfile.gettempdir(), prefix=".led_recon_")
|
|
||||||
with _os.fdopen(_fd, "w") as _f:
|
|
||||||
_json.dump(_recon_status, _f)
|
|
||||||
_os.replace(_tmp, _recon_path)
|
|
||||||
_tmp = None # Rename succeeded; nothing to clean up
|
|
||||||
except (OSError, ValueError, TypeError) as _e:
|
|
||||||
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
|
|
||||||
finally:
|
|
||||||
if _tmp is not None and _os.path.exists(_tmp):
|
|
||||||
try:
|
|
||||||
_os.unlink(_tmp)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@ from flask import Blueprint, render_template, flash
|
|||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
|
|
||||||
@@ -86,11 +84,10 @@ def load_partial(partial_name):
|
|||||||
elif partial_name == 'operation-history':
|
elif partial_name == 'operation-history':
|
||||||
return _load_operation_history_partial()
|
return _load_operation_history_partial()
|
||||||
else:
|
else:
|
||||||
return "Partial not found", 404
|
return f"Partial '{partial_name}' not found", 404
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial %s", partial_name, exc_info=True)
|
return f"Error loading partial '{partial_name}': {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
|
|
||||||
@pages_v3.route('/partials/plugin-config/<plugin_id>')
|
@pages_v3.route('/partials/plugin-config/<plugin_id>')
|
||||||
@@ -98,9 +95,8 @@ def load_plugin_config_partial(plugin_id):
|
|||||||
"""Load plugin configuration partial via HTMX - server-side rendered form"""
|
"""Load plugin configuration partial via HTMX - server-side rendered form"""
|
||||||
try:
|
try:
|
||||||
return _load_plugin_config_partial(plugin_id)
|
return _load_plugin_config_partial(plugin_id)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
|
||||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
|
||||||
|
|
||||||
def _load_overview_partial():
|
def _load_overview_partial():
|
||||||
"""Load overview partial with system stats"""
|
"""Load overview partial with system stats"""
|
||||||
@@ -111,8 +107,7 @@ def _load_overview_partial():
|
|||||||
return render_template('v3/partials/overview.html',
|
return render_template('v3/partials/overview.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_general_partial():
|
def _load_general_partial():
|
||||||
"""Load general settings partial"""
|
"""Load general settings partial"""
|
||||||
@@ -122,8 +117,7 @@ def _load_general_partial():
|
|||||||
return render_template('v3/partials/general.html',
|
return render_template('v3/partials/general.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_display_partial():
|
def _load_display_partial():
|
||||||
"""Load display settings partial"""
|
"""Load display settings partial"""
|
||||||
@@ -133,8 +127,7 @@ def _load_display_partial():
|
|||||||
return render_template('v3/partials/display.html',
|
return render_template('v3/partials/display.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_durations_partial():
|
def _load_durations_partial():
|
||||||
"""Load display durations partial"""
|
"""Load display durations partial"""
|
||||||
@@ -144,8 +137,7 @@ def _load_durations_partial():
|
|||||||
return render_template('v3/partials/durations.html',
|
return render_template('v3/partials/durations.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_schedule_partial():
|
def _load_schedule_partial():
|
||||||
"""Load schedule settings partial"""
|
"""Load schedule settings partial"""
|
||||||
@@ -161,8 +153,7 @@ def _load_schedule_partial():
|
|||||||
dim_schedule_config=dim_schedule_config,
|
dim_schedule_config=dim_schedule_config,
|
||||||
normal_brightness=normal_brightness)
|
normal_brightness=normal_brightness)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
|
|
||||||
def _load_weather_partial():
|
def _load_weather_partial():
|
||||||
@@ -173,8 +164,7 @@ def _load_weather_partial():
|
|||||||
return render_template('v3/partials/weather.html',
|
return render_template('v3/partials/weather.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_stocks_partial():
|
def _load_stocks_partial():
|
||||||
"""Load stocks configuration partial"""
|
"""Load stocks configuration partial"""
|
||||||
@@ -184,8 +174,7 @@ def _load_stocks_partial():
|
|||||||
return render_template('v3/partials/stocks.html',
|
return render_template('v3/partials/stocks.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_plugins_partial():
|
def _load_plugins_partial():
|
||||||
"""Load plugins management partial"""
|
"""Load plugins management partial"""
|
||||||
@@ -219,7 +208,7 @@ def _load_plugins_partial():
|
|||||||
plugin_info.update(fresh_manifest)
|
plugin_info.update(fresh_manifest)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If we can't read the fresh manifest, use the cached one
|
# If we can't read the fresh manifest, use the cached one
|
||||||
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
|
print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}")
|
||||||
|
|
||||||
# Get enabled status from config (source of truth)
|
# Get enabled status from config (source of truth)
|
||||||
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
||||||
@@ -267,13 +256,12 @@ def _load_plugins_partial():
|
|||||||
'branch': branch
|
'branch': branch
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading plugin data", exc_info=True)
|
print(f"Error loading plugin data: {e}")
|
||||||
|
|
||||||
return render_template('v3/partials/plugins.html',
|
return render_template('v3/partials/plugins.html',
|
||||||
plugins=plugins_data)
|
plugins=plugins_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_fonts_partial():
|
def _load_fonts_partial():
|
||||||
"""Load fonts management partial"""
|
"""Load fonts management partial"""
|
||||||
@@ -283,16 +271,14 @@ def _load_fonts_partial():
|
|||||||
return render_template('v3/partials/fonts.html',
|
return render_template('v3/partials/fonts.html',
|
||||||
fonts=fonts_data)
|
fonts=fonts_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_logs_partial():
|
def _load_logs_partial():
|
||||||
"""Load logs viewer partial"""
|
"""Load logs viewer partial"""
|
||||||
try:
|
try:
|
||||||
return render_template('v3/partials/logs.html')
|
return render_template('v3/partials/logs.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_raw_json_partial():
|
def _load_raw_json_partial():
|
||||||
"""Load raw JSON editor partial"""
|
"""Load raw JSON editor partial"""
|
||||||
@@ -309,16 +295,14 @@ def _load_raw_json_partial():
|
|||||||
main_config_path=pages_v3.config_manager.get_config_path(),
|
main_config_path=pages_v3.config_manager.get_config_path(),
|
||||||
secrets_config_path=pages_v3.config_manager.get_secrets_path())
|
secrets_config_path=pages_v3.config_manager.get_secrets_path())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_backup_restore_partial():
|
def _load_backup_restore_partial():
|
||||||
"""Load backup & restore partial."""
|
"""Load backup & restore partial."""
|
||||||
try:
|
try:
|
||||||
return render_template('v3/partials/backup_restore.html')
|
return render_template('v3/partials/backup_restore.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
@pages_v3.route('/setup')
|
@pages_v3.route('/setup')
|
||||||
def captive_setup():
|
def captive_setup():
|
||||||
@@ -330,24 +314,21 @@ def _load_wifi_partial():
|
|||||||
try:
|
try:
|
||||||
return render_template('v3/partials/wifi.html')
|
return render_template('v3/partials/wifi.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_cache_partial():
|
def _load_cache_partial():
|
||||||
"""Load cache management partial"""
|
"""Load cache management partial"""
|
||||||
try:
|
try:
|
||||||
return render_template('v3/partials/cache.html')
|
return render_template('v3/partials/cache.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_operation_history_partial():
|
def _load_operation_history_partial():
|
||||||
"""Load operation history partial"""
|
"""Load operation history partial"""
|
||||||
try:
|
try:
|
||||||
return render_template('v3/partials/operation_history.html')
|
return render_template('v3/partials/operation_history.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
|
|
||||||
def _load_plugin_config_partial(plugin_id):
|
def _load_plugin_config_partial(plugin_id):
|
||||||
@@ -355,11 +336,6 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
Load plugin configuration partial - server-side rendered form.
|
Load plugin configuration partial - server-side rendered form.
|
||||||
This replaces the client-side generateConfigForm() JavaScript.
|
This replaces the client-side generateConfigForm() JavaScript.
|
||||||
"""
|
"""
|
||||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
|
||||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
|
|
||||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not pages_v3.plugin_manager:
|
if not pages_v3.plugin_manager:
|
||||||
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
|
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
|
||||||
@@ -368,14 +344,6 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
if plugin_id.startswith('starlark:'):
|
if plugin_id.startswith('starlark:'):
|
||||||
return _load_starlark_config_partial(plugin_id[len('starlark:'):])
|
return _load_starlark_config_partial(plugin_id[len('starlark:'):])
|
||||||
|
|
||||||
# Resolve and validate all plugin paths against the plugins base directory
|
|
||||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
|
||||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
|
||||||
try:
|
|
||||||
_plugin_dir.relative_to(_plugins_base)
|
|
||||||
except ValueError:
|
|
||||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
|
||||||
|
|
||||||
# Try to get plugin info first
|
# Try to get plugin info first
|
||||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||||
|
|
||||||
@@ -385,7 +353,7 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||||
|
|
||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
|
return f'<div class="text-red-500 p-4">Plugin "{escape(plugin_id)}" not found</div>', 404
|
||||||
|
|
||||||
# Get plugin instance (may be None if not loaded)
|
# Get plugin instance (may be None if not loaded)
|
||||||
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
||||||
@@ -397,56 +365,59 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
config = full_config.get(plugin_id, {})
|
config = full_config.get(plugin_id, {})
|
||||||
|
|
||||||
# Load uploaded images from metadata file if images field exists in schema
|
# Load uploaded images from metadata file if images field exists in schema
|
||||||
schema_path_temp = _plugin_dir / "config_schema.json"
|
# This ensures uploaded images appear even if config hasn't been saved yet
|
||||||
|
schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
|
||||||
if schema_path_temp.exists():
|
if schema_path_temp.exists():
|
||||||
try:
|
try:
|
||||||
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
||||||
temp_schema = json.load(f)
|
temp_schema = json.load(f)
|
||||||
|
# Check if schema has an images field with x-widget: file-upload
|
||||||
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
||||||
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
||||||
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
|
# Load metadata file
|
||||||
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
|
# Get PROJECT_ROOT relative to this file
|
||||||
try:
|
project_root = Path(__file__).parent.parent.parent
|
||||||
metadata_file.relative_to(_assets_base)
|
metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json'
|
||||||
except ValueError:
|
if metadata_file.exists():
|
||||||
metadata_file = None
|
|
||||||
if metadata_file and metadata_file.exists():
|
|
||||||
try:
|
try:
|
||||||
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
||||||
metadata = json.load(mf)
|
metadata = json.load(mf)
|
||||||
|
# Convert metadata dict to list of image objects
|
||||||
images_from_metadata = list(metadata.values())
|
images_from_metadata = list(metadata.values())
|
||||||
|
# Only use metadata images if config doesn't have images or config images is empty
|
||||||
if not config.get('images') or len(config.get('images', [])) == 0:
|
if not config.get('images') or len(config.get('images', [])) == 0:
|
||||||
config['images'] = images_from_metadata
|
config['images'] = images_from_metadata
|
||||||
else:
|
else:
|
||||||
|
# Merge: add metadata images that aren't already in config
|
||||||
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
|
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
|
||||||
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
|
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
|
||||||
if new_images:
|
if new_images:
|
||||||
config['images'] = config.get('images', []) + new_images
|
config['images'] = config.get('images', []) + new_images
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not load plugin upload metadata: %s", e)
|
print(f"Warning: Could not load metadata for {plugin_id}: {e}")
|
||||||
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
|
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
|
||||||
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
|
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
|
||||||
|
|
||||||
# Get plugin schema
|
# Get plugin schema
|
||||||
schema = {}
|
schema = {}
|
||||||
schema_path = _plugin_dir / "config_schema.json"
|
schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
|
||||||
if schema_path.exists():
|
if schema_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not load schema for plugin: %s", e)
|
print(f"Warning: Could not load schema for {plugin_id}: {e}")
|
||||||
|
|
||||||
# Get web UI actions from plugin manifest
|
# Get web UI actions from plugin manifest
|
||||||
web_ui_actions = []
|
web_ui_actions = []
|
||||||
manifest_path = _plugin_dir / "manifest.json"
|
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
||||||
if manifest_path.exists():
|
if manifest_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
web_ui_actions = manifest.get('web_ui_actions', [])
|
web_ui_actions = manifest.get('web_ui_actions', [])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not load manifest for plugin: %s", e)
|
print(f"Warning: Could not load manifest for {plugin_id}: {e}")
|
||||||
|
|
||||||
# Mask secret fields before rendering template (fail closed — never leak secrets)
|
# Mask secret fields before rendering template (fail closed — never leak secrets)
|
||||||
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
|
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
|
||||||
@@ -482,24 +453,20 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
import traceback
|
||||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
traceback.print_exc()
|
||||||
|
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
def _load_starlark_config_partial(app_id):
|
def _load_starlark_config_partial(app_id):
|
||||||
"""Load configuration partial for a Starlark app."""
|
"""Load configuration partial for a Starlark app."""
|
||||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
|
||||||
app_id = os.path.basename(app_id or '')
|
|
||||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
|
|
||||||
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
|
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
|
||||||
|
|
||||||
if starlark_plugin and hasattr(starlark_plugin, 'apps'):
|
if starlark_plugin and hasattr(starlark_plugin, 'apps'):
|
||||||
app = starlark_plugin.apps.get(app_id)
|
app = starlark_plugin.apps.get(app_id)
|
||||||
if not app:
|
if not app:
|
||||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||||
return render_template(
|
return render_template(
|
||||||
'v3/partials/starlark_config.html',
|
'v3/partials/starlark_config.html',
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
@@ -515,45 +482,36 @@ def _load_starlark_config_partial(app_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Standalone: read from manifest file
|
# Standalone: read from manifest file
|
||||||
starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve()
|
manifest_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / 'manifest.json'
|
||||||
manifest_file = starlark_base / 'manifest.json'
|
|
||||||
if not manifest_file.exists():
|
if not manifest_file.exists():
|
||||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||||
|
|
||||||
with open(manifest_file, 'r') as f:
|
with open(manifest_file, 'r') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
|
|
||||||
app_data = manifest.get('apps', {}).get(app_id)
|
app_data = manifest.get('apps', {}).get(app_id)
|
||||||
if not app_data:
|
if not app_data:
|
||||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||||
|
|
||||||
# Load schema from schema.json if it exists — validate path stays within starlark_base
|
# Load schema from schema.json if it exists
|
||||||
schema = None
|
schema = None
|
||||||
schema_file = (starlark_base / app_id / 'schema.json').resolve()
|
schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json'
|
||||||
try:
|
if schema_file.exists():
|
||||||
schema_file.relative_to(starlark_base)
|
|
||||||
except ValueError:
|
|
||||||
schema_file = None
|
|
||||||
if schema_file and schema_file.exists():
|
|
||||||
try:
|
try:
|
||||||
with open(schema_file, 'r') as f:
|
with open(schema_file, 'r') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
logger.warning("Could not load starlark schema for app: %s", e)
|
logger.warning(f"[Pages V3] Could not load schema for {app_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
# Load config from config.json if it exists — validate path stays within starlark_base
|
# Load config from config.json if it exists
|
||||||
config = {}
|
config = {}
|
||||||
config_file = (starlark_base / app_id / 'config.json').resolve()
|
config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json'
|
||||||
try:
|
if config_file.exists():
|
||||||
config_file.relative_to(starlark_base)
|
|
||||||
except ValueError:
|
|
||||||
config_file = None
|
|
||||||
if config_file and config_file.exists():
|
|
||||||
try:
|
try:
|
||||||
with open(config_file, 'r') as f:
|
with open(config_file, 'r') as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
logger.warning("Could not load starlark config for app: %s", e)
|
logger.warning(f"[Pages V3] Could not load config for {app_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'v3/partials/starlark_config.html',
|
'v3/partials/starlark_config.html',
|
||||||
@@ -570,5 +528,5 @@ def _load_starlark_config_partial(app_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("[Pages V3] Error loading starlark config for app", exc_info=True)
|
logger.exception(f"[Pages V3] Error loading starlark config for {app_id}")
|
||||||
return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500
|
return f'<div class="text-red-500 p-4">Error loading starlark config: {str(e)}</div>', 500
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def get_local_ips():
|
|||||||
ip = ip.strip()
|
ip = ip.strip()
|
||||||
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
||||||
ips.append(ip)
|
ips.append(ip)
|
||||||
except Exception: # nosec B110 - hostname -I output parsing; non-critical startup info
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback: try socket method
|
# Fallback: try socket method
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
|
/* global showNotification, updateSystemStats */
|
||||||
// LED Matrix v3 JavaScript
|
// LED Matrix v3 JavaScript
|
||||||
// Additional helpers for HTMX and Alpine.js integration
|
// Additional helpers for HTMX and Alpine.js integration
|
||||||
|
|
||||||
@@ -51,8 +51,7 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE reconnection helper — closes and reopens both SSE streams,
|
// SSE reconnection helper
|
||||||
// reattaching the open/error handlers defined in base.html.
|
|
||||||
window.reconnectSSE = function() {
|
window.reconnectSSE = function() {
|
||||||
if (window.statsSource) {
|
if (window.statsSource) {
|
||||||
window.statsSource.close();
|
window.statsSource.close();
|
||||||
@@ -61,18 +60,14 @@ window.reconnectSSE = function() {
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (typeof updateSystemStats === 'function') updateSystemStats(data);
|
if (typeof updateSystemStats === 'function') updateSystemStats(data);
|
||||||
};
|
};
|
||||||
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
|
|
||||||
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.displaySource) {
|
if (window.displaySource) {
|
||||||
window.displaySource.close();
|
window.displaySource.close();
|
||||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||||
window.displaySource.onmessage = function(event) {
|
window.displaySource.onmessage = function() {
|
||||||
const data = JSON.parse(event.data);
|
// Handle display updates
|
||||||
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
|
|
||||||
};
|
};
|
||||||
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -51,10 +51,8 @@
|
|||||||
sanitizeValue(value) {
|
sanitizeValue(value) {
|
||||||
// Base implementation - widgets should override for specific needs
|
// Base implementation - widgets should override for specific needs
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
// Strip all HTML tags via the DOM parser to prevent XSS
|
// Basic XSS prevention
|
||||||
const div = document.createElement('div');
|
return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||||
div.textContent = value;
|
|
||||||
return div.textContent;
|
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,7 +331,7 @@
|
|||||||
removeButton.type = 'button';
|
removeButton.type = 'button';
|
||||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||||
removeButton.addEventListener('click', function() {
|
removeButton.addEventListener('click', function() {
|
||||||
window.removeCustomFeedRow(this);
|
removeCustomFeedRow(this);
|
||||||
});
|
});
|
||||||
const removeIcon = document.createElement('i');
|
const removeIcon = document.createElement('i');
|
||||||
removeIcon.className = 'fas fa-trash';
|
removeIcon.className = 'fas fa-trash';
|
||||||
|
|||||||
@@ -1,783 +0,0 @@
|
|||||||
/**
|
|
||||||
* JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
|
|
||||||
*
|
|
||||||
* Usage via config_schema.json:
|
|
||||||
* "file_manager": {
|
|
||||||
* "type": "null",
|
|
||||||
* "title": "Data Files",
|
|
||||||
* "x-widget": "json-file-manager",
|
|
||||||
* "x-widget-config": {
|
|
||||||
* "actions": {
|
|
||||||
* "list": "list-files", // required
|
|
||||||
* "get": "get-file", // required for editing
|
|
||||||
* "save": "save-file", // required for editing
|
|
||||||
* "upload": "upload-file", // optional
|
|
||||||
* "delete": "delete-file", // optional
|
|
||||||
* "create": "create-file", // optional
|
|
||||||
* "toggle": "toggle-category" // optional
|
|
||||||
* },
|
|
||||||
* "upload_hint": "Hint text under the drop zone",
|
|
||||||
* "directory_label": "of_the_day/",
|
|
||||||
* "create_fields": [
|
|
||||||
* { "key": "category_name", "label": "Category Name",
|
|
||||||
* "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
|
|
||||||
* "hint": "Used as filename" },
|
|
||||||
* { "key": "display_name", "label": "Display Name",
|
|
||||||
* "placeholder": "My Words" }
|
|
||||||
* ],
|
|
||||||
* "toggle_key": "category_name"
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* No CDN dependencies. Works on all modern browsers.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
class JsonFileManager {
|
|
||||||
constructor(container, config, pluginId) {
|
|
||||||
// Prevent duplicate instances on the same container
|
|
||||||
if (container._jfmInstance) {
|
|
||||||
container._jfmInstance._destroy();
|
|
||||||
}
|
|
||||||
container._jfmInstance = this;
|
|
||||||
|
|
||||||
this.el = container;
|
|
||||||
this.pluginId = pluginId;
|
|
||||||
this.actions = config.actions || {};
|
|
||||||
this.uploadHint = config.upload_hint || '';
|
|
||||||
this.dirLabel = config.directory_label || '';
|
|
||||||
this.createFields = config.create_fields || [];
|
|
||||||
this.toggleKey = config.toggle_key || null;
|
|
||||||
|
|
||||||
// Unique prefix for all DOM IDs in this instance
|
|
||||||
this._uid = 'jfm_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
// Mutable state
|
|
||||||
this._editFile = null;
|
|
||||||
this._deleteFile = null;
|
|
||||||
this._keyHandler = this._onKey.bind(this);
|
|
||||||
|
|
||||||
this._inject();
|
|
||||||
this._bind();
|
|
||||||
this._loadList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_destroy() {
|
|
||||||
document.removeEventListener('keydown', this._keyHandler);
|
|
||||||
this.el._jfmInstance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DOM Injection ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_inject() {
|
|
||||||
const u = this._uid;
|
|
||||||
const hasUpload = !!this.actions.upload;
|
|
||||||
const hasCreate = !!this.actions.create;
|
|
||||||
const hasDelete = !!this.actions.delete;
|
|
||||||
|
|
||||||
this.el.innerHTML = this._css(u) + `
|
|
||||||
<div id="${u}" class="jfm">
|
|
||||||
|
|
||||||
<div class="jfm-header">
|
|
||||||
<div class="jfm-header-left">
|
|
||||||
<span class="jfm-title">Data Files</span>
|
|
||||||
${this.dirLabel ? `<code class="jfm-dir">${this._esc(this.dirLabel)}</code>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="jfm-header-right">
|
|
||||||
${hasCreate ? `<button type="button" class="jfm-btn jfm-btn-primary jfm-btn-sm" data-jfm="open-create">+ New File</button>` : ''}
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="refresh" title="Refresh file list">↻</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="${u}-list" class="jfm-list">
|
|
||||||
<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${hasUpload ? `
|
|
||||||
<div class="jfm-upload-wrap">
|
|
||||||
<input type="file" accept=".json" id="${u}-fileinput" tabindex="-1">
|
|
||||||
<div class="jfm-dropzone" id="${u}-dropzone" data-jfm="open-picker" role="button" tabindex="0"
|
|
||||||
aria-label="Upload JSON file">
|
|
||||||
<span class="jfm-drop-icon">📁</span>
|
|
||||||
<p class="jfm-drop-primary">Drop a JSON file here, or click to browse</p>
|
|
||||||
${this.uploadHint ? `<p class="jfm-drop-hint">${this._esc(this.uploadHint)}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<!-- ── Edit modal ─────────────────────────────────────── -->
|
|
||||||
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
|
|
||||||
<div class="jfm-modal-box jfm-modal-wide">
|
|
||||||
<div class="jfm-modal-head">
|
|
||||||
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
|
|
||||||
<div class="jfm-modal-tools">
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
|
|
||||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
|
|
||||||
<textarea id="${u}-editor" class="jfm-editor"
|
|
||||||
spellcheck="false" autocomplete="off"
|
|
||||||
autocorrect="off" autocapitalize="off"
|
|
||||||
aria-label="JSON editor"></textarea>
|
|
||||||
<div class="jfm-modal-foot">
|
|
||||||
<span id="${u}-charcount" class="jfm-stat"></span>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="save" id="${u}-save-btn">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Delete modal ───────────────────────────────────── -->
|
|
||||||
${hasDelete ? `
|
|
||||||
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
|
|
||||||
<div class="jfm-modal-box">
|
|
||||||
<div class="jfm-modal-head">
|
|
||||||
<span class="jfm-modal-title">Delete file</span>
|
|
||||||
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-body">
|
|
||||||
<p>Delete <strong id="${u}-del-name"></strong>?</p>
|
|
||||||
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-foot">
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<!-- ── Create modal ───────────────────────────────────── -->
|
|
||||||
${hasCreate ? `
|
|
||||||
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
|
|
||||||
<div class="jfm-modal-box">
|
|
||||||
<div class="jfm-modal-head">
|
|
||||||
<span class="jfm-modal-title">Create new file</span>
|
|
||||||
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-body">
|
|
||||||
${this.createFields.map(f => `
|
|
||||||
<div class="jfm-field">
|
|
||||||
<label for="${u}-cf-${this._esc(f.key)}">${this._esc(f.label)}</label>
|
|
||||||
<input type="text" id="${u}-cf-${this._esc(f.key)}"
|
|
||||||
placeholder="${this._esc(f.placeholder || '')}"
|
|
||||||
${f.pattern ? `pattern="${this._esc(f.pattern)}"` : ''}>
|
|
||||||
${f.hint ? `<span class="jfm-hint">${this._esc(f.hint)}</span>` : ''}
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-foot">
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-create">Cancel</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="do-create" id="${u}-create-btn">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
</div>`; // end #${u}
|
|
||||||
|
|
||||||
// Cache frequently-used elements
|
|
||||||
this._root = document.getElementById(u);
|
|
||||||
this._listEl = document.getElementById(`${u}-list`);
|
|
||||||
this._editorEl = document.getElementById(`${u}-editor`);
|
|
||||||
this._editModal = document.getElementById(`${u}-edit-modal`);
|
|
||||||
this._delModal = document.getElementById(`${u}-del-modal`);
|
|
||||||
this._createModal = document.getElementById(`${u}-create-modal`);
|
|
||||||
this._dropzone = document.getElementById(`${u}-dropzone`);
|
|
||||||
this._fileInput = document.getElementById(`${u}-fileinput`);
|
|
||||||
}
|
|
||||||
|
|
||||||
_css(u) {
|
|
||||||
return `<style>
|
|
||||||
#${u}{font-family:inherit;color:#111827;}
|
|
||||||
#${u} *{box-sizing:border-box;}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
|
|
||||||
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
|
|
||||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
|
|
||||||
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
|
||||||
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
|
||||||
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
|
|
||||||
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
|
|
||||||
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
|
|
||||||
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
|
|
||||||
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
|
|
||||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
|
|
||||||
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
|
|
||||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
|
|
||||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
|
|
||||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
|
|
||||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
|
||||||
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
|
|
||||||
|
|
||||||
/* File list */
|
|
||||||
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
|
|
||||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
|
|
||||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
|
|
||||||
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
|
|
||||||
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
|
|
||||||
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
|
|
||||||
|
|
||||||
/* File cards */
|
|
||||||
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
|
||||||
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
|
|
||||||
#${u} .jfm-card.jfm-off{opacity:.6;}
|
|
||||||
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
|
|
||||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
|
|
||||||
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
|
||||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
|
|
||||||
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
|
|
||||||
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
|
|
||||||
|
|
||||||
/* Toggle */
|
|
||||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
|
|
||||||
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
|
|
||||||
|
|
||||||
/* Upload zone */
|
|
||||||
#${u} .jfm-upload-wrap{margin-top:.25rem;}
|
|
||||||
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
|
|
||||||
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
|
|
||||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
|
|
||||||
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
|
|
||||||
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
|
|
||||||
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
|
|
||||||
|
|
||||||
/* Modals */
|
|
||||||
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
|
|
||||||
#${u} .jfm-modal[hidden]{display:none;}
|
|
||||||
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
|
||||||
#${u} .jfm-modal-wide{max-width:880px;}
|
|
||||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
|
|
||||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
||||||
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
|
|
||||||
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
|
|
||||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
|
|
||||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
|
|
||||||
|
|
||||||
/* JSON editor */
|
|
||||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
|
|
||||||
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
|
||||||
#${u} .jfm-err-bar[hidden]{display:none;}
|
|
||||||
|
|
||||||
/* Create form */
|
|
||||||
#${u} .jfm-field{margin-bottom:.875rem;}
|
|
||||||
#${u} .jfm-field:last-child{margin-bottom:0;}
|
|
||||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
|
|
||||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
|
|
||||||
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
|
|
||||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
|
|
||||||
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
|
|
||||||
|
|
||||||
/* Spinner */
|
|
||||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
|
||||||
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
|
|
||||||
</style>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event Binding ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_bind() {
|
|
||||||
// Delegated clicks on the widget root
|
|
||||||
this._root.addEventListener('click', this._onClick.bind(this));
|
|
||||||
this._root.addEventListener('change', this._onChange.bind(this));
|
|
||||||
|
|
||||||
// Drag-and-drop on the dropzone
|
|
||||||
if (this._dropzone) {
|
|
||||||
this._dropzone.addEventListener('dragover', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
this._dropzone.classList.add('jfm-over');
|
|
||||||
});
|
|
||||||
this._dropzone.addEventListener('dragleave', () => {
|
|
||||||
this._dropzone.classList.remove('jfm-over');
|
|
||||||
});
|
|
||||||
this._dropzone.addEventListener('drop', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
this._dropzone.classList.remove('jfm-over');
|
|
||||||
const file = e.dataTransfer?.files[0];
|
|
||||||
if (file) this._uploadFile(file);
|
|
||||||
});
|
|
||||||
// Keyboard activation of drop zone
|
|
||||||
this._dropzone.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
this._fileInput?.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal backdrop clicks
|
|
||||||
[this._editModal, this._delModal, this._createModal].forEach(m => {
|
|
||||||
if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Editor: char count + Tab indent
|
|
||||||
if (this._editorEl) {
|
|
||||||
this._editorEl.addEventListener('input', () => this._updateStat());
|
|
||||||
this._editorEl.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
const s = this._editorEl.selectionStart;
|
|
||||||
const end = this._editorEl.selectionEnd;
|
|
||||||
const v = this._editorEl.value;
|
|
||||||
this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
|
|
||||||
this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
|
|
||||||
this._updateStat();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', this._keyHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onKey(e) {
|
|
||||||
const editOpen = this._editModal && !this._editModal.hidden;
|
|
||||||
const delOpen = this._delModal && !this._delModal.hidden;
|
|
||||||
const createOpen = this._createModal && !this._createModal.hidden;
|
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
if (editOpen) { this._closeEdit(); return; }
|
|
||||||
if (delOpen) { this._closeDel(); return; }
|
|
||||||
if (createOpen) { this._closeCreate(); return; }
|
|
||||||
}
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
this._doSave();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onClick(e) {
|
|
||||||
const btn = e.target.closest('[data-jfm]');
|
|
||||||
if (!btn) return;
|
|
||||||
const action = btn.dataset.jfm;
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'refresh': this._loadList(); break;
|
|
||||||
case 'open-picker': this._fileInput?.click(); break;
|
|
||||||
case 'open-create': this._openCreate(); break;
|
|
||||||
case 'close-edit': this._closeEdit(); break;
|
|
||||||
case 'close-del': this._closeDel(); break;
|
|
||||||
case 'close-create': this._closeCreate(); break;
|
|
||||||
case 'fmt': this._formatJson(); break;
|
|
||||||
case 'validate': this._validateJson(); break;
|
|
||||||
case 'save': this._doSave(); break;
|
|
||||||
case 'confirm-del': this._doDelete(); break;
|
|
||||||
case 'do-create': this._doCreate(); break;
|
|
||||||
case 'edit-file': {
|
|
||||||
const card = btn.closest('[data-jfm-file]');
|
|
||||||
if (card) this._openEdit(card.dataset.jfmFile);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'del-file': {
|
|
||||||
const card = btn.closest('[data-jfm-file]');
|
|
||||||
if (card) this._openDel(card.dataset.jfmFile);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onChange(e) {
|
|
||||||
// Toggle checkbox
|
|
||||||
if (e.target.classList.contains('jfm-toggle-cb')) {
|
|
||||||
const catName = e.target.dataset.cat;
|
|
||||||
const enabled = e.target.checked;
|
|
||||||
this._doToggle(catName, enabled, e.target);
|
|
||||||
}
|
|
||||||
// File input
|
|
||||||
if (e.target === this._fileInput) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) this._uploadFile(file);
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API helper ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _api(actionKey, params) {
|
|
||||||
const actionId = Object.prototype.hasOwnProperty.call(this.actions, actionKey) ? this.actions[actionKey] : undefined;
|
|
||||||
if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
|
|
||||||
const body = { plugin_id: this.pluginId, action_id: actionId };
|
|
||||||
if (params !== undefined) body.params = params;
|
|
||||||
const r = await fetch('/api/v3/plugins/action', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
if (!r.ok) throw new Error('Server error ' + r.status);
|
|
||||||
const ct = r.headers.get('content-type') || '';
|
|
||||||
if (!ct.includes('application/json')) {
|
|
||||||
const txt = await r.text();
|
|
||||||
throw new Error('Unexpected response: ' + txt.slice(0, 120));
|
|
||||||
}
|
|
||||||
return r.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── File List ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _loadList() {
|
|
||||||
this._listEl.innerHTML = `<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>`;
|
|
||||||
try {
|
|
||||||
const data = await this._api('list');
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
|
||||||
this._renderList(data.files || []);
|
|
||||||
} catch (err) {
|
|
||||||
this._listEl.innerHTML = `
|
|
||||||
<div class="jfm-empty">
|
|
||||||
<div class="jfm-empty-icon">⚠</div>
|
|
||||||
<p class="jfm-empty-title">Failed to load files</p>
|
|
||||||
<p class="jfm-empty-sub">${this._esc(err.message)}</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderList(files) {
|
|
||||||
if (!files.length) {
|
|
||||||
this._listEl.innerHTML = `
|
|
||||||
<div class="jfm-empty">
|
|
||||||
<div class="jfm-empty-icon">📁</div>
|
|
||||||
<p class="jfm-empty-title">No files yet</p>
|
|
||||||
<p class="jfm-empty-sub">Upload or create a JSON file to get started</p>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._listEl.innerHTML = files.map(f => this._card(f)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
_card(f) {
|
|
||||||
const enabled = f.enabled !== false;
|
|
||||||
const displayName = this._esc(f.display_name || f.filename);
|
|
||||||
const filename = this._esc(f.filename);
|
|
||||||
const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
|
|
||||||
const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
|
|
||||||
const hasEdit = !!this.actions.get && !!this.actions.save;
|
|
||||||
const hasDelete = !!this.actions.delete;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="jfm-card${enabled ? '' : ' jfm-off'}" data-jfm-file="${filename}">
|
|
||||||
<div class="jfm-card-top">
|
|
||||||
<span class="jfm-card-name" title="${filename}">${displayName}</span>
|
|
||||||
${showToggle ? `
|
|
||||||
<label class="jfm-toggle" title="${enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}">
|
|
||||||
<input type="checkbox" class="jfm-toggle-cb" data-cat="${catName}" ${enabled ? 'checked' : ''}>
|
|
||||||
<span>${enabled ? 'On' : 'Off'}</span>
|
|
||||||
</label>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="jfm-card-meta">
|
|
||||||
<span>📄 ${filename}</span>
|
|
||||||
<span>📊 ${f.entry_count ?? 0} entries · ${this._fmtSize(f.size || 0)}</span>
|
|
||||||
<span>🕑 ${this._fmtDate(f.modified)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-card-actions">
|
|
||||||
${hasEdit ? `<button type="button" class="jfm-btn jfm-btn-sm" data-jfm="edit-file">✎ Edit</button>` : ''}
|
|
||||||
${hasDelete ? `<button type="button" class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">🗑</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Edit flow ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _openEdit(filename) {
|
|
||||||
this._editFile = filename;
|
|
||||||
document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
|
|
||||||
this._clearErr();
|
|
||||||
this._editorEl.value = 'Loading…';
|
|
||||||
this._updateStat();
|
|
||||||
this._editModal.hidden = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await this._api('get', { filename });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
|
||||||
this._editorEl.value = JSON.stringify(data.content, null, 2);
|
|
||||||
this._updateStat();
|
|
||||||
this._editorEl.focus();
|
|
||||||
this._editorEl.setSelectionRange(0, 0);
|
|
||||||
this._editorEl.scrollTop = 0;
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Failed to load file: ' + err.message);
|
|
||||||
this._editorEl.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeEdit() {
|
|
||||||
if (this._editModal) this._editModal.hidden = true;
|
|
||||||
this._editFile = null;
|
|
||||||
this._clearErr();
|
|
||||||
}
|
|
||||||
|
|
||||||
_formatJson() {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(this._editorEl.value);
|
|
||||||
this._editorEl.value = JSON.stringify(parsed, null, 2);
|
|
||||||
this._updateStat();
|
|
||||||
this._clearErr();
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Invalid JSON — ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_validateJson() {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(this._editorEl.value);
|
|
||||||
const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
|
|
||||||
this._clearErr();
|
|
||||||
this._notify(`Valid JSON — ${n} top-level keys`, 'success');
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Invalid JSON — ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doSave() {
|
|
||||||
if (!this._editFile) return;
|
|
||||||
let contentStr;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(this._editorEl.value);
|
|
||||||
contentStr = JSON.stringify(parsed, null, 2);
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Cannot save — fix JSON first: ' + err.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = document.getElementById(`${this._uid}-save-btn`);
|
|
||||||
this._busy(btn, 'Saving…');
|
|
||||||
try {
|
|
||||||
const data = await this._api('save', { filename: this._editFile, content: contentStr });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Save failed');
|
|
||||||
this._notify('File saved', 'success');
|
|
||||||
this._closeEdit();
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Save failed: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
this._idle(btn, 'Save');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete flow ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_openDel(filename) {
|
|
||||||
this._deleteFile = filename;
|
|
||||||
const el = document.getElementById(`${this._uid}-del-name`);
|
|
||||||
if (el) el.textContent = filename;
|
|
||||||
if (this._delModal) this._delModal.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeDel() {
|
|
||||||
if (this._delModal) this._delModal.hidden = true;
|
|
||||||
this._deleteFile = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doDelete() {
|
|
||||||
if (!this._deleteFile) return;
|
|
||||||
const btn = document.getElementById(`${this._uid}-del-btn`);
|
|
||||||
this._busy(btn, 'Deleting…');
|
|
||||||
try {
|
|
||||||
const data = await this._api('delete', { filename: this._deleteFile });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
|
|
||||||
this._notify('File deleted', 'success');
|
|
||||||
this._closeDel();
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Delete failed: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
this._idle(btn, 'Delete');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create flow ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_openCreate() {
|
|
||||||
if (!this._createModal) return;
|
|
||||||
this.createFields.forEach(f => {
|
|
||||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
|
||||||
if (el) el.value = '';
|
|
||||||
});
|
|
||||||
this._createModal.hidden = false;
|
|
||||||
const first = this.createFields[0];
|
|
||||||
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeCreate() {
|
|
||||||
if (this._createModal) this._createModal.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doCreate() {
|
|
||||||
const params = {};
|
|
||||||
for (const f of this.createFields) {
|
|
||||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
|
||||||
const val = (el?.value || '').trim();
|
|
||||||
// display_name may be blank — auto-derived from category_name below
|
|
||||||
if (!val && f.key !== 'display_name') {
|
|
||||||
this._notify(`"${f.label}" is required`, 'error');
|
|
||||||
el?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (f.pattern && val && el && el.validity.patternMismatch) {
|
|
||||||
this._notify(`"${f.label}" format is invalid`, 'error');
|
|
||||||
el?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (val) params[f.key] = val;
|
|
||||||
}
|
|
||||||
// Auto-derive display_name from category_name when left blank
|
|
||||||
if (!params.display_name && params.category_name) {
|
|
||||||
params.display_name = params.category_name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
}
|
|
||||||
const btn = document.getElementById(`${this._uid}-create-btn`);
|
|
||||||
this._busy(btn, 'Creating…');
|
|
||||||
try {
|
|
||||||
const data = await this._api('create', params);
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Create failed');
|
|
||||||
this._notify('File created', 'success');
|
|
||||||
this._closeCreate();
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Create failed: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
this._idle(btn, 'Create');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Upload ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _uploadFile(file) {
|
|
||||||
if (!file.name.endsWith('.json')) {
|
|
||||||
this._notify('Please select a .json file', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let content;
|
|
||||||
try {
|
|
||||||
content = await file.text();
|
|
||||||
JSON.parse(content); // client-side validation
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Invalid JSON: ' + err.message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._dropzone) this._dropzone.style.opacity = '.5';
|
|
||||||
try {
|
|
||||||
const data = await this._api('upload', { filename: file.name, content });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
|
|
||||||
this._notify(`"${file.name}" uploaded`, 'success');
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Upload failed: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
if (this._dropzone) this._dropzone.style.opacity = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toggle ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _doToggle(catName, enabled, checkbox) {
|
|
||||||
checkbox.disabled = true;
|
|
||||||
try {
|
|
||||||
const params = { enabled };
|
|
||||||
if (this.toggleKey) params[this.toggleKey] = catName;
|
|
||||||
const data = await this._api('toggle', params);
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
|
|
||||||
this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Toggle failed: ' + err.message, 'error');
|
|
||||||
checkbox.checked = !enabled; // revert
|
|
||||||
checkbox.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_closeAll() {
|
|
||||||
this._closeEdit();
|
|
||||||
this._closeDel();
|
|
||||||
this._closeCreate();
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateStat() {
|
|
||||||
const v = this._editorEl?.value || '';
|
|
||||||
const lines = v ? v.split('\n').length : 0;
|
|
||||||
const el = document.getElementById(`${this._uid}-charcount`);
|
|
||||||
if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_showErr(msg) {
|
|
||||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
|
||||||
if (el) { el.textContent = msg; el.hidden = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearErr() {
|
|
||||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
|
||||||
if (el) { el.textContent = ''; el.hidden = true; }
|
|
||||||
}
|
|
||||||
|
|
||||||
_notify(msg, type) {
|
|
||||||
if (typeof window.showNotification === 'function') {
|
|
||||||
window.showNotification(msg, type || 'info');
|
|
||||||
} else {
|
|
||||||
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_busy(btn, label) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn._jfmOrigText = btn.textContent;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '';
|
|
||||||
const spin = document.createElement('span');
|
|
||||||
spin.className = 'jfm-spin';
|
|
||||||
btn.appendChild(spin);
|
|
||||||
btn.appendChild(document.createTextNode(' ' + label));
|
|
||||||
}
|
|
||||||
|
|
||||||
_idle(btn, label) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = btn._jfmOrigText !== undefined ? btn._jfmOrigText : label;
|
|
||||||
delete btn._jfmOrigText;
|
|
||||||
}
|
|
||||||
|
|
||||||
_esc(str) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = String(str ?? '');
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
_fmtSize(bytes) {
|
|
||||||
if (!bytes) return '0 B';
|
|
||||||
const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
|
|
||||||
const unit = ['B', 'KB', 'MB'][i];
|
|
||||||
const val = bytes / Math.pow(1024, i);
|
|
||||||
return (i ? val.toFixed(1) : val) + ' ' + unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
_fmtDate(str) {
|
|
||||||
if (!str) return '—';
|
|
||||||
try {
|
|
||||||
return new Date(str).toLocaleDateString(undefined, {
|
|
||||||
month: 'short', day: 'numeric', year: 'numeric'
|
|
||||||
});
|
|
||||||
} catch { return str; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Widget registry integration ──────────────────────────────────────────
|
|
||||||
|
|
||||||
window.JsonFileManager = JsonFileManager;
|
|
||||||
|
|
||||||
if (typeof window.LEDMatrixWidgets !== 'undefined') {
|
|
||||||
window.LEDMatrixWidgets.register('json-file-manager', {
|
|
||||||
name: 'JSON File Manager',
|
|
||||||
version: '1.0.0',
|
|
||||||
render(container, config, _value, options) {
|
|
||||||
new JsonFileManager(container, config || {}, options?.pluginId || '');
|
|
||||||
},
|
|
||||||
getValue() { return null; },
|
|
||||||
setValue() {}
|
|
||||||
});
|
|
||||||
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
|
|
||||||
} else {
|
|
||||||
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
const parts = formatter.formatToParts(now);
|
const parts = formatter.formatToParts(now);
|
||||||
const offsetPart = parts.find(p => p.type === 'timeZoneName');
|
const offsetPart = parts.find(p => p.type === 'timeZoneName');
|
||||||
return offsetPart ? offsetPart.value : '';
|
return offsetPart ? offsetPart.value : '';
|
||||||
} catch {
|
} catch (e) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1442,14 +1442,9 @@ function renderInstalledPlugins(plugins) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to escape values for use in HTML attributes
|
// Helper function to escape attributes for use in HTML
|
||||||
const escapeAttr = (text) => {
|
const escapeAttr = (text) => {
|
||||||
return (text || '')
|
return (text || '').replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
|
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
|
||||||
@@ -3446,28 +3441,6 @@ function generateFieldHtml(key, prop, value, prefix = '') {
|
|||||||
html += `<option value="${option}" ${selected}>${option}</option>`;
|
html += `<option value="${option}" ${selected}>${option}</option>`;
|
||||||
});
|
});
|
||||||
html += `</select>`;
|
html += `</select>`;
|
||||||
} else if (prop['x-widget'] === 'json-file-manager') {
|
|
||||||
// Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions)
|
|
||||||
const widgetConfig = prop['x-widget-config'] || {};
|
|
||||||
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
|
|
||||||
const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
|
|
||||||
html += `<div id="${safeFieldId}_jfm_mount"></div>`;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const mount = document.getElementById(`${safeFieldId}_jfm_mount`);
|
|
||||||
if (!mount) return;
|
|
||||||
// Destroy the previous instance for this mount only — leave other instances intact
|
|
||||||
window.__jfmInstances = window.__jfmInstances || {};
|
|
||||||
const prev = window.__jfmInstances[safeFieldId];
|
|
||||||
if (prev?._destroy) prev._destroy();
|
|
||||||
if (typeof JsonFileManager !== 'undefined') {
|
|
||||||
window.__jfmInstances[safeFieldId] = new JsonFileManager(mount, widgetConfig, pluginId);
|
|
||||||
} else {
|
|
||||||
window.__jfmInstances[safeFieldId] = null;
|
|
||||||
mount.innerHTML = '<p style="color:#dc2626;font-size:.875rem;">json-file-manager widget not loaded. Check base.html includes json-file-manager.js.</p>';
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
} else if (prop['x-widget'] === 'custom-html') {
|
} else if (prop['x-widget'] === 'custom-html') {
|
||||||
// Custom HTML widget - load HTML from plugin directory
|
// Custom HTML widget - load HTML from plugin directory
|
||||||
const htmlFile = prop['x-html-file'];
|
const htmlFile = prop['x-html-file'];
|
||||||
@@ -4534,8 +4507,6 @@ function syncFormToJson() {
|
|||||||
// Deep merge with existing config to preserve nested structures
|
// Deep merge with existing config to preserve nested structures
|
||||||
function deepMerge(target, source) {
|
function deepMerge(target, source) {
|
||||||
for (const key in source) {
|
for (const key in source) {
|
||||||
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
|
||||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||||
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
|
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
|
||||||
target[key] = {};
|
target[key] = {};
|
||||||
@@ -7502,28 +7473,17 @@ setTimeout(function() {
|
|||||||
console.log('installed-plugins-grid not found yet, will retry via event listeners');
|
console.log('installed-plugins-grid not found yet, will retry via event listeners');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try to attach install button handler after a delay (fallback).
|
// Also try to attach install button handler after a delay (fallback)
|
||||||
// Only run if the install button element is already in the DOM (i.e. the
|
|
||||||
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
|
|
||||||
// below handles it when the tab is first visited.
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof window.attachInstallButtonHandler === 'function' &&
|
if (typeof window.attachInstallButtonHandler === 'function') {
|
||||||
document.getElementById('install-plugin-from-url')) {
|
console.log('[FALLBACK] Attempting to attach install button handler...');
|
||||||
window.attachInstallButtonHandler();
|
window.attachInstallButtonHandler();
|
||||||
|
} else {
|
||||||
|
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
// Re-run install button wiring after HTMX settles the plugins tab content.
|
|
||||||
// Guard with element check so it only fires when the plugins partial is in the DOM,
|
|
||||||
// preventing spurious warnings on other tab loads.
|
|
||||||
document.addEventListener('htmx:afterSettle', function() {
|
|
||||||
if (document.getElementById('install-plugin-from-url') &&
|
|
||||||
typeof window.attachInstallButtonHandler === 'function') {
|
|
||||||
window.attachInstallButtonHandler();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Starlark Apps Integration ──────────────────────────────────────────────
|
// ─── Starlark Apps Integration ──────────────────────────────────────────────
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
@@ -136,7 +136,6 @@
|
|||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (typeof htmx !== 'undefined') {
|
if (typeof htmx !== 'undefined') {
|
||||||
console.log('HTMX loaded from fallback');
|
console.log('HTMX loaded from fallback');
|
||||||
window.dispatchEvent(new Event('htmx:ready'));
|
|
||||||
// Load extensions after core loads
|
// Load extensions after core loads
|
||||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
||||||
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
||||||
@@ -153,7 +152,6 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('HTMX loaded successfully');
|
console.log('HTMX loaded successfully');
|
||||||
window.dispatchEvent(new Event('htmx:ready'));
|
|
||||||
// Load extensions after core loads
|
// Load extensions after core loads
|
||||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
||||||
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
||||||
@@ -351,20 +349,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set data-loaded on tab containers after HTMX settles their content,
|
|
||||||
// preventing repeated re-fetches on every tab switch.
|
|
||||||
// Scoped to elements with hx-trigger="revealed" (tab containers only) so
|
|
||||||
// modals and plugin config panels that legitimately reload are unaffected.
|
|
||||||
document.body.addEventListener('htmx:afterSettle', function(event) {
|
|
||||||
if (event.detail && event.detail.target) {
|
|
||||||
var target = event.detail.target;
|
|
||||||
var trigger = target.getAttribute('hx-trigger') || '';
|
|
||||||
if (trigger.includes('revealed')) {
|
|
||||||
target.setAttribute('data-loaded', 'true');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
||||||
@@ -427,9 +411,6 @@
|
|||||||
.then(html => {
|
.then(html => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
content.innerHTML = html;
|
content.innerHTML = html;
|
||||||
if (typeof htmx !== 'undefined') {
|
|
||||||
htmx.process(content);
|
|
||||||
}
|
|
||||||
// Trigger full initialization chain
|
// Trigger full initialization chain
|
||||||
if (window.pluginManager) {
|
if (window.pluginManager) {
|
||||||
window.pluginManager.initialized = false;
|
window.pluginManager.initialized = false;
|
||||||
@@ -449,7 +430,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if HTMX doesn't load within 5 seconds
|
// Fallback if HTMX doesn't load within 5 seconds
|
||||||
var _pluginsFallbackTimer = setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof htmx === 'undefined') {
|
if (typeof htmx === 'undefined') {
|
||||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
||||||
// Load plugins tab content directly regardless of active tab,
|
// Load plugins tab content directly regardless of active tab,
|
||||||
@@ -457,7 +438,6 @@
|
|||||||
loadPluginsDirect();
|
loadPluginsDirect();
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
|
|
||||||
</script>
|
</script>
|
||||||
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
||||||
<script>
|
<script>
|
||||||
@@ -1050,9 +1030,6 @@
|
|||||||
.then(html => {
|
.then(html => {
|
||||||
overviewContent.innerHTML = html;
|
overviewContent.innerHTML = html;
|
||||||
overviewContent.setAttribute('data-loaded', 'true');
|
overviewContent.setAttribute('data-loaded', 'true');
|
||||||
if (typeof htmx !== 'undefined') {
|
|
||||||
htmx.process(overviewContent);
|
|
||||||
}
|
|
||||||
// Re-initialize Alpine.js for the new content
|
// Re-initialize Alpine.js for the new content
|
||||||
if (window.Alpine) {
|
if (window.Alpine) {
|
||||||
window.Alpine.initTree(overviewContent);
|
window.Alpine.initTree(overviewContent);
|
||||||
@@ -1081,7 +1058,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Also try direct load if HTMX doesn't load within 5 seconds
|
// Also try direct load if HTMX doesn't load within 5 seconds
|
||||||
var _overviewFallbackTimer = setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof htmx === 'undefined') {
|
if (typeof htmx === 'undefined') {
|
||||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
||||||
const appElement = document.querySelector('[x-data="app()"]');
|
const appElement = document.querySelector('[x-data="app()"]');
|
||||||
@@ -1093,7 +1070,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- General tab -->
|
<!-- General tab -->
|
||||||
@@ -1370,64 +1346,34 @@
|
|||||||
|
|
||||||
<!-- SSE connection for real-time updates -->
|
<!-- SSE connection for real-time updates -->
|
||||||
<script>
|
<script>
|
||||||
// Assign to window so reconnectSSE() in app.js can reach them.
|
// Connect to SSE streams
|
||||||
window.statsSource = new EventSource('/api/v3/stream/stats');
|
const statsSource = new EventSource('/api/v3/stream/stats');
|
||||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
const displaySource = new EventSource('/api/v3/stream/display');
|
||||||
|
|
||||||
window.statsSource.onmessage = function(event) {
|
statsSource.onmessage = function(event) {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
updateSystemStats(data);
|
updateSystemStats(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.displaySource.onmessage = function(event) {
|
displaySource.onmessage = function(event) {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
updateDisplayPreview(data);
|
updateDisplayPreview(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
function _setConnectionStatus(connected, reconnecting) {
|
// Connection status
|
||||||
const el = document.getElementById('connection-status');
|
statsSource.addEventListener('open', function() {
|
||||||
if (!el) return;
|
document.getElementById('connection-status').innerHTML = `
|
||||||
if (connected) {
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span class="text-gray-600">Connected</span>
|
<span class="text-gray-600">Connected</span>
|
||||||
`;
|
`;
|
||||||
} else if (reconnecting) {
|
});
|
||||||
el.innerHTML = `
|
|
||||||
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
statsSource.addEventListener('error', function() {
|
||||||
<span class="text-gray-600">Reconnecting…</span>
|
document.getElementById('connection-status').innerHTML = `
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||||
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
|
<span class="text-gray-600">Disconnected</span>
|
||||||
`;
|
`;
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
var _statsErrorCount = 0;
|
|
||||||
|
|
||||||
// Named on window so reconnectSSE() in app.js can reattach them after
|
|
||||||
// replacing the EventSource instances.
|
|
||||||
window._statsOpenHandler = function() {
|
|
||||||
_statsErrorCount = 0;
|
|
||||||
_setConnectionStatus(true, false);
|
|
||||||
};
|
|
||||||
window._statsErrorHandler = function() {
|
|
||||||
_statsErrorCount++;
|
|
||||||
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
|
|
||||||
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
|
|
||||||
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
|
|
||||||
};
|
|
||||||
window._displayErrorHandler = function() {
|
|
||||||
// Display stream errors don't change the status badge but log to console
|
|
||||||
// so failures aren't completely silent.
|
|
||||||
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.statsSource.addEventListener('open', window._statsOpenHandler);
|
|
||||||
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
|
||||||
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
|
||||||
|
|
||||||
function updateSystemStats(data) {
|
function updateSystemStats(data) {
|
||||||
// Update CPU in header
|
// Update CPU in header
|
||||||
@@ -1870,18 +1816,13 @@
|
|||||||
htmx.trigger(contentEl, 'revealed');
|
htmx.trigger(contentEl, 'revealed');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// HTMX is still loading asynchronously — retry when it signals ready,
|
// HTMX not available, use direct fetch
|
||||||
// or fall back to direct fetch if it fails to load entirely.
|
console.warn('HTMX not available, using direct fetch for tab:', tab);
|
||||||
const self = this;
|
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
|
||||||
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
|
loadOverviewDirect();
|
||||||
function onFailed() {
|
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
|
||||||
window.removeEventListener('htmx:ready', onReady);
|
loadWifiDirect();
|
||||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
|
||||||
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
|
||||||
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
|
||||||
}
|
}
|
||||||
window.addEventListener('htmx:ready', onReady, { once: true });
|
|
||||||
window.addEventListener('htmx-load-failed', onFailed, { once: true });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -4625,9 +4566,6 @@
|
|||||||
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
||||||
|
|
||||||
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
|
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
|
|
||||||
|
|
||||||
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
||||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "start_display"}'
|
hx-vals='{"action": "start_display"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||||
<i class="fas fa-play mr-2"></i>
|
<i class="fas fa-play mr-2"></i>
|
||||||
Start Display
|
Start Display
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "stop_display"}'
|
hx-vals='{"action": "stop_display"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||||
<i class="fas fa-stop mr-2"></i>
|
<i class="fas fa-stop mr-2"></i>
|
||||||
Stop Display
|
Stop Display
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "git_pull"}'
|
hx-vals='{"action": "git_pull"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
<i class="fas fa-download mr-2"></i>
|
<i class="fas fa-download mr-2"></i>
|
||||||
Update Code
|
Update Code
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
hx-vals='{"action": "reboot_system"}'
|
hx-vals='{"action": "reboot_system"}'
|
||||||
hx-confirm="Are you sure you want to reboot the system?"
|
hx-confirm="Are you sure you want to reboot the system?"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
||||||
<i class="fas fa-power-off mr-2"></i>
|
<i class="fas fa-power-off mr-2"></i>
|
||||||
Reboot System
|
Reboot System
|
||||||
|
|||||||
@@ -4,25 +4,6 @@
|
|||||||
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
|
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hardware status banner: shown when display service is in fallback/simulation mode -->
|
|
||||||
<div x-data="{ show: false, errorMsg: '' }"
|
|
||||||
x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => {
|
|
||||||
const hw = (d && d.data) || {};
|
|
||||||
if (hw.ok === false) { show = true; errorMsg = hw.error || 'Unknown error'; }
|
|
||||||
}).catch(() => {})"
|
|
||||||
x-show="show"
|
|
||||||
style="display:none"
|
|
||||||
class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-6">
|
|
||||||
<p class="font-semibold text-yellow-800"><i class="fas fa-exclamation-triangle mr-2"></i>LED matrix running in simulation mode</p>
|
|
||||||
<p class="text-sm text-yellow-700 mt-1">Hardware initialization failed: <span x-text="errorMsg" class="font-mono text-xs break-all"></span></p>
|
|
||||||
<p class="text-sm text-yellow-700 mt-2">
|
|
||||||
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
|
|
||||||
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>)
|
|
||||||
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
|
|
||||||
Check the <a href="/v3/logs" class="underline font-medium">Logs tab</a> for the full error.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form hx-post="/api/v3/config/main"
|
<form hx-post="/api/v3/config/main"
|
||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
@@ -168,7 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown</label>
|
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
@@ -176,20 +157,9 @@
|
|||||||
name="gpio_slowdown"
|
name="gpio_slowdown"
|
||||||
value="{{ main_config.display.runtime.gpio_slowdown or 3 }}"
|
value="{{ main_config.display.runtime.gpio_slowdown or 3 }}"
|
||||||
min="0"
|
min="0"
|
||||||
max="10"
|
max="5"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
<p class="mt-1 text-sm text-gray-600">Pi 3: 1–2 · Pi 4: 2–4 · Pi 5 PIO: 1–3. Increase if display shows garbage; in RIO mode higher values may improve performance.</p>
|
<p class="mt-1 text-sm text-gray-600">GPIO slowdown factor (0-5)</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="rp1_rio" class="block text-sm font-medium text-gray-700">
|
|
||||||
RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>
|
|
||||||
</label>
|
|
||||||
<select id="rp1_rio" name="rp1_rio" class="form-control">
|
|
||||||
<option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 — PIO (default, low CPU)</option>
|
|
||||||
<option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 — RIO (higher throughput; slowdown inverted)</option>
|
|
||||||
</select>
|
|
||||||
<p class="mt-1 text-sm text-gray-600">Pi 5 RP1 coprocessor mode. Ignored on Pi 3/4.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -843,14 +843,6 @@ async function updateFontPreview() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// BDF bitmap fonts cannot be rendered server-side — skip the API call
|
|
||||||
if (family.toLowerCase().endsWith('.bdf')) {
|
|
||||||
previewImage.style.display = 'none';
|
|
||||||
loadingText.style.display = 'block';
|
|
||||||
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
loadingText.textContent = 'Loading preview...';
|
loadingText.textContent = 'Loading preview...';
|
||||||
loadingText.style.display = 'block';
|
loadingText.style.display = 'block';
|
||||||
|
|||||||
@@ -1,66 +1,3 @@
|
|||||||
<!-- Reconciliation warning banner: shown when startup reconciliation found stale plugin config entries -->
|
|
||||||
<div id="reconciliation-banner" class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
|
|
||||||
<div class="flex-shrink-0 mr-3 mt-0.5">
|
|
||||||
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium text-yellow-800">Plugin Config Warning</p>
|
|
||||||
<p class="text-sm text-yellow-700 mt-1" id="reconciliation-banner-text"></p>
|
|
||||||
</div>
|
|
||||||
<button type="button" onclick="window.dismissReconciliationBanner()" class="ml-4 flex-shrink-0 text-yellow-500 hover:text-yellow-700" aria-label="Dismiss">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
|
|
||||||
var _recon_timer = null;
|
|
||||||
|
|
||||||
function checkReconciliation() {
|
|
||||||
fetch('/api/v3/plugins/reconciliation-status')
|
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (resp) {
|
|
||||||
var d = resp.data || {};
|
|
||||||
if (!d.done) {
|
|
||||||
// Reconciliation still running — poll again shortly
|
|
||||||
_recon_timer = setTimeout(checkReconciliation, 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_recon_timer = null;
|
|
||||||
if (!d.unresolved || d.unresolved.length === 0) return;
|
|
||||||
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
|
||||||
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
|
||||||
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
|
|
||||||
document.getElementById('reconciliation-banner-text').textContent =
|
|
||||||
'Stale plugin config entries found: ' + ids +
|
|
||||||
'. Remove them from config.json or reinstall via the Plugin Store.';
|
|
||||||
var banner = document.getElementById('reconciliation-banner');
|
|
||||||
banner.dataset.dismissKey = key;
|
|
||||||
banner.style.setProperty('display', 'flex', 'important');
|
|
||||||
})
|
|
||||||
.catch(function () {});
|
|
||||||
}
|
|
||||||
checkReconciliation();
|
|
||||||
|
|
||||||
window.dismissReconciliationBanner = function () {
|
|
||||||
var banner = document.getElementById('reconciliation-banner');
|
|
||||||
banner.style.setProperty('display', 'none', 'important');
|
|
||||||
if (_recon_timer !== null) {
|
|
||||||
clearTimeout(_recon_timer);
|
|
||||||
_recon_timer = null;
|
|
||||||
}
|
|
||||||
// Persist dismissal immediately so the banner won't reappear on reload
|
|
||||||
// even if the background sync fetch below fails.
|
|
||||||
var key = banner.dataset.dismissKey;
|
|
||||||
if (key) {
|
|
||||||
try { sessionStorage.setItem(DISMISS_KEY, key); } catch (e) {}
|
|
||||||
}
|
|
||||||
// Background sync only — do not rely on this for DISMISS_KEY or hiding.
|
|
||||||
fetch('/api/v3/plugins/reconciliation-status').catch(function () {});
|
|
||||||
};
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
||||||
@@ -151,7 +88,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "start_display"}'
|
hx-vals='{"action": "start_display"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||||
<i class="fas fa-play mr-2"></i>
|
<i class="fas fa-play mr-2"></i>
|
||||||
Start Display
|
Start Display
|
||||||
@@ -160,7 +97,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "stop_display"}'
|
hx-vals='{"action": "stop_display"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||||
<i class="fas fa-stop mr-2"></i>
|
<i class="fas fa-stop mr-2"></i>
|
||||||
Stop Display
|
Stop Display
|
||||||
@@ -170,7 +107,7 @@
|
|||||||
hx-vals='{"action": "git_pull"}'
|
hx-vals='{"action": "git_pull"}'
|
||||||
hx-confirm="This will stash any local changes and update the code. Continue?"
|
hx-confirm="This will stash any local changes and update the code. Continue?"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||||
<i class="fas fa-download mr-2"></i>
|
<i class="fas fa-download mr-2"></i>
|
||||||
Update Code
|
Update Code
|
||||||
@@ -180,7 +117,7 @@
|
|||||||
hx-vals='{"action": "reboot_system"}'
|
hx-vals='{"action": "reboot_system"}'
|
||||||
hx-confirm="Are you sure you want to reboot the system?"
|
hx-confirm="Are you sure you want to reboot the system?"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
||||||
<i class="fas fa-power-off mr-2"></i>
|
<i class="fas fa-power-off mr-2"></i>
|
||||||
Reboot System
|
Reboot System
|
||||||
@@ -190,7 +127,7 @@
|
|||||||
hx-vals='{"action": "shutdown_system"}'
|
hx-vals='{"action": "shutdown_system"}'
|
||||||
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
|
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System shutting down...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
|
||||||
<i class="fas fa-power-off mr-2"></i>
|
<i class="fas fa-power-off mr-2"></i>
|
||||||
Shutdown System
|
Shutdown System
|
||||||
@@ -199,7 +136,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "restart_display_service"}'
|
hx-vals='{"action": "restart_display_service"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||||
<i class="fas fa-redo mr-2"></i>
|
<i class="fas fa-redo mr-2"></i>
|
||||||
Restart Display Service
|
Restart Display Service
|
||||||
@@ -208,7 +145,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "restart_web_service"}'
|
hx-vals='{"action": "restart_web_service"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Web service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||||
<i class="fas fa-redo mr-2"></i>
|
<i class="fas fa-redo mr-2"></i>
|
||||||
Restart Web Service
|
Restart Web Service
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
||||||
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
||||||
{% set description = prop.description if prop.description else '' %}
|
{% set description = prop.description if prop.description else '' %}
|
||||||
{% set _pt = prop.get('type') %}
|
{% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %}
|
||||||
{% set field_type = _pt if (_pt is string) else ((_pt | first) if (_pt and _pt is iterable and _pt is not string) else 'string') %}
|
|
||||||
|
|
||||||
{# Handle nested objects - check for widget first #}
|
{# Handle nested objects - check for widget first #}
|
||||||
{% if field_type == 'object' %}
|
{% if field_type == 'object' %}
|
||||||
|
|||||||
Reference in New Issue
Block a user