fix: remove content accidentally bundled into the dead-code-removal commit
The previous commit's git add/commit swept in a lot of unrelated, unreviewed work alongside the intended dead-code deletions: a new Plugin Composer web UI, new security-audit/CI tooling, a new march-madness plugin, and 23 local-development-only symlinks under plugin-repos/ (per scripts/setup_plugin_repos.py's own docstring, these are meant to be generated locally, never committed -- .gitignore has no entry for them, which is how they slipped in). Removed here, split into their own PRs instead (except plugin-repos/* symlinks and march-madness/ncaa_logos, which are dropped rather than carried forward -- see PR discussion): - web_interface/blueprints/composer.py + composer-app.js + composer-canvas.js + composer.html + manager.py.j2 - scripts/prove_security.py, audit_plugins.py, generate_report.py - .github/workflows/security-audit.yml, .github/workflows/tests.yml, bandit.yaml - All plugin-repos/* symlinks (local dev artifacts, not meant to be committed at all) - plugin-repos/march-madness/* and the 4 new assets/sports/ncaa_logos/* PNGs it needed (left out of every split PR pending a decision on whether march-madness belongs in the core repo or the plugin monorepo) This PR now contains only what its title describes: the dead-code removal from the previous commit, untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/7-segment-clock
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/baseball-scoreboard
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/basketball-scoreboard
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/calendar
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/christmas-countdown
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/clock-simple
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/countdown
|
||||
@@ -1 +0,0 @@
|
||||
/home/chuck/Github/ledmatrix-plugins/plugins/f1-scoreboard
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/football-scoreboard
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/hello-world
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/hockey-scoreboard
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-flights
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-leaderboard
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-music
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-stocks
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/ledmatrix-weather
|
||||
|
Before Width: | Height: | Size: 476 B |
|
Before Width: | Height: | Size: 459 B |
|
Before Width: | Height: | Size: 545 B |
|
Before Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 561 B |
|
Before Width: | Height: | Size: 538 B |
|
Before Width: | Height: | Size: 521 B |
@@ -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"]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
requests>=2.33.0
|
||||
urllib3>=2.6.3
|
||||
Pillow>=12.2.0
|
||||
pytz>=2022.1
|
||||
numpy>=1.24.0
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/mqtt-notifications
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/news
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/odds-ticker
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/of-the-day
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/olympics
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/soccer-scoreboard
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/static-image
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/stock-news
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/text-display
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/ufc-scoreboard
|
||||
@@ -1 +0,0 @@
|
||||
../../ledmatrix-plugins/plugins/youtube-stats
|
||||
@@ -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())
|
||||
@@ -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):
|
||||
<artifact-dir>/
|
||||
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())
|
||||
@@ -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())
|
||||
@@ -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/<name> — Serve TTF font files for canvas rendering
|
||||
GET /composer/api/validate-id/<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/<font_name>')
|
||||
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/<plugin_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/<plugin_id>')
|
||||
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})
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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)
|
||||