Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a3e6f0685 | ||
|
|
5b6137f5f4 | ||
|
|
f97573c368 | ||
|
|
9a74db6de3 | ||
|
|
b7295129b5 | ||
|
|
3e94bb9664 | ||
|
|
44316d3bae | ||
|
|
baebe4f5f7 | ||
|
|
fccd6e70be | ||
|
|
2a74db3a59 | ||
|
|
4b39fbcfd1 | ||
|
|
7ba66e541c | ||
|
|
3f66d15af7 | ||
|
|
9490cf6023 | ||
|
|
15fc9003ac | ||
|
|
55a6a53fca | ||
|
|
c54718af2d | ||
|
|
e8afd23c98 |
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
exclude_paths:
|
|
||||||
- "plugin-repos/**"
|
|
||||||
- "plugins/**"
|
|
||||||
- "assets/**"
|
|
||||||
- "test/**"
|
|
||||||
- "scripts/debug/**"
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Claude Code Review
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, ready_for_review, reopened]
|
|
||||||
# Optional: Only run on specific file changes
|
|
||||||
# paths:
|
|
||||||
# - "src/**/*.ts"
|
|
||||||
# - "src/**/*.tsx"
|
|
||||||
# - "src/**/*.js"
|
|
||||||
# - "src/**/*.jsx"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude-review:
|
|
||||||
# Optional: Filter by PR author
|
|
||||||
# if: |
|
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
|
||||||
id: claude-review
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
|
||||||
plugins: 'code-review@claude-code-plugins'
|
|
||||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
|
||||||
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
plugin-safety:
|
|
||||||
name: Plugin safety harness + unit tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt -r requirements-test.txt
|
|
||||||
pip install RGBMatrixEmulator
|
|
||||||
|
|
||||||
- name: Run harness + visual rendering tests
|
|
||||||
run: |
|
|
||||||
pytest --no-cov \
|
|
||||||
test/plugins/test_harness.py \
|
|
||||||
test/plugins/test_visual_rendering.py \
|
|
||||||
test/plugins/test_plugin_matrix.py
|
|
||||||
@@ -8,7 +8,6 @@ config/config_secrets.json
|
|||||||
config/config.json
|
config/config.json
|
||||||
config/config.json.backup
|
config/config.json.backup
|
||||||
config/wifi_config.json
|
config/wifi_config.json
|
||||||
config/uninstalled_plugins.json
|
|
||||||
credentials.json
|
credentials.json
|
||||||
token.pickle
|
token.pickle
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
[submodule "rpi-rgb-led-matrix-master"]
|
[submodule "rpi-rgb-led-matrix-master"]
|
||||||
path = rpi-rgb-led-matrix-master
|
path = rpi-rgb-led-matrix-master
|
||||||
url = https://github.com/hzeller/rpi-rgb-led-matrix.git
|
url = https://github.com/hzeller/rpi-rgb-led-matrix.git
|
||||||
branch = master
|
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
# LEDMatrix
|
# LEDMatrix
|
||||||
[](LICENSE)
|
|
||||||
[](https://discord.gg/RdrC37rEag)
|
|
||||||
[](https://github.com/ChuckBuilds/ledmatrix)
|
|
||||||
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
|
||||||
|
|
||||||
|
|
||||||
## Welcome to LEDMatrix!
|
## Welcome to LEDMatrix!
|
||||||
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
||||||
|
|
||||||
@@ -132,15 +126,10 @@ The system supports live, recent, and upcoming game information for multiple spo
|
|||||||
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
||||||
|
|
||||||
### Raspberry Pi
|
### Raspberry Pi
|
||||||
- Raspberry Pi Zero's don't have enough processing power for this project.
|
- Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output.
|
||||||
- **Raspberry Pi 3B, 4, or 5**
|
- **Raspberry Pi 3B or 4 (NOT RPi 5!)**
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
||||||
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
|
|
||||||
```bash
|
|
||||||
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
|
||||||
```
|
|
||||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
|
||||||
|
|
||||||
|
|
||||||
### RGB Matrix Bonnet / HAT
|
### RGB Matrix Bonnet / HAT
|
||||||
@@ -592,7 +581,7 @@ These settings control runtime behavior and GPIO timing:
|
|||||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||||
- **Raspberry Pi 3**: Use 3
|
- **Raspberry Pi 3**: Use 3
|
||||||
- **Raspberry Pi 4**: Use 4
|
- **Raspberry Pi 4**: Use 4
|
||||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
- **Raspberry Pi 5**: Use 5 (or higher if needed)
|
||||||
- **Raspberry Pi Zero/1**: Use 1-2
|
- **Raspberry Pi Zero/1**: Use 1-2
|
||||||
- Incorrect values can cause display corruption, flickering, or system instability
|
- Incorrect values can cause display corruption, flickering, or system instability
|
||||||
- If you experience issues, try adjusting this value up or down by 1
|
- If you experience issues, try adjusting this value up or down by 1
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 111 KiB |
@@ -1,43 +1,43 @@
|
|||||||
{
|
{
|
||||||
"web_display_autostart": true,
|
"web_display_autostart": true,
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"mode": "per-day",
|
"mode": "per-day",
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00",
|
"end_time": "23:00",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": {
|
"monday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"tuesday": {
|
"tuesday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"wednesday": {
|
"wednesday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"thursday": {
|
"thursday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"friday": {
|
"friday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"saturday": {
|
"saturday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"sunday": {
|
"sunday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
}
|
}
|
||||||
@@ -51,46 +51,46 @@
|
|||||||
"end_time": "07:00",
|
"end_time": "07:00",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": {
|
"monday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"tuesday": {
|
"tuesday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"wednesday": {
|
"wednesday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"thursday": {
|
"thursday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"friday": {
|
"friday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"saturday": {
|
"saturday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"sunday": {
|
"sunday": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timezone": "America/New_York",
|
"timezone": "America/Chicago",
|
||||||
"location": {
|
"location": {
|
||||||
"city": "Tampa",
|
"city": "Dallas",
|
||||||
"state": "Florida",
|
"state": "Texas",
|
||||||
"country": "US"
|
"country": "US"
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
@@ -112,13 +112,7 @@
|
|||||||
"limit_refresh_rate_hz": 100
|
"limit_refresh_rate_hz": 100
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"gpio_slowdown": 3,
|
"gpio_slowdown": 3
|
||||||
"rp1_rio": 0
|
|
||||||
},
|
|
||||||
"double_sided": {
|
|
||||||
"enabled": false,
|
|
||||||
"copies": 2,
|
|
||||||
"axis": "horizontal"
|
|
||||||
},
|
},
|
||||||
"display_durations": {},
|
"display_durations": {},
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
@@ -132,11 +126,6 @@
|
|||||||
"buffer_ahead": 2
|
"buffer_ahead": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sync": {
|
|
||||||
"role": "standalone",
|
|
||||||
"port": 5765,
|
|
||||||
"follower_position": "left"
|
|
||||||
},
|
|
||||||
"plugin_system": {
|
"plugin_system": {
|
||||||
"plugins_directory": "plugin-repos",
|
"plugins_directory": "plugin-repos",
|
||||||
"auto_discover": true,
|
"auto_discover": true,
|
||||||
|
|||||||
@@ -34,16 +34,16 @@ This document outlines the transformation of the LEDMatrix project into a modula
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
1. [Current Architecture Analysis](#1-current-architecture-analysis)
|
1. [Current Architecture Analysis](#current-architecture-analysis)
|
||||||
2. [Plugin System Design](#2-plugin-system-design)
|
2. [Plugin System Design](#plugin-system-design)
|
||||||
3. [Plugin Store & Discovery](#3-plugin-store--discovery)
|
3. [Plugin Store & Discovery](#plugin-store--discovery)
|
||||||
4. [Web UI Transformation](#4-web-ui-transformation)
|
4. [Web UI Transformation](#web-ui-transformation)
|
||||||
5. [Migration Strategy](#5-migration-strategy)
|
5. [Migration Strategy](#migration-strategy)
|
||||||
6. [Plugin Developer Guidelines](#6-plugin-developer-guidelines)
|
6. [Plugin Developer Guidelines](#plugin-developer-guidelines)
|
||||||
7. [Technical Implementation Details](#7-technical-implementation-details)
|
7. [Technical Implementation Details](#technical-implementation-details)
|
||||||
8. [Best Practices & Standards](#8-best-practices--standards)
|
8. [Best Practices & Standards](#best-practices--standards)
|
||||||
9. [Security Considerations](#9-security-considerations)
|
9. [Security Considerations](#security-considerations)
|
||||||
10. [Implementation Roadmap](#10-implementation-roadmap)
|
10. [Implementation Roadmap](#implementation-roadmap)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
# Plugin Safety Harness
|
|
||||||
|
|
||||||
Renders a plugin across **every declared screen (mode)** and **a spread of
|
|
||||||
matrix sizes**, and fails if any combination crashes, draws past the panel edge,
|
|
||||||
or — for plugins that ship golden images — drifts visually. The goal: change a
|
|
||||||
plugin without breaking a size or screen you didn't think to test.
|
|
||||||
|
|
||||||
## Sizes: a sample, not a fixed list
|
|
||||||
|
|
||||||
There is **no fixed set of supported panel sizes** — an RGB matrix build can be
|
|
||||||
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
|
|
||||||
strips, tall stacks). Plugins are expected to read dimensions dynamically
|
|
||||||
(`self.display_manager.matrix.width/height`) and lay themselves out
|
|
||||||
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
|
|
||||||
here.
|
|
||||||
|
|
||||||
The harness therefore renders against a **representative sample** that spans the
|
|
||||||
axes of variation (`DEFAULT_TEST_SIZES` in `src/plugin_system/testing/sizes.py`),
|
|
||||||
not an authoritative list:
|
|
||||||
|
|
||||||
Each module is 64×32; entries are real panel-grid arrangements (cols × rows):
|
|
||||||
|
|
||||||
| Size | Grid | Why it's in the sample |
|
|
||||||
|---------|------|--------------------------------------------|
|
|
||||||
| 64×32 | 1×1 | single panel — tightest common rectangle |
|
|
||||||
| 128×32 | 2×1 | the baseline most plugins are tuned for |
|
|
||||||
| 64×64 | 1×2 | stacked — tall-narrow centering |
|
|
||||||
| 128×64 | 2×2 | block — icon scaling / vertical centering |
|
|
||||||
| 256×32 | 4×1 | long strip — wide horizontal layout |
|
|
||||||
| 128×96 | 2×3 | tall — vertical overflow |
|
|
||||||
| 256×128 | 4×4 | large block — both dimensions big at once |
|
|
||||||
|
|
||||||
**Override the sizes entirely** to test your actual hardware (or any shape):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# CLI — one-off:
|
|
||||||
python scripts/check_plugin.py --plugin clock-simple --sizes 8x16,64x64,256x32
|
|
||||||
|
|
||||||
# pytest — force every plugin onto your panel(s):
|
|
||||||
LEDMATRIX_TEST_SIZES="8x16,128x128" pytest test/plugins/test_plugin_matrix.py
|
|
||||||
|
|
||||||
# Per-plugin — declare the shapes a plugin targets in its test/harness.json:
|
|
||||||
# { "sizes": [[8, 16], [64, 64]] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Precedence: `LEDMATRIX_TEST_SIZES` env (global) → per-plugin `harness.json`
|
|
||||||
`sizes` → the default sample. Bounds checking adapts to whatever sizes a run
|
|
||||||
uses — the backing canvas is padded out to the **largest** panel in the run, so
|
|
||||||
a coordinate meant for a big build is still caught when rendering a small one.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Functional + bounds check across all sizes/screens:
|
|
||||||
python scripts/check_plugin.py --plugin clock-simple
|
|
||||||
|
|
||||||
# Every discovered plugin:
|
|
||||||
python scripts/check_plugin.py --all
|
|
||||||
|
|
||||||
# Dump PNGs to eyeball each size/screen:
|
|
||||||
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit code is non-zero if any `(plugin, size, screen)` fails. Plugins are
|
|
||||||
discovered in `plugin-repos/` and `plugins/` (override with `--plugin-dir`).
|
|
||||||
|
|
||||||
## What it checks (Phase 1 — always on)
|
|
||||||
|
|
||||||
1. **Loads** and builds its mode list.
|
|
||||||
2. **Renders every screen** at every size without raising. `update()` may fail
|
|
||||||
(no network in CI) and is tolerated; a crash in `display()` is a failure —
|
|
||||||
`display()` must handle the no-data state.
|
|
||||||
3. **Bounds**: nothing is drawn past the right/bottom edge. Implemented by
|
|
||||||
`BoundsCheckingDisplayManager`, which backs the declared panel with an
|
|
||||||
oversized canvas and flags any pixels that land in the margin. (Left/top
|
|
||||||
overflow at negative coordinates and BDF text are not flagged — golden images
|
|
||||||
cover those.)
|
|
||||||
|
|
||||||
## Golden images (Phase 2 — opt-in per plugin)
|
|
||||||
|
|
||||||
A plugin opts in by committing reference PNGs and (usually) a small harness spec:
|
|
||||||
|
|
||||||
```
|
|
||||||
plugins/<id>/test/harness.json # how to render deterministically
|
|
||||||
plugins/<id>/test/fixtures/mock.json # optional cached data
|
|
||||||
plugins/<id>/test/golden/<WxH>/<mode>.png
|
|
||||||
```
|
|
||||||
|
|
||||||
`test/harness.json` keys (all optional):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"config": { "timezone": "UTC" },
|
|
||||||
"mock_data": "fixtures/mock.json",
|
|
||||||
"freeze_time": "2025-08-01 15:25:00",
|
|
||||||
"skip_update": false,
|
|
||||||
"sizes": [[128, 32], [128, 64]]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate / refresh goldens after an intentional visual change, then review the
|
|
||||||
diff before committing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
|
||||||
--config '{"timezone":"UTC"}' --freeze-time "2025-08-01 15:25:00"
|
|
||||||
```
|
|
||||||
|
|
||||||
Comparison is exact by default (`compare_images` in `harness.py` accepts a
|
|
||||||
tolerance for known anti-aliasing noise). Determinism requires a pinned Pillow
|
|
||||||
and the bundled fonts — keep both stable when regenerating goldens.
|
|
||||||
|
|
||||||
## Tests & CI
|
|
||||||
|
|
||||||
- `test/plugins/test_harness.py` — unit tests for bounds detection, image
|
|
||||||
comparison, and mode enumeration (run anywhere).
|
|
||||||
- `test/plugins/test_plugin_matrix.py` — parametrized over discovered plugins ×
|
|
||||||
sizes × screens; honors each plugin's `test/harness.json` and goldens. Skips
|
|
||||||
when no plugins are present (e.g. a fresh core checkout); set
|
|
||||||
`LEDMATRIX_REQUIRE_PLUGINS=1` in a pipeline where plugins must be present to
|
|
||||||
turn an empty discovery into a hard failure instead. Point it at the monorepo
|
|
||||||
with `LEDMATRIX_PLUGINS_DIR=/path/to/ledmatrix-plugins/plugins`.
|
|
||||||
- `.github/workflows/test.yml` — runs the harness + visual tests on every PR.
|
|
||||||
|
|
||||||
The plugin monorepo has its own `Plugin Safety` workflow that runs this harness
|
|
||||||
against changed plugins on every PR.
|
|
||||||
|
|
||||||
## Developer workflow
|
|
||||||
|
|
||||||
1. Change the plugin on a branch.
|
|
||||||
2. `python scripts/check_plugin.py --plugin <id> --out-dir /tmp/preview` and
|
|
||||||
eyeball the PNGs.
|
|
||||||
3. Intentional visual change? `--update-golden`, review diffs, commit goldens.
|
|
||||||
4. (Monorepo) bump `manifest.json` version and let the pre-commit hook sync
|
|
||||||
`plugins.json`.
|
|
||||||
5. Push — CI re-runs the harness across all sizes and gates the PR.
|
|
||||||
@@ -10,98 +10,6 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
|
|||||||
|
|
||||||
## Available Core Widgets
|
## Available Core Widgets
|
||||||
|
|
||||||
### Plugin File Manager Widget (`plugin-file-manager`)
|
|
||||||
|
|
||||||
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
|
|
||||||
|
|
||||||
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"file_manager": {
|
|
||||||
"type": "null",
|
|
||||||
"title": "Data Files",
|
|
||||||
"x-widget": "plugin-file-manager",
|
|
||||||
"x-widget-config": {
|
|
||||||
"actions": {
|
|
||||||
"list": "list-files",
|
|
||||||
"get": "get-file",
|
|
||||||
"save": "save-file",
|
|
||||||
"upload": "upload-file",
|
|
||||||
"delete": "delete-file",
|
|
||||||
"create": "create-file",
|
|
||||||
"toggle": "toggle-category"
|
|
||||||
},
|
|
||||||
"upload_hint": "JSON files with day numbers 1–365 as keys",
|
|
||||||
"directory_label": "my_data/",
|
|
||||||
"create_fields": [
|
|
||||||
{ "key": "category_name", "label": "Category Name",
|
|
||||||
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
|
|
||||||
"hint": "Lowercase letters, numbers, underscores" },
|
|
||||||
{ "key": "display_name", "label": "Display Name",
|
|
||||||
"placeholder": "e.g., My Words", "hint": "Optional" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
|
||||||
|
|
||||||
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
|
||||||
|
|
||||||
**Used by:** of-the-day
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Time Picker Widget (`time-picker`)
|
|
||||||
|
|
||||||
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"target_time": {
|
|
||||||
"type": "string",
|
|
||||||
"x-widget": "time-picker",
|
|
||||||
"default": "00:00",
|
|
||||||
"x-options": {
|
|
||||||
"placeholder": "Select time",
|
|
||||||
"clearable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Used by:** countdown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### File Upload Single Widget (`file-upload-single`)
|
|
||||||
|
|
||||||
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"image_path": {
|
|
||||||
"type": "string",
|
|
||||||
"x-widget": "file-upload-single",
|
|
||||||
"x-upload-config": {
|
|
||||||
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
|
|
||||||
"max_size_mb": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
|
|
||||||
|
|
||||||
**Used by:** countdown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### File Upload Widget (`file-upload`)
|
### File Upload Widget (`file-upload`)
|
||||||
|
|
||||||
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ on_error() {
|
|||||||
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
||||||
if [ -n "${LOG_FILE:-}" ]; then
|
if [ -n "${LOG_FILE:-}" ]; then
|
||||||
echo "See the log for details: $LOG_FILE" >&2
|
echo "See the log for details: $LOG_FILE" >&2
|
||||||
echo "-- Last 100 lines from log --" >&2
|
echo "-- Last 50 lines from log --" >&2
|
||||||
tail -n 100 "$LOG_FILE" >&2 || true
|
tail -n 50 "$LOG_FILE" >&2 || true
|
||||||
fi
|
fi
|
||||||
echo "\nCommon fixes:" >&2
|
echo "\nCommon fixes:" >&2
|
||||||
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
||||||
@@ -36,17 +36,9 @@ if [ -r /proc/device-tree/model ]; then
|
|||||||
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
||||||
echo "Detected device: $DEVICE_MODEL"
|
echo "Detected device: $DEVICE_MODEL"
|
||||||
else
|
else
|
||||||
DEVICE_MODEL=""
|
|
||||||
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
|
|
||||||
IS_PI5=0
|
|
||||||
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
|
|
||||||
IS_PI5=1
|
|
||||||
echo "Raspberry Pi 5 detected — will verify RP1 library support."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
||||||
echo ""
|
echo ""
|
||||||
echo "Checking operating system requirements..."
|
echo "Checking operating system requirements..."
|
||||||
@@ -202,33 +194,8 @@ retry() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Wait for another apt/dpkg process (commonly unattended-upgrades running
|
apt_update() { retry apt update; }
|
||||||
# shortly after first boot) to release its lock before we try apt ourselves.
|
apt_install() { retry apt install -y "$@"; }
|
||||||
# Without this, apt_update/apt_install can fail outright in the first couple
|
|
||||||
# minutes after a fresh Pi OS boot with a generic "Command failed after 3
|
|
||||||
# attempts" error.
|
|
||||||
wait_for_apt_lock() {
|
|
||||||
command -v flock >/dev/null 2>&1 || return 0
|
|
||||||
local lock_file="/var/lib/dpkg/lock-frontend"
|
|
||||||
local max_wait=180
|
|
||||||
local waited=0
|
|
||||||
local printed=0
|
|
||||||
while ! flock -n "$lock_file" -c true 2>/dev/null; do
|
|
||||||
if [ "$printed" -eq 0 ]; then
|
|
||||||
echo "⚠ Waiting for another apt/dpkg process to finish (e.g. unattended-upgrades on first boot)..."
|
|
||||||
printed=1
|
|
||||||
fi
|
|
||||||
if [ "$waited" -ge "$max_wait" ]; then
|
|
||||||
echo "⚠ Still waiting after ${max_wait}s; proceeding anyway."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 5
|
|
||||||
waited=$((waited+5))
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
apt_update() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 update; }
|
|
||||||
apt_install() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 install -y "$@"; }
|
|
||||||
apt_remove() { apt-get remove -y "$@" || true; }
|
apt_remove() { apt-get remove -y "$@" || true; }
|
||||||
|
|
||||||
check_network() {
|
check_network() {
|
||||||
@@ -247,22 +214,6 @@ check_network() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
check_disk_space() {
|
|
||||||
command -v df >/dev/null 2>&1 || return 0
|
|
||||||
local available_mb
|
|
||||||
available_mb=$(df -m "$PROJECT_ROOT_DIR" | awk 'NR==2{print $4}')
|
|
||||||
available_mb=${available_mb:-0}
|
|
||||||
if [ "$available_mb" -lt 500 ]; then
|
|
||||||
echo "✗ ERROR: Insufficient disk space: ${available_mb}MB available (need at least 500MB)"
|
|
||||||
echo " Free up space first, e.g.: sudo apt clean && sudo apt autoremove"
|
|
||||||
exit 1
|
|
||||||
elif [ "$available_mb" -lt 1024 ]; then
|
|
||||||
echo "⚠ Limited disk space: ${available_mb}MB available (recommend at least 1GB for the rpi-rgb-led-matrix build in Step 6)"
|
|
||||||
else
|
|
||||||
echo "✓ Disk space sufficient: ${available_mb}MB available"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "This script will perform the following steps:"
|
echo "This script will perform the following steps:"
|
||||||
echo "1. Install system dependencies"
|
echo "1. Install system dependencies"
|
||||||
@@ -308,20 +259,21 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
CLEAR='
|
||||||
|
'
|
||||||
CURRENT_STEP="Install system dependencies"
|
CURRENT_STEP="Install system dependencies"
|
||||||
echo "Step 1: Installing system dependencies..."
|
echo "Step 1: Installing system dependencies..."
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
|
|
||||||
# Pre-flight checks before APT operations
|
# Ensure network is available before APT operations
|
||||||
check_network
|
check_network
|
||||||
check_disk_space
|
|
||||||
|
|
||||||
# Update package list
|
# Update package list
|
||||||
apt_update
|
apt_update
|
||||||
|
|
||||||
# Install required system packages
|
# Install required system packages
|
||||||
echo "Installing Python packages and dependencies..."
|
echo "Installing Python packages and dependencies..."
|
||||||
apt_install python3-pip python3-venv python-dev-is-python3 python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cmake ninja-build
|
apt_install python3-pip python3-venv python3-dev python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cython3 scons cmake ninja-build
|
||||||
|
|
||||||
# Install additional system dependencies that might be needed
|
# Install additional system dependencies that might be needed
|
||||||
echo "Installing additional system dependencies..."
|
echo "Installing additional system dependencies..."
|
||||||
@@ -719,6 +671,8 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
||||||
|
|
||||||
# Check if package is already installed (basic check - may not catch all cases)
|
# Check if package is already installed (basic check - may not catch all cases)
|
||||||
|
PACKAGE_NAME=$(echo "$line" | sed -E 's/[<>=!].*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||||
|
|
||||||
# Try installing with verbose output and timeout (if available)
|
# Try installing with verbose output and timeout (if available)
|
||||||
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
|
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
|
||||||
INSTALL_OUTPUT=$(mktemp)
|
INSTALL_OUTPUT=$(mktemp)
|
||||||
@@ -726,11 +680,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
|
|
||||||
if command -v timeout >/dev/null 2>&1; then
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
# Use timeout if available (10 minutes = 600 seconds)
|
# Use timeout if available (10 minutes = 600 seconds)
|
||||||
# --ignore-installed: apt-managed packages (e.g. python3-requests)
|
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||||
# ship no pip RECORD file, so upgrading them would otherwise abort
|
|
||||||
# with "uninstall-no-record-file"; this lays the new version down
|
|
||||||
# alongside instead of trying to uninstall the apt copy first.
|
|
||||||
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
|
||||||
INSTALL_SUCCESS=true
|
INSTALL_SUCCESS=true
|
||||||
else
|
else
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
@@ -738,7 +688,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo "✗ Timeout (10 minutes) installing: $line"
|
echo "✗ Timeout (10 minutes) installing: $line"
|
||||||
echo " This package may require building from source, which can be slow on Raspberry Pi."
|
echo " This package may require building from source, which can be slow on Raspberry Pi."
|
||||||
echo " You can try installing it manually later with:"
|
echo " You can try installing it manually later with:"
|
||||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose '$line'"
|
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose '$line'"
|
||||||
else
|
else
|
||||||
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
|
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
|
||||||
fi
|
fi
|
||||||
@@ -746,7 +696,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
else
|
else
|
||||||
# No timeout command available, install without timeout
|
# No timeout command available, install without timeout
|
||||||
echo " Note: timeout command not available, installation may take a while..."
|
echo " Note: timeout command not available, installation may take a while..."
|
||||||
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||||
INSTALL_SUCCESS=true
|
INSTALL_SUCCESS=true
|
||||||
else
|
else
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
@@ -798,7 +748,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo " 1. Ensure you have enough disk space: df -h"
|
echo " 1. Ensure you have enough disk space: df -h"
|
||||||
echo " 2. Check available memory: free -h"
|
echo " 2. Check available memory: free -h"
|
||||||
echo " 3. Try installing failed packages individually with verbose output:"
|
echo " 3. Try installing failed packages individually with verbose output:"
|
||||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose <package>"
|
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose <package>"
|
||||||
echo " 4. For packages that build from source (like numpy), consider:"
|
echo " 4. For packages that build from source (like numpy), consider:"
|
||||||
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
|
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
|
||||||
echo " - Or installing via apt if available: sudo apt install python3-<package>"
|
echo " - Or installing via apt if available: sudo apt install python3-<package>"
|
||||||
@@ -820,10 +770,7 @@ echo ""
|
|||||||
# Install web interface dependencies
|
# Install web interface dependencies
|
||||||
echo "Installing web interface dependencies..."
|
echo "Installing web interface dependencies..."
|
||||||
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
||||||
# --ignore-installed: apt-managed packages (e.g. python3-requests) ship no
|
if python3 -m pip install --break-system-packages --prefer-binary -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
||||||
# pip RECORD file, so upgrading them to the version pinned here would
|
|
||||||
# otherwise abort the whole install with "uninstall-no-record-file".
|
|
||||||
if python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
|
||||||
echo "✓ Web interface dependencies installed"
|
echo "✓ Web interface dependencies installed"
|
||||||
# Create marker file to indicate dependencies are installed
|
# Create marker file to indicate dependencies are installed
|
||||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||||
@@ -840,36 +787,11 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
|||||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||||
echo "-----------------------------------------------------"
|
echo "-----------------------------------------------------"
|
||||||
|
|
||||||
# On Pi 5, also check that the installed library has rp1_rio support.
|
# If already installed and not forcing rebuild, skip expensive build
|
||||||
# A library built before Pi 5 support was added imports fine but maps to the
|
|
||||||
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
|
|
||||||
_HAS_RP1=0
|
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
|
||||||
_HAS_RP1=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
_SKIP_BUILD=0
|
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
||||||
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
|
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||||
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
|
|
||||||
echo " Forcing rebuild to get Pi 5 RP1 support..."
|
|
||||||
else
|
|
||||||
_SKIP_BUILD=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_SKIP_BUILD" = "1" ]; then
|
|
||||||
_skip_suffix=""
|
|
||||||
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
|
|
||||||
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
|
||||||
else
|
else
|
||||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||||
# Wrapper used with retry(): removes any partial clone dir before each attempt
|
|
||||||
# so git clone doesn't fail with "destination path already exists".
|
|
||||||
_clone_rpi_rgb() {
|
|
||||||
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
|
||||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
|
||||||
}
|
|
||||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
@@ -877,14 +799,14 @@ else
|
|||||||
# Try to initialize submodule if .gitmodules exists
|
# Try to initialize submodule if .gitmodules exists
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
if ! git submodule update --init --recursive rpi-rgb-led-matrix-master 2>&1; then
|
||||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||||
retry _clone_rpi_rgb
|
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Fallback: clone directly if submodule not configured
|
# Fallback: clone directly if submodule not configured
|
||||||
echo "Submodule not configured, cloning directly from GitHub..."
|
echo "Submodule not configured, cloning directly from GitHub..."
|
||||||
retry _clone_rpi_rgb
|
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -896,34 +818,30 @@ else
|
|||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
rm -rf rpi-rgb-led-matrix-master
|
rm -rf rpi-rgb-led-matrix-master
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||||
else
|
else
|
||||||
retry _clone_rpi_rgb
|
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
|
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
|
||||||
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
echo "Building rpi-rgb-led-matrix Python bindings..."
|
||||||
echo " Build deps required: python-dev-is-python3 cmake"
|
# Build the library first, then Python bindings
|
||||||
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
|
# The build-python target depends on the library being built
|
||||||
BUILD_OUTPUT=$(mktemp)
|
if ! make build-python; then
|
||||||
BUILD_SUCCESS=false
|
echo "✗ Failed to build rpi-rgb-led-matrix Python bindings"
|
||||||
if python3 -m pip install --break-system-packages . > "$BUILD_OUTPUT" 2>&1; then
|
echo " Make sure you have the required build tools installed:"
|
||||||
BUILD_SUCCESS=true
|
echo " sudo apt install -y build-essential python3-dev cython3 scons"
|
||||||
|
popd >/dev/null
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
|
cd bindings/python
|
||||||
if [ "$BUILD_SUCCESS" != true ]; then
|
echo "Installing rpi-rgb-led-matrix Python package via pip..."
|
||||||
|
if ! python3 -m pip install --break-system-packages .; then
|
||||||
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
|
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
|
||||||
echo " Ensure build tools are installed:"
|
|
||||||
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
|
|
||||||
echo ""
|
|
||||||
echo "-- Last 50 lines of build output --"
|
|
||||||
tail -n 50 "$BUILD_OUTPUT"
|
|
||||||
rm -f "$BUILD_OUTPUT"
|
|
||||||
popd >/dev/null
|
popd >/dev/null
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
rm -f "$BUILD_OUTPUT"
|
|
||||||
popd >/dev/null
|
popd >/dev/null
|
||||||
else
|
else
|
||||||
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
|
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
|
||||||
@@ -945,17 +863,6 @@ except Exception as e:
|
|||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
echo "✓ rpi-rgb-led-matrix installed and verified"
|
echo "✓ rpi-rgb-led-matrix installed and verified"
|
||||||
# Pi 5: confirm the freshly-built library has rp1_rio support
|
|
||||||
if [ "$IS_PI5" = "1" ]; then
|
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
|
||||||
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
|
|
||||||
else
|
|
||||||
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
|
|
||||||
echo " Try updating the submodule and rebuilding:"
|
|
||||||
echo " git submodule update --remote rpi-rgb-led-matrix-master"
|
|
||||||
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "✗ rpi-rgb-led-matrix import test failed"
|
echo "✗ rpi-rgb-led-matrix import test failed"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -978,15 +885,11 @@ else
|
|||||||
# Try to install dependencies using the smart installer if available
|
# Try to install dependencies using the smart installer if available
|
||||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||||
echo "Using smart dependency installer..."
|
echo "Using smart dependency installer..."
|
||||||
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
|
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||||
# real time and in order relative to this script's own echo statements
|
|
||||||
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
|
||||||
else
|
else
|
||||||
echo "Using pip to install dependencies..."
|
echo "Using pip to install dependencies..."
|
||||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||||
# --ignore-installed: see the Step 5 web_interface/requirements.txt
|
python3 -m pip install --break-system-packages --prefer-binary -r requirements_web_v2.txt
|
||||||
# install above — same apt/pip RECORD-file conflict applies here.
|
|
||||||
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
|
|
||||||
else
|
else
|
||||||
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
||||||
fi
|
fi
|
||||||
@@ -1207,7 +1110,6 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
|
|||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
|
||||||
EOF
|
EOF
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||||
@@ -1576,7 +1478,7 @@ echo "WiFi Connection Status:"
|
|||||||
if command -v nmcli >/dev/null 2>&1; then
|
if command -v nmcli >/dev/null 2>&1; then
|
||||||
WIFI_STATUS=$(nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | grep -i wifi || echo "")
|
WIFI_STATUS=$(nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | grep -i wifi || echo "")
|
||||||
if [ -n "$WIFI_STATUS" ]; then
|
if [ -n "$WIFI_STATUS" ]; then
|
||||||
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
|
echo "$WIFI_STATUS" | while IFS=':' read -r device type state; do
|
||||||
if [ "$state" = "connected" ]; then
|
if [ "$state" = "connected" ]; then
|
||||||
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
|
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
|
||||||
if [ -n "$SSID" ]; then
|
if [ -n "$SSID" ]; then
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "March Madness Plugin Configuration",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Enable the March Madness tournament display"
|
||||||
|
},
|
||||||
|
"leagues": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Tournament Leagues",
|
||||||
|
"description": "Which NCAA tournaments to display",
|
||||||
|
"properties": {
|
||||||
|
"ncaam": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show NCAA Men's Tournament games"
|
||||||
|
},
|
||||||
|
"ncaaw": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show NCAA Women's Tournament games"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"favorite_teams": {
|
||||||
|
"type": "array",
|
||||||
|
"title": "Favorite Teams",
|
||||||
|
"description": "Team abbreviations to highlight (e.g., DUKE, UNC). Leave empty to show all teams equally.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"uniqueItems": true,
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"display_options": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Display Options",
|
||||||
|
"x-collapsed": true,
|
||||||
|
"properties": {
|
||||||
|
"show_seeds": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show tournament seeds (1-16) next to team names"
|
||||||
|
},
|
||||||
|
"show_round_logos": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show round logo separators between game groups"
|
||||||
|
},
|
||||||
|
"highlight_upsets": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Highlight upset winners (higher seed beating lower seed) in gold"
|
||||||
|
},
|
||||||
|
"show_bracket_progress": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show which teams are still alive in each region"
|
||||||
|
},
|
||||||
|
"scroll_speed": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 1.0,
|
||||||
|
"minimum": 0.5,
|
||||||
|
"maximum": 5.0,
|
||||||
|
"description": "Scroll speed (pixels per frame)"
|
||||||
|
},
|
||||||
|
"scroll_delay": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0.02,
|
||||||
|
"minimum": 0.001,
|
||||||
|
"maximum": 0.1,
|
||||||
|
"description": "Delay between scroll frames (seconds)"
|
||||||
|
},
|
||||||
|
"target_fps": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 120,
|
||||||
|
"minimum": 30,
|
||||||
|
"maximum": 200,
|
||||||
|
"description": "Target frames per second"
|
||||||
|
},
|
||||||
|
"loop": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Loop the scroll continuously"
|
||||||
|
},
|
||||||
|
"dynamic_duration": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Automatically adjust display duration based on content width"
|
||||||
|
},
|
||||||
|
"min_duration": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 30,
|
||||||
|
"minimum": 10,
|
||||||
|
"maximum": 300,
|
||||||
|
"description": "Minimum display duration in seconds"
|
||||||
|
},
|
||||||
|
"max_duration": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 300,
|
||||||
|
"minimum": 30,
|
||||||
|
"maximum": 600,
|
||||||
|
"description": "Maximum display duration in seconds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"data_settings": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Data Settings",
|
||||||
|
"x-collapsed": true,
|
||||||
|
"properties": {
|
||||||
|
"update_interval": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 300,
|
||||||
|
"minimum": 60,
|
||||||
|
"maximum": 3600,
|
||||||
|
"description": "How often to refresh tournament data (seconds). Automatically shortens to 60s when live games are detected."
|
||||||
|
},
|
||||||
|
"request_timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 30,
|
||||||
|
"minimum": 5,
|
||||||
|
"maximum": 60,
|
||||||
|
"description": "API request timeout in seconds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["enabled"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-propertyOrder": ["enabled", "leagues", "favorite_teams", "display_options", "data_settings"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,910 @@
|
|||||||
|
"""March Madness Plugin — NCAA Tournament bracket tracker for LED Matrix.
|
||||||
|
|
||||||
|
Displays a horizontally-scrolling ticker of NCAA Tournament games grouped by
|
||||||
|
round, with seeds, round logos, live scores, and upset highlighting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytz
|
||||||
|
import requests
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
except ImportError:
|
||||||
|
ScrollHelper = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SCOREBOARD_URLS = {
|
||||||
|
"ncaam": "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard",
|
||||||
|
"ncaaw": "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard",
|
||||||
|
}
|
||||||
|
|
||||||
|
ROUND_ORDER = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6}
|
||||||
|
|
||||||
|
ROUND_DISPLAY_NAMES = {
|
||||||
|
"NCG": "Championship",
|
||||||
|
"F4": "Final Four",
|
||||||
|
"E8": "Elite Eight",
|
||||||
|
"S16": "Sweet Sixteen",
|
||||||
|
"R32": "Round of 32",
|
||||||
|
"R64": "Round of 64",
|
||||||
|
}
|
||||||
|
|
||||||
|
ROUND_LOGO_FILES = {
|
||||||
|
"NCG": "CHAMPIONSHIP.png",
|
||||||
|
"F4": "FINAL_4.png",
|
||||||
|
"E8": "ELITE_8.png",
|
||||||
|
"S16": "SWEET_16.png",
|
||||||
|
"R32": "ROUND_32.png",
|
||||||
|
"R64": "ROUND_64.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
REGION_ORDER = {"E": 0, "W": 1, "S": 2, "MW": 3, "": 4}
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
COLOR_WHITE = (255, 255, 255)
|
||||||
|
COLOR_GOLD = (255, 215, 0)
|
||||||
|
COLOR_GRAY = (160, 160, 160)
|
||||||
|
COLOR_DIM = (100, 100, 100)
|
||||||
|
COLOR_RED = (255, 60, 60)
|
||||||
|
COLOR_GREEN = (60, 200, 60)
|
||||||
|
COLOR_BLACK = (0, 0, 0)
|
||||||
|
COLOR_DARK_BG = (20, 20, 20)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plugin Class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MarchMadnessPlugin(BasePlugin):
|
||||||
|
"""NCAA March Madness tournament bracket tracker."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
display_manager: Any,
|
||||||
|
cache_manager: Any,
|
||||||
|
plugin_manager: Any,
|
||||||
|
):
|
||||||
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
|
|
||||||
|
# Config
|
||||||
|
leagues_config = config.get("leagues", {})
|
||||||
|
self.show_ncaam: bool = leagues_config.get("ncaam", True)
|
||||||
|
self.show_ncaaw: bool = leagues_config.get("ncaaw", True)
|
||||||
|
self.favorite_teams: List[str] = [t.upper() for t in config.get("favorite_teams", [])]
|
||||||
|
|
||||||
|
display_options = config.get("display_options", {})
|
||||||
|
self.show_seeds: bool = display_options.get("show_seeds", True)
|
||||||
|
self.show_round_logos: bool = display_options.get("show_round_logos", True)
|
||||||
|
self.highlight_upsets: bool = display_options.get("highlight_upsets", True)
|
||||||
|
self.show_bracket_progress: bool = display_options.get("show_bracket_progress", True)
|
||||||
|
self.scroll_speed: float = display_options.get("scroll_speed", 1.0)
|
||||||
|
self.scroll_delay: float = display_options.get("scroll_delay", 0.02)
|
||||||
|
self.target_fps: int = display_options.get("target_fps", 120)
|
||||||
|
self.loop: bool = display_options.get("loop", True)
|
||||||
|
self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True)
|
||||||
|
self.min_duration: int = display_options.get("min_duration", 30)
|
||||||
|
self.max_duration: int = display_options.get("max_duration", 300)
|
||||||
|
if self.min_duration > self.max_duration:
|
||||||
|
self.logger.warning(
|
||||||
|
f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values"
|
||||||
|
)
|
||||||
|
self.min_duration, self.max_duration = self.max_duration, self.min_duration
|
||||||
|
|
||||||
|
data_settings = config.get("data_settings", {})
|
||||||
|
self.update_interval: int = data_settings.get("update_interval", 300)
|
||||||
|
self.request_timeout: int = data_settings.get("request_timeout", 30)
|
||||||
|
|
||||||
|
# Scrolling flag for display controller
|
||||||
|
self.enable_scrolling = True
|
||||||
|
|
||||||
|
# State
|
||||||
|
self.games_data: List[Dict] = []
|
||||||
|
self.ticker_image: Optional[Image.Image] = None
|
||||||
|
self.last_update: float = 0
|
||||||
|
self.dynamic_duration: float = 60
|
||||||
|
self.total_scroll_width: int = 0
|
||||||
|
self._display_start_time: Optional[float] = None
|
||||||
|
self._end_reached_logged: bool = False
|
||||||
|
self._update_lock = threading.Lock()
|
||||||
|
self._has_live_games: bool = False
|
||||||
|
self._cached_dynamic_duration: Optional[float] = None
|
||||||
|
self._duration_cache_time: float = 0
|
||||||
|
|
||||||
|
# Display dimensions
|
||||||
|
self.display_width: int = self.display_manager.matrix.width
|
||||||
|
self.display_height: int = self.display_manager.matrix.height
|
||||||
|
|
||||||
|
# HTTP session with retry
|
||||||
|
self.session = requests.Session()
|
||||||
|
retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
|
||||||
|
self.session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||||
|
self.headers = {"User-Agent": "LEDMatrix/2.0"}
|
||||||
|
|
||||||
|
# ScrollHelper
|
||||||
|
if ScrollHelper:
|
||||||
|
self.scroll_helper = ScrollHelper(self.display_width, self.display_height, logger=self.logger)
|
||||||
|
if hasattr(self.scroll_helper, "set_frame_based_scrolling"):
|
||||||
|
self.scroll_helper.set_frame_based_scrolling(True)
|
||||||
|
self.scroll_helper.set_scroll_speed(self.scroll_speed)
|
||||||
|
self.scroll_helper.set_scroll_delay(self.scroll_delay)
|
||||||
|
if hasattr(self.scroll_helper, "set_target_fps"):
|
||||||
|
self.scroll_helper.set_target_fps(self.target_fps)
|
||||||
|
self.scroll_helper.set_dynamic_duration_settings(
|
||||||
|
enabled=self.dynamic_duration_enabled,
|
||||||
|
min_duration=self.min_duration,
|
||||||
|
max_duration=self.max_duration,
|
||||||
|
buffer=0.1,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.scroll_helper = None
|
||||||
|
self.logger.warning("ScrollHelper not available")
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
self.fonts = self._load_fonts()
|
||||||
|
|
||||||
|
# Logos
|
||||||
|
self._round_logos: Dict[str, Image.Image] = {}
|
||||||
|
self._team_logo_cache: Dict[str, Optional[Image.Image]] = {}
|
||||||
|
self._march_madness_logo: Optional[Image.Image] = None
|
||||||
|
self._load_round_logos()
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"MarchMadnessPlugin initialized — NCAAM: {self.show_ncaam}, "
|
||||||
|
f"NCAAW: {self.show_ncaaw}, favorites: {self.favorite_teams}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Fonts
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
|
||||||
|
fonts = {}
|
||||||
|
try:
|
||||||
|
fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
||||||
|
except IOError:
|
||||||
|
fonts["score"] = ImageFont.load_default()
|
||||||
|
try:
|
||||||
|
fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||||
|
except IOError:
|
||||||
|
fonts["time"] = ImageFont.load_default()
|
||||||
|
try:
|
||||||
|
fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||||
|
except IOError:
|
||||||
|
fonts["detail"] = ImageFont.load_default()
|
||||||
|
return fonts
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Logo loading
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_round_logos(self) -> None:
|
||||||
|
logo_dir = Path("assets/sports/ncaa_logos")
|
||||||
|
for round_key, filename in ROUND_LOGO_FILES.items():
|
||||||
|
path = logo_dir / filename
|
||||||
|
try:
|
||||||
|
img = Image.open(path).convert("RGBA")
|
||||||
|
# Resize to fit display height
|
||||||
|
target_h = self.display_height - 4
|
||||||
|
ratio = target_h / img.height
|
||||||
|
target_w = int(img.width * ratio)
|
||||||
|
self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.logger.warning(f"Could not load round logo {filename}: {e}")
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(f"Unexpected error loading round logo {filename}")
|
||||||
|
|
||||||
|
# March Madness logo
|
||||||
|
mm_path = logo_dir / "MARCH_MADNESS.png"
|
||||||
|
try:
|
||||||
|
img = Image.open(mm_path).convert("RGBA")
|
||||||
|
target_h = self.display_height - 4
|
||||||
|
ratio = target_h / img.height
|
||||||
|
target_w = int(img.width * ratio)
|
||||||
|
self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.logger.warning(f"Could not load March Madness logo: {e}")
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Unexpected error loading March Madness logo")
|
||||||
|
|
||||||
|
def _get_team_logo(self, abbr: str) -> Optional[Image.Image]:
|
||||||
|
if abbr in self._team_logo_cache:
|
||||||
|
return self._team_logo_cache[abbr]
|
||||||
|
logo_dir = Path("assets/sports/ncaa_logos")
|
||||||
|
path = logo_dir / f"{abbr}.png"
|
||||||
|
try:
|
||||||
|
img = Image.open(path).convert("RGBA")
|
||||||
|
target_h = self.display_height - 6
|
||||||
|
ratio = target_h / img.height
|
||||||
|
target_w = int(img.width * ratio)
|
||||||
|
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||||
|
self._team_logo_cache[abbr] = img
|
||||||
|
return img
|
||||||
|
except (FileNotFoundError, OSError, ValueError):
|
||||||
|
self._team_logo_cache[abbr] = None
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(f"Unexpected error loading team logo for {abbr}")
|
||||||
|
self._team_logo_cache[abbr] = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Data fetching
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _is_tournament_window(self) -> bool:
|
||||||
|
today = datetime.now(pytz.utc)
|
||||||
|
return (3, 10) <= (today.month, today.day) <= (4, 10)
|
||||||
|
|
||||||
|
def _fetch_tournament_data(self) -> List[Dict]:
|
||||||
|
"""Fetch tournament games from ESPN scoreboard API."""
|
||||||
|
all_games: List[Dict] = []
|
||||||
|
|
||||||
|
leagues = []
|
||||||
|
if self.show_ncaam:
|
||||||
|
leagues.append("ncaam")
|
||||||
|
if self.show_ncaaw:
|
||||||
|
leagues.append("ncaaw")
|
||||||
|
|
||||||
|
for league_key in leagues:
|
||||||
|
url = SCOREBOARD_URLS.get(league_key)
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache_key = f"march_madness_{league_key}_scoreboard"
|
||||||
|
cache_max_age = 60 if self._has_live_games else self.update_interval
|
||||||
|
cached = self.cache_manager.get(cache_key, max_age=cache_max_age)
|
||||||
|
if cached:
|
||||||
|
all_games.extend(cached)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# NCAA basketball scoreboard without dates param returns current games
|
||||||
|
params = {"limit": 1000, "groups": 100}
|
||||||
|
resp = self.session.get(url, params=params, headers=self.headers, timeout=self.request_timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
events = data.get("events", [])
|
||||||
|
|
||||||
|
league_games = []
|
||||||
|
for event in events:
|
||||||
|
game = self._parse_event(event, league_key)
|
||||||
|
if game:
|
||||||
|
league_games.append(game)
|
||||||
|
|
||||||
|
self.cache_manager.set(cache_key, league_games)
|
||||||
|
self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games")
|
||||||
|
all_games.extend(league_games)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(f"Error fetching {league_key} tournament data")
|
||||||
|
|
||||||
|
return all_games
|
||||||
|
|
||||||
|
def _parse_event(self, event: Dict, league_key: str) -> Optional[Dict]:
|
||||||
|
"""Parse an ESPN event into a game dict."""
|
||||||
|
competitions = event.get("competitions", [])
|
||||||
|
if not competitions:
|
||||||
|
return None
|
||||||
|
comp = competitions[0]
|
||||||
|
|
||||||
|
# Confirm tournament game
|
||||||
|
comp_type = comp.get("type", {})
|
||||||
|
is_tournament = comp_type.get("abbreviation") == "TRNMNT"
|
||||||
|
notes = comp.get("notes", [])
|
||||||
|
headline = ""
|
||||||
|
if notes:
|
||||||
|
headline = notes[0].get("headline", "")
|
||||||
|
if not is_tournament and "Championship" in headline:
|
||||||
|
is_tournament = True
|
||||||
|
if not is_tournament:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = comp.get("status", {}).get("type", {})
|
||||||
|
state = status.get("state", "pre")
|
||||||
|
status_detail = status.get("shortDetail", "")
|
||||||
|
|
||||||
|
# Teams
|
||||||
|
competitors = comp.get("competitors", [])
|
||||||
|
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
|
||||||
|
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
|
||||||
|
if not home_team or not away_team:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_abbr = home_team.get("team", {}).get("abbreviation", "???")
|
||||||
|
away_abbr = away_team.get("team", {}).get("abbreviation", "???")
|
||||||
|
home_score = home_team.get("score", "0")
|
||||||
|
away_score = away_team.get("score", "0")
|
||||||
|
|
||||||
|
# Seeds
|
||||||
|
home_seed = home_team.get("curatedRank", {}).get("current", 0)
|
||||||
|
away_seed = away_team.get("curatedRank", {}).get("current", 0)
|
||||||
|
if home_seed >= 99:
|
||||||
|
home_seed = 0
|
||||||
|
if away_seed >= 99:
|
||||||
|
away_seed = 0
|
||||||
|
|
||||||
|
# Round and region
|
||||||
|
tournament_round = self._parse_round(headline)
|
||||||
|
tournament_region = self._parse_region(headline)
|
||||||
|
|
||||||
|
# Date/time
|
||||||
|
date_str = event.get("date", "")
|
||||||
|
start_time_utc = None
|
||||||
|
game_date = ""
|
||||||
|
game_time = ""
|
||||||
|
try:
|
||||||
|
if date_str.endswith("Z"):
|
||||||
|
date_str = date_str.replace("Z", "+00:00")
|
||||||
|
dt = datetime.fromisoformat(date_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
||||||
|
else:
|
||||||
|
start_time_utc = dt.astimezone(pytz.UTC)
|
||||||
|
local = start_time_utc.astimezone(pytz.timezone("US/Eastern"))
|
||||||
|
game_date = local.strftime("%-m/%-d")
|
||||||
|
game_time = local.strftime("%-I:%M%p").replace("AM", "am").replace("PM", "pm")
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Period / clock for live games
|
||||||
|
period = 0
|
||||||
|
clock = ""
|
||||||
|
period_text = ""
|
||||||
|
is_halftime = False
|
||||||
|
if state == "in":
|
||||||
|
status_obj = comp.get("status", {})
|
||||||
|
period = status_obj.get("period", 0)
|
||||||
|
clock = status_obj.get("displayClock", "")
|
||||||
|
detail_lower = status_detail.lower()
|
||||||
|
uses_quarters = league_key == "ncaaw" or "quarter" in detail_lower or detail_lower.startswith("q")
|
||||||
|
if period <= (4 if uses_quarters else 2):
|
||||||
|
period_text = f"Q{period}" if uses_quarters else f"H{period}"
|
||||||
|
else:
|
||||||
|
ot_num = period - (4 if uses_quarters else 2)
|
||||||
|
period_text = f"OT{ot_num}" if ot_num > 1 else "OT"
|
||||||
|
if "halftime" in detail_lower:
|
||||||
|
is_halftime = True
|
||||||
|
elif state == "post":
|
||||||
|
period_text = status.get("shortDetail", "Final")
|
||||||
|
if "Final" not in period_text:
|
||||||
|
period_text = "Final"
|
||||||
|
|
||||||
|
# Determine winner and upset
|
||||||
|
is_final = state == "post"
|
||||||
|
is_upset = False
|
||||||
|
winner_side = ""
|
||||||
|
if is_final:
|
||||||
|
try:
|
||||||
|
h = int(float(home_score))
|
||||||
|
a = int(float(away_score))
|
||||||
|
if h > a:
|
||||||
|
winner_side = "home"
|
||||||
|
if home_seed > away_seed > 0:
|
||||||
|
is_upset = True
|
||||||
|
elif a > h:
|
||||||
|
winner_side = "away"
|
||||||
|
if away_seed > home_seed > 0:
|
||||||
|
is_upset = True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": event.get("id", ""),
|
||||||
|
"league": league_key,
|
||||||
|
"home_abbr": home_abbr,
|
||||||
|
"away_abbr": away_abbr,
|
||||||
|
"home_score": str(home_score),
|
||||||
|
"away_score": str(away_score),
|
||||||
|
"home_seed": home_seed,
|
||||||
|
"away_seed": away_seed,
|
||||||
|
"tournament_round": tournament_round,
|
||||||
|
"tournament_region": tournament_region,
|
||||||
|
"state": state,
|
||||||
|
"is_final": is_final,
|
||||||
|
"is_live": state == "in",
|
||||||
|
"is_upcoming": state == "pre",
|
||||||
|
"is_halftime": is_halftime,
|
||||||
|
"period": period,
|
||||||
|
"period_text": period_text,
|
||||||
|
"clock": clock,
|
||||||
|
"status_detail": status_detail,
|
||||||
|
"game_date": game_date,
|
||||||
|
"game_time": game_time,
|
||||||
|
"start_time_utc": start_time_utc,
|
||||||
|
"is_upset": is_upset,
|
||||||
|
"winner_side": winner_side,
|
||||||
|
"headline": headline,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_round(headline: str) -> str:
|
||||||
|
hl = headline.lower()
|
||||||
|
if "national championship" in hl:
|
||||||
|
return "NCG"
|
||||||
|
if "final four" in hl:
|
||||||
|
return "F4"
|
||||||
|
if "elite 8" in hl or "elite eight" in hl:
|
||||||
|
return "E8"
|
||||||
|
if "sweet 16" in hl or "sweet sixteen" in hl:
|
||||||
|
return "S16"
|
||||||
|
if "2nd round" in hl or "second round" in hl:
|
||||||
|
return "R32"
|
||||||
|
if "1st round" in hl or "first round" in hl:
|
||||||
|
return "R64"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_region(headline: str) -> str:
|
||||||
|
if "East Region" in headline:
|
||||||
|
return "E"
|
||||||
|
if "West Region" in headline:
|
||||||
|
return "W"
|
||||||
|
if "South Region" in headline:
|
||||||
|
return "S"
|
||||||
|
if "Midwest Region" in headline:
|
||||||
|
return "MW"
|
||||||
|
m = re.search(r"Regional (\d+)", headline)
|
||||||
|
if m:
|
||||||
|
return f"R{m.group(1)}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Game processing
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _process_games(self, games: List[Dict]) -> Dict[str, List[Dict]]:
|
||||||
|
"""Group games by round, sorted by round significance then region/seed."""
|
||||||
|
grouped: Dict[str, List[Dict]] = {}
|
||||||
|
for game in games:
|
||||||
|
rnd = game.get("tournament_round", "")
|
||||||
|
grouped.setdefault(rnd, []).append(game)
|
||||||
|
|
||||||
|
# Sort each round's games by region then seed matchup
|
||||||
|
for rnd, round_games in grouped.items():
|
||||||
|
round_games.sort(
|
||||||
|
key=lambda g: (
|
||||||
|
REGION_ORDER.get(g.get("tournament_region", ""), 4),
|
||||||
|
min(g.get("away_seed", 99), g.get("home_seed", 99)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Rendering
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _draw_text_with_outline(
|
||||||
|
self,
|
||||||
|
draw: ImageDraw.Draw,
|
||||||
|
text: str,
|
||||||
|
xy: tuple,
|
||||||
|
font: ImageFont.FreeTypeFont,
|
||||||
|
fill: tuple = COLOR_WHITE,
|
||||||
|
outline: tuple = COLOR_BLACK,
|
||||||
|
) -> None:
|
||||||
|
x, y = xy
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
if dx or dy:
|
||||||
|
draw.text((x + dx, y + dy), text, font=font, fill=outline)
|
||||||
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
def _create_round_separator(self, round_key: str) -> Image.Image:
|
||||||
|
"""Create a separator tile for a tournament round."""
|
||||||
|
height = self.display_height
|
||||||
|
name = ROUND_DISPLAY_NAMES.get(round_key, round_key)
|
||||||
|
font = self.fonts["time"]
|
||||||
|
|
||||||
|
# Measure text
|
||||||
|
tmp = Image.new("RGB", (1, 1))
|
||||||
|
tmp_draw = ImageDraw.Draw(tmp)
|
||||||
|
text_width = int(tmp_draw.textlength(name, font=font))
|
||||||
|
|
||||||
|
# Logo on each side
|
||||||
|
logo = self._round_logos.get(round_key, self._march_madness_logo)
|
||||||
|
logo_w = logo.width if logo else 0
|
||||||
|
padding = 6
|
||||||
|
|
||||||
|
total_w = padding + logo_w + padding + text_width + padding + logo_w + padding
|
||||||
|
total_w = max(total_w, 80)
|
||||||
|
|
||||||
|
img = Image.new("RGB", (total_w, height), COLOR_DARK_BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Draw logos
|
||||||
|
x = padding
|
||||||
|
if logo:
|
||||||
|
logo_y = (height - logo.height) // 2
|
||||||
|
img.paste(logo, (x, logo_y), logo)
|
||||||
|
x += logo_w + padding
|
||||||
|
|
||||||
|
# Draw round name
|
||||||
|
text_y = (height - 8) // 2 # 8px font
|
||||||
|
self._draw_text_with_outline(draw, name, (x, text_y), font, fill=COLOR_GOLD)
|
||||||
|
x += text_width + padding
|
||||||
|
|
||||||
|
if logo:
|
||||||
|
logo_y = (height - logo.height) // 2
|
||||||
|
img.paste(logo, (x, logo_y), logo)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
def _create_game_tile(self, game: Dict) -> Image.Image:
|
||||||
|
"""Create a single game tile for the scrolling ticker."""
|
||||||
|
height = self.display_height
|
||||||
|
font_score = self.fonts["score"]
|
||||||
|
font_time = self.fonts["time"]
|
||||||
|
font_detail = self.fonts["detail"]
|
||||||
|
|
||||||
|
# Load team logos
|
||||||
|
away_logo = self._get_team_logo(game["away_abbr"])
|
||||||
|
home_logo = self._get_team_logo(game["home_abbr"])
|
||||||
|
logo_w = 0
|
||||||
|
if away_logo:
|
||||||
|
logo_w = max(logo_w, away_logo.width)
|
||||||
|
if home_logo:
|
||||||
|
logo_w = max(logo_w, home_logo.width)
|
||||||
|
if logo_w == 0:
|
||||||
|
logo_w = 24
|
||||||
|
|
||||||
|
# Build text elements
|
||||||
|
away_seed_str = f"({game['away_seed']})" if self.show_seeds and game.get("away_seed", 0) > 0 else ""
|
||||||
|
home_seed_str = f"({game['home_seed']})" if self.show_seeds and game.get("home_seed", 0) > 0 else ""
|
||||||
|
away_text = f"{away_seed_str}{game['away_abbr']}"
|
||||||
|
home_text = f"{game['home_abbr']}{home_seed_str}"
|
||||||
|
|
||||||
|
# Measure text widths
|
||||||
|
tmp = Image.new("RGB", (1, 1))
|
||||||
|
tmp_draw = ImageDraw.Draw(tmp)
|
||||||
|
away_text_w = int(tmp_draw.textlength(away_text, font=font_detail))
|
||||||
|
home_text_w = int(tmp_draw.textlength(home_text, font=font_detail))
|
||||||
|
|
||||||
|
# Center content: status line
|
||||||
|
if game["is_live"]:
|
||||||
|
if game["is_halftime"]:
|
||||||
|
status_text = "Halftime"
|
||||||
|
else:
|
||||||
|
status_text = f"{game['period_text']} {game['clock']}".strip()
|
||||||
|
elif game["is_final"]:
|
||||||
|
status_text = game.get("period_text", "Final")
|
||||||
|
else:
|
||||||
|
status_text = f"{game['game_date']} {game['game_time']}".strip()
|
||||||
|
|
||||||
|
status_w = int(tmp_draw.textlength(status_text, font=font_time))
|
||||||
|
|
||||||
|
# Score line (for live/final)
|
||||||
|
score_text = ""
|
||||||
|
if game["is_live"] or game["is_final"]:
|
||||||
|
score_text = f"{game['away_score']}-{game['home_score']}"
|
||||||
|
score_w = int(tmp_draw.textlength(score_text, font=font_score)) if score_text else 0
|
||||||
|
|
||||||
|
# Calculate tile width
|
||||||
|
h_pad = 4
|
||||||
|
center_w = max(status_w, score_w, 40)
|
||||||
|
tile_w = h_pad + logo_w + h_pad + away_text_w + h_pad + center_w + h_pad + home_text_w + h_pad + logo_w + h_pad
|
||||||
|
|
||||||
|
img = Image.new("RGB", (tile_w, height), COLOR_BLACK)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Paste away logo
|
||||||
|
x = h_pad
|
||||||
|
if away_logo:
|
||||||
|
logo_y = (height - away_logo.height) // 2
|
||||||
|
img.paste(away_logo, (x, logo_y), away_logo)
|
||||||
|
x += logo_w + h_pad
|
||||||
|
|
||||||
|
# Away team text (seed + abbr)
|
||||||
|
is_fav_away = game["away_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||||
|
away_color = COLOR_GOLD if is_fav_away else COLOR_WHITE
|
||||||
|
if game["is_final"] and game["winner_side"] == "away" and self.highlight_upsets and game["is_upset"]:
|
||||||
|
away_color = COLOR_GOLD
|
||||||
|
team_text_y = (height - 6) // 2 - 5 # Upper half
|
||||||
|
self._draw_text_with_outline(draw, away_text, (x, team_text_y), font_detail, fill=away_color)
|
||||||
|
x += away_text_w + h_pad
|
||||||
|
|
||||||
|
# Center block
|
||||||
|
center_x = x
|
||||||
|
center_mid = center_x + center_w // 2
|
||||||
|
|
||||||
|
# Status text (top center of center block)
|
||||||
|
status_x = center_mid - status_w // 2
|
||||||
|
status_y = 2
|
||||||
|
status_color = COLOR_GREEN if game["is_live"] else COLOR_GRAY
|
||||||
|
self._draw_text_with_outline(draw, status_text, (status_x, status_y), font_time, fill=status_color)
|
||||||
|
|
||||||
|
# Score (bottom center of center block, for live/final)
|
||||||
|
if score_text:
|
||||||
|
score_x = center_mid - score_w // 2
|
||||||
|
score_y = height - 13
|
||||||
|
# Upset highlighting
|
||||||
|
if game["is_final"] and game["is_upset"] and self.highlight_upsets:
|
||||||
|
score_color = COLOR_GOLD
|
||||||
|
elif game["is_live"]:
|
||||||
|
score_color = COLOR_WHITE
|
||||||
|
else:
|
||||||
|
score_color = COLOR_WHITE
|
||||||
|
self._draw_text_with_outline(draw, score_text, (score_x, score_y), font_score, fill=score_color)
|
||||||
|
|
||||||
|
# Date for final games (below score)
|
||||||
|
if game["is_final"] and game.get("game_date"):
|
||||||
|
date_w = int(draw.textlength(game["game_date"], font=font_detail))
|
||||||
|
date_x = center_mid - date_w // 2
|
||||||
|
date_y = height - 6
|
||||||
|
self._draw_text_with_outline(draw, game["game_date"], (date_x, date_y), font_detail, fill=COLOR_DIM)
|
||||||
|
|
||||||
|
x = center_x + center_w + h_pad
|
||||||
|
|
||||||
|
# Home team text
|
||||||
|
is_fav_home = game["home_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||||
|
home_color = COLOR_GOLD if is_fav_home else COLOR_WHITE
|
||||||
|
if game["is_final"] and game["winner_side"] == "home" and self.highlight_upsets and game["is_upset"]:
|
||||||
|
home_color = COLOR_GOLD
|
||||||
|
self._draw_text_with_outline(draw, home_text, (x, team_text_y), font_detail, fill=home_color)
|
||||||
|
x += home_text_w + h_pad
|
||||||
|
|
||||||
|
# Paste home logo
|
||||||
|
if home_logo:
|
||||||
|
logo_y = (height - home_logo.height) // 2
|
||||||
|
img.paste(home_logo, (x, logo_y), home_logo)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
def _create_ticker_image(self) -> None:
|
||||||
|
"""Build the full scrolling ticker image from game tiles."""
|
||||||
|
if not self.games_data:
|
||||||
|
self.ticker_image = None
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.clear_cache()
|
||||||
|
return
|
||||||
|
|
||||||
|
grouped = self._process_games(self.games_data)
|
||||||
|
content_items: List[Image.Image] = []
|
||||||
|
|
||||||
|
# Order rounds by significance (most important first)
|
||||||
|
sorted_rounds = sorted(grouped.keys(), key=lambda r: ROUND_ORDER.get(r, 6))
|
||||||
|
|
||||||
|
for rnd in sorted_rounds:
|
||||||
|
games = grouped[rnd]
|
||||||
|
if not games:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add round separator
|
||||||
|
if self.show_round_logos and rnd:
|
||||||
|
separator = self._create_round_separator(rnd)
|
||||||
|
content_items.append(separator)
|
||||||
|
|
||||||
|
# Add game tiles
|
||||||
|
for game in games:
|
||||||
|
tile = self._create_game_tile(game)
|
||||||
|
content_items.append(tile)
|
||||||
|
|
||||||
|
if not content_items:
|
||||||
|
self.ticker_image = None
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.clear_cache()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.scroll_helper:
|
||||||
|
self.ticker_image = None
|
||||||
|
return
|
||||||
|
|
||||||
|
gap_width = 16
|
||||||
|
|
||||||
|
# Use ScrollHelper to create the scrolling image
|
||||||
|
self.ticker_image = self.scroll_helper.create_scrolling_image(
|
||||||
|
content_items=content_items,
|
||||||
|
item_gap=gap_width,
|
||||||
|
element_gap=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.total_scroll_width = self.scroll_helper.total_scroll_width
|
||||||
|
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Ticker image created: {self.ticker_image.width}px wide, "
|
||||||
|
f"{len(self.games_data)} games, dynamic_duration={self.dynamic_duration:.0f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Plugin lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
"""Fetch and process tournament data."""
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
# Use shorter interval if live games detected
|
||||||
|
interval = 60 if self._has_live_games else self.update_interval
|
||||||
|
if current_time - self.last_update < interval:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._update_lock:
|
||||||
|
self.last_update = current_time
|
||||||
|
|
||||||
|
if not self._is_tournament_window():
|
||||||
|
self.logger.debug("Outside tournament window, skipping fetch")
|
||||||
|
self.games_data = []
|
||||||
|
self.ticker_image = None
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.clear_cache()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
games = self._fetch_tournament_data()
|
||||||
|
self._has_live_games = any(g["is_live"] for g in games)
|
||||||
|
self.games_data = games
|
||||||
|
self._create_ticker_image()
|
||||||
|
self.logger.info(
|
||||||
|
f"Updated: {len(games)} games, "
|
||||||
|
f"live={self._has_live_games}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Update error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def display(self, force_clear: bool = False) -> None:
|
||||||
|
"""Render one scroll frame."""
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if force_clear or self._display_start_time is None:
|
||||||
|
self._display_start_time = time.time()
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.reset_scroll()
|
||||||
|
self._end_reached_logged = False
|
||||||
|
|
||||||
|
if not self.games_data or self.ticker_image is None:
|
||||||
|
self._display_fallback()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.scroll_helper:
|
||||||
|
self._display_fallback()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.loop or not self.scroll_helper.is_scroll_complete():
|
||||||
|
self.scroll_helper.update_scroll_position()
|
||||||
|
elif not self._end_reached_logged:
|
||||||
|
self.logger.info("Scroll complete")
|
||||||
|
self._end_reached_logged = True
|
||||||
|
|
||||||
|
visible = self.scroll_helper.get_visible_portion()
|
||||||
|
if visible is None:
|
||||||
|
self._display_fallback()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||||
|
|
||||||
|
matrix_w = self.display_manager.matrix.width
|
||||||
|
matrix_h = self.display_manager.matrix.height
|
||||||
|
if not hasattr(self.display_manager, "image") or self.display_manager.image is None:
|
||||||
|
self.display_manager.image = Image.new("RGB", (matrix_w, matrix_h), COLOR_BLACK)
|
||||||
|
self.display_manager.image.paste(visible, (0, 0))
|
||||||
|
self.display_manager.update_display()
|
||||||
|
self.scroll_helper.log_frame_rate()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Display error: {e}", exc_info=True)
|
||||||
|
self._display_fallback()
|
||||||
|
|
||||||
|
def _display_fallback(self) -> None:
|
||||||
|
w = self.display_manager.matrix.width
|
||||||
|
h = self.display_manager.matrix.height
|
||||||
|
img = Image.new("RGB", (w, h), COLOR_BLACK)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
if self._is_tournament_window():
|
||||||
|
text = "No games"
|
||||||
|
else:
|
||||||
|
text = "Off-season"
|
||||||
|
|
||||||
|
text_w = int(draw.textlength(text, font=self.fonts["time"]))
|
||||||
|
text_x = (w - text_w) // 2
|
||||||
|
text_y = (h - 8) // 2
|
||||||
|
draw.text((text_x, text_y), text, font=self.fonts["time"], fill=COLOR_GRAY)
|
||||||
|
|
||||||
|
# Show March Madness logo if available
|
||||||
|
if self._march_madness_logo:
|
||||||
|
logo_y = (h - self._march_madness_logo.height) // 2
|
||||||
|
img.paste(self._march_madness_logo, (2, logo_y), self._march_madness_logo)
|
||||||
|
|
||||||
|
self.display_manager.image = img
|
||||||
|
self.display_manager.update_display()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Duration / cycle management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_display_duration(self) -> float:
|
||||||
|
current_time = time.time()
|
||||||
|
if self._cached_dynamic_duration is not None:
|
||||||
|
cache_age = current_time - self._duration_cache_time
|
||||||
|
if cache_age < 5.0:
|
||||||
|
return self._cached_dynamic_duration
|
||||||
|
|
||||||
|
self._cached_dynamic_duration = self.dynamic_duration
|
||||||
|
self._duration_cache_time = current_time
|
||||||
|
return self.dynamic_duration
|
||||||
|
|
||||||
|
def supports_dynamic_duration(self) -> bool:
|
||||||
|
if not self.enabled:
|
||||||
|
return False
|
||||||
|
return self.dynamic_duration_enabled
|
||||||
|
|
||||||
|
def is_cycle_complete(self) -> bool:
|
||||||
|
if not self.supports_dynamic_duration():
|
||||||
|
return True
|
||||||
|
if self._display_start_time is not None and self.dynamic_duration > 0:
|
||||||
|
elapsed = time.time() - self._display_start_time
|
||||||
|
if elapsed >= self.dynamic_duration:
|
||||||
|
return True
|
||||||
|
if not self.loop and self.scroll_helper and self.scroll_helper.is_scroll_complete():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reset_cycle_state(self) -> None:
|
||||||
|
super().reset_cycle_state()
|
||||||
|
self._display_start_time = None
|
||||||
|
self._end_reached_logged = False
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.reset_scroll()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Vegas mode
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_vegas_content(self):
|
||||||
|
if not self.games_data:
|
||||||
|
return None
|
||||||
|
tiles = []
|
||||||
|
for game in self.games_data:
|
||||||
|
tiles.append(self._create_game_tile(game))
|
||||||
|
return tiles if tiles else None
|
||||||
|
|
||||||
|
def get_vegas_content_type(self) -> str:
|
||||||
|
return "multi"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Info / cleanup
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_info(self) -> Dict:
|
||||||
|
info = super().get_info()
|
||||||
|
info["total_games"] = len(self.games_data)
|
||||||
|
info["has_live_games"] = self._has_live_games
|
||||||
|
info["dynamic_duration"] = self.dynamic_duration
|
||||||
|
info["tournament_window"] = self._is_tournament_window()
|
||||||
|
return info
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
self.games_data = []
|
||||||
|
self.ticker_image = None
|
||||||
|
if self.scroll_helper:
|
||||||
|
self.scroll_helper.clear_cache()
|
||||||
|
self._team_logo_cache.clear()
|
||||||
|
if self.session:
|
||||||
|
self.session.close()
|
||||||
|
self.session = None
|
||||||
|
super().cleanup()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": "march-madness",
|
||||||
|
"name": "March Madness",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting",
|
||||||
|
"author": "ChuckBuilds",
|
||||||
|
"category": "sports",
|
||||||
|
"tags": [
|
||||||
|
"ncaa",
|
||||||
|
"basketball",
|
||||||
|
"march-madness",
|
||||||
|
"tournament",
|
||||||
|
"bracket",
|
||||||
|
"scrolling"
|
||||||
|
],
|
||||||
|
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
||||||
|
"branch": "main",
|
||||||
|
"plugin_path": "plugins/march-madness",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"ledmatrix_min": "2.0.0",
|
||||||
|
"released": "2026-02-16"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stars": 0,
|
||||||
|
"downloads": 0,
|
||||||
|
"last_updated": "2026-02-16",
|
||||||
|
"verified": true,
|
||||||
|
"screenshot": "",
|
||||||
|
"display_modes": [
|
||||||
|
"march_madness"
|
||||||
|
],
|
||||||
|
"dependencies": {},
|
||||||
|
"entry_point": "manager.py",
|
||||||
|
"class_name": "MarchMadnessPlugin"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
requests>=2.28.0
|
||||||
|
Pillow>=10.3.0
|
||||||
|
pytz>=2022.1
|
||||||
|
numpy>=1.24.0
|
||||||
@@ -22,6 +22,5 @@
|
|||||||
"Pillow>=10.0.0",
|
"Pillow>=10.0.0",
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
"requests>=2.31.0"
|
"requests>=2.31.0"
|
||||||
],
|
]
|
||||||
"local_only": true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
Pillow>=12.2.0
|
Pillow>=10.4.0
|
||||||
PyYAML>=6.0.2
|
PyYAML>=6.0.2
|
||||||
requests>=2.33.0
|
requests>=2.32.0
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Test-only dependencies for the plugin safety harness and pytest suite.
|
|
||||||
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
|
||||||
#
|
|
||||||
# pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
|
|
||||||
# major-version caps) in requirements.txt, so they are intentionally NOT
|
|
||||||
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
|
|
||||||
# pytest>=9.0.3,<10 and made the two files impossible to install together.
|
|
||||||
# Only declare what requirements.txt doesn't already provide.
|
|
||||||
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie)
|
# Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie)
|
||||||
|
|
||||||
# Image processing
|
# Image processing
|
||||||
Pillow>=12.2.0,<13.0.0
|
Pillow>=10.4.0,<12.0.0
|
||||||
numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
||||||
|
|
||||||
# Timezone handling
|
# Timezone handling
|
||||||
@@ -12,7 +12,7 @@ timezonefinder>=6.5.0,<7.0.0 # Updated for better performance and accuracy
|
|||||||
geopy>=2.4.1,<3.0.0
|
geopy>=2.4.1,<3.0.0
|
||||||
|
|
||||||
# HTTP requests
|
# HTTP requests
|
||||||
requests>=2.33.0,<3.0.0
|
requests>=2.32.0,<3.0.0
|
||||||
|
|
||||||
# Google API integration
|
# Google API integration
|
||||||
google-auth-oauthlib>=1.2.0,<2.0.0
|
google-auth-oauthlib>=1.2.0,<2.0.0
|
||||||
@@ -23,10 +23,10 @@ google-api-python-client>=2.147.0,<3.0.0
|
|||||||
freetype-py>=2.5.1,<3.0.0
|
freetype-py>=2.5.1,<3.0.0
|
||||||
|
|
||||||
# Spotify integration
|
# Spotify integration
|
||||||
spotipy>=2.25.2,<3.0.0
|
spotipy>=2.24.0,<3.0.0
|
||||||
|
|
||||||
# Flask web framework
|
# Flask web framework
|
||||||
Flask>=3.1.3,<4.0.0
|
Flask>=3.0.0,<4.0.0
|
||||||
|
|
||||||
# Text processing
|
# Text processing
|
||||||
unidecode>=1.3.8,<2.0.0
|
unidecode>=1.3.8,<2.0.0
|
||||||
@@ -35,7 +35,7 @@ unidecode>=1.3.8,<2.0.0
|
|||||||
icalevents>=0.1.27,<1.0.0
|
icalevents>=0.1.27,<1.0.0
|
||||||
|
|
||||||
# WebSocket support
|
# WebSocket support
|
||||||
python-socketio>=5.14.0,<6.0.0
|
python-socketio>=5.11.0,<6.0.0
|
||||||
python-engineio>=4.9.0,<5.0.0
|
python-engineio>=4.9.0,<5.0.0
|
||||||
websockets>=12.0,<14.0
|
websockets>=12.0,<14.0
|
||||||
websocket-client>=1.8.0,<2.0.0
|
websocket-client>=1.8.0,<2.0.0
|
||||||
@@ -43,11 +43,8 @@ websocket-client>=1.8.0,<2.0.0
|
|||||||
# JSON Schema validation
|
# JSON Schema validation
|
||||||
jsonschema>=4.20.0,<5.0.0
|
jsonschema>=4.20.0,<5.0.0
|
||||||
|
|
||||||
# Requirement specifier parsing (plugin dependency satisfaction checks)
|
|
||||||
packaging>=23.0,<27.0
|
|
||||||
|
|
||||||
# Testing dependencies
|
# Testing dependencies
|
||||||
pytest>=9.0.3,<10.0.0
|
pytest>=7.4.0,<8.0.0
|
||||||
pytest-cov>=4.1.0,<5.0.0
|
pytest-cov>=4.1.0,<5.0.0
|
||||||
pytest-mock>=3.11.0,<4.0.0
|
pytest-mock>=3.11.0,<4.0.0
|
||||||
mypy>=1.5.0,<2.0.0
|
mypy>=1.5.0,<2.0.0
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ if debug_mode:
|
|||||||
|
|
||||||
# Try to import the plugin system directly to get better error info
|
# Try to import the plugin system directly to get better error info
|
||||||
print("DEBUG: Attempting to import src.plugin_system...", flush=True)
|
print("DEBUG: Attempting to import src.plugin_system...", flush=True)
|
||||||
|
from src.plugin_system import PluginManager
|
||||||
print("DEBUG: Plugin system import successful", flush=True)
|
print("DEBUG: Plugin system import successful", flush=True)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"DEBUG: Plugin system import failed: {e}", flush=True)
|
print(f"DEBUG: Plugin system import failed: {e}", flush=True)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ and preventing validation errors.
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ Analyze all plugin config schemas to identify issues:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Set, Any
|
||||||
import jsonschema
|
import jsonschema
|
||||||
from jsonschema import Draft7Validator
|
from jsonschema import Draft7Validator
|
||||||
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Plugin safety checker.
|
|
||||||
|
|
||||||
Renders a plugin across every declared screen (mode) and every supported matrix
|
|
||||||
size, and fails if any screen crashes, overflows the panel, or (for plugins with
|
|
||||||
committed golden images) drifts visually.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# Functional + bounds check across all sizes/modes:
|
|
||||||
python scripts/check_plugin.py --plugin clock-simple
|
|
||||||
|
|
||||||
# Every discovered plugin:
|
|
||||||
python scripts/check_plugin.py --all
|
|
||||||
|
|
||||||
# Dump PNGs for each size/mode so you can eyeball them:
|
|
||||||
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
|
||||||
|
|
||||||
# Refresh committed golden images after an intentional visual change:
|
|
||||||
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
|
||||||
--mock-data plugins/clock-simple/test/fixtures/mock.json
|
|
||||||
|
|
||||||
Exit code is non-zero if any (plugin, size, mode) fails.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
os.environ['EMULATOR'] = 'true'
|
|
||||||
|
|
||||||
from src.logging_config import get_logger # noqa: E402
|
|
||||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
|
||||||
find_plugin_dir, load_config_defaults, load_harness_spec,
|
|
||||||
)
|
|
||||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
|
||||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
|
||||||
)
|
|
||||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
|
||||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = get_logger("[Check Plugin]")
|
|
||||||
|
|
||||||
DEFAULT_SEARCH_DIRS = [
|
|
||||||
str(PROJECT_ROOT / 'plugins'),
|
|
||||||
str(PROJECT_ROOT / 'plugin-repos'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def discover_plugins(search_dirs: List[str]) -> List[str]:
|
|
||||||
"""All plugin ids found across the search dirs (dirs containing manifest.json)."""
|
|
||||||
found = []
|
|
||||||
for d in search_dirs:
|
|
||||||
base = Path(d)
|
|
||||||
if not base.exists():
|
|
||||||
continue
|
|
||||||
for child in sorted(base.iterdir()):
|
|
||||||
if (child / 'manifest.json').exists() and child.name not in found:
|
|
||||||
found.append(child.name)
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def parse_sizes(spec: Optional[str]):
|
|
||||||
if not spec:
|
|
||||||
return None
|
|
||||||
sizes = []
|
|
||||||
for token in spec.split(','):
|
|
||||||
if not token.strip():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
sizes.append(parse_size_token(token))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise SystemExit(str(exc)) from exc
|
|
||||||
return sizes
|
|
||||||
|
|
||||||
|
|
||||||
def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
|
||||||
config: Dict, run_update: bool, out_dir: Optional[Path],
|
|
||||||
update_golden: bool, golden_dir_override: Optional[Path],
|
|
||||||
freeze_time: Optional[str]) -> List[RenderResult]:
|
|
||||||
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
|
||||||
if not plugin_dir:
|
|
||||||
logger.error("Plugin '%s' not found in: %s", plugin_id, search_dirs)
|
|
||||||
return [RenderResult(plugin_id, 0, 0, "<not-found>", error="plugin directory not found")]
|
|
||||||
|
|
||||||
# Per-plugin test/harness.json holds the deterministic settings the committed
|
|
||||||
# goldens were generated with (config, mock data, frozen time, sizes). Load
|
|
||||||
# them so the CLI/CI render reproduces the golden the same way the pytest
|
|
||||||
# matrix path does; explicit CLI flags still override the file.
|
|
||||||
spec = load_harness_spec(plugin_dir)
|
|
||||||
|
|
||||||
# config_schema defaults (real-install behavior), then harness.json config,
|
|
||||||
# then CLI --config — most specific wins.
|
|
||||||
full_config = {"enabled": True}
|
|
||||||
full_config.update(load_config_defaults(plugin_dir))
|
|
||||||
full_config.update(spec.get("config", {}))
|
|
||||||
full_config.update(config)
|
|
||||||
|
|
||||||
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
|
||||||
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
|
||||||
# CLI value wins when provided, else fall back to the harness.json setting.
|
|
||||||
effective_mock_data = mock_data or spec.get("mock_data_contents", {})
|
|
||||||
effective_freeze = freeze_time or spec.get("freeze_time")
|
|
||||||
effective_run_update = run_update and not spec.get("skip_update", False)
|
|
||||||
|
|
||||||
results = render_plugin_matrix(
|
|
||||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
|
|
||||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
|
||||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
|
||||||
)
|
|
||||||
|
|
||||||
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
|
|
||||||
if update_golden:
|
|
||||||
written = write_goldens(results, golden_dir)
|
|
||||||
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
|
|
||||||
else:
|
|
||||||
compare_to_goldens(results, golden_dir)
|
|
||||||
|
|
||||||
if out_dir:
|
|
||||||
for r in results:
|
|
||||||
if r.image is None:
|
|
||||||
continue
|
|
||||||
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
|
||||||
"""Print a per-plugin grid. Returns True if everything passed."""
|
|
||||||
everything_ok = True
|
|
||||||
for plugin_id, results in all_results.items():
|
|
||||||
print(f"\n=== {plugin_id} ===")
|
|
||||||
for r in results:
|
|
||||||
if r.ok:
|
|
||||||
status = "PASS"
|
|
||||||
detail = ""
|
|
||||||
if r.golden_checked:
|
|
||||||
detail = " (golden ✓)"
|
|
||||||
if r.update_error is not None:
|
|
||||||
detail += f" (update warn: {r.update_error})"
|
|
||||||
else:
|
|
||||||
everything_ok = False
|
|
||||||
if r.error is not None:
|
|
||||||
status, detail = "FAIL", f" error={r.error}"
|
|
||||||
elif r.overflow is not None:
|
|
||||||
status, detail = "FAIL", f" overflow bbox={r.overflow}"
|
|
||||||
elif r.golden_ok is False:
|
|
||||||
status = "FAIL"
|
|
||||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
|
||||||
else:
|
|
||||||
status, detail = "FAIL", ""
|
|
||||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
|
||||||
print()
|
|
||||||
return everything_ok
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="Check a plugin renders safely across sizes & screens")
|
|
||||||
group = parser.add_mutually_exclusive_group(required=True)
|
|
||||||
group.add_argument('--plugin', '-p', help='Plugin id to check')
|
|
||||||
group.add_argument('--all', action='store_true', help='Check every discovered plugin')
|
|
||||||
parser.add_argument('--plugin-dir', '-d', default=None, help='Directory to search for plugins')
|
|
||||||
parser.add_argument('--sizes', default=None, help='Comma-separated WxH list (default: all supported)')
|
|
||||||
parser.add_argument('--config', '-c', default='{}', help='Plugin config overrides as JSON')
|
|
||||||
parser.add_argument('--mock-data', '-m', default=None, help='Path to JSON file with mock cache data')
|
|
||||||
parser.add_argument('--out-dir', '-o', default=None, help='Also dump rendered PNGs here')
|
|
||||||
parser.add_argument('--skip-update', action='store_true', help='Skip calling update()')
|
|
||||||
parser.add_argument('--update-golden', action='store_true', help='Write/refresh golden images')
|
|
||||||
parser.add_argument('--golden-dir', default=None, help='Override golden dir (default: <plugin>/test/golden)')
|
|
||||||
parser.add_argument('--freeze-time', default=None,
|
|
||||||
help='Freeze wall clock, e.g. "2025-08-01 15:25:00" (for time-dependent plugins)')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
search_dirs = [args.plugin_dir] if args.plugin_dir else DEFAULT_SEARCH_DIRS
|
|
||||||
sizes = parse_sizes(args.sizes)
|
|
||||||
|
|
||||||
try:
|
|
||||||
config = json.loads(args.config)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error("Invalid --config JSON: %s", e)
|
|
||||||
return 2
|
|
||||||
if not isinstance(config, dict):
|
|
||||||
logger.error("--config must be a JSON object, got %s", type(config).__name__)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
mock_data = {}
|
|
||||||
if args.mock_data:
|
|
||||||
mock_path = Path(args.mock_data)
|
|
||||||
if not mock_path.exists():
|
|
||||||
logger.error("Mock data file not found: %s", args.mock_data)
|
|
||||||
return 2
|
|
||||||
with open(mock_path) as f:
|
|
||||||
mock_data = json.load(f)
|
|
||||||
if not isinstance(mock_data, dict):
|
|
||||||
logger.error("--mock-data must be a JSON object (key -> cache value), got %s",
|
|
||||||
type(mock_data).__name__)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
plugin_ids = discover_plugins(search_dirs) if args.all else [args.plugin]
|
|
||||||
if not plugin_ids:
|
|
||||||
logger.error("No plugins found in: %s", search_dirs)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
out_dir = Path(args.out_dir) if args.out_dir else None
|
|
||||||
golden_dir_override = Path(args.golden_dir) if args.golden_dir else None
|
|
||||||
|
|
||||||
all_results: Dict[str, List[RenderResult]] = {}
|
|
||||||
for plugin_id in plugin_ids:
|
|
||||||
all_results[plugin_id] = check_one(
|
|
||||||
plugin_id=plugin_id, search_dirs=search_dirs, sizes=sizes,
|
|
||||||
mock_data=mock_data, config=config, run_update=not args.skip_update,
|
|
||||||
out_dir=out_dir, update_golden=args.update_golden,
|
|
||||||
golden_dir_override=golden_dir_override, freeze_time=args.freeze_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
# When refreshing goldens we skip drift comparison, but a crash or overflow
|
|
||||||
# still means the plugin is broken — never let --update-golden mask that.
|
|
||||||
ok = print_report(all_results)
|
|
||||||
return 0 if ok else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Clear all plugin dependency markers to force fresh dependency check
|
||||||
|
# Useful after updating plugins or troubleshooting dependency issues
|
||||||
|
|
||||||
|
echo "Clearing plugin dependency markers..."
|
||||||
|
|
||||||
|
# Check both possible cache locations
|
||||||
|
CACHE_DIRS=(
|
||||||
|
"/var/cache/ledmatrix"
|
||||||
|
"$HOME/.cache/ledmatrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
|
||||||
|
if [ -d "$CACHE_DIR" ]; then
|
||||||
|
echo "Checking $CACHE_DIR..."
|
||||||
|
marker_count=$(find "$CACHE_DIR" -name "plugin_*_deps_installed" 2>/dev/null | wc -l)
|
||||||
|
if [ "$marker_count" -gt 0 ]; then
|
||||||
|
echo "Found $marker_count dependency marker(s) in $CACHE_DIR"
|
||||||
|
find "$CACHE_DIR" -name "plugin_*_deps_installed" -delete
|
||||||
|
echo "Cleared $marker_count marker(s)"
|
||||||
|
else
|
||||||
|
echo "No dependency markers found in $CACHE_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done! Dependency markers cleared."
|
||||||
|
echo "Next startup will check and install dependencies as needed."
|
||||||
|
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
Check what imports are actually in the app.py file on the Pi
|
Check what imports are actually in the app.py file on the Pi
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Read the app.py file and check the import lines
|
# Read the app.py file and check the import lines
|
||||||
|
|||||||
@@ -67,9 +67,8 @@ def main():
|
|||||||
print(" 📍 Will run on: http://0.0.0.0:5000")
|
print(" 📍 Will run on: http://0.0.0.0:5000")
|
||||||
print(" ⏹️ Press Ctrl+C to stop")
|
print(" ⏹️ Press Ctrl+C to stop")
|
||||||
|
|
||||||
# Run the app (debug mode controlled by env var to satisfy security scanners)
|
# Run the app (this should start the server)
|
||||||
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
app.run(host='0.0.0.0', port=5000, debug=_debug)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n ⏹️ Server stopped by user")
|
print("\n ⏹️ Server stopped by user")
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ link_github_plugin() {
|
|||||||
log_info "Repository already exists at $target_dir"
|
log_info "Repository already exists at $target_dir"
|
||||||
if [[ -d "$target_dir/.git" ]]; then
|
if [[ -d "$target_dir/.git" ]]; then
|
||||||
log_info "Updating repository..."
|
log_info "Updating repository..."
|
||||||
(cd "$target_dir" && git pull --rebase) || true
|
(cd "$target_dir" && git pull --rebase || true)
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Pillow compatibility smoke test.
|
|
||||||
|
|
||||||
Exercises the Pillow APIs used throughout LEDMatrix to verify a new
|
|
||||||
Pillow version doesn't break image rendering, font handling, or resize ops.
|
|
||||||
|
|
||||||
Run after upgrading Pillow:
|
|
||||||
python3 scripts/dev/test_pillow_compat.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def check(label, fn):
|
|
||||||
try:
|
|
||||||
result = fn()
|
|
||||||
print(f" ✓ {label}" + (f" — {result}" if result is not None else ""))
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ {label} — {type(e).__name__}: {e}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
import PIL
|
|
||||||
|
|
||||||
print(f"Pillow {PIL.__version__} on Python {sys.version.split()[0]}\n")
|
|
||||||
|
|
||||||
failures = 0
|
|
||||||
|
|
||||||
print("Image creation:")
|
|
||||||
failures += not check("Image.new RGB",
|
|
||||||
lambda: Image.new('RGB', (128, 32), (0, 0, 0)).size)
|
|
||||||
failures += not check("Image.new RGBA",
|
|
||||||
lambda: Image.new('RGBA', (64, 64), (255, 0, 0, 128)).size)
|
|
||||||
failures += not check("Image.new 1-bit",
|
|
||||||
lambda: Image.new('1', (16, 16)).size)
|
|
||||||
|
|
||||||
print("\nDraw operations:")
|
|
||||||
img = Image.new('RGB', (128, 32), (0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
failures += not check("draw.rectangle",
|
|
||||||
lambda: draw.rectangle([0, 0, 127, 31], outline=(255, 0, 0)))
|
|
||||||
failures += not check("draw.text",
|
|
||||||
lambda: draw.text((2, 2), "Hello", fill=(255, 255, 255), font=font))
|
|
||||||
failures += not check("draw.line",
|
|
||||||
lambda: draw.line([0, 0, 127, 31], fill=(0, 255, 0)))
|
|
||||||
|
|
||||||
print("\nFont metrics (used in text_helper, scroll_helper):")
|
|
||||||
failures += not check("draw.textlength",
|
|
||||||
lambda: f"{draw.textlength('Test', font=font):.1f}px")
|
|
||||||
failures += not check("draw.textbbox",
|
|
||||||
lambda: draw.textbbox((0, 0), "Test", font=font))
|
|
||||||
|
|
||||||
print("\nResampling (used in logo_helper, image_utils, sports base):")
|
|
||||||
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
|
|
||||||
failures += not check("Image.Resampling.LANCZOS exists",
|
|
||||||
lambda: str(Image.Resampling.LANCZOS))
|
|
||||||
failures += not check("thumbnail with LANCZOS",
|
|
||||||
lambda: (logo.thumbnail((64, 32), Image.Resampling.LANCZOS), logo.size)[1])
|
|
||||||
big = Image.new('RGB', (300, 300), (0, 128, 255))
|
|
||||||
failures += not check("resize with LANCZOS",
|
|
||||||
lambda: big.resize((128, 32), Image.Resampling.LANCZOS).size)
|
|
||||||
|
|
||||||
print("\nComposite / paste (used in display rendering):")
|
|
||||||
base = Image.new('RGB', (128, 32), (0, 0, 0))
|
|
||||||
overlay = Image.new('RGBA', (32, 32), (255, 0, 0, 128))
|
|
||||||
failures += not check("paste RGBA onto RGB",
|
|
||||||
lambda: (base.paste(overlay.convert('RGB'), (0, 0)), base.size)[1])
|
|
||||||
failures += not check("Image.alpha_composite",
|
|
||||||
lambda: Image.alpha_composite(
|
|
||||||
Image.new('RGBA', (32, 32)), overlay).size)
|
|
||||||
|
|
||||||
print("\nImage I/O:")
|
|
||||||
import io
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format='PNG')
|
|
||||||
buf.seek(0)
|
|
||||||
failures += not check("save/load PNG roundtrip",
|
|
||||||
lambda: Image.open(buf).size)
|
|
||||||
|
|
||||||
print()
|
|
||||||
if failures == 0:
|
|
||||||
print(f"All checks passed. Pillow {PIL.__version__} is compatible.")
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
print(f"{failures} check(s) failed — review output above.", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -15,6 +15,7 @@ Usage: python tools/validate_python.py <python_file>
|
|||||||
import ast
|
import ast
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
def validate_file(filepath: str) -> bool:
|
def validate_file(filepath: str) -> bool:
|
||||||
"""Validate a Python file for common issues."""
|
"""Validate a Python file for common issues."""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ echo ""
|
|||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# Get the actual user
|
# Get the actual user
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ if [ -f "$PROJECT_DIR/config/config.json" ]; then
|
|||||||
echo -e "${GREEN}✓ Config file found${NC}"
|
echo -e "${GREEN}✓ Config file found${NC}"
|
||||||
|
|
||||||
# Check web_display_autostart setting
|
# Check web_display_autostart setting
|
||||||
AUTOSTART=$(grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' "$PROJECT_DIR/config/config.json" | grep -o '[a-z]*$')
|
AUTOSTART=$(cat "$PROJECT_DIR/config/config.json" | grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' | grep -o '[a-z]*$')
|
||||||
|
|
||||||
if [ "$AUTOSTART" == "true" ]; then
|
if [ "$AUTOSTART" == "true" ]; then
|
||||||
echo -e "${GREEN}✓ web_display_autostart: true${NC}"
|
echo -e "${GREEN}✓ web_display_autostart: true${NC}"
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ NC='\033[0m' # No Color
|
|||||||
# Check if running as root or with sudo
|
# Check if running as root or with sudo
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$EUID" -ne 0 ]; then
|
||||||
echo -e "${YELLOW}Warning: Some checks require sudo. Running what we can...${NC}"
|
echo -e "${YELLOW}Warning: Some checks require sudo. Running what we can...${NC}"
|
||||||
|
SUDO=""
|
||||||
|
else
|
||||||
|
SUDO=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PROJECT_DIR="${HOME}/LEDMatrix"
|
PROJECT_DIR="${HOME}/LEDMatrix"
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ total_count=${#ARCHITECTURES[@]}
|
|||||||
|
|
||||||
for arch in "${!ARCHITECTURES[@]}"; do
|
for arch in "${!ARCHITECTURES[@]}"; do
|
||||||
if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then
|
if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then
|
||||||
success_count=$((success_count + 1))
|
((success_count++))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ echo "Fixing LEDMatrix assets directory permissions..."
|
|||||||
|
|
||||||
# Get the real user (not root when running with sudo)
|
# Get the real user (not root when running with sudo)
|
||||||
REAL_USER=${SUDO_USER:-$USER}
|
REAL_USER=${SUDO_USER:-$USER}
|
||||||
|
# Resolve the home directory of the real user robustly
|
||||||
|
if command -v getent >/dev/null 2>&1; then
|
||||||
|
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
|
||||||
|
else
|
||||||
|
REAL_HOME=$(eval echo ~"$REAL_USER")
|
||||||
|
fi
|
||||||
REAL_GROUP=$(id -gn "$REAL_USER")
|
REAL_GROUP=$(id -gn "$REAL_USER")
|
||||||
|
|
||||||
# Get the project directory
|
# Get the project directory
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# safe_pip_install.sh — Install a requirements.txt as root after validating
|
|
||||||
# that the resolved path is the project's own requirements.txt or a plugin's
|
|
||||||
# requirements.txt under plugin-repos/ or plugins/.
|
|
||||||
#
|
|
||||||
# This script is intended to be called via sudo from the web interface, so
|
|
||||||
# that packages a plugin declares end up visible to ledmatrix.service (which
|
|
||||||
# runs as root) rather than only to whichever non-root user runs the web
|
|
||||||
# interface. Plugin code already runs as root once loaded, so installing its
|
|
||||||
# declared dependencies as root is not a new trust boundary.
|
|
||||||
#
|
|
||||||
# Usage: safe_pip_install.sh <requirements_txt_path>
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ $# -ne 1 ]; then
|
|
||||||
echo "Usage: $0 <requirements_txt_path>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TARGET="$1"
|
|
||||||
|
|
||||||
# Determine the project root (parent of scripts/fix_perms/)
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
|
|
||||||
# Allowed locations (resolved, no trailing slash):
|
|
||||||
# - the project's own requirements.txt
|
|
||||||
# - any requirements.txt under plugin-repos/ or plugins/
|
|
||||||
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
|
|
||||||
ALLOWED_BASES=(
|
|
||||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
|
|
||||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve the target path (follow symlinks); works even if it doesn't exist.
|
|
||||||
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
|
|
||||||
|
|
||||||
# Must be named requirements.txt — never install from an arbitrary file.
|
|
||||||
if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
|
|
||||||
echo "DENIED: $RESOLVED_TARGET is not a requirements.txt file" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
ALLOWED=false
|
|
||||||
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
|
|
||||||
ALLOWED=true
|
|
||||||
else
|
|
||||||
for BASE in "${ALLOWED_BASES[@]}"; do
|
|
||||||
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
|
|
||||||
ALLOWED=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$ALLOWED" = false ]; then
|
|
||||||
echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2
|
|
||||||
echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$RESOLVED_TARGET" ]; then
|
|
||||||
echo "ERROR: $RESOLVED_TARGET does not exist" >&2
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
PYTHON_PATH="$(command -v python3)"
|
|
||||||
# --ignore-installed: root's site-packages often has apt/dpkg-managed copies
|
|
||||||
# of common libraries (requests, urllib3, ...) with no pip RECORD file, which
|
|
||||||
# pip refuses to uninstall in place ("Cannot uninstall: no RECORD file was
|
|
||||||
# found"). This tells pip to install the newer version alongside rather than
|
|
||||||
# aborting the whole requirements.txt install over one such conflict.
|
|
||||||
exec "$PYTHON_PATH" -m pip install --break-system-packages --ignore-installed -r "$RESOLVED_TARGET"
|
|
||||||
@@ -33,7 +33,6 @@ POWEROFF_PATH=$(command -v poweroff) || true
|
|||||||
BASH_PATH=$(command -v bash) || true
|
BASH_PATH=$(command -v bash) || true
|
||||||
JOURNALCTL_PATH=$(command -v journalctl) || true
|
JOURNALCTL_PATH=$(command -v journalctl) || true
|
||||||
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
||||||
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
|
|
||||||
|
|
||||||
# Validate required commands (systemctl, bash, python3 are essential)
|
# Validate required commands (systemctl, bash, python3 are essential)
|
||||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
||||||
@@ -49,15 +48,11 @@ if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate helper scripts exist
|
# Validate helper script exists
|
||||||
if [ ! -f "$SAFE_RM_PATH" ]; then
|
if [ ! -f "$SAFE_RM_PATH" ]; then
|
||||||
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
|
|
||||||
echo "Error: Safe pip install helper not found: $SAFE_PIP_INSTALL_PATH" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Command paths:"
|
echo "Command paths:"
|
||||||
echo " Python: $PYTHON_PATH"
|
echo " Python: $PYTHON_PATH"
|
||||||
@@ -67,7 +62,6 @@ echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
|
|||||||
echo " Bash: $BASH_PATH"
|
echo " Bash: $BASH_PATH"
|
||||||
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
||||||
echo " Safe plugin rm: $SAFE_RM_PATH"
|
echo " Safe plugin rm: $SAFE_RM_PATH"
|
||||||
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
|
|
||||||
|
|
||||||
# Create a temporary sudoers file
|
# Create a temporary sudoers file
|
||||||
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||||
@@ -107,22 +101,13 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Required: python3, bash
|
# Required: python3, bash
|
||||||
# NOTE: display_controller.py/start_display.sh/stop_display.sh live at the
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py"
|
||||||
# project root, not under scripts/install/ (where this script lives) —
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh"
|
||||||
# must use PROJECT_ROOT here, not PROJECT_DIR.
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT/display_controller.py"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/start_display.sh"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/stop_display.sh"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "# Allow web user to remove plugin directories via vetted helper script"
|
echo "# Allow web user to remove plugin directories via vetted helper script"
|
||||||
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
||||||
echo ""
|
|
||||||
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
|
|
||||||
echo "# helper script, so packages are visible to root-run ledmatrix.service"
|
|
||||||
echo "# (not just the web interface's own user). The helper validates the target"
|
|
||||||
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
|
|
||||||
} > "$TEMP_SUDOERS"
|
} > "$TEMP_SUDOERS"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -141,7 +126,6 @@ echo "- Run display_controller.py directly"
|
|||||||
echo "- Execute start_display.sh and stop_display.sh"
|
echo "- Execute start_display.sh and stop_display.sh"
|
||||||
echo "- Reboot and shutdown the system"
|
echo "- Reboot and shutdown the system"
|
||||||
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
||||||
echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Ask for confirmation
|
# Ask for confirmation
|
||||||
@@ -163,13 +147,6 @@ fi
|
|||||||
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
||||||
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
||||||
fi
|
fi
|
||||||
echo "Hardening safe_pip_install.sh ownership..."
|
|
||||||
if ! sudo chown root:root "$SAFE_PIP_INSTALL_PATH"; then
|
|
||||||
echo "Warning: Could not set ownership on $SAFE_PIP_INSTALL_PATH"
|
|
||||||
fi
|
|
||||||
if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
|
|
||||||
echo "Warning: Could not set permissions on $SAFE_PIP_INSTALL_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
||||||
echo "Configuration applied successfully!"
|
echo "Configuration applied successfully!"
|
||||||
@@ -183,7 +160,7 @@ if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
|||||||
echo "✗ systemctl status ledmatrix.service - Failed"
|
echo "✗ systemctl status ledmatrix.service - Failed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
|
if sudo -n test -f "$PROJECT_DIR/start_display.sh"; then
|
||||||
echo "✓ File access test - OK"
|
echo "✓ File access test - OK"
|
||||||
else
|
else
|
||||||
echo "✗ File access test - Failed"
|
echo "✗ File access test - Failed"
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ else
|
|||||||
ACTUAL_USER=$(whoami)
|
ACTUAL_USER=$(whoami)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Get the home directory of the actual user
|
||||||
|
USER_HOME=$(eval echo ~$ACTUAL_USER)
|
||||||
|
|
||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
|
||||||
@@ -31,8 +34,7 @@ echo "Generating service file with dynamic paths..."
|
|||||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LED Matrix Web Interface Service
|
Description=LED Matrix Web Interface Service
|
||||||
After=network-online.target
|
After=network.target
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
@@ -340,13 +340,8 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Execute with proper error handling and non-interactive mode
|
# Execute with proper error handling and non-interactive mode
|
||||||
# Temporarily disable errexit AND the ERR trap to capture exit code instead of
|
# Temporarily disable errexit to capture exit code instead of exiting immediately
|
||||||
# exiting immediately. `set +e` alone does not suppress the ERR trap, so without
|
|
||||||
# `trap '' ERR` a non-zero exit from first_time_install.sh would trigger on_error
|
|
||||||
# here with the generic "Main installation" message instead of the detailed
|
|
||||||
# if/else handling below.
|
|
||||||
set +e
|
set +e
|
||||||
trap '' ERR
|
|
||||||
|
|
||||||
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
|
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
|
||||||
# When running manually, /tmp usually has correct permissions (1777)
|
# When running manually, /tmp usually has correct permissions (1777)
|
||||||
@@ -375,7 +370,6 @@ main() {
|
|||||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
|
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
|
||||||
fi
|
fi
|
||||||
INSTALL_EXIT_CODE=$?
|
INSTALL_EXIT_CODE=$?
|
||||||
trap 'on_error $LINENO' ERR # Re-enable ERR trap
|
|
||||||
set -e # Re-enable errexit
|
set -e # Re-enable errexit
|
||||||
|
|
||||||
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
||||||
|
|||||||
@@ -6,143 +6,82 @@ then falls back to pip with --break-system-packages
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
import warnings
|
import warnings
|
||||||
from collections import deque
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple
|
|
||||||
|
|
||||||
# How many trailing lines of a failed command's output to keep for the
|
def install_via_apt(package_name):
|
||||||
# end-of-run failure summary. Keeps the root cause near the end of the log,
|
"""Try to install a package via apt."""
|
||||||
# which is where first_time_install.sh's error handler tails from.
|
try:
|
||||||
ERROR_TAIL_LINES = 15
|
# Map pip package names to apt package names
|
||||||
|
apt_package_map = {
|
||||||
|
'flask': 'python3-flask',
|
||||||
|
'PIL': 'python3-pil',
|
||||||
|
'freetype': 'python3-freetype',
|
||||||
|
'psutil': 'python3-psutil',
|
||||||
|
'werkzeug': 'python3-werkzeug',
|
||||||
|
'numpy': 'python3-numpy',
|
||||||
|
'requests': 'python3-requests',
|
||||||
|
'python-dateutil': 'python3-dateutil',
|
||||||
|
'pytz': 'python3-tz',
|
||||||
|
'geopy': 'python3-geopy',
|
||||||
|
'unidecode': 'python3-unidecode',
|
||||||
|
'websockets': 'python3-websockets',
|
||||||
|
'websocket-client': 'python3-websocket-client'
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||||
|
|
||||||
def _run(cmd: List[str]) -> Tuple[bool, str]:
|
print(f"Trying to install {apt_package} via apt...")
|
||||||
"""Run a command, streaming combined stdout/stderr to a temp file.
|
subprocess.check_call([
|
||||||
|
'sudo', 'apt', 'update'
|
||||||
|
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
Returns (success, output) instead of raising, so callers can report
|
subprocess.check_call([
|
||||||
*why* a command failed rather than just that it failed. `output` is
|
'sudo', 'apt', 'install', '-y', apt_package
|
||||||
bounded to the last ERROR_TAIL_LINES lines so failures from very
|
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
chatty commands (e.g. pip build logs) don't get buffered in memory.
|
|
||||||
"""
|
|
||||||
with tempfile.TemporaryFile(mode='w+b') as f:
|
|
||||||
result = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT) # nosec B603 B607 - hardcoded apt/pip args # nosemgrep
|
|
||||||
f.seek(0)
|
|
||||||
# Stream line-by-line so only the last ERROR_TAIL_LINES are ever held
|
|
||||||
# in memory, regardless of how much output the command produced.
|
|
||||||
tail = deque(
|
|
||||||
(line.decode('utf-8', errors='replace').rstrip('\n') for line in f),
|
|
||||||
maxlen=ERROR_TAIL_LINES,
|
|
||||||
)
|
|
||||||
return result.returncode == 0, '\n'.join(tail)
|
|
||||||
|
|
||||||
|
|
||||||
def install_via_apt(package_name: str) -> Tuple[bool, str]:
|
|
||||||
"""Try to install a package via apt. Returns (success, output)."""
|
|
||||||
# Map pip package names to apt package names
|
|
||||||
apt_package_map = {
|
|
||||||
'flask': 'python3-flask',
|
|
||||||
'PIL': 'python3-pil',
|
|
||||||
'freetype': 'python3-freetype',
|
|
||||||
'psutil': 'python3-psutil',
|
|
||||||
'werkzeug': 'python3-werkzeug',
|
|
||||||
'numpy': 'python3-numpy',
|
|
||||||
'requests': 'python3-requests',
|
|
||||||
'python-dateutil': 'python3-dateutil',
|
|
||||||
'pytz': 'python3-tz',
|
|
||||||
'geopy': 'python3-geopy',
|
|
||||||
'unidecode': 'python3-unidecode',
|
|
||||||
'websockets': 'python3-websockets',
|
|
||||||
'websocket-client': 'python3-websocket-client'
|
|
||||||
}
|
|
||||||
|
|
||||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
|
||||||
|
|
||||||
print(f"Trying to install {apt_package} via apt...")
|
|
||||||
success, output = _run(['sudo', 'apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', apt_package])
|
|
||||||
if success:
|
|
||||||
print(f"Successfully installed {apt_package} via apt")
|
print(f"Successfully installed {apt_package} via apt")
|
||||||
return True, ""
|
return True
|
||||||
|
|
||||||
print(f"Failed to install {apt_package} via apt, will try pip")
|
except subprocess.CalledProcessError:
|
||||||
return False, output
|
print(f"Failed to install {package_name} via apt, will try pip")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def install_via_pip(package_name):
|
||||||
def install_via_pip(package_name: str) -> Tuple[bool, str]:
|
|
||||||
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
||||||
|
|
||||||
--break-system-packages allows pip to install into the system Python on
|
--break-system-packages allows pip to install into the system Python on
|
||||||
Debian/Ubuntu-based systems without a virtual environment.
|
Debian/Ubuntu-based systems without a virtual environment.
|
||||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||||
exhausting /tmp space during compilation.
|
exhausting /tmp space during compilation.
|
||||||
--ignore-installed stops pip from trying to *uninstall* packages that were
|
|
||||||
installed by apt (e.g. python3-requests). Those Debian packages ship no
|
|
||||||
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
|
|
||||||
and aborts the whole install. With --ignore-installed, pip lays the new
|
|
||||||
version down in /usr/local where it shadows the apt copy instead of removing
|
|
||||||
it. This matters when a pip dependency (google-api-python-client pulls a
|
|
||||||
newer requests) needs to upgrade an apt-managed package.
|
|
||||||
|
|
||||||
Returns (success, output).
|
|
||||||
"""
|
"""
|
||||||
print(f"Installing {package_name} via pip...")
|
try:
|
||||||
success, output = _run([
|
print(f"Installing {package_name} via pip...")
|
||||||
sys.executable, '-m', 'pip', 'install',
|
subprocess.check_call([
|
||||||
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
|
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
||||||
])
|
])
|
||||||
if success:
|
|
||||||
print(f"Successfully installed {package_name} via pip")
|
print(f"Successfully installed {package_name} via pip")
|
||||||
return True, ""
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Failed to install {package_name} via pip: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
print(f"Failed to install {package_name} via pip (see failure summary at end of log)")
|
def check_package_installed(package_name):
|
||||||
return False, output
|
|
||||||
|
|
||||||
|
|
||||||
# Distribution (pip/apt) names whose importable module name differs.
|
|
||||||
IMPORT_NAME_MAP = {
|
|
||||||
'python-dateutil': 'dateutil',
|
|
||||||
'websocket-client': 'websocket',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def check_package_installed(package_name: str) -> bool:
|
|
||||||
"""Check if a package is already installed."""
|
"""Check if a package is already installed."""
|
||||||
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
|
|
||||||
# Suppress deprecation warnings when checking if packages are installed
|
# Suppress deprecation warnings when checking if packages are installed
|
||||||
# (we're just checking, not using them)
|
# (we're just checking, not using them)
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||||
try:
|
try:
|
||||||
__import__(import_name)
|
__import__(package_name)
|
||||||
return True
|
return True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
|
|
||||||
print("=" * 60)
|
|
||||||
for package in failed_packages:
|
|
||||||
print(f"\nPackage: {package}")
|
|
||||||
print("-" * 40)
|
|
||||||
output = failure_details.get(package, "").strip()
|
|
||||||
if not output:
|
|
||||||
print(" (no output captured)")
|
|
||||||
continue
|
|
||||||
for line in output.splitlines()[-ERROR_TAIL_LINES:]:
|
|
||||||
print(f" {line}")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main installation function."""
|
"""Main installation function."""
|
||||||
print("Installing dependencies for LED Matrix Web Interface V2...")
|
print("Installing dependencies for LED Matrix Web Interface V2...")
|
||||||
|
|
||||||
print("Refreshing apt package index...")
|
|
||||||
_run(['sudo', 'apt', 'update']) # best-effort; individual installs surface their own errors
|
|
||||||
|
|
||||||
# List of required packages
|
# List of required packages
|
||||||
required_packages = [
|
required_packages = [
|
||||||
'flask',
|
'flask',
|
||||||
@@ -161,7 +100,6 @@ def main():
|
|||||||
]
|
]
|
||||||
|
|
||||||
failed_packages = []
|
failed_packages = []
|
||||||
failure_details = {}
|
|
||||||
|
|
||||||
for package in required_packages:
|
for package in required_packages:
|
||||||
if check_package_installed(package):
|
if check_package_installed(package):
|
||||||
@@ -169,12 +107,9 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Try apt first, then pip
|
# Try apt first, then pip
|
||||||
ok, apt_output = install_via_apt(package)
|
if not install_via_apt(package):
|
||||||
if not ok:
|
if not install_via_pip(package):
|
||||||
ok, pip_output = install_via_pip(package)
|
|
||||||
if not ok:
|
|
||||||
failed_packages.append(package)
|
failed_packages.append(package)
|
||||||
failure_details[package] = pip_output or apt_output
|
|
||||||
|
|
||||||
# Install packages that don't have apt equivalents
|
# Install packages that don't have apt equivalents
|
||||||
special_packages = [
|
special_packages = [
|
||||||
@@ -189,10 +124,8 @@ def main():
|
|||||||
]
|
]
|
||||||
|
|
||||||
for package in special_packages:
|
for package in special_packages:
|
||||||
ok, pip_output = install_via_pip(package)
|
if not install_via_pip(package):
|
||||||
if not ok:
|
|
||||||
failed_packages.append(package)
|
failed_packages.append(package)
|
||||||
failure_details[package] = pip_output
|
|
||||||
|
|
||||||
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
|
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
|
||||||
# Check if already installed first
|
# Check if already installed first
|
||||||
@@ -200,36 +133,36 @@ def main():
|
|||||||
print("rgbmatrix module already installed, skipping...")
|
print("rgbmatrix module already installed, skipping...")
|
||||||
else:
|
else:
|
||||||
print("Installing rgbmatrix module from local source...")
|
print("Installing rgbmatrix module from local source...")
|
||||||
# Get project root (parent of scripts directory)
|
try:
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
# Get project root (parent of scripts directory)
|
||||||
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
if rgbmatrix_path.exists():
|
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
||||||
# Check if the module has been built (look for setup.py)
|
if rgbmatrix_path.exists():
|
||||||
setup_py = rgbmatrix_path / 'setup.py'
|
# Check if the module has been built (look for setup.py)
|
||||||
if setup_py.exists():
|
setup_py = rgbmatrix_path / 'setup.py'
|
||||||
# Try installing - use regular install, not editable mode
|
if setup_py.exists():
|
||||||
# This is optional for web interface and should already be installed in Step 6
|
# Try installing - use regular install, not editable mode
|
||||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
|
# This is optional for web interface and should already be installed in Step 6
|
||||||
if ok:
|
subprocess.check_call([
|
||||||
|
sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)
|
||||||
|
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
print("rgbmatrix module installed successfully")
|
print("rgbmatrix module installed successfully")
|
||||||
else:
|
else:
|
||||||
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
||||||
# and should be installed in Step 6 of first_time_install.sh
|
print(" This is normal if Step 6 hasn't completed yet.")
|
||||||
print("Warning: Failed to install rgbmatrix module:")
|
|
||||||
for line in output.strip().splitlines()[-ERROR_TAIL_LINES:]:
|
|
||||||
print(f" {line}")
|
|
||||||
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
|
||||||
print(" The web interface will work without it.")
|
|
||||||
else:
|
else:
|
||||||
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
||||||
print(" This is normal if Step 6 hasn't completed yet.")
|
except subprocess.CalledProcessError as e:
|
||||||
else:
|
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
||||||
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
# and should be installed in Step 6 of first_time_install.sh
|
||||||
|
print(f"Warning: Failed to install rgbmatrix module: {e}")
|
||||||
|
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
||||||
|
print(" The web interface will work without it.")
|
||||||
|
# Don't add to failed_packages since it's optional
|
||||||
|
|
||||||
if failed_packages:
|
if failed_packages:
|
||||||
print(f"\nFailed to install the following packages: {failed_packages}")
|
print(f"\nFailed to install the following packages: {failed_packages}")
|
||||||
print("You may need to install them manually or check your system configuration.")
|
print("You may need to install them manually or check your system configuration.")
|
||||||
print_failure_summary(failed_packages, failure_details)
|
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
print("\nAll dependencies installed successfully!")
|
print("\nAll dependencies installed successfully!")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, Sequence, Union
|
||||||
|
|
||||||
# Add project root to path
|
# Add project root to path
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -27,15 +28,49 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
|
|
||||||
# Import logger after path setup so src.logging_config is importable
|
# Import logger after path setup so src.logging_config is importable
|
||||||
from src.logging_config import get_logger # noqa: E402
|
from src.logging_config import get_logger # noqa: E402
|
||||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
|
||||||
find_plugin_dir, load_manifest, load_config_defaults,
|
|
||||||
)
|
|
||||||
logger = get_logger("[Render Plugin]")
|
logger = get_logger("[Render Plugin]")
|
||||||
|
|
||||||
MIN_DIMENSION = 1
|
MIN_DIMENSION = 1
|
||||||
MAX_DIMENSION = 512
|
MAX_DIMENSION = 512
|
||||||
|
|
||||||
|
|
||||||
|
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||||
|
"""Find a plugin directory by searching multiple paths."""
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
loader = PluginLoader()
|
||||||
|
for search_dir in search_dirs:
|
||||||
|
search_path = Path(search_dir)
|
||||||
|
if not search_path.exists():
|
||||||
|
continue
|
||||||
|
result = loader.find_plugin_directory(plugin_id, search_path)
|
||||||
|
if result:
|
||||||
|
return Path(result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
|
||||||
|
"""Load and return manifest.json from plugin directory."""
|
||||||
|
manifest_path = plugin_dir / 'manifest.json'
|
||||||
|
if not manifest_path.exists():
|
||||||
|
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||||
|
with open(manifest_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
|
||||||
|
"""Extract default values from config_schema.json."""
|
||||||
|
schema_path = plugin_dir / 'config_schema.json'
|
||||||
|
if not schema_path.exists():
|
||||||
|
return {}
|
||||||
|
with open(schema_path, 'r') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
defaults: Dict[str, Any] = {}
|
||||||
|
for key, prop in schema.get('properties', {}).items():
|
||||||
|
if 'default' in prop:
|
||||||
|
defaults[key] = prop['default']
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
||||||
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
||||||
@@ -46,7 +81,7 @@ def main() -> int:
|
|||||||
help='Plugin config as JSON string')
|
help='Plugin config as JSON string')
|
||||||
parser.add_argument('--mock-data', '-m', default=None,
|
parser.add_argument('--mock-data', '-m', default=None,
|
||||||
help='Path to JSON file with mock cache data')
|
help='Path to JSON file with mock cache data')
|
||||||
parser.add_argument('--output', '-o', default='/tmp/plugin_render.png', # nosec B108 - dev script default; user can override
|
parser.add_argument('--output', '-o', default='/tmp/plugin_render.png',
|
||||||
help='Output PNG path (default: /tmp/plugin_render.png)')
|
help='Output PNG path (default: /tmp/plugin_render.png)')
|
||||||
parser.add_argument('--width', type=int, default=128, help='Display width (default: 128)')
|
parser.add_argument('--width', type=int, default=128, help='Display width (default: 128)')
|
||||||
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ Supports both unittest and pytest.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
import argparse
|
import argparse
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -196,14 +198,17 @@ def main():
|
|||||||
if runner == 'auto':
|
if runner == 'auto':
|
||||||
# Try pytest first, fall back to unittest
|
# Try pytest first, fall back to unittest
|
||||||
try:
|
try:
|
||||||
|
import pytest
|
||||||
runner = 'pytest'
|
runner = 'pytest'
|
||||||
except ImportError:
|
except ImportError:
|
||||||
runner = 'unittest'
|
runner = 'unittest'
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
if runner == 'pytest':
|
if runner == 'pytest':
|
||||||
|
import importlib.util
|
||||||
return run_pytest_tests(test_files, args.verbose, args.coverage)
|
return run_pytest_tests(test_files, args.verbose, args.coverage)
|
||||||
else:
|
else:
|
||||||
|
import importlib.util
|
||||||
return run_unittest_tests(test_files, args.verbose)
|
return run_unittest_tests(test_files, args.verbose)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ This script allows manual clearing of specific cache keys or all cache data.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# Add the src directory to the path so we can import our modules
|
# Add the src directory to the path so we can import our modules
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ def main():
|
|||||||
# Ensure PYTHONPATH is set correctly if web_interface.py has relative imports to src
|
# Ensure PYTHONPATH is set correctly if web_interface.py has relative imports to src
|
||||||
# The WorkingDirectory in systemd service should handle this for web_interface.py
|
# The WorkingDirectory in systemd service should handle this for web_interface.py
|
||||||
print(f"Launching web interface v3: {sys.executable} {WEB_INTERFACE_SCRIPT}")
|
print(f"Launching web interface v3: {sys.executable} {WEB_INTERFACE_SCRIPT}")
|
||||||
os.execvp(sys.executable, [sys.executable, WEB_INTERFACE_SCRIPT]) # nosec B606 - both args are fixed constants
|
os.execvp(sys.executable, [sys.executable, WEB_INTERFACE_SCRIPT])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to exec web interface: {e}")
|
print(f"Failed to exec web interface: {e}")
|
||||||
sys.exit(1) # Failed to start
|
sys.exit(1) # Failed to start
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class WiFiMonitorDaemon:
|
|||||||
logger.error(f"NetworkManager restart failed (rc={e.returncode}); "
|
logger.error(f"NetworkManager restart failed (rc={e.returncode}); "
|
||||||
"resetting failure counter to avoid tight retry loop")
|
"resetting failure counter to avoid tight retry loop")
|
||||||
self._consecutive_internet_failures = 0
|
self._consecutive_internet_failures = 0
|
||||||
except (subprocess.SubprocessError, OSError) as e:
|
except Exception as e:
|
||||||
logger.error(f"NetworkManager restart error: {e}; "
|
logger.error(f"NetworkManager restart error: {e}; "
|
||||||
"resetting failure counter to avoid tight retry loop")
|
"resetting failure counter to avoid tight retry loop")
|
||||||
self._consecutive_internet_failures = 0
|
self._consecutive_internet_failures = 0
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ where Recent/Upcoming managers consume data from the background service cache.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
from typing import Dict, Optional, Any, Callable
|
from typing import Dict, Optional, Any, Callable
|
||||||
|
from datetime import datetime
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
class BackgroundCacheMixin:
|
class BackgroundCacheMixin:
|
||||||
|
|||||||
@@ -14,15 +14,19 @@ Key Features:
|
|||||||
- Memory-efficient data storage
|
- Memory-efficient data storage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any, Optional, Callable
|
from typing import Dict, Any, Optional, List, Callable, Union
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
import json
|
||||||
import queue
|
import queue
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor, Future
|
||||||
|
import weakref
|
||||||
from src.cache_manager import CacheManager
|
from src.cache_manager import CacheManager
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -223,7 +227,7 @@ class BackgroundDataService:
|
|||||||
self.stats['cache_misses'] += 1
|
self.stats['cache_misses'] += 1
|
||||||
|
|
||||||
# Submit to executor
|
# Submit to executor
|
||||||
self.executor.submit(self._fetch_data_worker, request)
|
future = self.executor.submit(self._fetch_data_worker, request)
|
||||||
|
|
||||||
logger.info(f"Submitted background fetch request {request_id} for {sport} {year}")
|
logger.info(f"Submitted background fetch request {request_id} for {sport} {year}")
|
||||||
return request_id
|
return request_id
|
||||||
@@ -549,12 +553,13 @@ class BackgroundDataService:
|
|||||||
if to_remove:
|
if to_remove:
|
||||||
logger.info(f"Cleared {len(to_remove)} old completed requests")
|
logger.info(f"Cleared {len(to_remove)} old completed requests")
|
||||||
|
|
||||||
def shutdown(self, wait: bool = True):
|
def shutdown(self, wait: bool = True, timeout: int = 30):
|
||||||
"""
|
"""
|
||||||
Shutdown the background data service.
|
Shutdown the background data service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
wait: Whether to wait for active requests to complete
|
wait: Whether to wait for active requests to complete
|
||||||
|
timeout: Maximum time to wait for shutdown
|
||||||
"""
|
"""
|
||||||
logger.info("Shutting down BackgroundDataService...")
|
logger.info("Shutting down BackgroundDataService...")
|
||||||
|
|
||||||
@@ -565,14 +570,24 @@ class BackgroundDataService:
|
|||||||
for request_id in list(self.active_requests.keys()):
|
for request_id in list(self.active_requests.keys()):
|
||||||
self.cancel_request(request_id)
|
self.cancel_request(request_id)
|
||||||
|
|
||||||
self.executor.shutdown(wait=wait)
|
# Shutdown executor with compatibility for older Python versions
|
||||||
|
try:
|
||||||
|
# Try with timeout parameter (Python 3.9+)
|
||||||
|
self.executor.shutdown(wait=wait, timeout=timeout)
|
||||||
|
except TypeError:
|
||||||
|
# Fallback for older Python versions that don't support timeout
|
||||||
|
if wait and timeout:
|
||||||
|
# For older versions, we can't specify timeout, so just wait
|
||||||
|
self.executor.shutdown(wait=True)
|
||||||
|
else:
|
||||||
|
self.executor.shutdown(wait=wait)
|
||||||
|
|
||||||
logger.info("BackgroundDataService shutdown complete")
|
logger.info("BackgroundDataService shutdown complete")
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
"""Cleanup when service is destroyed."""
|
"""Cleanup when service is destroyed."""
|
||||||
if not self._shutdown:
|
if not self._shutdown:
|
||||||
self.shutdown(wait=False)
|
self.shutdown(wait=False, timeout=None)
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
_background_service: Optional[BackgroundDataService] = None
|
_background_service: Optional[BackgroundDataService] = None
|
||||||
|
|||||||
@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
try:
|
try:
|
||||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||||
manifest = json.loads(manifest_raw)
|
manifest = json.loads(manifest_raw)
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||||
return False, "Invalid manifest.json", {}
|
return False, f"Invalid manifest.json: {e}", {}
|
||||||
|
|
||||||
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
||||||
return False, "Invalid manifest structure", {}
|
return False, "Invalid manifest structure", {}
|
||||||
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
return True, "", result_manifest
|
return True, "", result_manifest
|
||||||
except zipfile.BadZipFile:
|
except zipfile.BadZipFile:
|
||||||
return False, "File is not a valid ZIP archive", {}
|
return False, "File is not a valid ZIP archive", {}
|
||||||
except OSError:
|
except OSError as e:
|
||||||
return False, "Could not read backup", {}
|
return False, f"Could not read backup: {e}", {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ fields and data structures.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Any, Optional, List
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import pytz
|
import pytz
|
||||||
@@ -21,10 +21,12 @@ class APIDataExtractor(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||||
"""Extract common game details from raw API data."""
|
"""Extract common game details from raw API data."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
||||||
"""Extract sport-specific fields (downs, innings, periods, etc.)."""
|
"""Extract sport-specific fields (downs, innings, periods, etc.)."""
|
||||||
|
pass
|
||||||
|
|
||||||
def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
|
def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
|
||||||
"""Extract common game details that work across all sports."""
|
"""Extract common game details that work across all sports."""
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ class Baseball(SportsCore):
|
|||||||
return
|
return
|
||||||
|
|
||||||
series_summary = game.get("series_summary", "")
|
series_summary = game.get("series_summary", "")
|
||||||
|
font = self.fonts.get('detail', ImageFont.load_default())
|
||||||
bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time'])
|
bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time'])
|
||||||
height = bbox[3] - bbox[1]
|
height = bbox[3] - bbox[1]
|
||||||
shots_y = (self.display_height - height) // 2
|
shots_y = (self.display_height - height) // 2
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ to support different APIs and data providers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, List
|
from typing import Dict, Any, Optional, List
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
import time
|
||||||
|
|
||||||
class DataSource(ABC):
|
class DataSource(ABC):
|
||||||
"""Abstract base class for data sources."""
|
"""Abstract base class for data sources."""
|
||||||
@@ -34,14 +35,17 @@ class DataSource(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
|
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
|
||||||
"""Fetch live games for a sport/league."""
|
"""Fetch live games for a sport/league."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
|
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
|
||||||
"""Fetch schedule for a sport/league within date range."""
|
"""Fetch schedule for a sport/league within date range."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
def fetch_standings(self, sport: str, league: str) -> Dict:
|
||||||
"""Fetch standings for a sport/league."""
|
"""Fetch standings for a sport/league."""
|
||||||
|
pass
|
||||||
|
|
||||||
def get_headers(self) -> Dict[str, str]:
|
def get_headers(self) -> Dict[str, str]:
|
||||||
"""Get headers for API requests."""
|
"""Get headers for API requests."""
|
||||||
@@ -213,7 +217,7 @@ class MLBAPIDataSource(DataSource):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.logger.debug("Fetched standings from MLB API")
|
self.logger.debug(f"Fetched standings from MLB API")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -292,7 +296,7 @@ class SoccerAPIDataSource(DataSource):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.logger.debug("Fetched standings from soccer API")
|
self.logger.debug(f"Fetched standings from soccer API")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional, List
|
||||||
from src.display_manager import DisplayManager
|
from src.display_manager import DisplayManager
|
||||||
from src.cache_manager import CacheManager
|
from src.cache_manager import CacheManager
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
import logging
|
import logging
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import time
|
||||||
from src.base_classes.data_sources import ESPNDataSource
|
from src.base_classes.data_sources import ESPNDataSource
|
||||||
from src.base_classes.sports import SportsCore, SportsLive
|
from src.base_classes.sports import SportsCore, SportsLive
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
@@ -77,6 +79,8 @@ class Hockey(SportsCore):
|
|||||||
away_shots = round(home_team_saves / home_team_saves_per)
|
away_shots = round(home_team_saves / home_team_saves_per)
|
||||||
if away_team_saves_per > 0:
|
if away_team_saves_per > 0:
|
||||||
home_shots = round(away_team_saves / away_team_saves_per)
|
home_shots = round(away_team_saves / away_team_saves_per)
|
||||||
|
status_short = status["type"].get("shortDetail", "")
|
||||||
|
|
||||||
if situation and status["type"]["state"] == "in":
|
if situation and status["type"]["state"] == "in":
|
||||||
# Detect scoring events from status detail
|
# Detect scoring events from status detail
|
||||||
# status_detail = status["type"].get("detail", "")
|
# status_detail = status["type"].get("detail", "")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import time
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
@@ -172,8 +172,8 @@ class SportsCore(ABC):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key)
|
fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key)
|
||||||
except RuntimeError as e:
|
except Exception:
|
||||||
self.logger.debug("Could not resolve home directory (expected for service users): %s", e)
|
pass
|
||||||
|
|
||||||
fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key)
|
fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key)
|
||||||
|
|
||||||
@@ -416,6 +416,7 @@ class SportsCore(ABC):
|
|||||||
league=self.league,
|
league=self.league,
|
||||||
event_id=game['id'],
|
event_id=game['id'],
|
||||||
update_interval_seconds=update_interval,
|
update_interval_seconds=update_interval,
|
||||||
|
is_live=is_live
|
||||||
)
|
)
|
||||||
|
|
||||||
if odds_data:
|
if odds_data:
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ Follows LEDMatrix configuration management patterns:
|
|||||||
- Maintainable: Changes to odds logic affect all plugins
|
- Maintainable: Changes to odds logic affect all plugins
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
class BaseOddsManager:
|
class BaseOddsManager:
|
||||||
|
|||||||
@@ -194,20 +194,18 @@ class CacheStrategy:
|
|||||||
"""
|
"""
|
||||||
key_lower = key.lower()
|
key_lower = key.lower()
|
||||||
|
|
||||||
# Odds data — checked before the generic 'live' block below because
|
# Odds data — checked FIRST because odds keys may also contain 'live'/'current'
|
||||||
# live-odds cache keys (e.g. odds_espn_basketball_nba_<id>_live) contain
|
# (e.g. odds_espn_nba_game_123_live). The odds TTL (120s for live, 1800s for
|
||||||
# both 'odds' AND 'live'. Without this ordering the 'live' check below
|
# upcoming) must win over the generic sports_live TTL (30s) to avoid hitting
|
||||||
# would match first and return 'sports_live' (30 s TTL) instead of the
|
# the ESPN odds API every 30 seconds per game.
|
||||||
# correct 'odds_live' (120 s TTL).
|
|
||||||
if 'odds' in key_lower:
|
if 'odds' in key_lower:
|
||||||
|
# For live games, use shorter cache; for upcoming games, use longer cache
|
||||||
if any(x in key_lower for x in ['live', 'current']):
|
if any(x in key_lower for x in ['live', 'current']):
|
||||||
return 'odds_live' # Live odds change more frequently
|
return 'odds_live' # Live odds change more frequently (120s TTL)
|
||||||
return 'odds' # Regular odds for upcoming games
|
return 'odds' # Regular odds for upcoming games (1800s TTL)
|
||||||
|
|
||||||
# Live sports data
|
# Live sports data (only reached if key does NOT contain 'odds')
|
||||||
if any(x in key_lower for x in ['live', 'current', 'scoreboard']):
|
if any(x in key_lower for x in ['live', 'current', 'scoreboard']):
|
||||||
if 'soccer' in key_lower:
|
|
||||||
return 'sports_live' # Soccer live data is very time-sensitive
|
|
||||||
return 'sports_live'
|
return 'sports_live'
|
||||||
|
|
||||||
# Weather data
|
# Weather data
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import threading
|
|||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from src.exceptions import CacheError
|
||||||
|
|
||||||
|
|
||||||
class CacheStrategyProtocol(Protocol):
|
class CacheStrategyProtocol(Protocol):
|
||||||
@@ -68,14 +69,13 @@ class DiskCache:
|
|||||||
return None
|
return None
|
||||||
return os.path.join(self.cache_dir, f"{key}.json")
|
return os.path.join(self.cache_dir, f"{key}.json")
|
||||||
|
|
||||||
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get data from disk cache.
|
Get data from disk cache.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
max_age: Maximum age in seconds; None disables age-based expiry
|
max_age: Maximum age in seconds
|
||||||
(the record never counts as stale). Mirrors MemoryCache.get.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cached data or None if not found or expired
|
Cached data or None if not found or expired
|
||||||
@@ -106,13 +106,7 @@ class DiskCache:
|
|||||||
record_ts = None
|
record_ts = None
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
# max_age=None means "never expires" (mirrors MemoryCache and the
|
if record_ts is None or (now - record_ts) <= max_age:
|
||||||
# cache_manager docstring). Guard it explicitly — otherwise the
|
|
||||||
# comparison below raises TypeError and the record is treated as a
|
|
||||||
# miss, which silently breaks callers that persist long-lived state
|
|
||||||
# via get(key, max_age=None) (e.g. plugin health/metrics that must
|
|
||||||
# survive restarts and be read cross-process).
|
|
||||||
if record_ts is None or max_age is None or (now - record_ts) <= max_age:
|
|
||||||
return record
|
return record
|
||||||
else:
|
else:
|
||||||
# Stale on disk; keep file for potential diagnostics but treat as miss
|
# Stale on disk; keep file for potential diagnostics but treat as miss
|
||||||
@@ -190,7 +184,7 @@ class DiskCache:
|
|||||||
os.replace(tmp_path, cache_path)
|
os.replace(tmp_path, cache_path)
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
try:
|
try:
|
||||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
os.chmod(cache_path, 0o660)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Non-critical if chmod fails
|
pass # Non-critical if chmod fails
|
||||||
finally:
|
finally:
|
||||||
@@ -208,7 +202,7 @@ class DiskCache:
|
|||||||
os.fsync(cache_file.fileno())
|
os.fsync(cache_file.fileno())
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
try:
|
try:
|
||||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
os.chmod(cache_path, 0o660)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Non-critical if chmod fails
|
pass # Non-critical if chmod fails
|
||||||
self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
|
self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
|
||||||
@@ -216,7 +210,7 @@ class DiskCache:
|
|||||||
# If direct write also fails, try fallback location
|
# If direct write also fails, try fallback location
|
||||||
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
||||||
raise # Re-raise to trigger fallback logic
|
raise # Re-raise to trigger fallback logic
|
||||||
except (IOError, OSError, PermissionError):
|
except (IOError, OSError, PermissionError) as e:
|
||||||
# Attempt one-time fallback write to user's home cache directory
|
# Attempt one-time fallback write to user's home cache directory
|
||||||
try:
|
try:
|
||||||
# Try user's home cache directory as fallback
|
# Try user's home cache directory as fallback
|
||||||
@@ -234,7 +228,7 @@ class DiskCache:
|
|||||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
try:
|
try:
|
||||||
os.chmod(fallback_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
os.chmod(fallback_path, 0o660)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Non-critical if chmod fails
|
pass # Non-critical if chmod fails
|
||||||
self.logger.debug("Cache wrote to fallback location: %s", fallback_path)
|
self.logger.debug("Cache wrote to fallback location: %s", fallback_path)
|
||||||
|
|||||||
@@ -1,28 +1,3 @@
|
|||||||
"""
|
|
||||||
Cache Manager — multi-tier response cache for the LEDMatrix application.
|
|
||||||
|
|
||||||
:class:`CacheManager` provides a unified caching layer used by all plugins
|
|
||||||
to reduce external API calls and survive network outages gracefully.
|
|
||||||
|
|
||||||
Two storage tiers
|
|
||||||
-----------------
|
|
||||||
* **Memory tier** (:class:`~src.cache.memory_cache.MemoryCache`): fast LRU
|
|
||||||
cache (up to 1 000 entries by default). Hit on this tier before touching
|
|
||||||
disk.
|
|
||||||
* **Disk tier** (:class:`~src.cache.disk_cache.DiskCache`): filesystem-backed
|
|
||||||
persistent store that survives process restarts.
|
|
||||||
|
|
||||||
Data written to cache is serialised as JSON. :class:`DateTimeEncoder` handles
|
|
||||||
``datetime`` objects transparently so callers don't have to pre-serialise them.
|
|
||||||
|
|
||||||
Typical plugin usage::
|
|
||||||
|
|
||||||
data = self.cache_manager.get_cached_data('my_key', max_age=300)
|
|
||||||
if data is None:
|
|
||||||
data = fetch_from_api()
|
|
||||||
self.cache_manager.save_cache('my_key', data)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -32,6 +7,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from src.exceptions import CacheError
|
from src.exceptions import CacheError
|
||||||
from src.cache.memory_cache import MemoryCache
|
from src.cache.memory_cache import MemoryCache
|
||||||
from src.cache.disk_cache import DiskCache
|
from src.cache.disk_cache import DiskCache
|
||||||
@@ -40,10 +16,7 @@ from src.cache.cache_metrics import CacheMetrics
|
|||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
class DateTimeEncoder(json.JSONEncoder):
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
|
|
||||||
|
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
|
|
||||||
if isinstance(obj, datetime):
|
if isinstance(obj, datetime):
|
||||||
return obj.isoformat()
|
return obj.isoformat()
|
||||||
return super().default(obj)
|
return super().default(obj)
|
||||||
@@ -138,7 +111,7 @@ class CacheManager:
|
|||||||
if os.access(system_cache_dir, os.W_OK):
|
if os.access(system_cache_dir, os.W_OK):
|
||||||
self.logger.info(f"Using system cache directory: {system_cache_dir}")
|
self.logger.info(f"Using system cache directory: {system_cache_dir}")
|
||||||
return system_cache_dir
|
return system_cache_dir
|
||||||
except (OSError, IOError, PermissionError):
|
except (OSError, IOError, PermissionError) as perm_error:
|
||||||
# Permission errors are expected when running as non-root
|
# Permission errors are expected when running as non-root
|
||||||
self.logger.debug(f"Could not create system cache directory (permission denied): {system_cache_dir}")
|
self.logger.debug(f"Could not create system cache directory (permission denied): {system_cache_dir}")
|
||||||
except (OSError, IOError, PermissionError) as e:
|
except (OSError, IOError, PermissionError) as e:
|
||||||
@@ -574,19 +547,9 @@ class CacheManager:
|
|||||||
}
|
}
|
||||||
return self.save_cache(data_type, cache_data)
|
return self.save_cache(data_type, cache_data)
|
||||||
|
|
||||||
def get(self, key: str, max_age: Optional[int] = 300,
|
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
||||||
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
"""Get data from cache if it exists and is not stale."""
|
||||||
"""Get data from cache if it exists and is not stale.
|
cached_data = self.get_cached_data(key, max_age)
|
||||||
|
|
||||||
Args:
|
|
||||||
key: Cache key
|
|
||||||
max_age: Max age (seconds) for the on-disk entry; None never expires.
|
|
||||||
memory_ttl: Max age (seconds) for the in-memory entry. Pass 0 to
|
|
||||||
bypass the memory tier and force a fresh read from disk — used by
|
|
||||||
cross-process readers that must observe another process's latest
|
|
||||||
write rather than a stale first snapshot. Defaults to max_age.
|
|
||||||
"""
|
|
||||||
cached_data = self.get_cached_data(key, max_age, memory_ttl=memory_ttl)
|
|
||||||
if cached_data and 'data' in cached_data:
|
if cached_data and 'data' in cached_data:
|
||||||
return cached_data['data']
|
return cached_data['data']
|
||||||
return cached_data
|
return cached_data
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins.
|
|||||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, Optional
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ This example shows how to refactor the basketball plugin to use the
|
|||||||
ledmatrix-common package for cleaner, more maintainable code.
|
ledmatrix-common package for cleaner, more maintainable code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
# Import common helpers
|
# Import common helpers
|
||||||
from src.common import (
|
from src.common import (
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def test_utilities(display_width: int, display_height: int):
|
|||||||
print(f"Testing LEDMatrix Common utilities with {display_width}x{display_height} display")
|
print(f"Testing LEDMatrix Common utilities with {display_width}x{display_height} display")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ledmatrix_common import LogoHelper, TextHelper, DisplayHelper, GameHelper, ConfigHelper
|
from ledmatrix_common import LogoHelper, TextHelper, APIHelper, DisplayHelper, GameHelper, ConfigHelper
|
||||||
|
|
||||||
# Test LogoHelper
|
# Test LogoHelper
|
||||||
print("Testing LogoHelper...")
|
print("Testing LogoHelper...")
|
||||||
@@ -63,12 +63,12 @@ def test_utilities(display_width: int, display_height: int):
|
|||||||
|
|
||||||
# Test GameHelper
|
# Test GameHelper
|
||||||
print("Testing GameHelper...")
|
print("Testing GameHelper...")
|
||||||
GameHelper()
|
game_helper = GameHelper()
|
||||||
print("GameHelper initialized")
|
print("GameHelper initialized")
|
||||||
|
|
||||||
# Test ConfigHelper
|
# Test ConfigHelper
|
||||||
print("Testing ConfigHelper...")
|
print("Testing ConfigHelper...")
|
||||||
ConfigHelper()
|
config_helper = ConfigHelper()
|
||||||
print("ConfigHelper initialized")
|
print("ConfigHelper initialized")
|
||||||
|
|
||||||
print("All tests passed!")
|
print("All tests passed!")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
@@ -166,7 +166,8 @@ class DisplayHelper:
|
|||||||
img = self.create_base_image(background_color)
|
img = self.create_base_image(background_color)
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# Start text off-screen to the right
|
# Calculate text position (start off-screen to the right)
|
||||||
|
text_width = draw.textlength(text, font=font)
|
||||||
x_position = self.display_width
|
x_position = self.display_width
|
||||||
|
|
||||||
# Draw text
|
# Draw text
|
||||||
@@ -215,6 +216,7 @@ class DisplayHelper:
|
|||||||
PIL Image with error message
|
PIL Image with error message
|
||||||
"""
|
"""
|
||||||
img = self.create_base_image((50, 0, 0)) # Dark red background
|
img = self.create_base_image((50, 0, 0)) # Dark red background
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# Use default font
|
# Use default font
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
@@ -235,6 +237,8 @@ class DisplayHelper:
|
|||||||
PIL Image with no data message
|
PIL Image with no data message
|
||||||
"""
|
"""
|
||||||
img = self.create_base_image((0, 0, 0))
|
img = self.create_base_image((0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|||||||
@@ -8,37 +8,16 @@ files that need to be accessible by both root service and web user.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
|
|
||||||
# own error output can be logged/displayed without echoing back a private
|
|
||||||
# index URL's embedded basic-auth secret verbatim (e.g. from a
|
|
||||||
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
|
|
||||||
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_url_credentials(text: Optional[str]) -> str:
|
|
||||||
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
|
|
||||||
|
|
||||||
Safe to call on any subprocess output destined for logs: it only ever
|
|
||||||
shortens/replaces the credential substring, never changes the presence
|
|
||||||
or absence of the specific fixed phrases callers check for
|
|
||||||
(e.g. "a password is required"), so it can't affect control flow.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text or ""
|
|
||||||
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
|
|
||||||
|
|
||||||
# System directories that should never have their permissions modified
|
# System directories that should never have their permissions modified
|
||||||
# These directories have special system-level permissions that must be preserved
|
# These directories have special system-level permissions that must be preserved
|
||||||
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
PROTECTED_SYSTEM_DIRECTORIES = {
|
||||||
'/tmp',
|
'/tmp',
|
||||||
'/var/tmp',
|
'/var/tmp',
|
||||||
'/dev',
|
'/dev',
|
||||||
@@ -308,119 +287,3 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
|
|||||||
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
|
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
|
|
||||||
"""
|
|
||||||
Install a requirements.txt file for a plugin (or the project itself).
|
|
||||||
|
|
||||||
Prefers the vetted sudo wrapper (scripts/fix_perms/safe_pip_install.sh) so
|
|
||||||
packages end up visible to root-run ledmatrix.service, not just to
|
|
||||||
whichever non-root user happens to run the calling process (e.g. the web
|
|
||||||
interface). Falls back to installing with the calling process's own
|
|
||||||
interpreter if the wrapper isn't set up yet (the admin hasn't run
|
|
||||||
scripts/install/configure_web_sudo.sh), so dependency installation still
|
|
||||||
does *something* useful rather than hard-failing.
|
|
||||||
|
|
||||||
Always installs with the interpreter that will actually run the code
|
|
||||||
(``sys.executable`` in the fallback path, the wrapper's ``python3`` in the
|
|
||||||
sudo path) rather than a bare ``pip``/``pip3`` off PATH, which can
|
|
||||||
silently resolve to a different Python installation (e.g. system Python
|
|
||||||
vs. a virtualenv) than the one importing the package at runtime.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
req_file: Path to a requirements.txt file
|
|
||||||
timeout: Subprocess timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
subprocess.CompletedProcess from the pip (or wrapper) invocation.
|
|
||||||
Never raises on a non-zero exit; callers should check ``returncode``.
|
|
||||||
``stdout`` is prefixed with an explanatory note when the root wrapper
|
|
||||||
was unavailable and the fallback path was used.
|
|
||||||
"""
|
|
||||||
project_root = Path(__file__).resolve().parent.parent.parent
|
|
||||||
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
|
|
||||||
|
|
||||||
if wrapper.exists():
|
|
||||||
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
|
|
||||||
# be invoked with an explicit, known path rather than relying on the
|
|
||||||
# wrapper's shebang: sudoers matches the exact command line.
|
|
||||||
bash_candidates = []
|
|
||||||
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
|
|
||||||
if candidate and candidate not in bash_candidates:
|
|
||||||
bash_candidates.append(candidate)
|
|
||||||
|
|
||||||
result = None
|
|
||||||
for bash_path in bash_candidates:
|
|
||||||
# bash_path and wrapper are fixed, known-good paths, and
|
|
||||||
# safe_pip_install.sh independently re-validates req_file is an
|
|
||||||
# allowed requirements.txt before installing anything as root.
|
|
||||||
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
|
||||||
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
|
||||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
|
||||||
)
|
|
||||||
# Redact immediately: pip can echo a private index URL's embedded
|
|
||||||
# basic-auth credentials back in its own error/progress output
|
|
||||||
# (e.g. from a requirements.txt --index-url line). Doesn't affect
|
|
||||||
# the fixed-phrase "denied" check below -- those phrases never
|
|
||||||
# overlap with URL syntax.
|
|
||||||
result.stderr = _redact_url_credentials(result.stderr)
|
|
||||||
result.stdout = _redact_url_credentials(result.stdout)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result
|
|
||||||
# Distinguish "sudo rejected this exact command line" (worth
|
|
||||||
# trying the next bash candidate) from "sudo ran it but pip
|
|
||||||
# itself failed" (a real error — stop and surface it).
|
|
||||||
denied = any(
|
|
||||||
phrase in result.stderr
|
|
||||||
for phrase in ("a password is required", "is not allowed to run", "no tty present")
|
|
||||||
)
|
|
||||||
if not denied:
|
|
||||||
# Deliberately don't interpolate req_file or the pip output here:
|
|
||||||
# this log line is scanner-visible, and a static analyzer can't
|
|
||||||
# tell "already redacted above" from "still raw" just by looking
|
|
||||||
# at this call in isolation. The full (redacted) text is still
|
|
||||||
# available to callers via the returned CompletedProcess.
|
|
||||||
logger.warning(
|
|
||||||
"Root pip install failed (rc=%s); see the returned "
|
|
||||||
"CompletedProcess.stderr for details.",
|
|
||||||
result.returncode,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Same reasoning as above: no req_file / pip-output interpolation in
|
|
||||||
# this log line, only in the returned note/CompletedProcess.
|
|
||||||
logger.warning(
|
|
||||||
"Root pip install wrapper denied via sudo for all candidates; "
|
|
||||||
"falling back to user-level install. See the returned "
|
|
||||||
"CompletedProcess.stderr for details."
|
|
||||||
)
|
|
||||||
note = (
|
|
||||||
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
|
|
||||||
"installed for the current process's user only. Packages may not be "
|
|
||||||
"visible to ledmatrix.service if it runs as a different user — "
|
|
||||||
"run scripts/install/configure_web_sudo.sh to fix this.]\n"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"safe_pip_install.sh not found; falling back to user-level install."
|
|
||||||
)
|
|
||||||
note = (
|
|
||||||
"[safe_pip_install.sh not found; installed for the current process's "
|
|
||||||
"user only. Run scripts/install/configure_web_sudo.sh to enable "
|
|
||||||
"root installs visible to ledmatrix.service.]\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# sys.executable is this process's own interpreter (not
|
|
||||||
# attacker-influenced), and req_file is a Path built internally by callers
|
|
||||||
# (store_manager.py plugin paths, PROJECT_ROOT/requirements.txt), never
|
|
||||||
# raw external/user input. --ignore-installed matches safe_pip_install.sh:
|
|
||||||
# apt-managed packages (e.g. python3-requests) ship no pip RECORD file, so
|
|
||||||
# upgrading them would otherwise abort with "uninstall-no-record-file".
|
|
||||||
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
|
||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
|
|
||||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
|
||||||
)
|
|
||||||
result.stderr = _redact_url_credentials(result.stderr)
|
|
||||||
result.stdout = note + _redact_url_credentials(result.stdout)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|||||||
@@ -347,40 +347,34 @@ class ScrollHelper:
|
|||||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||||
|
|
||||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||||
"""Fast integer pixel extraction (no interpolation).
|
"""Fast integer pixel extraction (no interpolation)."""
|
||||||
|
# Fast numpy array slicing for normal case (no wrap-around)
|
||||||
Uses Image.frombytes instead of Image.fromarray: frombytes skips
|
if end_x <= self.cached_image.width:
|
||||||
numpy's array-protocol overhead and is ~50% faster for the display-sized
|
# Normal case: single slice - fastest path
|
||||||
slices (128×32 = 12 KB) used here.
|
frame_array = self.cached_array[:, start_x:end_x]
|
||||||
"""
|
# Convert to PIL Image (minimal overhead)
|
||||||
_size = (self.display_width, self.display_height)
|
return Image.fromarray(frame_array)
|
||||||
img_w = self.cached_image.width
|
|
||||||
|
|
||||||
if end_x <= img_w:
|
|
||||||
# Normal case: single contiguous slice (fastest path)
|
|
||||||
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
|
|
||||||
return Image.frombytes('RGB', _size, frame_array.tobytes())
|
|
||||||
else:
|
else:
|
||||||
# Ensure frame buffer is allocated for all non-simple paths
|
# Wrap-around case: combine two slices using numpy
|
||||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
width1 = self.cached_image.width - start_x
|
||||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
|
||||||
|
|
||||||
width1 = img_w - start_x
|
|
||||||
if width1 > 0:
|
if width1 > 0:
|
||||||
# Wrap-around: tail of image + head of image
|
# Use pre-allocated buffer for output
|
||||||
|
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||||
|
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
# First part from end of image (fast numpy slice)
|
||||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||||||
|
|
||||||
|
# Second part from beginning of image
|
||||||
remaining_width = self.display_width - width1
|
remaining_width = self.display_width - width1
|
||||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||||||
else:
|
|
||||||
# Edge case: start_x at or past image end — show from beginning,
|
|
||||||
# clamped to available width (scroll_position should wrap before
|
|
||||||
# reaching this state in normal operation).
|
|
||||||
available = min(self.display_width, img_w)
|
|
||||||
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
|
||||||
if available < self.display_width:
|
|
||||||
self._frame_buffer[:, available:] = 0
|
|
||||||
|
|
||||||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
# Convert combined buffer to PIL Image
|
||||||
|
return Image.fromarray(self._frame_buffer)
|
||||||
|
else:
|
||||||
|
# Edge case: start_x >= image width, wrap to beginning
|
||||||
|
frame_array = self.cached_array[:, :self.display_width]
|
||||||
|
return Image.fromarray(frame_array)
|
||||||
|
|
||||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,651 +0,0 @@
|
|||||||
"""
|
|
||||||
Multi-Display Sync Manager
|
|
||||||
|
|
||||||
Synchronizes scrolling content across two LED matrix display units over UDP.
|
|
||||||
Runs at the core framework level — works with any plugin automatically.
|
|
||||||
|
|
||||||
Roles:
|
|
||||||
standalone No sync (default behavior)
|
|
||||||
leader Drives scroll, sends rendered follower frames via UDP
|
|
||||||
follower Receives frames from leader; falls back to own plugins when
|
|
||||||
the leader goes offline
|
|
||||||
|
|
||||||
Compatibility rule: rows and cols must match between leader and follower.
|
|
||||||
chain_length may differ — each display can have a different number of panels.
|
|
||||||
|
|
||||||
Port default: 5765 (UDP). Open this port on both Pis if ufw is active:
|
|
||||||
sudo ufw allow 5765/udp
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Callable, Optional
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
|
||||||
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
|
||||||
_RAW_MAGIC = b'SYNC_RAW'
|
|
||||||
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
|
|
||||||
|
|
||||||
|
|
||||||
SYNC_PORT = 5765
|
|
||||||
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
|
||||||
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
|
||||||
PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone
|
|
||||||
LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone
|
|
||||||
STATUS_FILE = os.path.join(tempfile.gettempdir(), "led_matrix_sync_status.json")
|
|
||||||
|
|
||||||
|
|
||||||
class SyncRole(Enum):
|
|
||||||
STANDALONE = "standalone"
|
|
||||||
LEADER = "leader"
|
|
||||||
FOLLOWER = "follower"
|
|
||||||
|
|
||||||
|
|
||||||
class LeaderState(Enum):
|
|
||||||
NO_PEER = "no_peer"
|
|
||||||
CONNECTED = "connected"
|
|
||||||
INCOMPATIBLE = "incompatible"
|
|
||||||
|
|
||||||
|
|
||||||
class FollowerState(Enum):
|
|
||||||
STANDALONE = "standalone"
|
|
||||||
FOLLOWER = "follower"
|
|
||||||
|
|
||||||
|
|
||||||
class DisplaySyncManager:
|
|
||||||
"""
|
|
||||||
Core sync manager. Instantiated by DisplayController based on config['sync'].
|
|
||||||
Leader sends compressed PNG frames to the follower after each render cycle.
|
|
||||||
Follower renders received frames; returns to own plugin stack when leader
|
|
||||||
goes offline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
role_str: str,
|
|
||||||
cfg: dict,
|
|
||||||
hw_config: dict,
|
|
||||||
logger: logging.Logger,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
role_str: "standalone" | "leader" | "follower"
|
|
||||||
cfg: config['sync'] dict
|
|
||||||
hw_config: config['display']['hardware'] dict (this Pi's own config)
|
|
||||||
logger: framework logger
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.role = SyncRole(role_str)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Invalid sync role '%s', defaulting to standalone", role_str)
|
|
||||||
self.role = SyncRole.STANDALONE
|
|
||||||
|
|
||||||
self.logger = logger
|
|
||||||
self.port = int(cfg.get("port", SYNC_PORT))
|
|
||||||
self._hw_config = hw_config
|
|
||||||
|
|
||||||
# Leader state
|
|
||||||
self._leader_state = LeaderState.NO_PEER
|
|
||||||
self._peer_ip: Optional[str] = None
|
|
||||||
self._peer_compatible: bool = False
|
|
||||||
self._peer_chain: int = 0
|
|
||||||
self._last_heartbeat_time: float = 0.0
|
|
||||||
self._leader_width: int = 0 # set by display_controller after init
|
|
||||||
|
|
||||||
# Follower state
|
|
||||||
self._follower_state = FollowerState.STANDALONE
|
|
||||||
self._latest_frame: Optional[Image.Image] = None # pixel-frame fallback
|
|
||||||
self._latest_scroll_x: Optional[float] = None # Vegas scroll position
|
|
||||||
self._last_leader_frame_time: float = 0.0
|
|
||||||
self._frame_lock = threading.Lock()
|
|
||||||
self._leader_ip: Optional[str] = None
|
|
||||||
self._on_new_cycle: Optional[Callable[[], None]] = None # called when leader starts new cycle
|
|
||||||
self._on_scroll_image: Optional[Callable[[Image.Image], None]] = None # called with Image when received
|
|
||||||
self._pending_scroll_image: Optional[Image.Image] = None # image received before callback set
|
|
||||||
self._scroll_image_lock = threading.Lock() # guards _on_scroll_image / _pending_scroll_image
|
|
||||||
self._img_server_sock = None # TCP server for scroll image transfer
|
|
||||||
|
|
||||||
# Leader state additions
|
|
||||||
self._on_follower_connected: Optional[Callable[[], None]] = None # called when follower connects
|
|
||||||
|
|
||||||
self._error_message: Optional[str] = None
|
|
||||||
self._running = False
|
|
||||||
self._recv_sock: Optional[socket.socket] = None
|
|
||||||
self._send_sock: Optional[socket.socket] = None
|
|
||||||
|
|
||||||
if self.role == SyncRole.STANDALONE:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.role == SyncRole.LEADER:
|
|
||||||
self._start_leader()
|
|
||||||
elif self.role == SyncRole.FOLLOWER:
|
|
||||||
self._start_follower()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Leader setup #
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def _start_leader(self) -> None:
|
|
||||||
# Receive socket: listens for hello + heartbeat from follower
|
|
||||||
self._recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # nosec B104
|
|
||||||
self._recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
self._recv_sock.bind(("", self.port)) # nosec B104 — intentional: must receive UDP broadcast on all interfaces
|
|
||||||
self._recv_sock.settimeout(1.0)
|
|
||||||
|
|
||||||
# Send socket: unicast frames + hello_ack to follower
|
|
||||||
self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
|
|
||||||
self._running = True
|
|
||||||
threading.Thread(
|
|
||||||
target=self._leader_recv_loop, daemon=True, name="sync-leader-recv"
|
|
||||||
).start()
|
|
||||||
threading.Thread(
|
|
||||||
target=self._leader_watchdog, daemon=True, name="sync-leader-watchdog"
|
|
||||||
).start()
|
|
||||||
self.logger.info("Sync: leader started on UDP port %d", self.port)
|
|
||||||
self.write_status_file()
|
|
||||||
|
|
||||||
def _leader_recv_loop(self) -> None:
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
data, addr = self._recv_sock.recvfrom(1024)
|
|
||||||
sender_ip = addr[0]
|
|
||||||
try:
|
|
||||||
msg = json.loads(data.decode("utf-8"))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
||||||
continue
|
|
||||||
t = msg.get("t")
|
|
||||||
if t == "hello":
|
|
||||||
self._handle_hello(msg, sender_ip)
|
|
||||||
elif t == "hb":
|
|
||||||
if self._peer_ip == sender_ip:
|
|
||||||
self._last_heartbeat_time = time.time()
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync leader recv error: %s", exc)
|
|
||||||
|
|
||||||
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
|
||||||
hw = self._hw_config
|
|
||||||
local_rows = hw.get("rows", 32)
|
|
||||||
local_cols = hw.get("cols", 64)
|
|
||||||
peer_rows = int(msg.get("rows", 0))
|
|
||||||
peer_cols = int(msg.get("cols", 0))
|
|
||||||
peer_chain = int(msg.get("chain", 1))
|
|
||||||
|
|
||||||
compatible = peer_rows == local_rows and peer_cols == local_cols
|
|
||||||
|
|
||||||
self._peer_ip = sender_ip
|
|
||||||
self._peer_compatible = compatible
|
|
||||||
self._peer_chain = peer_chain
|
|
||||||
self._last_heartbeat_time = time.time()
|
|
||||||
|
|
||||||
prev_state = self._leader_state
|
|
||||||
if compatible:
|
|
||||||
if prev_state != LeaderState.CONNECTED:
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: follower connected at %s (chain=%d)", sender_ip, peer_chain
|
|
||||||
)
|
|
||||||
self._leader_state = LeaderState.CONNECTED
|
|
||||||
self._error_message = None
|
|
||||||
# Send scroll image immediately on new connection so follower has identical content
|
|
||||||
if prev_state != LeaderState.CONNECTED and self._on_follower_connected:
|
|
||||||
threading.Thread(
|
|
||||||
target=self._on_follower_connected,
|
|
||||||
daemon=True, name="sync-leader-img-push"
|
|
||||||
).start()
|
|
||||||
else:
|
|
||||||
self._leader_state = LeaderState.INCOMPATIBLE
|
|
||||||
self._error_message = (
|
|
||||||
f"Incompatible panels: follower is {peer_cols}x{peer_rows}, "
|
|
||||||
f"leader is {local_cols}x{local_rows}. "
|
|
||||||
f"rows and cols must match between displays."
|
|
||||||
)
|
|
||||||
if prev_state != LeaderState.INCOMPATIBLE:
|
|
||||||
self.logger.error("Sync: %s", self._error_message)
|
|
||||||
|
|
||||||
if self._leader_state != prev_state:
|
|
||||||
self.write_status_file()
|
|
||||||
|
|
||||||
ack = json.dumps({
|
|
||||||
"t": "hello_ack",
|
|
||||||
"compatible": compatible,
|
|
||||||
"leader_width": self._leader_width,
|
|
||||||
"error": self._error_message,
|
|
||||||
}).encode("utf-8")
|
|
||||||
try:
|
|
||||||
self._send_sock.sendto(ack, (sender_ip, self.port))
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: hello_ack send failed: %s", exc)
|
|
||||||
|
|
||||||
def _leader_watchdog(self) -> None:
|
|
||||||
while self._running:
|
|
||||||
time.sleep(1.0)
|
|
||||||
if self._leader_state == LeaderState.CONNECTED:
|
|
||||||
if time.time() - self._last_heartbeat_time > PEER_TIMEOUT:
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: follower heartbeat timeout — peer disconnected"
|
|
||||||
)
|
|
||||||
self._leader_state = LeaderState.NO_PEER
|
|
||||||
self._peer_ip = None
|
|
||||||
self._peer_compatible = False
|
|
||||||
self.write_status_file()
|
|
||||||
|
|
||||||
def _image_server_loop(self) -> None:
|
|
||||||
"""Follower: TCP server that receives the leader's scroll image at each new cycle."""
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
conn, addr = self._img_server_sock.accept()
|
|
||||||
conn.settimeout(10.0)
|
|
||||||
try:
|
|
||||||
# 4-byte big-endian length prefix
|
|
||||||
hdr = b""
|
|
||||||
while len(hdr) < 4:
|
|
||||||
chunk = conn.recv(4 - len(hdr))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
hdr += chunk
|
|
||||||
if len(hdr) < 4:
|
|
||||||
continue
|
|
||||||
length = int.from_bytes(hdr, "big")
|
|
||||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB — well above any real scroll image
|
|
||||||
if length <= 0 or length > _MAX_IMAGE_BYTES:
|
|
||||||
self.logger.warning(
|
|
||||||
"Sync: rejected TCP image with invalid length %d (max %d) from %s",
|
|
||||||
length, _MAX_IMAGE_BYTES, addr,
|
|
||||||
)
|
|
||||||
conn.close()
|
|
||||||
continue
|
|
||||||
data = bytearray()
|
|
||||||
while len(data) < length:
|
|
||||||
chunk = conn.recv(min(65536, length - len(data)))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
data.extend(chunk)
|
|
||||||
img = Image.open(io.BytesIO(data))
|
|
||||||
_MAX_W, _MAX_H = 100_000, 256 # generous for any real scroll image
|
|
||||||
if img.width > _MAX_W or img.height > _MAX_H:
|
|
||||||
self.logger.warning(
|
|
||||||
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
|
|
||||||
img.width, img.height, _MAX_W, _MAX_H, addr,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
img.load()
|
|
||||||
except (Image.DecompressionBombError, ValueError) as exc:
|
|
||||||
self.logger.warning("Sync: rejected decompression bomb from %s: %s", addr, exc)
|
|
||||||
continue
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: received scroll image %dx%d (%d bytes compressed)",
|
|
||||||
img.width, img.height, length,
|
|
||||||
)
|
|
||||||
with self._scroll_image_lock:
|
|
||||||
if self._on_scroll_image:
|
|
||||||
cb = self._on_scroll_image
|
|
||||||
else:
|
|
||||||
# Callback not registered yet (startup race) — cache it
|
|
||||||
self._pending_scroll_image = img
|
|
||||||
cb = None
|
|
||||||
if cb:
|
|
||||||
cb(img)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: image server error: %s", exc)
|
|
||||||
|
|
||||||
def send_scroll_image(self, image: Image.Image) -> None:
|
|
||||||
"""Leader: send the full scroll image to the follower via TCP.
|
|
||||||
PNG compression typically reduces a 5000×32 image to ~20–50KB,
|
|
||||||
transferring in <20ms on local WiFi. Called at new_cycle and on
|
|
||||||
first connection so both Pis always have identical cached_arrays.
|
|
||||||
"""
|
|
||||||
if self.role != SyncRole.LEADER:
|
|
||||||
return
|
|
||||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
buf = io.BytesIO()
|
|
||||||
image.save(buf, format="PNG", optimize=True)
|
|
||||||
data = buf.getvalue()
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
||||||
sock.settimeout(5.0)
|
|
||||||
sock.connect((self._peer_ip, self.port + 1))
|
|
||||||
sock.sendall(len(data).to_bytes(4, "big") + data)
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: sent scroll image %dx%d (%d bytes compressed)",
|
|
||||||
image.width, image.height, len(data),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: image send error: %s", exc)
|
|
||||||
|
|
||||||
def set_on_follower_connected(self, callback: Callable[[], None]) -> None:
|
|
||||||
"""Leader: callback fired (in a thread) when a compatible follower first connects.
|
|
||||||
Use this to push the current scroll image immediately.
|
|
||||||
If a follower is already connected when this is called, fires right away
|
|
||||||
(handles the race where follower connects during leader startup).
|
|
||||||
"""
|
|
||||||
self._on_follower_connected = callback
|
|
||||||
if self._leader_state == LeaderState.CONNECTED:
|
|
||||||
threading.Thread(
|
|
||||||
target=callback, daemon=True, name="sync-leader-img-push-late"
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def set_on_scroll_image(self, callback: Callable[[Image.Image], None]) -> None:
|
|
||||||
"""Follower: callback fired with the received Image when leader sends scroll image.
|
|
||||||
If an image was received before this callback was registered (startup race),
|
|
||||||
fires immediately with that cached image.
|
|
||||||
"""
|
|
||||||
with self._scroll_image_lock:
|
|
||||||
self._on_scroll_image = callback
|
|
||||||
pending = self._pending_scroll_image
|
|
||||||
self._pending_scroll_image = None
|
|
||||||
if pending is not None:
|
|
||||||
callback(pending)
|
|
||||||
|
|
||||||
def send_scroll_x(self, scroll_x: float) -> None:
|
|
||||||
"""Leader (Vegas mode): broadcast scroll position instead of a pixel frame.
|
|
||||||
The follower renders from its own local pipeline at scroll_x - display_width.
|
|
||||||
~20 bytes vs ~18KB for raw frames — eliminates all content-change artifacts.
|
|
||||||
"""
|
|
||||||
if self.role != SyncRole.LEADER:
|
|
||||||
return
|
|
||||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
msg = json.dumps({"t": "sx", "x": round(scroll_x, 2)}).encode("utf-8")
|
|
||||||
self._send_sock.sendto(msg, (self._peer_ip, self.port))
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: scroll_x send error: %s", exc)
|
|
||||||
|
|
||||||
def send_new_cycle(self) -> None:
|
|
||||||
"""Leader: signal that a new scroll cycle has started so follower rebuilds its image."""
|
|
||||||
if self.role != SyncRole.LEADER:
|
|
||||||
return
|
|
||||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._send_sock.sendto(b'{"t":"nc"}', (self._peer_ip, self.port))
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: new_cycle send error: %s", exc)
|
|
||||||
|
|
||||||
def send_frame(self, image: Image.Image) -> None:
|
|
||||||
"""Leader: send a rendered frame to the follower as raw RGB bytes.
|
|
||||||
Raw format is orders of magnitude faster than PNG on Pi hardware —
|
|
||||||
no encode on sender, no decode on receiver.
|
|
||||||
Packet: 8-byte magic + 4-byte (width, height) header + raw RGB bytes.
|
|
||||||
"""
|
|
||||||
if self.role != SyncRole.LEADER:
|
|
||||||
return
|
|
||||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
arr = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
|
||||||
header = _RAW_MAGIC + _RAW_HEADER.pack(image.width, image.height)
|
|
||||||
data = header + arr.tobytes()
|
|
||||||
if len(data) <= 65000:
|
|
||||||
self._send_sock.sendto(data, (self._peer_ip, self.port))
|
|
||||||
elif not getattr(self, '_oversized_frame_warned', False):
|
|
||||||
self._oversized_frame_warned = True
|
|
||||||
self.logger.warning(
|
|
||||||
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
|
||||||
"image %dx%d will not be sent; use TCP image sync instead",
|
|
||||||
len(data), image.width, image.height,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: frame send error: %s", exc)
|
|
||||||
|
|
||||||
def set_leader_width(self, width: int) -> None:
|
|
||||||
"""Called by DisplayController once display_manager.width is known."""
|
|
||||||
self._leader_width = width
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Follower setup #
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def _start_follower(self) -> None:
|
|
||||||
# Receive socket: listens for frames + hello_ack from leader
|
|
||||||
self._recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
self._recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
self._recv_sock.bind(("", self.port)) # nosec B104 — intentional: must receive UDP broadcast on all interfaces
|
|
||||||
self._recv_sock.settimeout(0.1)
|
|
||||||
|
|
||||||
# Send socket: broadcasts hello + heartbeat
|
|
||||||
self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
self._send_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
||||||
|
|
||||||
self._running = True
|
|
||||||
threading.Thread(
|
|
||||||
target=self._follower_recv_loop, daemon=True, name="sync-follower-recv"
|
|
||||||
).start()
|
|
||||||
threading.Thread(
|
|
||||||
target=self._follower_announce_loop, daemon=True, name="sync-follower-announce"
|
|
||||||
).start()
|
|
||||||
threading.Thread(
|
|
||||||
target=self._follower_watchdog, daemon=True, name="sync-follower-watchdog"
|
|
||||||
).start()
|
|
||||||
# TCP server: receives scroll images from leader (port + 1)
|
|
||||||
self._img_server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # nosec B104
|
|
||||||
self._img_server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
self._img_server_sock.bind(("", self.port + 1)) # nosec B104 — intentional: TCP server must accept connections on all interfaces
|
|
||||||
self._img_server_sock.listen(1)
|
|
||||||
self._img_server_sock.settimeout(1.0)
|
|
||||||
threading.Thread(
|
|
||||||
target=self._image_server_loop, daemon=True, name="sync-image-server"
|
|
||||||
).start()
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: follower started on UDP port %d, image server on TCP %d",
|
|
||||||
self.port, self.port + 1,
|
|
||||||
)
|
|
||||||
self.write_status_file()
|
|
||||||
|
|
||||||
def _follower_recv_loop(self) -> None:
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
data, addr = self._recv_sock.recvfrom(65535)
|
|
||||||
sender_ip = addr[0]
|
|
||||||
|
|
||||||
if data[:8] == _RAW_MAGIC or len(data) > 512:
|
|
||||||
# Frame data: prefer magic-tagged raw RGB; fall back to legacy PNG
|
|
||||||
try:
|
|
||||||
if data[:8] == _RAW_MAGIC:
|
|
||||||
w, h = _RAW_HEADER.unpack(data[8:12])
|
|
||||||
raw = data[12:]
|
|
||||||
img = Image.frombuffer(
|
|
||||||
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fallback: try legacy PNG
|
|
||||||
img = Image.open(io.BytesIO(data))
|
|
||||||
img.load()
|
|
||||||
with self._frame_lock:
|
|
||||||
self._latest_frame = img
|
|
||||||
self._last_leader_frame_time = time.time()
|
|
||||||
self._leader_ip = sender_ip
|
|
||||||
|
|
||||||
if self._follower_state == FollowerState.STANDALONE:
|
|
||||||
self._follower_state = FollowerState.FOLLOWER
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: leader active at %s — switching to follower mode",
|
|
||||||
sender_ip,
|
|
||||||
)
|
|
||||||
self.write_status_file()
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: frame decode error: %s", exc)
|
|
||||||
else:
|
|
||||||
# Control message
|
|
||||||
try:
|
|
||||||
msg = json.loads(data.decode("utf-8"))
|
|
||||||
t = msg.get("t")
|
|
||||||
if t == "hello_ack":
|
|
||||||
self._leader_ip = sender_ip
|
|
||||||
self._peer_compatible = msg.get("compatible", False)
|
|
||||||
self._error_message = msg.get("error")
|
|
||||||
if not self._peer_compatible and self._error_message:
|
|
||||||
self.logger.error(
|
|
||||||
"Sync: leader rejected handshake — %s",
|
|
||||||
self._error_message,
|
|
||||||
)
|
|
||||||
self.write_status_file()
|
|
||||||
elif t == "sx":
|
|
||||||
# Vegas scroll-position sync — tiny message, renders locally
|
|
||||||
self._latest_scroll_x = float(msg["x"])
|
|
||||||
self._last_leader_frame_time = time.time()
|
|
||||||
self._leader_ip = sender_ip
|
|
||||||
if self._follower_state == FollowerState.STANDALONE:
|
|
||||||
self._follower_state = FollowerState.FOLLOWER
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: leader active at %s — switching to follower mode",
|
|
||||||
sender_ip,
|
|
||||||
)
|
|
||||||
self.write_status_file()
|
|
||||||
if self._on_new_cycle:
|
|
||||||
self._on_new_cycle() # build initial scroll image
|
|
||||||
elif t == "nc":
|
|
||||||
# Leader started a new scroll cycle — rebuild local image
|
|
||||||
if self._on_new_cycle:
|
|
||||||
self._on_new_cycle()
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError, KeyError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync follower recv error: %s", exc)
|
|
||||||
|
|
||||||
def _follower_announce_loop(self) -> None:
|
|
||||||
hw = self._hw_config
|
|
||||||
hello = json.dumps({
|
|
||||||
"t": "hello",
|
|
||||||
"rows": hw.get("rows", 32),
|
|
||||||
"cols": hw.get("cols", 64),
|
|
||||||
"chain": hw.get("chain_length", 1),
|
|
||||||
}).encode("utf-8")
|
|
||||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
|
||||||
dest = ("<broadcast>", self.port)
|
|
||||||
|
|
||||||
last_hello = 0.0
|
|
||||||
last_hb = 0.0
|
|
||||||
|
|
||||||
while self._running:
|
|
||||||
now = time.time()
|
|
||||||
if now - last_hello >= HELLO_INTERVAL:
|
|
||||||
try:
|
|
||||||
self._send_sock.sendto(hello, dest)
|
|
||||||
last_hello = now
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: hello broadcast error: %s", exc)
|
|
||||||
if now - last_hb >= HEARTBEAT_INTERVAL:
|
|
||||||
try:
|
|
||||||
self._send_sock.sendto(heartbeat, dest)
|
|
||||||
last_hb = now
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: heartbeat error: %s", exc)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
def _follower_watchdog(self) -> None:
|
|
||||||
while self._running:
|
|
||||||
time.sleep(1.0)
|
|
||||||
if self._follower_state == FollowerState.FOLLOWER:
|
|
||||||
if time.time() - self._last_leader_frame_time > LEADER_TIMEOUT:
|
|
||||||
self.logger.info(
|
|
||||||
"Sync: leader frame timeout — returning to standalone mode"
|
|
||||||
)
|
|
||||||
self._follower_state = FollowerState.STANDALONE
|
|
||||||
with self._frame_lock:
|
|
||||||
self._latest_frame = None
|
|
||||||
self.write_status_file()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Public API #
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def is_follower_active(self) -> bool:
|
|
||||||
"""True when this Pi is in active follower mode (receiving frames)."""
|
|
||||||
return (
|
|
||||||
self.role == SyncRole.FOLLOWER
|
|
||||||
and self._follower_state == FollowerState.FOLLOWER
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_latest_scroll_x(self) -> Optional[float]:
|
|
||||||
"""Follower: return the most recently received Vegas scroll position, or None."""
|
|
||||||
return self._latest_scroll_x
|
|
||||||
|
|
||||||
def set_on_new_cycle(self, callback: Callable[[], None]) -> None:
|
|
||||||
"""Follower: register a callback fired when the leader starts a new scroll cycle.
|
|
||||||
Used to trigger a local start_new_cycle() so both Pis rebuild from same fresh data.
|
|
||||||
"""
|
|
||||||
self._on_new_cycle = callback
|
|
||||||
|
|
||||||
def get_latest_frame(self) -> Optional[Image.Image]:
|
|
||||||
"""Follower: return the most recently received pixel frame (non-Vegas fallback)."""
|
|
||||||
with self._frame_lock:
|
|
||||||
return self._latest_frame
|
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
|
||||||
"""Return sync state dict for the web API status endpoint."""
|
|
||||||
hw = self._hw_config
|
|
||||||
base = {
|
|
||||||
"role": self.role.value,
|
|
||||||
"port": self.port,
|
|
||||||
"local_rows": hw.get("rows", 32),
|
|
||||||
"local_cols": hw.get("cols", 64),
|
|
||||||
"local_chain": hw.get("chain_length", 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.role == SyncRole.STANDALONE:
|
|
||||||
return {**base, "state": "standalone"}
|
|
||||||
|
|
||||||
if self.role == SyncRole.LEADER:
|
|
||||||
return {
|
|
||||||
**base,
|
|
||||||
"state": self._leader_state.value,
|
|
||||||
"peer_ip": self._peer_ip,
|
|
||||||
"peer_compatible": self._peer_compatible,
|
|
||||||
"peer_chain": self._peer_chain,
|
|
||||||
"leader_width": self._leader_width,
|
|
||||||
"error": self._error_message,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Follower
|
|
||||||
return {
|
|
||||||
**base,
|
|
||||||
"state": self._follower_state.value,
|
|
||||||
"leader_ip": self._leader_ip,
|
|
||||||
"peer_compatible": self._peer_compatible,
|
|
||||||
"error": self._error_message,
|
|
||||||
}
|
|
||||||
|
|
||||||
def write_status_file(self) -> None:
|
|
||||||
"""Write current sync status to STATUS_FILE for the web UI to read."""
|
|
||||||
try:
|
|
||||||
status = self.get_status()
|
|
||||||
status["ts"] = time.time()
|
|
||||||
tmp = STATUS_FILE + ".tmp"
|
|
||||||
with open(tmp, "w") as f:
|
|
||||||
json.dump(status, f)
|
|
||||||
os.replace(tmp, STATUS_FILE)
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: status file write error: %s", exc)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""Shut down threads and close sockets."""
|
|
||||||
self._running = False
|
|
||||||
for sock in (self._recv_sock, self._send_sock, self._img_server_sock):
|
|
||||||
if sock:
|
|
||||||
try:
|
|
||||||
sock.close()
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug("Sync: error closing socket: %s", exc)
|
|
||||||
@@ -6,6 +6,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Union
|
from typing import Optional, Tuple, Union
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
|
|||||||