mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-17 18:53:33 +00:00
Compare commits
35 Commits
35df06b8e1
...
update-rgb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c53e4995c4 | ||
|
|
a0f19d8972 | ||
|
|
4f126d6133 | ||
|
|
5dde1125e9 | ||
|
|
05b3fa56cb | ||
|
|
44d1a08db4 | ||
|
|
6a4644007d | ||
|
|
1c4d5c5271 | ||
|
|
dbb53da31d | ||
|
|
452afacd12 | ||
|
|
3b45a75f75 | ||
|
|
1a0f1c8015 | ||
|
|
b361866679 | ||
|
|
ceb4c4105f | ||
|
|
e9af18cdf1 | ||
|
|
5e6c40ad55 | ||
|
|
d6bd1ee215 | ||
|
|
acaf8a248e | ||
|
|
db9585cea9 | ||
|
|
65e3e8319b | ||
|
|
4ef3f8cad5 | ||
|
|
338bdc44cb | ||
|
|
73c00140df | ||
|
|
68a38c39f7 | ||
|
|
941291561a | ||
|
|
39ccdcf00d | ||
|
|
781224591f | ||
|
|
601fedb9b4 | ||
|
|
6812dfe7a6 | ||
|
|
efe6b1fe23 | ||
|
|
5ea2acd897 | ||
|
|
68a0fe1182 | ||
|
|
7afc2c0670 | ||
|
|
ee4149dc49 | ||
|
|
5ddf8b1aea |
@@ -43,39 +43,48 @@ cp ../../.cursor/plugin_templates/*.template .
|
|||||||
2. **Using dev_plugin_setup.sh**:
|
2. **Using dev_plugin_setup.sh**:
|
||||||
```bash
|
```bash
|
||||||
# Link from GitHub
|
# Link from GitHub
|
||||||
./dev_plugin_setup.sh link-github my-plugin
|
./scripts/dev/dev_plugin_setup.sh link-github my-plugin
|
||||||
|
|
||||||
# Link local repo
|
# Link local repo
|
||||||
./dev_plugin_setup.sh link my-plugin /path/to/repo
|
./scripts/dev/dev_plugin_setup.sh link my-plugin /path/to/repo
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Plugins
|
### Running the Display
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Emulator (development)
|
# Emulator mode (development, no hardware required)
|
||||||
python run.py --emulator
|
python3 run.py --emulator
|
||||||
|
# (equivalent: EMULATOR=true python3 run.py)
|
||||||
|
|
||||||
# Hardware (production)
|
# Hardware (production, requires the rpi-rgb-led-matrix submodule built)
|
||||||
python run.py
|
python3 run.py
|
||||||
|
|
||||||
# As service
|
# As a systemd service
|
||||||
sudo systemctl start ledmatrix
|
sudo systemctl start ledmatrix
|
||||||
|
|
||||||
|
# Dev preview server (renders plugins to a browser without running run.py)
|
||||||
|
python3 scripts/dev_server.py # then open http://localhost:5001
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `-e`/`--emulator` CLI flag is defined in `run.py:19-20` and
|
||||||
|
sets `os.environ["EMULATOR"] = "true"` before any display imports,
|
||||||
|
which `src/display_manager.py:2` then reads to switch between the
|
||||||
|
hardware and emulator backends.
|
||||||
|
|
||||||
### Managing Plugins
|
### Managing Plugins
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List plugins
|
# List plugins
|
||||||
./dev_plugin_setup.sh list
|
./scripts/dev/dev_plugin_setup.sh list
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
./dev_plugin_setup.sh status
|
./scripts/dev/dev_plugin_setup.sh status
|
||||||
|
|
||||||
# Update plugin(s)
|
# Update plugin(s)
|
||||||
./dev_plugin_setup.sh update [plugin-name]
|
./scripts/dev/dev_plugin_setup.sh update [plugin-name]
|
||||||
|
|
||||||
# Unlink plugin
|
# Unlink plugin
|
||||||
./dev_plugin_setup.sh unlink <plugin-name>
|
./scripts/dev/dev_plugin_setup.sh unlink <plugin-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using These Files with Cursor
|
## Using These Files with Cursor
|
||||||
@@ -118,9 +127,13 @@ Refer to `plugins_guide.md` for:
|
|||||||
- **Plugin System**: `src/plugin_system/`
|
- **Plugin System**: `src/plugin_system/`
|
||||||
- **Base Plugin**: `src/plugin_system/base_plugin.py`
|
- **Base Plugin**: `src/plugin_system/base_plugin.py`
|
||||||
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
|
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
|
||||||
- **Example Plugins**: `plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`
|
- **Example Plugins**: see the
|
||||||
|
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
repo for canonical sources (e.g. `plugins/hockey-scoreboard/`,
|
||||||
|
`plugins/football-scoreboard/`). Installed plugins land in
|
||||||
|
`plugin-repos/` (default) or `plugins/` (dev fallback).
|
||||||
- **Architecture Docs**: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
- **Architecture Docs**: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
||||||
- **Development Setup**: `dev_plugin_setup.sh`
|
- **Development Setup**: `scripts/dev/dev_plugin_setup.sh`
|
||||||
|
|
||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
|
|||||||
@@ -156,11 +156,15 @@ def _fetch_data(self):
|
|||||||
|
|
||||||
### Adding Image Rendering
|
### Adding Image Rendering
|
||||||
|
|
||||||
|
There is no `draw_image()` helper on `DisplayManager`. To render an
|
||||||
|
image, paste it directly onto the underlying PIL `Image`
|
||||||
|
(`display_manager.image`) and then call `update_display()`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def _render_content(self):
|
def _render_content(self):
|
||||||
# Load and render image
|
# Load and paste image onto the display canvas
|
||||||
image = Image.open("assets/logo.png")
|
image = Image.open("assets/logo.png").convert("RGB")
|
||||||
self.display_manager.draw_image(image, x=0, y=0)
|
self.display_manager.image.paste(image, (0, 0))
|
||||||
|
|
||||||
# Draw text overlay
|
# Draw text overlay
|
||||||
self.display_manager.draw_text(
|
self.display_manager.draw_text(
|
||||||
@@ -168,8 +172,18 @@ def _render_content(self):
|
|||||||
x=10, y=20,
|
x=10, y=20,
|
||||||
color=(255, 255, 255)
|
color=(255, 255, 255)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.display_manager.update_display()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For transparency, paste with a mask:
|
||||||
|
|
||||||
|
```python
|
||||||
|
icon = Image.open("assets/icon.png").convert("RGBA")
|
||||||
|
self.display_manager.image.paste(icon, (5, 5), icon)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Adding Live Priority
|
### Adding Live Priority
|
||||||
|
|
||||||
1. Enable in config:
|
1. Enable in config:
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ This method is best for plugins stored in separate Git repositories.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Link a plugin from GitHub (auto-detects URL)
|
# Link a plugin from GitHub (auto-detects URL)
|
||||||
./dev_plugin_setup.sh link-github <plugin-name>
|
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
||||||
|
|
||||||
# Example: Link hockey-scoreboard plugin
|
# Example: Link hockey-scoreboard plugin
|
||||||
./dev_plugin_setup.sh link-github hockey-scoreboard
|
./scripts/dev/dev_plugin_setup.sh link-github hockey-scoreboard
|
||||||
|
|
||||||
# With custom URL
|
# With custom URL
|
||||||
./dev_plugin_setup.sh link-github <plugin-name> https://github.com/user/repo.git
|
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name> https://github.com/user/repo.git
|
||||||
```
|
```
|
||||||
|
|
||||||
The script will:
|
The script will:
|
||||||
@@ -71,10 +71,10 @@ The script will:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Link a local plugin repository
|
# Link a local plugin repository
|
||||||
./dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
||||||
|
|
||||||
# Example: Link a local plugin
|
# Example: Link a local plugin
|
||||||
./dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
### Method 2: Manual Plugin Creation
|
### Method 2: Manual Plugin Creation
|
||||||
@@ -321,7 +321,8 @@ Each plugin has its own section in `config/config.json`:
|
|||||||
|
|
||||||
### Secrets Management
|
### Secrets Management
|
||||||
|
|
||||||
Store sensitive data (API keys, tokens) in `config/config_secrets.json`:
|
Store sensitive data (API keys, tokens) in `config/config_secrets.json`
|
||||||
|
under the same plugin id you use in `config/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -331,19 +332,21 @@ Store sensitive data (API keys, tokens) in `config/config_secrets.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Reference secrets in main config:
|
At load time, the config manager deep-merges `config_secrets.json` into
|
||||||
|
the main config (verified at `src/config_manager.py:162-172`). So in
|
||||||
|
your plugin's code:
|
||||||
|
|
||||||
```json
|
```python
|
||||||
{
|
class MyPlugin(BasePlugin):
|
||||||
"my-plugin": {
|
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||||
"enabled": true,
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
"config_secrets": {
|
self.api_key = config.get("api_key") # already merged from secrets
|
||||||
"api_key": "my-plugin.api_key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
There is no separate `config_secrets` reference field — just put the
|
||||||
|
secret value under the same plugin namespace and read it from the
|
||||||
|
merged config.
|
||||||
|
|
||||||
### Plugin Discovery
|
### Plugin Discovery
|
||||||
|
|
||||||
Plugins are automatically discovered when:
|
Plugins are automatically discovered when:
|
||||||
@@ -355,7 +358,7 @@ Check discovered plugins:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using dev_plugin_setup.sh
|
# Using dev_plugin_setup.sh
|
||||||
./dev_plugin_setup.sh list
|
./scripts/dev/dev_plugin_setup.sh list
|
||||||
|
|
||||||
# Output shows:
|
# Output shows:
|
||||||
# ✓ plugin-name (symlink)
|
# ✓ plugin-name (symlink)
|
||||||
@@ -368,7 +371,7 @@ Check discovered plugins:
|
|||||||
Check plugin status and git information:
|
Check plugin status and git information:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./dev_plugin_setup.sh status
|
./scripts/dev/dev_plugin_setup.sh status
|
||||||
|
|
||||||
# Output shows:
|
# Output shows:
|
||||||
# ✓ plugin-name
|
# ✓ plugin-name
|
||||||
@@ -391,13 +394,19 @@ cd ledmatrix-my-plugin
|
|||||||
|
|
||||||
# Link to LEDMatrix project
|
# Link to LEDMatrix project
|
||||||
cd /path/to/LEDMatrix
|
cd /path/to/LEDMatrix
|
||||||
./dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Development Cycle
|
### 2. Development Cycle
|
||||||
|
|
||||||
1. **Edit plugin code** in linked repository
|
1. **Edit plugin code** in linked repository
|
||||||
2. **Test with emulator**: `python run.py --emulator`
|
2. **Test with the dev preview server**:
|
||||||
|
`python3 scripts/dev_server.py` (then open `http://localhost:5001`).
|
||||||
|
Or run the full display in emulator mode with
|
||||||
|
`python3 run.py --emulator` (or equivalently
|
||||||
|
`EMULATOR=true python3 run.py`). The `-e`/`--emulator` CLI flag is
|
||||||
|
defined in `run.py:19-20` and sets the same `EMULATOR` environment
|
||||||
|
variable internally.
|
||||||
3. **Check logs** for errors or warnings
|
3. **Check logs** for errors or warnings
|
||||||
4. **Update configuration** in `config/config.json` if needed
|
4. **Update configuration** in `config/config.json` if needed
|
||||||
5. **Iterate** until plugin works correctly
|
5. **Iterate** until plugin works correctly
|
||||||
@@ -406,30 +415,30 @@ cd /path/to/LEDMatrix
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy to Raspberry Pi
|
# Deploy to Raspberry Pi
|
||||||
rsync -avz plugins/my-plugin/ pi@raspberrypi:/path/to/LEDMatrix/plugins/my-plugin/
|
rsync -avz plugins/my-plugin/ ledpi@your-pi-ip:/path/to/LEDMatrix/plugins/my-plugin/
|
||||||
|
|
||||||
# Or if using git, pull on Pi
|
# Or if using git, pull on Pi
|
||||||
ssh pi@raspberrypi "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
|
ssh ledpi@your-pi-ip "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
|
||||||
|
|
||||||
# Restart service
|
# Restart service
|
||||||
ssh pi@raspberrypi "sudo systemctl restart ledmatrix"
|
ssh ledpi@your-pi-ip "sudo systemctl restart ledmatrix"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Updating Plugins
|
### 4. Updating Plugins
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Update single plugin from git
|
# Update single plugin from git
|
||||||
./dev_plugin_setup.sh update my-plugin
|
./scripts/dev/dev_plugin_setup.sh update my-plugin
|
||||||
|
|
||||||
# Update all linked plugins
|
# Update all linked plugins
|
||||||
./dev_plugin_setup.sh update
|
./scripts/dev/dev_plugin_setup.sh update
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Unlinking Plugins
|
### 5. Unlinking Plugins
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Remove symlink (preserves repository)
|
# Remove symlink (preserves repository)
|
||||||
./dev_plugin_setup.sh unlink my-plugin
|
./scripts/dev/dev_plugin_setup.sh unlink my-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -625,8 +634,8 @@ python run.py --emulator
|
|||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Check symlink: `ls -la plugins/my-plugin`
|
1. Check symlink: `ls -la plugins/my-plugin`
|
||||||
2. Verify target exists: `readlink -f plugins/my-plugin`
|
2. Verify target exists: `readlink -f plugins/my-plugin`
|
||||||
3. Update plugin: `./dev_plugin_setup.sh update my-plugin`
|
3. Update plugin: `./scripts/dev/dev_plugin_setup.sh update my-plugin`
|
||||||
4. Re-link plugin if needed: `./dev_plugin_setup.sh unlink my-plugin && ./dev_plugin_setup.sh link my-plugin <path>`
|
4. Re-link plugin if needed: `./scripts/dev/dev_plugin_setup.sh unlink my-plugin && ./scripts/dev/dev_plugin_setup.sh link my-plugin <path>`
|
||||||
5. Check git status: `cd plugins/my-plugin && git status`
|
5. Check git status: `cd plugins/my-plugin && git status`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -697,22 +706,22 @@ python run.py --emulator
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Link plugin from GitHub
|
# Link plugin from GitHub
|
||||||
./dev_plugin_setup.sh link-github <name>
|
./scripts/dev/dev_plugin_setup.sh link-github <name>
|
||||||
|
|
||||||
# Link local plugin
|
# Link local plugin
|
||||||
./dev_plugin_setup.sh link <name> <path>
|
./scripts/dev/dev_plugin_setup.sh link <name> <path>
|
||||||
|
|
||||||
# List all plugins
|
# List all plugins
|
||||||
./dev_plugin_setup.sh list
|
./scripts/dev/dev_plugin_setup.sh list
|
||||||
|
|
||||||
# Check plugin status
|
# Check plugin status
|
||||||
./dev_plugin_setup.sh status
|
./scripts/dev/dev_plugin_setup.sh status
|
||||||
|
|
||||||
# Update plugin(s)
|
# Update plugin(s)
|
||||||
./dev_plugin_setup.sh update [name]
|
./scripts/dev/dev_plugin_setup.sh update [name]
|
||||||
|
|
||||||
# Unlink plugin
|
# Unlink plugin
|
||||||
./dev_plugin_setup.sh unlink <name>
|
./scripts/dev/dev_plugin_setup.sh unlink <name>
|
||||||
|
|
||||||
# Run with emulator
|
# Run with emulator
|
||||||
python run.py --emulator
|
python run.py --emulator
|
||||||
|
|||||||
80
.cursorrules
80
.cursorrules
@@ -2,7 +2,31 @@
|
|||||||
|
|
||||||
## Plugin System Overview
|
## Plugin System Overview
|
||||||
|
|
||||||
The LEDMatrix project uses a plugin-based architecture. All display functionality (except core calendar) is implemented as plugins that are dynamically loaded from the `plugins/` directory.
|
The LEDMatrix project uses a plugin-based architecture. All display
|
||||||
|
functionality (except core calendar) is implemented as plugins that are
|
||||||
|
dynamically loaded from the directory configured by
|
||||||
|
`plugin_system.plugins_directory` in `config.json` — the default is
|
||||||
|
`plugin-repos/` (per `config/config.template.json:130`).
|
||||||
|
|
||||||
|
> **Fallback note (scoped):** `PluginManager.discover_plugins()`
|
||||||
|
> (`src/plugin_system/plugin_manager.py:154`) only scans the
|
||||||
|
> configured directory — there is no fallback to `plugins/` in the
|
||||||
|
> main discovery path. A fallback to `plugins/` does exist in two
|
||||||
|
> narrower places:
|
||||||
|
> - `store_manager.py:1700-1718` — store operations (install/update/
|
||||||
|
> uninstall) check `plugins/` if the plugin isn't found in the
|
||||||
|
> configured directory, so plugin-store flows work even when your
|
||||||
|
> dev symlinks live in `plugins/`.
|
||||||
|
> - `schema_manager.py:70-80` — `get_schema_path()` probes both
|
||||||
|
> `plugins/` and `plugin-repos/` for `config_schema.json` so the
|
||||||
|
> web UI form generation finds the schema regardless of where the
|
||||||
|
> plugin lives.
|
||||||
|
>
|
||||||
|
> The dev workflow in `scripts/dev/dev_plugin_setup.sh` creates
|
||||||
|
> symlinks under `plugins/`, which is why the store and schema
|
||||||
|
> fallbacks exist. For day-to-day development, set
|
||||||
|
> `plugin_system.plugins_directory` to `plugins` so the main
|
||||||
|
> discovery path picks up your symlinks.
|
||||||
|
|
||||||
## Plugin Structure
|
## Plugin Structure
|
||||||
|
|
||||||
@@ -27,14 +51,15 @@ The LEDMatrix project uses a plugin-based architecture. All display functionalit
|
|||||||
**Option A: Use dev_plugin_setup.sh (Recommended)**
|
**Option A: Use dev_plugin_setup.sh (Recommended)**
|
||||||
```bash
|
```bash
|
||||||
# Link from GitHub
|
# Link from GitHub
|
||||||
./dev_plugin_setup.sh link-github <plugin-name>
|
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
||||||
|
|
||||||
# Link local repository
|
# Link local repository
|
||||||
./dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option B: Manual Setup**
|
**Option B: Manual Setup**
|
||||||
1. Create directory in `plugins/<plugin-id>/`
|
1. Create directory in `plugin-repos/<plugin-id>/` (or `plugins/<plugin-id>/`
|
||||||
|
if you're using the dev fallback location)
|
||||||
2. Add `manifest.json` with required fields
|
2. Add `manifest.json` with required fields
|
||||||
3. Create `manager.py` with plugin class
|
3. Create `manager.py` with plugin class
|
||||||
4. Add `config_schema.json` for configuration
|
4. Add `config_schema.json` for configuration
|
||||||
@@ -63,7 +88,13 @@ Plugins are configured in `config/config.json`:
|
|||||||
### 3. Testing Plugins
|
### 3. Testing Plugins
|
||||||
|
|
||||||
**On Development Machine:**
|
**On Development Machine:**
|
||||||
- Use emulator: `python run.py --emulator` or `./run_emulator.sh`
|
- Run the dev preview server: `python3 scripts/dev_server.py` (then
|
||||||
|
open `http://localhost:5001`) — renders plugins in the browser
|
||||||
|
without running the full display loop
|
||||||
|
- Or run the full display in emulator mode:
|
||||||
|
`python3 run.py --emulator` (or equivalently
|
||||||
|
`EMULATOR=true python3 run.py`, or `./scripts/dev/run_emulator.sh`).
|
||||||
|
The `-e`/`--emulator` CLI flag is defined in `run.py:19-20`.
|
||||||
- Test plugin loading: Check logs for plugin discovery and loading
|
- Test plugin loading: Check logs for plugin discovery and loading
|
||||||
- Validate configuration: Ensure config matches `config_schema.json`
|
- Validate configuration: Ensure config matches `config_schema.json`
|
||||||
|
|
||||||
@@ -75,15 +106,22 @@ Plugins are configured in `config/config.json`:
|
|||||||
### 4. Plugin Development Best Practices
|
### 4. Plugin Development Best Practices
|
||||||
|
|
||||||
**Code Organization:**
|
**Code Organization:**
|
||||||
- Keep plugin code in `plugins/<plugin-id>/`
|
- Keep plugin code in `plugin-repos/<plugin-id>/` (or its dev-time
|
||||||
|
symlink in `plugins/<plugin-id>/`)
|
||||||
- Use shared assets from `assets/` directory when possible
|
- Use shared assets from `assets/` directory when possible
|
||||||
- Follow existing plugin patterns (see `plugins/hockey-scoreboard/` as reference)
|
- Follow existing plugin patterns — canonical sources live in the
|
||||||
|
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
repo (`plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`,
|
||||||
|
`plugins/clock-simple/`, etc.)
|
||||||
- Place shared utilities in `src/common/` if reusable across plugins
|
- Place shared utilities in `src/common/` if reusable across plugins
|
||||||
|
|
||||||
**Configuration Management:**
|
**Configuration Management:**
|
||||||
- Use `config_schema.json` for validation
|
- Use `config_schema.json` for validation
|
||||||
- Store secrets in `config/config_secrets.json` (not in main config)
|
- Store secrets in `config/config_secrets.json` under the same plugin
|
||||||
- Reference secrets via `config_secrets` key in main config
|
id namespace as the main config — they're deep-merged into the main
|
||||||
|
config at load time (`src/config_manager.py:162-172`), so plugin
|
||||||
|
code reads them directly from `config.get(...)` like any other key
|
||||||
|
- There is no separate `config_secrets` reference field
|
||||||
- Validate all required fields in `validate_config()`
|
- Validate all required fields in `validate_config()`
|
||||||
|
|
||||||
**Error Handling:**
|
**Error Handling:**
|
||||||
@@ -138,18 +176,32 @@ Located in: `src/display_manager.py`
|
|||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
- `clear()`: Clear the display
|
- `clear()`: Clear the display
|
||||||
- `draw_text(text, x, y, color, font)`: Draw text
|
- `draw_text(text, x, y, color, font, small_font, centered)`: Draw text
|
||||||
- `draw_image(image, x, y)`: Draw PIL Image
|
- `update_display()`: Push the buffer to the physical display
|
||||||
- `update_display()`: Update physical display
|
- `draw_weather_icon(condition, x, y, size)`: Draw a weather icon
|
||||||
- `width`, `height`: Display dimensions
|
- `width`, `height`: Display dimensions
|
||||||
|
|
||||||
|
**Image rendering**: there is no `draw_image()` helper. Paste directly
|
||||||
|
onto the underlying PIL Image:
|
||||||
|
```python
|
||||||
|
self.display_manager.image.paste(pil_image, (x, y))
|
||||||
|
self.display_manager.update_display()
|
||||||
|
```
|
||||||
|
For transparency, paste with a mask: `image.paste(rgba, (x, y), rgba)`.
|
||||||
|
|
||||||
### Cache Manager
|
### Cache Manager
|
||||||
Located in: `src/cache_manager.py`
|
Located in: `src/cache_manager.py`
|
||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
- `get(key, max_age=None)`: Get cached value
|
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
|
||||||
- `set(key, value, ttl=None)`: Cache a value
|
- `set(key, value, ttl=None)`: Cache a value
|
||||||
- `delete(key)`: Remove cached value
|
- `delete(key)` / `clear_cache(key=None)`: Remove a single cache entry,
|
||||||
|
or (for `clear_cache` with no argument) every cached entry. `delete`
|
||||||
|
is an alias for `clear_cache(key)`.
|
||||||
|
- `get_cached_data_with_strategy(key, data_type)`: Cache get with
|
||||||
|
data-type-aware TTL strategy
|
||||||
|
- `get_background_cached_data(key, sport_key)`: Cache get for the
|
||||||
|
background-fetch service path
|
||||||
|
|
||||||
## Plugin Manifest Schema
|
## Plugin Manifest Schema
|
||||||
|
|
||||||
|
|||||||
96
.github/ISSUE_TEMPLATE/bug_report.md
vendored
96
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,38 +1,84 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Report a problem with LEDMatrix
|
||||||
title: ''
|
title: ''
|
||||||
labels: ''
|
labels: bug
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
<!--
|
||||||
A clear and concise description of what the bug is.
|
Before filing: please check existing issues to see if this is already
|
||||||
|
reported. For security issues, see SECURITY.md and report privately.
|
||||||
|
-->
|
||||||
|
|
||||||
**To Reproduce**
|
## Describe the bug
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
<!-- A clear and concise description of what the bug is. -->
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
## Steps to reproduce
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
1.
|
||||||
- OS: [e.g. iOS]
|
2.
|
||||||
- Browser [e.g. chrome, safari]
|
3.
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
## Expected behavior
|
||||||
- Device: [e.g. iPhone6]
|
|
||||||
- OS: [e.g. iOS8.1]
|
|
||||||
- Browser [e.g. stock browser, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
<!-- What you expected to happen. -->
|
||||||
Add any other context about the problem here.
|
|
||||||
|
## Actual behavior
|
||||||
|
|
||||||
|
<!-- What actually happened. Include any error messages. -->
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
- **Raspberry Pi model**: <!-- e.g. Pi 3B+, Pi 4 8GB, Pi Zero 2W -->
|
||||||
|
- **OS / kernel**: <!-- output of `cat /etc/os-release` and `uname -a` -->
|
||||||
|
- **LED matrix panels**: <!-- e.g. 2x Adafruit 64x32, 1x Waveshare 96x48 -->
|
||||||
|
- **HAT / Bonnet**: <!-- e.g. Adafruit RGB Matrix Bonnet, Electrodragon HAT -->
|
||||||
|
- **PWM jumper mod soldered?**: <!-- yes / no -->
|
||||||
|
- **Display chain**: <!-- chain_length × parallel, e.g. "2x1" -->
|
||||||
|
|
||||||
|
## LEDMatrix version
|
||||||
|
|
||||||
|
<!-- Run `git rev-parse HEAD` in the LEDMatrix directory, or paste the
|
||||||
|
release tag if you installed from a release. -->
|
||||||
|
|
||||||
|
```
|
||||||
|
git commit:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin involved (if any)
|
||||||
|
|
||||||
|
- **Plugin id**:
|
||||||
|
- **Plugin version** (from `manifest.json`):
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
<!-- Paste the relevant section from config/config.json. Redact any
|
||||||
|
API keys before pasting. For display issues, the `display.hardware`
|
||||||
|
block is most relevant. For plugin issues, paste that plugin's section. -->
|
||||||
|
|
||||||
|
```json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
<!-- The first 50 lines of the relevant log are usually enough. Run:
|
||||||
|
sudo journalctl -u ledmatrix -n 100 --no-pager
|
||||||
|
or for the web service:
|
||||||
|
sudo journalctl -u ledmatrix-web -n 100 --no-pager
|
||||||
|
-->
|
||||||
|
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots / video (optional)
|
||||||
|
|
||||||
|
<!-- A photo of the actual display, or a screenshot of the web UI,
|
||||||
|
helps a lot for visual issues. -->
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
<!-- Anything else that might be relevant: when did this start happening,
|
||||||
|
what's different about your setup, what have you already tried, etc. -->
|
||||||
|
|||||||
62
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
62
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Pull Request
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- 1-3 sentences describing what this PR does and why. -->
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
<!-- Check all that apply. -->
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Refactor (no functional change)
|
||||||
|
- [ ] Build / CI
|
||||||
|
- [ ] Plugin work (link to the plugin)
|
||||||
|
|
||||||
|
## Related issues
|
||||||
|
|
||||||
|
<!-- "Fixes #123" or "Refs #123". Use "Fixes" for bug PRs so the issue
|
||||||
|
auto-closes when this merges. -->
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
<!-- How did you test this? Check all that apply. Add details for any
|
||||||
|
checked box. -->
|
||||||
|
|
||||||
|
- [ ] Ran on a real Raspberry Pi with hardware
|
||||||
|
- [ ] Ran in emulator mode (`EMULATOR=true python3 run.py`)
|
||||||
|
- [ ] Ran the dev preview server (`scripts/dev_server.py`)
|
||||||
|
- [ ] Ran the test suite (`pytest`)
|
||||||
|
- [ ] Manually verified the affected code path in the web UI
|
||||||
|
- [ ] N/A — documentation-only change
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [ ] I updated `README.md` if user-facing behavior changed
|
||||||
|
- [ ] I updated the relevant doc in `docs/` if developer behavior changed
|
||||||
|
- [ ] I added/updated docstrings on new public functions
|
||||||
|
- [ ] N/A — no docs needed
|
||||||
|
|
||||||
|
## Plugin compatibility
|
||||||
|
|
||||||
|
<!-- For changes to BasePlugin, the plugin loader, the web UI, or the
|
||||||
|
config schema. -->
|
||||||
|
|
||||||
|
- [ ] No plugin breakage expected
|
||||||
|
- [ ] Some plugins will need updates — listed below
|
||||||
|
- [ ] N/A — change doesn't touch the plugin system
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] My commits follow the message convention in `CONTRIBUTING.md`
|
||||||
|
- [ ] I read `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md`
|
||||||
|
- [ ] I've not committed any secrets or hardcoded API keys
|
||||||
|
- [ ] If this adds a new config key, the form in the web UI was
|
||||||
|
verified (the form is generated from `config_schema.json`)
|
||||||
|
|
||||||
|
## Notes for reviewer
|
||||||
|
|
||||||
|
<!-- Anything reviewers should know — gotchas, things you weren't
|
||||||
|
sure about, decisions you'd like a second opinion on. -->
|
||||||
1
.gitmodules
vendored
1
.gitmodules
vendored
@@ -1,3 +1,4 @@
|
|||||||
[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
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@@ -4,8 +4,14 @@
|
|||||||
- `src/plugin_system/` — Plugin loader, manager, store manager, base plugin class
|
- `src/plugin_system/` — Plugin loader, manager, store manager, base plugin class
|
||||||
- `web_interface/` — Flask web UI (blueprints, templates, static JS)
|
- `web_interface/` — Flask web UI (blueprints, templates, static JS)
|
||||||
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
||||||
- `plugins/` — Installed plugins directory (gitignored)
|
- `plugin-repos/` — **Default** plugin install directory used by the
|
||||||
- `plugin-repos/` — Development symlinks to monorepo plugin dirs
|
Plugin Store, set by `plugin_system.plugins_directory` in
|
||||||
|
`config.json` (default per `config/config.template.json:130`).
|
||||||
|
Not gitignored.
|
||||||
|
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
||||||
|
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
||||||
|
loader falls back to it when something isn't found in `plugin-repos/`
|
||||||
|
(`src/plugin_system/schema_manager.py:77`).
|
||||||
|
|
||||||
## Plugin System
|
## Plugin System
|
||||||
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
||||||
|
|||||||
137
CODE_OF_CONDUCT.md
Normal file
137
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official email address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
This includes the LEDMatrix Discord server, GitHub repositories owned by
|
||||||
|
ChuckBuilds, and any other forums hosted by or affiliated with the project.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement on the
|
||||||
|
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT) (DM a moderator or
|
||||||
|
ChuckBuilds directly) or by opening a private GitHub Security Advisory if
|
||||||
|
the issue involves account safety. All complaints will be reviewed and
|
||||||
|
investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
|
||||||
|
at [https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
113
CONTRIBUTING.md
Normal file
113
CONTRIBUTING.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Contributing to LEDMatrix
|
||||||
|
|
||||||
|
Thanks for considering a contribution! LEDMatrix is built with help from
|
||||||
|
the community and we welcome bug reports, plugins, documentation
|
||||||
|
improvements, and code changes.
|
||||||
|
|
||||||
|
## Quick links
|
||||||
|
|
||||||
|
- **Bugs / feature requests**: open an issue using one of the templates
|
||||||
|
in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/).
|
||||||
|
- **Real-time discussion**: the
|
||||||
|
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT).
|
||||||
|
- **Plugin development**:
|
||||||
|
[`docs/PLUGIN_DEVELOPMENT_GUIDE.md`](docs/PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||||
|
and the [`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
repository.
|
||||||
|
- **Security issues**: see [`SECURITY.md`](SECURITY.md). Please don't
|
||||||
|
open public issues for vulnerabilities.
|
||||||
|
|
||||||
|
## Setting up a development environment
|
||||||
|
|
||||||
|
1. Clone with submodules:
|
||||||
|
```bash
|
||||||
|
git clone --recurse-submodules https://github.com/ChuckBuilds/LEDMatrix.git
|
||||||
|
cd LEDMatrix
|
||||||
|
```
|
||||||
|
2. For development without hardware, run the dev preview server:
|
||||||
|
```bash
|
||||||
|
python3 scripts/dev_server.py
|
||||||
|
# then open http://localhost:5001
|
||||||
|
```
|
||||||
|
See [`docs/DEV_PREVIEW.md`](docs/DEV_PREVIEW.md) for details.
|
||||||
|
3. To run the full display in emulator mode:
|
||||||
|
```bash
|
||||||
|
EMULATOR=true python3 run.py
|
||||||
|
```
|
||||||
|
4. To target real hardware on a Raspberry Pi, follow the install
|
||||||
|
instructions in the root [`README.md`](README.md).
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`docs/HOW_TO_RUN_TESTS.md`](docs/HOW_TO_RUN_TESTS.md) for details
|
||||||
|
on test markers, the per-plugin tests, and the web-interface
|
||||||
|
integration tests.
|
||||||
|
|
||||||
|
## Submitting changes
|
||||||
|
|
||||||
|
1. **Open an issue first** for non-trivial changes. This avoids
|
||||||
|
wasted work on PRs that don't fit the project direction.
|
||||||
|
2. **Create a topic branch** off `main`:
|
||||||
|
`feat/<short-description>`, `fix/<short-description>`,
|
||||||
|
`docs/<short-description>`.
|
||||||
|
3. **Keep PRs focused.** One conceptual change per PR. If you find
|
||||||
|
adjacent bugs while working, fix them in a separate PR.
|
||||||
|
4. **Follow the existing code style.** Python code uses standard
|
||||||
|
`black`/`ruff` conventions; HTML/JS in `web_interface/` follows the
|
||||||
|
patterns already in `templates/v3/` and `static/v3/`.
|
||||||
|
5. **Update documentation** alongside code changes. If you add a
|
||||||
|
config key, document it in the relevant `*.md` file (or, for
|
||||||
|
plugins, in `config_schema.json` so the form is auto-generated).
|
||||||
|
6. **Run the tests** locally before opening the PR.
|
||||||
|
7. **Use the PR template** — `.github/PULL_REQUEST_TEMPLATE.md` will
|
||||||
|
prompt you for what we need.
|
||||||
|
|
||||||
|
## Commit message convention
|
||||||
|
|
||||||
|
Conventional Commits is encouraged but not strictly enforced:
|
||||||
|
|
||||||
|
- `feat: add NHL playoff bracket display`
|
||||||
|
- `fix(plugin-loader): handle missing class_name in manifest`
|
||||||
|
- `docs: correct web UI port in TROUBLESHOOTING.md`
|
||||||
|
- `refactor(cache): consolidate strategy lookup`
|
||||||
|
|
||||||
|
Keep the subject under 72 characters; put the why in the body.
|
||||||
|
|
||||||
|
## Contributing a plugin
|
||||||
|
|
||||||
|
LEDMatrix plugins live in their own repository:
|
||||||
|
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins).
|
||||||
|
Plugin contributions go through that repo's
|
||||||
|
[`SUBMISSION.md`](https://github.com/ChuckBuilds/ledmatrix-plugins/blob/main/SUBMISSION.md)
|
||||||
|
process. The
|
||||||
|
[`hello-world` plugin](https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hello-world)
|
||||||
|
is the canonical starter template.
|
||||||
|
|
||||||
|
## Reviewing pull requests
|
||||||
|
|
||||||
|
Maintainer review is by [@ChuckBuilds](https://github.com/ChuckBuilds).
|
||||||
|
Community review is welcome on any open PR — leave constructive
|
||||||
|
comments, test on your hardware if applicable, and call out anything
|
||||||
|
unclear.
|
||||||
|
|
||||||
|
## Code of conduct
|
||||||
|
|
||||||
|
This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). By
|
||||||
|
participating you agree to abide by its terms.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LEDMatrix is licensed under the [GNU General Public License v3.0 or
|
||||||
|
later](LICENSE). By submitting a contribution you agree to license it
|
||||||
|
under the same terms (the standard "inbound = outbound" rule that
|
||||||
|
GitHub applies by default).
|
||||||
|
|
||||||
|
LEDMatrix builds on
|
||||||
|
[`rpi-rgb-led-matrix`](https://github.com/hzeller/rpi-rgb-led-matrix),
|
||||||
|
which is GPL-2.0-or-later. The "or later" clause makes it compatible
|
||||||
|
with GPL-3.0 distribution.
|
||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
33
README.md
33
README.md
@@ -1,4 +1,5 @@
|
|||||||
# LEDMatrix
|
# 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.
|
||||||
|
|
||||||
@@ -782,14 +783,18 @@ The LEDMatrix system includes Web Interface that runs on port 5000 and provides
|
|||||||
|
|
||||||
### Installing the Web Interface Service
|
### Installing the Web Interface Service
|
||||||
|
|
||||||
|
> The first-time installer (`first_time_install.sh`) already installs the
|
||||||
|
> web service. The steps below only apply if you need to (re)install it
|
||||||
|
> manually.
|
||||||
|
|
||||||
1. Make the install script executable:
|
1. Make the install script executable:
|
||||||
```bash
|
```bash
|
||||||
chmod +x install_web_service.sh
|
chmod +x scripts/install/install_web_service.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Run the install script with sudo:
|
2. Run the install script with sudo:
|
||||||
```bash
|
```bash
|
||||||
sudo ./install_web_service.sh
|
sudo ./scripts/install/install_web_service.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script will:
|
The script will:
|
||||||
@@ -874,3 +879,27 @@ sudo systemctl enable ledmatrix-web.service
|
|||||||
|
|
||||||
|
|
||||||
### If you've read this far — thanks!
|
### If you've read this far — thanks!
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LEDMatrix is licensed under the
|
||||||
|
[GNU General Public License v3.0 or later](LICENSE).
|
||||||
|
|
||||||
|
LEDMatrix builds on
|
||||||
|
[`rpi-rgb-led-matrix`](https://github.com/hzeller/rpi-rgb-led-matrix),
|
||||||
|
which is GPL-2.0-or-later. The "or later" clause makes it compatible
|
||||||
|
with GPL-3.0 distribution.
|
||||||
|
|
||||||
|
Plugin contributions in
|
||||||
|
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
are also GPL-3.0-or-later unless individual plugins specify otherwise.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the PR
|
||||||
|
flow, and how to add a plugin. Bug reports and feature requests go in
|
||||||
|
the [issue tracker](https://github.com/ChuckBuilds/LEDMatrix/issues).
|
||||||
|
Security issues should be reported privately per
|
||||||
|
[SECURITY.md](SECURITY.md).
|
||||||
|
|||||||
86
SECURITY.md
Normal file
86
SECURITY.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
If you've found a security issue in LEDMatrix, **please don't open a
|
||||||
|
public GitHub issue**. Disclose it privately so we can fix it before it's
|
||||||
|
exploited.
|
||||||
|
|
||||||
|
### How to report
|
||||||
|
|
||||||
|
Use one of these channels, in order of preference:
|
||||||
|
|
||||||
|
1. **GitHub Security Advisories** (preferred). On the LEDMatrix repo,
|
||||||
|
go to **Security → Advisories → Report a vulnerability**. This
|
||||||
|
creates a private discussion thread visible only to you and the
|
||||||
|
maintainer.
|
||||||
|
- Direct link: <https://github.com/ChuckBuilds/LEDMatrix/security/advisories/new>
|
||||||
|
2. **Discord DM**. Send a direct message to a moderator on the
|
||||||
|
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT). Don't post in
|
||||||
|
public channels.
|
||||||
|
|
||||||
|
Please include:
|
||||||
|
|
||||||
|
- A description of the issue
|
||||||
|
- The version / commit hash you're testing against
|
||||||
|
- Steps to reproduce, ideally a minimal proof of concept
|
||||||
|
- The impact you can demonstrate
|
||||||
|
- Any suggested mitigation
|
||||||
|
|
||||||
|
### What to expect
|
||||||
|
|
||||||
|
- An acknowledgement within a few days (this is a hobby project, not
|
||||||
|
a 24/7 ops team).
|
||||||
|
- A discussion of the issue's severity and a plan for the fix.
|
||||||
|
- Credit in the release notes when the fix ships, unless you'd
|
||||||
|
prefer to remain anonymous.
|
||||||
|
- For high-severity issues affecting active deployments, we'll
|
||||||
|
coordinate disclosure timing with you.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope for this policy:
|
||||||
|
|
||||||
|
- The LEDMatrix display controller, web interface, and plugin loader
|
||||||
|
in this repository
|
||||||
|
- The official plugins in
|
||||||
|
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
- Installation scripts and systemd unit files
|
||||||
|
|
||||||
|
Out of scope (please report upstream):
|
||||||
|
|
||||||
|
- Vulnerabilities in `rpi-rgb-led-matrix` itself —
|
||||||
|
report to <https://github.com/hzeller/rpi-rgb-led-matrix>
|
||||||
|
- Vulnerabilities in Python packages we depend on — report to the
|
||||||
|
upstream package maintainer
|
||||||
|
- Issues in third-party plugins not in `ledmatrix-plugins` — report
|
||||||
|
to that plugin's repository
|
||||||
|
|
||||||
|
## Known security model
|
||||||
|
|
||||||
|
LEDMatrix is designed for trusted local networks. Several limitations
|
||||||
|
are intentional rather than vulnerabilities:
|
||||||
|
|
||||||
|
- **No web UI authentication.** The web interface assumes the network
|
||||||
|
it's running on is trusted. Don't expose port 5000 to the internet.
|
||||||
|
- **Plugins run unsandboxed.** Installed plugins execute in the same
|
||||||
|
Python process as the display loop with full file-system and
|
||||||
|
network access. Review plugin code (especially third-party plugins
|
||||||
|
from arbitrary GitHub URLs) before installing. The Plugin Store
|
||||||
|
marks community plugins as **Custom** to highlight this.
|
||||||
|
- **The display service runs as root** for hardware GPIO access. This
|
||||||
|
is required by `rpi-rgb-led-matrix`.
|
||||||
|
- **`config_secrets.json` is plaintext.** API keys and tokens are
|
||||||
|
stored unencrypted on the Pi. Lock down filesystem permissions on
|
||||||
|
the config directory if this matters for your deployment.
|
||||||
|
|
||||||
|
These are documented as known limitations rather than bugs. If you
|
||||||
|
have ideas for improving them while keeping the project usable on a
|
||||||
|
Pi, open a discussion — we're interested.
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
LEDMatrix is rolling-release on `main`. Security fixes land on `main`
|
||||||
|
and become available the next time users run **Update Code** from the
|
||||||
|
web UI's Overview tab (which does a `git pull`). There are no LTS
|
||||||
|
branches.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 657 KiB |
@@ -112,7 +112,8 @@
|
|||||||
"limit_refresh_rate_hz": 100
|
"limit_refresh_rate_hz": 100
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"gpio_slowdown": 3
|
"gpio_slowdown": 3,
|
||||||
|
"rp1_rio": 0
|
||||||
},
|
},
|
||||||
"display_durations": {},
|
"display_durations": {},
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
@@ -126,6 +127,11 @@
|
|||||||
"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,
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
{
|
{
|
||||||
"ledmatrix-weather": {
|
|
||||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
|
||||||
},
|
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||||
},
|
},
|
||||||
"music": {
|
|
||||||
"SPOTIFY_CLIENT_ID": "YOUR_SPOTIFY_CLIENT_ID_HERE",
|
|
||||||
"SPOTIFY_CLIENT_SECRET": "YOUR_SPOTIFY_CLIENT_SECRET_HERE",
|
|
||||||
"SPOTIFY_REDIRECT_URI": "http://127.0.0.1:8888/callback"
|
|
||||||
},
|
|
||||||
"github": {
|
"github": {
|
||||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -437,26 +437,26 @@ When on-demand expires or is cleared, the display returns to the next highest pr
|
|||||||
|
|
||||||
### Web Interface Controls
|
### Web Interface Controls
|
||||||
|
|
||||||
**Access:** Navigate to Settings → Plugin Management
|
Each installed plugin has its own tab in the second nav row of the web
|
||||||
|
UI. Inside the plugin's tab, scroll to **On-Demand Controls**:
|
||||||
|
|
||||||
**Controls:**
|
- **Run On-Demand** — triggers the plugin immediately, even if it's
|
||||||
- **Show Now Button** - Triggers plugin immediately
|
disabled in the rotation
|
||||||
- **Duration Slider** - Set display time (0 = indefinite)
|
- **Stop On-Demand** — clears on-demand and returns to the normal
|
||||||
- **Pin Checkbox** - Keep showing until manually cleared
|
rotation
|
||||||
- **Stop Button** - Clear on-demand and return to rotation
|
|
||||||
- **Shift+Click Stop** - Stop the entire display service
|
|
||||||
|
|
||||||
**Status Card:**
|
The display service must be running. The status banner at the top of
|
||||||
- Real-time status updates
|
the plugin tab shows the active on-demand plugin, mode, and remaining
|
||||||
- Shows active plugin and remaining time
|
time when something is active.
|
||||||
- Pin status indicator
|
|
||||||
|
|
||||||
### REST API Reference
|
### REST API Reference
|
||||||
|
|
||||||
|
The API is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||||
|
|
||||||
#### Start On-Demand Display
|
#### Start On-Demand Display
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/display/on-demand/start
|
POST /api/v3/display/on-demand/start
|
||||||
|
|
||||||
# Body:
|
# Body:
|
||||||
{
|
{
|
||||||
@@ -467,20 +467,20 @@ POST /api/display/on-demand/start
|
|||||||
|
|
||||||
# Examples:
|
# Examples:
|
||||||
# 30-second preview
|
# 30-second preview
|
||||||
curl -X POST http://localhost:5050/api/display/on-demand/start \
|
curl -X POST http://localhost:5000/api/v3/display/on-demand/start \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "weather", "duration": 30}'
|
-d '{"plugin_id": "weather", "duration": 30}'
|
||||||
|
|
||||||
# Pin indefinitely
|
# Pin indefinitely
|
||||||
curl -X POST http://localhost:5050/api/display/on-demand/start \
|
curl -X POST http://localhost:5000/api/v3/display/on-demand/start \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "hockey-scores", "pinned": true}'
|
-d '{"plugin_id": "hockey-scoreboard", "pinned": true}'
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Stop On-Demand Display
|
#### Stop On-Demand Display
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/display/on-demand/stop
|
POST /api/v3/display/on-demand/stop
|
||||||
|
|
||||||
# Body:
|
# Body:
|
||||||
{
|
{
|
||||||
@@ -489,10 +489,10 @@ POST /api/display/on-demand/stop
|
|||||||
|
|
||||||
# Examples:
|
# Examples:
|
||||||
# Clear on-demand
|
# Clear on-demand
|
||||||
curl -X POST http://localhost:5050/api/display/on-demand/stop
|
curl -X POST http://localhost:5000/api/v3/display/on-demand/stop
|
||||||
|
|
||||||
# Stop service too
|
# Stop service too
|
||||||
curl -X POST http://localhost:5050/api/display/on-demand/stop \
|
curl -X POST http://localhost:5000/api/v3/display/on-demand/stop \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"stop_service": true}'
|
-d '{"stop_service": true}'
|
||||||
```
|
```
|
||||||
@@ -500,10 +500,10 @@ curl -X POST http://localhost:5050/api/display/on-demand/stop \
|
|||||||
#### Get On-Demand Status
|
#### Get On-Demand Status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /api/display/on-demand/status
|
GET /api/v3/display/on-demand/status
|
||||||
|
|
||||||
# Example:
|
# Example:
|
||||||
curl http://localhost:5050/api/display/on-demand/status
|
curl http://localhost:5000/api/v3/display/on-demand/status
|
||||||
|
|
||||||
# Response:
|
# Response:
|
||||||
{
|
{
|
||||||
@@ -516,35 +516,15 @@ curl http://localhost:5050/api/display/on-demand/status
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python API Methods
|
> There is no public Python on-demand API. The display controller's
|
||||||
|
> on-demand machinery is internal — drive it through the REST endpoints
|
||||||
```python
|
> above (or the web UI buttons), which write a request into the cache
|
||||||
from src.display_controller import DisplayController
|
> manager under the `display_on_demand_request` key
|
||||||
|
> (`web_interface/blueprints/api_v3.py:1622,1687`) that the controller
|
||||||
controller = DisplayController()
|
> polls at `src/display_controller.py:921`. A separate
|
||||||
|
> `display_on_demand_config` key is used by the controller itself
|
||||||
# Show plugin for 30 seconds
|
> during activation to track what's currently running (written at
|
||||||
controller.show_on_demand('weather', duration=30)
|
> `display_controller.py:1195`, cleared at `:1221`).
|
||||||
|
|
||||||
# Pin plugin until manually cleared
|
|
||||||
controller.show_on_demand('hockey-scores', pinned=True)
|
|
||||||
|
|
||||||
# Show indefinitely (not pinned, clears on expiry if duration set later)
|
|
||||||
controller.show_on_demand('weather', duration=0)
|
|
||||||
|
|
||||||
# Use plugin's default duration
|
|
||||||
controller.show_on_demand('weather')
|
|
||||||
|
|
||||||
# Clear on-demand
|
|
||||||
controller.clear_on_demand()
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
is_active = controller.is_on_demand_active()
|
|
||||||
|
|
||||||
# Get detailed info
|
|
||||||
info = controller.get_on_demand_info()
|
|
||||||
# Returns: {'active': bool, 'mode': str, 'duration': float, 'remaining': float, 'pinned': bool}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Duration Modes
|
### Duration Modes
|
||||||
|
|
||||||
@@ -557,27 +537,31 @@ info = controller.get_on_demand_info()
|
|||||||
|
|
||||||
### Use Case Examples
|
### Use Case Examples
|
||||||
|
|
||||||
**Quick Check (30-second preview):**
|
**Quick check (30-second preview):**
|
||||||
```python
|
```bash
|
||||||
controller.show_on_demand('weather', duration=30)
|
curl -X POST http://localhost:5000/api/v3/display/on-demand/start \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"plugin_id": "ledmatrix-weather", "duration": 30}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pin Important Information:**
|
**Pin important information:**
|
||||||
```python
|
```bash
|
||||||
controller.show_on_demand('game-score', pinned=True)
|
curl -X POST http://localhost:5000/api/v3/display/on-demand/start \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"plugin_id": "hockey-scoreboard", "pinned": true}'
|
||||||
# ... later ...
|
# ... later ...
|
||||||
controller.clear_on_demand()
|
curl -X POST http://localhost:5000/api/v3/display/on-demand/stop
|
||||||
```
|
```
|
||||||
|
|
||||||
**Indefinite Display:**
|
**Indefinite display:**
|
||||||
```python
|
```bash
|
||||||
controller.show_on_demand('welcome-message', duration=0)
|
curl -X POST http://localhost:5000/api/v3/display/on-demand/start \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"plugin_id": "text-display", "duration": 0}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Testing Plugin:**
|
**Testing a plugin during development:** the same call works, or just
|
||||||
```python
|
click **Run On-Demand** in the plugin's tab.
|
||||||
controller.show_on_demand('my-new-plugin', duration=60)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Best Practices
|
### Best Practices
|
||||||
|
|
||||||
@@ -613,7 +597,10 @@ controller.show_on_demand('my-new-plugin', duration=60)
|
|||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
On-demand display uses Redis cache keys to manage state across service restarts and coordinate between web interface and display controller. Understanding these keys helps troubleshoot stuck states.
|
On-demand display uses cache keys (managed by `src/cache_manager.py` —
|
||||||
|
file-based, not Redis) to coordinate state between the web interface
|
||||||
|
and the display controller across service restarts. Understanding these
|
||||||
|
keys helps troubleshoot stuck states.
|
||||||
|
|
||||||
### Cache Keys
|
### Cache Keys
|
||||||
|
|
||||||
@@ -688,19 +675,26 @@ On-demand display uses Redis cache keys to manage state across service restarts
|
|||||||
### Manual Recovery Procedures
|
### Manual Recovery Procedures
|
||||||
|
|
||||||
**Via Web Interface (Recommended):**
|
**Via Web Interface (Recommended):**
|
||||||
1. Navigate to Settings → Cache Management
|
1. Open the **Cache** tab in the web UI
|
||||||
2. Search for "on_demand" keys
|
2. Find the `display_on_demand_*` entries
|
||||||
3. Select keys to delete
|
3. Delete them
|
||||||
4. Click "Delete Selected"
|
4. Restart display: `sudo systemctl restart ledmatrix`
|
||||||
5. Restart display: `sudo systemctl restart ledmatrix`
|
|
||||||
|
|
||||||
**Via Command Line:**
|
**Via Command Line:**
|
||||||
```bash
|
|
||||||
# Clear specific key
|
|
||||||
redis-cli DEL display_on_demand_config
|
|
||||||
|
|
||||||
# Clear all on-demand keys
|
The cache is stored as JSON files under one of:
|
||||||
redis-cli KEYS "display_on_demand_*" | xargs redis-cli DEL
|
|
||||||
|
- `/var/cache/ledmatrix/` (preferred when the service has permission)
|
||||||
|
- `~/.cache/ledmatrix/`
|
||||||
|
- `/opt/ledmatrix/cache/`
|
||||||
|
- `/tmp/ledmatrix-cache/` (fallback)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the cache dir actually in use
|
||||||
|
journalctl -u ledmatrix | grep -i "cache directory" | tail -1
|
||||||
|
|
||||||
|
# Clear all on-demand keys (replace path with the one above)
|
||||||
|
rm /var/cache/ledmatrix/display_on_demand_*
|
||||||
|
|
||||||
# Restart service
|
# Restart service
|
||||||
sudo systemctl restart ledmatrix
|
sudo systemctl restart ledmatrix
|
||||||
@@ -711,19 +705,22 @@ sudo systemctl restart ledmatrix
|
|||||||
from src.cache_manager import CacheManager
|
from src.cache_manager import CacheManager
|
||||||
|
|
||||||
cache = CacheManager()
|
cache = CacheManager()
|
||||||
cache.delete('display_on_demand_config')
|
cache.clear_cache('display_on_demand_config')
|
||||||
cache.delete('display_on_demand_state')
|
cache.clear_cache('display_on_demand_state')
|
||||||
cache.delete('display_on_demand_request')
|
cache.clear_cache('display_on_demand_request')
|
||||||
cache.delete('display_on_demand_processed_id')
|
cache.clear_cache('display_on_demand_processed_id')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> The actual public method is `clear_cache(key=None)` — there is no
|
||||||
|
> `delete()` method on `CacheManager`.
|
||||||
|
|
||||||
### Cache Impact on Running Service
|
### Cache Impact on Running Service
|
||||||
|
|
||||||
**IMPORTANT:** Clearing cache keys does NOT immediately affect the running controller in memory.
|
**IMPORTANT:** Clearing cache keys does NOT immediately affect the running controller in memory.
|
||||||
|
|
||||||
**To fully reset:**
|
**To fully reset:**
|
||||||
1. Stop the service: `sudo systemctl stop ledmatrix`
|
1. Stop the service: `sudo systemctl stop ledmatrix`
|
||||||
2. Clear cache keys (web UI or redis-cli)
|
2. Clear cache keys (web UI Cache tab or `rm` from the cache directory)
|
||||||
3. Clear systemd environment: `sudo systemctl daemon-reload`
|
3. Clear systemd environment: `sudo systemctl daemon-reload`
|
||||||
4. Start the service: `sudo systemctl start ledmatrix`
|
4. Start the service: `sudo systemctl start ledmatrix`
|
||||||
|
|
||||||
@@ -767,7 +764,7 @@ Enable background service per plugin in `config/config.json`:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"nfl_scoreboard": {
|
"football-scoreboard": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"background_service": {
|
"background_service": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -801,19 +798,13 @@ Enable background service per plugin in `config/config.json`:
|
|||||||
- Returns immediately: < 0.1 seconds
|
- Returns immediately: < 0.1 seconds
|
||||||
- Background refresh (if stale): async, no blocking
|
- Background refresh (if stale): async, no blocking
|
||||||
|
|
||||||
### Implementation Status
|
### Plugins using the background service
|
||||||
|
|
||||||
**Phase 1 (Complete):**
|
The background data service is used by all of the sports scoreboard
|
||||||
- ✅ NFL scoreboard implemented
|
plugins (football, hockey, baseball/MLB, basketball, soccer, lacrosse,
|
||||||
- ✅ Background threading architecture
|
F1, UFC), the odds ticker, and the leaderboard plugin. Each plugin's
|
||||||
- ✅ Cache integration
|
`background_service` block (under its own config namespace) follows the
|
||||||
- ✅ Error handling and retry logic
|
same shape as the example above.
|
||||||
|
|
||||||
**Phase 2 (Planned):**
|
|
||||||
- ⏳ NCAAFB (college football)
|
|
||||||
- ⏳ NBA (basketball)
|
|
||||||
- ⏳ NHL (hockey)
|
|
||||||
- ⏳ MLB (baseball)
|
|
||||||
|
|
||||||
### Error Handling & Fallback
|
### Error Handling & Fallback
|
||||||
|
|
||||||
|
|||||||
@@ -250,19 +250,29 @@ WARNING - Plugin ID 'Football-Scoreboard' may conflict with 'football-scoreboard
|
|||||||
|
|
||||||
## Checking Configuration via API
|
## Checking Configuration via API
|
||||||
|
|
||||||
|
The API blueprint mounts at `/api/v3` (`web_interface/app.py:144`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Get current config
|
# Get full main config (includes all plugin sections)
|
||||||
curl http://localhost:5000/api/v3/config
|
curl http://localhost:5000/api/v3/config/main
|
||||||
|
|
||||||
# Get specific plugin config
|
# Save updated main config
|
||||||
curl http://localhost:5000/api/v3/config/plugin/football-scoreboard
|
curl -X POST http://localhost:5000/api/v3/config/main \
|
||||||
|
|
||||||
# Validate config without saving
|
|
||||||
curl -X POST http://localhost:5000/api/v3/config/validate \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"football-scoreboard": {"enabled": true}}'
|
-d @new-config.json
|
||||||
|
|
||||||
|
# Get config schema for a specific plugin
|
||||||
|
curl "http://localhost:5000/api/v3/plugins/schema?plugin_id=football-scoreboard"
|
||||||
|
|
||||||
|
# Get a single plugin's current config
|
||||||
|
curl "http://localhost:5000/api/v3/plugins/config?plugin_id=football-scoreboard"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> There is no dedicated `/config/plugin/<id>` or `/config/validate`
|
||||||
|
> endpoint — config validation runs server-side automatically when you
|
||||||
|
> POST to `/config/main` or `/plugins/config`. See
|
||||||
|
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for the full list.
|
||||||
|
|
||||||
## Backup and Recovery
|
## Backup and Recovery
|
||||||
|
|
||||||
### Manual Backup
|
### Manual Backup
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ display_manager.defer_update(lambda: self.update_cache(), priority=0)
|
|||||||
# Basic caching
|
# Basic caching
|
||||||
cached = cache_manager.get("key", max_age=3600)
|
cached = cache_manager.get("key", max_age=3600)
|
||||||
cache_manager.set("key", data)
|
cache_manager.set("key", data)
|
||||||
cache_manager.delete("key")
|
cache_manager.delete("key") # alias for clear_cache(key)
|
||||||
|
|
||||||
# Advanced caching
|
# Advanced caching
|
||||||
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
||||||
|
|||||||
@@ -141,19 +141,27 @@ stage('Checkout') {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Plugin Submodules
|
## Plugins
|
||||||
|
|
||||||
Plugin submodules are located in the `plugins/` directory and are managed similarly:
|
Plugins are **not** git submodules of this repository. The plugins
|
||||||
|
directory (configured by `plugin_system.plugins_directory` in
|
||||||
|
`config/config.json`, default `plugin-repos/`) is populated at install
|
||||||
|
time by the plugin loader as users install plugins from the Plugin Store
|
||||||
|
or from a GitHub URL via the web interface. Plugin source lives in a
|
||||||
|
separate repository:
|
||||||
|
[ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins).
|
||||||
|
|
||||||
**Initialize all plugin submodules:**
|
To work on a plugin locally without going through the Plugin Store, clone
|
||||||
```bash
|
that repo and symlink (or copy) the plugin directory into your configured
|
||||||
git submodule update --init --recursive plugins/
|
plugins directory — by default `plugin-repos/<plugin-id>/`. The plugin
|
||||||
```
|
loader will pick it up on the next display restart. The directory name
|
||||||
|
must match the plugin's `id` in `manifest.json`.
|
||||||
|
|
||||||
**Initialize a specific plugin:**
|
For more information, see:
|
||||||
```bash
|
|
||||||
git submodule update --init --recursive plugins/hockey-scoreboard
|
|
||||||
```
|
|
||||||
|
|
||||||
For more information about plugins, see the [Plugin Development Guide](.cursor/plugins_guide.md) and [Plugin Architecture Specification](docs/PLUGIN_ARCHITECTURE_SPEC.md).
|
|
||||||
|
|
||||||
|
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) — end-to-end
|
||||||
|
plugin development workflow
|
||||||
|
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) — plugin system
|
||||||
|
specification
|
||||||
|
- [DEV_PREVIEW.md](DEV_PREVIEW.md) — preview plugins on a desktop without a
|
||||||
|
Pi
|
||||||
|
|||||||
@@ -32,10 +32,15 @@ The LEDMatrix emulator allows you to run and test LEDMatrix displays on your com
|
|||||||
### 1. Clone the Repository
|
### 1. Clone the Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/LEDMatrix.git
|
git clone --recurse-submodules https://github.com/ChuckBuilds/LEDMatrix.git
|
||||||
cd LEDMatrix
|
cd LEDMatrix
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> The emulator does **not** require building the
|
||||||
|
> `rpi-rgb-led-matrix-master` submodule (it uses `RGBMatrixEmulator`
|
||||||
|
> instead), so `--recurse-submodules` is optional here. Run it anyway if
|
||||||
|
> you also want to test the real-hardware code path.
|
||||||
|
|
||||||
### 2. Install Emulator Dependencies
|
### 2. Install Emulator Dependencies
|
||||||
|
|
||||||
Install the emulator-specific requirements:
|
Install the emulator-specific requirements:
|
||||||
@@ -58,12 +63,13 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
### 1. Emulator Configuration File
|
### 1. Emulator Configuration File
|
||||||
|
|
||||||
The emulator uses `emulator_config.json` for configuration. Here's the default configuration:
|
The emulator uses `emulator_config.json` for configuration. Here's the
|
||||||
|
default configuration as it ships in the repo:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"pixel_outline": 0,
|
"pixel_outline": 0,
|
||||||
"pixel_size": 16,
|
"pixel_size": 5,
|
||||||
"pixel_style": "square",
|
"pixel_style": "square",
|
||||||
"pixel_glow": 6,
|
"pixel_glow": 6,
|
||||||
"display_adapter": "pygame",
|
"display_adapter": "pygame",
|
||||||
@@ -90,7 +96,7 @@ The emulator uses `emulator_config.json` for configuration. Here's the default c
|
|||||||
| Option | Description | Default | Values |
|
| Option | Description | Default | Values |
|
||||||
|--------|-------------|---------|--------|
|
|--------|-------------|---------|--------|
|
||||||
| `pixel_outline` | Pixel border thickness | 0 | 0-5 |
|
| `pixel_outline` | Pixel border thickness | 0 | 0-5 |
|
||||||
| `pixel_size` | Size of each pixel | 16 | 8-64 |
|
| `pixel_size` | Size of each pixel | 5 | 1-64 (8–16 is typical for testing) |
|
||||||
| `pixel_style` | Pixel shape | "square" | "square", "circle" |
|
| `pixel_style` | Pixel shape | "square" | "square", "circle" |
|
||||||
| `pixel_glow` | Glow effect intensity | 6 | 0-20 |
|
| `pixel_glow` | Glow effect intensity | 6 | 0-20 |
|
||||||
| `display_adapter` | Display backend | "pygame" | "pygame", "browser" |
|
| `display_adapter` | Display backend | "pygame" | "pygame", "browser" |
|
||||||
|
|||||||
@@ -138,6 +138,27 @@ font = self.font_manager.resolve_font(
|
|||||||
|
|
||||||
## For Plugin Developers
|
## For Plugin Developers
|
||||||
|
|
||||||
|
> **Note**: plugins that ship their own fonts via a `"fonts"` block
|
||||||
|
> in `manifest.json` are registered automatically during plugin load
|
||||||
|
> (`src/plugin_system/plugin_manager.py` calls
|
||||||
|
> `FontManager.register_plugin_fonts()`). The `plugin://…` source
|
||||||
|
> URIs documented below are resolved relative to the plugin's
|
||||||
|
> install directory.
|
||||||
|
>
|
||||||
|
> The **Fonts** tab in the web UI that lists detected
|
||||||
|
> manager-registered fonts is still a **placeholder
|
||||||
|
> implementation** — fonts that managers register through
|
||||||
|
> `register_manager_font()` do not yet appear there. The
|
||||||
|
> programmatic per-element override workflow described in
|
||||||
|
> [Manual Font Overrides](#manual-font-overrides) below
|
||||||
|
> (`set_override()` / `remove_override()` / the
|
||||||
|
> `config/font_overrides.json` store) **does** work today and is
|
||||||
|
> the supported way to override a font for an element until the
|
||||||
|
> Fonts tab is wired up. If you can't wait and need a workaround
|
||||||
|
> right now, you can also just load the font directly with PIL
|
||||||
|
> (or `freetype-py` for BDF) inside your plugin's `manager.py`
|
||||||
|
> and skip the override system entirely.
|
||||||
|
|
||||||
### Plugin Font Registration
|
### Plugin Font Registration
|
||||||
|
|
||||||
In your plugin's `manifest.json`:
|
In your plugin's `manifest.json`:
|
||||||
@@ -359,5 +380,8 @@ self.font = self.font_manager.resolve_font(
|
|||||||
|
|
||||||
## Example: Complete Manager Implementation
|
## Example: Complete Manager Implementation
|
||||||
|
|
||||||
See `test/font_manager_example.py` for a complete working example.
|
For a working example of the font manager API in use, see
|
||||||
|
`src/font_manager.py` itself and the bundled scoreboard base classes
|
||||||
|
in `src/base_classes/` (e.g., `hockey.py`, `football.py`) which
|
||||||
|
register and resolve fonts via the patterns documented above.
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ This guide will help you set up your LEDMatrix display for the first time and ge
|
|||||||
|
|
||||||
**If you see "LEDMatrix-Setup" WiFi network:**
|
**If you see "LEDMatrix-Setup" WiFi network:**
|
||||||
1. Connect your device to "LEDMatrix-Setup" (open network, no password)
|
1. Connect your device to "LEDMatrix-Setup" (open network, no password)
|
||||||
2. Open browser to: `http://192.168.4.1:5050`
|
2. Open browser to: `http://192.168.4.1:5000`
|
||||||
3. Navigate to the WiFi tab
|
3. Navigate to the WiFi tab
|
||||||
4. Click "Scan" to find your WiFi network
|
4. Click "Scan" to find your WiFi network
|
||||||
5. Select your network, enter password
|
5. Select your network, enter password
|
||||||
@@ -48,14 +48,14 @@ This guide will help you set up your LEDMatrix display for the first time and ge
|
|||||||
|
|
||||||
**If already connected to WiFi:**
|
**If already connected to WiFi:**
|
||||||
1. Find your Pi's IP address (check your router, or run `hostname -I` on the Pi)
|
1. Find your Pi's IP address (check your router, or run `hostname -I` on the Pi)
|
||||||
2. Open browser to: `http://your-pi-ip:5050`
|
2. Open browser to: `http://your-pi-ip:5000`
|
||||||
|
|
||||||
### 3. Access the Web Interface
|
### 3. Access the Web Interface
|
||||||
|
|
||||||
Once connected, access the web interface:
|
Once connected, access the web interface:
|
||||||
|
|
||||||
```
|
```
|
||||||
http://your-pi-ip:5050
|
http://your-pi-ip:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see:
|
You should see:
|
||||||
@@ -69,84 +69,84 @@ You should see:
|
|||||||
|
|
||||||
### Step 1: Configure Display Hardware
|
### Step 1: Configure Display Hardware
|
||||||
|
|
||||||
1. Navigate to Settings → **Display Settings**
|
1. Open the **Display** tab
|
||||||
2. Set your matrix configuration:
|
2. Set your matrix configuration:
|
||||||
- **Rows**: 32 or 64 (match your hardware)
|
- **Rows**: 32 or 64 (match your hardware)
|
||||||
- **Columns**: 64, 128, or 256 (match your hardware)
|
- **Columns**: commonly 64 or 96; the web UI accepts any integer
|
||||||
- **Chain Length**: Number of panels chained together
|
in the 16–128 range, but 64 and 96 are the values the bundled
|
||||||
- **Brightness**: 50-75% recommended for indoor use
|
panel hardware ships with
|
||||||
3. Click **Save Configuration**
|
- **Chain Length**: Number of panels chained horizontally
|
||||||
4. Click **Restart Display** to apply changes
|
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
|
||||||
|
mod) or `adafruit-hat` (without). See the root README for the full list.
|
||||||
|
- **Brightness**: 70–90 is fine for indoor use
|
||||||
|
3. Click **Save**
|
||||||
|
4. From the **Overview** tab, click **Restart Display Service** to apply
|
||||||
|
|
||||||
**Tip:** If the display doesn't look right, try different hardware mapping options.
|
**Tip:** if the display shows garbage or nothing, the most common culprits
|
||||||
|
are an incorrect `hardware_mapping`, a `gpio_slowdown` value that doesn't
|
||||||
|
match your Pi model, or panels needing the E-line mod. See
|
||||||
|
[TROUBLESHOOTING.md](TROUBLESHOOTING.md).
|
||||||
|
|
||||||
### Step 2: Set Timezone and Location
|
### Step 2: Set Timezone and Location
|
||||||
|
|
||||||
1. Navigate to Settings → **General Settings**
|
1. Open the **General** tab
|
||||||
2. Set your timezone (e.g., "America/New_York")
|
2. Set your timezone (e.g., `America/New_York`) and location
|
||||||
3. Set your location (city, state, country)
|
3. Click **Save**
|
||||||
4. Click **Save Configuration**
|
|
||||||
|
|
||||||
**Why it matters:** Correct timezone ensures accurate time display. Location enables weather and location-based features.
|
Correct timezone ensures accurate time display, and location is used by
|
||||||
|
weather and other location-aware plugins.
|
||||||
|
|
||||||
### Step 3: Install Plugins
|
### Step 3: Install Plugins
|
||||||
|
|
||||||
1. Navigate to **Plugin Store** tab
|
1. Open the **Plugin Manager** tab
|
||||||
2. Browse available plugins:
|
2. Scroll to the **Plugin Store** section to browse available plugins
|
||||||
- **Time & Date**: Clock, calendar
|
3. Click **Install** on the plugins you want
|
||||||
- **Weather**: Weather forecasts
|
4. Wait for installation to finish — installed plugins appear in the
|
||||||
- **Sports**: NHL, NBA, NFL, MLB scores
|
**Installed Plugins** section above and get their own tab in the second
|
||||||
- **Finance**: Stocks, crypto
|
nav row
|
||||||
- **Custom**: Community plugins
|
5. Toggle the plugin to enabled
|
||||||
3. Click **Install** on desired plugins
|
6. From **Overview**, click **Restart Display Service**
|
||||||
4. Wait for installation to complete
|
|
||||||
5. Navigate to **Plugin Management** tab
|
|
||||||
6. Enable installed plugins (toggle switch)
|
|
||||||
7. Click **Restart Display**
|
|
||||||
|
|
||||||
**Popular First Plugins:**
|
You can also install community plugins straight from a GitHub URL using the
|
||||||
- `clock-simple` - Simple digital clock
|
**Install from GitHub** section further down the same tab — see
|
||||||
- `weather` - Weather forecast
|
[PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) for details.
|
||||||
- `nhl-scores` - NHL scores (if you're a hockey fan)
|
|
||||||
|
|
||||||
### Step 4: Configure Plugins
|
### Step 4: Configure Plugins
|
||||||
|
|
||||||
1. Navigate to **Plugin Management** tab
|
1. Each installed plugin gets its own tab in the second navigation row
|
||||||
2. Find a plugin you installed
|
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
|
||||||
3. Click the ⚙️ **Configure** button
|
update intervals, display duration, etc.)
|
||||||
4. Edit settings (e.g., favorite teams, update intervals)
|
3. Click **Save**
|
||||||
5. Click **Save**
|
4. Restart the display service from **Overview** so the new settings take
|
||||||
6. Click **Restart Display**
|
effect
|
||||||
|
|
||||||
**Example: Weather Plugin**
|
**Example: Weather Plugin**
|
||||||
- Set your location (city, state, country)
|
- Set your location (city, state, country)
|
||||||
- Add API key from OpenWeatherMap (free signup)
|
- Add an API key from OpenWeatherMap (free signup) to
|
||||||
- Set update interval (300 seconds recommended)
|
`config/config_secrets.json` or directly in the plugin's config screen
|
||||||
|
- Set the update interval (300 seconds is reasonable)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing Your Display
|
## Testing Your Display
|
||||||
|
|
||||||
### Quick Test
|
### Run a single plugin on demand
|
||||||
|
|
||||||
1. Navigate to **Overview** tab
|
The fastest way to verify a plugin works without waiting for the rotation:
|
||||||
2. Click **Test Display** button
|
|
||||||
3. You should see a test pattern on your LED matrix
|
|
||||||
|
|
||||||
### Manual Plugin Trigger
|
1. Open the plugin's tab (second nav row)
|
||||||
|
2. Scroll to **On-Demand Controls**
|
||||||
|
3. Click **Run On-Demand** — the plugin runs immediately even if disabled
|
||||||
|
4. Click **Stop On-Demand** to return to the normal rotation
|
||||||
|
|
||||||
1. Navigate to **Plugin Management** tab
|
### Check the live preview and logs
|
||||||
2. Find a plugin
|
|
||||||
3. Click **Show Now** button
|
|
||||||
4. The plugin should display immediately
|
|
||||||
5. Click **Stop** to return to rotation
|
|
||||||
|
|
||||||
### Check Logs
|
- The **Overview** tab shows a **Live Display Preview** that mirrors what's
|
||||||
|
on the matrix in real time — handy for debugging without looking at the
|
||||||
1. Navigate to **Logs** tab
|
panel.
|
||||||
2. Watch real-time logs
|
- The **Logs** tab streams the display and web service logs. Look for
|
||||||
3. Look for any ERROR messages
|
`ERROR` lines if something isn't working; normal operation just shows
|
||||||
4. Normal operation shows INFO messages about plugin rotation
|
`INFO` messages about plugin rotation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,12 +156,12 @@ You should see:
|
|||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
1. Power supply connected and adequate (5V, 4A minimum)
|
1. Power supply connected and adequate (5V, 4A minimum)
|
||||||
2. LED matrix connected to GPIO pins correctly
|
2. LED matrix connected to the bonnet/HAT correctly
|
||||||
3. Display service running: `sudo systemctl status ledmatrix`
|
3. Display service running: `sudo systemctl status ledmatrix`
|
||||||
4. Hardware configuration matches your matrix (rows/columns)
|
4. Hardware configuration matches your matrix (rows/cols/chain length)
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
1. Restart display: Settings → Overview → Restart Display
|
1. Restart from the **Overview** tab → **Restart Display Service**
|
||||||
2. Or via SSH: `sudo systemctl restart ledmatrix`
|
2. Or via SSH: `sudo systemctl restart ledmatrix`
|
||||||
|
|
||||||
### Web Interface Won't Load
|
### Web Interface Won't Load
|
||||||
@@ -169,8 +169,8 @@ You should see:
|
|||||||
**Check:**
|
**Check:**
|
||||||
1. Pi is connected to network: `ping your-pi-ip`
|
1. Pi is connected to network: `ping your-pi-ip`
|
||||||
2. Web service running: `sudo systemctl status ledmatrix-web`
|
2. Web service running: `sudo systemctl status ledmatrix-web`
|
||||||
3. Correct port: Use `:5050` not `:5000`
|
3. Correct port: the web UI listens on `:5000`
|
||||||
4. Firewall not blocking port 5050
|
4. Firewall not blocking port 5000
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
1. Restart web service: `sudo systemctl restart ledmatrix-web`
|
1. Restart web service: `sudo systemctl restart ledmatrix-web`
|
||||||
@@ -179,15 +179,15 @@ You should see:
|
|||||||
### Plugins Not Showing
|
### Plugins Not Showing
|
||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
1. Plugins are enabled (toggle switch in Plugin Management)
|
1. Plugin is enabled (toggle on the **Plugin Manager** tab)
|
||||||
2. Display has been restarted after enabling
|
2. Display service was restarted after enabling
|
||||||
3. Plugin duration is reasonable (not too short)
|
3. Plugin's display duration is non-zero
|
||||||
4. No errors in logs for the plugin
|
4. No errors in the **Logs** tab for that plugin
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
1. Enable plugin in Plugin Management
|
1. Enable the plugin from **Plugin Manager**
|
||||||
2. Restart display
|
2. Click **Restart Display Service** on **Overview**
|
||||||
3. Check logs for plugin-specific errors
|
3. Check the **Logs** tab for plugin-specific errors
|
||||||
|
|
||||||
### Weather Plugin Shows "No Data"
|
### Weather Plugin Shows "No Data"
|
||||||
|
|
||||||
@@ -207,18 +207,18 @@ You should see:
|
|||||||
|
|
||||||
### Customize Your Display
|
### Customize Your Display
|
||||||
|
|
||||||
**Adjust Display Durations:**
|
**Adjust display durations:**
|
||||||
- Navigate to Settings → Durations
|
- Each plugin's tab has a **Display Duration (seconds)** field — set how
|
||||||
- Set how long each plugin displays
|
long that plugin stays on screen each rotation.
|
||||||
- Save and restart
|
|
||||||
|
|
||||||
**Organize Plugin Order:**
|
**Organize plugin order:**
|
||||||
- Use Plugin Management to enable/disable plugins
|
- Use the **Plugin Manager** tab to enable/disable plugins. The display
|
||||||
- Display cycles through enabled plugins in order
|
cycles through enabled plugins in the order they appear.
|
||||||
|
|
||||||
**Add More Plugins:**
|
**Add more plugins:**
|
||||||
- Check Plugin Store regularly for new plugins
|
- Check the **Plugin Store** section of **Plugin Manager** for new plugins.
|
||||||
- Install from GitHub URLs for custom/community plugins
|
- Install community plugins straight from a GitHub URL via
|
||||||
|
**Install from GitHub** on the same tab.
|
||||||
|
|
||||||
### Enable Advanced Features
|
### Enable Advanced Features
|
||||||
|
|
||||||
@@ -279,26 +279,39 @@ sudo journalctl -u ledmatrix-web -f
|
|||||||
│ ├── config.json # Main configuration
|
│ ├── config.json # Main configuration
|
||||||
│ ├── config_secrets.json # API keys and secrets
|
│ ├── config_secrets.json # API keys and secrets
|
||||||
│ └── wifi_config.json # WiFi settings
|
│ └── wifi_config.json # WiFi settings
|
||||||
├── plugins/ # Installed plugins
|
├── plugin-repos/ # Installed plugins (default location)
|
||||||
├── cache/ # Cached data
|
├── cache/ # Cached data
|
||||||
└── web_interface/ # Web interface files
|
└── web_interface/ # Web interface files
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> The plugin install location is configurable via
|
||||||
|
> `plugin_system.plugins_directory` in `config.json`. The default is
|
||||||
|
> `plugin-repos/`. Plugin discovery (`PluginManager.discover_plugins()`)
|
||||||
|
> only scans the configured directory — it does not fall back to
|
||||||
|
> `plugins/`. However, the Plugin Store install/update path and the
|
||||||
|
> web UI's schema loader do also probe `plugins/` so the dev symlinks
|
||||||
|
> created by `scripts/dev/dev_plugin_setup.sh` keep working.
|
||||||
|
|
||||||
### Web Interface
|
### Web Interface
|
||||||
|
|
||||||
```
|
```
|
||||||
Main Interface: http://your-pi-ip:5050
|
Main Interface: http://your-pi-ip:5000
|
||||||
|
|
||||||
Tabs:
|
System tabs:
|
||||||
- Overview: System stats and quick actions
|
- Overview System stats, live preview, quick actions
|
||||||
- General Settings: Timezone, location, autostart
|
- General Timezone, location, plugin-system settings
|
||||||
- Display Settings: Hardware configuration
|
- WiFi Network selection and AP-mode setup
|
||||||
- Durations: Plugin display times
|
- Schedule Power and dim schedules
|
||||||
- Sports Configuration: Per-league settings
|
- Display Matrix hardware configuration
|
||||||
- Plugin Management: Enable/disable, configure
|
- Config Editor Raw config.json editor
|
||||||
- Plugin Store: Install new plugins
|
- Fonts Upload and manage fonts
|
||||||
- Font Management: Upload and manage fonts
|
- Logs Real-time log viewing
|
||||||
- Logs: Real-time log viewing
|
- Cache Cached data inspection and cleanup
|
||||||
|
- Operation History Recent service operations
|
||||||
|
|
||||||
|
Plugin tabs (second row):
|
||||||
|
- Plugin Manager Browse the Plugin Store, install/enable plugins
|
||||||
|
- <plugin-id> One tab per installed plugin for its config
|
||||||
```
|
```
|
||||||
|
|
||||||
### WiFi Access Point
|
### WiFi Access Point
|
||||||
@@ -306,7 +319,7 @@ Tabs:
|
|||||||
```
|
```
|
||||||
Network Name: LEDMatrix-Setup
|
Network Name: LEDMatrix-Setup
|
||||||
Password: (none - open network)
|
Password: (none - open network)
|
||||||
URL when connected: http://192.168.4.1:5050
|
URL when connected: http://192.168.4.1:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Make sure you have the testing packages installed:
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Or install just the test dependencies
|
# Or install just the test dependencies
|
||||||
pip install pytest pytest-cov pytest-mock pytest-timeout
|
pip install pytest pytest-cov pytest-mock
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Set Environment Variables
|
### 2. Set Environment Variables
|
||||||
@@ -85,8 +85,14 @@ pytest -m slow
|
|||||||
# Run all tests in the test directory
|
# Run all tests in the test directory
|
||||||
pytest test/
|
pytest test/
|
||||||
|
|
||||||
# Run all integration tests
|
# Run plugin tests only
|
||||||
pytest test/integration/
|
pytest test/plugins/
|
||||||
|
|
||||||
|
# Run web interface tests only
|
||||||
|
pytest test/web_interface/
|
||||||
|
|
||||||
|
# Run web interface integration tests
|
||||||
|
pytest test/web_interface/integration/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Understanding Test Output
|
## Understanding Test Output
|
||||||
@@ -233,18 +239,39 @@ pytest --maxfail=3
|
|||||||
test/
|
test/
|
||||||
├── conftest.py # Shared fixtures and configuration
|
├── conftest.py # Shared fixtures and configuration
|
||||||
├── test_display_controller.py # Display controller tests
|
├── test_display_controller.py # Display controller tests
|
||||||
├── test_plugin_system.py # Plugin system tests
|
|
||||||
├── test_display_manager.py # Display manager tests
|
├── test_display_manager.py # Display manager tests
|
||||||
├── test_config_service.py # Config service tests
|
├── test_plugin_system.py # Plugin system tests
|
||||||
|
├── test_plugin_loader.py # Plugin discovery/loading tests
|
||||||
|
├── test_plugin_loading_failures.py # Plugin failure-mode tests
|
||||||
├── test_cache_manager.py # Cache manager tests
|
├── test_cache_manager.py # Cache manager tests
|
||||||
├── test_font_manager.py # Font manager tests
|
|
||||||
├── test_error_handling.py # Error handling tests
|
|
||||||
├── test_config_manager.py # Config manager tests
|
├── test_config_manager.py # Config manager tests
|
||||||
├── integration/ # Integration tests
|
├── test_config_service.py # Config service tests
|
||||||
│ ├── test_e2e.py # End-to-end tests
|
├── test_config_validation_edge_cases.py # Config edge cases
|
||||||
│ └── test_plugin_integration.py # Plugin integration tests
|
├── test_font_manager.py # Font manager tests
|
||||||
├── test_error_scenarios.py # Error scenario tests
|
├── test_layout_manager.py # Layout manager tests
|
||||||
└── test_edge_cases.py # Edge case tests
|
├── test_text_helper.py # Text helper tests
|
||||||
|
├── test_error_handling.py # Error handling tests
|
||||||
|
├── test_error_aggregator.py # Error aggregation tests
|
||||||
|
├── test_schema_manager.py # Schema manager tests
|
||||||
|
├── test_web_api.py # Web API tests
|
||||||
|
├── test_nba_*.py # NBA-specific test suites
|
||||||
|
├── plugins/ # Per-plugin test suites
|
||||||
|
│ ├── test_clock_simple.py
|
||||||
|
│ ├── test_calendar.py
|
||||||
|
│ ├── test_basketball_scoreboard.py
|
||||||
|
│ ├── test_soccer_scoreboard.py
|
||||||
|
│ ├── test_odds_ticker.py
|
||||||
|
│ ├── test_text_display.py
|
||||||
|
│ ├── test_visual_rendering.py
|
||||||
|
│ └── test_plugin_base.py
|
||||||
|
└── web_interface/
|
||||||
|
├── test_config_manager_atomic.py
|
||||||
|
├── test_state_reconciliation.py
|
||||||
|
├── test_plugin_operation_queue.py
|
||||||
|
├── test_dedup_unique_arrays.py
|
||||||
|
└── integration/ # Web interface integration tests
|
||||||
|
├── test_config_flows.py
|
||||||
|
└── test_plugin_operations.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Categories
|
### Test Categories
|
||||||
@@ -309,11 +336,15 @@ pytest --cov=src --cov-report=html
|
|||||||
|
|
||||||
## Continuous Integration
|
## Continuous Integration
|
||||||
|
|
||||||
Tests are configured to run automatically in CI/CD. The GitHub Actions workflow (`.github/workflows/tests.yml`) runs:
|
The repo runs
|
||||||
|
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
|
||||||
- All tests on multiple Python versions (3.10, 3.11, 3.12)
|
(bandit + semgrep) on every push. A pytest CI workflow at
|
||||||
- Coverage reporting
|
`.github/workflows/tests.yml` is queued to land alongside this
|
||||||
- Uploads coverage to Codecov (if configured)
|
PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
|
||||||
|
the workflow file itself was held back from that PR because the
|
||||||
|
push token lacked the GitHub `workflow` scope, so it needs to be
|
||||||
|
committed separately by a maintainer. Once it's in, this section
|
||||||
|
will be updated to describe what the job runs.
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ If you encounter issues during migration:
|
|||||||
|
|
||||||
1. Check the [README.md](README.md) for current installation and usage instructions
|
1. Check the [README.md](README.md) for current installation and usage instructions
|
||||||
2. Review script README files:
|
2. Review script README files:
|
||||||
- `scripts/install/README.md` - Installation scripts documentation
|
- [`scripts/install/README.md`](../scripts/install/README.md) - Installation scripts documentation
|
||||||
- `scripts/fix_perms/README.md` (if exists) - Permission scripts documentation
|
- [`scripts/fix_perms/README.md`](../scripts/fix_perms/README.md) - Permission scripts documentation
|
||||||
3. Check system logs: `journalctl -u ledmatrix -f` or `journalctl -u ledmatrix-web -f`
|
3. Check system logs: `journalctl -u ledmatrix -f` or `journalctl -u ledmatrix-web -f`
|
||||||
4. Review the troubleshooting section in the main README
|
4. Review the troubleshooting section in the main README
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,95 @@ Get display duration for this plugin. Can be overridden for dynamic durations.
|
|||||||
|
|
||||||
Return plugin info for display in web UI. Override to provide additional state information.
|
Return plugin info for display in web UI. Override to provide additional state information.
|
||||||
|
|
||||||
|
### Dynamic-duration hooks
|
||||||
|
|
||||||
|
Plugins that render multi-step content (e.g. cycling through several games)
|
||||||
|
can extend their display time until they've shown everything. To opt in,
|
||||||
|
either set `dynamic_duration.enabled: true` in the plugin's config or
|
||||||
|
override `supports_dynamic_duration()`.
|
||||||
|
|
||||||
|
#### `supports_dynamic_duration() -> bool`
|
||||||
|
|
||||||
|
Return `True` if this plugin should use dynamic durations. Default reads
|
||||||
|
`config["dynamic_duration"]["enabled"]`.
|
||||||
|
|
||||||
|
#### `get_dynamic_duration_cap() -> Optional[float]`
|
||||||
|
|
||||||
|
Maximum number of seconds the controller will keep this plugin on screen
|
||||||
|
in dynamic mode. Default reads
|
||||||
|
`config["dynamic_duration"]["max_duration_seconds"]`.
|
||||||
|
|
||||||
|
#### `is_cycle_complete() -> bool`
|
||||||
|
|
||||||
|
Override this to return `True` only after the plugin has rendered all of
|
||||||
|
its content for the current rotation. Default returns `True` immediately,
|
||||||
|
which means a single `display()` call counts as a full cycle.
|
||||||
|
|
||||||
|
#### `reset_cycle_state() -> None`
|
||||||
|
|
||||||
|
Called by the controller before each new dynamic-duration session. Reset
|
||||||
|
internal counters/iterators here.
|
||||||
|
|
||||||
|
### Live priority hooks
|
||||||
|
|
||||||
|
Live priority lets a plugin temporarily take over the rotation when it has
|
||||||
|
urgent content (live games, breaking news). Enable by setting
|
||||||
|
`live_priority: true` in the plugin's config and overriding
|
||||||
|
`has_live_content()`.
|
||||||
|
|
||||||
|
#### `has_live_priority() -> bool`
|
||||||
|
|
||||||
|
Whether live priority is enabled in config (default reads
|
||||||
|
`config["live_priority"]`).
|
||||||
|
|
||||||
|
#### `has_live_content() -> bool`
|
||||||
|
|
||||||
|
Override to return `True` when the plugin currently has urgent content.
|
||||||
|
Default returns `False`.
|
||||||
|
|
||||||
|
#### `get_live_modes() -> List[str]`
|
||||||
|
|
||||||
|
List of display modes to show during a live takeover. Default returns the
|
||||||
|
plugin's `display_modes` from its manifest.
|
||||||
|
|
||||||
|
### Vegas scroll hooks
|
||||||
|
|
||||||
|
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
||||||
|
rotating one at a time. Plugins control how their content appears via
|
||||||
|
these hooks. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for the user
|
||||||
|
side of Vegas mode.
|
||||||
|
|
||||||
|
#### `get_vegas_content() -> Optional[PIL.Image | List[PIL.Image] | None]`
|
||||||
|
|
||||||
|
Return content to inject into the scroll. Multi-item plugins (sports,
|
||||||
|
odds, news) should return a *list* of PIL Images so each item scrolls
|
||||||
|
independently. Static plugins (clock, weather) can return a single image.
|
||||||
|
Returning `None` falls back to capturing whatever `display()` produces.
|
||||||
|
|
||||||
|
#### `get_vegas_content_type() -> str`
|
||||||
|
|
||||||
|
`'multi'`, `'static'`, or `'none'`. Affects how Vegas mode treats the
|
||||||
|
plugin. Default `'static'`.
|
||||||
|
|
||||||
|
#### `get_vegas_display_mode() -> VegasDisplayMode`
|
||||||
|
|
||||||
|
Returns one of `VegasDisplayMode.SCROLL`, `FIXED_SEGMENT`, or `STATIC`.
|
||||||
|
Read from `config["vegas_mode"]` or override directly.
|
||||||
|
|
||||||
|
#### `get_supported_vegas_modes() -> List[VegasDisplayMode]`
|
||||||
|
|
||||||
|
The set of Vegas modes this plugin can render. Used by the UI to populate
|
||||||
|
the mode selector for this plugin.
|
||||||
|
|
||||||
|
#### `get_vegas_segment_width() -> Optional[int]`
|
||||||
|
|
||||||
|
For `FIXED_SEGMENT` plugins, the width in pixels of the segment they
|
||||||
|
occupy in the scroll. `None` lets the controller pick a default.
|
||||||
|
|
||||||
|
> The full source for `BasePlugin` lives in
|
||||||
|
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||||
|
> source, the source wins — please open an issue or PR to fix the doc.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Display Manager
|
## Display Manager
|
||||||
@@ -228,23 +317,31 @@ date_str = self.display_manager.format_date_with_ordinal(datetime.now())
|
|||||||
|
|
||||||
### Image Rendering
|
### Image Rendering
|
||||||
|
|
||||||
#### `draw_image(image: PIL.Image, x: int, y: int) -> None`
|
The display manager doesn't provide a dedicated `draw_image()` method.
|
||||||
|
Instead, plugins paste directly onto the underlying PIL Image
|
||||||
|
(`display_manager.image`), then call `update_display()` to push the buffer
|
||||||
|
to the matrix.
|
||||||
|
|
||||||
Draw a PIL Image object on the canvas.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `image`: PIL Image object
|
|
||||||
- `x` (int): X position (left edge)
|
|
||||||
- `y` (int): Y position (top edge)
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```python
|
```python
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
logo = Image.open("assets/logo.png")
|
|
||||||
self.display_manager.draw_image(logo, x=10, y=10)
|
logo = Image.open("assets/logo.png").convert("RGB")
|
||||||
|
self.display_manager.image.paste(logo, (10, 10))
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For transparency support, paste using a mask:
|
||||||
|
|
||||||
|
```python
|
||||||
|
icon = Image.open("assets/icon.png").convert("RGBA")
|
||||||
|
self.display_manager.image.paste(icon, (5, 5), icon)
|
||||||
|
self.display_manager.update_display()
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the same pattern the bundled scoreboard base classes
|
||||||
|
(`src/base_classes/baseball.py`, `basketball.py`, `football.py`,
|
||||||
|
`hockey.py`) use, so it's the canonical way to render arbitrary images.
|
||||||
|
|
||||||
### Weather Icons
|
### Weather Icons
|
||||||
|
|
||||||
#### `draw_weather_icon(condition: str, x: int, y: int, size: int = 16) -> None`
|
#### `draw_weather_icon(condition: str, x: int, y: int, size: int = 16) -> None`
|
||||||
@@ -440,12 +537,23 @@ self.cache_manager.set("weather_data", {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `delete(key: str) -> None`
|
#### `clear_cache(key: Optional[str] = None) -> None`
|
||||||
|
|
||||||
Remove a specific cache entry.
|
Remove a specific cache entry, or all cache entries when called without
|
||||||
|
arguments.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
- `key` (str): Cache key to delete
|
- `key` (str, optional): Cache key to delete. If omitted, every cached
|
||||||
|
entry (memory + disk) is cleared.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```python
|
||||||
|
# Drop one stale entry
|
||||||
|
self.cache_manager.clear_cache("weather_data")
|
||||||
|
|
||||||
|
# Nuke everything (rare — typically only used by maintenance tooling)
|
||||||
|
self.cache_manager.clear_cache()
|
||||||
|
```
|
||||||
|
|
||||||
### Advanced Methods
|
### Advanced Methods
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# LEDMatrix Plugin Architecture Specification
|
# LEDMatrix Plugin Architecture Specification
|
||||||
|
|
||||||
|
> **Historical design document.** This spec was written *before* the
|
||||||
|
> plugin system was built. Most of it is still architecturally
|
||||||
|
> accurate, but specific details have drifted from the shipped
|
||||||
|
> implementation:
|
||||||
|
>
|
||||||
|
> - Code paths reference `web_interface_v2.py`; the current web UI is
|
||||||
|
> `web_interface/app.py` with v3 Blueprint-based templates.
|
||||||
|
> - The example Flask routes use `/api/plugins/*`; the real API
|
||||||
|
> blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||||
|
> - The default plugin location is `plugin-repos/` (configurable via
|
||||||
|
> `plugin_system.plugins_directory`), not `./plugins/`.
|
||||||
|
> - The "Migration Strategy" and "Implementation Roadmap" sections
|
||||||
|
> describe work that has now shipped.
|
||||||
|
>
|
||||||
|
> For the current system, see:
|
||||||
|
> [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md),
|
||||||
|
> [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md), and
|
||||||
|
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md).
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
This document outlines the transformation of the LEDMatrix project into a modular, plugin-based architecture that enables user-created displays. The goal is to create a flexible, extensible system similar to Home Assistant Community Store (HACS) where users can discover, install, and manage custom display managers from GitHub repositories.
|
This document outlines the transformation of the LEDMatrix project into a modular, plugin-based architecture that enables user-created displays. The goal is to create a flexible, extensible system similar to Home Assistant Community Store (HACS) where users can discover, install, and manage custom display managers from GitHub repositories.
|
||||||
@@ -9,22 +28,22 @@ This document outlines the transformation of the LEDMatrix project into a modula
|
|||||||
1. **Gradual Migration**: Existing managers remain in core while new plugin infrastructure is built
|
1. **Gradual Migration**: Existing managers remain in core while new plugin infrastructure is built
|
||||||
2. **Migration Required**: Breaking changes with migration tools provided
|
2. **Migration Required**: Breaking changes with migration tools provided
|
||||||
3. **GitHub-Based Store**: Simple discovery system, packages served from GitHub repos
|
3. **GitHub-Based Store**: Simple discovery system, packages served from GitHub repos
|
||||||
4. **Plugin Location**: `./plugins/` directory in project root
|
4. **Plugin Location**: `./plugins/` directory in project root *(actual default is now `plugin-repos/`)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
1. [Current Architecture Analysis](#current-architecture-analysis)
|
1. [Current Architecture Analysis](#1-current-architecture-analysis)
|
||||||
2. [Plugin System Design](#plugin-system-design)
|
2. [Plugin System Design](#2-plugin-system-design)
|
||||||
3. [Plugin Store & Discovery](#plugin-store--discovery)
|
3. [Plugin Store & Discovery](#3-plugin-store--discovery)
|
||||||
4. [Web UI Transformation](#web-ui-transformation)
|
4. [Web UI Transformation](#4-web-ui-transformation)
|
||||||
5. [Migration Strategy](#migration-strategy)
|
5. [Migration Strategy](#5-migration-strategy)
|
||||||
6. [Plugin Developer Guidelines](#plugin-developer-guidelines)
|
6. [Plugin Developer Guidelines](#6-plugin-developer-guidelines)
|
||||||
7. [Technical Implementation Details](#technical-implementation-details)
|
7. [Technical Implementation Details](#7-technical-implementation-details)
|
||||||
8. [Best Practices & Standards](#best-practices--standards)
|
8. [Best Practices & Standards](#8-best-practices--standards)
|
||||||
9. [Security Considerations](#security-considerations)
|
9. [Security Considerations](#9-security-considerations)
|
||||||
10. [Implementation Roadmap](#implementation-roadmap)
|
10. [Implementation Roadmap](#10-implementation-roadmap)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -184,37 +184,45 @@ plugin-repos/
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"id": "my-plugin",
|
||||||
"name": "My Plugin",
|
"name": "My Plugin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Plugin description",
|
"description": "Plugin description",
|
||||||
"author": "Your Name",
|
"author": "Your Name",
|
||||||
|
"entry_point": "manager.py",
|
||||||
|
"class_name": "MyPlugin",
|
||||||
"display_modes": ["my_plugin"],
|
"display_modes": ["my_plugin"],
|
||||||
"config_schema": {
|
"config_schema": "config_schema.json"
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {"type": "boolean", "default": false},
|
|
||||||
"update_interval": {"type": "integer", "default": 3600}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The required fields the plugin loader will check for are `id`,
|
||||||
|
`name`, `version`, `class_name`, and `display_modes`. `entry_point`
|
||||||
|
defaults to `manager.py` if omitted. `config_schema` must be a
|
||||||
|
**file path** (relative to the plugin directory) — the schema itself
|
||||||
|
lives in a separate JSON file, not inline in the manifest. The
|
||||||
|
`class_name` value must match the actual class defined in the entry
|
||||||
|
point file **exactly** (case-sensitive, no spaces); otherwise the
|
||||||
|
loader fails with `AttributeError` at load time.
|
||||||
|
|
||||||
### Plugin Manager Class
|
### Plugin Manager Class
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from src.plugin_system.base_plugin import BasePlugin
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
class MyPluginManager(BasePlugin):
|
class MyPlugin(BasePlugin):
|
||||||
def __init__(self, config, display_manager, cache_manager, font_manager):
|
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||||
super().__init__(config, display_manager, cache_manager, font_manager)
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
self.enabled = config.get('enabled', False)
|
# self.config, self.display_manager, self.cache_manager,
|
||||||
|
# self.plugin_manager, self.logger, and self.enabled are
|
||||||
|
# all set up by BasePlugin.__init__.
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Update plugin data"""
|
"""Fetch/update data. Called based on update_interval."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def display(self, force_clear=False):
|
def display(self, force_clear=False):
|
||||||
"""Display plugin content"""
|
"""Render plugin content to the LED matrix."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_duration(self):
|
def get_duration(self):
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Plugin Configuration Tabs
|
# Plugin Configuration Tabs
|
||||||
|
|
||||||
|
> **Status note:** this doc was written during the rollout of the
|
||||||
|
> per-plugin configuration tab feature. The feature itself is shipped
|
||||||
|
> and working in the current v3 web interface, but a few file paths
|
||||||
|
> in the "Implementation Details" section below still reference the
|
||||||
|
> pre-v3 file layout (`web_interface_v2.py`, `templates/index_v2.html`).
|
||||||
|
> The current implementation lives in `web_interface/app.py`,
|
||||||
|
> `web_interface/blueprints/api_v3.py`, and `web_interface/templates/v3/`.
|
||||||
|
> The user-facing description (Overview, Features, Form Generation
|
||||||
|
> Process) is still accurate.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Each installed plugin now gets its own dedicated configuration tab in the web interface. This provides a clean, organized way to configure plugins without cluttering the main Plugins management tab.
|
Each installed plugin now gets its own dedicated configuration tab in the web interface. This provides a clean, organized way to configure plugins without cluttering the main Plugins management tab.
|
||||||
@@ -29,10 +39,14 @@ Each installed plugin now gets its own dedicated configuration tab in the web in
|
|||||||
3. Click **Save Configuration**
|
3. Click **Save Configuration**
|
||||||
4. Restart the display service to apply changes
|
4. Restart the display service to apply changes
|
||||||
|
|
||||||
### Plugin Management vs Configuration
|
### Plugin Manager vs Per-Plugin Configuration
|
||||||
|
|
||||||
- **Plugins Tab**: Used for plugin management (install, enable/disable, update, uninstall)
|
- **Plugin Manager tab** (second nav row): used for browsing the
|
||||||
- **Plugin-Specific Tabs**: Used for configuring plugin behavior and settings
|
Plugin Store, installing plugins, toggling installed plugins on/off,
|
||||||
|
and updating/uninstalling them
|
||||||
|
- **Per-plugin tabs** (one per installed plugin, also in the second
|
||||||
|
nav row): used for configuring that specific plugin's behavior and
|
||||||
|
settings via a form auto-generated from its `config_schema.json`
|
||||||
|
|
||||||
## For Plugin Developers
|
## For Plugin Developers
|
||||||
|
|
||||||
@@ -194,12 +208,12 @@ Renders as: Dropdown select
|
|||||||
|
|
||||||
### Form Generation Process
|
### Form Generation Process
|
||||||
|
|
||||||
1. Web UI loads installed plugins via `/api/plugins/installed`
|
1. Web UI loads installed plugins via `/api/v3/plugins/installed`
|
||||||
2. For each plugin, the backend loads its `config_schema.json`
|
2. For each plugin, the backend loads its `config_schema.json`
|
||||||
3. Frontend generates a tab button with plugin name
|
3. Frontend generates a tab button with plugin name
|
||||||
4. Frontend generates a form based on the JSON Schema
|
4. Frontend generates a form based on the JSON Schema
|
||||||
5. Current config values from `config.json` are populated
|
5. Current config values from `config.json` are populated
|
||||||
6. When saved, each field is sent to `/api/plugins/config` endpoint
|
6. When saved, each field is sent to `/api/v3/plugins/config` endpoint
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
|
|
||||||
@@ -207,7 +221,7 @@ Renders as: Dropdown select
|
|||||||
|
|
||||||
**File**: `web_interface_v2.py`
|
**File**: `web_interface_v2.py`
|
||||||
|
|
||||||
- Modified `/api/plugins/installed` endpoint to include `config_schema_data`
|
- Modified `/api/v3/plugins/installed` endpoint to include `config_schema_data`
|
||||||
- Loads each plugin's `config_schema.json` if it exists
|
- Loads each plugin's `config_schema.json` if it exists
|
||||||
- Returns schema data along with plugin info
|
- Returns schema data along with plugin info
|
||||||
|
|
||||||
@@ -227,7 +241,7 @@ New Functions:
|
|||||||
```
|
```
|
||||||
Page Load
|
Page Load
|
||||||
→ refreshPlugins()
|
→ refreshPlugins()
|
||||||
→ /api/plugins/installed
|
→ /api/v3/plugins/installed
|
||||||
→ Returns plugins with config_schema_data
|
→ Returns plugins with config_schema_data
|
||||||
→ generatePluginTabs()
|
→ generatePluginTabs()
|
||||||
→ Creates tab buttons
|
→ Creates tab buttons
|
||||||
@@ -241,7 +255,7 @@ User Saves
|
|||||||
→ savePluginConfiguration()
|
→ savePluginConfiguration()
|
||||||
→ Reads form data
|
→ Reads form data
|
||||||
→ Converts types per schema
|
→ Converts types per schema
|
||||||
→ Sends to /api/plugins/config
|
→ Sends to /api/v3/plugins/config
|
||||||
→ Updates config.json
|
→ Updates config.json
|
||||||
→ Shows success notification
|
→ Shows success notification
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ Flask Backend │
|
│ Flask Backend │
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
│ ┌───────────────────────────────────────────────────────┐ │
|
||||||
│ │ /api/plugins/installed │ │
|
│ │ /api/v3/plugins/installed │ │
|
||||||
│ │ • Discover plugins in plugins/ directory │ │
|
│ │ • Discover plugins in plugins/ directory │ │
|
||||||
│ │ • Load manifest.json for each plugin │ │
|
│ │ • Load manifest.json for each plugin │ │
|
||||||
│ │ • Load config_schema.json if exists │ │
|
│ │ • Load config_schema.json if exists │ │
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
│ └───────────────────────────────────────────────────────┘ │
|
│ └───────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
│ ┌───────────────────────────────────────────────────────┐ │
|
||||||
│ │ /api/plugins/config │ │
|
│ │ /api/v3/plugins/config │ │
|
||||||
│ │ • Receive key-value pair │ │
|
│ │ • Receive key-value pair │ │
|
||||||
│ │ • Update config.json │ │
|
│ │ • Update config.json │ │
|
||||||
│ │ • Return success/error │ │
|
│ │ • Return success/error │ │
|
||||||
@@ -88,7 +88,7 @@ DOMContentLoaded Event
|
|||||||
refreshPlugins()
|
refreshPlugins()
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
GET /api/plugins/installed
|
GET /api/v3/plugins/installed
|
||||||
│
|
│
|
||||||
├─→ For each plugin directory:
|
├─→ For each plugin directory:
|
||||||
│ ├─→ Read manifest.json
|
│ ├─→ Read manifest.json
|
||||||
@@ -146,7 +146,7 @@ savePluginConfiguration(pluginId)
|
|||||||
│ │ • array: split(',')
|
│ │ • array: split(',')
|
||||||
│ │ • string: as-is
|
│ │ • string: as-is
|
||||||
│ │
|
│ │
|
||||||
│ └─→ POST /api/plugins/config
|
│ └─→ POST /api/v3/plugins/config
|
||||||
│ {
|
│ {
|
||||||
│ plugin_id: "hello-world",
|
│ plugin_id: "hello-world",
|
||||||
│ key: "message",
|
│ key: "message",
|
||||||
@@ -174,7 +174,7 @@ Refresh Plugins
|
|||||||
Window Load
|
Window Load
|
||||||
└── DOMContentLoaded
|
└── DOMContentLoaded
|
||||||
└── refreshPlugins()
|
└── refreshPlugins()
|
||||||
├── fetch('/api/plugins/installed')
|
├── fetch('/api/v3/plugins/installed')
|
||||||
├── renderInstalledPlugins(plugins)
|
├── renderInstalledPlugins(plugins)
|
||||||
└── generatePluginTabs(plugins)
|
└── generatePluginTabs(plugins)
|
||||||
└── For each plugin:
|
└── For each plugin:
|
||||||
@@ -198,19 +198,19 @@ User Interactions
|
|||||||
│ ├── Process form data
|
│ ├── Process form data
|
||||||
│ ├── Convert types per schema
|
│ ├── Convert types per schema
|
||||||
│ └── For each field:
|
│ └── For each field:
|
||||||
│ └── POST /api/plugins/config
|
│ └── POST /api/v3/plugins/config
|
||||||
│
|
│
|
||||||
└── resetPluginConfig(pluginId)
|
└── resetPluginConfig(pluginId)
|
||||||
├── Get schema defaults
|
├── Get schema defaults
|
||||||
└── For each field:
|
└── For each field:
|
||||||
└── POST /api/plugins/config
|
└── POST /api/v3/plugins/config
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (Python)
|
### Backend (Python)
|
||||||
|
|
||||||
```
|
```
|
||||||
Flask Routes
|
Flask Routes
|
||||||
├── /api/plugins/installed (GET)
|
├── /api/v3/plugins/installed (GET)
|
||||||
│ └── api_plugins_installed()
|
│ └── api_plugins_installed()
|
||||||
│ ├── PluginManager.discover_plugins()
|
│ ├── PluginManager.discover_plugins()
|
||||||
│ ├── For each plugin:
|
│ ├── For each plugin:
|
||||||
@@ -219,7 +219,7 @@ Flask Routes
|
|||||||
│ │ └── Load config from config.json
|
│ │ └── Load config from config.json
|
||||||
│ └── Return JSON response
|
│ └── Return JSON response
|
||||||
│
|
│
|
||||||
└── /api/plugins/config (POST)
|
└── /api/v3/plugins/config (POST)
|
||||||
└── api_plugin_config()
|
└── api_plugin_config()
|
||||||
├── Parse request JSON
|
├── Parse request JSON
|
||||||
├── Load current config
|
├── Load current config
|
||||||
@@ -279,7 +279,7 @@ LEDMatrix/
|
|||||||
### 3. Individual Config Updates
|
### 3. Individual Config Updates
|
||||||
|
|
||||||
**Why**: Simplifies backend API
|
**Why**: Simplifies backend API
|
||||||
**How**: Each field saved separately via `/api/plugins/config`
|
**How**: Each field saved separately via `/api/v3/plugins/config`
|
||||||
**Benefit**: Atomic updates, easier error handling
|
**Benefit**: Atomic updates, easier error handling
|
||||||
|
|
||||||
### 4. Type Conversion in Frontend
|
### 4. Type Conversion in Frontend
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
|
|
||||||
### For Users
|
### For Users
|
||||||
|
|
||||||
1. Open the web interface: `http://your-pi-ip:5001`
|
1. Open the web interface: `http://your-pi-ip:5000`
|
||||||
2. Go to the **Plugin Store** tab
|
2. Open the **Plugin Manager** tab
|
||||||
3. Install a plugin (e.g., "Hello World")
|
3. Find a plugin in the **Plugin Store** section (e.g., "Hello World")
|
||||||
4. Notice a new tab appears with the plugin's name
|
and click **Install**
|
||||||
5. Click on the plugin's tab to configure it
|
4. Notice a new tab appears in the second nav row with the plugin's name
|
||||||
6. Modify settings and click **Save Configuration**
|
5. Click that tab to configure the plugin
|
||||||
7. Restart the display to see changes
|
6. Modify settings and click **Save**
|
||||||
|
7. From **Overview**, click **Restart Display Service** to see changes
|
||||||
|
|
||||||
That's it! Each installed plugin automatically gets its own configuration tab.
|
That's it! Each installed plugin automatically gets its own configuration tab.
|
||||||
|
|
||||||
@@ -171,9 +172,11 @@ User enters: `255, 0, 0`
|
|||||||
### For Users
|
### For Users
|
||||||
|
|
||||||
1. **Reset Anytime**: Use "Reset to Defaults" to restore original settings
|
1. **Reset Anytime**: Use "Reset to Defaults" to restore original settings
|
||||||
2. **Navigate Back**: Click "Back to Plugin Management" to return to Plugins tab
|
2. **Navigate Back**: Switch to the **Plugin Manager** tab to see the
|
||||||
|
full list of installed plugins
|
||||||
3. **Check Help Text**: Each field has a description explaining what it does
|
3. **Check Help Text**: Each field has a description explaining what it does
|
||||||
4. **Restart Required**: Remember to restart the display after saving
|
4. **Restart Required**: Remember to restart the display service from
|
||||||
|
**Overview** after saving
|
||||||
|
|
||||||
### For Developers
|
### For Developers
|
||||||
|
|
||||||
@@ -206,8 +209,10 @@ User enters: `255, 0, 0`
|
|||||||
## 📚 Next Steps
|
## 📚 Next Steps
|
||||||
|
|
||||||
- Read the full documentation: [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md)
|
- Read the full documentation: [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md)
|
||||||
- Check implementation details: [PLUGIN_CONFIG_TABS_SUMMARY.md](PLUGIN_CONFIG_TABS_SUMMARY.md)
|
- Check the configuration architecture: [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md)
|
||||||
- Browse example plugins: `plugins/hello-world/`, `plugins/clock-simple/`
|
- Browse example plugins in the
|
||||||
|
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
repo, especially `plugins/hello-world/` and `plugins/clock-simple/`
|
||||||
- Join the community for help and suggestions
|
- Join the community for help and suggestions
|
||||||
|
|
||||||
## 🎉 That's It!
|
## 🎉 That's It!
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
# ✅ Plugin Custom Icons Feature - Complete
|
# Plugin Custom Icons Feature
|
||||||
|
|
||||||
|
> **Note:** this doc was originally written against the v2 web
|
||||||
|
> interface. The v3 web interface now honors the same `icon` field
|
||||||
|
> in `manifest.json` — the API passes it through at
|
||||||
|
> `web_interface/blueprints/api_v3.py` and the three plugin-tab
|
||||||
|
> render sites in `web_interface/templates/v3/base.html` read it
|
||||||
|
> with a `fas fa-puzzle-piece` fallback. The guidance below still
|
||||||
|
> applies; only the referenced template/helper names differ.
|
||||||
|
|
||||||
## What Was Implemented
|
## What Was Implemented
|
||||||
|
|
||||||
@@ -304,7 +312,7 @@ Result: `[logo] Company Metrics` tab
|
|||||||
|
|
||||||
To test custom icons:
|
To test custom icons:
|
||||||
|
|
||||||
1. **Open web interface** at `http://your-pi:5001`
|
1. **Open web interface** at `http://your-pi-ip:5000`
|
||||||
2. **Check installed plugins**:
|
2. **Check installed plugins**:
|
||||||
- Hello World should show 👋
|
- Hello World should show 👋
|
||||||
- Clock Simple should show 🕐
|
- Clock Simple should show 🕐
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ sudo systemctl start ledmatrix-web
|
|||||||
|
|
||||||
### ✅ Scenario 2: Web Interface Plugin Installation
|
### ✅ Scenario 2: Web Interface Plugin Installation
|
||||||
|
|
||||||
**What:** Installing/enabling plugins via web interface at `http://pi-ip:5001`
|
**What:** Installing/enabling plugins via web interface at `http://pi-ip:5000`
|
||||||
|
|
||||||
- **Web service runs as:** root (ledmatrix-web.service)
|
- **Web service runs as:** root (ledmatrix-web.service)
|
||||||
- **Installs to:** System-wide
|
- **Installs to:** System-wide
|
||||||
|
|||||||
@@ -77,10 +77,12 @@ sudo chmod -R 755 /root/.cache
|
|||||||
|
|
||||||
The web interface handles dependency installation correctly in the service context:
|
The web interface handles dependency installation correctly in the service context:
|
||||||
|
|
||||||
1. Access the web interface (usually http://ledpi:8080)
|
1. Access the web interface (`http://ledpi:5000` or `http://your-pi-ip:5000`)
|
||||||
2. Navigate to Plugin Store or Plugin Management
|
2. Open the **Plugin Manager** tab (use the **Plugin Store** section to
|
||||||
3. Install plugins through the web UI
|
find the plugin, or **Install from GitHub**)
|
||||||
4. The system will automatically handle dependencies
|
3. Install the plugin through the web UI
|
||||||
|
4. The system automatically handles dependency installation in the
|
||||||
|
service context (which has the right permissions)
|
||||||
|
|
||||||
## Prevention
|
## Prevention
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ When developing plugins in separate repositories, you need a way to:
|
|||||||
|
|
||||||
The solution uses **symbolic links** to connect plugin repositories to the `plugins/` directory, combined with a helper script to manage the linking process.
|
The solution uses **symbolic links** to connect plugin repositories to the `plugins/` directory, combined with a helper script to manage the linking process.
|
||||||
|
|
||||||
|
> **Plugin directory note:** the dev workflow described here puts
|
||||||
|
> symlinks in `plugins/`. The plugin loader's *production* default is
|
||||||
|
> `plugin-repos/` (set by `plugin_system.plugins_directory` in
|
||||||
|
> `config.json`). Importantly, the main discovery path
|
||||||
|
> (`PluginManager.discover_plugins()`) only scans the configured
|
||||||
|
> directory — it does **not** fall back to `plugins/`. Two narrower
|
||||||
|
> paths do: the Plugin Store install/update logic in `store_manager.py`,
|
||||||
|
> and `schema_manager.get_schema_path()` (which the web UI form
|
||||||
|
> generator uses to find `config_schema.json`). That's why plugins
|
||||||
|
> installed via the Plugin Store still work even with symlinks in
|
||||||
|
> `plugins/`, but your own dev plugin won't appear in the rotation
|
||||||
|
> until you either move it to `plugin-repos/` or change
|
||||||
|
> `plugin_system.plugins_directory` to `plugins` in the General tab
|
||||||
|
> of the web UI. The latter is the smoother dev setup.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Link a Plugin from GitHub
|
### 1. Link a Plugin from GitHub
|
||||||
@@ -466,7 +481,9 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
|
|||||||
|
|
||||||
**Display Manager** (`self.display_manager`):
|
**Display Manager** (`self.display_manager`):
|
||||||
- `clear()`, `update_display()` - Core display operations
|
- `clear()`, `update_display()` - Core display operations
|
||||||
- `draw_text()`, `draw_image()` - Rendering methods
|
- `draw_text()` - Text rendering. For images, paste directly onto
|
||||||
|
`display_manager.image` (a PIL Image) and call `update_display()`;
|
||||||
|
there is no `draw_image()` helper method.
|
||||||
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
|
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
|
||||||
- `get_text_width()`, `get_font_height()` - Text utilities
|
- `get_text_width()`, `get_font_height()` - Text utilities
|
||||||
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# LEDMatrix Plugin System - Implementation Summary
|
# LEDMatrix Plugin System - Implementation Summary
|
||||||
|
|
||||||
|
> **Status note:** this is a high-level summary written during the
|
||||||
|
> initial plugin system rollout. Most of it is accurate, but a few
|
||||||
|
> sections describe features that are aspirational or only partially
|
||||||
|
> implemented (per-plugin virtual envs, resource limits, registry
|
||||||
|
> manager). Drift from current reality is called out inline.
|
||||||
|
|
||||||
This document provides a comprehensive overview of the plugin architecture implementation, consolidating details from multiple plugin-related implementation summaries.
|
This document provides a comprehensive overview of the plugin architecture implementation, consolidating details from multiple plugin-related implementation summaries.
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
@@ -14,16 +20,25 @@ The LEDMatrix plugin system transforms the project into a modular, extensible pl
|
|||||||
LEDMatrix/
|
LEDMatrix/
|
||||||
├── src/plugin_system/
|
├── src/plugin_system/
|
||||||
│ ├── base_plugin.py # Plugin interface contract
|
│ ├── base_plugin.py # Plugin interface contract
|
||||||
|
│ ├── plugin_loader.py # Discovery + dynamic import
|
||||||
│ ├── plugin_manager.py # Lifecycle management
|
│ ├── plugin_manager.py # Lifecycle management
|
||||||
│ ├── store_manager.py # GitHub integration
|
│ ├── store_manager.py # GitHub install / store integration
|
||||||
│ └── registry_manager.py # Plugin discovery
|
│ ├── schema_manager.py # Config schema validation
|
||||||
├── plugins/ # User-installed plugins
|
│ ├── health_monitor.py # Plugin health metrics
|
||||||
|
│ ├── operation_queue.py # Async install/update operations
|
||||||
|
│ └── state_manager.py # Persistent plugin state
|
||||||
|
├── plugin-repos/ # Default plugin install location
|
||||||
│ ├── football-scoreboard/
|
│ ├── football-scoreboard/
|
||||||
│ ├── ledmatrix-music/
|
│ ├── ledmatrix-music/
|
||||||
│ └── ledmatrix-stocks/
|
│ └── ledmatrix-stocks/
|
||||||
└── config/config.json # Plugin configurations
|
└── config/config.json # Plugin configurations
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Earlier drafts of this doc referenced `registry_manager.py`. It was
|
||||||
|
> never created — discovery happens in `plugin_loader.py`. The earlier
|
||||||
|
> default plugin location of `plugins/` has been replaced with
|
||||||
|
> `plugin-repos/` (see `config/config.template.json:130`).
|
||||||
|
|
||||||
### Key Design Decisions
|
### Key Design Decisions
|
||||||
|
|
||||||
✅ **Gradual Migration**: Plugin system added alongside existing managers
|
✅ **Gradual Migration**: Plugin system added alongside existing managers
|
||||||
@@ -77,14 +92,26 @@ LEDMatrix/
|
|||||||
- **Fallback System**: Default icons when custom ones unavailable
|
- **Fallback System**: Default icons when custom ones unavailable
|
||||||
|
|
||||||
#### Dependency Management
|
#### Dependency Management
|
||||||
- **Requirements.txt**: Per-plugin dependencies
|
- **Requirements.txt**: Per-plugin dependencies, installed system-wide
|
||||||
- **Virtual Environments**: Isolated dependency management
|
via pip on first plugin load
|
||||||
- **Version Pinning**: Explicit version constraints
|
- **Version Pinning**: Standard pip version constraints in
|
||||||
|
`requirements.txt`
|
||||||
|
|
||||||
#### Permission System
|
> Earlier plans called for per-plugin virtual environments. That isn't
|
||||||
- **File Access Control**: Configurable file system permissions
|
> implemented — plugin Python deps install into the system Python
|
||||||
- **Network Access**: Controlled API access
|
> environment (or whatever environment the LEDMatrix service is using).
|
||||||
- **Resource Limits**: CPU and memory constraints
|
> Conflicting versions across plugins are not auto-resolved.
|
||||||
|
|
||||||
|
#### Health monitoring
|
||||||
|
- **Resource Monitor** (`src/plugin_system/resource_monitor.py`): tracks
|
||||||
|
CPU and memory metrics per plugin and warns about slow plugins
|
||||||
|
- **Health Monitor** (`src/plugin_system/health_monitor.py`): tracks
|
||||||
|
plugin failures and last-success timestamps
|
||||||
|
|
||||||
|
> Earlier plans called for hard CPU/memory limits and a sandboxed
|
||||||
|
> permission system. Neither is implemented. Plugins run in the same
|
||||||
|
> process as the display loop with full file-system and network access
|
||||||
|
> — review third-party plugin code before installing.
|
||||||
|
|
||||||
## Plugin Development
|
## Plugin Development
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,20 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Transform LEDMatrix into a modular, plugin-based system where users can create, share, and install custom displays via a GitHub-based store (similar to HACS for Home Assistant).
|
LEDMatrix is a modular, plugin-based system where users create, share,
|
||||||
|
and install custom displays via a GitHub-based store (similar in spirit
|
||||||
|
to HACS for Home Assistant). This page is a quick reference; for the
|
||||||
|
full design see [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md)
|
||||||
|
and [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md).
|
||||||
|
|
||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
✅ **Gradual Migration**: Existing managers stay, plugins added alongside
|
✅ **Plugin-First**: All display features (calendar excepted) are now plugins
|
||||||
✅ **Migration Required**: Breaking changes in v3.0, tools provided
|
✅ **GitHub Store**: Discovery from `ledmatrix-plugins` registry plus
|
||||||
✅ **GitHub Store**: Simple discovery, packages from repos
|
any GitHub URL
|
||||||
✅ **Plugin Location**: `./plugins/` directory
|
✅ **Plugin Location**: configured by `plugin_system.plugins_directory`
|
||||||
|
in `config.json` (default `plugin-repos/`; the loader also searches
|
||||||
|
`plugins/` as a fallback)
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
@@ -19,15 +25,16 @@ LEDMatrix/
|
|||||||
│ └── plugin_system/
|
│ └── plugin_system/
|
||||||
│ ├── base_plugin.py # Plugin interface
|
│ ├── base_plugin.py # Plugin interface
|
||||||
│ ├── plugin_manager.py # Load/unload plugins
|
│ ├── plugin_manager.py # Load/unload plugins
|
||||||
|
│ ├── plugin_loader.py # Discovery + dynamic import
|
||||||
│ └── store_manager.py # Install from GitHub
|
│ └── store_manager.py # Install from GitHub
|
||||||
├── plugins/
|
├── plugin-repos/ # Default plugin install location
|
||||||
│ ├── clock-simple/
|
│ ├── clock-simple/
|
||||||
│ │ ├── manifest.json # Metadata
|
│ │ ├── manifest.json # Metadata
|
||||||
│ │ ├── manager.py # Main plugin class
|
│ │ ├── manager.py # Main plugin class
|
||||||
│ │ ├── requirements.txt # Dependencies
|
│ │ ├── requirements.txt # Dependencies
|
||||||
│ │ ├── config_schema.json # Validation
|
│ │ ├── config_schema.json # Validation
|
||||||
│ │ └── README.md
|
│ │ └── README.md
|
||||||
│ └── nhl-scores/
|
│ └── hockey-scoreboard/
|
||||||
│ └── ... (same structure)
|
│ └── ... (same structure)
|
||||||
└── config/config.json # Plugin configs
|
└── config/config.json # Plugin configs
|
||||||
```
|
```
|
||||||
@@ -109,100 +116,45 @@ git push origin v1.0.0
|
|||||||
|
|
||||||
### Web UI
|
### Web UI
|
||||||
|
|
||||||
1. **Browse Store**: Plugin Store tab → Search/filter
|
1. **Browse Store**: Plugin Manager tab → Plugin Store section → Search/filter
|
||||||
2. **Install**: Click "Install" button
|
2. **Install**: Click **Install** in the plugin's row
|
||||||
3. **Configure**: Plugin Manager → Click ⚙️ Configure
|
3. **Configure**: open the plugin's tab in the second nav row
|
||||||
4. **Enable/Disable**: Toggle switch
|
4. **Enable/Disable**: toggle switch in the **Installed Plugins** list
|
||||||
5. **Reorder**: Drag and drop in rotation list
|
5. **Reorder**: order is set by the position in `display_modes` /
|
||||||
|
plugin order; rearranging via drag-and-drop is not yet supported
|
||||||
|
|
||||||
### API
|
### REST API
|
||||||
|
|
||||||
```python
|
The API is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||||
# Install plugin
|
|
||||||
POST /api/plugins/install
|
|
||||||
{"plugin_id": "my-plugin"}
|
|
||||||
|
|
||||||
# Install from custom URL
|
|
||||||
POST /api/plugins/install-from-url
|
|
||||||
{"repo_url": "https://github.com/User/plugin"}
|
|
||||||
|
|
||||||
# List installed
|
|
||||||
GET /api/plugins/installed
|
|
||||||
|
|
||||||
# Toggle
|
|
||||||
POST /api/plugins/toggle
|
|
||||||
{"plugin_id": "my-plugin", "enabled": true}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command Line
|
|
||||||
|
|
||||||
```python
|
|
||||||
from src.plugin_system.store_manager import PluginStoreManager
|
|
||||||
|
|
||||||
store = PluginStoreManager()
|
|
||||||
|
|
||||||
# Install
|
|
||||||
store.install_plugin('nhl-scores')
|
|
||||||
|
|
||||||
# Install from URL
|
|
||||||
store.install_from_url('https://github.com/User/plugin')
|
|
||||||
|
|
||||||
# Update
|
|
||||||
store.update_plugin('nhl-scores')
|
|
||||||
|
|
||||||
# Uninstall
|
|
||||||
store.uninstall_plugin('nhl-scores')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Path
|
|
||||||
|
|
||||||
### Phase 1: v2.0.0 (Plugin Infrastructure)
|
|
||||||
- Plugin system alongside existing managers
|
|
||||||
- 100% backward compatible
|
|
||||||
- Web UI shows plugin store
|
|
||||||
|
|
||||||
### Phase 2: v2.1.0 (Example Plugins)
|
|
||||||
- Reference plugins created
|
|
||||||
- Migration examples
|
|
||||||
- Developer docs
|
|
||||||
|
|
||||||
### Phase 3: v2.2.0 (Migration Tools)
|
|
||||||
- Auto-migration script
|
|
||||||
- Config converter
|
|
||||||
- Testing tools
|
|
||||||
|
|
||||||
### Phase 4: v2.5.0 (Deprecation)
|
|
||||||
- Warnings on legacy managers
|
|
||||||
- Migration guide
|
|
||||||
- 95% backward compatible
|
|
||||||
|
|
||||||
### Phase 5: v3.0.0 (Plugin-Only)
|
|
||||||
- Legacy managers removed from core
|
|
||||||
- Packaged as official plugins
|
|
||||||
- **Breaking change - migration required**
|
|
||||||
|
|
||||||
## Quick Migration
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Backup
|
# Install plugin from the registry
|
||||||
cp config/config.json config/config.json.backup
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"plugin_id": "hockey-scoreboard"}'
|
||||||
|
|
||||||
# 2. Run migration
|
# Install from custom URL
|
||||||
python3 scripts/migrate_to_plugins.py
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install-from-url \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"repo_url": "https://github.com/User/plugin"}'
|
||||||
|
|
||||||
# 3. Review
|
# List installed
|
||||||
cat config/config.json.migrated
|
curl http://your-pi-ip:5000/api/v3/plugins/installed
|
||||||
|
|
||||||
# 4. Apply
|
# Toggle
|
||||||
mv config/config.json.migrated config/config.json
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/toggle \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
# 5. Restart
|
-d '{"plugin_id": "hockey-scoreboard", "enabled": true}'
|
||||||
sudo systemctl restart ledmatrix
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for the full list.
|
||||||
|
|
||||||
## Plugin Registry Structure
|
## Plugin Registry Structure
|
||||||
|
|
||||||
**ChuckBuilds/ledmatrix-plugin-registry/plugins.json**:
|
The official registry lives at
|
||||||
|
[`ChuckBuilds/ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins).
|
||||||
|
The Plugin Store reads `plugins.json` at the root of that repo, which
|
||||||
|
follows this shape:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"plugins": [
|
"plugins": [
|
||||||
@@ -245,42 +197,30 @@ sudo systemctl restart ledmatrix
|
|||||||
- ✅ Community handles custom displays
|
- ✅ Community handles custom displays
|
||||||
- ✅ Easier to review changes
|
- ✅ Easier to review changes
|
||||||
|
|
||||||
## What's Missing?
|
## Known Limitations
|
||||||
|
|
||||||
This specification covers the technical architecture. Additional considerations:
|
The plugin system is shipped and stable, but some things are still
|
||||||
|
intentionally simple:
|
||||||
|
|
||||||
1. **Sandboxing**: Current design has no isolation (future enhancement)
|
1. **Sandboxing**: plugins run in the same process as the display loop;
|
||||||
2. **Resource Limits**: No CPU/memory limits per plugin (future)
|
there is no isolation. Review code before installing third-party
|
||||||
3. **Plugin Ratings**: Registry needs rating/review system
|
plugins.
|
||||||
4. **Auto-Updates**: Manual update only (could add auto-update)
|
2. **Resource limits**: there's a resource monitor that warns about
|
||||||
5. **Dependency Conflicts**: No automatic resolution
|
slow plugins, but no hard CPU/memory caps.
|
||||||
6. **Version Pinning**: Limited version constraint checking
|
3. **Plugin ratings**: not yet — the Plugin Store shows version,
|
||||||
7. **Plugin Testing**: No automated testing framework
|
author, and category but no community rating system.
|
||||||
8. **Marketplace**: No paid plugins (all free/open source)
|
4. **Auto-updates**: manual via the Plugin Manager tab; no automatic
|
||||||
|
background updates.
|
||||||
## Next Steps
|
5. **Dependency conflicts**: each plugin's `requirements.txt` is
|
||||||
|
installed via pip; conflicting versions across plugins are not
|
||||||
1. ✅ Review this specification
|
resolved automatically.
|
||||||
2. Start Phase 1 implementation
|
6. **Plugin testing framework**: see
|
||||||
3. Create first 3-4 example plugins
|
[HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) and
|
||||||
4. Set up plugin registry repo
|
[DEV_PREVIEW.md](DEV_PREVIEW.md) — there are tools, but no
|
||||||
5. Build web UI components
|
mandatory test gate.
|
||||||
6. Test on Pi hardware
|
|
||||||
7. Release v2.0.0 alpha
|
|
||||||
|
|
||||||
## Questions to Resolve
|
|
||||||
|
|
||||||
Before implementing, consider:
|
|
||||||
|
|
||||||
1. Should we support plugin dependencies (plugin A requires plugin B)?
|
|
||||||
2. How to handle breaking changes in core display_manager API?
|
|
||||||
3. Should plugins be able to add new web UI pages?
|
|
||||||
4. What about plugins that need hardware beyond LED matrix?
|
|
||||||
5. How to prevent malicious plugins?
|
|
||||||
6. Should there be plugin quotas (max API calls, etc.)?
|
|
||||||
7. How to handle plugin conflicts (two clocks competing)?
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**See PLUGIN_ARCHITECTURE_SPEC.md for full details**
|
**See [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) for the
|
||||||
|
full architectural specification.**
|
||||||
|
|
||||||
|
|||||||
@@ -95,14 +95,14 @@ Official plugin registry for [LEDMatrix](https://github.com/ChuckBuilds/LEDMatri
|
|||||||
|
|
||||||
All plugins can be installed through the LEDMatrix web interface:
|
All plugins can be installed through the LEDMatrix web interface:
|
||||||
|
|
||||||
1. Open web interface (http://your-pi-ip:5050)
|
1. Open web interface (http://your-pi-ip:5000)
|
||||||
2. Go to Plugin Store tab
|
2. Open the **Plugin Manager** tab
|
||||||
3. Browse or search for plugins
|
3. Browse or search the **Plugin Store** section
|
||||||
4. Click Install
|
4. Click **Install**
|
||||||
|
|
||||||
Or via API:
|
Or via API:
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/install \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
|
||||||
-d '{"plugin_id": "clock-simple"}'
|
-d '{"plugin_id": "clock-simple"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ Before submitting, ensure your plugin:
|
|||||||
1. **Test Your Plugin**
|
1. **Test Your Plugin**
|
||||||
```bash
|
```bash
|
||||||
# Install via URL on your Pi
|
# Install via URL on your Pi
|
||||||
curl -X POST http://your-pi:5050/api/plugins/install-from-url \
|
curl -X POST http://your-pi:5000/api/v3/plugins/install-from-url \
|
||||||
-d '{"repo_url": "https://github.com/you/ledmatrix-your-plugin"}'
|
-d '{"repo_url": "https://github.com/you/ledmatrix-your-plugin"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ git push
|
|||||||
# 1. Receive PR on ledmatrix-plugins repo
|
# 1. Receive PR on ledmatrix-plugins repo
|
||||||
# 2. Review using VERIFICATION.md checklist
|
# 2. Review using VERIFICATION.md checklist
|
||||||
# 3. Test installation:
|
# 3. Test installation:
|
||||||
curl -X POST http://pi:5050/api/plugins/install-from-url \
|
curl -X POST http://pi:5000/api/v3/plugins/install-from-url \
|
||||||
-d '{"repo_url": "https://github.com/contributor/plugin"}'
|
-d '{"repo_url": "https://github.com/contributor/plugin"}'
|
||||||
|
|
||||||
# 4. If approved, merge PR
|
# 4. If approved, merge PR
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ The LEDMatrix Plugin Store allows you to discover, install, and manage display p
|
|||||||
```bash
|
```bash
|
||||||
# Web UI: Plugin Store → Search → Click Install
|
# Web UI: Plugin Store → Search → Click Install
|
||||||
# API:
|
# API:
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/install \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "clock-simple"}'
|
-d '{"plugin_id": "clock-simple"}'
|
||||||
```
|
```
|
||||||
@@ -21,7 +21,7 @@ curl -X POST http://your-pi-ip:5050/api/plugins/install \
|
|||||||
```bash
|
```bash
|
||||||
# Web UI: Plugin Store → "Install from URL" → Paste URL
|
# Web UI: Plugin Store → "Install from URL" → Paste URL
|
||||||
# API:
|
# API:
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/install-from-url \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install-from-url \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"repo_url": "https://github.com/user/ledmatrix-plugin"}'
|
-d '{"repo_url": "https://github.com/user/ledmatrix-plugin"}'
|
||||||
```
|
```
|
||||||
@@ -29,20 +29,20 @@ curl -X POST http://your-pi-ip:5050/api/plugins/install-from-url \
|
|||||||
### Manage Plugins
|
### Manage Plugins
|
||||||
```bash
|
```bash
|
||||||
# List installed
|
# List installed
|
||||||
curl "http://your-pi-ip:5050/api/plugins/installed"
|
curl "http://your-pi-ip:5000/api/v3/plugins/installed"
|
||||||
|
|
||||||
# Enable/disable
|
# Enable/disable
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/toggle \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/toggle \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "clock-simple", "enabled": true}'
|
-d '{"plugin_id": "clock-simple", "enabled": true}'
|
||||||
|
|
||||||
# Update
|
# Update
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/update \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/update \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "clock-simple"}'
|
-d '{"plugin_id": "clock-simple"}'
|
||||||
|
|
||||||
# Uninstall
|
# Uninstall
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/uninstall \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/uninstall \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "clock-simple"}'
|
-d '{"plugin_id": "clock-simple"}'
|
||||||
```
|
```
|
||||||
@@ -56,7 +56,7 @@ curl -X POST http://your-pi-ip:5050/api/plugins/uninstall \
|
|||||||
The official plugin store contains curated, verified plugins that have been reviewed by maintainers.
|
The official plugin store contains curated, verified plugins that have been reviewed by maintainers.
|
||||||
|
|
||||||
**Via Web Interface:**
|
**Via Web Interface:**
|
||||||
1. Open the web interface at http://your-pi-ip:5050
|
1. Open the web interface at http://your-pi-ip:5000
|
||||||
2. Navigate to the "Plugin Store" tab
|
2. Navigate to the "Plugin Store" tab
|
||||||
3. Browse or search for plugins
|
3. Browse or search for plugins
|
||||||
4. Click "Install" on the desired plugin
|
4. Click "Install" on the desired plugin
|
||||||
@@ -65,7 +65,7 @@ The official plugin store contains curated, verified plugins that have been revi
|
|||||||
|
|
||||||
**Via REST API:**
|
**Via REST API:**
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/install \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "clock-simple"}'
|
-d '{"plugin_id": "clock-simple"}'
|
||||||
```
|
```
|
||||||
@@ -101,7 +101,7 @@ Install any plugin directly from a GitHub repository, even if it's not in the of
|
|||||||
|
|
||||||
**Via REST API:**
|
**Via REST API:**
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/install-from-url \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install-from-url \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"repo_url": "https://github.com/user/ledmatrix-my-plugin"}'
|
-d '{"repo_url": "https://github.com/user/ledmatrix-my-plugin"}'
|
||||||
```
|
```
|
||||||
@@ -131,13 +131,13 @@ else:
|
|||||||
**Via REST API:**
|
**Via REST API:**
|
||||||
```bash
|
```bash
|
||||||
# Search by query
|
# Search by query
|
||||||
curl "http://your-pi-ip:5050/api/plugins/store/search?q=hockey"
|
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?q=hockey"
|
||||||
|
|
||||||
# Filter by category
|
# Filter by category
|
||||||
curl "http://your-pi-ip:5050/api/plugins/store/search?category=sports"
|
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?category=sports"
|
||||||
|
|
||||||
# Filter by tags
|
# Filter by tags
|
||||||
curl "http://your-pi-ip:5050/api/plugins/store/search?tags=nhl&tags=hockey"
|
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?tags=nhl&tags=hockey"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Via Python:**
|
**Via Python:**
|
||||||
@@ -168,7 +168,7 @@ results = store.search_plugins(tags=["nhl", "hockey"])
|
|||||||
|
|
||||||
**Via REST API:**
|
**Via REST API:**
|
||||||
```bash
|
```bash
|
||||||
curl "http://your-pi-ip:5050/api/plugins/installed"
|
curl "http://your-pi-ip:5000/api/v3/plugins/installed"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Via Python:**
|
**Via Python:**
|
||||||
@@ -192,7 +192,7 @@ for plugin_id in installed:
|
|||||||
|
|
||||||
**Via REST API:**
|
**Via REST API:**
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/toggle \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/toggle \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "clock-simple", "enabled": true}'
|
-d '{"plugin_id": "clock-simple", "enabled": true}'
|
||||||
```
|
```
|
||||||
@@ -207,7 +207,7 @@ curl -X POST http://your-pi-ip:5050/api/plugins/toggle \
|
|||||||
|
|
||||||
**Via REST API:**
|
**Via REST API:**
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/update \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/update \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "clock-simple"}'
|
-d '{"plugin_id": "clock-simple"}'
|
||||||
```
|
```
|
||||||
@@ -230,7 +230,7 @@ success = store.update_plugin('clock-simple')
|
|||||||
|
|
||||||
**Via REST API:**
|
**Via REST API:**
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://your-pi-ip:5050/api/plugins/uninstall \
|
curl -X POST http://your-pi-ip:5000/api/v3/plugins/uninstall \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "clock-simple"}'
|
-d '{"plugin_id": "clock-simple"}'
|
||||||
```
|
```
|
||||||
@@ -351,15 +351,15 @@ All API endpoints return JSON with this structure:
|
|||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/api/plugins/store/list` | List all plugins in store |
|
| GET | `/api/v3/plugins/store/list` | List all plugins in store |
|
||||||
| GET | `/api/plugins/store/search` | Search for plugins |
|
| GET | `/api/v3/plugins/store/search` | Search for plugins |
|
||||||
| GET | `/api/plugins/installed` | List installed plugins |
|
| GET | `/api/v3/plugins/installed` | List installed plugins |
|
||||||
| POST | `/api/plugins/install` | Install from registry |
|
| POST | `/api/v3/plugins/install` | Install from registry |
|
||||||
| POST | `/api/plugins/install-from-url` | Install from GitHub URL |
|
| POST | `/api/v3/plugins/install-from-url` | Install from GitHub URL |
|
||||||
| POST | `/api/plugins/uninstall` | Uninstall plugin |
|
| POST | `/api/v3/plugins/uninstall` | Uninstall plugin |
|
||||||
| POST | `/api/plugins/update` | Update plugin |
|
| POST | `/api/v3/plugins/update` | Update plugin |
|
||||||
| POST | `/api/plugins/toggle` | Enable/disable plugin |
|
| POST | `/api/v3/plugins/toggle` | Enable/disable plugin |
|
||||||
| POST | `/api/plugins/config` | Update plugin config |
|
| POST | `/api/v3/plugins/config` | Update plugin config |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -369,7 +369,7 @@ All API endpoints return JSON with this structure:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install
|
# Install
|
||||||
curl -X POST http://192.168.1.100:5050/api/plugins/install \
|
curl -X POST http://192.168.1.100:5000/api/v3/plugins/install \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "clock-simple"}'
|
-d '{"plugin_id": "clock-simple"}'
|
||||||
|
|
||||||
@@ -390,12 +390,12 @@ sudo systemctl restart ledmatrix
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install your own plugin during development
|
# Install your own plugin during development
|
||||||
curl -X POST http://192.168.1.100:5050/api/plugins/install-from-url \
|
curl -X POST http://192.168.1.100:5000/api/v3/plugins/install-from-url \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"repo_url": "https://github.com/myusername/ledmatrix-my-custom-plugin"}'
|
-d '{"repo_url": "https://github.com/myusername/ledmatrix-my-custom-plugin"}'
|
||||||
|
|
||||||
# Enable it
|
# Enable it
|
||||||
curl -X POST http://192.168.1.100:5050/api/plugins/toggle \
|
curl -X POST http://192.168.1.100:5000/api/v3/plugins/toggle \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"plugin_id": "my-custom-plugin", "enabled": true}'
|
-d '{"plugin_id": "my-custom-plugin", "enabled": true}'
|
||||||
|
|
||||||
|
|||||||
241
docs/README.md
241
docs/README.md
@@ -1,199 +1,84 @@
|
|||||||
# LEDMatrix Documentation
|
# LEDMatrix Documentation
|
||||||
|
|
||||||
Welcome to the LEDMatrix documentation! This directory contains comprehensive guides, specifications, and reference materials for the LEDMatrix project.
|
This directory contains guides, references, and architectural notes for the
|
||||||
|
LEDMatrix project. If you are setting up a Pi for the first time, start with
|
||||||
|
the [project root README](../README.md) — it covers hardware, OS imaging, and
|
||||||
|
the one-shot installer. The pages here go deeper.
|
||||||
|
|
||||||
## 📚 Documentation Overview
|
## I'm a new user
|
||||||
|
|
||||||
This documentation has been recently consolidated (January 2026) to reduce redundancy while maintaining comprehensive coverage. We've reduced from 51 main documents to 16-17 well-organized files (~68% reduction) by merging duplicates, archiving ephemeral content, and unifying writing styles.
|
1. [GETTING_STARTED.md](GETTING_STARTED.md) — first-time setup walkthrough
|
||||||
|
2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) — using the web UI
|
||||||
|
3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) — installing and managing plugins
|
||||||
|
4. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) — WiFi and AP-mode setup
|
||||||
|
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
|
||||||
|
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
||||||
|
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
||||||
|
|
||||||
## 📖 Quick Start
|
## I want to write a plugin
|
||||||
|
|
||||||
### For New Users
|
Start here:
|
||||||
1. **Installation**: Follow the main [README.md](../README.md) in the project root
|
|
||||||
2. **First Setup**: See [GETTING_STARTED.md](GETTING_STARTED.md) for first-time setup guide
|
|
||||||
3. **Web Interface**: Use [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) to learn the control panel
|
|
||||||
4. **Troubleshooting**: Check [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common issues
|
|
||||||
|
|
||||||
### For Developers
|
1. [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) — end-to-end workflow
|
||||||
1. **Plugin Development**: See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for complete guide
|
2. [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) — cheat sheet
|
||||||
2. **Advanced Patterns**: Read [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) for advanced techniques
|
3. [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — display, cache, and plugin-manager APIs
|
||||||
3. **API Reference**: Check [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for available methods
|
4. [PLUGIN_ERROR_HANDLING.md](PLUGIN_ERROR_HANDLING.md) — error-handling patterns
|
||||||
4. **Configuration**: See [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) for config schemas
|
5. [DEV_PREVIEW.md](DEV_PREVIEW.md) — preview plugins on your dev machine without a Pi
|
||||||
|
6. [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) — running the matrix emulator
|
||||||
|
|
||||||
### For API Integration
|
Going deeper:
|
||||||
1. **REST API**: See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for all web interface endpoints
|
|
||||||
2. **Plugin API**: See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for plugin developer APIs
|
|
||||||
3. **Developer Reference**: See [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) for common tasks
|
|
||||||
|
|
||||||
## 📋 Documentation Categories
|
- [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) — advanced patterns
|
||||||
|
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) — full plugin-system spec
|
||||||
|
- [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md) /
|
||||||
|
[PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md)
|
||||||
|
- [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md) (+ [example JSON](PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json))
|
||||||
|
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) /
|
||||||
|
[PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md)
|
||||||
|
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
|
||||||
|
- [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps
|
||||||
|
- [widget-guide.md](widget-guide.md) — widget development
|
||||||
|
|
||||||
### 🚀 Getting Started & User Guides
|
## Configuring plugins
|
||||||
- [GETTING_STARTED.md](GETTING_STARTED.md) - First-time setup and quick start guide
|
|
||||||
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Complete web interface user guide
|
|
||||||
- [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi configuration and AP mode setup
|
|
||||||
- [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Installing and managing plugins
|
|
||||||
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Common issues and solutions
|
|
||||||
|
|
||||||
### ⚡ Advanced Features
|
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
|
||||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) - Vegas scroll mode, on-demand display, cache management, background services, permissions
|
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
|
||||||
|
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
|
||||||
|
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
|
||||||
|
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
|
||||||
|
|
||||||
### 🔌 Plugin Development
|
## Advanced features
|
||||||
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete plugin development workflow
|
|
||||||
- [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) - Plugin development quick reference
|
|
||||||
- [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
|
|
||||||
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration schema design
|
|
||||||
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) - Configuration tabs feature
|
|
||||||
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) - Quick configuration guide
|
|
||||||
- [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md) - Managing plugin dependencies
|
|
||||||
- [PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) - Dependency troubleshooting
|
|
||||||
|
|
||||||
### 🏗️ Plugin Features & Extensions
|
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||||
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) - Custom plugin icons
|
cache management, background services, permissions
|
||||||
- [PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md) - Custom icons implementation
|
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||||
- [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Plugin system implementation
|
|
||||||
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) - Setting up plugin registry
|
|
||||||
- [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md) - Web UI actions for plugins
|
|
||||||
|
|
||||||
### 📡 API Reference
|
## Reference
|
||||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API documentation (71+ endpoints)
|
|
||||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin developer API (Display Manager, Cache Manager, Plugin Manager)
|
|
||||||
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) - Quick reference for common developer tasks
|
|
||||||
|
|
||||||
### 🏛️ Architecture & Design
|
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints
|
||||||
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete plugin system specification
|
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins
|
||||||
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) - Configuration system architecture
|
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
|
||||||
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) - Core configuration properties
|
- [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) — what the plugin system actually does
|
||||||
|
|
||||||
### 🛠️ Development & Tools
|
## Contributing to LEDMatrix itself
|
||||||
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment setup
|
|
||||||
- [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) - Set up development environment with emulator
|
|
||||||
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) - Testing documentation
|
|
||||||
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) - Multi-workspace development
|
|
||||||
- [FONT_MANAGER.md](FONT_MANAGER.md) - Font management system
|
|
||||||
|
|
||||||
### 🔄 Migration & Updates
|
- [DEVELOPMENT.md](DEVELOPMENT.md) — environment setup
|
||||||
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) - Breaking changes and migration instructions
|
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
|
||||||
- [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) - SSH troubleshooting after install
|
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
|
||||||
|
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
|
||||||
|
|
||||||
### 📚 Miscellaneous
|
## Archive
|
||||||
- [widget-guide.md](widget-guide.md) - Widget development guide
|
|
||||||
- Template files:
|
|
||||||
- [plugin_registry_template.json](plugin_registry_template.json) - Plugin registry template
|
|
||||||
- [PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json](PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json) - Web UI actions example
|
|
||||||
|
|
||||||
## 🎯 Key Resources by Use Case
|
`docs/archive/` holds older guides that have been superseded or describe
|
||||||
|
features that have been removed. They are kept for historical context and
|
||||||
|
git history but should not be relied on.
|
||||||
|
|
||||||
### I'm new to LEDMatrix
|
## Contributing to the docs
|
||||||
1. [GETTING_STARTED.md](GETTING_STARTED.md) - Start here for first-time setup
|
|
||||||
2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Learn the control panel
|
|
||||||
3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Install plugins
|
|
||||||
|
|
||||||
### I want to create a plugin
|
- Markdown only, professional tone, minimal emoji.
|
||||||
1. [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete development guide
|
- Prefer adding to an existing page over creating a new one. If you add a
|
||||||
2. [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Available methods and APIs
|
new page, link it from this index in the section it belongs to.
|
||||||
3. [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns
|
- If a page becomes obsolete, move it to `docs/archive/` rather than
|
||||||
4. [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
|
deleting it, so links don't rot.
|
||||||
5. [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete specification
|
- Keep examples runnable — paths, commands, and config keys here should
|
||||||
|
match what's actually in the repo.
|
||||||
### I need to troubleshoot an issue
|
|
||||||
1. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Comprehensive troubleshooting guide
|
|
||||||
2. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi/network issues
|
|
||||||
3. [PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) - Dependency issues
|
|
||||||
|
|
||||||
### I want to use advanced features
|
|
||||||
1. [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) - Vegas scroll, on-demand display, background services
|
|
||||||
2. [FONT_MANAGER.md](FONT_MANAGER.md) - Font management
|
|
||||||
3. [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - API integration
|
|
||||||
|
|
||||||
### I want to understand the architecture
|
|
||||||
1. [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - System architecture
|
|
||||||
2. [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) - Configuration architecture
|
|
||||||
3. [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Implementation details
|
|
||||||
|
|
||||||
## 🔄 Recent Consolidations (January 2026)
|
|
||||||
|
|
||||||
### Major Consolidation Effort
|
|
||||||
- **Before**: 51 main documentation files
|
|
||||||
- **After**: 16-17 well-organized files
|
|
||||||
- **Reduction**: ~68% fewer files
|
|
||||||
- **Archived**: 33 files (consolidated sources + ephemeral docs)
|
|
||||||
|
|
||||||
### New Consolidated Guides
|
|
||||||
- **GETTING_STARTED.md** - New first-time user guide
|
|
||||||
- **WEB_INTERFACE_GUIDE.md** - Consolidated web interface documentation
|
|
||||||
- **WIFI_NETWORK_SETUP.md** - Consolidated WiFi setup (5 files → 1)
|
|
||||||
- **PLUGIN_STORE_GUIDE.md** - Consolidated plugin store guides (2 files → 1)
|
|
||||||
- **TROUBLESHOOTING.md** - Consolidated troubleshooting (4 files → 1)
|
|
||||||
- **ADVANCED_FEATURES.md** - Consolidated advanced features (6 files → 1)
|
|
||||||
|
|
||||||
### What Was Archived
|
|
||||||
- Ephemeral debug documents (DEBUG_WEB_ISSUE.md, BROWSER_ERRORS_EXPLANATION.md, etc.)
|
|
||||||
- Implementation summaries (PLUGIN_CONFIG_TABS_SUMMARY.md, STARTUP_OPTIMIZATION_SUMMARY.md, etc.)
|
|
||||||
- Consolidated source files (WIFI_SETUP.md, V3_INTERFACE_README.md, etc.)
|
|
||||||
- Testing documentation (CAPTIVE_PORTAL_TESTING.md, etc.)
|
|
||||||
|
|
||||||
All archived files are preserved in `docs/archive/` with full git history.
|
|
||||||
|
|
||||||
### Benefits
|
|
||||||
- ✅ Easier to find information (fewer files to search)
|
|
||||||
- ✅ No duplicate content
|
|
||||||
- ✅ Consistent writing style (professional technical)
|
|
||||||
- ✅ Updated outdated references
|
|
||||||
- ✅ Fixed broken internal links
|
|
||||||
- ✅ Better organization for users vs developers
|
|
||||||
|
|
||||||
## 📝 Contributing to Documentation
|
|
||||||
|
|
||||||
### Documentation Standards
|
|
||||||
- Use Markdown format with consistent headers
|
|
||||||
- Professional technical writing style
|
|
||||||
- Minimal emojis (1-2 per major section for navigation)
|
|
||||||
- Include code examples where helpful
|
|
||||||
- Provide both quick start and detailed reference sections
|
|
||||||
- Cross-reference related documentation
|
|
||||||
|
|
||||||
### Adding New Documentation
|
|
||||||
1. Consider if content should be added to existing docs first
|
|
||||||
2. Place in appropriate category (see sections above)
|
|
||||||
3. Update this README.md with the new document
|
|
||||||
4. Follow naming conventions (FEATURE_NAME.md)
|
|
||||||
5. Use consistent formatting and voice
|
|
||||||
|
|
||||||
### Consolidation Guidelines
|
|
||||||
- **User Guides**: Consolidate by topic (WiFi, troubleshooting, etc.)
|
|
||||||
- **Developer Guides**: Keep development vs reference vs architecture separate
|
|
||||||
- **Debug Documents**: Archive after issues are resolved
|
|
||||||
- **Implementation Summaries**: Archive completed implementation details
|
|
||||||
- **Ephemeral Content**: Archive, don't keep in main docs
|
|
||||||
|
|
||||||
## 🔗 Related Documentation
|
|
||||||
|
|
||||||
- [Main Project README](../README.md) - Installation and basic usage
|
|
||||||
- [Web Interface README](../web_interface/README.md) - Web interface details
|
|
||||||
- [GitHub Issues](https://github.com/ChuckBuilds/LEDMatrix/issues) - Bug reports and feature requests
|
|
||||||
- [GitHub Discussions](https://github.com/ChuckBuilds/LEDMatrix/discussions) - Community support
|
|
||||||
|
|
||||||
## 📊 Documentation Statistics
|
|
||||||
|
|
||||||
- **Main Documents**: 16-17 files (after consolidation)
|
|
||||||
- **Archived Documents**: 33 files (in docs/archive/)
|
|
||||||
- **Categories**: 9 major sections
|
|
||||||
- **Primary Language**: English
|
|
||||||
- **Format**: Markdown (.md)
|
|
||||||
- **Last Major Update**: January 2026
|
|
||||||
- **Coverage**: Installation, user guides, development, troubleshooting, architecture, API references
|
|
||||||
|
|
||||||
### Documentation Highlights
|
|
||||||
- ✅ Comprehensive user guides for first-time setup
|
|
||||||
- ✅ Complete REST API documentation (71+ endpoints)
|
|
||||||
- ✅ Complete Plugin API reference (Display Manager, Cache Manager, Plugin Manager)
|
|
||||||
- ✅ Advanced plugin development guide with examples
|
|
||||||
- ✅ Consolidated configuration documentation
|
|
||||||
- ✅ Professional technical writing throughout
|
|
||||||
- ✅ ~68% reduction in file count while maintaining coverage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This documentation index was last updated: January 2026*
|
|
||||||
|
|
||||||
*For questions or suggestions about the documentation, please open an issue or start a discussion on GitHub.*
|
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ All endpoints return JSON responses with a standard format:
|
|||||||
- [Cache](#cache)
|
- [Cache](#cache)
|
||||||
- [WiFi](#wifi)
|
- [WiFi](#wifi)
|
||||||
- [Streams](#streams)
|
- [Streams](#streams)
|
||||||
|
- [Logs](#logs)
|
||||||
|
- [Error tracking](#error-tracking)
|
||||||
|
- [Health](#health)
|
||||||
|
- [Schedule (dim/power)](#schedule-dimpower)
|
||||||
|
- [Plugin-specific endpoints](#plugin-specific-endpoints)
|
||||||
|
- [Starlark Apps](#starlark-apps)
|
||||||
|
|
||||||
|
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||||
|
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
|
||||||
|
> Flask app at `app.py:607-615`. There are about 92 routes total — see
|
||||||
|
> `web_interface/blueprints/api_v3.py` for the canonical list.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1201,10 +1212,16 @@ Upload a custom font file.
|
|||||||
|
|
||||||
### Delete Font
|
### Delete Font
|
||||||
|
|
||||||
**DELETE** `/api/v3/fonts/delete/<font_family>`
|
**DELETE** `/api/v3/fonts/<font_family>`
|
||||||
|
|
||||||
Delete an uploaded font.
|
Delete an uploaded font.
|
||||||
|
|
||||||
|
### Font Preview
|
||||||
|
|
||||||
|
**GET** `/api/v3/fonts/preview?family=<font_family>&text=<sample>`
|
||||||
|
|
||||||
|
Render a small preview image of a font for use in the web UI font picker.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cache
|
## Cache
|
||||||
@@ -1439,6 +1456,130 @@ Get recent log entries.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Error tracking
|
||||||
|
|
||||||
|
### Get Error Summary
|
||||||
|
|
||||||
|
**GET** `/api/v3/errors/summary`
|
||||||
|
|
||||||
|
Aggregated counts of recent errors across all plugins and core
|
||||||
|
components, used by the web UI's error indicator.
|
||||||
|
|
||||||
|
### Get Plugin Errors
|
||||||
|
|
||||||
|
**GET** `/api/v3/errors/plugin/<plugin_id>`
|
||||||
|
|
||||||
|
Recent errors for a specific plugin.
|
||||||
|
|
||||||
|
### Clear Errors
|
||||||
|
|
||||||
|
**POST** `/api/v3/errors/clear`
|
||||||
|
|
||||||
|
Clear the in-memory error aggregator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
**GET** `/api/v3/health`
|
||||||
|
|
||||||
|
Lightweight liveness check used by the WiFi monitor and external
|
||||||
|
monitoring tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schedule (dim/power)
|
||||||
|
|
||||||
|
### Get Dim Schedule
|
||||||
|
|
||||||
|
**GET** `/api/v3/config/dim-schedule`
|
||||||
|
|
||||||
|
Read the dim/power schedule that automatically reduces brightness or
|
||||||
|
turns the display off at configured times.
|
||||||
|
|
||||||
|
### Update Dim Schedule
|
||||||
|
|
||||||
|
**POST** `/api/v3/config/dim-schedule`
|
||||||
|
|
||||||
|
Update the dim schedule. Body matches the structure returned by GET.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin-specific endpoints
|
||||||
|
|
||||||
|
A handful of endpoints belong to individual built-in or shipped plugins.
|
||||||
|
|
||||||
|
### Calendar
|
||||||
|
|
||||||
|
**GET** `/api/v3/plugins/calendar/list-calendars`
|
||||||
|
|
||||||
|
List the calendars available on the authenticated Google account.
|
||||||
|
Used by the calendar plugin's config UI.
|
||||||
|
|
||||||
|
### Of The Day
|
||||||
|
|
||||||
|
**POST** `/api/v3/plugins/of-the-day/json/upload`
|
||||||
|
|
||||||
|
Upload a JSON data file for the Of-The-Day plugin's category data.
|
||||||
|
|
||||||
|
**POST** `/api/v3/plugins/of-the-day/json/delete`
|
||||||
|
|
||||||
|
Delete a previously uploaded Of-The-Day data file.
|
||||||
|
|
||||||
|
### Plugin Static Assets
|
||||||
|
|
||||||
|
**GET** `/api/v3/plugins/<plugin_id>/static/<path:file_path>`
|
||||||
|
|
||||||
|
Serve a static asset (image, font, etc.) from a plugin's directory.
|
||||||
|
Used internally by the web UI to render plugin previews and icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Starlark Apps
|
||||||
|
|
||||||
|
The Starlark plugin lets you run [Tronbyt](https://github.com/tronbyt/apps)
|
||||||
|
Starlark apps on the matrix. These endpoints expose its UI.
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
**GET** `/api/v3/starlark/status`
|
||||||
|
|
||||||
|
Returns whether the Pixlet binary is installed and the Starlark plugin
|
||||||
|
is operational.
|
||||||
|
|
||||||
|
### Install Pixlet
|
||||||
|
|
||||||
|
**POST** `/api/v3/starlark/install-pixlet`
|
||||||
|
|
||||||
|
Download and install the Pixlet binary on the Pi.
|
||||||
|
|
||||||
|
### Apps
|
||||||
|
|
||||||
|
**GET** `/api/v3/starlark/apps` — list installed Starlark apps
|
||||||
|
**GET** `/api/v3/starlark/apps/<app_id>` — get app details
|
||||||
|
**DELETE** `/api/v3/starlark/apps/<app_id>` — uninstall an app
|
||||||
|
**GET** `/api/v3/starlark/apps/<app_id>/config` — get app config schema
|
||||||
|
**PUT** `/api/v3/starlark/apps/<app_id>/config` — update app config
|
||||||
|
**POST** `/api/v3/starlark/apps/<app_id>/render` — render app to a frame
|
||||||
|
**POST** `/api/v3/starlark/apps/<app_id>/toggle` — enable/disable app
|
||||||
|
|
||||||
|
### Repository (Tronbyt community apps)
|
||||||
|
|
||||||
|
**GET** `/api/v3/starlark/repository/categories` — browse categories
|
||||||
|
**GET** `/api/v3/starlark/repository/browse?category=<cat>` — browse apps
|
||||||
|
**POST** `/api/v3/starlark/repository/install` — install an app from the
|
||||||
|
community repository
|
||||||
|
|
||||||
|
### Upload custom app
|
||||||
|
|
||||||
|
**POST** `/api/v3/starlark/upload`
|
||||||
|
|
||||||
|
Upload a custom Starlark `.star` file as a new app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
All endpoints may return error responses in the following format:
|
All endpoints may return error responses in the following format:
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ If the script reboots the Pi (which it recommends), network services may restart
|
|||||||
# Connect to your WiFi network (replace with your SSID and password)
|
# Connect to your WiFi network (replace with your SSID and password)
|
||||||
sudo nmcli device wifi connect "YourWiFiSSID" password "YourPassword"
|
sudo nmcli device wifi connect "YourWiFiSSID" password "YourPassword"
|
||||||
|
|
||||||
# Or use the web interface at http://192.168.4.1:5001
|
# Or use the web interface at http://192.168.4.1:5000
|
||||||
# Navigate to WiFi tab and connect to your network
|
# Navigate to WiFi tab and connect to your network
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -177,9 +177,9 @@ sudo systemctl restart NetworkManager
|
|||||||
|
|
||||||
Even if SSH is unavailable, you can access the web interface:
|
Even if SSH is unavailable, you can access the web interface:
|
||||||
|
|
||||||
1. **Via AP Mode**: Connect to **LEDMatrix-Setup** network and visit `http://192.168.4.1:5001`
|
1. **Via AP Mode**: Connect to **LEDMatrix-Setup** network and visit `http://192.168.4.1:5000`
|
||||||
2. **Via WiFi**: If WiFi is connected, visit `http://<pi-ip-address>:5001`
|
2. **Via WiFi**: If WiFi is connected, visit `http://<pi-ip-address>:5000`
|
||||||
3. **Via Ethernet**: Visit `http://<pi-ip-address>:5001`
|
3. **Via Ethernet**: Visit `http://<pi-ip-address>:5000`
|
||||||
|
|
||||||
The web interface allows you to:
|
The web interface allows you to:
|
||||||
- Configure WiFi connections
|
- Configure WiFi connections
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ Pixlet is the rendering engine that executes Starlark apps. The plugin will atte
|
|||||||
|
|
||||||
#### Auto-Install via Web UI
|
#### Auto-Install via Web UI
|
||||||
|
|
||||||
Navigate to: **Plugins → Starlark Apps → Status → Install Pixlet**
|
Navigate to: **Plugin Manager → Starlark Apps tab (in the second nav row) → Status → Install Pixlet**
|
||||||
|
|
||||||
This runs the bundled installation script which downloads the appropriate binary for your platform.
|
This runs the bundled installation script which downloads the appropriate binary for your platform.
|
||||||
|
|
||||||
@@ -110,10 +110,10 @@ Verify installation:
|
|||||||
|
|
||||||
### 2. Enable the Starlark Apps Plugin
|
### 2. Enable the Starlark Apps Plugin
|
||||||
|
|
||||||
1. Open the web UI
|
1. Open the web UI (`http://your-pi-ip:5000`)
|
||||||
2. Navigate to **Plugins**
|
2. Open the **Plugin Manager** tab
|
||||||
3. Find **Starlark Apps** in the installed plugins list
|
3. Find **Starlark Apps** in the **Installed Plugins** list
|
||||||
4. Enable the plugin
|
4. Enable the plugin (it then gets its own tab in the second nav row)
|
||||||
5. Configure settings:
|
5. Configure settings:
|
||||||
- **Magnify**: Auto-calculated based on your display size (or set manually)
|
- **Magnify**: Auto-calculated based on your display size (or set manually)
|
||||||
- **Render Interval**: How often apps re-render (default: 300s)
|
- **Render Interval**: How often apps re-render (default: 300s)
|
||||||
@@ -122,7 +122,7 @@ Verify installation:
|
|||||||
|
|
||||||
### 3. Browse and Install Apps
|
### 3. Browse and Install Apps
|
||||||
|
|
||||||
1. Navigate to **Plugins → Starlark Apps → App Store**
|
1. Navigate to **Plugin Manager → Starlark Apps tab (in the second nav row) → App Store**
|
||||||
2. Browse available apps (974+ options)
|
2. Browse available apps (974+ options)
|
||||||
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
|
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
|
||||||
4. Click **Install** on desired apps
|
4. Click **Install** on desired apps
|
||||||
@@ -307,7 +307,7 @@ Many apps require API keys for external services:
|
|||||||
**Symptom**: "Pixlet binary not found" error
|
**Symptom**: "Pixlet binary not found" error
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Run auto-installer: **Plugins → Starlark Apps → Install Pixlet**
|
1. Run auto-installer: **Plugin Manager → Starlark Apps tab (in the second nav row) → Install Pixlet**
|
||||||
2. Manual install: `bash scripts/download_pixlet.sh`
|
2. Manual install: `bash scripts/download_pixlet.sh`
|
||||||
3. Check permissions: `chmod +x bin/pixlet/pixlet-*`
|
3. Check permissions: `chmod +x bin/pixlet/pixlet-*`
|
||||||
4. Verify architecture: `uname -m` matches binary name
|
4. Verify architecture: `uname -m` matches binary name
|
||||||
@@ -338,7 +338,7 @@ Many apps require API keys for external services:
|
|||||||
**Symptom**: Content appears stretched, squished, or cropped
|
**Symptom**: Content appears stretched, squished, or cropped
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Check magnify setting: **Plugins → Starlark Apps → Config**
|
1. Check magnify setting: **Plugin Manager → Starlark Apps tab (in the second nav row) → Config**
|
||||||
2. Try `center_small_output=true` to preserve aspect ratio
|
2. Try `center_small_output=true` to preserve aspect ratio
|
||||||
3. Adjust `magnify` manually (1-8) for your display size
|
3. Adjust `magnify` manually (1-8) for your display size
|
||||||
4. Some apps assume 64×32 - may not scale perfectly to all sizes
|
4. Some apps assume 64×32 - may not scale perfectly to all sizes
|
||||||
@@ -349,7 +349,7 @@ Many apps require API keys for external services:
|
|||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Check render interval: **App Config → Render Interval** (300s default)
|
1. Check render interval: **App Config → Render Interval** (300s default)
|
||||||
2. Force re-render: **Plugins → Starlark Apps → {App} → Render Now**
|
2. Force re-render: **Plugin Manager → Starlark Apps tab (in the second nav row) → {App} → Render Now**
|
||||||
3. Clear cache: Restart LEDMatrix service
|
3. Clear cache: Restart LEDMatrix service
|
||||||
4. API rate limits: Some services throttle requests
|
4. API rate limits: Some services throttle requests
|
||||||
5. Check app logs for API errors
|
5. Check app logs for API errors
|
||||||
|
|||||||
@@ -47,13 +47,15 @@ bash scripts/diagnose_web_interface.sh
|
|||||||
# WiFi setup verification
|
# WiFi setup verification
|
||||||
./scripts/verify_wifi_setup.sh
|
./scripts/verify_wifi_setup.sh
|
||||||
|
|
||||||
# Weather plugin troubleshooting
|
|
||||||
./troubleshoot_weather.sh
|
|
||||||
|
|
||||||
# Captive portal troubleshooting
|
# Captive portal troubleshooting
|
||||||
./scripts/troubleshoot_captive_portal.sh
|
./scripts/troubleshoot_captive_portal.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Weather is provided by the `ledmatrix-weather` plugin (installed via the
|
||||||
|
> Plugin Store). To troubleshoot weather, check that plugin's tab in the
|
||||||
|
> web UI for its API key and recent error messages, then watch the
|
||||||
|
> **Logs** tab.
|
||||||
|
|
||||||
### 4. Check Configuration
|
### 4. Check Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -85,7 +87,7 @@ python3 web_interface/start.py
|
|||||||
#### Service Not Running/Starting
|
#### Service Not Running/Starting
|
||||||
|
|
||||||
**Symptoms:**
|
**Symptoms:**
|
||||||
- Cannot access web interface at http://your-pi-ip:5050
|
- Cannot access web interface at http://your-pi-ip:5000
|
||||||
- `systemctl status ledmatrix-web` shows `inactive (dead)`
|
- `systemctl status ledmatrix-web` shows `inactive (dead)`
|
||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
@@ -157,13 +159,13 @@ sudo systemctl restart ledmatrix-web
|
|||||||
|
|
||||||
**Symptoms:**
|
**Symptoms:**
|
||||||
- Error: `Address already in use`
|
- Error: `Address already in use`
|
||||||
- Service fails to bind to port 5050
|
- Service fails to bind to port 5000
|
||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
|
||||||
1. **Check what's using the port:**
|
1. **Check what's using the port:**
|
||||||
```bash
|
```bash
|
||||||
sudo lsof -i :5050
|
sudo lsof -i :5000
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Kill the conflicting process:**
|
2. **Kill the conflicting process:**
|
||||||
@@ -265,7 +267,7 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
6. **Manually enable AP mode:**
|
6. **Manually enable AP mode:**
|
||||||
```bash
|
```bash
|
||||||
# Via API
|
# Via API
|
||||||
curl -X POST http://localhost:5050/api/wifi/ap/enable
|
curl -X POST http://localhost:5000/api/wifi/ap/enable
|
||||||
|
|
||||||
# Via Python
|
# Via Python
|
||||||
python3 -c "
|
python3 -c "
|
||||||
@@ -291,9 +293,8 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Use correct IP address and port:**
|
2. **Use correct IP address and port:**
|
||||||
- Correct: `http://192.168.4.1:5050`
|
- Correct: `http://192.168.4.1:5000`
|
||||||
- NOT: `http://192.168.4.1` (port 80)
|
- NOT: `http://192.168.4.1` (port 80 — nothing listens there)
|
||||||
- NOT: `http://192.168.4.1:5000`
|
|
||||||
|
|
||||||
3. **Check wlan0 has correct IP:**
|
3. **Check wlan0 has correct IP:**
|
||||||
```bash
|
```bash
|
||||||
@@ -309,7 +310,7 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
|
|
||||||
5. **Test from the Pi itself:**
|
5. **Test from the Pi itself:**
|
||||||
```bash
|
```bash
|
||||||
curl http://192.168.4.1:5050
|
curl http://192.168.4.1:5000
|
||||||
# Should return HTML
|
# Should return HTML
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -340,11 +341,11 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
|
|
||||||
4. **Manual captive portal testing:**
|
4. **Manual captive portal testing:**
|
||||||
- Try these URLs manually:
|
- Try these URLs manually:
|
||||||
- `http://192.168.4.1:5050`
|
- `http://192.168.4.1:5000`
|
||||||
- `http://captive.apple.com`
|
- `http://captive.apple.com`
|
||||||
- `http://connectivitycheck.gstatic.com/generate_204`
|
- `http://connectivitycheck.gstatic.com/generate_204`
|
||||||
|
|
||||||
#### Firewall Blocking Port 5050
|
#### Firewall Blocking Port 5000
|
||||||
|
|
||||||
**Symptoms:**
|
**Symptoms:**
|
||||||
- Services running but cannot connect
|
- Services running but cannot connect
|
||||||
@@ -357,9 +358,9 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
sudo ufw status
|
sudo ufw status
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Allow port 5050:**
|
2. **Allow port 5000:**
|
||||||
```bash
|
```bash
|
||||||
sudo ufw allow 5050/tcp
|
sudo ufw allow 5000/tcp
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Check iptables:**
|
3. **Check iptables:**
|
||||||
@@ -372,7 +373,7 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
sudo ufw disable
|
sudo ufw disable
|
||||||
# Test if it works, then re-enable and add rule
|
# Test if it works, then re-enable and add rule
|
||||||
sudo ufw enable
|
sudo ufw enable
|
||||||
sudo ufw allow 5050/tcp
|
sudo ufw allow 5000/tcp
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -403,9 +404,9 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. **Verify in web interface:**
|
3. **Verify in web interface:**
|
||||||
- Navigate to Plugin Management tab
|
- Open the **Plugin Manager** tab
|
||||||
- Toggle the switch to enable
|
- Toggle the plugin switch to enable
|
||||||
- Restart display
|
- From **Overview**, click **Restart Display Service**
|
||||||
|
|
||||||
#### Plugin Not Loading
|
#### Plugin Not Loading
|
||||||
|
|
||||||
@@ -690,12 +691,12 @@ nslookup api.openweathermap.org
|
|||||||
dig api.openweathermap.org
|
dig api.openweathermap.org
|
||||||
|
|
||||||
# Test HTTP endpoint
|
# Test HTTP endpoint
|
||||||
curl -I http://your-pi-ip:5050
|
curl -I http://your-pi-ip:5000
|
||||||
curl http://192.168.4.1:5050
|
curl http://192.168.4.1:5000
|
||||||
|
|
||||||
# Check listening ports
|
# Check listening ports
|
||||||
sudo lsof -i :5050
|
sudo lsof -i :5000
|
||||||
sudo netstat -tuln | grep 5050
|
sudo netstat -tuln | grep 5000
|
||||||
|
|
||||||
# Check network interfaces
|
# Check network interfaces
|
||||||
ip addr show
|
ip addr show
|
||||||
@@ -808,7 +809,7 @@ echo ""
|
|||||||
|
|
||||||
echo "4. Network Status:"
|
echo "4. Network Status:"
|
||||||
ip addr show | grep -E "(wlan|eth|inet )"
|
ip addr show | grep -E "(wlan|eth|inet )"
|
||||||
curl -s http://localhost:5050 > /dev/null && echo "Web interface: OK" || echo "Web interface: FAILED"
|
curl -s http://localhost:5000 > /dev/null && echo "Web interface: OK" || echo "Web interface: FAILED"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "5. File Structure:"
|
echo "5. File Structure:"
|
||||||
@@ -837,22 +838,22 @@ A properly functioning system should show:
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Web Interface Accessible:**
|
2. **Web Interface Accessible:**
|
||||||
- Navigate to http://your-pi-ip:5050
|
- Navigate to http://your-pi-ip:5000
|
||||||
- Page loads successfully
|
- Page loads successfully
|
||||||
- Display preview visible
|
- Display preview visible
|
||||||
|
|
||||||
3. **Logs Show Normal Operation:**
|
3. **Logs Show Normal Operation:**
|
||||||
```
|
```
|
||||||
INFO: Web interface started on port 5050
|
INFO: Web interface started on port 5000
|
||||||
INFO: Loaded X plugins
|
INFO: Loaded X plugins
|
||||||
INFO: Display rotation active
|
INFO: Display rotation active
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Process Listening on Port:**
|
4. **Process Listening on Port:**
|
||||||
```bash
|
```bash
|
||||||
$ sudo lsof -i :5050
|
$ sudo lsof -i :5000
|
||||||
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||||
python3 1234 ledpi 3u IPv4 12345 0t0 TCP *:5050 (LISTEN)
|
python3 1234 ledpi 3u IPv4 12345 0t0 TCP *:5000 (LISTEN)
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Plugins Loading:**
|
5. **Plugins Loading:**
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ The LEDMatrix web interface provides a complete control panel for managing your
|
|||||||
|
|
||||||
2. Open a web browser and navigate to:
|
2. Open a web browser and navigate to:
|
||||||
```
|
```
|
||||||
http://your-pi-ip:5050
|
http://your-pi-ip:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
3. The interface will load with the Overview tab displaying system stats and a live display preview.
|
3. The interface will load with the Overview tab displaying system stats and a live display preview.
|
||||||
@@ -31,17 +31,28 @@ sudo systemctl status ledmatrix-web
|
|||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
The interface uses a tab-based layout for easy navigation between features:
|
The interface uses a two-row tab layout. The system tabs are always
|
||||||
|
present:
|
||||||
|
|
||||||
- **Overview** - System stats, quick actions, and display preview
|
- **Overview** — System stats, quick actions, live display preview
|
||||||
- **General Settings** - Timezone, location, and autostart configuration
|
- **General** — Timezone, location, plugin-system settings
|
||||||
- **Display Settings** - Hardware configuration, brightness, and display options
|
- **WiFi** — Network selection and AP-mode setup
|
||||||
- **Durations** - Display rotation timing configuration
|
- **Schedule** — Power and dim schedules
|
||||||
- **Sports Configuration** - Per-league settings and on-demand modes
|
- **Display** — Matrix hardware configuration (rows, cols, hardware
|
||||||
- **Plugin Management** - Install, configure, enable/disable plugins
|
mapping, GPIO slowdown, brightness, PWM)
|
||||||
- **Plugin Store** - Discover and install plugins
|
- **Config Editor** — Raw `config.json` editor with validation
|
||||||
- **Font Management** - Upload fonts, manage overrides, and preview
|
- **Fonts** — Upload and manage fonts
|
||||||
- **Logs** - Real-time log streaming with filtering and search
|
- **Logs** — Real-time log streaming
|
||||||
|
- **Cache** — Cached data inspection and cleanup
|
||||||
|
- **Operation History** — Recent service operations
|
||||||
|
|
||||||
|
A second nav row holds plugin tabs:
|
||||||
|
|
||||||
|
- **Plugin Manager** — browse the **Plugin Store** section, install
|
||||||
|
plugins from GitHub, enable/disable installed plugins
|
||||||
|
- **<plugin-id>** — one tab per installed plugin for its own
|
||||||
|
configuration form (auto-generated from the plugin's
|
||||||
|
`config_schema.json`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,131 +68,84 @@ The Overview tab provides at-a-glance information and quick actions:
|
|||||||
- Disk usage
|
- Disk usage
|
||||||
- Network status
|
- Network status
|
||||||
|
|
||||||
**Quick Actions:**
|
**Quick Actions** (verified in `web_interface/templates/v3/partials/overview.html`):
|
||||||
- **Start/Stop Display** - Control the display service
|
- **Start Display** / **Stop Display** — control the display service
|
||||||
- **Restart Display** - Restart to apply configuration changes
|
- **Restart Display Service** — apply configuration changes
|
||||||
- **Test Display** - Run a quick test pattern
|
- **Restart Web Service** — restart the web UI itself
|
||||||
|
- **Update Code** — `git pull` the latest version (stashes local changes)
|
||||||
|
- **Reboot System** / **Shutdown System** — confirm-gated power controls
|
||||||
|
|
||||||
**Display Preview:**
|
**Display Preview:**
|
||||||
- Live preview of what's currently shown on the LED matrix
|
- Live preview of what's currently shown on the LED matrix
|
||||||
- Updates in real-time
|
- Updates in real-time
|
||||||
- Useful for remote monitoring
|
- Useful for remote monitoring
|
||||||
|
|
||||||
### General Settings Tab
|
### General Tab
|
||||||
|
|
||||||
Configure basic system settings:
|
Configure basic system settings:
|
||||||
|
|
||||||
**Timezone:**
|
- **Timezone** — used by all time/date displays
|
||||||
- Set your local timezone for accurate time display
|
- **Location** — city/state/country for weather and other location-aware
|
||||||
- Auto-detects common timezones
|
plugins
|
||||||
|
- **Plugin System Settings** — including the `plugins_directory` (default
|
||||||
|
`plugin-repos/`) used by the plugin loader
|
||||||
|
- **Autostart** options for the display service
|
||||||
|
|
||||||
**Location:**
|
Click **Save** to write changes to `config/config.json`. Most changes
|
||||||
- Set latitude/longitude for location-based features
|
require a display service restart from **Overview**.
|
||||||
- Used by weather plugins and sunrise/sunset calculations
|
|
||||||
|
|
||||||
**Autostart:**
|
### Display Tab
|
||||||
- Enable/disable display autostart on boot
|
|
||||||
- Configure systemd service settings
|
|
||||||
|
|
||||||
**Save Changes:**
|
|
||||||
- Click "Save Configuration" to apply changes
|
|
||||||
- Restart the display for changes to take effect
|
|
||||||
|
|
||||||
### Display Settings Tab
|
|
||||||
|
|
||||||
Configure your LED matrix hardware:
|
Configure your LED matrix hardware:
|
||||||
|
|
||||||
**Matrix Configuration:**
|
**Matrix configuration:**
|
||||||
- Rows: Number of LED rows (typically 32 or 64)
|
- `rows` — LED rows (typically 32 or 64)
|
||||||
- Columns: Number of LED columns (typically 64, 128, or 256)
|
- `cols` — LED columns (typically 64 or 96)
|
||||||
- Chain Length: Number of chained panels
|
- `chain_length` — number of horizontally chained panels
|
||||||
- Parallel Chains: Number of parallel chains
|
- `parallel` — number of parallel chains
|
||||||
|
- `hardware_mapping` — `adafruit-hat-pwm` (with PWM jumper mod),
|
||||||
|
`adafruit-hat` (without), `regular`, or `regular-pi1`
|
||||||
|
- `gpio_slowdown` — must match your Pi model (3 for Pi 3, 4 for Pi 4, etc.)
|
||||||
|
- `brightness` — 0–100%
|
||||||
|
- `pwm_bits`, `pwm_lsb_nanoseconds`, `pwm_dither_bits` — PWM tuning
|
||||||
|
- Dynamic Duration — global cap for plugins that extend their display
|
||||||
|
time based on content
|
||||||
|
|
||||||
**Display Options:**
|
Changes require **Restart Display Service** from the Overview tab.
|
||||||
- Brightness: Adjust LED brightness (0-100%)
|
|
||||||
- Hardware Mapping: GPIO pin mapping
|
|
||||||
- Slowdown GPIO: Timing adjustment for compatibility
|
|
||||||
|
|
||||||
**Save and Apply:**
|
### Plugin Manager Tab
|
||||||
- Changes require a display restart
|
|
||||||
- Use "Test Display" to verify configuration
|
|
||||||
|
|
||||||
### Durations Tab
|
The Plugin Manager has three main sections:
|
||||||
|
|
||||||
Control how long each plugin displays:
|
1. **Installed Plugins** — toggle installed plugins on/off, see version
|
||||||
|
info. Each installed plugin also gets its own tab in the second nav
|
||||||
|
row for its configuration form.
|
||||||
|
2. **Plugin Store** — browse plugins from the official
|
||||||
|
`ledmatrix-plugins` registry. Click **Install** to fetch and
|
||||||
|
install. Filter by category and search.
|
||||||
|
3. **Install from GitHub** — install third-party plugins by pasting a
|
||||||
|
GitHub repository URL. **Install Single Plugin** for a single-plugin
|
||||||
|
repo, **Load Registry** for a multi-plugin monorepo.
|
||||||
|
|
||||||
**Global Settings:**
|
When a plugin is installed and enabled:
|
||||||
- Default Duration: Default time for plugins without specific durations
|
- A new tab for that plugin appears in the second nav row
|
||||||
- Transition Speed: Speed of transitions between plugins
|
- Open the tab to edit its config (auto-generated form from
|
||||||
|
`config_schema.json`)
|
||||||
|
- The tab also exposes **Run On-Demand** / **Stop On-Demand** controls
|
||||||
|
to render that plugin immediately, even if it's disabled in the
|
||||||
|
rotation
|
||||||
|
|
||||||
**Per-Plugin Durations:**
|
### Per-plugin Configuration Tabs
|
||||||
- Set custom display duration for each plugin
|
|
||||||
- Override global default for specific plugins
|
|
||||||
- Measured in seconds
|
|
||||||
|
|
||||||
### Sports Configuration Tab
|
Each installed plugin has its own tab in the second nav row. The form
|
||||||
|
fields are auto-generated from the plugin's `config_schema.json`, so
|
||||||
|
options always match the plugin's current code.
|
||||||
|
|
||||||
Configure sports-specific settings:
|
To temporarily run a plugin outside the normal rotation, use the
|
||||||
|
**Run On-Demand** / **Stop On-Demand** buttons inside its tab. This
|
||||||
|
works even when the plugin is disabled.
|
||||||
|
|
||||||
**Per-League Settings:**
|
### Fonts Tab
|
||||||
- Favorite teams
|
|
||||||
- Show favorite teams only
|
|
||||||
- Include scores/standings
|
|
||||||
- Refresh intervals
|
|
||||||
|
|
||||||
**On-Demand Modes:**
|
|
||||||
- Live Priority: Show live games immediately
|
|
||||||
- Game Day Mode: Enhanced display during game days
|
|
||||||
- Score Alerts: Highlight score changes
|
|
||||||
|
|
||||||
### Plugin Management Tab
|
|
||||||
|
|
||||||
Manage installed plugins:
|
|
||||||
|
|
||||||
**Plugin List:**
|
|
||||||
- View all installed plugins
|
|
||||||
- See plugin status (enabled/disabled)
|
|
||||||
- Check last update time
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- **Enable/Disable**: Toggle plugin using the switch
|
|
||||||
- **Configure**: Click ⚙️ to edit plugin settings
|
|
||||||
- **Update**: Update plugin to latest version
|
|
||||||
- **Uninstall**: Remove plugin completely
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
- Edit plugin-specific settings
|
|
||||||
- Changes are saved to `config/config.json`
|
|
||||||
- Restart display to apply changes
|
|
||||||
|
|
||||||
**Note:** See [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) for information on installing plugins.
|
|
||||||
|
|
||||||
### Plugin Store Tab
|
|
||||||
|
|
||||||
Discover and install new plugins:
|
|
||||||
|
|
||||||
**Browse Plugins:**
|
|
||||||
- View available plugins in the official store
|
|
||||||
- Filter by category (sports, weather, time, finance, etc.)
|
|
||||||
- Search by name, description, or author
|
|
||||||
|
|
||||||
**Install Plugins:**
|
|
||||||
- Click "Install" next to any plugin
|
|
||||||
- Wait for installation to complete
|
|
||||||
- Restart the display to activate
|
|
||||||
|
|
||||||
**Install from URL:**
|
|
||||||
- Install plugins from any GitHub repository
|
|
||||||
- Paste the repository URL in the "Install from URL" section
|
|
||||||
- Review the warning about unverified plugins
|
|
||||||
- Click "Install from URL"
|
|
||||||
|
|
||||||
**Plugin Information:**
|
|
||||||
- View plugin descriptions, ratings, and screenshots
|
|
||||||
- Check compatibility and requirements
|
|
||||||
- Read user reviews (when available)
|
|
||||||
|
|
||||||
### Font Management Tab
|
|
||||||
|
|
||||||
Manage fonts for your display:
|
Manage fonts for your display:
|
||||||
|
|
||||||
@@ -229,37 +193,37 @@ View real-time system logs:
|
|||||||
|
|
||||||
### Changing Display Brightness
|
### Changing Display Brightness
|
||||||
|
|
||||||
1. Navigate to the **Display Settings** tab
|
1. Open the **Display** tab
|
||||||
2. Adjust the **Brightness** slider (0-100%)
|
2. Adjust the **Brightness** slider (0–100)
|
||||||
3. Click **Save Configuration**
|
3. Click **Save**
|
||||||
4. Restart the display for changes to take effect
|
4. Click **Restart Display Service** on the **Overview** tab
|
||||||
|
|
||||||
### Installing a New Plugin
|
### Installing a New Plugin
|
||||||
|
|
||||||
1. Navigate to the **Plugin Store** tab
|
1. Open the **Plugin Manager** tab
|
||||||
2. Browse or search for the desired plugin
|
2. Scroll to the **Plugin Store** section and browse or search
|
||||||
3. Click **Install** next to the plugin
|
3. Click **Install** next to the plugin
|
||||||
4. Wait for installation to complete
|
4. Toggle the plugin on in **Installed Plugins**
|
||||||
5. Restart the display
|
5. Click **Restart Display Service** on **Overview**
|
||||||
6. Enable the plugin in the **Plugin Management** tab
|
|
||||||
|
|
||||||
### Configuring a Plugin
|
### Configuring a Plugin
|
||||||
|
|
||||||
1. Navigate to the **Plugin Management** tab
|
1. Open the plugin's tab in the second nav row (each installed plugin
|
||||||
2. Find the plugin you want to configure
|
has its own tab)
|
||||||
3. Click the ⚙️ **Configure** button
|
2. Edit the auto-generated form
|
||||||
4. Edit the settings in the form
|
3. Click **Save**
|
||||||
5. Click **Save**
|
4. Restart the display service from **Overview**
|
||||||
6. Restart the display to apply changes
|
|
||||||
|
|
||||||
### Setting Favorite Sports Teams
|
### Setting Favorite Sports Teams
|
||||||
|
|
||||||
1. Navigate to the **Sports Configuration** tab
|
Sports favorites live in the relevant plugin's tab — there is no
|
||||||
2. Select the league (NHL, NBA, MLB, NFL)
|
separate "Sports Configuration" tab. For example:
|
||||||
3. Choose your favorite teams from the dropdown
|
|
||||||
4. Enable "Show favorite teams only" if desired
|
1. Install **Hockey Scoreboard** from **Plugin Manager → Plugin Store**
|
||||||
5. Click **Save Configuration**
|
2. Open the **Hockey Scoreboard** tab in the second nav row
|
||||||
6. Restart the display
|
3. Add your favorites under `favorite_teams.<league>` (e.g.
|
||||||
|
`favorite_teams.nhl`)
|
||||||
|
4. Click **Save** and restart the display service
|
||||||
|
|
||||||
### Troubleshooting Display Issues
|
### Troubleshooting Display Issues
|
||||||
|
|
||||||
@@ -296,12 +260,10 @@ The interface is fully responsive and works on mobile devices:
|
|||||||
- Touch-friendly interface
|
- Touch-friendly interface
|
||||||
- Responsive layout adapts to screen size
|
- Responsive layout adapts to screen size
|
||||||
- All features available on mobile
|
- All features available on mobile
|
||||||
- Swipe navigation between tabs
|
|
||||||
|
|
||||||
**Tips for Mobile:**
|
**Tips for Mobile:**
|
||||||
- Use landscape mode for better visibility
|
- Use landscape mode for better visibility
|
||||||
- Pinch to zoom on display preview
|
- Pinch to zoom on display preview
|
||||||
- Long-press for context menus
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -322,15 +284,21 @@ The web interface is built on a REST API that you can access programmatically:
|
|||||||
|
|
||||||
**API Base URL:**
|
**API Base URL:**
|
||||||
```
|
```
|
||||||
http://your-pi-ip:5050/api
|
http://your-pi-ip:5000/api/v3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The API blueprint mounts at `/api/v3` (see
|
||||||
|
`web_interface/app.py:144`). All endpoints below are relative to that
|
||||||
|
base.
|
||||||
|
|
||||||
**Common Endpoints:**
|
**Common Endpoints:**
|
||||||
- `GET /api/config/main` - Get configuration
|
- `GET /api/v3/config/main` — Get main configuration
|
||||||
- `POST /api/config/main` - Update configuration
|
- `POST /api/v3/config/main` — Update main configuration
|
||||||
- `GET /api/system/status` - Get system status
|
- `GET /api/v3/system/status` — Get system status
|
||||||
- `POST /api/system/action` - Control display (start/stop/restart)
|
- `POST /api/v3/system/action` — Control display (start/stop/restart, reboot, etc.)
|
||||||
- `GET /api/plugins/installed` - List installed plugins
|
- `GET /api/v3/plugins/installed` — List installed plugins
|
||||||
|
- `POST /api/v3/plugins/install` — Install a plugin from the store
|
||||||
|
- `POST /api/v3/plugins/install-from-url` — Install a plugin from a GitHub URL
|
||||||
|
|
||||||
**Note:** See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete API documentation.
|
**Note:** See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete API documentation.
|
||||||
|
|
||||||
@@ -353,7 +321,7 @@ http://your-pi-ip:5050/api
|
|||||||
sudo systemctl start ledmatrix-web
|
sudo systemctl start ledmatrix-web
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Check that port 5050 is not blocked by firewall
|
3. Check that port 5000 is not blocked by firewall
|
||||||
4. Verify the Pi's IP address is correct
|
4. Verify the Pi's IP address is correct
|
||||||
|
|
||||||
### Changes Not Applying
|
### Changes Not Applying
|
||||||
@@ -429,7 +397,12 @@ The web interface uses modern web technologies:
|
|||||||
- Web service: `sudo journalctl -u ledmatrix-web -f`
|
- Web service: `sudo journalctl -u ledmatrix-web -f`
|
||||||
|
|
||||||
**Plugins:**
|
**Plugins:**
|
||||||
- Plugin directory: `/plugins/`
|
- Plugin directory: configurable via
|
||||||
|
`plugin_system.plugins_directory` in `config.json` (default
|
||||||
|
`plugin-repos/`). Main plugin discovery only scans this directory;
|
||||||
|
the Plugin Store install flow and the schema loader additionally
|
||||||
|
probe `plugins/` so dev symlinks created by
|
||||||
|
`scripts/dev/dev_plugin_setup.sh` keep working.
|
||||||
- Plugin config: `/config/config.json` (per-plugin sections)
|
- Plugin config: `/config/config.json` (per-plugin sections)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ The LEDMatrix WiFi system provides automatic network configuration with intellig
|
|||||||
|
|
||||||
**If not connected to WiFi:**
|
**If not connected to WiFi:**
|
||||||
1. Wait 90 seconds after boot (AP mode activation grace period)
|
1. Wait 90 seconds after boot (AP mode activation grace period)
|
||||||
2. Connect to WiFi network: **LEDMatrix-Setup** (open network)
|
2. Connect to WiFi network **LEDMatrix-Setup** (default password
|
||||||
3. Open browser to: `http://192.168.4.1:5050`
|
`ledmatrix123` — change it in `config/wifi_config.json` if you want
|
||||||
4. Navigate to the WiFi tab
|
an open network or a different password)
|
||||||
|
3. Open browser to: `http://192.168.4.1:5000`
|
||||||
|
4. Open the **WiFi** tab
|
||||||
5. Scan, select your network, and connect
|
5. Scan, select your network, and connect
|
||||||
|
|
||||||
**If already connected:**
|
**If already connected:**
|
||||||
1. Open browser to: `http://your-pi-ip:5050`
|
1. Open browser to: `http://your-pi-ip:5000`
|
||||||
2. Navigate to the WiFi tab
|
2. Navigate to the WiFi tab
|
||||||
3. Configure as needed
|
3. Configure as needed
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ WiFi settings are stored in `config/wifi_config.json`:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ap_ssid": "LEDMatrix-Setup",
|
"ap_ssid": "LEDMatrix-Setup",
|
||||||
"ap_password": "",
|
"ap_password": "ledmatrix123",
|
||||||
"ap_channel": 7,
|
"ap_channel": 7,
|
||||||
"auto_enable_ap_mode": true,
|
"auto_enable_ap_mode": true,
|
||||||
"saved_networks": [
|
"saved_networks": [
|
||||||
@@ -93,10 +95,10 @@ WiFi settings are stored in `config/wifi_config.json`:
|
|||||||
|
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `ap_ssid` | `LEDMatrix-Setup` | Network name for AP mode |
|
| `ap_ssid` | `LEDMatrix-Setup` | Network name broadcast in AP mode |
|
||||||
| `ap_password` | `` (empty) | AP password (empty = open network) |
|
| `ap_password` | `ledmatrix123` | AP password. Set to `""` to make the network open (no password). |
|
||||||
| `ap_channel` | `7` | WiFi channel (use 1, 6, or 11 for non-overlapping) |
|
| `ap_channel` | `7` | WiFi channel (1, 6, or 11 are non-overlapping) |
|
||||||
| `auto_enable_ap_mode` | `true` | Automatically enable AP mode when disconnected |
|
| `auto_enable_ap_mode` | `true` | Automatically enable AP mode when both WiFi and Ethernet are disconnected |
|
||||||
| `saved_networks` | `[]` | Array of saved WiFi credentials |
|
| `saved_networks` | `[]` | Array of saved WiFi credentials |
|
||||||
|
|
||||||
### Auto-Enable AP Mode Behavior
|
### Auto-Enable AP Mode Behavior
|
||||||
@@ -130,10 +132,10 @@ WiFi settings are stored in `config/wifi_config.json`:
|
|||||||
**Via API:**
|
**Via API:**
|
||||||
```bash
|
```bash
|
||||||
# Scan for networks
|
# Scan for networks
|
||||||
curl "http://your-pi-ip:5050/api/wifi/scan"
|
curl "http://your-pi-ip:5000/api/v3/wifi/scan"
|
||||||
|
|
||||||
# Connect to network
|
# Connect to network
|
||||||
curl -X POST http://your-pi-ip:5050/api/wifi/connect \
|
curl -X POST http://your-pi-ip:5000/api/v3/wifi/connect \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"ssid": "YourNetwork", "password": "your-password"}'
|
-d '{"ssid": "YourNetwork", "password": "your-password"}'
|
||||||
```
|
```
|
||||||
@@ -147,10 +149,10 @@ curl -X POST http://your-pi-ip:5050/api/wifi/connect \
|
|||||||
**Via API:**
|
**Via API:**
|
||||||
```bash
|
```bash
|
||||||
# Enable AP mode
|
# Enable AP mode
|
||||||
curl -X POST http://your-pi-ip:5050/api/wifi/ap/enable
|
curl -X POST http://your-pi-ip:5000/api/v3/wifi/ap/enable
|
||||||
|
|
||||||
# Disable AP mode
|
# Disable AP mode
|
||||||
curl -X POST http://your-pi-ip:5050/api/wifi/ap/disable
|
curl -X POST http://your-pi-ip:5000/api/v3/wifi/ap/disable
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Manual enable still requires both WiFi and Ethernet to be disconnected.
|
**Note:** Manual enable still requires both WiFi and Ethernet to be disconnected.
|
||||||
@@ -211,16 +213,17 @@ The system checks connections in this order:
|
|||||||
|
|
||||||
### AP Mode Settings
|
### AP Mode Settings
|
||||||
|
|
||||||
- **SSID**: LEDMatrix-Setup (configurable)
|
- **SSID**: `LEDMatrix-Setup` (configurable via `ap_ssid`)
|
||||||
- **Network**: Open (no password by default)
|
- **Network**: WPA2, default password `ledmatrix123` (configurable via
|
||||||
|
`ap_password` — set to `""` for an open network)
|
||||||
- **IP Address**: 192.168.4.1
|
- **IP Address**: 192.168.4.1
|
||||||
- **DHCP Range**: 192.168.4.2 - 192.168.4.20
|
- **DHCP Range**: 192.168.4.2 – 192.168.4.20
|
||||||
- **Channel**: 7 (configurable)
|
- **Channel**: 7 (configurable via `ap_channel`)
|
||||||
|
|
||||||
### Accessing Services in AP Mode
|
### Accessing Services in AP Mode
|
||||||
|
|
||||||
When AP mode is active:
|
When AP mode is active:
|
||||||
- Web Interface: `http://192.168.4.1:5050`
|
- Web Interface: `http://192.168.4.1:5000`
|
||||||
- SSH: `ssh ledpi@192.168.4.1`
|
- SSH: `ssh ledpi@192.168.4.1`
|
||||||
- Captive portal may automatically redirect browsers
|
- Captive portal may automatically redirect browsers
|
||||||
|
|
||||||
@@ -237,7 +240,9 @@ When AP mode is active:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The default is an open network for easy initial setup. For deployments in public areas, consider adding a password.
|
**Note:** The default password is `ledmatrix123` for easy initial
|
||||||
|
setup. Change it for any deployment in a public area, or set
|
||||||
|
`ap_password` to `""` if you specifically want an open network.
|
||||||
|
|
||||||
**2. Use Non-Overlapping WiFi Channels:**
|
**2. Use Non-Overlapping WiFi Channels:**
|
||||||
- Channels 1, 6, 11 are non-overlapping (2.4GHz)
|
- Channels 1, 6, 11 are non-overlapping (2.4GHz)
|
||||||
@@ -398,7 +403,7 @@ Interface should exist
|
|||||||
|
|
||||||
**Check 4: Try Manual Enable**
|
**Check 4: Try Manual Enable**
|
||||||
- Use web interface: WiFi tab → Enable AP Mode
|
- Use web interface: WiFi tab → Enable AP Mode
|
||||||
- Or via API: `curl -X POST http://localhost:5050/api/wifi/ap/enable`
|
- Or via API: `curl -X POST http://localhost:5000/api/v3/wifi/ap/enable`
|
||||||
|
|
||||||
### Cannot Connect to WiFi Network
|
### Cannot Connect to WiFi Network
|
||||||
|
|
||||||
@@ -551,36 +556,36 @@ The WiFi setup feature exposes the following API endpoints:
|
|||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/api/wifi/status` | Get current WiFi connection status |
|
| GET | `/api/v3/wifi/status` | Get current WiFi connection status |
|
||||||
| GET | `/api/wifi/scan` | Scan for available WiFi networks |
|
| GET | `/api/v3/wifi/scan` | Scan for available WiFi networks |
|
||||||
| POST | `/api/wifi/connect` | Connect to a WiFi network |
|
| POST | `/api/v3/wifi/connect` | Connect to a WiFi network |
|
||||||
| POST | `/api/wifi/ap/enable` | Enable access point mode |
|
| POST | `/api/v3/wifi/ap/enable` | Enable access point mode |
|
||||||
| POST | `/api/wifi/ap/disable` | Disable access point mode |
|
| POST | `/api/v3/wifi/ap/disable` | Disable access point mode |
|
||||||
| GET | `/api/wifi/ap/auto-enable` | Get auto-enable setting |
|
| GET | `/api/v3/wifi/ap/auto-enable` | Get auto-enable setting |
|
||||||
| POST | `/api/wifi/ap/auto-enable` | Set auto-enable setting |
|
| POST | `/api/v3/wifi/ap/auto-enable` | Set auto-enable setting |
|
||||||
|
|
||||||
### Example Usage
|
### Example Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Get WiFi status
|
# Get WiFi status
|
||||||
curl "http://your-pi-ip:5050/api/wifi/status"
|
curl "http://your-pi-ip:5000/api/v3/wifi/status"
|
||||||
|
|
||||||
# Scan for networks
|
# Scan for networks
|
||||||
curl "http://your-pi-ip:5050/api/wifi/scan"
|
curl "http://your-pi-ip:5000/api/v3/wifi/scan"
|
||||||
|
|
||||||
# Connect to network
|
# Connect to network
|
||||||
curl -X POST http://your-pi-ip:5050/api/wifi/connect \
|
curl -X POST http://your-pi-ip:5000/api/v3/wifi/connect \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"ssid": "MyNetwork", "password": "mypassword"}'
|
-d '{"ssid": "MyNetwork", "password": "mypassword"}'
|
||||||
|
|
||||||
# Enable AP mode
|
# Enable AP mode
|
||||||
curl -X POST http://your-pi-ip:5050/api/wifi/ap/enable
|
curl -X POST http://your-pi-ip:5000/api/v3/wifi/ap/enable
|
||||||
|
|
||||||
# Check auto-enable setting
|
# Check auto-enable setting
|
||||||
curl "http://your-pi-ip:5050/api/wifi/ap/auto-enable"
|
curl "http://your-pi-ip:5000/api/v3/wifi/ap/auto-enable"
|
||||||
|
|
||||||
# Set auto-enable
|
# Set auto-enable
|
||||||
curl -X POST http://your-pi-ip:5050/api/wifi/ap/auto-enable \
|
curl -X POST http://your-pi-ip:5000/api/v3/wifi/ap/auto-enable \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"auto_enable_ap_mode": true}'
|
-d '{"auto_enable_ap_mode": true}'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -259,8 +259,6 @@ 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 "----------------------------------------"
|
||||||
@@ -273,7 +271,7 @@ 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 python3-dev python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cython3 scons cmake ninja-build
|
apt_install python3-pip python3-venv python-dev-is-python3 python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel 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..."
|
||||||
@@ -599,8 +597,12 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
|||||||
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
||||||
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
||||||
{
|
{
|
||||||
"weather": {
|
"youtube": {
|
||||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||||
|
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
@@ -667,8 +669,6 @@ 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)
|
||||||
@@ -821,20 +821,13 @@ else
|
|||||||
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 "Building rpi-rgb-led-matrix Python bindings..."
|
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
||||||
# Build the library first, then Python bindings
|
echo " Build deps required: python-dev-is-python3 cmake"
|
||||||
# The build-python target depends on the library being built
|
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
|
||||||
if ! make build-python; then
|
|
||||||
echo "✗ Failed to build rpi-rgb-led-matrix Python bindings"
|
|
||||||
echo " Make sure you have the required build tools installed:"
|
|
||||||
echo " sudo apt install -y build-essential python3-dev cython3 scons"
|
|
||||||
popd >/dev/null
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cd bindings/python
|
|
||||||
echo "Installing rpi-rgb-led-matrix Python package via pip..."
|
|
||||||
if ! python3 -m pip install --break-system-packages .; then
|
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"
|
||||||
popd >/dev/null
|
popd >/dev/null
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -1082,6 +1075,7 @@ SYSTEMCTL_PATH=$(which systemctl)
|
|||||||
REBOOT_PATH=$(which reboot)
|
REBOOT_PATH=$(which reboot)
|
||||||
POWEROFF_PATH=$(which poweroff)
|
POWEROFF_PATH=$(which poweroff)
|
||||||
BASH_PATH=$(which bash)
|
BASH_PATH=$(which bash)
|
||||||
|
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||||
|
|
||||||
# Create sudoers content
|
# Create sudoers content
|
||||||
cat > /tmp/ledmatrix_web_sudoers << EOF
|
cat > /tmp/ledmatrix_web_sudoers << EOF
|
||||||
@@ -1097,10 +1091,23 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
|||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
|
||||||
|
$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
|
||||||
|
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
||||||
echo "Sudoers configuration already up to date"
|
echo "Sudoers configuration already up to date"
|
||||||
@@ -1461,7 +1468,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 device type state; do
|
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ 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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
requests>=2.28.0
|
requests>=2.33.0
|
||||||
Pillow>=9.1.0
|
urllib3>=1.26.0
|
||||||
|
Pillow>=12.2.0
|
||||||
pytz>=2022.1
|
pytz>=2022.1
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
Pillow>=10.4.0
|
Pillow>=12.2.0
|
||||||
PyYAML>=6.0.2
|
PyYAML>=6.0.2
|
||||||
requests>=2.32.0
|
requests>=2.33.0
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
"""Initialize the Web UI Info plugin."""
|
"""Initialize the Web UI Info plugin."""
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
|
|
||||||
|
# AP mode cache (must be initialized before _get_local_ip)
|
||||||
|
self._ap_mode_cached = False
|
||||||
|
self._ap_mode_cache_time = 0.0
|
||||||
|
self._ap_mode_cache_ttl = 60.0
|
||||||
|
|
||||||
# Get device hostname
|
# Get device hostname
|
||||||
try:
|
try:
|
||||||
self.device_id = socket.gethostname()
|
self.device_id = socket.gethostname()
|
||||||
@@ -47,7 +52,7 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
|
|
||||||
# IP refresh tracking
|
# IP refresh tracking
|
||||||
self.last_ip_refresh = time.time()
|
self.last_ip_refresh = time.time()
|
||||||
self.ip_refresh_interval = 30.0 # Refresh IP every 30 seconds
|
self.ip_refresh_interval = 300.0
|
||||||
|
|
||||||
# Rotation state
|
# Rotation state
|
||||||
self.current_display_mode = "hostname" # "hostname" or "ip"
|
self.current_display_mode = "hostname" # "hostname" or "ip"
|
||||||
@@ -56,17 +61,41 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
|
|
||||||
self.web_ui_url = f"http://{self.device_id}:5000"
|
self.web_ui_url = f"http://{self.device_id}:5000"
|
||||||
|
|
||||||
|
# Display cache - avoid re-rendering when nothing changed
|
||||||
|
self._cached_display_image = None
|
||||||
|
self._display_dirty = True
|
||||||
|
self._font_small = self._load_font()
|
||||||
|
|
||||||
self.logger.info(f"Web UI Info plugin initialized - Hostname: {self.device_id}, IP: {self.device_ip}")
|
self.logger.info(f"Web UI Info plugin initialized - Hostname: {self.device_id}, IP: {self.device_ip}")
|
||||||
|
|
||||||
|
def _load_font(self):
|
||||||
|
"""Load and cache the display font."""
|
||||||
|
try:
|
||||||
|
current_dir = Path(__file__).resolve().parent
|
||||||
|
project_root = current_dir.parent.parent
|
||||||
|
font_path = project_root / "assets" / "fonts" / "4x6-font.ttf"
|
||||||
|
if font_path.exists():
|
||||||
|
return ImageFont.truetype(str(font_path), 6)
|
||||||
|
font_path = "assets/fonts/4x6-font.ttf"
|
||||||
|
if os.path.exists(font_path):
|
||||||
|
return ImageFont.truetype(font_path, 6)
|
||||||
|
return ImageFont.load_default()
|
||||||
|
except (FileNotFoundError, OSError) as e:
|
||||||
|
self.logger.debug(f"Could not load custom font: {e}, using default")
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
def _is_ap_mode_active(self) -> bool:
|
def _is_ap_mode_active(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if AP mode is currently active.
|
Check if AP mode is currently active (cached with TTL).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if AP mode is active, False otherwise
|
bool: True if AP mode is active, False otherwise
|
||||||
"""
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self._ap_mode_cache_time < self._ap_mode_cache_ttl:
|
||||||
|
return self._ap_mode_cached
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if hostapd service is running
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["systemctl", "is-active", "hostapd"],
|
["systemctl", "is-active", "hostapd"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -74,9 +103,10 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
timeout=2
|
timeout=2
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip() == "active":
|
if result.returncode == 0 and result.stdout.strip() == "active":
|
||||||
|
self._ap_mode_cached = True
|
||||||
|
self._ap_mode_cache_time = current_time
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check if wlan0 has AP mode IP (192.168.4.1)
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["ip", "addr", "show", "wlan0"],
|
["ip", "addr", "show", "wlan0"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -84,11 +114,17 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
timeout=2
|
timeout=2
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and "192.168.4.1" in result.stdout:
|
if result.returncode == 0 and "192.168.4.1" in result.stdout:
|
||||||
|
self._ap_mode_cached = True
|
||||||
|
self._ap_mode_cache_time = current_time
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
self._ap_mode_cached = False
|
||||||
|
self._ap_mode_cache_time = current_time
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug(f"Error checking AP mode status: {e}")
|
self.logger.debug(f"Error checking AP mode status: {e}")
|
||||||
|
self._ap_mode_cached = False
|
||||||
|
self._ap_mode_cache_time = current_time
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _get_local_ip(self) -> str:
|
def _get_local_ip(self) -> str:
|
||||||
@@ -105,7 +141,21 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
return "192.168.4.1"
|
return "192.168.4.1"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try using 'hostname -I' first (fastest, gets all IPs)
|
# Try socket method first (zero subprocess overhead, fastest)
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(('8.8.8.8', 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
||||||
|
self.logger.debug(f"Found IP via socket method: {ip}")
|
||||||
|
return ip
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: Try using 'hostname -I'
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["hostname", "-I"],
|
["hostname", "-I"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -114,7 +164,6 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
ips = result.stdout.strip().split()
|
ips = result.stdout.strip().split()
|
||||||
# Filter out loopback and AP mode IPs
|
|
||||||
for ip in ips:
|
for ip in ips:
|
||||||
ip = ip.strip()
|
ip = ip.strip()
|
||||||
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
||||||
@@ -132,20 +181,16 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
current_interface = None
|
current_interface = None
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
# Check for interface name
|
|
||||||
if ':' in line and not line.startswith('inet'):
|
if ':' in line and not line.startswith('inet'):
|
||||||
parts = line.split(':')
|
parts = line.split(':')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
current_interface = parts[1].strip().split('@')[0]
|
current_interface = parts[1].strip().split('@')[0]
|
||||||
# Check for inet address
|
|
||||||
elif line.startswith('inet '):
|
elif line.startswith('inet '):
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
ip_with_cidr = parts[1]
|
ip_with_cidr = parts[1]
|
||||||
ip = ip_with_cidr.split('/')[0]
|
ip = ip_with_cidr.split('/')[0]
|
||||||
# Skip loopback and AP mode IPs
|
|
||||||
if not ip.startswith("127.") and ip != "192.168.4.1":
|
if not ip.startswith("127.") and ip != "192.168.4.1":
|
||||||
# Prefer eth0/ethernet interfaces, then wlan0, then others
|
|
||||||
if current_interface and (
|
if current_interface and (
|
||||||
current_interface.startswith("eth") or
|
current_interface.startswith("eth") or
|
||||||
current_interface.startswith("enp")
|
current_interface.startswith("enp")
|
||||||
@@ -156,21 +201,6 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
||||||
return ip
|
return ip
|
||||||
|
|
||||||
# Fallback: Try socket method (requires internet connectivity)
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
try:
|
|
||||||
# Connect to a public DNS server (doesn't actually connect)
|
|
||||||
s.connect(('8.8.8.8', 80))
|
|
||||||
ip = s.getsockname()[0]
|
|
||||||
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
|
||||||
self.logger.debug(f"Found IP via socket method: {ip}")
|
|
||||||
return ip
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
||||||
try:
|
try:
|
||||||
ip = socket.gethostbyname(socket.gethostname())
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
@@ -196,11 +226,11 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
"""
|
"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - self.last_ip_refresh >= self.ip_refresh_interval:
|
if current_time - self.last_ip_refresh >= self.ip_refresh_interval:
|
||||||
# Refresh IP address to handle network state changes
|
|
||||||
new_ip = self._get_local_ip()
|
new_ip = self._get_local_ip()
|
||||||
if new_ip != self.device_ip:
|
if new_ip != self.device_ip:
|
||||||
self.logger.info(f"IP address changed from {self.device_ip} to {new_ip}")
|
self.logger.info(f"IP address changed from {self.device_ip} to {new_ip}")
|
||||||
self.device_ip = new_ip
|
self.device_ip = new_ip
|
||||||
|
self._display_dirty = True
|
||||||
self.last_ip_refresh = current_time
|
self.last_ip_refresh = current_time
|
||||||
|
|
||||||
def display(self, force_clear: bool = False) -> None:
|
def display(self, force_clear: bool = False) -> None:
|
||||||
@@ -215,16 +245,23 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
# Check if we need to rotate between hostname and IP
|
# Check if we need to rotate between hostname and IP
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - self.last_rotation_time >= self.rotation_interval:
|
if current_time - self.last_rotation_time >= self.rotation_interval:
|
||||||
# Switch display mode
|
|
||||||
if self.current_display_mode == "hostname":
|
if self.current_display_mode == "hostname":
|
||||||
self.current_display_mode = "ip"
|
self.current_display_mode = "ip"
|
||||||
else:
|
else:
|
||||||
self.current_display_mode = "hostname"
|
self.current_display_mode = "hostname"
|
||||||
self.last_rotation_time = current_time
|
self.last_rotation_time = current_time
|
||||||
|
self._display_dirty = True
|
||||||
self.logger.debug(f"Rotated to display mode: {self.current_display_mode}")
|
self.logger.debug(f"Rotated to display mode: {self.current_display_mode}")
|
||||||
|
|
||||||
if force_clear:
|
if force_clear:
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
|
self._display_dirty = True
|
||||||
|
|
||||||
|
# Use cached image if nothing changed
|
||||||
|
if not self._display_dirty and self._cached_display_image is not None:
|
||||||
|
self.display_manager.image = self._cached_display_image
|
||||||
|
self.display_manager.update_display()
|
||||||
|
return
|
||||||
|
|
||||||
# Get display dimensions
|
# Get display dimensions
|
||||||
width = self.display_manager.matrix.width
|
width = self.display_manager.matrix.width
|
||||||
@@ -234,74 +271,40 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
img = Image.new('RGB', (width, height), (0, 0, 0))
|
img = Image.new('RGB', (width, height), (0, 0, 0))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# Try to load a small font
|
|
||||||
# Try to find project root and use assets/fonts
|
|
||||||
font_small = None
|
|
||||||
try:
|
|
||||||
# Try to find project root (parent of plugins directory)
|
|
||||||
current_dir = Path(__file__).resolve().parent
|
|
||||||
project_root = current_dir.parent.parent
|
|
||||||
font_path = project_root / "assets" / "fonts" / "4x6-font.ttf"
|
|
||||||
|
|
||||||
if font_path.exists():
|
|
||||||
font_small = ImageFont.truetype(str(font_path), 6)
|
|
||||||
else:
|
|
||||||
# Try relative path from current working directory
|
|
||||||
font_path = "assets/fonts/4x6-font.ttf"
|
|
||||||
if os.path.exists(font_path):
|
|
||||||
font_small = ImageFont.truetype(font_path, 6)
|
|
||||||
else:
|
|
||||||
font_small = ImageFont.load_default()
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug(f"Could not load custom font: {e}, using default")
|
|
||||||
font_small = ImageFont.load_default()
|
|
||||||
|
|
||||||
# Determine which address to display
|
# Determine which address to display
|
||||||
if self.current_display_mode == "ip":
|
if self.current_display_mode == "ip":
|
||||||
address = self.device_ip
|
address = self.device_ip
|
||||||
else:
|
else:
|
||||||
address = self.device_id
|
address = self.device_id
|
||||||
|
|
||||||
# Prepare text to display
|
|
||||||
lines = [
|
lines = [
|
||||||
"visit web ui",
|
"visit web ui",
|
||||||
f"at {address}:5000"
|
f"at {address}:5000"
|
||||||
]
|
]
|
||||||
|
|
||||||
# Calculate text positions (centered)
|
|
||||||
y_start = 5
|
y_start = 5
|
||||||
line_height = 8
|
line_height = 8
|
||||||
total_height = len(lines) * line_height
|
|
||||||
|
|
||||||
# Draw each line
|
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
# Get text size for centering
|
bbox = draw.textbbox((0, 0), line, font=self._font_small)
|
||||||
bbox = draw.textbbox((0, 0), line, font=font_small)
|
|
||||||
text_width = bbox[2] - bbox[0]
|
text_width = bbox[2] - bbox[0]
|
||||||
text_height = bbox[3] - bbox[1]
|
|
||||||
|
|
||||||
# Center horizontally
|
|
||||||
x = (width - text_width) // 2
|
x = (width - text_width) // 2
|
||||||
y = y_start + (i * line_height)
|
y = y_start + (i * line_height)
|
||||||
|
draw.text((x, y), line, font=self._font_small, fill=(255, 255, 255))
|
||||||
|
|
||||||
# Draw text in white
|
self._cached_display_image = img
|
||||||
draw.text((x, y), line, font=font_small, fill=(255, 255, 255))
|
self._display_dirty = False
|
||||||
|
|
||||||
# Set the image on the display manager
|
|
||||||
self.display_manager.image = img
|
self.display_manager.image = img
|
||||||
|
|
||||||
# Update the display
|
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
|
|
||||||
self.logger.debug(f"Displayed web UI info: {address}:5000 (mode: {self.current_display_mode})")
|
self.logger.debug(f"Displayed web UI info: {address}:5000 (mode: {self.current_display_mode})")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error displaying web UI info: {e}")
|
self.logger.error(f"Error displaying web UI info: {e}")
|
||||||
# Fallback: just clear the display
|
|
||||||
try:
|
try:
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_display_duration(self) -> float:
|
def get_display_duration(self) -> float:
|
||||||
|
|||||||
@@ -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>=10.4.0,<12.0.0
|
Pillow>=12.2.0,<13.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.32.0,<3.0.0
|
requests>=2.33.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.24.0,<3.0.0
|
spotipy>=2.25.2,<3.0.0
|
||||||
|
|
||||||
# Flask web framework
|
# Flask web framework
|
||||||
Flask>=3.0.0,<4.0.0
|
Flask>=3.1.3,<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.11.0,<6.0.0
|
python-socketio>=5.14.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
|
||||||
@@ -44,7 +44,29 @@ websocket-client>=1.8.0,<2.0.0
|
|||||||
jsonschema>=4.20.0,<5.0.0
|
jsonschema>=4.20.0,<5.0.0
|
||||||
|
|
||||||
# Testing dependencies
|
# Testing dependencies
|
||||||
pytest>=7.4.0,<8.0.0
|
pytest>=9.0.3,<10.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
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
# Optional dependencies — the code imports these inside try/except
|
||||||
|
# blocks and gracefully degrades when missing. Install them for the
|
||||||
|
# full feature set, or skip them for a minimal install.
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# scipy — sub-pixel interpolation in
|
||||||
|
# src/common/scroll_helper.py for smoother
|
||||||
|
# scrolling. Falls back to a simpler shift algorithm.
|
||||||
|
# pip install 'scipy>=1.10.0,<2.0.0'
|
||||||
|
#
|
||||||
|
# psutil — per-plugin resource monitoring in
|
||||||
|
# src/plugin_system/resource_monitor.py. The monitor
|
||||||
|
# silently no-ops when missing (PSUTIL_AVAILABLE = False).
|
||||||
|
# pip install 'psutil>=5.9.0,<6.0.0'
|
||||||
|
#
|
||||||
|
# Flask-Limiter — request rate limiting in web_interface/app.py
|
||||||
|
# (accidental-abuse protection, not security). The
|
||||||
|
# web interface starts without rate limiting when
|
||||||
|
# this is missing.
|
||||||
|
# pip install 'Flask-Limiter>=3.5.0,<4.0.0'
|
||||||
|
|||||||
Submodule rpi-rgb-led-matrix-master updated: 2cfff2a4b1...8907235630
1
run.py
1
run.py
@@ -51,7 +51,6 @@ 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)
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
# NBA Logo Downloader
|
# NBA Logo Downloader
|
||||||
|
|
||||||
This script downloads all NBA team logos from the ESPN API and saves them in the `assets/sports/nba_logos/` directory for use with the NBA leaderboard.
|
This script downloads all NBA team logos from the ESPN API and saves
|
||||||
|
them in the `assets/sports/nba_logos/` directory.
|
||||||
|
|
||||||
|
> **Heads up:** the NBA leaderboard and basketball scoreboards now
|
||||||
|
> live as plugins in the
|
||||||
|
> [`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
> repo (`basketball-scoreboard`, `ledmatrix-leaderboard`). Those
|
||||||
|
> plugins download the logos they need automatically on first display.
|
||||||
|
> This standalone script is mainly useful when you want to pre-populate
|
||||||
|
> the assets directory ahead of time, or for development/debugging.
|
||||||
|
|
||||||
|
All commands below should be run from the LEDMatrix project root.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Basic Usage
|
### Basic Usage
|
||||||
```bash
|
```bash
|
||||||
python download_nba_logos.py
|
python3 scripts/download_nba_logos.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Force Re-download
|
### Force Re-download
|
||||||
If you want to re-download all logos (even if they already exist):
|
If you want to re-download all logos (even if they already exist):
|
||||||
```bash
|
```bash
|
||||||
python download_nba_logos.py --force
|
python3 scripts/download_nba_logos.py --force
|
||||||
```
|
```
|
||||||
|
|
||||||
### Quiet Mode
|
### Quiet Mode
|
||||||
Reduce logging output:
|
Reduce logging output:
|
||||||
```bash
|
```bash
|
||||||
python download_nba_logos.py --quiet
|
python3 scripts/download_nba_logos.py --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
### Combined Options
|
### Combined Options
|
||||||
```bash
|
```bash
|
||||||
python download_nba_logos.py --force --quiet
|
python3 scripts/download_nba_logos.py --force --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
@@ -82,12 +93,14 @@ assets/sports/nba_logos/
|
|||||||
└── WAS.png # Washington Wizards
|
└── WAS.png # Washington Wizards
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integration with NBA Leaderboard
|
## Integration with NBA plugins
|
||||||
|
|
||||||
Once the logos are downloaded, the NBA leaderboard will:
|
Once the logos are in `assets/sports/nba_logos/`, both the
|
||||||
- ✅ Use local logos instantly (no download delays)
|
`basketball-scoreboard` and `ledmatrix-leaderboard` plugins will pick
|
||||||
- ✅ Display team logos in the scrolling leaderboard
|
them up automatically and skip their own first-run download. This is
|
||||||
- ✅ Show proper team branding for all 30 NBA teams
|
useful if you want to deploy a Pi without internet access to ESPN, or
|
||||||
|
if you want to preview the display on your dev machine without
|
||||||
|
waiting for downloads.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -102,6 +115,6 @@ This is normal - some teams might have temporary API issues or the ESPN API migh
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.7+
|
- Python 3.9+ (matches the project's overall minimum)
|
||||||
- `requests` library (should be installed with the project)
|
- `requests` library (already in `requirements.txt`)
|
||||||
- Write access to `assets/sports/nba_logos/` directory
|
- Write access to `assets/sports/nba_logos/` directory
|
||||||
|
|||||||
@@ -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, Optional
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ 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, Set, Any
|
from typing import Dict, List, Any
|
||||||
import jsonschema
|
import jsonschema
|
||||||
from jsonschema import Draft7Validator
|
from jsonschema import Draft7Validator
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$SCRIPT_DIR"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
PLUGINS_DIR="$PROJECT_ROOT/plugins"
|
PLUGINS_DIR="$PROJECT_ROOT/plugins"
|
||||||
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
|
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
|
||||||
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
|
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
|
||||||
@@ -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 +0,0 @@
|
|||||||
/home/chuck/.ledmatrix-dev-plugins/ledmatrix-of-the-day
|
|
||||||
95
scripts/dev/test_pillow_compat.py
Executable file
95
scripts/dev/test_pillow_compat.py
Executable file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/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,7 +15,6 @@ 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,7 +13,6 @@ 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=$(cat "$PROJECT_DIR/config/config.json" | grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' | grep -o '[a-z]*$')
|
AUTOSTART=$(grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' "$PROJECT_DIR/config/config.json" | 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,9 +18,6 @@ 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=$((success_count + 1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
70
scripts/fix_perms/README.md
Normal file
70
scripts/fix_perms/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Permission Fix Scripts
|
||||||
|
|
||||||
|
This directory contains shell scripts for repairing file/directory
|
||||||
|
permissions on a LEDMatrix installation. They're typically only needed
|
||||||
|
when something has gone wrong — for example, after running parts of the
|
||||||
|
install as the wrong user, after a manual file copy that didn't preserve
|
||||||
|
ownership, or after a permissions-related error from the display or
|
||||||
|
web service.
|
||||||
|
|
||||||
|
Most of these scripts require `sudo` since they touch directories
|
||||||
|
owned by the `ledmatrix` service user or by `root`.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- **`fix_assets_permissions.sh`** — Fixes ownership and write
|
||||||
|
permissions on the `assets/` tree so plugins can download and cache
|
||||||
|
team logos, fonts, and other static content.
|
||||||
|
|
||||||
|
- **`fix_cache_permissions.sh`** — Fixes permissions on every cache
|
||||||
|
directory the project may use (`/var/cache/ledmatrix/`,
|
||||||
|
`~/.cache/ledmatrix/`, `/opt/ledmatrix/cache/`, project-local
|
||||||
|
`cache/`). Also creates placeholder logo subdirectories used by the
|
||||||
|
sports plugins.
|
||||||
|
|
||||||
|
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
|
||||||
|
directory so both the root display service and the web service user
|
||||||
|
can read and write plugin files (manifests, configs, requirements
|
||||||
|
installs).
|
||||||
|
|
||||||
|
- **`fix_web_permissions.sh`** — Fixes permissions on log files,
|
||||||
|
systemd journal access, and the sudoers entries the web interface
|
||||||
|
needs to control the display service.
|
||||||
|
|
||||||
|
- **`fix_nhl_cache.sh`** — Targeted fix for NHL plugin cache issues
|
||||||
|
(clears the NHL cache and restarts the display service).
|
||||||
|
|
||||||
|
- **`safe_plugin_rm.sh`** — Validates that a plugin removal path is
|
||||||
|
inside an allowed base directory before deleting it. Used by the web
|
||||||
|
interface (via sudo) when a user clicks **Uninstall** on a plugin —
|
||||||
|
prevents path-traversal abuse from the web UI.
|
||||||
|
|
||||||
|
## When to use these
|
||||||
|
|
||||||
|
Most users never need to run these directly. The first-time installer
|
||||||
|
(`first_time_install.sh`) sets up permissions correctly, and the web
|
||||||
|
interface manages plugin install/uninstall through the sudoers entries
|
||||||
|
the installer creates.
|
||||||
|
|
||||||
|
Run these scripts only when:
|
||||||
|
|
||||||
|
- You see "Permission denied" errors in `journalctl -u ledmatrix` or
|
||||||
|
the web UI Logs tab.
|
||||||
|
- You manually copied files into the project directory as the wrong
|
||||||
|
user.
|
||||||
|
- You restored from a backup that didn't preserve ownership.
|
||||||
|
- You moved the LEDMatrix directory and need to re-anchor permissions.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run from the project root
|
||||||
|
sudo ./scripts/fix_perms/fix_cache_permissions.sh
|
||||||
|
sudo ./scripts/fix_perms/fix_assets_permissions.sh
|
||||||
|
sudo ./scripts/fix_perms/fix_plugin_permissions.sh
|
||||||
|
sudo ./scripts/fix_perms/fix_web_permissions.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're not sure which one you need, run `fix_cache_permissions.sh`
|
||||||
|
first — it's the most commonly needed and creates several directories
|
||||||
|
the other scripts assume exist.
|
||||||
@@ -7,12 +7,6 @@ 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
|
||||||
|
|||||||
@@ -4,16 +4,26 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
|
|||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
- **`one-shot-install.sh`** - Single-command installer; clones the
|
||||||
|
repo, checks prerequisites, then runs `first_time_install.sh`.
|
||||||
|
Invoked via `curl ... | bash` from the project root README.
|
||||||
- **`install_service.sh`** - Installs the main LED Matrix display service (systemd)
|
- **`install_service.sh`** - Installs the main LED Matrix display service (systemd)
|
||||||
- **`install_web_service.sh`** - Installs the web interface service (systemd)
|
- **`install_web_service.sh`** - Installs the web interface service (systemd)
|
||||||
- **`install_wifi_monitor.sh`** - Installs the WiFi monitor daemon service
|
- **`install_wifi_monitor.sh`** - Installs the WiFi monitor daemon service
|
||||||
- **`setup_cache.sh`** - Sets up persistent cache directory with proper permissions
|
- **`setup_cache.sh`** - Sets up persistent cache directory with proper permissions
|
||||||
- **`configure_web_sudo.sh`** - Configures passwordless sudo access for web interface actions
|
- **`configure_web_sudo.sh`** - Configures passwordless sudo access for web interface actions
|
||||||
|
- **`configure_wifi_permissions.sh`** - Grants the `ledmatrix` user
|
||||||
|
the WiFi management permissions needed by the web interface and
|
||||||
|
the WiFi monitor service
|
||||||
- **`migrate_config.sh`** - Migrates configuration files to new formats (if needed)
|
- **`migrate_config.sh`** - Migrates configuration files to new formats (if needed)
|
||||||
|
- **`debug_install.sh`** - Diagnostic helper used when an install
|
||||||
|
fails; collects environment info and recent logs
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
These scripts are typically called by `first_time_install.sh` in the project root, but can also be run individually if needed.
|
These scripts are typically called by `first_time_install.sh` in the
|
||||||
|
project root (which itself is invoked by `one-shot-install.sh`), but
|
||||||
|
can also be run individually if needed.
|
||||||
|
|
||||||
**Note:** Most installation scripts require `sudo` privileges to install systemd services and configure system settings.
|
**Note:** Most installation scripts require `sudo` privileges to install systemd services and configure system settings.
|
||||||
|
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
|||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
||||||
|
|
||||||
# Optional: journalctl (non-critical — skip if not found)
|
# Optional: journalctl (non-critical — skip if not found)
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ 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)
|
||||||
|
|
||||||
@@ -34,7 +31,8 @@ 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.target
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
@@ -81,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',
|
parser.add_argument('--output', '-o', default='/tmp/plugin_render.png', # nosec B108 - dev script default; user can override
|
||||||
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,9 +7,7 @@ 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
|
||||||
|
|
||||||
@@ -198,17 +196,14 @@ 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,9 +6,7 @@ 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])
|
os.execvp(sys.executable, [sys.executable, WEB_INTERFACE_SCRIPT]) # nosec B606 - both args are fixed constants
|
||||||
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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add project root to path (parent of scripts/utils/)
|
# Add project root to path (parent of scripts/utils/)
|
||||||
@@ -43,6 +44,10 @@ class WiFiMonitorDaemon:
|
|||||||
self.wifi_manager = WiFiManager()
|
self.wifi_manager = WiFiManager()
|
||||||
self.running = True
|
self.running = True
|
||||||
self.last_state = None
|
self.last_state = None
|
||||||
|
# Counts consecutive checks where nmcli says "connected" but internet is unreachable.
|
||||||
|
# After _nm_restart_threshold failures, NetworkManager is restarted as a recovery step.
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
self._nm_restart_threshold = 5 # ~2.5 min at 30s interval
|
||||||
|
|
||||||
# Register signal handlers for graceful shutdown
|
# Register signal handlers for graceful shutdown
|
||||||
signal.signal(signal.SIGINT, self._signal_handler)
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
@@ -122,6 +127,43 @@ class WiFiMonitorDaemon:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||||
|
|
||||||
|
# Escalating recovery: if nmcli reports connected but actual internet
|
||||||
|
# is unreachable for several consecutive checks, restart NetworkManager.
|
||||||
|
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
||||||
|
# AP-enable trigger clean and avoid false-positive AP enables from
|
||||||
|
# transient packet loss on otherwise working WiFi.
|
||||||
|
if updated_status.connected and not updated_status.ap_mode_active:
|
||||||
|
if not self.wifi_manager.check_internet_connectivity():
|
||||||
|
self._consecutive_internet_failures += 1
|
||||||
|
logger.warning(
|
||||||
|
f"Internet unreachable despite nmcli connection "
|
||||||
|
f"({self._consecutive_internet_failures}/{self._nm_restart_threshold})"
|
||||||
|
)
|
||||||
|
if self._consecutive_internet_failures >= self._nm_restart_threshold:
|
||||||
|
logger.warning("Restarting NetworkManager to recover internet connectivity")
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["/usr/bin/systemctl", "restart", "NetworkManager"],
|
||||||
|
capture_output=True, timeout=20, check=True
|
||||||
|
)
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
# NM restart causes a brief WiFi drop; reset the AP-mode grace
|
||||||
|
# counter so that transient disconnect doesn't count toward
|
||||||
|
# triggering AP mode.
|
||||||
|
self.wifi_manager._disconnected_checks = 0
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"NetworkManager restart failed (rc={e.returncode}); "
|
||||||
|
"resetting failure counter to avoid tight retry loop")
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
except (subprocess.SubprocessError, OSError) as e:
|
||||||
|
logger.error(f"NetworkManager restart error: {e}; "
|
||||||
|
"resetting failure counter to avoid tight retry loop")
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
else:
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
else:
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
|
||||||
# Sleep until next check
|
# Sleep until next check
|
||||||
time.sleep(self.check_interval)
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ 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,19 +14,15 @@ 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, List, Callable, Union
|
from typing import Dict, Any, Optional, Callable
|
||||||
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, Future
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
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__)
|
||||||
@@ -227,7 +223,7 @@ class BackgroundDataService:
|
|||||||
self.stats['cache_misses'] += 1
|
self.stats['cache_misses'] += 1
|
||||||
|
|
||||||
# Submit to executor
|
# Submit to executor
|
||||||
future = self.executor.submit(self._fetch_data_worker, request)
|
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
|
||||||
@@ -553,13 +549,12 @@ 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, timeout: int = 30):
|
def shutdown(self, wait: bool = True):
|
||||||
"""
|
"""
|
||||||
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...")
|
||||||
|
|
||||||
@@ -570,16 +565,6 @@ 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)
|
||||||
|
|
||||||
# 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)
|
self.executor.shutdown(wait=wait)
|
||||||
|
|
||||||
logger.info("BackgroundDataService shutdown complete")
|
logger.info("BackgroundDataService shutdown complete")
|
||||||
@@ -587,7 +572,7 @@ class BackgroundDataService:
|
|||||||
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, timeout=None)
|
self.shutdown(wait=False)
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
_background_service: Optional[BackgroundDataService] = None
|
_background_service: Optional[BackgroundDataService] = None
|
||||||
|
|||||||
605
src/backup_manager.py
Normal file
605
src/backup_manager.py
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
"""
|
||||||
|
User configuration backup and restore.
|
||||||
|
|
||||||
|
Packages the user's LEDMatrix configuration, secrets, WiFi settings,
|
||||||
|
user-uploaded fonts, plugin image uploads, and installed-plugin manifest
|
||||||
|
into a single ``.zip`` that can be exported from one installation and
|
||||||
|
imported on a fresh install.
|
||||||
|
|
||||||
|
This module is intentionally Flask-free so it can be unit-tested and
|
||||||
|
used from scripts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
# Filenames shipped with the LEDMatrix repository under ``assets/fonts/``.
|
||||||
|
# Anything present on disk but NOT in this set is treated as a user upload
|
||||||
|
# and included in backups. Keep this snapshot in sync with the repo — regenerate
|
||||||
|
# with::
|
||||||
|
#
|
||||||
|
# ls assets/fonts/
|
||||||
|
#
|
||||||
|
# Tests assert the set matches the checked-in fonts.
|
||||||
|
BUNDLED_FONTS: frozenset[str] = frozenset({
|
||||||
|
"10x20.bdf",
|
||||||
|
"4x6.bdf",
|
||||||
|
"4x6-font.ttf",
|
||||||
|
"5by7.regular.ttf",
|
||||||
|
"5x7.bdf",
|
||||||
|
"5x8.bdf",
|
||||||
|
"6x9.bdf",
|
||||||
|
"6x10.bdf",
|
||||||
|
"6x12.bdf",
|
||||||
|
"6x13.bdf",
|
||||||
|
"6x13B.bdf",
|
||||||
|
"6x13O.bdf",
|
||||||
|
"7x13.bdf",
|
||||||
|
"7x13B.bdf",
|
||||||
|
"7x13O.bdf",
|
||||||
|
"7x14.bdf",
|
||||||
|
"7x14B.bdf",
|
||||||
|
"8x13.bdf",
|
||||||
|
"8x13B.bdf",
|
||||||
|
"8x13O.bdf",
|
||||||
|
"9x15.bdf",
|
||||||
|
"9x15B.bdf",
|
||||||
|
"9x18.bdf",
|
||||||
|
"9x18B.bdf",
|
||||||
|
"AUTHORS",
|
||||||
|
"bdf_font_guide",
|
||||||
|
"clR6x12.bdf",
|
||||||
|
"helvR12.bdf",
|
||||||
|
"ic8x8u.bdf",
|
||||||
|
"MatrixChunky8.bdf",
|
||||||
|
"MatrixChunky8X.bdf",
|
||||||
|
"MatrixLight6.bdf",
|
||||||
|
"MatrixLight6X.bdf",
|
||||||
|
"MatrixLight8X.bdf",
|
||||||
|
"PressStart2P-Regular.ttf",
|
||||||
|
"README",
|
||||||
|
"README.md",
|
||||||
|
"texgyre-27.bdf",
|
||||||
|
"tom-thumb.bdf",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Relative paths inside the project that the backup knows how to round-trip.
|
||||||
|
_CONFIG_REL = Path("config/config.json")
|
||||||
|
_SECRETS_REL = Path("config/config_secrets.json")
|
||||||
|
_WIFI_REL = Path("config/wifi_config.json")
|
||||||
|
_FONTS_REL = Path("assets/fonts")
|
||||||
|
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
||||||
|
_STATE_REL = Path("data/plugin_state.json")
|
||||||
|
|
||||||
|
MANIFEST_NAME = "manifest.json"
|
||||||
|
PLUGINS_MANIFEST_NAME = "plugins.json"
|
||||||
|
|
||||||
|
# Hard cap on the size of a single file we'll accept inside an uploaded ZIP
|
||||||
|
# to limit zip-bomb risk. 50 MB matches the existing plugin-image upload cap.
|
||||||
|
_MAX_MEMBER_BYTES = 50 * 1024 * 1024
|
||||||
|
# Hard cap on the total uncompressed size of an uploaded ZIP.
|
||||||
|
_MAX_TOTAL_BYTES = 200 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RestoreOptions:
|
||||||
|
"""Which sections of a backup should be restored."""
|
||||||
|
|
||||||
|
restore_config: bool = True
|
||||||
|
restore_secrets: bool = True
|
||||||
|
restore_wifi: bool = True
|
||||||
|
restore_fonts: bool = True
|
||||||
|
restore_plugin_uploads: bool = True
|
||||||
|
reinstall_plugins: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RestoreResult:
|
||||||
|
"""Outcome of a restore operation."""
|
||||||
|
|
||||||
|
success: bool = False
|
||||||
|
restored: List[str] = field(default_factory=list)
|
||||||
|
skipped: List[str] = field(default_factory=list)
|
||||||
|
plugins_to_install: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
plugins_installed: List[str] = field(default_factory=list)
|
||||||
|
plugins_failed: List[Dict[str, str]] = field(default_factory=list)
|
||||||
|
errors: List[str] = field(default_factory=list)
|
||||||
|
manifest: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manifest helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ledmatrix_version(project_root: Path) -> str:
|
||||||
|
"""Best-effort version string for the current install."""
|
||||||
|
version_file = project_root / "VERSION"
|
||||||
|
if version_file.exists():
|
||||||
|
try:
|
||||||
|
return version_file.read_text(encoding="utf-8").strip() or "unknown"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
head_file = project_root / ".git" / "HEAD"
|
||||||
|
if head_file.exists():
|
||||||
|
try:
|
||||||
|
head = head_file.read_text(encoding="utf-8").strip()
|
||||||
|
if head.startswith("ref: "):
|
||||||
|
ref = head[5:]
|
||||||
|
ref_path = project_root / ".git" / ref
|
||||||
|
if ref_path.exists():
|
||||||
|
return ref_path.read_text(encoding="utf-8").strip()[:12] or "unknown"
|
||||||
|
return head[:12] or "unknown"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
"ledmatrix_version": _ledmatrix_version(project_root),
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"contents": contents,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Installed-plugin enumeration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Return a list of currently-installed plugins suitable for the backup
|
||||||
|
manifest. Each entry has ``plugin_id`` and ``version``.
|
||||||
|
|
||||||
|
Reads ``data/plugin_state.json`` if present; otherwise walks the plugin
|
||||||
|
directory and reads each ``manifest.json``.
|
||||||
|
"""
|
||||||
|
plugins: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
state_file = project_root / _STATE_REL
|
||||||
|
if state_file.exists():
|
||||||
|
try:
|
||||||
|
with state_file.open("r", encoding="utf-8") as f:
|
||||||
|
state = json.load(f)
|
||||||
|
raw_plugins = state.get("states", {}) if isinstance(state, dict) else {}
|
||||||
|
if isinstance(raw_plugins, dict):
|
||||||
|
for plugin_id, info in raw_plugins.items():
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
plugins[plugin_id] = {
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"version": info.get("version") or "",
|
||||||
|
"enabled": bool(info.get("enabled", True)),
|
||||||
|
}
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("Could not read plugin_state.json: %s", e)
|
||||||
|
|
||||||
|
# Fall back to scanning plugin-repos/ for manifests.
|
||||||
|
plugins_root = project_root / "plugin-repos"
|
||||||
|
if plugins_root.exists():
|
||||||
|
for entry in sorted(plugins_root.iterdir()):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
manifest = entry / "manifest.json"
|
||||||
|
if not manifest.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with manifest.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
plugin_id = data.get("id") or entry.name
|
||||||
|
if plugin_id not in plugins:
|
||||||
|
plugins[plugin_id] = {
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"version": data.get("version", ""),
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted(plugins.values(), key=lambda p: p["plugin_id"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font filtering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def iter_user_fonts(project_root: Path) -> List[Path]:
|
||||||
|
"""Return absolute paths to user-uploaded fonts (anything in
|
||||||
|
``assets/fonts/`` not listed in :data:`BUNDLED_FONTS`)."""
|
||||||
|
fonts_dir = project_root / _FONTS_REL
|
||||||
|
if not fonts_dir.exists():
|
||||||
|
return []
|
||||||
|
user_fonts: List[Path] = []
|
||||||
|
for entry in sorted(fonts_dir.iterdir()):
|
||||||
|
if entry.is_file() and entry.name not in BUNDLED_FONTS:
|
||||||
|
user_fonts.append(entry)
|
||||||
|
return user_fonts
|
||||||
|
|
||||||
|
|
||||||
|
def iter_plugin_uploads(project_root: Path) -> List[Path]:
|
||||||
|
"""Return every file under ``assets/plugins/*/uploads/`` (recursive)."""
|
||||||
|
plugin_root = project_root / _PLUGIN_UPLOADS_REL
|
||||||
|
if not plugin_root.exists():
|
||||||
|
return []
|
||||||
|
out: List[Path] = []
|
||||||
|
for plugin_dir in sorted(plugin_root.iterdir()):
|
||||||
|
if not plugin_dir.is_dir():
|
||||||
|
continue
|
||||||
|
uploads = plugin_dir / "uploads"
|
||||||
|
if not uploads.exists():
|
||||||
|
continue
|
||||||
|
for root, _dirs, files in os.walk(uploads):
|
||||||
|
for name in sorted(files):
|
||||||
|
out.append(Path(root) / name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup(
|
||||||
|
project_root: Path,
|
||||||
|
output_dir: Optional[Path] = None,
|
||||||
|
) -> Path:
|
||||||
|
"""
|
||||||
|
Build a backup ZIP and write it into ``output_dir`` (defaults to
|
||||||
|
``<project_root>/config/backups/exports/``). Returns the path to the
|
||||||
|
created file.
|
||||||
|
"""
|
||||||
|
project_root = Path(project_root).resolve()
|
||||||
|
if output_dir is None:
|
||||||
|
output_dir = project_root / "config" / "backups" / "exports"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
hostname = socket.gethostname() or "ledmatrix"
|
||||||
|
safe_host = "".join(c for c in hostname if c.isalnum() or c in "-_") or "ledmatrix"
|
||||||
|
zip_name = f"ledmatrix-backup-{safe_host}-{timestamp}.zip"
|
||||||
|
zip_path = output_dir / zip_name
|
||||||
|
|
||||||
|
contents: List[str] = []
|
||||||
|
|
||||||
|
# Stream directly to a temp file so we never hold the whole ZIP in memory.
|
||||||
|
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# Config files.
|
||||||
|
if (project_root / _CONFIG_REL).exists():
|
||||||
|
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
|
||||||
|
contents.append("config")
|
||||||
|
if (project_root / _SECRETS_REL).exists():
|
||||||
|
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
|
||||||
|
contents.append("secrets")
|
||||||
|
if (project_root / _WIFI_REL).exists():
|
||||||
|
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
||||||
|
contents.append("wifi")
|
||||||
|
|
||||||
|
# User-uploaded fonts.
|
||||||
|
user_fonts = iter_user_fonts(project_root)
|
||||||
|
if user_fonts:
|
||||||
|
for font in user_fonts:
|
||||||
|
arcname = font.relative_to(project_root).as_posix()
|
||||||
|
zf.write(font, arcname)
|
||||||
|
contents.append("fonts")
|
||||||
|
|
||||||
|
# Plugin uploads.
|
||||||
|
plugin_uploads = iter_plugin_uploads(project_root)
|
||||||
|
if plugin_uploads:
|
||||||
|
for upload in plugin_uploads:
|
||||||
|
arcname = upload.relative_to(project_root).as_posix()
|
||||||
|
zf.write(upload, arcname)
|
||||||
|
contents.append("plugin_uploads")
|
||||||
|
|
||||||
|
# Installed plugins manifest.
|
||||||
|
plugins = list_installed_plugins(project_root)
|
||||||
|
if plugins:
|
||||||
|
zf.writestr(
|
||||||
|
PLUGINS_MANIFEST_NAME,
|
||||||
|
json.dumps(plugins, indent=2),
|
||||||
|
)
|
||||||
|
contents.append("plugins")
|
||||||
|
|
||||||
|
# Manifest goes last so that `contents` reflects what we actually wrote.
|
||||||
|
manifest = _build_manifest(contents, project_root)
|
||||||
|
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
os.replace(tmp_path, zip_path)
|
||||||
|
except Exception:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
logger.info("Created backup %s (%d bytes)", zip_path, zip_path.stat().st_size)
|
||||||
|
return zip_path
|
||||||
|
|
||||||
|
|
||||||
|
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
||||||
|
"""Return a summary of what ``create_backup`` would include."""
|
||||||
|
project_root = Path(project_root).resolve()
|
||||||
|
return {
|
||||||
|
"has_config": (project_root / _CONFIG_REL).exists(),
|
||||||
|
"has_secrets": (project_root / _SECRETS_REL).exists(),
|
||||||
|
"has_wifi": (project_root / _WIFI_REL).exists(),
|
||||||
|
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
||||||
|
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
||||||
|
"plugins": list_installed_plugins(project_root),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_extract_path(base_dir: Path, member_name: str) -> Optional[Path]:
|
||||||
|
"""Resolve a ZIP member name against ``base_dir`` and reject anything
|
||||||
|
that escapes it. Returns the resolved absolute path, or ``None`` if the
|
||||||
|
name is unsafe."""
|
||||||
|
# Reject absolute paths and Windows-style drives outright.
|
||||||
|
if member_name.startswith(("/", "\\")) or (len(member_name) >= 2 and member_name[1] == ":"):
|
||||||
|
return None
|
||||||
|
target = (base_dir / member_name).resolve()
|
||||||
|
try:
|
||||||
|
target.relative_to(base_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Inspect a backup ZIP without extracting to disk.
|
||||||
|
|
||||||
|
Returns ``(ok, error_message, manifest_dict)``. ``manifest_dict`` contains
|
||||||
|
the parsed manifest plus diagnostic fields:
|
||||||
|
- ``detected_contents``: list of section names present in the archive
|
||||||
|
- ``plugins``: parsed plugins.json if present
|
||||||
|
- ``total_uncompressed``: sum of uncompressed sizes
|
||||||
|
"""
|
||||||
|
zip_path = Path(zip_path)
|
||||||
|
if not zip_path.exists():
|
||||||
|
return False, f"Backup file not found: {zip_path}", {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
if MANIFEST_NAME not in names:
|
||||||
|
return False, "Backup is missing manifest.json", {}
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
with tempfile.TemporaryDirectory() as _sandbox:
|
||||||
|
sandbox = Path(_sandbox)
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.file_size > _MAX_MEMBER_BYTES:
|
||||||
|
return False, f"Member {info.filename} is too large", {}
|
||||||
|
total += info.file_size
|
||||||
|
if total > _MAX_TOTAL_BYTES:
|
||||||
|
return False, "Backup exceeds maximum allowed size", {}
|
||||||
|
# Safety: reject members with unsafe paths up front.
|
||||||
|
if _safe_extract_path(sandbox, info.filename) is None:
|
||||||
|
return False, f"Unsafe path in backup: {info.filename}", {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||||
|
manifest = json.loads(manifest_raw)
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||||
|
return False, f"Invalid manifest.json: {e}", {}
|
||||||
|
|
||||||
|
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
||||||
|
return False, "Invalid manifest structure", {}
|
||||||
|
if manifest.get("schema_version") != SCHEMA_VERSION:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Unsupported backup schema version: {manifest.get('schema_version')}",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
detected: List[str] = []
|
||||||
|
if _CONFIG_REL.as_posix() in names:
|
||||||
|
detected.append("config")
|
||||||
|
if _SECRETS_REL.as_posix() in names:
|
||||||
|
detected.append("secrets")
|
||||||
|
if _WIFI_REL.as_posix() in names:
|
||||||
|
detected.append("wifi")
|
||||||
|
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||||
|
detected.append("fonts")
|
||||||
|
if any(
|
||||||
|
n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") and "/uploads/" in n
|
||||||
|
for n in names
|
||||||
|
):
|
||||||
|
detected.append("plugin_uploads")
|
||||||
|
|
||||||
|
plugins: List[Dict[str, Any]] = []
|
||||||
|
if PLUGINS_MANIFEST_NAME in names:
|
||||||
|
try:
|
||||||
|
plugins = json.loads(zf.read(PLUGINS_MANIFEST_NAME).decode("utf-8"))
|
||||||
|
if not isinstance(plugins, list):
|
||||||
|
plugins = []
|
||||||
|
else:
|
||||||
|
detected.append("plugins")
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
plugins = []
|
||||||
|
|
||||||
|
result_manifest = dict(manifest)
|
||||||
|
result_manifest["detected_contents"] = detected
|
||||||
|
result_manifest["plugins"] = plugins
|
||||||
|
result_manifest["total_uncompressed"] = total
|
||||||
|
result_manifest["file_count"] = len(names)
|
||||||
|
return True, "", result_manifest
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
return False, "File is not a valid ZIP archive", {}
|
||||||
|
except OSError as e:
|
||||||
|
return False, f"Could not read backup: {e}", {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Restore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
|
||||||
|
"""Extract ``zip_path`` into ``dest_dir`` rejecting any unsafe members."""
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
target = _safe_extract_path(dest_dir, info.filename)
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(f"Unsafe path in backup: {info.filename}")
|
||||||
|
if info.is_dir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zf.open(info, "r") as src, open(target, "wb") as dst:
|
||||||
|
shutil.copyfileobj(src, dst, length=64 * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_file(src: Path, dst: Path) -> None:
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(
|
||||||
|
zip_path: Path,
|
||||||
|
project_root: Path,
|
||||||
|
options: Optional[RestoreOptions] = None,
|
||||||
|
) -> RestoreResult:
|
||||||
|
"""
|
||||||
|
Restore ``zip_path`` into ``project_root`` according to ``options``.
|
||||||
|
|
||||||
|
Plugin reinstalls are NOT performed here — the caller is responsible for
|
||||||
|
walking ``result.plugins_to_install`` and calling the store manager. This
|
||||||
|
keeps this module Flask-free and side-effect free beyond the filesystem.
|
||||||
|
"""
|
||||||
|
if options is None:
|
||||||
|
options = RestoreOptions()
|
||||||
|
project_root = Path(project_root).resolve()
|
||||||
|
result = RestoreResult()
|
||||||
|
|
||||||
|
ok, err, manifest = validate_backup(zip_path)
|
||||||
|
if not ok:
|
||||||
|
result.errors.append(err)
|
||||||
|
return result
|
||||||
|
result.manifest = manifest
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ledmatrix_restore_") as tmp:
|
||||||
|
tmp_dir = Path(tmp)
|
||||||
|
try:
|
||||||
|
_extract_zip_safe(Path(zip_path), tmp_dir)
|
||||||
|
except (ValueError, zipfile.BadZipFile, OSError) as e:
|
||||||
|
result.errors.append(f"Failed to extract backup: {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Main config.
|
||||||
|
if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
|
||||||
|
try:
|
||||||
|
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
|
||||||
|
result.restored.append("config")
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore config.json: {e}")
|
||||||
|
elif (tmp_dir / _CONFIG_REL).exists():
|
||||||
|
result.skipped.append("config")
|
||||||
|
|
||||||
|
# Secrets.
|
||||||
|
if options.restore_secrets and (tmp_dir / _SECRETS_REL).exists():
|
||||||
|
try:
|
||||||
|
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
|
||||||
|
result.restored.append("secrets")
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore config_secrets.json: {e}")
|
||||||
|
elif (tmp_dir / _SECRETS_REL).exists():
|
||||||
|
result.skipped.append("secrets")
|
||||||
|
|
||||||
|
# WiFi.
|
||||||
|
if options.restore_wifi and (tmp_dir / _WIFI_REL).exists():
|
||||||
|
try:
|
||||||
|
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
|
||||||
|
result.restored.append("wifi")
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore wifi_config.json: {e}")
|
||||||
|
elif (tmp_dir / _WIFI_REL).exists():
|
||||||
|
result.skipped.append("wifi")
|
||||||
|
|
||||||
|
# User fonts — skip anything that collides with a bundled font.
|
||||||
|
tmp_fonts = tmp_dir / _FONTS_REL
|
||||||
|
if options.restore_fonts and tmp_fonts.exists():
|
||||||
|
restored_count = 0
|
||||||
|
for font in sorted(tmp_fonts.iterdir()):
|
||||||
|
if not font.is_file():
|
||||||
|
continue
|
||||||
|
if font.name in BUNDLED_FONTS:
|
||||||
|
result.skipped.append(f"font:{font.name} (bundled)")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_copy_file(font, project_root / _FONTS_REL / font.name)
|
||||||
|
restored_count += 1
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore font {font.name}: {e}")
|
||||||
|
if restored_count:
|
||||||
|
result.restored.append(f"fonts ({restored_count})")
|
||||||
|
elif tmp_fonts.exists():
|
||||||
|
result.skipped.append("fonts")
|
||||||
|
|
||||||
|
# Plugin uploads.
|
||||||
|
tmp_uploads = tmp_dir / _PLUGIN_UPLOADS_REL
|
||||||
|
if options.restore_plugin_uploads and tmp_uploads.exists():
|
||||||
|
count = 0
|
||||||
|
for root, _dirs, files in os.walk(tmp_uploads):
|
||||||
|
for name in files:
|
||||||
|
src = Path(root) / name
|
||||||
|
rel = src.relative_to(tmp_dir)
|
||||||
|
if "/uploads/" not in rel.as_posix():
|
||||||
|
result.errors.append(f"Rejected unexpected plugin path: {rel}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_copy_file(src, project_root / rel)
|
||||||
|
count += 1
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore {rel}: {e}")
|
||||||
|
if count:
|
||||||
|
result.restored.append(f"plugin_uploads ({count})")
|
||||||
|
elif tmp_uploads.exists():
|
||||||
|
result.skipped.append("plugin_uploads")
|
||||||
|
|
||||||
|
# Plugins list (for caller to reinstall).
|
||||||
|
if options.reinstall_plugins and (tmp_dir / PLUGINS_MANIFEST_NAME).exists():
|
||||||
|
try:
|
||||||
|
with (tmp_dir / PLUGINS_MANIFEST_NAME).open("r", encoding="utf-8") as f:
|
||||||
|
plugins = json.load(f)
|
||||||
|
if isinstance(plugins, list):
|
||||||
|
result.plugins_to_install = [
|
||||||
|
{"plugin_id": p.get("plugin_id"), "version": p.get("version", "")}
|
||||||
|
for p in plugins
|
||||||
|
if isinstance(p, dict) and p.get("plugin_id")
|
||||||
|
]
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
result.errors.append(f"Could not read plugins.json: {e}")
|
||||||
|
|
||||||
|
result.success = not result.errors
|
||||||
|
return result
|
||||||
@@ -7,7 +7,7 @@ fields and data structures.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Optional
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import pytz
|
import pytz
|
||||||
@@ -21,12 +21,10 @@ 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,7 +329,6 @@ 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,6 +1,4 @@
|
|||||||
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,11 +6,10 @@ to support different APIs and data providers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, List
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
import time
|
|
||||||
|
|
||||||
class DataSource(ABC):
|
class DataSource(ABC):
|
||||||
"""Abstract base class for data sources."""
|
"""Abstract base class for data sources."""
|
||||||
@@ -35,17 +34,14 @@ 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."""
|
||||||
@@ -217,7 +213,7 @@ class MLBAPIDataSource(DataSource):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.logger.debug(f"Fetched standings from MLB API")
|
self.logger.debug("Fetched standings from MLB API")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -296,7 +292,7 @@ class SoccerAPIDataSource(DataSource):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.logger.debug(f"Fetched standings from soccer API")
|
self.logger.debug("Fetched standings from soccer API")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional
|
||||||
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,6 +1,4 @@
|
|||||||
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
|
||||||
@@ -79,8 +77,6 @@ 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, Callable, Dict, List, Optional
|
from typing import Any, 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 Exception:
|
except RuntimeError as e:
|
||||||
pass
|
self.logger.debug("Could not resolve home directory (expected for service users): %s", e)
|
||||||
|
|
||||||
fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key)
|
fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key)
|
||||||
|
|
||||||
@@ -416,7 +416,6 @@ 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,21 +11,10 @@ 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
|
|
||||||
|
|
||||||
# Import the API counter function from web interface
|
|
||||||
try:
|
|
||||||
from web_interface_v2 import increment_api_counter
|
|
||||||
except ImportError:
|
|
||||||
# Fallback if web interface is not available
|
|
||||||
def increment_api_counter(kind: str, count: int = 1):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BaseOddsManager:
|
class BaseOddsManager:
|
||||||
@@ -132,8 +121,6 @@ class BaseOddsManager:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
raw_data = response.json()
|
raw_data = response.json()
|
||||||
|
|
||||||
# Increment API counter for odds data
|
|
||||||
increment_api_counter('odds', 1)
|
|
||||||
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||||
|
|
||||||
odds_data = self._extract_espn_data(raw_data)
|
odds_data = self._extract_espn_data(raw_data)
|
||||||
|
|||||||
18
src/cache/cache_strategy.py
vendored
18
src/cache/cache_strategy.py
vendored
@@ -194,18 +194,20 @@ class CacheStrategy:
|
|||||||
"""
|
"""
|
||||||
key_lower = key.lower()
|
key_lower = key.lower()
|
||||||
|
|
||||||
# Odds data — checked FIRST because odds keys may also contain 'live'/'current'
|
# Odds data — checked before the generic 'live' block below because
|
||||||
# (e.g. odds_espn_nba_game_123_live). The odds TTL (120s for live, 1800s for
|
# live-odds cache keys (e.g. odds_espn_basketball_nba_<id>_live) contain
|
||||||
# upcoming) must win over the generic sports_live TTL (30s) to avoid hitting
|
# both 'odds' AND 'live'. Without this ordering the 'live' check below
|
||||||
# the ESPN odds API every 30 seconds per game.
|
# would match first and return 'sports_live' (30 s TTL) instead of the
|
||||||
|
# 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 (120s TTL)
|
return 'odds_live' # Live odds change more frequently
|
||||||
return 'odds' # Regular odds for upcoming games (1800s TTL)
|
return 'odds' # Regular odds for upcoming games
|
||||||
|
|
||||||
# Live sports data (only reached if key does NOT contain 'odds')
|
# Live sports data
|
||||||
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
|
||||||
|
|||||||
9
src/cache/disk_cache.py
vendored
9
src/cache/disk_cache.py
vendored
@@ -13,7 +13,6 @@ 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):
|
||||||
@@ -184,7 +183,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)
|
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Non-critical if chmod fails
|
pass # Non-critical if chmod fails
|
||||||
finally:
|
finally:
|
||||||
@@ -202,7 +201,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)
|
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||||
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)
|
||||||
@@ -210,7 +209,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) as e:
|
except (IOError, OSError, PermissionError):
|
||||||
# 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
|
||||||
@@ -228,7 +227,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)
|
os.chmod(fallback_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||||
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)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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
|
||||||
@@ -111,7 +110,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) as perm_error:
|
except (OSError, IOError, PermissionError):
|
||||||
# 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:
|
||||||
@@ -320,18 +319,43 @@ class CacheManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def clear_cache(self, key: Optional[str] = None) -> None:
|
def clear_cache(self, key: Optional[str] = None) -> None:
|
||||||
"""Clear cache for a specific key or all keys."""
|
"""Clear cache entries.
|
||||||
if key:
|
|
||||||
# Clear specific key
|
Pass a non-empty ``key`` to remove a single entry, or pass
|
||||||
self._memory_cache_component.clear(key)
|
``None`` (the default) to clear every cached entry. An empty
|
||||||
self._disk_cache_component.clear(key)
|
string is rejected to prevent accidental whole-cache wipes
|
||||||
self.logger.info("Cleared cache for key: %s", key)
|
from callers that pass through unvalidated input.
|
||||||
else:
|
"""
|
||||||
|
if key is None:
|
||||||
# Clear all keys
|
# Clear all keys
|
||||||
memory_count = self._memory_cache_component.size()
|
memory_count = self._memory_cache_component.size()
|
||||||
self._memory_cache_component.clear()
|
self._memory_cache_component.clear()
|
||||||
self._disk_cache_component.clear()
|
self._disk_cache_component.clear()
|
||||||
self.logger.info("Cleared all cache: %d memory entries", memory_count)
|
self.logger.info("Cleared all cache: %d memory entries", memory_count)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
raise ValueError(
|
||||||
|
"clear_cache(key) requires a non-empty string; "
|
||||||
|
"pass key=None to clear all entries"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear specific key
|
||||||
|
self._memory_cache_component.clear(key)
|
||||||
|
self._disk_cache_component.clear(key)
|
||||||
|
self.logger.info("Cleared cache for key: %s", key)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
"""Remove a single cache entry.
|
||||||
|
|
||||||
|
Thin wrapper around :meth:`clear_cache` that **requires** a
|
||||||
|
non-empty string key — unlike ``clear_cache(None)`` it never
|
||||||
|
wipes every entry. Raises ``ValueError`` on ``None`` or an
|
||||||
|
empty string.
|
||||||
|
"""
|
||||||
|
if key is None or not isinstance(key, str) or not key:
|
||||||
|
raise ValueError("delete(key) requires a non-empty string key")
|
||||||
|
self.clear_cache(key)
|
||||||
|
|
||||||
def list_cache_files(self) -> List[Dict[str, Any]]:
|
def list_cache_files(self) -> List[Dict[str, Any]]:
|
||||||
"""List all cache files with metadata (key, age, size, path).
|
"""List all cache files with metadata (key, age, size, path).
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ General-purpose utility functions:
|
|||||||
- Boolean parsing
|
- Boolean parsing
|
||||||
- Logger creation (deprecated - use `src.logging_config.get_logger()`)
|
- Logger creation (deprecated - use `src.logging_config.get_logger()`)
|
||||||
|
|
||||||
|
## Permission Utilities (`permission_utils.py`)
|
||||||
|
|
||||||
|
Helpers for ensuring directory permissions and ownership are correct
|
||||||
|
when running as a service (used by `CacheManager` to set up its
|
||||||
|
persistent cache directory).
|
||||||
|
|
||||||
|
## CLI Helpers (`cli.py`)
|
||||||
|
|
||||||
|
Shared CLI argument parsing helpers used by `scripts/dev/*` and other
|
||||||
|
command-line entry points.
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
|
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
|
||||||
|
|||||||
@@ -5,13 +5,10 @@ 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, timedelta
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from typing import Any, Dict, Optional
|
||||||
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,11 +5,9 @@ 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, APIHelper, DisplayHelper, GameHelper, ConfigHelper
|
from ledmatrix_common import LogoHelper, TextHelper, 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...")
|
||||||
game_helper = GameHelper()
|
GameHelper()
|
||||||
print("GameHelper initialized")
|
print("GameHelper initialized")
|
||||||
|
|
||||||
# Test ConfigHelper
|
# Test ConfigHelper
|
||||||
print("Testing ConfigHelper...")
|
print("Testing ConfigHelper...")
|
||||||
config_helper = ConfigHelper()
|
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, List, Optional, Tuple, Union
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
@@ -166,8 +166,7 @@ class DisplayHelper:
|
|||||||
img = self.create_base_image(background_color)
|
img = self.create_base_image(background_color)
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# Calculate text position (start off-screen to the right)
|
# Start text 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
|
||||||
@@ -216,7 +215,6 @@ 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()
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ 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
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# 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 = {
|
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
||||||
'/tmp',
|
'/tmp',
|
||||||
'/var/tmp',
|
'/var/tmp',
|
||||||
'/dev',
|
'/dev',
|
||||||
|
|||||||
651
src/common/sync_manager.py
Normal file
651
src/common/sync_manager.py
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
"""
|
||||||
|
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,7 +6,6 @@ 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 Optional, Tuple, Union
|
from typing import Union
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -313,17 +313,8 @@ class ConfigManager:
|
|||||||
self._merge_template_defaults(self.config, template_config)
|
self._merge_template_defaults(self.config, template_config)
|
||||||
|
|
||||||
# Save migrated config using atomic save to preserve permissions
|
# Save migrated config using atomic save to preserve permissions
|
||||||
# Load secrets if they exist to pass to atomic save
|
|
||||||
secrets_content = {}
|
|
||||||
if os.path.exists(self.secrets_path):
|
|
||||||
try:
|
|
||||||
with open(self.secrets_path, 'r') as f_secrets:
|
|
||||||
secrets_content = json.load(f_secrets)
|
|
||||||
except Exception:
|
|
||||||
pass # Continue without secrets if can't load
|
|
||||||
|
|
||||||
# Use atomic save to preserve file permissions
|
# Use atomic save to preserve file permissions
|
||||||
# Note: save_config_atomic handles secrets internally, no need to pass new_secrets
|
# Note: save_config_atomic handles secrets internally
|
||||||
result = self.save_config_atomic(
|
result = self.save_config_atomic(
|
||||||
new_config_data=self.config,
|
new_config_data=self.config,
|
||||||
create_backup=False, # Already created backup above
|
create_backup=False, # Already created backup above
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user