diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml deleted file mode 100644 index e82700c8..00000000 --- a/.github/workflows/security-audit.yml +++ /dev/null @@ -1,277 +0,0 @@ -name: Security Audit - -on: - push: - branches: - - main - - 'feat/**' - pull_request: - branches: - - main - schedule: - # Weekly full scan — catches new CVEs in existing deps - - cron: '0 6 * * 1' - workflow_dispatch: - -permissions: - contents: read - pull-requests: write - security-events: write - -jobs: - - # ───────────────────────────────────────────────────────────────────────── - # SAST — Static Application Security Testing - # ───────────────────────────────────────────────────────────────────────── - sast: - name: Static Analysis (bandit + semgrep) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install SAST tools - run: pip install bandit==1.8.3 semgrep - - # Bandit — Python-specific security linter - # --exit-zero: findings are warnings, not CI blockers. - # The security-report job interprets severity. - - name: Run bandit - run: | - bandit -r src/ web_interface/ \ - -c bandit.yaml \ - -f json \ - -o bandit-results.json \ - --exit-zero - - # Semgrep — broader pattern-based analysis - # || true: prevents network/rate-limit errors from blocking the workflow - - name: Run semgrep - run: | - semgrep --config "p/python" \ - --config "p/flask" \ - --json \ - --output semgrep-results.json \ - src/ web_interface/ \ - || true - - - name: Upload SAST artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: sast-results - path: | - bandit-results.json - semgrep-results.json - retention-days: 30 - - - # ───────────────────────────────────────────────────────────────────────── - # Dependency Vulnerability Scanning - # ───────────────────────────────────────────────────────────────────────── - dependency-audit: - name: Dependency Audit (pip-audit + safety) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install audit tools - run: pip install pip-audit safety - - # Install project deps. Hardware-specific packages (rgbmatrix) will fail - # to build on Ubuntu runners — || true handles this gracefully. - # pip-audit operates on installed packages; partial install is acceptable. - - name: Install project dependencies - run: | - pip install -r requirements.txt || true - pip install -r web_interface/requirements.txt || true - pip install -r requirements-emulator.txt || true - - - name: Run pip-audit - run: | - pip-audit --format json --output pip-audit-results.json || true - - - name: Run safety check - run: | - safety check --output json > safety-results.json 2>&1 || true - - - name: Upload dependency audit artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: dependency-audit-results - path: | - pip-audit-results.json - safety-results.json - retention-days: 30 - - - # ───────────────────────────────────────────────────────────────────────── - # Secrets Detection - # ───────────────────────────────────────────────────────────────────────── - secrets-scan: - name: Secrets Scan (gitleaks) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Full history for scanning all commits - - # continue-on-error: config/config_secrets.template.json contains - # placeholder strings (YOUR_*) that may trigger gitleaks rules. - # The generate_report.py script suppresses these false positives. - - name: Run gitleaks - uses: gitleaks/gitleaks-action@v2 - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload secrets scan artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: secrets-scan-results - path: results.sarif - retention-days: 30 - - - # ───────────────────────────────────────────────────────────────────────── - # LEDMatrix-Specific Security Proofs - # ───────────────────────────────────────────────────────────────────────── - ledmatrix-security-proofs: - name: LEDMatrix Security Proofs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: pip install -r requirements.txt || true - - # Script exits 1 only on CRITICAL findings. - # Warnings are reported but do not block the workflow. - - name: Run security proofs - run: | - python scripts/prove_security.py \ - --output security-proofs-results.json \ - --verbose - - - name: Upload proofs artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: security-proofs-results - path: security-proofs-results.json - retention-days: 30 - - - # ───────────────────────────────────────────────────────────────────────── - # Plugin Security Audit - # ───────────────────────────────────────────────────────────────────────── - plugin-audit: - name: Plugin Security Audit - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - # Script exits 1 only on CRITICAL findings (eval/exec in plugins). - # Missing manifest.json etc are warnings. - - name: Run plugin audit - run: | - python scripts/audit_plugins.py \ - --output plugin-audit-results.json \ - --verbose - - - name: Upload plugin audit artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: plugin-audit-results - path: plugin-audit-results.json - retention-days: 30 - - - # ───────────────────────────────────────────────────────────────────────── - # Aggregate Report - # ───────────────────────────────────────────────────────────────────────── - security-report: - name: Security Report - runs-on: ubuntu-latest - needs: - - sast - - dependency-audit - - secrets-scan - - ledmatrix-security-proofs - - plugin-audit - if: always() # Run even if upstream jobs fail or are skipped - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: audit-artifacts/ - - - name: Generate consolidated report - run: | - python scripts/generate_report.py \ - --artifact-dir audit-artifacts/ \ - --output security-report.md \ - --verbose - - - name: Upload consolidated report - uses: actions/upload-artifact@v4 - with: - name: security-report - path: security-report.md - retention-days: 90 - - - name: Comment on PR - if: github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const report = fs.readFileSync('security-report.md', 'utf8'); - // Use sticky comment — update existing comment rather than adding a new one each run - const { data: comments } = await github.rest.issues.listComments({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - }); - const botComment = comments.find(c => - c.user.type === 'Bot' && c.body.includes('🔒 Security Audit') - ); - if (botComment) { - await github.rest.issues.updateComment({ - comment_id: botComment.id, - owner: context.repo.owner, - repo: context.repo.repo, - body: report, - }); - } else { - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: report, - }); - } diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index c59cac7d..00000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -jobs: - test: - name: pytest (Python ${{ matrix.python-version }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ['3.10', '3.11', '3.12'] - steps: - - name: Check out repository - uses: actions/checkout@v4 - with: - submodules: false # rgbmatrix submodule not needed in EMULATOR mode - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - # Optional deps that some test modules import - pip install scipy psutil Flask-Limiter - - - name: Run tests - env: - EMULATOR: "true" - run: | - pytest \ - -m "not hardware and not slow" \ - --tb=short diff --git a/assets/sports/ncaa_logos/COLGATE.png b/assets/sports/ncaa_logos/COLGATE.png deleted file mode 100644 index 9d17a9d0..00000000 Binary files a/assets/sports/ncaa_logos/COLGATE.png and /dev/null differ diff --git a/assets/sports/ncaa_logos/LEHIGH.png b/assets/sports/ncaa_logos/LEHIGH.png deleted file mode 100644 index cbe8f4e2..00000000 Binary files a/assets/sports/ncaa_logos/LEHIGH.png and /dev/null differ diff --git a/assets/sports/ncaa_logos/MICHIGAN.png b/assets/sports/ncaa_logos/MICHIGAN.png deleted file mode 100644 index 3929cafe..00000000 Binary files a/assets/sports/ncaa_logos/MICHIGAN.png and /dev/null differ diff --git a/assets/sports/ncaa_logos/RUTGERS.png b/assets/sports/ncaa_logos/RUTGERS.png deleted file mode 100644 index 19e826dc..00000000 Binary files a/assets/sports/ncaa_logos/RUTGERS.png and /dev/null differ diff --git a/bandit.yaml b/bandit.yaml deleted file mode 100644 index abc7b273..00000000 --- a/bandit.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# bandit.yaml — LEDMatrix bandit configuration -# https://bandit.readthedocs.io/en/latest/config.html -# -# Skips are justified by the specific codebase context documented below. -# Do not remove skips without updating the justification comment. - -skips: - # B104: Binding to all interfaces (0.0.0.0) - # Intentional — the Flask server binds 0.0.0.0 for LAN access on a Raspberry Pi. - # This is not internet-facing and is documented in web_interface/app.py. - - B104 - - # B603: subprocess call without shell=True - # All subprocess.run() calls in this codebase use list arguments (confirmed by - # grep — zero uses of shell=True in src/ or web_interface/). List args prevent - # shell injection. See src/common/permission_utils.py for the primary usage. - - B603 - - # B607: Starting a process with a partial executable path - # The subprocess calls invoke system utilities (systemctl, sudo, git) by name. - # These are fixed-list invocations, not user-controlled, and rely on PATH. - - B607 - -exclude_dirs: - - tests - - test - - venv - - .venv - - rpi-rgb-led-matrix-master - # prove_security.py intentionally contains detection patterns as string literals - # (e.g. "eval(", "exec(") to search for in other files — bandit would flag - # these as false positives. - - scripts/prove_security.py diff --git a/plugin-repos/7-segment-clock b/plugin-repos/7-segment-clock deleted file mode 120000 index 163f4f3a..00000000 --- a/plugin-repos/7-segment-clock +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/7-segment-clock \ No newline at end of file diff --git a/plugin-repos/baseball-scoreboard b/plugin-repos/baseball-scoreboard deleted file mode 120000 index 63432824..00000000 --- a/plugin-repos/baseball-scoreboard +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/baseball-scoreboard \ No newline at end of file diff --git a/plugin-repos/basketball-scoreboard b/plugin-repos/basketball-scoreboard deleted file mode 120000 index ff0117c3..00000000 --- a/plugin-repos/basketball-scoreboard +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/basketball-scoreboard \ No newline at end of file diff --git a/plugin-repos/calendar b/plugin-repos/calendar deleted file mode 120000 index c4b8324a..00000000 --- a/plugin-repos/calendar +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/calendar \ No newline at end of file diff --git a/plugin-repos/christmas-countdown b/plugin-repos/christmas-countdown deleted file mode 120000 index ec1aa2b8..00000000 --- a/plugin-repos/christmas-countdown +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/christmas-countdown \ No newline at end of file diff --git a/plugin-repos/clock-simple b/plugin-repos/clock-simple deleted file mode 120000 index 5e584126..00000000 --- a/plugin-repos/clock-simple +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/clock-simple \ No newline at end of file diff --git a/plugin-repos/countdown b/plugin-repos/countdown deleted file mode 120000 index cd392140..00000000 --- a/plugin-repos/countdown +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/countdown \ No newline at end of file diff --git a/plugin-repos/f1-scoreboard b/plugin-repos/f1-scoreboard deleted file mode 120000 index 2ec020ef..00000000 --- a/plugin-repos/f1-scoreboard +++ /dev/null @@ -1 +0,0 @@ -/home/chuck/Github/ledmatrix-plugins/plugins/f1-scoreboard \ No newline at end of file diff --git a/plugin-repos/football-scoreboard b/plugin-repos/football-scoreboard deleted file mode 120000 index 328f9e76..00000000 --- a/plugin-repos/football-scoreboard +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/football-scoreboard \ No newline at end of file diff --git a/plugin-repos/hello-world b/plugin-repos/hello-world deleted file mode 120000 index 08a052ae..00000000 --- a/plugin-repos/hello-world +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/hello-world \ No newline at end of file diff --git a/plugin-repos/hockey-scoreboard b/plugin-repos/hockey-scoreboard deleted file mode 120000 index 108d21cb..00000000 --- a/plugin-repos/hockey-scoreboard +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/hockey-scoreboard \ No newline at end of file diff --git a/plugin-repos/ledmatrix-flights b/plugin-repos/ledmatrix-flights deleted file mode 120000 index 8d103c21..00000000 --- a/plugin-repos/ledmatrix-flights +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/ledmatrix-flights \ No newline at end of file diff --git a/plugin-repos/ledmatrix-leaderboard b/plugin-repos/ledmatrix-leaderboard deleted file mode 120000 index 2242a321..00000000 --- a/plugin-repos/ledmatrix-leaderboard +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/ledmatrix-leaderboard \ No newline at end of file diff --git a/plugin-repos/ledmatrix-music b/plugin-repos/ledmatrix-music deleted file mode 120000 index 20c9157e..00000000 --- a/plugin-repos/ledmatrix-music +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/ledmatrix-music \ No newline at end of file diff --git a/plugin-repos/ledmatrix-stocks b/plugin-repos/ledmatrix-stocks deleted file mode 120000 index 83cb8b87..00000000 --- a/plugin-repos/ledmatrix-stocks +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/ledmatrix-stocks \ No newline at end of file diff --git a/plugin-repos/ledmatrix-weather b/plugin-repos/ledmatrix-weather deleted file mode 120000 index 5362e752..00000000 --- a/plugin-repos/ledmatrix-weather +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/ledmatrix-weather \ No newline at end of file diff --git a/plugin-repos/march-madness/assets/logos/CHAMPIONSHIP.png b/plugin-repos/march-madness/assets/logos/CHAMPIONSHIP.png deleted file mode 100644 index 7279e4cd..00000000 Binary files a/plugin-repos/march-madness/assets/logos/CHAMPIONSHIP.png and /dev/null differ diff --git a/plugin-repos/march-madness/assets/logos/ELITE_8.png b/plugin-repos/march-madness/assets/logos/ELITE_8.png deleted file mode 100644 index f1063a4a..00000000 Binary files a/plugin-repos/march-madness/assets/logos/ELITE_8.png and /dev/null differ diff --git a/plugin-repos/march-madness/assets/logos/FINAL_4.png b/plugin-repos/march-madness/assets/logos/FINAL_4.png deleted file mode 100644 index f968eec5..00000000 Binary files a/plugin-repos/march-madness/assets/logos/FINAL_4.png and /dev/null differ diff --git a/plugin-repos/march-madness/assets/logos/MARCH_MADNESS.png b/plugin-repos/march-madness/assets/logos/MARCH_MADNESS.png deleted file mode 100644 index 304f870b..00000000 Binary files a/plugin-repos/march-madness/assets/logos/MARCH_MADNESS.png and /dev/null differ diff --git a/plugin-repos/march-madness/assets/logos/ROUND_32.png b/plugin-repos/march-madness/assets/logos/ROUND_32.png deleted file mode 100644 index 0e40f5b9..00000000 Binary files a/plugin-repos/march-madness/assets/logos/ROUND_32.png and /dev/null differ diff --git a/plugin-repos/march-madness/assets/logos/ROUND_64.png b/plugin-repos/march-madness/assets/logos/ROUND_64.png deleted file mode 100644 index ea55cd23..00000000 Binary files a/plugin-repos/march-madness/assets/logos/ROUND_64.png and /dev/null differ diff --git a/plugin-repos/march-madness/assets/logos/SWEET_16.png b/plugin-repos/march-madness/assets/logos/SWEET_16.png deleted file mode 100644 index be36574b..00000000 Binary files a/plugin-repos/march-madness/assets/logos/SWEET_16.png and /dev/null differ diff --git a/plugin-repos/march-madness/config_schema.json b/plugin-repos/march-madness/config_schema.json deleted file mode 100644 index bccfe0a8..00000000 --- a/plugin-repos/march-madness/config_schema.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "$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"] -} diff --git a/plugin-repos/march-madness/manager.py b/plugin-repos/march-madness/manager.py deleted file mode 100644 index cd0d8c73..00000000 --- a/plugin-repos/march-madness/manager.py +++ /dev/null @@ -1,910 +0,0 @@ -"""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(__file__).parent / "assets" / "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 banner logo (also in plugin assets) - 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() diff --git a/plugin-repos/march-madness/manifest.json b/plugin-repos/march-madness/manifest.json deleted file mode 100644 index 3f0d16f6..00000000 --- a/plugin-repos/march-madness/manifest.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "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" -} diff --git a/plugin-repos/march-madness/requirements.txt b/plugin-repos/march-madness/requirements.txt deleted file mode 100644 index d1d90878..00000000 --- a/plugin-repos/march-madness/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -requests>=2.33.0 -urllib3>=2.6.3 -Pillow>=12.2.0 -pytz>=2022.1 -numpy>=1.24.0 diff --git a/plugin-repos/mqtt-notifications b/plugin-repos/mqtt-notifications deleted file mode 120000 index 48249504..00000000 --- a/plugin-repos/mqtt-notifications +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/mqtt-notifications \ No newline at end of file diff --git a/plugin-repos/news b/plugin-repos/news deleted file mode 120000 index acb96c95..00000000 --- a/plugin-repos/news +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/news \ No newline at end of file diff --git a/plugin-repos/odds-ticker b/plugin-repos/odds-ticker deleted file mode 120000 index e71955b1..00000000 --- a/plugin-repos/odds-ticker +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/odds-ticker \ No newline at end of file diff --git a/plugin-repos/of-the-day b/plugin-repos/of-the-day deleted file mode 120000 index 79cae43f..00000000 --- a/plugin-repos/of-the-day +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/of-the-day \ No newline at end of file diff --git a/plugin-repos/olympics b/plugin-repos/olympics deleted file mode 120000 index 36f5ebe0..00000000 --- a/plugin-repos/olympics +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/olympics \ No newline at end of file diff --git a/plugin-repos/soccer-scoreboard b/plugin-repos/soccer-scoreboard deleted file mode 120000 index 05d4f6df..00000000 --- a/plugin-repos/soccer-scoreboard +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/soccer-scoreboard \ No newline at end of file diff --git a/plugin-repos/static-image b/plugin-repos/static-image deleted file mode 120000 index 65b6e656..00000000 --- a/plugin-repos/static-image +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/static-image \ No newline at end of file diff --git a/plugin-repos/stock-news b/plugin-repos/stock-news deleted file mode 120000 index dde9397e..00000000 --- a/plugin-repos/stock-news +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/stock-news \ No newline at end of file diff --git a/plugin-repos/text-display b/plugin-repos/text-display deleted file mode 120000 index 612ed769..00000000 --- a/plugin-repos/text-display +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/text-display \ No newline at end of file diff --git a/plugin-repos/ufc-scoreboard b/plugin-repos/ufc-scoreboard deleted file mode 120000 index 747df74f..00000000 --- a/plugin-repos/ufc-scoreboard +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/ufc-scoreboard \ No newline at end of file diff --git a/plugin-repos/youtube-stats b/plugin-repos/youtube-stats deleted file mode 120000 index d98ca063..00000000 --- a/plugin-repos/youtube-stats +++ /dev/null @@ -1 +0,0 @@ -../../ledmatrix-plugins/plugins/youtube-stats \ No newline at end of file diff --git a/scripts/audit_plugins.py b/scripts/audit_plugins.py deleted file mode 100644 index b2a92c14..00000000 --- a/scripts/audit_plugins.py +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env python3 -""" -LEDMatrix Plugin Security Auditor - -Performs AST-based security analysis of all Python files in plugin directories. -Designed to run in CI — exits non-zero on CRITICAL findings only. - -Usage: - python scripts/audit_plugins.py - python scripts/audit_plugins.py --verbose - python scripts/audit_plugins.py --plugin hello-world - python scripts/audit_plugins.py --output results.json -""" - -import ast -import argparse -import json -import sys -from dataclasses import dataclass, asdict -from pathlib import Path -from datetime import datetime, timezone - -PROJECT_ROOT = Path(__file__).resolve().parent.parent - -PLUGIN_BASE_DIRS = [ - PROJECT_ROOT / "plugins", - PROJECT_ROOT / "plugin-repos", -] - - -# ───────────────────────────────────────────────────────────────────────────── -# Finding dataclass -# ───────────────────────────────────────────────────────────────────────────── - -@dataclass -class Finding: - plugin_id: str - file: str - line: int - severity: str # CRITICAL | WARNING | INFO - rule: str - message: str - - def to_dict(self) -> dict: - return asdict(self) - - -# ───────────────────────────────────────────────────────────────────────────── -# AST visitor -# ───────────────────────────────────────────────────────────────────────────── - -class _PluginVisitor(ast.NodeVisitor): - """Collect security findings from a single plugin Python file.""" - - def __init__(self, filepath: Path, plugin_id: str): - self.filepath = filepath - self.plugin_id = plugin_id - self.findings: list[Finding] = [] - - def _add(self, node: ast.AST, severity: str, rule: str, message: str) -> None: - self.findings.append(Finding( - plugin_id=self.plugin_id, - file=str(self.filepath.relative_to(PROJECT_ROOT)), - line=getattr(node, "lineno", 0), - severity=severity, - rule=rule, - message=message, - )) - - def visit_Call(self, node: ast.Call) -> None: - # eval() / exec() — arbitrary code execution - if isinstance(node.func, ast.Name): - if node.func.id == "eval": - self._add(node, "CRITICAL", "PLUGIN-001", - "eval() call — arbitrary code execution risk") - elif node.func.id == "exec": - self._add(node, "CRITICAL", "PLUGIN-002", - "exec() call — arbitrary code execution risk") - elif node.func.id == "compile": - self._add(node, "WARNING", "PLUGIN-003", - "compile() call — dynamic code compilation") - - # subprocess.*(shell=True) - if isinstance(node.func, ast.Attribute): - is_subprocess = ( - isinstance(node.func.value, ast.Name) and - node.func.value.id == "subprocess" and - node.func.attr in ("run", "call", "Popen", "check_call", "check_output") - ) - if is_subprocess: - for kw in node.keywords: - if (kw.arg == "shell" and - isinstance(kw.value, ast.Constant) and - kw.value.value is True): - self._add(node, "WARNING", "PLUGIN-004", - f"subprocess.{node.func.attr}(shell=True) — " - f"shell injection risk if args include user input") - - # os.system() — shell execution - is_os_system = ( - isinstance(node.func.value, ast.Name) and - node.func.value.id == "os" and - node.func.attr == "system" - ) - if is_os_system: - self._add(node, "WARNING", "PLUGIN-005", - "os.system() call — prefer subprocess with list args") - - self.generic_visit(node) - - def visit_Import(self, node: ast.Import) -> None: - for alias in node.names: - self._check_import(node, alias.name) - self.generic_visit(node) - - def visit_ImportFrom(self, node: ast.ImportFrom) -> None: - if node.module: - self._check_import(node, node.module) - self.generic_visit(node) - - def _check_import(self, node: ast.AST, module_name: str) -> None: - dangerous = { - "ctypes": ("WARNING", "PLUGIN-010", "ctypes import — native code execution"), - "cffi": ("WARNING", "PLUGIN-011", "cffi import — native code execution"), - "pickle": ("WARNING", "PLUGIN-012", - "pickle import — deserialization can execute arbitrary code"), - "marshal": ("WARNING", "PLUGIN-013", - "marshal import — deserialization risk"), - } - for mod, (severity, rule, msg) in dangerous.items(): - if module_name == mod or module_name.startswith(mod + "."): - self._add(node, severity, rule, msg) - - -# ───────────────────────────────────────────────────────────────────────────── -# Per-plugin audit -# ───────────────────────────────────────────────────────────────────────────── - -def audit_plugin(plugin_dir: Path) -> list[Finding]: - """Audit a single plugin directory. Returns all findings.""" - findings: list[Finding] = [] - plugin_id = plugin_dir.name - - # Check for required files - for required_file, rule, msg in [ - ("manifest.json", "PLUGIN-020", - "manifest.json missing — plugin may be incomplete"), - ("config_schema.json", "PLUGIN-021", - "config_schema.json missing — no input validation schema declared"), - ]: - if not (plugin_dir / required_file).exists(): - findings.append(Finding( - plugin_id=plugin_id, - file=str((plugin_dir / required_file).relative_to(PROJECT_ROOT)), - line=0, - severity="WARNING", - rule=rule, - message=msg, - )) - - # AST analysis of all Python files - for py_file in sorted(plugin_dir.rglob("*.py")): - try: - source = py_file.read_text(encoding="utf-8") - tree = ast.parse(source, filename=str(py_file)) - visitor = _PluginVisitor(py_file, plugin_id) - visitor.visit(tree) - findings.extend(visitor.findings) - except SyntaxError as exc: - findings.append(Finding( - plugin_id=plugin_id, - file=str(py_file.relative_to(PROJECT_ROOT)), - line=getattr(exc, "lineno", 0) or 0, - severity="WARNING", - rule="PLUGIN-030", - message=f"Python syntax error — cannot be parsed: {exc}", - )) - except OSError as exc: - findings.append(Finding( - plugin_id=plugin_id, - file=str(py_file.relative_to(PROJECT_ROOT)), - line=0, - severity="INFO", - rule="PLUGIN-031", - message=f"Could not read file: {exc}", - )) - - return findings - - -# ───────────────────────────────────────────────────────────────────────────── -# Main -# ───────────────────────────────────────────────────────────────────────────── - -def main() -> int: - parser = argparse.ArgumentParser( - description="LEDMatrix plugin security auditor", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument("--plugin", "-p", default=None, - help="Audit a specific plugin ID only") - parser.add_argument("--output", "-o", default=None, - help="Write JSON results to this file") - parser.add_argument("--verbose", "-v", action="store_true", - help="Show all findings, not just summary") - args = parser.parse_args() - - print("=" * 60) - print("LEDMatrix Plugin Security Audit") - print(f"Project root: {PROJECT_ROOT}") - print("=" * 60) - - all_findings: list[Finding] = [] - plugins_scanned = 0 - - for base_dir in PLUGIN_BASE_DIRS: - if not base_dir.exists(): - if args.verbose: - print(f" ⏭️ Skipping {base_dir.name}/ (directory not found)") - continue - - base_label = base_dir.relative_to(PROJECT_ROOT) - print(f"\n Scanning {base_label}/") - - for plugin_dir in sorted(base_dir.iterdir()): - if not plugin_dir.is_dir(): - continue - if plugin_dir.name.startswith((".", "_")): - continue - if args.plugin and plugin_dir.name != args.plugin: - continue - - findings = audit_plugin(plugin_dir) - all_findings.extend(findings) - plugins_scanned += 1 - - critical = [f for f in findings if f.severity == "CRITICAL"] - warnings = [f for f in findings if f.severity == "WARNING"] - - if critical: - icon, label = "🚨", "CRITICAL" - elif warnings: - icon, label = "⚠️ ", "WARN " - else: - icon, label = "✅", "PASS " - - print(f" {icon} [{label}] {plugin_dir.name}" - f" — {len(critical)} critical, {len(warnings)} warnings") - - if args.verbose: - for f in findings: - severity_icon = {"CRITICAL": "🚨", "WARNING": "⚠️ ", "INFO": "ℹ️ "}.get( - f.severity, " " - ) - print(f" {severity_icon} {f.rule} {f.file}:{f.line} — {f.message}") - - # Summary - critical_findings = [f for f in all_findings if f.severity == "CRITICAL"] - warning_findings = [f for f in all_findings if f.severity == "WARNING"] - - print(f"\n{'=' * 60}") - print(f" Plugins scanned : {plugins_scanned}") - print(f" CRITICAL : {len(critical_findings)}") - print(f" WARNING : {len(warning_findings)}") - - if critical_findings: - print("\n 🚨 CRITICAL findings:") - for f in critical_findings: - print(f" {f.plugin_id} | {Path(f.file).name}:{f.line} | {f.message}") - - # Write JSON output - if args.output: - output_data = { - "timestamp": datetime.now(timezone.utc).isoformat(), - "plugins_scanned": plugins_scanned, - "summary": { - "critical": len(critical_findings), - "warnings": len(warning_findings), - }, - "findings": [f.to_dict() for f in all_findings], - } - Path(args.output).write_text( - json.dumps(output_data, indent=2), encoding="utf-8" - ) - print(f"\n Results written to: {args.output}") - - if critical_findings: - print("\n 🚨 Blocking — CRITICAL issues must be resolved") - return 1 - - print("\n ✅ No critical issues found") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/generate_report.py b/scripts/generate_report.py deleted file mode 100644 index 0459c692..00000000 --- a/scripts/generate_report.py +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env python3 -""" -Security Report Generator - -Aggregates JSON output from all CI security audit jobs into a single -Markdown report suitable for PR comments and artifact storage. - -Expected artifact layout (from actions/download-artifact@v4): - / - sast-results/ - bandit-results.json - semgrep-results.json - dependency-audit-results/ - pip-audit-results.json - safety-results.json - secrets-scan-results/ - gitleaks-results.json - security-proofs-results/ - security-proofs-results.json - plugin-audit-results/ - plugin-audit-results.json - -Usage: - python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md - python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md --verbose -""" - -import argparse -import json -import sys -from pathlib import Path -from datetime import datetime, timezone - -PROJECT_ROOT = Path(__file__).resolve().parent.parent - -# Gitleaks matches containing these strings are template placeholders, not real secrets -_GITLEAKS_SUPPRESS = [ - "YOUR_", - "PLACEHOLDER", - "_HERE", - "example.com", - "config_secrets.template", -] - - -# ───────────────────────────────────────────────────────────────────────────── -# Helpers -# ───────────────────────────────────────────────────────────────────────────── - -def _load(path: Path) -> dict | list | None: - """Load JSON file, returning None on any error.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, FileNotFoundError, OSError): - return None - - -def _md_table_row(*cells: str) -> str: - return "| " + " | ".join(str(c) for c in cells) + " |" - - -# ───────────────────────────────────────────────────────────────────────────── -# Per-tool summarizers -# Returns: (markdown_lines: list[str], critical_count: int) -# ───────────────────────────────────────────────────────────────────────────── - -def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int]: - data = _load(artifact_dir / "sast-results" / "bandit-results.json") - if data is None: - return ["_bandit results not available_"], 0 - - results = data.get("results", []) - high = [r for r in results if r.get("issue_severity") == "HIGH"] - medium = [r for r in results if r.get("issue_severity") == "MEDIUM"] - low = [r for r in results if r.get("issue_severity") == "LOW"] - - lines = [ - f"**Bandit**: {len(high)} HIGH · {len(medium)} MEDIUM · {len(low)} LOW" - ] - - if high: - lines += [ - "", - "| Severity | File | Line | Issue |", - "| --- | --- | --- | --- |", - ] - for r in high[:10]: - fname = Path(r.get("filename", "")).name - lines.append(_md_table_row( - "HIGH", f"`{fname}`", - str(r.get("line_number", "?")), - r.get("issue_text", "") - )) - if len(high) > 10: - lines.append(f"_… and {len(high) - 10} more HIGH findings_") - - return lines, len(high) - - -def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int]: - data = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json") - if data is None: - return ["_pip-audit results not available_"], 0 - - # pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]} - vulns: list[dict] = [] - for dep in data.get("dependencies", []): - for v in dep.get("vulns", []): - vulns.append({"package": dep.get("name", "?"), **v}) - - lines = [f"**pip-audit**: {len(vulns)} vulnerabilities found"] - - if vulns: - lines += ["", "| Package | ID | Fix |", "| --- | --- | --- |"] - for v in vulns[:10]: - fix = v.get("fix_versions", ["none"]) - fix_str = ", ".join(fix) if fix else "none" - lines.append(_md_table_row( - v.get("package", "?"), - v.get("id", "?"), - fix_str, - )) - - # Treat known vulnerabilities as warnings, not critical (they may be unavoidable) - return lines, 0 - - -def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int]: - data = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json") - if data is None: - return ["_gitleaks results not available_"], 0 - - if not isinstance(data, list): - data = [] - - real_findings = [] - suppressed = 0 - for finding in data: - secret_val = str(finding.get("Secret", "") or finding.get("Match", "")) - if any(p in secret_val for p in _GITLEAKS_SUPPRESS): - suppressed += 1 - else: - real_findings.append(finding) - - lines = [ - f"**Gitleaks**: {len(real_findings)} finding(s) " - f"({suppressed} suppressed as template placeholders)" - ] - - if real_findings: - lines += ["", "| Rule | File | Line | Description |", "| --- | --- | --- | --- |"] - for f in real_findings[:10]: - fname = Path(f.get("File", "")).name - lines.append(_md_table_row( - f.get("RuleID", "?"), - f"`{fname}`", - str(f.get("StartLine", "?")), - f.get("Description", ""), - )) - - critical = len(real_findings) # any real secret is critical - return lines, critical - - -def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int]: - data = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json") - if data is None: - return ["_security proofs results not available_"], 0 - - if not isinstance(data, list): - data = [] - - critical = [r for r in data if r.get("severity") == "CRITICAL"] - warnings = [r for r in data if r.get("severity") == "WARNING"] - passed = [r for r in data if r.get("severity") == "PASS"] - skipped = [r for r in data if r.get("severity") == "SKIP"] - - lines = [ - f"**Security Proofs**: " - f"{len(passed)} PASS · {len(warnings)} WARN · " - f"{len(critical)} CRITICAL · {len(skipped)} SKIP", - "", - ] - - _icon = {"PASS": "✅", "INFO": "ℹ️", "WARNING": "⚠️", - "CRITICAL": "🚨", "SKIP": "⏭️"} - for r in data: - icon = _icon.get(r.get("severity", ""), "❓") - lines.append( - f"- {icon} **{r.get('test_id', '?')}**: {r.get('message', '')}" - ) - if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"): - lines.append(f" - _{r['details']}_") - - return lines, len(critical) - - -def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int]: - data = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json") - if data is None: - return ["_plugin audit results not available_"], 0 - - summary = data.get("summary", {}) - findings = data.get("findings", []) - critical_findings = [f for f in findings if f.get("severity") == "CRITICAL"] - warning_findings = [f for f in findings if f.get("severity") == "WARNING"] - - lines = [ - f"**Plugin Audit**: {data.get('plugins_scanned', '?')} plugins scanned — " - f"{summary.get('critical', 0)} CRITICAL · {summary.get('warnings', 0)} WARNINGS" - ] - - if critical_findings: - lines += ["", "| Plugin | File | Line | Rule | Message |", - "| --- | --- | --- | --- | --- |"] - for f in critical_findings[:10]: - fname = Path(f.get("file", "")).name - lines.append(_md_table_row( - f.get("plugin_id", "?"), - f"`{fname}`", - str(f.get("line", "?")), - f.get("rule", "?"), - f.get("message", ""), - )) - - if warning_findings and not critical_findings: - lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_") - - return lines, summary.get("critical", 0) - - -# ───────────────────────────────────────────────────────────────────────────── -# Main -# ───────────────────────────────────────────────────────────────────────────── - -def main() -> int: - parser = argparse.ArgumentParser( - description="Generate consolidated security audit report", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument("--artifact-dir", required=True, - help="Directory containing downloaded CI artifacts") - parser.add_argument("--output", "-o", required=True, - help="Output Markdown file path") - parser.add_argument("--verbose", "-v", action="store_true") - args = parser.parse_args() - - artifact_dir = Path(args.artifact_dir) - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - - bandit_lines, bandit_crit = _summarize_bandit(artifact_dir) - pip_audit_lines, pip_audit_crit = _summarize_pip_audit(artifact_dir) - gitleaks_lines, gitleaks_crit = _summarize_gitleaks(artifact_dir) - proofs_lines, proofs_crit = _summarize_security_proofs(artifact_dir) - plugins_lines, plugins_crit = _summarize_plugin_audit(artifact_dir) - - total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit - overall = "ACTION REQUIRED 🚨" if total_critical > 0 else "PASSED ✅" - - def section(title: str, lines: list[str]) -> str: - return f"### {title}\n\n" + "\n".join(lines) + "\n" - - report = f"""## 🔒 Security Audit — {overall} - -_Generated: {timestamp}_ - -| Critical | High/Warn | Overall | -| :---: | :---: | :---: | -| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | ⚠️ see below | {overall} | - ---- - -{section('SAST — Bandit', bandit_lines)} -{section('Dependencies — pip-audit', pip_audit_lines)} -{section('Secrets — Gitleaks', gitleaks_lines)} -{section('LEDMatrix Security Proofs', proofs_lines)} -{section('Plugin Security Audit', plugins_lines)} ---- - -_Total critical findings: **{total_critical}**_ -""" - - output_path = Path(args.output) - output_path.write_text(report, encoding="utf-8") - - if args.verbose: - print(f" Report written to: {output_path}") - print(f" Status: {overall}") - print(f" Critical findings: {total_critical}") - print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} " - f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/prove_security.py b/scripts/prove_security.py deleted file mode 100644 index 18ce61bc..00000000 --- a/scripts/prove_security.py +++ /dev/null @@ -1,504 +0,0 @@ -#!/usr/bin/env python3 -""" -LEDMatrix Security Proof Tests - -Automated proofs that run in CI to verify security properties hold on every -commit. Inspired by the Huntarr security review approach of using standard -tooling to confirm specific vulnerability classes are absent. - -Usage: - python scripts/prove_security.py - python scripts/prove_security.py --verbose - python scripts/prove_security.py --output results.json - -Exit code: 1 only if CRITICAL findings are detected. Warnings are reported -but do not block CI. -""" - -import ast -import argparse -import json -import re -import sys -from dataclasses import dataclass, asdict -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parent.parent - - -# ───────────────────────────────────────────────────────────────────────────── -# Result dataclass -# ───────────────────────────────────────────────────────────────────────────── - -@dataclass -class TestResult: - test_id: str - severity: str # PASS | INFO | WARNING | CRITICAL | SKIP - message: str - details: str = "" - - def to_dict(self) -> dict: - return asdict(self) - - @property - def icon(self) -> str: - return { - "PASS": "✅", - "INFO": "ℹ️ ", - "WARNING": "⚠️ ", - "CRITICAL": "🚨", - "SKIP": "⏭️ ", - }.get(self.severity, "❓") - - -# ───────────────────────────────────────────────────────────────────────────── -# T1: Plugin Loading / Zip Slip -# ───────────────────────────────────────────────────────────────────────────── - -def test_t1a_zip_slip_protection() -> TestResult: - """ - Verify that zip-slip protection exists in store_manager.py. - - The protection lives at src/plugin_system/store_manager.py and uses - Path.is_relative_to() to validate each zip member before extraction. - This test confirms the guard is present — it should always pass green. - """ - store_manager = PROJECT_ROOT / "src" / "plugin_system" / "store_manager.py" - if not store_manager.exists(): - return TestResult("T1a", "CRITICAL", - "store_manager.py not found", - f"Expected at {store_manager}") - - content = store_manager.read_text(encoding="utf-8") - - has_relative_to = "is_relative_to" in content - has_log_message = "Zip-slip detected" in content - - if not has_relative_to: - return TestResult("T1a", "CRITICAL", - "Zip-slip protection (is_relative_to) NOT FOUND in store_manager.py", - "The is_relative_to() guard must be present before zipfile.extractall()") - - if not has_log_message: - return TestResult("T1a", "WARNING", - "is_relative_to() found but 'Zip-slip detected' log message missing", - "Verify the protection block is still active and the log was not removed") - - return TestResult("T1a", "PASS", - "Zip-slip protection verified", - "is_relative_to() guard + 'Zip-slip detected' log present in store_manager.py") - - -def test_t1b_dangerous_plugin_calls() -> list[TestResult]: - """ - Scan plugin directories for dangerous function calls (eval, exec). - These represent arbitrary code execution risks in plugin code. - """ - results = [] - plugin_dirs = [ - PROJECT_ROOT / "plugins", - PROJECT_ROOT / "plugin-repos", - ] - - violations: list[str] = [] - files_scanned = 0 - - for base in plugin_dirs: - if not base.exists(): - continue - for plugin_dir in sorted(base.iterdir()): - if not plugin_dir.is_dir() or plugin_dir.name.startswith(('.', '_')): - continue - for py_file in plugin_dir.rglob("*.py"): - files_scanned += 1 - try: - source = py_file.read_text(encoding="utf-8") - tree = ast.parse(source, filename=str(py_file)) - for node in ast.walk(tree): - if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): - if node.func.id in ("eval", "exec"): - rel = py_file.relative_to(PROJECT_ROOT) - violations.append( - f"{rel}:{node.lineno} — {node.func.id}() call") - except (SyntaxError, OSError): - pass - - if violations: - results.append(TestResult( - "T1b", "CRITICAL", - f"Dangerous function calls found in plugins ({len(violations)} instance(s))", - "; ".join(violations[:10]) - )) - else: - results.append(TestResult( - "T1b", "PASS", - f"No eval()/exec() calls found in plugins", - f"{files_scanned} plugin Python files scanned" - )) - - return results - - -# ───────────────────────────────────────────────────────────────────────────── -# T2: API Surface Inventory -# ───────────────────────────────────────────────────────────────────────────── - -def test_t2a_api_surface_inventory() -> TestResult: - """ - Document the API surface area. - - This app intentionally has no authentication (local-only Raspberry Pi - design, documented in web_interface/app.py). This test produces an - inventory for audit purposes and warns only if the design-intent comment - is removed from app.py (which would indicate someone deleted the rationale - without adding auth, rather than a deliberate undocumented change). - """ - api_file = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py" - app_file = PROJECT_ROOT / "web_interface" / "app.py" - - if not api_file.exists(): - return TestResult("T2a", "WARNING", "api_v3.py not found", str(api_file)) - - api_content = api_file.read_text(encoding="utf-8") - routes = re.findall(r"@api_v3\.route\('([^']+)'", api_content) - - csrf_documented = False - if app_file.exists(): - app_content = app_file.read_text(encoding="utf-8") - csrf_documented = "CSRF protection disabled for local-only" in app_content - - summary = ( - f"{len(routes)} API routes in api_v3.py. " - f"No auth decorators (intentional local-only design). " - f"CSRF disabled: {'YES — design intent documented in app.py' if csrf_documented else 'YES — but design intent comment NOT found in app.py'}. " - f"Rate limiting: 1000/min." - ) - - if not csrf_documented: - return TestResult( - "T2a", "WARNING", - "CSRF is disabled but the design-intent comment is missing from app.py", - "Add the rationale comment back, or add proper CSRF protection if " - "the app is now internet-facing" - ) - - return TestResult("T2a", "INFO", "API surface documented", summary) - - -# ───────────────────────────────────────────────────────────────────────────── -# T3: Secrets & Credential Handling -# ───────────────────────────────────────────────────────────────────────────── - -# Patterns that suggest real credentials (must be >8 chars, not placeholders) -_SECRET_PATTERNS = [ - (r'(?i)password\s*=\s*["\'](?!none|empty|placeholder|example|test|default|""|'')[^"\']{8,}["\']', "WARNING"), - (r'(?i)api[_-]?key\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING"), - (r'(?i)secret\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING"), - # Real GitHub token pattern - (r'ghp_[a-zA-Z0-9]{36}', "CRITICAL"), - # Generic long bearer tokens - (r'Bearer\s+[a-zA-Z0-9\-_\.]{32,}', "WARNING"), -] - -_TEMPLATE_SKIP_STRINGS = [ - "YOUR_", "PLACEHOLDER", "_HERE", "example.com", "config_secrets.template", - "prove_security", # this file itself -] - -_SCAN_DIRS = ["src", "web_interface", "scripts"] - - -def test_t3a_hardcoded_secrets() -> TestResult: - """Scan source code for hardcoded credentials.""" - violations: list[str] = [] - - for dir_name in _SCAN_DIRS: - scan_dir = PROJECT_ROOT / dir_name - if not scan_dir.exists(): - continue - for py_file in scan_dir.rglob("*.py"): - # Skip test files and this script - if "test" in str(py_file).lower() or "prove_security" in str(py_file): - continue - try: - content = py_file.read_text(encoding="utf-8") - except OSError: - continue - - for pattern, severity in _SECRET_PATTERNS: - for match in re.finditer(pattern, content): - line_content = match.group(0) - # Skip lines containing template placeholder strings - if any(skip in line_content for skip in _TEMPLATE_SKIP_STRINGS): - continue - rel = py_file.relative_to(PROJECT_ROOT) - line_no = content[: match.start()].count("\n") + 1 - violations.append( - f"[{severity}] {rel}:{line_no} — {line_content[:60]}" - ) - - critical_violations = [v for v in violations if "[CRITICAL]" in v] - if critical_violations: - return TestResult( - "T3a", "CRITICAL", - f"Hardcoded secrets found ({len(critical_violations)} critical)", - "; ".join(critical_violations[:5]) - ) - if violations: - return TestResult( - "T3a", "WARNING", - f"Potential hardcoded secrets found ({len(violations)} instance(s))", - "; ".join(violations[:5]) - ) - - return TestResult("T3a", "PASS", "No hardcoded secrets detected", - f"Scanned {', '.join(_SCAN_DIRS)}") - - -def test_t3b_plaintext_password_storage() -> TestResult: - """ - Check for user account password storage without hashing. - - The LEDMatrix app has no user account system, so this should produce INFO. - It would only CRITICAL if someone added user auth and stored passwords without hashing. - - We require all three of: a password *variable assignment or DB operation*, - a clear storage call (INSERT / db commit / ORM save), and no hashing lib present - — to avoid false positives from files that contain 'password' for WiFi handling - and '.save()' for image/file saving in unrelated functions. - """ - hashing_libs = ["bcrypt", "argon2", "pbkdf2", "scrypt", - "generate_password_hash", "hashpw", "make_password"] - # Patterns that indicate password being stored in a database / ORM context. - # Must be specific enough to avoid matching set.add(), file.save(), etc. - db_storage_patterns = ["INSERT INTO", "db.session", "session.add(", "session.commit(", "orm.save"] - - password_storage_found = False - - for dir_name in _SCAN_DIRS: - scan_dir = PROJECT_ROOT / dir_name - if not scan_dir.exists(): - continue - for py_file in scan_dir.rglob("*.py"): - try: - content = py_file.read_text(encoding="utf-8") - except OSError: - continue - # Require DB/ORM context specifically — not just any .save() call - if ("password" in content.lower() and - any(store in content for store in db_storage_patterns) and - not any(h in content for h in hashing_libs)): - password_storage_found = True - - if password_storage_found: - return TestResult( - "T3b", "CRITICAL", - "Potential plaintext password storage in database/ORM detected", - "Found password + database storage operations without a recognized hashing library" - ) - - return TestResult("T3b", "INFO", - "No plaintext password storage detected", - "App has no user account system — expected result") - - -# ───────────────────────────────────────────────────────────────────────────── -# T4: Path Traversal -# ───────────────────────────────────────────────────────────────────────────── - -def test_t4a_path_traversal() -> TestResult: - """ - Verify static file serving uses send_from_directory (safe) rather than - open() with user-supplied paths. Also checks for extractall() calls that - lack the is_relative_to() guard. - """ - issues: list[str] = [] - - app_file = PROJECT_ROOT / "web_interface" / "app.py" - if app_file.exists(): - content = app_file.read_text(encoding="utf-8") - # The file-serve route should use send_from_directory or commonpath - if "send_from_directory" not in content and "commonpath" not in content: - issues.append("app.py: file-serve routes may not use send_from_directory/commonpath") - - # Check all extractall() calls have a preceding is_relative_to guard - for py_file in (PROJECT_ROOT / "src").rglob("*.py"): - try: - content = py_file.read_text(encoding="utf-8") - except OSError: - continue - if "extractall(" in content and "is_relative_to" not in content: - rel = py_file.relative_to(PROJECT_ROOT) - issues.append(f"{rel}: extractall() without is_relative_to() guard") - - if issues: - return TestResult( - "T4a", "WARNING", - f"Potential path traversal patterns found ({len(issues)})", - "; ".join(issues) - ) - - return TestResult("T4a", "PASS", - "Path traversal mitigations verified", - "send_from_directory/commonpath used for file serving; " - "extractall() calls have is_relative_to() guards") - - -# ───────────────────────────────────────────────────────────────────────────── -# T5: Auth Bypass Patterns -# ───────────────────────────────────────────────────────────────────────────── - -def test_t5a_auth_bypass_patterns() -> TestResult: - """ - Look for broken auth bypass patterns — not the intentional no-auth design - (T2a covers that), but patterns that suggest auth was INTENDED to exist - but has an exploitable bypass: broad substring matching, debug-mode skips, - or if-True conditions. - """ - bypass_signals = [ - (r'if\s+True\s*:', "if True: bypass"), - (r'if\s+debug\s*:', "debug-mode auth skip"), - (r'request\.path\s+in\s+', "substring path matching in auth (Huntarr pattern)"), - (r'EXEMPT_ROUTES\s*=', "exempt routes list"), - ] - - findings: list[str] = [] - - for dir_name in ["src", "web_interface"]: - scan_dir = PROJECT_ROOT / dir_name - if not scan_dir.exists(): - continue - for py_file in scan_dir.rglob("*.py"): - try: - content = py_file.read_text(encoding="utf-8") - except OSError: - continue - for pattern, label in bypass_signals: - if re.search(pattern, content): - # Only flag if the file also contains auth-related terms - if any(auth in content.lower() for auth in - ["auth", "login", "authenticate", "token", "permission"]): - rel = py_file.relative_to(PROJECT_ROOT) - findings.append(f"{rel}: {label}") - - if findings: - return TestResult( - "T5a", "WARNING", - f"Potential auth bypass patterns found ({len(findings)})", - "; ".join(findings[:5]) - ) - - return TestResult("T5a", "PASS", - "No auth bypass patterns detected", - "Checked src/ and web_interface/ for bypass signals") - - -# ───────────────────────────────────────────────────────────────────────────── -# T6: Docker / Container Hardening -# ───────────────────────────────────────────────────────────────────────────── - -def test_t6_docker_hardening() -> TestResult: - """Container security — skipped if no Dockerfile exists.""" - dockerfile = PROJECT_ROOT / "Dockerfile" - if not dockerfile.exists(): - return TestResult("T6", "SKIP", - "No Dockerfile found — container security scan not applicable", - "If Docker support is added in future, enable hadolint/trivy scanning " - "in .github/workflows/security-audit.yml") - - content = dockerfile.read_text(encoding="utf-8") - issues: list[str] = [] - - # Check for non-root USER directive - user_lines = [l for l in content.splitlines() if l.strip().startswith("USER")] - if not user_lines or user_lines[-1].strip() == "USER root": - issues.append("Container runs as root — use USER directive to drop privileges") - - # Check for pinned base image tags - from_lines = [l for l in content.splitlines() if l.strip().startswith("FROM")] - for from_line in from_lines: - parts = from_line.split() - if len(parts) >= 2: - image = parts[1] - if ":" not in image or image.endswith(":latest"): - issues.append(f"Unpinned base image: {image}") - - if issues: - return TestResult("T6", "WARNING", - f"Dockerfile hardening issues ({len(issues)})", - "; ".join(issues)) - - return TestResult("T6", "PASS", "Dockerfile hardening checks passed", "") - - -# ───────────────────────────────────────────────────────────────────────────── -# Runner -# ───────────────────────────────────────────────────────────────────────────── - -def main() -> int: - parser = argparse.ArgumentParser( - description="LEDMatrix security proof tests", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument("--output", "-o", default=None, - help="Write JSON results to this file") - parser.add_argument("--verbose", "-v", action="store_true", - help="Show details for each check") - args = parser.parse_args() - - print("=" * 60) - print("LEDMatrix Security Proof Tests") - print(f"Project root: {PROJECT_ROOT}") - print("=" * 60) - - all_results: list[TestResult] = [] - - # Run all test groups - all_results.append(test_t1a_zip_slip_protection()) - all_results.extend(test_t1b_dangerous_plugin_calls()) - all_results.append(test_t2a_api_surface_inventory()) - all_results.append(test_t3a_hardcoded_secrets()) - all_results.append(test_t3b_plaintext_password_storage()) - all_results.append(test_t4a_path_traversal()) - all_results.append(test_t5a_auth_bypass_patterns()) - all_results.append(test_t6_docker_hardening()) - - # Print results - print() - for r in all_results: - line = f" {r.icon} [{r.severity:<8}] {r.test_id}: {r.message}" - print(line) - if args.verbose and r.details: - print(f" {r.details}") - - # Tally - critical = [r for r in all_results if r.severity == "CRITICAL"] - warnings = [r for r in all_results if r.severity == "WARNING"] - passed = [r for r in all_results if r.severity == "PASS"] - skipped = [r for r in all_results if r.severity == "SKIP"] - - print() - print(f" Results: {len(passed)} PASS {len(warnings)} WARN " - f"{len(critical)} CRITICAL {len(skipped)} SKIP") - - # Write JSON output - if args.output: - output_data = [r.to_dict() for r in all_results] - Path(args.output).write_text( - json.dumps(output_data, indent=2), encoding="utf-8" - ) - print(f" Results written to: {args.output}") - - if critical: - print(f"\n 🚨 {len(critical)} CRITICAL issue(s) found — blocking") - return 1 - - if warnings: - print(f"\n ⚠️ {len(warnings)} warning(s) found — non-blocking") - - print("\n ✅ All checks passed (warnings are non-blocking)") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py deleted file mode 100644 index a6e19374..00000000 --- a/web_interface/blueprints/composer.py +++ /dev/null @@ -1,837 +0,0 @@ -""" -Plugin Composer blueprint — drag-and-drop plugin builder for LEDMatrix. - -Routes: - GET /composer/ — Composer page - POST /composer/api/generate — Generate and return plugin ZIP - POST /composer/api/install — Write plugin directly to plugins_dir - GET /composer/api/fonts/ — Serve TTF font files for canvas rendering - GET /composer/api/validate-id/ — Check if a plugin ID is already taken -""" -import ast -import io -import json -import logging -import re -import zipfile -from datetime import datetime -from pathlib import Path - -import jinja2 -import jsonschema -from flask import Blueprint, jsonify, render_template, request, send_file - -logger = logging.getLogger(__name__) - -composer_bp = Blueprint('composer', __name__) - -# Module-level attributes injected by app.py at registration time -composer_bp.config_manager = None -composer_bp.plugin_manager = None -composer_bp.plugins_dir = None -composer_bp.project_root = None - -# Fonts safe to serve to the browser for canvas rendering -_ALLOWED_FONTS = frozenset({'PressStart2P-Regular.ttf', '4x6-font.ttf', '5by7.regular.ttf'}) - -# Map composer font keys → DisplayManager attribute names -_FONT_ATTR_MAP = { - 'press_start': 'regular_font', - 'four_by_six': 'extra_small_font', - 'five_by_seven': 'bdf_5x7_font', -} - -# Font sizes in LED pixels (used to compute second-line Y offsets) -_FONT_SIZE_MAP = { - 'press_start': 8, - 'four_by_six': 6, - 'five_by_seven': 7, -} - -_PLUGIN_ID_RE = re.compile(r'^[a-z][a-z0-9-]{0,62}$') -_PYTHON_IDENT_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') - -# ── Jinja2 environment (separate from Flask's; autoescape=False for code gen) ── - -_jinja_env: jinja2.Environment | None = None - - -def _get_jinja_env() -> jinja2.Environment: - global _jinja_env - if _jinja_env is None: - template_dir = Path(__file__).parent.parent / 'templates' / 'v3' / 'composer' - _jinja_env = jinja2.Environment( - loader=jinja2.FileSystemLoader(str(template_dir)), - autoescape=False, - trim_blocks=True, - lstrip_blocks=True, - ) - _jinja_env.filters['as_rgb'] = _as_rgb_filter - _jinja_env.filters['as_fill'] = _as_fill_filter - return _jinja_env - - -def _as_rgb_filter(val) -> str: - """[r, g, b] → '(r, g, b)'""" - if val is None: - return 'None' - return f'({int(val[0])}, {int(val[1])}, {int(val[2])})' - - -def _as_fill_filter(val) -> str: - """[r, g, b] or None → '(r, g, b)' or 'None'""" - if val is None: - return 'None' - return _as_rgb_filter(val) - - -# ── Helper functions ────────────────────────────────────────────────────────── - -def _to_class_name(name: str) -> str: - """'My Clock' → 'MyClockPlugin' (avoids double-suffix if name already ends with Plugin)""" - words = re.sub(r'[^a-zA-Z0-9]', ' ', name).split() - base = ''.join(w.capitalize() for w in words) - return base if base.endswith('Plugin') else base + 'Plugin' - - -def _compute_pos_expr(val: int, anchor: str | None, dim_var: str) -> str: - """Produce a Python expression string for an anchored or fixed position. - - anchor=None/'left'/'top' → fixed pixel value - anchor='center' → dim_var // 2 ± offset - anchor='right'/'bottom' → dim_var - offset - """ - if not anchor or anchor in ('left', 'top'): - return str(val) - if anchor in ('center', 'middle'): - if val == 0: - return f"{dim_var} // 2" - return f"{dim_var} // 2 + {val}" if val > 0 else f"{dim_var} // 2 - {abs(val)}" - if anchor in ('right', 'bottom'): - return dim_var if val == 0 else f"{dim_var} - {val}" - return str(val) - - -# Character widths in LED pixels per font (for text-alignment x offset math) -_FONT_CHAR_W = { - 'press_start': 8, - 'four_by_six': 4, - 'five_by_seven': 5, -} - - -def _aligned_x_expr(x_base_expr: str, text_align: str, char_count: int, char_w: int) -> str: - """Return Python x expression for text alignment. - - left → x_base_expr (no change) - center → x_base_expr - half_text_width - right → x_base_expr - text_width - """ - if text_align == 'left' or not text_align: - return x_base_expr - text_px = char_count * char_w - if text_align == 'center': - offset = text_px // 2 - return f"({x_base_expr}) - {offset}" if offset else x_base_expr - if text_align == 'right': - return f"({x_base_expr}) - {text_px}" if text_px else x_base_expr - return x_base_expr - - -def _preprocess_elements(elements: list) -> list: - """Expand raw element dicts into template-ready dicts with anchor expressions. - - Invisible elements (visible=False) are excluded from generated code entirely. - """ - result = [] - for el in elements: - # Skip hidden elements — they exist only in the preview - if el.get('visible') is False: - continue - - p = dict(el) - t = el.get('type', '') - - # Section elements are layer-list annotations only — no canvas output - if t == 'section': - continue - - x_anchor = el.get('xAnchor') or None - y_anchor = el.get('yAnchor') or None - p['min_width'] = int(el.get('minWidth', 0) or 0) - - if t in ('text', 'clock'): - font_key = el.get('font', 'press_start') - p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" - text_align = el.get('textAlign', 'left') - raw_x = el.get('x', 0) - x_base_expr = _compute_pos_expr(raw_x, x_anchor, 'width') - p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - font_size = _FONT_SIZE_MAP.get(font_key, 8) - char_w = _FONT_CHAR_W.get(font_key, 8) - line_spacing = int(el.get('lineSpacing', 2)) - y_expr = p['y_expr'] - p['y2_expr'] = f"({y_expr}) + {font_size + line_spacing}" - if t == 'text': - t1 = el.get('text', '') or '' - t2 = el.get('text2', '') or '' - p['text2'] = t2 - # Detect {variable} tokens — generate format_map() call instead of literal - _var_re = re.compile(r'\{([a-zA-Z_]\w*)\}') - p['text_is_template'] = bool(_var_re.search(t1) or _var_re.search(t2)) - ref_len = max(len(t1), len(t2)) if t2 else len(t1) - p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w) - p['x2_expr'] = p['x_expr'] # second line uses same x - else: # clock - fmt1 = el.get('format', '%H:%M') or '%H:%M' - fmt2 = el.get('format2', '') or '' - p['format2'] = fmt2 - ref_len = max(len(fmt1), len(fmt2)) if fmt2 else len(fmt1) - p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w) - p['x2_expr'] = p['x_expr'] - p['blink'] = bool(el.get('blink', False)) - - elif t == 'dynamic_text': - binding = el.get('binding', {}) - p['binding_source'] = binding.get('source', 'config') - p['binding_key'] = binding.get('key', '') - p['binding_format'] = binding.get('format') - font_key = el.get('font', 'press_start') - p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 100)})" - x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - p['x_expr'] = x_base_expr # dynamic text: runtime content determines width; use raw pos - p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - p['blink'] = bool(el.get('blink', False)) - - elif t == 'rectangle': - x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 10) - h = el.get('height', 8) - p['x_expr'] = x_expr - p['y_expr'] = y_expr - # x2/y2 as runtime expressions to support anchored positions - p['x2_expr'] = f"({x_expr}) + {w}" - p['y2_expr'] = f"({y_expr}) + {h}" - fill = ( - [el.get('fillR', 0), el.get('fillG', 0), el.get('fillB', 128)] - if el.get('hasFill', True) else None - ) - outline = ( - [el.get('outR', 255), el.get('outG', 255), el.get('outB', 255)] - if el.get('hasOutline', True) else None - ) - p['fill_tuple'] = _as_fill_filter(fill) - p['outline_tuple'] = _as_fill_filter(outline) - p['blink'] = bool(el.get('blink', False)) - - elif t in ('line', 'divider'): - if t == 'divider': - orient = el.get('orientation', 'horizontal') - if orient == 'horizontal': - y_val = el.get('y', 16) - y_expr = _compute_pos_expr(y_val, y_anchor, 'height') - p.update(x0_expr='0', y0_expr=y_expr, x1_expr='width - 1', y1_expr=y_expr) - else: - x_val = el.get('x', 64) - x_expr = _compute_pos_expr(x_val, x_anchor, 'width') - p.update(x0_expr=x_expr, y0_expr='0', x1_expr=x_expr, y1_expr='height - 1') - else: - p['x0_expr'] = _compute_pos_expr(el.get('x0', 0), x_anchor, 'width') - p['y0_expr'] = _compute_pos_expr(el.get('y0', 0), y_anchor, 'height') - p['x1_expr'] = str(el.get('x1', 127)) - p['y1_expr'] = str(el.get('y1', 0)) - p['rgb_tuple'] = f"({el.get('r', 180)}, {el.get('g', 180)}, {el.get('b', 180)})" - p['line_width'] = el.get('lineWidth', 1) - p['blink'] = bool(el.get('blink', False)) - - elif t == 'progress_bar': - p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - p['bar_width'] = int(el.get('barWidth', 40)) - p['bar_height'] = int(el.get('barHeight', 6)) - binding = el.get('binding', {}) - p['binding_key'] = binding.get('key', '') - p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, {el.get('b', 100)})" - bg = ( - [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] - if el.get('hasBg', True) else None - ) - outline = ( - [el.get('outR', 100), el.get('outG', 100), el.get('outB', 100)] - if el.get('hasOutline', True) else None - ) - p['bg_tuple'] = _as_fill_filter(bg) - p['outline_tuple'] = _as_fill_filter(outline) - p['blink'] = bool(el.get('blink', False)) - - elif t == 'arc': - x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 24) - h = el.get('height', 24) - p['x_expr'] = x_expr - p['y_expr'] = y_expr - p['x2_expr'] = f"({x_expr}) + {w}" - p['y2_expr'] = f"({y_expr}) + {h}" - p['start_angle'] = int(el.get('startAngle', 0)) - p['end_angle'] = int(el.get('endAngle', 270)) - p['line_width'] = max(1, int(el.get('lineWidth', 2))) - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})" - p['blink'] = bool(el.get('blink', False)) - - elif t == 'ellipse': - x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 24) - h = el.get('height', 12) - p['x_expr'] = x_expr - p['y_expr'] = y_expr - p['x2_expr'] = f"({x_expr}) + {w}" - p['y2_expr'] = f"({y_expr}) + {h}" - fill = ( - [el.get('fillR', 0), el.get('fillG', 100), el.get('fillB', 200)] - if el.get('hasFill', True) else None - ) - outline = ( - [el.get('outR', 100), el.get('outG', 180), el.get('outB', 255)] - if el.get('hasOutline', True) else None - ) - p['fill_tuple'] = _as_fill_filter(fill) - p['outline_tuple'] = _as_fill_filter(outline) - p['blink'] = bool(el.get('blink', False)) - - elif t == 'pixel': - p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" - p['blink'] = bool(el.get('blink', False)) - - elif t == 'rounded_rectangle': - x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 24) - h = el.get('height', 10) - p['x_expr'] = x_expr - p['y_expr'] = y_expr - p['x2_expr'] = f"({x_expr}) + {w}" - p['y2_expr'] = f"({y_expr}) + {h}" - p['border_radius'] = int(el.get('borderRadius', 3)) - fill = ( - [el.get('fillR', 0), el.get('fillG', 80), el.get('fillB', 180)] - if el.get('hasFill', True) else None - ) - outline = ( - [el.get('outR', 120), el.get('outG', 180), el.get('outB', 255)] - if el.get('hasOutline', True) else None - ) - p['fill_tuple'] = _as_fill_filter(fill) - p['outline_tuple'] = _as_fill_filter(outline) - p['blink'] = bool(el.get('blink', False)) - - elif t == 'countdown': - font_key = el.get('font', 'four_by_six') - p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 180)}, {el.get('b', 0)})" - binding = el.get('binding', {}) - p['binding_key'] = binding.get('key', '') - p['countdown_format'] = el.get('countdownFormat', 'dh') - x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - p['x_expr'] = x_base_expr - p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - p['blink'] = bool(el.get('blink', False)) - - elif t == 'pips': - p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - p['pip_count'] = max(1, int(el.get('count', 5))) - p['pip_size'] = max(1, int(el.get('pipSize', 4))) - p['pip_spacing'] = max(0, int(el.get('pipSpacing', 2))) - p['show_empty'] = bool(el.get('showEmpty', True)) - binding = el.get('binding', {}) - p['binding_key'] = binding.get('key', '') - p['fill_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})" - p['empty_tuple'] = f"({el.get('emptyR', 50)}, {el.get('emptyG', 50)}, {el.get('emptyB', 50)})" - p['blink'] = bool(el.get('blink', False)) - - elif t == 'sparkline': - x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - p['x_expr'] = x_expr - p['y_expr'] = y_expr - p['bar_width_px'] = int(el.get('width', 40)) - p['bar_height_px'] = int(el.get('height', 12)) - p['bar_count'] = max(1, int(el.get('barCount', 8))) - p['bar_spacing'] = max(0, int(el.get('barSpacing', 1))) - binding = el.get('binding', {}) - p['binding_key'] = binding.get('key', '') - p['fill_tuple'] = f"({el.get('r', 80)}, {el.get('g', 200)}, {el.get('b', 120)})" - bg = [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] if el.get('hasBg', False) else None - p['bg_tuple'] = _as_fill_filter(bg) - p['blink'] = bool(el.get('blink', False)) - - elif t == 'gauge': - x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') - y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 32) - h = el.get('height', 32) - p['x_expr'] = x_expr - p['y_expr'] = y_expr - p['x2_expr'] = f"({x_expr}) + {w}" - p['y2_expr'] = f"({y_expr}) + {h}" - p['start_angle'] = int(el.get('startAngle', 135)) - p['end_angle'] = int(el.get('endAngle', 45)) - p['line_width'] = max(1, int(el.get('lineWidth', 3))) - p['rgb_tuple'] = f"({el.get('r', 80)}, {el.get('g', 220)}, {el.get('b', 80)})" - track = ( - [el.get('trackR', 40), el.get('trackG', 40), el.get('trackB', 40)] - if el.get('hasTrack', True) else None - ) - p['track_tuple'] = _as_fill_filter(track) - binding = el.get('binding', {}) - p['binding_key'] = binding.get('key', '') - font_key = el.get('font', 'four_by_six') - p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font') - p['show_label'] = bool(el.get('showLabel', True)) - p['label_tuple'] = f"({el.get('labelR', 200)}, {el.get('labelG', 200)}, {el.get('labelB', 200)})" - p['blink'] = bool(el.get('blink', False)) - - elif t == 'marquee': - font_key = el.get('font', 'press_start') - p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" - p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - p['text'] = el.get('text', 'Scrolling text') - p['char_w'] = _FONT_CHAR_W.get(font_key, 8) - p['gap'] = int(el.get('gap', 16)) - p['scroll_speed'] = max(1, int(el.get('scrollSpeed', 1))) - p['direction'] = el.get('direction', 'left') - # Data key stored in self._data for stateful scrolling across display() calls - raw_id = str(el.get('id', 0)).replace('-', '_') - p['data_key'] = f"mq_{raw_id}" - p['blink'] = bool(el.get('blink', False)) - - result.append(p) - return result - - -def _generate_plugin_files(data: dict) -> dict: - """ - Generate all plugin file contents as strings. - - Returns dict: {'manager.py', 'manifest.json', 'config_schema.json', 'requirements.txt'} - Raises ValueError with a human-readable message on any validation failure. - """ - metadata = data.get('metadata', {}) - elements = data.get('elements', []) - data_model = data.get('dataModel', {}) - config_vars = data_model.get('configVars', []) - - plugin_id = metadata.get('id', '').strip() - if not _PLUGIN_ID_RE.match(plugin_id): - raise ValueError( - 'Plugin ID must start with a lowercase letter and contain only ' - 'lowercase letters, numbers, and hyphens (max 63 chars).' - ) - - plugin_name = metadata.get('name', '').strip() - if not plugin_name: - raise ValueError('Plugin name is required.') - - author = metadata.get('author', '').strip() - if not author: - raise ValueError('Author is required.') - - version = metadata.get('version', '1.0.0').strip() - - # Validate config var keys are valid Python identifiers - for cv in config_vars: - key = cv.get('key', '') - if not _PYTHON_IDENT_RE.match(key): - raise ValueError(f'Config variable key "{key}" is not a valid Python identifier.') - - class_name = _to_class_name(plugin_name) - # Only consider visible elements for code generation flags - visible_elements = [e for e in elements if e.get('visible') is not False] - processed = _preprocess_elements(elements) - has_clock = any(e.get('type') == 'clock' for e in visible_elements) - has_blink = any(e.get('blink') for e in visible_elements) - has_countdown = any(e.get('type') == 'countdown' for e in visible_elements) - _var_re = re.compile(r'\{[a-zA-Z_]\w*\}') - has_text_template = any( - e.get('type') == 'text' and ( - _var_re.search(e.get('text', '') or '') or - _var_re.search(e.get('text2', '') or '') - ) - for e in visible_elements - ) - - # Background fill color (None → don't render, use LED panel's native black) - bg_color: str | None = None - bg_raw = metadata.get('bgColor') - if isinstance(bg_raw, dict): - r, g, b = int(bg_raw.get('r', 0)), int(bg_raw.get('g', 0)), int(bg_raw.get('b', 0)) - if r or g or b: - bg_color = f'({r}, {g}, {b})' - - # Render manager.py - env = _get_jinja_env() - try: - tmpl = env.get_template('manager.py.j2') - except jinja2.TemplateNotFound: - raise ValueError('Code generation template not found. This is a server configuration issue.') - - manager_py = tmpl.render( - plugin_name=plugin_name, - class_name=class_name, - plugin_id=plugin_id, - generated_date=datetime.now().strftime('%Y-%m-%d'), - config_vars=config_vars, - elements=processed, - has_clock=has_clock, - has_blink=has_blink, - has_countdown=has_countdown, - has_text_template=has_text_template, - bg_color=bg_color, - ) - - # Syntax-check the generated Python - try: - ast.parse(manager_py) - except SyntaxError as exc: - raise ValueError(f'Generated code has a syntax error: {exc}') from exc - - # Build manifest - manifest = { - 'id': plugin_id, - 'name': plugin_name, - 'version': version, - 'author': author, - 'description': metadata.get('description', f'Custom plugin created with LEDMatrix Plugin Composer'), - 'category': metadata.get('category', 'custom'), - 'tags': ['composer', 'custom'], - 'entry_point': 'manager.py', - 'class_name': class_name, - 'display_modes': [plugin_id], - 'compatible_versions': ['>=2.0.0'], - 'last_updated': datetime.now().strftime('%Y-%m-%d'), - 'update_interval': int(metadata.get('update_interval', 60)), - 'default_duration': float(metadata.get('display_duration', 15)), - 'versions': [ - {'released': datetime.now().strftime('%Y-%m-%d'), 'version': version} - ], - } - - # Validate manifest against the project's schema - if composer_bp.project_root: - schema_path = Path(composer_bp.project_root) / 'schema' / 'manifest_schema.json' - if schema_path.exists(): - schema = json.loads(schema_path.read_text()) - validator = jsonschema.Draft7Validator(schema) - errors = list(validator.iter_errors(manifest)) - if errors: - msgs = '; '.join(e.message for e in errors[:3]) - raise ValueError(f'Manifest validation failed: {msgs}') - - # Build config_schema - type_map = { - 'string': {'type': 'string'}, - 'number': {'type': 'number', 'minimum': 0}, - 'boolean': {'type': 'boolean'}, - 'color': { - 'type': 'array', - 'items': {'type': 'integer', 'minimum': 0, 'maximum': 255}, - 'minItems': 3, - 'maxItems': 3, - }, - } - - config_properties = { - 'enabled': {'type': 'boolean', 'default': True}, - 'display_duration': {'type': 'number', 'minimum': 1, 'default': float(metadata.get('display_duration', 15))}, - } - for cv in config_vars: - cv_type = cv.get('type', 'string') - prop = dict(type_map.get(cv_type, {'type': 'string'})) - if cv.get('description'): - prop['description'] = cv['description'] - if cv.get('label'): - prop['title'] = cv['label'] - default = cv.get('default', '') - if cv_type == 'number': - try: - prop['default'] = float(default) if default != '' else 0 - except (TypeError, ValueError): - prop['default'] = 0 - elif cv_type == 'boolean': - prop['default'] = bool(default) - else: - prop['default'] = default - config_properties[cv['key']] = prop - - config_schema = { - '$schema': 'http://json-schema.org/draft-07/schema#', - 'type': 'object', - 'properties': config_properties, - } - - return { - 'manager.py': manager_py, - 'manifest.json': json.dumps(manifest, indent=2), - 'config_schema.json': json.dumps(config_schema, indent=2), - 'requirements.txt': '', - } - - -def _save_composer_state(target_dir: Path, payload: dict) -> None: - """Persist the raw composer payload alongside the generated plugin files.""" - (target_dir / '_composer_state.json').write_text( - json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8' - ) - - -def _pack_zip(files: dict, plugin_id: str) -> io.BytesIO: - """Pack generated plugin files into an in-memory ZIP.""" - buf = io.BytesIO() - with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf: - for filename, content in files.items(): - info = zipfile.ZipInfo(f'{plugin_id}/{filename}') - info.compress_type = zipfile.ZIP_DEFLATED - zf.writestr(info, content.encode('utf-8') if isinstance(content, str) else content) - buf.seek(0) - return buf - - -# ── Routes ──────────────────────────────────────────────────────────────────── - -@composer_bp.route('/') -def index(): - return render_template('v3/composer.html') - - -@composer_bp.route('/api/generate', methods=['POST']) -def generate_zip(): - data = request.get_json(force=True, silent=True) - if not data: - return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 - try: - files = _generate_plugin_files(data) - except ValueError as exc: - return jsonify({'status': 'error', 'message': str(exc)}), 422 - - plugin_id = data.get('metadata', {}).get('id', 'plugin') - files['_composer_state.json'] = json.dumps(data, indent=2, ensure_ascii=False) - zip_buf = _pack_zip(files, plugin_id) - return send_file( - zip_buf, - mimetype='application/zip', - as_attachment=True, - download_name=f'{plugin_id}.zip', - ) - - -@composer_bp.route('/api/install', methods=['POST']) -def install_locally(): - if not composer_bp.plugins_dir: - return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503 - - data = request.get_json(force=True, silent=True) - if not data: - return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 - - try: - files = _generate_plugin_files(data) - except ValueError as exc: - return jsonify({'status': 'error', 'message': str(exc)}), 422 - - plugin_id = data.get('metadata', {}).get('id', '') - target = Path(composer_bp.plugins_dir) / plugin_id - force = bool(data.get('_force', False)) - - if target.exists() and not force: - return jsonify({ - 'status': 'conflict', - 'message': f'Plugin "{plugin_id}" is already installed.', - }), 409 - - try: - if target.exists() and force: - import shutil as _shutil - _shutil.rmtree(target) - target.mkdir(parents=True, exist_ok=False) - for filename, content in files.items(): - (target / filename).write_text(content, encoding='utf-8') - _save_composer_state(target, data) - except OSError as exc: - return jsonify({'status': 'error', 'message': f'Failed to write plugin files: {exc}'}), 500 - - # Trigger plugin discovery so it shows up in the Plugin Manager immediately - if composer_bp.plugin_manager: - try: - composer_bp.plugin_manager.discover_plugins() - except Exception as exc: - logger.warning('discover_plugins() failed after composer install: %s', exc) - - return jsonify({ - 'status': 'success', - 'message': f'Plugin "{plugin_id}" installed successfully.', - 'plugin_id': plugin_id, - }) - - -@composer_bp.route('/api/fonts/') -def serve_font(font_name): - """Serve an allowlisted font file for canvas FontFace loading.""" - if font_name not in _ALLOWED_FONTS: - return '', 404 - if not composer_bp.project_root: - return '', 503 - font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / font_name - if not font_path.exists(): - return '', 404 - return send_file(str(font_path), mimetype='font/ttf') - - -@composer_bp.route('/api/validate-id/') -def validate_id(plugin_id): - """Check whether a plugin ID is valid and available.""" - if not _PLUGIN_ID_RE.match(plugin_id): - return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'}) - if composer_bp.plugins_dir: - taken = (Path(composer_bp.plugins_dir) / plugin_id).exists() - if taken: - return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'}) - return jsonify({'valid': True, 'available': True}) - - -@composer_bp.route('/api/plugins') -def list_plugins(): - """List installed plugins, flagging which ones have a saved composer state.""" - if not composer_bp.plugins_dir: - return jsonify([]) - plugins_dir = Path(composer_bp.plugins_dir) - results = [] - for entry in sorted(plugins_dir.iterdir()): - if not entry.is_dir(): - continue - manifest_path = entry / 'manifest.json' - if not manifest_path.exists(): - continue - try: - manifest = json.loads(manifest_path.read_text()) - except Exception: - continue - has_state = (entry / '_composer_state.json').exists() - results.append({ - 'id': manifest.get('id', entry.name), - 'name': manifest.get('name', entry.name), - 'version': manifest.get('version', ''), - 'author': manifest.get('author', ''), - 'has_composer_state': has_state, - }) - return jsonify(results) - - -@composer_bp.route('/api/preview', methods=['POST']) -def preview_code(): - """Generate plugin files and return them as JSON for the code preview modal.""" - data = request.get_json(force=True, silent=True) - if not data: - return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 - try: - files = _generate_plugin_files(data) - except ValueError as exc: - return jsonify({'status': 'error', 'message': str(exc)}), 422 - return jsonify({ - 'status': 'ok', - 'files': { - 'manager.py': files['manager.py'], - 'manifest.json': files['manifest.json'], - 'config_schema.json': files['config_schema.json'], - }, - }) - - -@composer_bp.route('/api/load/') -def load_plugin(plugin_id): - """Load a plugin's composer state for editing. - - If a _composer_state.json exists, return it verbatim. - Otherwise, extract config vars from config_schema.json for a partial import. - """ - if not composer_bp.plugins_dir: - return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503 - if not _PLUGIN_ID_RE.match(plugin_id): - return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 - - plugin_dir = Path(composer_bp.plugins_dir) / plugin_id - if not plugin_dir.exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - # Full composer state - state_path = plugin_dir / '_composer_state.json' - if state_path.exists(): - try: - state = json.loads(state_path.read_text()) - return jsonify({'status': 'ok', 'source': 'composer', 'state': state}) - except Exception as exc: - return jsonify({'status': 'error', 'message': f'Failed to read state: {exc}'}), 500 - - # Partial import from config_schema.json - schema_path = plugin_dir / 'config_schema.json' - manifest_path = plugin_dir / 'manifest.json' - config_vars = [] - - if schema_path.exists(): - try: - schema = json.loads(schema_path.read_text()) - props = schema.get('properties', {}) - skip = {'enabled', 'display_duration', 'update_interval'} - type_map = {'boolean': 'boolean', 'number': 'number', 'integer': 'number', 'string': 'string'} - for key, prop in props.items(): - if key in skip: - continue - prop_type = prop.get('type', 'string') - if isinstance(prop_type, list): - prop_type = next((t for t in prop_type if t != 'null'), 'string') - # Detect color arrays - if prop_type == 'array' and prop.get('maxItems') == 3: - cv_type = 'color' - else: - cv_type = type_map.get(prop_type, 'string') - config_vars.append({ - 'key': key, - 'label': prop.get('title', key.replace('_', ' ').title()), - 'type': cv_type, - 'default': prop.get('default', ''), - 'description': prop.get('description', ''), - }) - except Exception: - pass - - manifest = {} - if manifest_path.exists(): - try: - manifest = json.loads(manifest_path.read_text()) - except Exception: - pass - - partial_state = { - 'composer_version': '1.0', - 'metadata': { - 'id': manifest.get('id', plugin_id), - 'name': manifest.get('name', plugin_id), - 'author': manifest.get('author', ''), - 'version': manifest.get('version', '1.0.0'), - 'description': manifest.get('description', ''), - 'category': manifest.get('category', 'custom'), - 'display_duration': manifest.get('default_duration', 15), - 'update_interval': manifest.get('update_interval', 60), - 'api_requirements': manifest.get('api_requirements', []), - }, - 'elements': [], - 'dataModel': {'configVars': config_vars, 'dataSources': [], 'computedVars': []}, - } - return jsonify({'status': 'ok', 'source': 'schema_import', 'state': partial_state}) diff --git a/web_interface/static/v3/js/composer/composer-app.js b/web_interface/static/v3/js/composer/composer-app.js deleted file mode 100644 index f0124371..00000000 --- a/web_interface/static/v3/js/composer/composer-app.js +++ /dev/null @@ -1,1337 +0,0 @@ -/** - * composerApp() — AlpineJS component for the Plugin Composer. - * - * Key architecture notes: - * - All mutations call _snapshot() for undo/redo support - * - Elements with anchors: drag stores offset from anchor, not raw px - * - localStorage autosaves on every mutation (debounced 1.5s) - * - composer_version in payload allows future server-side migration - */ - -// ── Template library ───────────────────────────────────────────────────────── -const COMPOSER_TEMPLATES = [ - { - id: 'blank', - label: 'Blank Canvas', - description: 'Start from scratch', - icon: 'fas fa-plus-square', - preset: '128×32', - elements: [], - dataModel: { configVars: [], dataSources: [], computedVars: [] }, - }, - { - id: 'clock', - label: 'Digital Clock', - description: 'Large clock with date below a divider', - icon: 'fas fa-clock', - preset: '128×32', - elements: [ - { id:1, type:'clock', x:44, y:4, format:'%H:%M', font:'press_start', r:100, g:255, b:100, xAnchor:null, yAnchor:null, minWidth:0, label:'time', conditions:[] }, - { id:2, type:'divider', x:0, y:18, orientation:'horizontal', r:50, g:50, b:50, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] }, - { id:3, type:'clock', x:32, y:22, format:'%A %b %d', font:'four_by_six', r:150, g:150, b:150, xAnchor:null, yAnchor:null, minWidth:0, label:'date', conditions:[] }, - ], - dataModel: { configVars:[], dataSources:[], computedVars:[] }, - }, - { - id: 'announcement', - label: 'Announcement', - description: 'Static title + configurable message text', - icon: 'fas fa-bullhorn', - preset: '128×32', - elements: [ - { id:1, type:'text', x:4, y:2, text:'ANNOUNCEMENT', font:'four_by_six', r:255, g:200, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'title', conditions:[] }, - { id:2, type:'divider', x:0, y:11, orientation:'horizontal', r:80, g:80, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] }, - { id:3, type:'dynamic_text', x:4, y:15, binding:{source:'config',key:'message',format:null}, font:'four_by_six', r:255, g:255, b:255, xAnchor:null, yAnchor:null, minWidth:0, label:'message', conditions:[] }, - ], - dataModel: { - configVars:[{ key:'message', label:'Message', type:'string', default:'Hello World!', description:'Message to display' }], - dataSources:[], computedVars:[], - }, - }, - { - id: 'scoreboard', - label: 'Scoreboard', - description: 'Two-team scores with status line', - icon: 'fas fa-trophy', - preset: '128×32', - elements: [ - { id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'home_team',format:null}, font:'four_by_six', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'home team', conditions:[] }, - { id:2, type:'dynamic_text', x:2, y:12, binding:{source:'config',key:'home_score',format:null}, font:'press_start', r:255,g:220,b:50, xAnchor:null,yAnchor:null,minWidth:0, label:'home score', conditions:[] }, - { id:3, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, - { id:4, type:'dynamic_text', x:68, y:2, binding:{source:'config',key:'away_team',format:null}, font:'four_by_six', r:200,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away team', conditions:[] }, - { id:5, type:'dynamic_text', x:68, y:12, binding:{source:'config',key:'away_score',format:null}, font:'press_start', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away score', conditions:[] }, - { id:6, type:'dynamic_text', x:44, y:25, binding:{source:'config',key:'game_status',format:null}, font:'four_by_six', r:150,g:150,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'status', conditions:[] }, - ], - dataModel: { - configVars:[ - {key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbreviation'}, - {key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'}, - {key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbreviation'}, - {key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'}, - {key:'game_status',label:'Game Status', type:'string', default:'LIVE', description:'Status label'}, - ], - dataSources:[], computedVars:[], - }, - }, - { - id: 'weather', - label: 'Weather Card', - description: 'Temperature + condition + location', - icon: 'fas fa-cloud-sun', - preset: '128×32', - elements: [ - { id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'temperature',format:null}, font:'press_start', r:255,g:140,b:40, xAnchor:null,yAnchor:null,minWidth:0, label:'temp', conditions:[] }, - { id:2, type:'divider', x:0, y:16, orientation:'horizontal', r:50,g:50,b:80, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, - { id:3, type:'dynamic_text', x:2, y:20, binding:{source:'config',key:'condition',format:null}, font:'four_by_six', r:180,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'condition', conditions:[] }, - { id:4, type:'dynamic_text', x:80, y:20, binding:{source:'config',key:'location',format:null}, font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'location', conditions:[] }, - ], - dataModel: { - configVars:[ - {key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'}, - {key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'}, - {key:'location', label:'Location', type:'string', default:'My City', description:'City name'}, - ], - dataSources:[], computedVars:[], - }, - }, - { - id: 'crypto', - label: 'Crypto Ticker', - description: 'Asset price + 24h change percentage', - icon: 'fas fa-chart-line', - preset: '128×32', - elements: [ - { id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'ticker',format:null}, font:'press_start', r:255,g:165,b:0, xAnchor:null,yAnchor:null,minWidth:0, label:'ticker', conditions:[] }, - { id:2, type:'dynamic_text', x:2, y:16, binding:{source:'config',key:'price',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'price', conditions:[] }, - { id:3, type:'dynamic_text', x:88, y:16, binding:{source:'config',key:'change_pct',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'change', conditions:[] }, - ], - dataModel: { - configVars:[ - {key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'}, - {key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'}, - {key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'}, - ], - dataSources:[], computedVars:[], - }, - }, - { - id: 'system', - label: 'System Monitor', - description: 'CPU, memory, and IP address', - icon: 'fas fa-server', - preset: '128×32', - elements: [ - { id:1, type:'text', x:2, y:2, text:'CPU', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, - { id:2, type:'dynamic_text', x:22, y:2, binding:{source:'config',key:'cpu_usage',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'cpu', conditions:[] }, - { id:3, type:'text', x:2, y:12, text:'MEM', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, - { id:4, type:'dynamic_text', x:22, y:12, binding:{source:'config',key:'mem_usage',format:null}, font:'four_by_six', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'mem', conditions:[] }, - { id:5, type:'text', x:2, y:22, text:'IP', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, - { id:6, type:'dynamic_text', x:14, y:22, binding:{source:'config',key:'ip_address',format:null}, font:'four_by_six', r:255,g:255,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'ip', conditions:[] }, - ], - dataModel: { - configVars:[ - {key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'}, - {key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'}, - {key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'}, - ], - dataSources:[], computedVars:[], - }, - }, - { - id: 'split_panel', - label: 'Split Panel', - description: 'Left label + right value, divided', - icon: 'fas fa-columns', - preset: '128×32', - elements: [ - { id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'left_label',format:null}, font:'four_by_six', r:180,g:180,b:180, xAnchor:null,yAnchor:null,minWidth:0, label:'label', conditions:[] }, - { id:2, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, - { id:3, type:'dynamic_text', x:68, y:4, binding:{source:'config',key:'right_value',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'value', conditions:[] }, - ], - dataModel: { - configVars:[ - {key:'left_label', label:'Label', type:'string', default:'LABEL', description:'Left panel label'}, - {key:'right_value', label:'Value', type:'string', default:'--', description:'Right panel value'}, - ], - dataSources:[], computedVars:[], - }, - }, -]; - -// ── LED display color palette ──────────────────────────────────────────────── -const LED_PALETTE = [ - { label: 'White', hex: '#ffffff', r: 255, g: 255, b: 255 }, - { label: 'Red', hex: '#ff2020', r: 255, g: 32, b: 32 }, - { label: 'Green', hex: '#20ff40', r: 32, g: 255, b: 64 }, - { label: 'Blue', hex: '#4060ff', r: 64, g: 96, b: 255 }, - { label: 'Yellow', hex: '#ffee00', r: 255, g: 238, b: 0 }, - { label: 'Orange', hex: '#ff8000', r: 255, g: 128, b: 0 }, - { label: 'Cyan', hex: '#00eeff', r: 0, g: 238, b: 255 }, - { label: 'Magenta', hex: '#ff00cc', r: 255, g: 0, b: 204 }, - { label: 'Amber', hex: '#ffb400', r: 255, g: 180, b: 0 }, - { label: 'Dim white', hex: '#888888', r: 136, g: 136, b: 136 }, - { label: 'Dark gray', hex: '#444444', r: 68, g: 68, b: 68 }, - { label: 'Off/Black', hex: '#000000', r: 0, g: 0, b: 0 }, -]; - -// ── Autosave helpers ───────────────────────────────────────────────────────── -const LS_KEY = 'ledmatrix_composer_draft'; -let _autosaveTimer = null; - -function _debouncedAutosave(payload) { - clearTimeout(_autosaveTimer); - _autosaveTimer = setTimeout(() => { - try { localStorage.setItem(LS_KEY, JSON.stringify(payload)); } catch (_) {} - }, 1500); -} - -// ── Main component ─────────────────────────────────────────────────────────── -function composerApp() { - return { - // ── Plugin metadata ─────────────────────────────────────────────── - metadata: { - id: '', - name: '', - author: '', - version: '1.0.0', - description: '', - category: 'custom', - display_duration: 15, - update_interval: 60, - api_requirements: [], - bgColor: { r: 0, g: 0, b: 0 }, - }, - - // ── Canvas / display preset ─────────────────────────────────────── - SCALE: 4, - MATRIX_W: 128, - MATRIX_H: 32, - currentPreset: '128×32', - - // ── Elements ────────────────────────────────────────────────────── - elements: [], - _nextId: 1, - - // ── Data model ─────────────────────────────────────────────────── - dataModel: { - configVars: [], - dataSources: [], - computedVars: [], - }, - - // ── Undo/redo ───────────────────────────────────────────────────── - _history: [], - _historyIndex: -1, - - // ── Selection & interaction ─────────────────────────────────────── - selectedId: null, - _drag: { active: false }, - _resize: { active: false }, - - // ── Canvas options ──────────────────────────────────────────────── - snapToGrid: false, - snapSize: 4, - - // ── Clipboard ───────────────────────────────────────────────────── - _clipboard: null, - _styleClipboard: null, - - // ── UI state ────────────────────────────────────────────────────── - isDirty: false, - statusMsg: '', - statusType: 'info', - generateStatus: 'idle', - installStatus: 'idle', - showGridOverlay: true, - showGuides: false, - blinkAnimating: false, - _blinkPhase: false, - _blinkTimer: null, - _animTick: 0, - _previewValues: {}, - showHelpModal: false, - _recentColors: [], - showRuler: false, - showConfigVarModal: false, - showOpenModal: false, - showTemplateModal: false, - showCodeModal: false, - _idAutoGenerated: true, - hoverInfo: '', - installedPlugins: [], - loadPluginsStatus: 'idle', - codeFiles: {}, - codeTab: 'manager.py', - loadingCode: false, - newConfigVar: { key: '', label: '', type: 'string', default: '', description: '' }, - - // ── Computed ────────────────────────────────────────────────────── - get selectedElement() { - return this.elements.find(e => e.id === this.selectedId) ?? null; - }, - get canExport() { - return this.metadata.name.trim() && this.metadata.id.trim() && this.elements.length > 0; - }, - get canUndo() { return this._historyIndex > 0; }, - get canRedo() { return this._historyIndex < this._history.length - 1; }, - get templates() { return COMPOSER_TEMPLATES; }, - get displayPresets() { return window.ComposerCanvas.DISPLAY_PRESETS; }, - get palette() { return LED_PALETTE; }, - - // ── Pre-made variable library ───────────────────────────────────── - preMadeVarCategories: [ - { label: 'Display', vars: [ - {key:'title_text', label:'Title', type:'string', default:'My Plugin', description:'Main title'}, - {key:'subtitle', label:'Subtitle', type:'string', default:'', description:'Secondary line'}, - {key:'message', label:'Message', type:'string', default:'Hello!', description:'Dynamic message'}, - ]}, - { label: 'Sports / Score', vars: [ - {key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'}, - {key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'}, - {key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbr'}, - {key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbr'}, - {key:'game_status', label:'Game Status', type:'string', default:'LIVE', description:'Status label'}, - {key:'period', label:'Period', type:'string', default:'1st', description:'Period/quarter'}, - {key:'time_left', label:'Time Left', type:'string', default:'15:00',description:'Game clock'}, - ]}, - { label: 'Weather', vars: [ - {key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'}, - {key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'}, - {key:'humidity', label:'Humidity', type:'string', default:'50%', description:'Humidity'}, - {key:'location', label:'Location', type:'string', default:'My City',description:'Location name'}, - ]}, - { label: 'System / Stats', vars: [ - {key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'}, - {key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'}, - {key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'}, - {key:'uptime', label:'Uptime', type:'string', default:'0d 0h', description:'System uptime'}, - ]}, - { label: 'Crypto / Finance', vars: [ - {key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'}, - {key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'}, - {key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'}, - {key:'volume', label:'Volume', type:'string', default:'0', description:'Trading volume'}, - ]}, - ], - - elementTypes: [ - { id: 'text', label: 'Text', icon: 'fas fa-font' }, - { id: 'dynamic_text', label: 'Dynamic Text', icon: 'fas fa-code' }, - { id: 'clock', label: 'Clock', icon: 'fas fa-clock' }, - { id: 'rectangle', label: 'Rectangle', icon: 'fas fa-square' }, - { id: 'rounded_rectangle', label: 'Rounded Rect', icon: 'fas fa-stop' }, - { id: 'ellipse', label: 'Ellipse', icon: 'fas fa-circle' }, - { id: 'arc', label: 'Arc', icon: 'fas fa-circle-notch' }, - { id: 'pixel', label: 'Pixel', icon: 'fas fa-dot-circle' }, - { id: 'line', label: 'Line', icon: 'fas fa-minus' }, - { id: 'divider', label: 'Divider', icon: 'fas fa-grip-lines' }, - { id: 'progress_bar', label: 'Progress Bar', icon: 'fas fa-tasks' }, - { id: 'countdown', label: 'Countdown', icon: 'fas fa-hourglass-half' }, - { id: 'marquee', label: 'Marquee', icon: 'fas fa-arrows-alt-h' }, - { id: 'gauge', label: 'Gauge', icon: 'fas fa-tachometer-alt' }, - { id: 'sparkline', label: 'Sparkline', icon: 'fas fa-chart-bar' }, - { id: 'pips', label: 'Pips / Rating', icon: 'fas fa-star-half-alt' }, - { id: 'section', label: 'Section Label', icon: 'fas fa-tag' }, - ], - - COLOR_THEMES: [ - { label: 'Matrix', colors: [{ r:0,g:255,b:70 }, { r:0,g:180,b:50 }, { r:0,g:100,b:30 }] }, - { label: 'Neon', colors: [{ r:255,g:0,b:200 }, { r:0,g:240,b:255 }, { r:255,g:230,b:0 }] }, - { label: 'Warm', colors: [{ r:255,g:160,b:0 }, { r:255,g:80,b:0 }, { r:255,g:220,b:80 }] }, - { label: 'Arctic', colors: [{ r:100,g:200,b:255},{ r:150,g:230,b:255},{ r:200,g:240,b:255}] }, - { label: 'Retro', colors: [{ r:255,g:140,b:0 }, { r:255,g:60,b:60 }, { r:80,g:200,b:120 }] }, - { label: 'Mono', colors: [{ r:255,g:255,b:255},{ r:170,g:170,b:170},{ r:90,g:90,b:90 }] }, - ], - - // ── Lifecycle ───────────────────────────────────────────────────── - init() { - this.$nextTick(() => { - const canvas = document.getElementById('led-canvas'); - if (canvas) { - window.ComposerCanvas.init(canvas); - window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE); - this._loadFonts().then(() => { - this._tryRestoreDraft(); - this._loadRecentColors(); - this._snapshot(); - this.render(); - }); - } - document.addEventListener('keydown', (e) => this._onKeyDown(e)); - }); - }, - - async _loadFonts() { - try { - const ff = new FontFace('PressStart2P', 'url(/composer/api/fonts/PressStart2P-Regular.ttf)'); - document.fonts.add(await ff.load()); - } catch (e) { - console.warn('PressStart2P load failed:', e); - } - }, - - _loadRecentColors() { - try { - const raw = localStorage.getItem('ledmatrix_composer_recent_colors'); - if (raw) this._recentColors = JSON.parse(raw).slice(0, 8); - } catch (_) {} - }, - - _trackColor(r, g, b) { - this._recentColors = [ - { r, g, b }, - ...this._recentColors.filter(c => !(c.r === r && c.g === g && c.b === b)), - ].slice(0, 8); - try { localStorage.setItem('ledmatrix_composer_recent_colors', JSON.stringify(this._recentColors)); } catch (_) {} - }, - - toggleRuler() { - this.showRuler = !this.showRuler; - this.render(); - }, - - _tryRestoreDraft() { - try { - const raw = localStorage.getItem(LS_KEY); - if (!raw) return; - const draft = JSON.parse(raw); - if (!draft?.composer_version) return; - if (!confirm('Resume your last unsaved session?')) { - localStorage.removeItem(LS_KEY); - return; - } - this._applyState(draft); - this._setStatus('Draft restored from last session', 'info'); - } catch (_) {} - }, - - // ── Undo / Redo ─────────────────────────────────────────────────── - _snapshot() { - const state = JSON.stringify({ - metadata: this.metadata, - elements: this.elements, - dataModel: this.dataModel, - currentPreset: this.currentPreset, - }); - this._history.splice(this._historyIndex + 1); - this._history.push(state); - if (this._history.length > 50) this._history.shift(); - else this._historyIndex++; - _debouncedAutosave(this._buildPayload()); - }, - - undo() { - if (!this.canUndo) return; - this._historyIndex--; - const state = JSON.parse(this._history[this._historyIndex]); - this._applyState(state); - this._setStatus('Undo', 'info'); - this.render(); - }, - - redo() { - if (!this.canRedo) return; - this._historyIndex++; - const state = JSON.parse(this._history[this._historyIndex]); - this._applyState(state); - this._setStatus('Redo', 'info'); - this.render(); - }, - - _applyState(state) { - if (state.metadata) this.metadata = state.metadata; - if (state.elements) this.elements = state.elements.map(el => ({ - xAnchor: null, yAnchor: null, minWidth: 0, conditions: [], - ...el, - id: el.id ?? (this._nextId++), - })); - if (state.dataModel) this.dataModel = state.dataModel; - if (state.currentPreset && state.currentPreset !== this.currentPreset) { - this.changePreset(state.currentPreset, { silent: true }); - } - this._nextId = Math.max(...this.elements.map(e => e.id + 1), 1); - this.selectedId = null; - this.isDirty = true; - }, - - // ── Display preset ──────────────────────────────────────────────── - changePreset(presetLabel, opts = {}) { - const preset = window.ComposerCanvas.DISPLAY_PRESETS.find(p => p.label === presetLabel); - if (!preset) return; - this.currentPreset = presetLabel; - this.MATRIX_W = preset.w; - this.MATRIX_H = preset.h; - this.SCALE = preset.w <= 64 ? 6 : preset.w <= 128 ? 4 : 2; - const canvas = document.getElementById('led-canvas'); - if (canvas) { - window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE); - canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px'; - canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px'; - } - if (!opts.silent) this.render(); - }, - - // ── Rendering ───────────────────────────────────────────────────── - render(opts = {}) { - window.ComposerCanvas.render( - this.elements, this.selectedId, this.MATRIX_W, this.MATRIX_H, this.SCALE, - { bgColor: this.metadata.bgColor, showGuides: this.showGuides, showRuler: this.showRuler, blinkOff: this.blinkAnimating && this._blinkPhase, animTick: this.blinkAnimating ? this._animTick : null, previewValues: this._previewValues, ...opts }, - ); - }, - - toggleGrid() { - this.showGridOverlay = !this.showGridOverlay; - window.ComposerCanvas.setGrid(this.showGridOverlay); - this.render(); - }, - - toggleGuides() { - this.showGuides = !this.showGuides; - this.render(); - }, - - toggleBlinkPreview() { - this.blinkAnimating = !this.blinkAnimating; - if (this.blinkAnimating) { - this._blinkPhase = false; - this._animTick = 0; - let blinkTick = 0; - this._blinkTimer = setInterval(() => { - this._animTick++; - blinkTick++; - if (blinkTick >= 3) { this._blinkPhase = !this._blinkPhase; blinkTick = 0; } - this.render(); - }, 200); - } else { - clearInterval(this._blinkTimer); - this._blinkTimer = null; - this._blinkPhase = false; - this._animTick = 0; - this.render(); - } - }, - - downloadPng() { - const canvas = document.getElementById('led-canvas'); - if (!canvas) return; - const link = document.createElement('a'); - link.download = (this.metadata.id || 'composer-preview') + '.png'; - link.href = canvas.toDataURL('image/png'); - link.click(); - }, - - exportDesign() { - const payload = this._buildPayload(); - const json = JSON.stringify(payload, null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.download = (this.metadata.id || 'composer-design') + '.composer.json'; - link.href = url; - link.click(); - URL.revokeObjectURL(url); - }, - - importDesign() { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json,.composer.json'; - input.onchange = (e) => { - const file = e.target.files[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (ev) => { - try { - const data = JSON.parse(ev.target.result); - if (!data.composer_version) throw new Error('Not a composer file'); - if (this.isDirty && !confirm('Replace current design?')) return; - this._applyState({ metadata: data.metadata, elements: data.elements, dataModel: data.dataModel }); - this.isDirty = false; - this._setStatus('Design loaded', 'success'); - this.render(); - } catch (err) { - this._setStatus('Failed to load: ' + err.message, 'error'); - } - }; - reader.readAsText(file); - }; - input.click(); - }, - - // ── Zoom controls ───────────────────────────────────────────────── - zoomIn() { - if (this.SCALE >= 8) return; - this.SCALE++; - this._applyScale(); - }, - zoomOut() { - if (this.SCALE <= 1) return; - this.SCALE--; - this._applyScale(); - }, - _applyScale() { - const canvas = document.getElementById('led-canvas'); - if (!canvas) return; - window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE); - canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px'; - canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px'; - this.render(); - }, - setCustomSize() { - const input = prompt( - 'Enter canvas size as WxH (e.g. 96×48, 192×64):', - `${this.MATRIX_W}×${this.MATRIX_H}` - ); - if (!input) return; - const m = input.match(/(\d+)[×xX*,\s]+(\d+)/); - if (!m) { this._setStatus('Invalid size — use WxH format', 'error'); return; } - const w = Math.max(8, Math.min(512, parseInt(m[1]))); - const h = Math.max(8, Math.min(256, parseInt(m[2]))); - this.MATRIX_W = w; - this.MATRIX_H = h; - this.currentPreset = `${w}×${h}`; - this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2; - this._applyScale(); - this._setStatus(`Canvas set to ${w}×${h}`, 'info'); - }, - zoomFit() { - // Fit canvas to center column: subtract left (~224px) + right (~288px) panels + margins - const avW = Math.max(80, window.innerWidth - 560); - // Subtract header (44) + toolbar (~40) + layers (~180) + status (28) + padding - const avH = Math.max(40, window.innerHeight - 340); - const fitW = Math.floor(avW / this.MATRIX_W); - const fitH = Math.floor(avH / this.MATRIX_H); - this.SCALE = Math.max(1, Math.min(8, Math.min(fitW, fitH))); - this._applyScale(); - }, - - onBgColorChange(hex) { - this.metadata.bgColor = { - r: parseInt(hex.slice(1, 3), 16), - g: parseInt(hex.slice(3, 5), 16), - b: parseInt(hex.slice(5, 7), 16), - }; - this.render(); - }, - - // ── Canvas events ───────────────────────────────────────────────── - onCanvasMouseDown(event) { - const { lx, ly } = this._canvasToLed(event); - - // Priority 1: resize handle on selected rectangle (skip if locked) - if (this.selectedElement?.type === 'rectangle' && !this.selectedElement.locked) { - const handle = window.ComposerCanvas.getResizeHandle( - this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H - ); - if (handle) { - const el = this.selectedElement; - this._resize = { - active: true, elemId: el.id, handle, - startMX: event.clientX, startMY: event.clientY, - startW: el.width, startH: el.height, - startX: el.x, startY: el.y, - }; - return; - } - } - - // Priority 2: element hit test (reverse = top layer first) - const hit = [...this.elements].reverse().find( - el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H) - ); - - if (hit) { - this.selectedId = hit.id; - if (!hit.locked) { - const stored = this._getStoredPos(hit); - this._drag = { - active: true, elemId: hit.id, - startMX: event.clientX, startMY: event.clientY, - startEX: stored.x, startEY: stored.y, - }; - } - } else { - this.selectedId = null; - } - this.render(); - }, - - onCanvasMouseMove(event) { - const { lx, ly } = this._canvasToLed(event); - this.hoverInfo = `${lx}, ${ly}`; - - // Resize - if (this._resize.active) { - const dx = Math.round((event.clientX - this._resize.startMX) / this.SCALE); - const dy = Math.round((event.clientY - this._resize.startMY) / this.SCALE); - const el = this.elements.find(e => e.id === this._resize.elemId); - if (!el) return; - const h = this._resize.handle; - if (h.includes('e')) el.width = Math.max(1, this._resize.startW + dx); - if (h.includes('s')) el.height = Math.max(1, this._resize.startH + dy); - if (h.includes('w')) { - const nw = Math.max(1, this._resize.startW - dx); - el.x = this._resize.startX + (this._resize.startW - nw); - el.width = nw; - } - if (h.includes('n')) { - const nh = Math.max(1, this._resize.startH - dy); - el.y = this._resize.startY + (this._resize.startH - nh); - el.height = nh; - } - this.isDirty = true; - this.render({ showTooltip: true }); - - // Cursor - const canvas = document.getElementById('led-canvas'); - if (canvas) canvas.style.cursor = window.ComposerCanvas.getCursorForHandle(h); - return; - } - - // Move drag - if (this._drag.active) { - const dx = Math.round((event.clientX - this._drag.startMX) / this.SCALE); - const dy = Math.round((event.clientY - this._drag.startMY) / this.SCALE); - const el = this.elements.find(e => e.id === this._drag.elemId); - if (!el) return; - - if (el.type === 'line') { - const newX = Math.max(0, Math.min(this.MATRIX_W - 1, this._drag.startEX + dx)); - const newY = Math.max(0, Math.min(this.MATRIX_H - 1, this._drag.startEY + dy)); - const offX = newX - el.x0, offY = newY - el.y0; - el.x0 = newX; el.y0 = newY; - el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + offX)); - el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + offY)); - this._drag.startEX = el.x0; this._drag.startEY = el.y0; - this._drag.startMX = event.clientX; this._drag.startMY = event.clientY; - } else { - // Store anchor-relative offset - const newStored = { - x: this._drag.startEX + dx, - y: this._drag.startEY + dy, - }; - this._setStoredPos(el, newStored); - } - this.isDirty = true; - this.render({ showTooltip: true }); - return; - } - - // Cursor hints when hovering - const canvas = document.getElementById('led-canvas'); - if (canvas) { - let cursor = 'crosshair'; - if (this.selectedElement?.type === 'rectangle') { - const handle = window.ComposerCanvas.getResizeHandle( - this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H - ); - if (handle) cursor = window.ComposerCanvas.getCursorForHandle(handle); - } - if (cursor === 'crosshair') { - const hit = [...this.elements].reverse().find( - el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H) - ); - if (hit) cursor = 'move'; - } - canvas.style.cursor = cursor; - } - }, - - onCanvasMouseUp() { - if (this._resize.active || this._drag.active) { - this._resize = { active: false }; - this._drag = { active: false }; - this._snapshot(); - } - const canvas = document.getElementById('led-canvas'); - if (canvas) canvas.style.cursor = 'crosshair'; - }, - - onCanvasMouseLeave() { - this.hoverInfo = ''; - this.onCanvasMouseUp(); - }, - - _canvasToLed(event) { - const rect = event.target.getBoundingClientRect(); - const lx = Math.max(0, Math.min(this.MATRIX_W - 1, Math.floor((event.clientX - rect.left) / this.SCALE))); - const ly = Math.max(0, Math.min(this.MATRIX_H - 1, Math.floor((event.clientY - rect.top) / this.SCALE))); - return { lx: this._snap(lx), ly: this._snap(ly) }; - }, - - _snap(v) { - if (!this.snapToGrid || this.snapSize < 2) return v; - return Math.round(v / this.snapSize) * this.snapSize; - }, - - // Anchor-aware position storage: x/y stored as offset from anchor - _getStoredPos(el) { - return { x: el.x ?? el.x0 ?? 0, y: el.y ?? el.y0 ?? 0 }; - }, - - _setStoredPos(el, { x, y }) { - if (this.snapToGrid && this.snapSize > 0) { - x = Math.round(x / this.snapSize) * this.snapSize; - y = Math.round(y / this.snapSize) * this.snapSize; - } - const clampX = v => Math.max(-this.MATRIX_W, Math.min(this.MATRIX_W * 2, v)); - const clampY = v => Math.max(-this.MATRIX_H, Math.min(this.MATRIX_H * 2, v)); - if (el.type === 'line') { - el.x0 = clampX(x); el.y0 = clampY(y); - } else { - el.x = clampX(x); el.y = clampY(y); - } - }, - - // ── Alignment ───────────────────────────────────────────────────── - _alignElement(el, axis, mode) { - const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H); - if (!bb) return; - if (axis === 'x') { - const newX = mode === 'start' ? 0 - : mode === 'center' ? Math.round((this.MATRIX_W - bb.w) / 2) - : this.MATRIX_W - bb.w; - // Clear x-anchor so stored x IS the absolute position - if ('xAnchor' in el) el.xAnchor = null; - el.x = Math.round(newX); - if (el.type === 'line') el.x0 = Math.round(newX); - } else { - const newY = mode === 'start' ? 0 - : mode === 'center' ? Math.round((this.MATRIX_H - bb.h) / 2) - : this.MATRIX_H - bb.h; - if ('yAnchor' in el) el.yAnchor = null; - el.y = Math.round(newY); - if (el.type === 'line') el.y0 = Math.round(newY); - } - this._snapshot(); - this.isDirty = true; - this.render(); - }, - alignLeft() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'start'); }, - alignHCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'center'); }, - alignRight() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'end'); }, - alignTop() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'start'); }, - alignVCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'center'); }, - alignBottom() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'end'); }, - - // ── Keyboard shortcuts ──────────────────────────────────────────── - _onKeyDown(e) { - const tag = document.activeElement?.tagName; - const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; - - // Ctrl/Cmd combos work everywhere - if (e.ctrlKey || e.metaKey) { - if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); return; } - if ((e.key === 'y') || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); this.redo(); return; } - if (e.key === 'd' && this.selectedId) { e.preventDefault(); this.duplicateElement(this.selectedId); return; } - if (e.key === 'c' && this.selectedId && !e.shiftKey) { e.preventDefault(); this.copyElement(); return; } - if (e.key === 'c' && this.selectedId && e.shiftKey) { e.preventDefault(); this.copyStyle(); return; } - if (e.key === 'v' && this._clipboard && !e.shiftKey) { e.preventDefault(); this.pasteElement(); return; } - if (e.key === 'v' && this._styleClipboard && e.shiftKey) { e.preventDefault(); this.pasteStyle(); return; } - if (e.key === 'a') { e.preventDefault(); if (this.elements.length) { this.selectedId = this.elements[0].id; this.render(); } return; } - } - - // Tab cycles through elements regardless of input focus - if (e.key === 'Tab' && this.elements.length) { - e.preventDefault(); - const idx = this.elements.findIndex(el => el.id === this.selectedId); - const next = e.shiftKey - ? (idx <= 0 ? this.elements.length - 1 : idx - 1) - : (idx >= this.elements.length - 1 ? 0 : idx + 1); - this.selectedId = this.elements[next].id; - this.render(); - return; - } - - if (inInput) return; - - const dist = e.shiftKey ? 5 : (this.snapToGrid && this.snapSize >= 2 ? this.snapSize : 1); - if (e.key === 'ArrowLeft') { e.preventDefault(); this.nudge(-dist, 0); } - if (e.key === 'ArrowRight') { e.preventDefault(); this.nudge(dist, 0); } - if (e.key === 'ArrowUp') { e.preventDefault(); this.nudge(0, -dist); } - if (e.key === 'ArrowDown') { e.preventDefault(); this.nudge(0, dist); } - if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedId) { - e.preventDefault(); - this.removeElement(this.selectedId); - } - if (e.key === 'Escape') { - if (this.showHelpModal) { this.showHelpModal = false; return; } - this.selectedId = null; this.render(); - } - if (e.key === '?') { this.showHelpModal = !this.showHelpModal; } - if (e.key === 'g' || e.key === 'G') { this.snapToGrid = !this.snapToGrid; } - if (e.key === ']' && this.selectedId) { this.bringToFront(this.selectedId); } - if (e.key === '[' && this.selectedId) { this.sendToBack(this.selectedId); } - }, - - nudge(dx, dy) { - const el = this.selectedElement; - if (!el) return; - if (el.locked) { this._setStatus('Unlock element to move it', 'info'); return; } - if (el.type === 'line') { - el.x0 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x0 + dx)); - el.y0 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y0 + dy)); - el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + dx)); - el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + dy)); - } else if (el.type === 'divider') { - if (el.orientation === 'horizontal') el.y = Math.max(0, Math.min(this.MATRIX_H - 1, (el.y ?? 0) + dy)); - else el.x = Math.max(0, Math.min(this.MATRIX_W - 1, (el.x ?? 0) + dx)); - } else { - el.x = (el.x ?? 0) + dx; - el.y = (el.y ?? 0) + dy; - } - this.isDirty = true; - this._snapshot(); - this.render(); - }, - - // ── Element management ──────────────────────────────────────────── - addElement(type) { - const defaults = window.ComposerCanvas.ELEMENT_DEFAULTS[type]; - if (!defaults) return; - const el = { - id: this._nextId++, - type, - label: '', - conditions: [], - xAnchor: null, yAnchor: null, minWidth: 0, - x: Math.floor(this.MATRIX_W / 4), - y: Math.floor(this.MATRIX_H / 2) - 4, - ...structuredClone(defaults), - }; - if (type === 'line') { - const cx = Math.floor(this.MATRIX_W / 4), cy = Math.floor(this.MATRIX_H / 2); - el.x0 = cx; el.y0 = cy; - el.x1 = Math.min(this.MATRIX_W - 1, cx + 30); el.y1 = cy; - el.x = cx; el.y = cy; - } - this.elements.push(el); - this.selectedId = el.id; - this.isDirty = true; - this._snapshot(); - this.render(); - }, - - duplicateElement(id) { - const el = this.elements.find(e => e.id === id); - if (!el) return; - const copy = { - ...structuredClone(el), - id: this._nextId++, - label: el.label ? el.label + ' copy' : '', - }; - // Offset slightly so it's visually distinct - if (copy.type === 'line') { copy.x0 += 4; copy.y0 += 4; copy.x1 += 4; copy.y1 += 4; } - else { copy.x = (copy.x ?? 0) + 4; copy.y = (copy.y ?? 0) + 4; } - - const idx = this.elements.findIndex(e => e.id === id); - this.elements.splice(idx + 1, 0, copy); - this.selectedId = copy.id; - this.isDirty = true; - this._snapshot(); - this.render(); - }, - - copyElement() { - if (!this.selectedElement) return; - this._clipboard = structuredClone(this.selectedElement); - this._setStatus(`Copied: ${this.selectedElement.label || this.selectedElement.type}`, 'info'); - }, - - copyStyle() { - const el = this.selectedElement; - if (!el) return; - this._styleClipboard = { r: el.r, g: el.g, b: el.b, font: el.font }; - this._setStatus('Style copied — select another element and paste style', 'info'); - }, - - pasteStyle() { - const el = this.selectedElement; - if (!el || !this._styleClipboard) return; - const { r, g, b, font } = this._styleClipboard; - if (r !== undefined) { el.r = r; el.g = g; el.b = b; } - if (font !== undefined && el.font !== undefined) el.font = font; - this.isDirty = true; - this._snapshot(); - this.render(); - this._setStatus('Style pasted', 'success'); - }, - - pasteElement() { - if (!this._clipboard) return; - const copy = structuredClone(this._clipboard); - copy.id = this._nextId++; - if (copy.type === 'line') { copy.x0 += 6; copy.y0 += 6; copy.x1 += 6; copy.y1 += 6; } - else { copy.x = (copy.x ?? 0) + 6; copy.y = (copy.y ?? 0) + 6; } - this.elements.push(copy); - this.selectedId = copy.id; - this.isDirty = true; - this._snapshot(); - this.render(); - this._setStatus('Pasted element', 'success'); - }, - - removeElement(id) { - this.elements = this.elements.filter(e => e.id !== id); - if (this.selectedId === id) this.selectedId = null; - this.isDirty = true; - this._snapshot(); - this.render(); - }, - - moveElementUp(id) { - const idx = this.elements.findIndex(e => e.id === id); - if (idx < this.elements.length - 1) { - [this.elements[idx], this.elements[idx + 1]] = [this.elements[idx + 1], this.elements[idx]]; - this.isDirty = true; - this._snapshot(); - this.render(); - } - }, - - moveElementDown(id) { - const idx = this.elements.findIndex(e => e.id === id); - if (idx > 0) { - [this.elements[idx], this.elements[idx - 1]] = [this.elements[idx - 1], this.elements[idx]]; - this.isDirty = true; - this._snapshot(); - this.render(); - } - }, - - bringToFront(id) { - const idx = this.elements.findIndex(e => e.id === id); - if (idx < this.elements.length - 1) { - this.elements.push(this.elements.splice(idx, 1)[0]); - this.isDirty = true; - this._snapshot(); - this.render(); - } - }, - - sendToBack(id) { - const idx = this.elements.findIndex(e => e.id === id); - if (idx > 0) { - this.elements.unshift(this.elements.splice(idx, 1)[0]); - this.isDirty = true; - this._snapshot(); - this.render(); - } - }, - - clearAll() { - if (this.elements.length === 0) return; - if (!confirm('Remove all elements from the canvas?')) return; - this.elements = []; - this.selectedId = null; - this.isDirty = true; - this._snapshot(); - this.render(); - }, - - // ── Alignment ───────────────────────────────────────────────────── - alignElement(dir) { - const el = this.selectedElement; - if (!el) return; - const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H); - switch (dir) { - case 'left': el.x = 0; break; - case 'center': el.x = Math.round((this.MATRIX_W - bb.w) / 2); break; - case 'right': el.x = this.MATRIX_W - bb.w; break; - case 'top': el.y = 0; break; - case 'middle': el.y = Math.round((this.MATRIX_H - bb.h) / 2); break; - case 'bottom': el.y = this.MATRIX_H - bb.h; break; - } - this.isDirty = true; - this._snapshot(); - this.render(); - }, - - // ── Templates ───────────────────────────────────────────────────── - loadTemplate(tmpl) { - if (this.isDirty && this.elements.length > 0) { - if (!confirm('Load template? Your current canvas will be replaced.')) return; - } - this.elements = tmpl.elements.map((el, i) => ({ - xAnchor: null, yAnchor: null, minWidth: 0, conditions: [], - ...structuredClone(el), - id: i + 1, - })); - this._nextId = this.elements.length + 1; - this.dataModel = structuredClone(tmpl.dataModel); - if (tmpl.preset) this.changePreset(tmpl.preset, { silent: true }); - this.selectedId = null; - this.showTemplateModal = false; - this.isDirty = true; - this._snapshot(); - this.render(); - this._setStatus(`Loaded template: ${tmpl.label}`, 'success'); - }, - - // ── Metadata helpers ────────────────────────────────────────────── - onNameInput() { - if (this._idAutoGenerated) { - this.metadata.id = this.metadata.name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - } - }, - onIdInput() { this._idAutoGenerated = false; }, - isIdValid() { return /^[a-z][a-z0-9-]{0,62}$/.test(this.metadata.id); }, - - // ── Config variables ────────────────────────────────────────────── - addConfigVar() { - const key = this.newConfigVar.key.trim(); - if (!key) { this._setStatus('Key is required', 'error'); return; } - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { - this._setStatus('Key must be a valid Python identifier', 'error'); return; - } - if (this.dataModel.configVars.find(v => v.key === key)) { - this._setStatus(`"${key}" already exists`, 'error'); return; - } - this.dataModel.configVars.push({ ...this.newConfigVar, key }); - this.newConfigVar = { key:'', label:'', type:'string', default:'', description:'' }; - this.showConfigVarModal = false; - this.isDirty = true; - this._snapshot(); - }, - - removeConfigVar(key) { - const bound = this.elements.filter( - e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key - ); - if (bound.length && !confirm(`"${key}" is used by ${bound.length} element(s). Remove anyway?`)) return; - this.dataModel.configVars = this.dataModel.configVars.filter(v => v.key !== key); - this.isDirty = true; - this._snapshot(); - }, - - _isBound(key) { - return this.elements.some( - e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key - ); - }, - - addPreMadeVar(varDef) { - if (this.dataModel.configVars.find(v => v.key === varDef.key)) { - this._setStatus(`"${varDef.key}" already added`, 'warning'); return; - } - this.dataModel.configVars.push({ ...varDef }); - this.isDirty = true; - this._snapshot(); - this._setStatus(`Added: ${varDef.key}`, 'success'); - }, - - // ── Open existing plugin ────────────────────────────────────────── - async openPluginModal() { - this.showOpenModal = true; - this.installedPlugins = []; - this.loadPluginsStatus = 'loading'; - try { - const resp = await fetch('/composer/api/plugins'); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - this.installedPlugins = await resp.json(); - this.loadPluginsStatus = 'done'; - } catch (e) { - this.loadPluginsStatus = 'error'; - this._setStatus('Could not load plugin list: ' + e.message, 'error'); - } - }, - - async loadPlugin(pluginId) { - this.showOpenModal = false; - try { - const resp = await fetch(`/composer/api/load/${pluginId}`); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.message || `HTTP ${resp.status}`); - } - const data = await resp.json(); - const state = data.state ?? data; - this._applyState(state); - this._idAutoGenerated = false; - this.isDirty = false; - this._snapshot(); - this._setStatus(`Loaded: ${pluginId}` + (data.source === 'schema_import' ? ' (config vars only)' : ''), 'success'); - this.render(); - } catch (e) { - this._setStatus('Failed to load plugin: ' + e.message, 'error'); - } - }, - - // ── Code preview ────────────────────────────────────────────────── - async showPreview() { - if (!this._validateBeforeExport()) return; - this.showCodeModal = true; - this.loadingCode = true; - this.codeFiles = {}; - try { - const resp = await fetch('/composer/api/preview', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(this._buildPayload()), - }); - const data = await resp.json(); - if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`); - this.codeFiles = data.files; - this.codeTab = 'manager.py'; - } catch (e) { - this._setStatus('Preview failed: ' + e.message, 'error'); - this.showCodeModal = false; - } finally { - this.loadingCode = false; - } - }, - - // ── Color helpers ───────────────────────────────────────────────── - rgbToHex(r, g, b) { - return '#' + [r, g, b].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join(''); - }, - - onColorChange(el, hexField, hex) { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; } - else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; } - else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; } - else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; } - else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; } - else { el.r = r; el.g = g; el.b = b; } - this._trackColor(r, g, b); - this.render(); - }, - - applyPaletteColor(el, hexField, swatch) { - const { r, g, b } = swatch; - if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; } - else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; } - else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; } - else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; } - else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; } - else { el.r = r; el.g = g; el.b = b; } - this._trackColor(r, g, b); - this.isDirty = true; - this._snapshot(); - this.render(); - }, - - // ── Export & Install ────────────────────────────────────────────── - _buildPayload() { - return { - composer_version: '1.0', - metadata: this.metadata, - elements: this.elements, - dataModel: this.dataModel, - preset: this.currentPreset, - }; - }, - - _validateBeforeExport() { - if (!this.metadata.name.trim()) { this._setStatus('Plugin name is required', 'error'); return false; } - if (!this.isIdValid()) { this._setStatus('Plugin ID is invalid', 'error'); return false; } - if (!this.metadata.author.trim()) { this._setStatus('Author is required', 'error'); return false; } - if (this.elements.length === 0) { this._setStatus('Add at least one element', 'error'); return false; } - const unbound = this.elements.filter( - e => (e.type === 'dynamic_text' || e.type === 'progress_bar') && !e.binding?.key - ); - if (unbound.length) { this._setStatus(`${unbound.length} element(s) have no variable bound`, 'error'); return false; } - return true; - }, - - async generateZip() { - if (!this._validateBeforeExport()) return; - this.generateStatus = 'working'; - try { - const resp = await fetch('/composer/api/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(this._buildPayload()), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({ message: 'Server error' })); - throw new Error(err.message || `HTTP ${resp.status}`); - } - const blob = await resp.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = `${this.metadata.id}.zip`; a.click(); - URL.revokeObjectURL(url); - this.generateStatus = 'done'; - this._setStatus('Plugin ZIP downloaded', 'success'); - setTimeout(() => { if (this.generateStatus === 'done') this.generateStatus = 'idle'; }, 4000); - } catch (err) { - this.generateStatus = 'error'; - this._setStatus(err.message, 'error'); - setTimeout(() => { if (this.generateStatus === 'error') this.generateStatus = 'idle'; }, 6000); - } - }, - - async installLocally(force = false) { - if (!this._validateBeforeExport()) return; - this.installStatus = 'working'; - try { - const resp = await fetch('/composer/api/install', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...this._buildPayload(), _force: force }), - }); - const data = await resp.json().catch(() => ({ message: 'Server error' })); - - if (resp.status === 409 && !force) { - this.installStatus = 'idle'; - if (confirm(`Plugin "${this.metadata.id}" is already installed.\n\nOverwrite it with the current design?`)) { - return this.installLocally(true); - } - return; - } - - if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`); - - this.installStatus = 'done'; - this.isDirty = false; - localStorage.removeItem(LS_KEY); - this._setStatus(`"${this.metadata.id}" installed — enable it in the Plugin Manager`, 'success'); - setTimeout(() => { if (this.installStatus === 'done') this.installStatus = 'idle'; }, 6000); - } catch (err) { - this.installStatus = 'error'; - this._setStatus(err.message, 'error'); - setTimeout(() => { if (this.installStatus === 'error') this.installStatus = 'idle'; }, 6000); - } - }, - - // ── Color themes ────────────────────────────────────────────────── - applyColorTheme(theme) { - // Distribute theme colors round-robin across text-type elements - const textTypes = ['text', 'dynamic_text', 'clock', 'countdown', 'marquee', 'gauge']; - let idx = 0; - for (const el of this.elements) { - if (textTypes.includes(el.type)) { - const c = theme.colors[idx % theme.colors.length]; - el.r = c.r; el.g = c.g; el.b = c.b; - idx++; - } - } - this.isDirty = true; - this._snapshot(); - this.render(); - this._setStatus(`Applied theme: ${theme.label}`, 'success'); - }, - - // ── Status ──────────────────────────────────────────────────────── - _setStatus(msg, type = 'info') { - this.statusMsg = msg; - this.statusType = type; - }, - - statusClass() { - return { - info: 'text-gray-500', - success: 'text-green-600', - error: 'text-red-600', - warning: 'text-yellow-600', - }[this.statusType] || 'text-gray-500'; - }, - }; -} diff --git a/web_interface/static/v3/js/composer/composer-canvas.js b/web_interface/static/v3/js/composer/composer-canvas.js deleted file mode 100644 index adc3791d..00000000 --- a/web_interface/static/v3/js/composer/composer-canvas.js +++ /dev/null @@ -1,757 +0,0 @@ -/** - * ComposerCanvas — stateless LED matrix canvas renderer. - * - * Coordinate system: LED pixels (integers). All drawing multiplies by SCALE. - * PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline. - * → Canvas text cy = (actualY + fontSizePx) * SCALE - * - * Anchors: element x/y are offsets from their anchor point: - * xAnchor=null/'left' → x is fixed offset from left - * xAnchor='center' → x is offset from width/2 - * xAnchor='right' → x is offset inward from right edge - * yAnchor follows the same pattern with 'top'/'middle'/'bottom' - * - * Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity. - * - * Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids). - */ -window.ComposerCanvas = (() => { - 'use strict'; - - let _canvas = null; - let _ctx = null; - let _showGrid = true; - - const DISPLAY_PRESETS = [ - { label: '64×32', w: 64, h: 32 }, - { label: '128×32', w: 128, h: 32 }, - { label: '128×64', w: 128, h: 64 }, - { label: '256×32', w: 256, h: 32 }, - { label: '256×64', w: 256, h: 64 }, - ]; - - const FONT_MAP = { - press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 }, - four_by_six: { family: 'monospace', sizePx: 6, charW: 4 }, - five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 }, - }; - - const ELEMENT_DEFAULTS = { - text: { - text: 'Hello', font: 'press_start', - r: 255, g: 255, b: 255, - text2: '', lineSpacing: 2, textAlign: 'left', - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - dynamic_text: { - binding: { source: 'config', key: '', format: null }, - font: 'press_start', textAlign: 'left', - r: 255, g: 200, b: 100, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - clock: { - format: '%H:%M', font: 'press_start', - r: 100, g: 255, b: 100, - format2: '', lineSpacing: 2, textAlign: 'left', - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - rectangle: { - width: 20, height: 8, - fillR: 0, fillG: 0, fillB: 128, hasFill: true, - outR: 255, outG: 255, outB: 255, hasOutline: true, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - ellipse: { - width: 24, height: 12, - fillR: 0, fillG: 100, fillB: 200, hasFill: true, - outR: 100, outG: 180, outB: 255, hasOutline: true, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - arc: { - width: 24, height: 24, - startAngle: 0, endAngle: 270, lineWidth: 2, - r: 255, g: 200, b: 0, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - pixel: { - r: 255, g: 255, b: 255, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - rounded_rectangle: { - width: 24, height: 10, borderRadius: 3, - fillR: 0, fillG: 80, fillB: 180, hasFill: true, - outR: 120, outG: 180, outB: 255, hasOutline: true, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - line: { - x0: 0, y0: 16, x1: 63, y1: 16, - r: 180, g: 180, b: 180, lineWidth: 1, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - divider: { - orientation: 'horizontal', y: 16, x: 64, - r: 100, g: 100, b: 100, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - progress_bar: { - barWidth: 60, barHeight: 6, - binding: { source: 'config', key: '', format: null }, - r: 80, g: 200, b: 80, - bgR: 30, bgG: 30, bgB: 30, hasBg: true, - outR: 100, outG: 100, outB: 100, hasOutline: true, - previewPct: 65, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - countdown: { - binding: { source: 'config', key: '', format: null }, - countdownFormat: 'dh', - font: 'four_by_six', textAlign: 'left', - r: 255, g: 180, b: 0, - previewText: '42d 3h', - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - marquee: { - text: 'Scrolling text', font: 'press_start', - r: 255, g: 255, b: 255, - scrollSpeed: 1, gap: 16, direction: 'left', - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - section: { - label: 'Section', - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - pips: { - count: 5, filled: 3, pipSize: 4, pipSpacing: 2, - r: 255, g: 200, b: 0, - emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true, - binding: { source: 'config', key: '', format: null }, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - sparkline: { - width: 40, height: 12, - barCount: 8, barSpacing: 1, - r: 80, g: 200, b: 120, - bgR: 30, bgG: 30, bgB: 30, hasBg: false, - binding: { source: 'config', key: '', format: null }, - previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85', - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - gauge: { - width: 32, height: 32, - startAngle: 135, endAngle: 45, lineWidth: 3, - binding: { source: 'config', key: '', format: null }, - r: 80, g: 220, b: 80, - trackR: 40, trackG: 40, trackB: 40, hasTrack: true, - showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200, - previewPct: 65, - xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, - }, - }; - - // ── Anchor resolution ──────────────────────────────────────────────── - function resolveAnchor(val, anchor, dim) { - if (!anchor || anchor === 'left' || anchor === 'top') return val; - if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val; - if (anchor === 'right' || anchor === 'bottom') return dim - val; - return val; - } - - function computeActualPos(el, matrixW, matrixH) { - const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW); - const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH); - return { x: ax, y: ay }; - } - - // ── Bounding box (LED pixel space) ────────────────────────────────── - function getBoundingBox(el, matrixW, matrixH) { - const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH); - const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; - - switch (el.type) { - case 'text': { - const t1 = el.text || '', t2 = el.text2 || ''; - const w = Math.max(t1.length, t2.length) * finfo.charW; - const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx; - const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; - return { x: bx, y: ay, w, h }; - } - case 'dynamic_text': { - const key = el.binding?.key || '?'; - const w = (`{${key}}`).length * finfo.charW; - const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; - return { x: bx, y: ay, w, h: finfo.sizePx }; - } - case 'clock': { - const t1 = el.format || '%H:%M', t2 = el.format2 || ''; - const w = Math.max(t1.length, t2.length) * finfo.charW; - const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx; - const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; - return { x: bx, y: ay, w, h }; - } - case 'countdown': { - const pt = el.previewText || '--d --h'; - const w = pt.length * finfo.charW; - const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; - return { x: bx, y: ay, w, h: finfo.sizePx }; - } - case 'rectangle': - case 'rounded_rectangle': - case 'ellipse': - case 'arc': - return { x: ax, y: ay, w: el.width, h: el.height }; - case 'pixel': - return { x: ax, y: ay, w: 1, h: 1 }; - case 'line': - return { - x: Math.min(el.x0, el.x1), y: Math.min(el.y0, el.y1), - w: Math.max(1, Math.abs(el.x1 - el.x0)), - h: Math.max(1, Math.abs(el.y1 - el.y0)), - }; - case 'divider': - return el.orientation === 'horizontal' - ? { x: 0, y: ay, w: matrixW, h: 1 } - : { x: ax, y: 0, w: 1, h: matrixH }; - case 'progress_bar': - return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 }; - case 'marquee': { - const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start; - return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx }; - } - case 'gauge': - return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 }; - case 'sparkline': - return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 }; - case 'pips': { - const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2; - return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps }; - } - case 'section': - return { x: ax, y: ay, w: 0, h: 0 }; - default: - return { x: ax, y: ay, w: 4, h: 4 }; - } - } - - // ── Resize handle support ───────────────────────────────────────────── - // Returns 8 handle points for a rectangle in LED pixel space - function _getRectHandles(el, matrixW, matrixH) { - const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH); - const w = el.width, h = el.height; - const cx = ax + w / 2, cy = ay + h / 2; - return { - nw: { x: ax, y: ay }, - n: { x: cx, y: ay }, - ne: { x: ax + w, y: ay }, - w: { x: ax, y: cy }, - e: { x: ax + w, y: cy }, - sw: { x: ax, y: ay + h }, - s: { x: cx, y: ay + h }, - se: { x: ax + w, y: ay + h }, - }; - } - - // Returns the handle direction under LED-space point (lx, ly), or null - function getResizeHandle(el, lx, ly, matrixW, matrixH) { - if (!['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) return null; - const handles = _getRectHandles(el, matrixW, matrixH); - const PAD = 4; - for (const [dir, pt] of Object.entries(handles)) { - if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir; - } - return null; - } - - const _HANDLE_CURSORS = { - nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize', - w: 'w-resize', e: 'e-resize', - sw: 'sw-resize', s: 's-resize', se: 'se-resize', - }; - function getCursorForHandle(handle) { - return _HANDLE_CURSORS[handle] || 'crosshair'; - } - - // ── Hit test ───────────────────────────────────────────────────────── - function hitTest(el, lx, ly, matrixW, matrixH) { - const PAD = 3; - const bb = getBoundingBox(el, matrixW, matrixH); - return ( - lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD && - ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD - ); - } - - // ── Draw a single element ───────────────────────────────────────────── - function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) { - const s = SCALE; - const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH); - const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth; - const hidden = el.visible === false; - - ctx.save(); - if (hidden) ctx.globalAlpha = 0.12; - else if (belowBreakpoint) ctx.globalAlpha = 0.25; - - // Blink animation: when blinkOff, fully hide blinking elements - if (el.blink) { - if (opts.blinkOff) { ctx.restore(); return; } - ctx.globalAlpha *= 0.55; - } - - // Helper: compute draw X for text alignment - const _textX = (text, finfo) => { - const tw = text.length * finfo.charW * s; - if (el.textAlign === 'center') return ax * s - tw / 2; - if (el.textAlign === 'right') return ax * s - tw; - return ax * s; - }; - - try { - switch (el.type) { - case 'text': - case 'dynamic_text': - case 'clock': { - const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; - const key = el.binding?.key || '?'; - const pv = opts.previewValues?.[key]; - // Substitute {variable} tokens in text using previewValues - const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => { - const v = opts.previewValues?.[k]; - return v !== undefined && v !== '' ? String(v) : `{${k}}`; - }); - const displayText = - el.type === 'text' ? _subVars(el.text || '') - : el.type === 'clock' ? (el.format || '%H:%M') - : (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`); - ctx.font = `${finfo.sizePx * s}px ${finfo.family}`; - ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s); - // Second line (text and clock) - if (el.type === 'text' && el.text2) { - const t2 = _subVars(el.text2); - const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2); - ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s); - } - if (el.type === 'clock' && el.format2) { - const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2); - ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s); - } - break; - } - - case 'countdown': { - const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; - const t = el.previewText || '--d --h'; - ctx.font = `${finfo.sizePx * s}px ${finfo.family}`; - ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s); - break; - } - - case 'rectangle': { - const rx = ax * s, ry = ay * s; - const rw = el.width * s, rh = el.height * s; - if (el.hasFill) { - ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`; - ctx.fillRect(rx, ry, rw, rh); - } - if (el.hasOutline) { - ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; - ctx.lineWidth = 1; - ctx.strokeRect(rx, ry, rw, rh); - } - break; - } - - case 'ellipse': { - const cx = (ax + el.width / 2) * s; - const cy = (ay + el.height / 2) * s; - const rx = (el.width / 2) * s; - const ry = (el.height / 2) * s; - ctx.beginPath(); - ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); - if (el.hasFill) { - ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`; - ctx.fill(); - } - if (el.hasOutline) { - ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; - ctx.lineWidth = 1; - ctx.stroke(); - } - break; - } - - case 'arc': { - const cx = (ax + el.width / 2) * s; - const cy = (ay + el.height / 2) * s; - const rx = (el.width / 2) * s; - const ry = (el.height / 2) * s; - // PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false - const startRad = (el.startAngle ?? 0) * Math.PI / 180; - const endRad = (el.endAngle ?? 270) * Math.PI / 180; - ctx.beginPath(); - ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false); - ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.lineWidth = Math.max(1, el.lineWidth || 2); - ctx.stroke(); - break; - } - - case 'pixel': { - ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.fillRect(ax * s, ay * s, s, s); - break; - } - - case 'rounded_rectangle': { - const rx = ax * s, ry = ay * s; - const rw = el.width * s, rh = el.height * s; - const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2); - ctx.beginPath(); - ctx.roundRect(rx, ry, rw, rh, rad); - if (el.hasFill) { - ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`; - ctx.fill(); - } - if (el.hasOutline) { - ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; - ctx.lineWidth = 1; - ctx.stroke(); - } - break; - } - - case 'line': { - ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.lineWidth = Math.max(1, el.lineWidth || 1); - ctx.beginPath(); - ctx.moveTo(el.x0 * s, el.y0 * s); - ctx.lineTo(el.x1 * s, el.y1 * s); - ctx.stroke(); - break; - } - - case 'divider': { - const isH = (el.orientation || 'horizontal') === 'horizontal'; - ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.lineWidth = 1; - ctx.beginPath(); - if (isH) { - ctx.moveTo(0, ay * s + 0.5); - ctx.lineTo(_canvas.width, ay * s + 0.5); - } else { - ctx.moveTo(ax * s + 0.5, 0); - ctx.lineTo(ax * s + 0.5, _canvas.height); - } - ctx.stroke(); - break; - } - - case 'pips': { - const pipCount = Math.max(1, el.count ?? 5); - const pvPips = opts.previewValues?.[el.binding?.key]; - const filledN = pvPips !== undefined - ? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0))) - : Math.max(0, Math.min(pipCount, el.filled ?? 3)); - const ps = Math.max(1, el.pipSize ?? 4); - const pg = Math.max(0, el.pipSpacing ?? 2); - for (let i = 0; i < pipCount; i++) { - const isFilled = i < filledN; - if (!isFilled && !el.showEmpty) continue; - ctx.fillStyle = isFilled - ? `rgb(${el.r},${el.g},${el.b})` - : `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`; - ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s); - } - break; - } - - case 'sparkline': { - const slW = el.width ?? 40, slH = el.height ?? 12; - const count = Math.max(1, el.barCount ?? 8); - const spacing = el.barSpacing ?? 1; - const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count)); - const rawVals = (el.previewData || '').split(',') - .map(v => parseFloat(v.trim())).filter(n => !isNaN(n)); - while (rawVals.length < count) rawVals.push(0); - const maxV = Math.max(...rawVals.slice(0, count), 0.001); - const rx = ax * s, ry = ay * s; - if (el.hasBg) { - ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`; - ctx.fillRect(rx, ry, slW * s, slH * s); - } - ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; - for (let i = 0; i < count; i++) { - const norm = Math.max(0, Math.min(1, rawVals[i] / maxV)); - const barH = Math.max(1, Math.round(slH * norm)); - const bx = rx + (barW + spacing) * i * s; - const by = ry + (slH - barH) * s; - ctx.fillRect(bx, by, barW * s, barH * s); - } - break; - } - - case 'gauge': { - const gw = (el.width ?? 32), gh = (el.height ?? 32); - const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s; - const rx = (gw / 2) * s, ry = (gh / 2) * s; - const lw = Math.max(1, (el.lineWidth ?? 3)); - const startDeg = el.startAngle ?? 135; - const endDeg = el.endAngle ?? 45; - // Arc sweep: from startDeg clockwise to endDeg (PIL convention) - const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360; - const pvGauge = opts.previewValues?.[el.binding?.key]; - const pct = pvGauge !== undefined - ? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100 - : Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100; - const fillSweep = totalSweep * pct; - const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90 - - // Track arc - if (el.hasTrack !== false) { - ctx.beginPath(); - ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false); - ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`; - ctx.lineWidth = lw * s; - ctx.stroke(); - } - // Fill arc - if (pct > 0) { - ctx.beginPath(); - ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false); - ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.lineWidth = lw * s; - ctx.stroke(); - } - // Centre label - if (el.showLabel) { - const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six; - const labelText = Math.round(pct * 100) + '%'; - ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`; - ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`; - const ltw = ctx.measureText(labelText).width; - ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2); - } - break; - } - - case 'marquee': { - const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; - const text = el.text || 'Scrolling text'; - const tw = text.length * finfo.charW * s; - const gap = (el.gap ?? 16) * s; - const totalW = tw + gap; - const tick = opts.animTick ?? 0; - const speed = (el.scrollSpeed ?? 1) * 2; - const scrolled = (tick * speed) % totalW; - // left: text enters from right; right: text enters from left - const startX = el.direction === 'right' - ? scrolled - tw - : matrixW * s - scrolled; - ctx.font = `${finfo.sizePx * s}px ${finfo.family}`; - ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; - // Clip to canvas width so text doesn't bleed outside - ctx.save(); - ctx.beginPath(); - ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s); - ctx.clip(); - for (let i = -1; i <= 2; i++) { - ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s); - } - ctx.restore(); - break; - } - - case 'progress_bar': { - const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6; - const pvPb = opts.previewValues?.[el.binding?.key]; - const pct = pvPb !== undefined - ? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100 - : Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100; - const rx = ax * s, ry = ay * s; - if (el.hasBg) { - ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`; - ctx.fillRect(rx, ry, bw * s, bh * s); - } - const fillW = Math.max(0, Math.round(bw * pct)); - if (fillW > 0) { - ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.fillRect(rx, ry, fillW * s, bh * s); - } - if (el.hasOutline) { - ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`; - ctx.lineWidth = 1; - ctx.strokeRect(rx, ry, bw * s, bh * s); - } - break; - } - } - - if (belowBreakpoint) { - ctx.globalAlpha = 0.6; - const bb = getBoundingBox(el, matrixW, matrixH); - ctx.font = `${Math.max(8, s * 2)}px monospace`; - ctx.fillStyle = '#facc15'; - ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s); - } - } finally { - ctx.restore(); - } - } - - // ── Selection indicator ────────────────────────────────────────────── - function _drawSelection(ctx, el, SCALE, matrixW, matrixH) { - const bb = getBoundingBox(el, matrixW, matrixH); - const PAD = 2, s = SCALE; - const rx = bb.x * s - PAD, ry = bb.y * s - PAD; - const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2; - - ctx.save(); - ctx.strokeStyle = '#3b82f6'; - ctx.lineWidth = 1; - ctx.setLineDash([3, 2]); - ctx.strokeRect(rx, ry, rw, rh); - ctx.setLineDash([]); - - if (el.xAnchor || el.yAnchor) { - ctx.font = `${Math.max(7, s)}px sans-serif`; - ctx.fillStyle = '#a78bfa'; - const anchorText = [ - el.xAnchor ? `x:${el.xAnchor[0]}` : '', - el.yAnchor ? `y:${el.yAnchor[0]}` : '', - ].filter(Boolean).join(' '); - if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2); - } - - // Resize handles: on rect, rounded rect, ellipse - if (['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) { - const handles = _getRectHandles(el, matrixW, matrixH); - const HS = 5; - ctx.fillStyle = 'white'; - ctx.strokeStyle = '#2563eb'; - ctx.lineWidth = 1; - for (const pt of Object.values(handles)) { - const hx = pt.x * s - HS / 2; - const hy = pt.y * s - HS / 2; - ctx.fillRect(hx, hy, HS, HS); - ctx.strokeRect(hx, hy, HS, HS); - } - } else { - // Corner dots for non-rectangle elements - ctx.fillStyle = '#3b82f6'; - const HS = 4; - for (const [hx, hy] of [ - [rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2], - [rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2], - ]) ctx.fillRect(hx, hy, HS, HS); - } - - ctx.restore(); - } - - // ── Dimension tooltip while dragging ───────────────────────────────── - function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) { - const bb = getBoundingBox(el, matrixW, matrixH); - const label = el.type === 'rectangle' - ? `${el.width}×${el.height}` - : `${bb.x},${bb.y}`; - const s = SCALE; - ctx.save(); - ctx.font = `${Math.max(9, s * 1.5)}px monospace`; - const tw = ctx.measureText(label).width; - const tx = bb.x * s, ty = (bb.y - 2) * s; - ctx.fillStyle = 'rgba(0,0,0,0.7)'; - ctx.fillRect(tx - 2, ty - 10, tw + 4, 12); - ctx.fillStyle = 'white'; - ctx.fillText(label, tx, ty); - ctx.restore(); - } - - // ── Public API ─────────────────────────────────────────────────────── - - function init(canvasEl) { - _canvas = canvasEl; - _ctx = canvasEl.getContext('2d'); - } - - function setGrid(show) { _showGrid = show; } - - function updateCanvasSize(matrixW, matrixH, SCALE) { - if (!_canvas) return; - _canvas.width = matrixW * SCALE; - _canvas.height = matrixH * SCALE; - } - - function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) { - if (!_ctx) return; - const cW = matrixW * SCALE, cH = matrixH * SCALE; - - const bg = opts.bgColor; - _ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000'; - _ctx.fillRect(0, 0, cW, cH); - - if (_showGrid) { - _ctx.strokeStyle = 'rgba(255,255,255,0.07)'; - _ctx.lineWidth = 0.5; - for (let x = SCALE; x < cW; x += SCALE) { - _ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke(); - } - for (let y = SCALE; y < cH; y += SCALE) { - _ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke(); - } - } - - for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts); - - if (opts.showRuler) { - _ctx.save(); - _ctx.fillStyle = 'rgba(255,255,255,0.08)'; - _ctx.fillRect(0, 0, cW, SCALE); // top strip - _ctx.fillRect(0, 0, SCALE, cH); // left strip - _ctx.strokeStyle = 'rgba(255,255,255,0.5)'; - _ctx.fillStyle = 'rgba(255,255,255,0.6)'; - _ctx.font = `${Math.max(5, SCALE - 1)}px monospace`; - const step = SCALE >= 4 ? 8 : 16; - for (let px = 0; px <= matrixW; px += step) { - const cx = px * SCALE; - const major = px % 32 === 0; - _ctx.lineWidth = 0.5; - _ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke(); - if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1); - } - for (let py = 0; py <= matrixH; py += step) { - const cy = py * SCALE; - const major = py % 32 === 0; - _ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke(); - if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1); - } - _ctx.restore(); - } - - if (opts.showGuides) { - _ctx.save(); - _ctx.strokeStyle = 'rgba(255,60,60,0.45)'; - _ctx.lineWidth = 1; - _ctx.setLineDash([4, 3]); - const mx = Math.floor(cW / 2) + 0.5; - const my = Math.floor(cH / 2) + 0.5; - _ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke(); - _ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke(); - _ctx.setLineDash([]); - _ctx.restore(); - } - - const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null; - if (sel) { - _drawSelection(_ctx, sel, SCALE, matrixW, matrixH); - if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH); - } - } - - return { - init, render, setGrid, updateCanvasSize, - hitTest, getBoundingBox, computeActualPos, resolveAnchor, - getResizeHandle, getCursorForHandle, - ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, - }; -})(); diff --git a/web_interface/templates/v3/composer.html b/web_interface/templates/v3/composer.html deleted file mode 100644 index b6b062a8..00000000 --- a/web_interface/templates/v3/composer.html +++ /dev/null @@ -1,1852 +0,0 @@ - - - - - - Plugin Composer — LEDMatrix - - - - - - -
- - -
- - - -
-

- - Plugin Composer - Beta -

- - -
- - -
- -
- - - - - - - - - - -
- - - - - - - - - - -
- - -
- - -
-
- - -
- Plugin Info -
-
- Name * - -
-
- - ID * - - - - -
-
- Author * - -
-
-
- Duration s - -
-
- Version - -
-
-
- Background -
- - - -
-
-
- Category - -
-
- Description - -
-
-
- - -
- Add Elements -
- -
-
- - -
- Color Themes -
- -
-
- - -
-
- Config Variables - -
-
- -

No variables

-
-
- - -
- Quick-Add Vars - -
- -
- -
- -
-
- - -
- - -
-
- Display - - -
- - × - · × - - - - - - -
- - -
- -
- - - - -
- -
- -
- - -
- - -
- - -
-
- Layers - top ↑ -
-
- - -
-
- -
- - -
-
- - - - - -
-
- -
- - -
- - - unsaved - - -
- - - SNAP - - - el · - - -
- - - - - - - - - - - - - - - - - - -
- - - - - - - diff --git a/web_interface/templates/v3/composer/manager.py.j2 b/web_interface/templates/v3/composer/manager.py.j2 deleted file mode 100644 index 5d90e06d..00000000 --- a/web_interface/templates/v3/composer/manager.py.j2 +++ /dev/null @@ -1,298 +0,0 @@ -""" -{{ plugin_name }} — LEDMatrix Plugin -Generated by LEDMatrix Plugin Composer on {{ generated_date }} - -Extension points: - update() → add HTTP/MQTT data-fetching logic here - _get_display_values() → map fetched data to display strings - display() → add new elements or adapt layout per display size -""" -from src.plugin_system.base_plugin import BasePlugin -{% if has_clock or has_countdown %} -from datetime import datetime -{% endif %} -{% if has_blink %} -import time -{% endif %} -{% if has_text_template %} -from collections import defaultdict -{% endif %} - - -class {{ class_name }}(BasePlugin): - - def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): - super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) -{% for var in config_vars %} - self.{{ var.key }} = config.get({{ var.key | tojson }}, {{ var.default | tojson }}) -{% endfor %} - # Live data cache — populated by update(); always {} in static layouts - self._data = {} - - def update(self): - """Fetch and refresh display data. - - For dynamic plugins: fetch from APIs/MQTT here and store in self._data. - _get_display_values() will read self._data to produce display strings. - """ - # --- Data sources (add fetch logic here for dynamic plugins) --- - pass - - def _get_display_values(self): - """Map config variables and live data to display-ready strings. - - This is the single extension point for v2 data sources: - add self._data lookups here once update() populates them. - """ - return { -{% for var in config_vars %} - {{ var.key | tojson }}: str(self.{{ var.key }}), -{% endfor %} - } - - def display(self, force_clear=False): - try: -{% if has_text_template %} - values = defaultdict(str, self._get_display_values()) -{% else %} - values = self._get_display_values() -{% endif %} - - if force_clear: - self.display_manager.clear() - - width = self.display_manager.width - height = self.display_manager.height -{% if bg_color %} - - self.display_manager.draw.rectangle([0, 0, width, height], fill={{ bg_color }}) -{% endif %} - - # ── Elements (rendered bottom to top) ────────────────────────── -{% for el in elements %} -{% set p = " " if el.min_width > 0 else " " %} -{% set pi = (p + " ") if el.blink else p %} -{% if el.min_width > 0 %} - if width >= {{ el.min_width }}: # breakpoint: {{ el.min_width }}px+ displays only -{% endif %} -{% if el.blink %} -{{ p }}if int(time.time() * 2) % 2: -{% endif %} - -{% if el.type == 'text' %} -{{ pi }}self.display_manager.draw_text( -{% if el.text_is_template %} -{{ pi }} {{ el.text | tojson }}.format_map(values), -{% else %} -{{ pi }} {{ el.text | tojson }}, -{% endif %} -{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, -{{ pi }} color={{ el.rgb_tuple }}, -{{ pi }} font=self.display_manager.{{ el.font_attr }}, -{{ pi }}) -{% if el.text2 %} -{{ pi }}self.display_manager.draw_text( -{% if el.text_is_template %} -{{ pi }} {{ el.text2 | tojson }}.format_map(values), -{% else %} -{{ pi }} {{ el.text2 | tojson }}, -{% endif %} -{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }}, -{{ pi }} color={{ el.rgb_tuple }}, -{{ pi }} font=self.display_manager.{{ el.font_attr }}, -{{ pi }}) -{% endif %} -{% elif el.type == 'dynamic_text' %} -{% if el.binding_source == 'config' %} -{{ pi }}self.display_manager.draw_text( -{{ pi }} values.get({{ el.binding_key | tojson }}, ''), -{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, -{{ pi }} color={{ el.rgb_tuple }}, -{{ pi }} font=self.display_manager.{{ el.font_attr }}, -{{ pi }}) -{% endif %} -{% elif el.type == 'clock' %} -{{ pi }}self.display_manager.draw_text( -{{ pi }} datetime.now().strftime({{ el.format | tojson }}), -{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, -{{ pi }} color={{ el.rgb_tuple }}, -{{ pi }} font=self.display_manager.{{ el.font_attr }}, -{{ pi }}) -{% if el.format2 %} -{{ pi }}self.display_manager.draw_text( -{{ pi }} datetime.now().strftime({{ el.format2 | tojson }}), -{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }}, -{{ pi }} color={{ el.rgb_tuple }}, -{{ pi }} font=self.display_manager.{{ el.font_attr }}, -{{ pi }}) -{% endif %} -{% elif el.type == 'countdown' %} -{{ pi }}_cd_target = float(values.get({{ el.binding_key | tojson }}, 0) or 0) -{{ pi }}_cd_secs = max(0.0, _cd_target - datetime.now().timestamp()) -{% if el.countdown_format == 'dhms' %} -{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400) -{{ pi }}_cd_h, _cd_rem = divmod(_cd_rem, 3600) -{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60) -{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}:{_cd_m:02d}:{_cd_s:02d}' -{% elif el.countdown_format == 'hms' %} -{{ pi }}_cd_h, _cd_rem = divmod(int(_cd_secs), 3600) -{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60) -{{ pi }}_cd_str = f'{_cd_h}h {_cd_m:02d}:{_cd_s:02d}' -{% elif el.countdown_format == 'dhm' %} -{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400) -{{ pi }}_cd_h, _cd_m = divmod(_cd_rem // 60, 60) -{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}h {_cd_m:02d}m' -{% else %} -{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400) -{{ pi }}_cd_h = _cd_rem // 3600 -{{ pi }}_cd_str = f'{_cd_d}d {_cd_h}h' -{% endif %} -{{ pi }}self.display_manager.draw_text( -{{ pi }} _cd_str, -{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, -{{ pi }} color={{ el.rgb_tuple }}, -{{ pi }} font=self.display_manager.{{ el.font_attr }}, -{{ pi }}) -{% elif el.type == 'rectangle' %} -{{ pi }}self.display_manager.draw.rectangle( -{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], -{{ pi }} fill={{ el.fill_tuple }}, -{{ pi }} outline={{ el.outline_tuple }}, -{{ pi }}) -{% elif el.type == 'arc' %} -{{ pi }}self.display_manager.draw.arc( -{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], -{{ pi }} start={{ el.start_angle }}, end={{ el.end_angle }}, -{{ pi }} fill={{ el.rgb_tuple }}, -{{ pi }} width={{ el.line_width }}, -{{ pi }}) -{% elif el.type == 'ellipse' %} -{{ pi }}self.display_manager.draw.ellipse( -{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], -{{ pi }} fill={{ el.fill_tuple }}, -{{ pi }} outline={{ el.outline_tuple }}, -{{ pi }}) -{% elif el.type == 'pixel' %} -{{ pi }}self.display_manager.draw.point( -{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}], -{{ pi }} fill={{ el.rgb_tuple }}, -{{ pi }}) -{% elif el.type == 'rounded_rectangle' %} -{{ pi }}self.display_manager.draw.rounded_rectangle( -{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], -{{ pi }} radius={{ el.border_radius }}, -{{ pi }} fill={{ el.fill_tuple }}, -{{ pi }} outline={{ el.outline_tuple }}, -{{ pi }}) -{% elif el.type in ('line', 'divider') %} -{{ pi }}self.display_manager.draw.line( -{{ pi }} [{{ el.x0_expr }}, {{ el.y0_expr }}, {{ el.x1_expr }}, {{ el.y1_expr }}], -{{ pi }} fill={{ el.rgb_tuple }}, -{{ pi }} width={{ el.line_width }}, -{{ pi }}) -{% elif el.type == 'pips' %} -{{ pi }}_pip_filled = max(0, min({{ el.pip_count }}, int(float(values.get({{ el.binding_key | tojson }}, 0) or 0)))) -{{ pi }}for _pip_i in range({{ el.pip_count }}): -{{ pi }} _pip_x = ({{ el.x_expr }}) + _pip_i * ({{ el.pip_size }} + {{ el.pip_spacing }}) -{{ pi }} _pip_color = {{ el.fill_tuple }} if _pip_i < _pip_filled else {{ el.empty_tuple }} -{% if not el.show_empty %} -{{ pi }} if _pip_i >= _pip_filled: -{{ pi }} continue -{% endif %} -{{ pi }} self.display_manager.draw.rectangle( -{{ pi }} [_pip_x, {{ el.y_expr }}, _pip_x + {{ el.pip_size }} - 1, ({{ el.y_expr }}) + {{ el.pip_size }} - 1], -{{ pi }} fill=_pip_color, -{{ pi }} ) -{% elif el.type == 'sparkline' %} -{{ pi }}_sl_raw = str(values.get({{ el.binding_key | tojson }}, '') or '') -{{ pi }}_sl_vals = [float(v.strip()) for v in _sl_raw.split(',') if v.strip()][:{{ el.bar_count }}] -{{ pi }}_sl_vals += [0.0] * max(0, {{ el.bar_count }} - len(_sl_vals)) -{{ pi }}_sl_max = max(_sl_vals) if any(_sl_vals) else 1.0 -{{ pi }}_sl_bw = max(1, ({{ el.bar_width_px }} - {{ el.bar_spacing }} * ({{ el.bar_count }} - 1)) // {{ el.bar_count }}) -{% if el.bg_tuple != 'None' %} -{{ pi }}self.display_manager.draw.rectangle( -{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, ({{ el.x_expr }}) + {{ el.bar_width_px }}, ({{ el.y_expr }}) + {{ el.bar_height_px }}], -{{ pi }} fill={{ el.bg_tuple }}, -{{ pi }}) -{% endif %} -{{ pi }}for _sl_i, _sl_v in enumerate(_sl_vals): -{{ pi }} _sl_norm = max(0.0, min(1.0, _sl_v / (_sl_max or 1))) -{{ pi }} _sl_bh = max(1, round({{ el.bar_height_px }} * _sl_norm)) -{{ pi }} _sl_bx = ({{ el.x_expr }}) + (_sl_bw + {{ el.bar_spacing }}) * _sl_i -{{ pi }} _sl_by = ({{ el.y_expr }}) + {{ el.bar_height_px }} - _sl_bh -{{ pi }} self.display_manager.draw.rectangle( -{{ pi }} [_sl_bx, _sl_by, _sl_bx + _sl_bw - 1, _sl_by + _sl_bh - 1], -{{ pi }} fill={{ el.fill_tuple }}, -{{ pi }} ) -{% elif el.type == 'gauge' %} -{{ pi }}_gv = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) -{{ pi }}_g_total = (({{ el.end_angle }} - {{ el.start_angle }}) % 360) or 360 -{{ pi }}_g_sweep = _g_total * _gv / 100.0 -{% if el.track_tuple != 'None' %} -{{ pi }}self.display_manager.draw.arc( -{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], -{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_total, -{{ pi }} fill={{ el.track_tuple }}, -{{ pi }} width={{ el.line_width }}, -{{ pi }}) -{% endif %} -{{ pi }}if _g_sweep > 0: -{{ pi }} self.display_manager.draw.arc( -{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], -{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_sweep, -{{ pi }} fill={{ el.rgb_tuple }}, -{{ pi }} width={{ el.line_width }}, -{{ pi }} ) -{% if el.show_label %} -{{ pi }}_g_cx = ({{ el.x_expr }}) + ({{ el.x2_expr }} - ({{ el.x_expr }})) // 2 -{{ pi }}_g_cy = ({{ el.y_expr }}) + ({{ el.y2_expr }} - ({{ el.y_expr }})) // 2 -{{ pi }}self.display_manager.draw_text( -{{ pi }} f'{int(_gv)}%', -{{ pi }} x=_g_cx, y=_g_cy, -{{ pi }} color={{ el.label_tuple }}, -{{ pi }} font=self.display_manager.{{ el.font_attr }}, -{{ pi }}) -{% endif %} -{% elif el.type == 'marquee' %} -{{ pi }}_{{ el.data_key }}_text = {{ el.text | tojson }} -{{ pi }}_{{ el.data_key }}_tw = len(_{{ el.data_key }}_text) * {{ el.char_w }} -{{ pi }}_{{ el.data_key }}_x = int(self._data.get({{ el.data_key | tojson }}, width)) -{% if el.direction == 'right' %} -{{ pi }}_{{ el.data_key }}_x += {{ el.scroll_speed }} -{{ pi }}if _{{ el.data_key }}_x > width: -{{ pi }} _{{ el.data_key }}_x = -(_{{ el.data_key }}_tw + {{ el.gap }}) -{% else %} -{{ pi }}_{{ el.data_key }}_x -= {{ el.scroll_speed }} -{{ pi }}if _{{ el.data_key }}_x < -(_{{ el.data_key }}_tw + {{ el.gap }}): -{{ pi }} _{{ el.data_key }}_x = width -{% endif %} -{{ pi }}self._data[{{ el.data_key | tojson }}] = _{{ el.data_key }}_x -{{ pi }}self.display_manager.draw_text( -{{ pi }} _{{ el.data_key }}_text, -{{ pi }} x=_{{ el.data_key }}_x, y={{ el.y_expr }}, -{{ pi }} color={{ el.rgb_tuple }}, -{{ pi }} font=self.display_manager.{{ el.font_attr }}, -{{ pi }}) -{% elif el.type == 'progress_bar' %} -{{ pi }}_pb_x = {{ el.x_expr }} -{{ pi }}_pb_y = {{ el.y_expr }} -{{ pi }}_pb_pct = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) / 100.0 -{{ pi }}_pb_fill_w = int({{ el.bar_width }} * _pb_pct) -{{ pi }}self.display_manager.draw.rectangle( -{{ pi }} [_pb_x, _pb_y, _pb_x + {{ el.bar_width }}, _pb_y + {{ el.bar_height }}], -{{ pi }} fill={{ el.bg_tuple }}, -{{ pi }} outline={{ el.outline_tuple }}, -{{ pi }}) -{{ pi }}if _pb_fill_w > 0: -{{ pi }} self.display_manager.draw.rectangle( -{{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}], -{{ pi }} fill={{ el.fill_tuple }}, -{{ pi }} ) -{% endif %} -{% endfor %} - # ── End elements ─────────────────────────────────────────────── - - self.display_manager.update_display() - - except Exception as e: - self.logger.error('Display error: %s', e, exc_info=True)