Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7804ea8f69 | ||
|
|
64c7289593 | ||
|
|
b09434a418 | ||
|
|
7ab6fb1aff | ||
|
|
ba6eccb489 | ||
|
|
5ea0d511dc | ||
|
|
1c928b2033 | ||
|
|
b2df0fda1b | ||
|
|
15c61def67 | ||
|
|
c8a0ddcf7b | ||
|
|
9fe23af432 | ||
|
|
c0d97e4867 | ||
|
|
e3c85cece6 | ||
|
|
ba38a83c2c | ||
|
|
c3a7a110c4 | ||
|
|
6047eb5e4e | ||
|
|
c4c46d3ba7 | ||
|
|
da9a999102 | ||
|
|
1e4c890d59 | ||
|
|
7f96075076 | ||
|
|
439013b18c | ||
|
|
e5bbfa2ae3 | ||
|
|
db49275075 | ||
|
|
a11412dabb | ||
|
|
fe5bed2886 | ||
|
|
5b30052b59 | ||
|
|
8557eff88a | ||
|
|
b8c01c69fb | ||
|
|
e6e0a16140 | ||
|
|
989eae9405 | ||
|
|
d469fe39d2 | ||
|
|
09103a8a7d | ||
|
|
724673ba0b | ||
|
|
6cfcf2e384 | ||
|
|
c00bf5e8e6 | ||
|
|
0e9e2cabba | ||
|
|
65d82580bc | ||
|
|
f6c0fe55d9 | ||
|
|
6f45ff5e63 | ||
|
|
1e62677257 | ||
|
|
224847cebc | ||
|
|
aeaeaa4e94 | ||
|
|
76f5d8a336 | ||
|
|
7eb7a58d0c | ||
|
|
b518c51679 | ||
|
|
6bc13a8934 | ||
|
|
bcef1957a9 | ||
|
|
da5937da3d | ||
|
|
c4927e82a3 | ||
|
|
9964dd2183 | ||
|
|
865d62f67b | ||
|
|
8ad9d191a7 | ||
|
|
7f9c73e9aa | ||
|
|
b9416ef803 | ||
|
|
f6afbdbb15 | ||
|
|
3a81f38f09 | ||
|
|
7b90759252 | ||
|
|
b11bcfa204 | ||
|
|
3967a6cffc | ||
|
|
4e61d7248a | ||
|
|
ece416c4e5 | ||
|
|
13bbb537f3 | ||
|
|
afe9001aed | ||
|
|
abedc46104 | ||
|
|
1fe7237799 | ||
|
|
ddf5f085a5 | ||
|
|
5baf983fe0 | ||
|
|
82f3a3a3e4 | ||
|
|
c1ce0b7b04 | ||
|
|
f3894916a9 | ||
|
|
9a1f94f793 | ||
|
|
61e462c635 | ||
|
|
4fe3cdd906 | ||
|
|
4a1fd7464a | ||
|
|
cd5a4e2251 | ||
|
|
3f8edf5113 | ||
|
|
f813ea2117 | ||
|
|
9d024f24ef | ||
|
|
269385c97c | ||
|
|
604f58ff07 | ||
|
|
a8b3e86775 | ||
|
|
a231d4dbc7 | ||
|
|
84afa9d64f | ||
|
|
e1ce7189f1 | ||
|
|
342e9164b8 | ||
|
|
0903f9055f | ||
|
|
967f3a0567 | ||
|
|
21c8a54f68 | ||
|
|
cf02538d2e | ||
|
|
81e1bc596f | ||
|
|
19686ab761 | ||
|
|
92f1960d00 | ||
|
|
116abb0daa | ||
|
|
7e5967e160 | ||
|
|
f475038895 | ||
|
|
1d51efe4c7 | ||
|
|
7ae614aa35 | ||
|
|
2082665252 | ||
|
|
f9b3d6ae52 | ||
|
|
9616a5a054 | ||
|
|
c200b5837d | ||
|
|
9f2743471c | ||
|
|
fddb0e06db | ||
|
|
8360220809 | ||
|
|
9e3f184d81 | ||
|
|
869e36fb2f | ||
|
|
d01da3bd9f | ||
|
|
814c21de1c | ||
|
|
914bf2002f | ||
|
|
47afaaac2b | ||
|
|
6d1cbfb70b | ||
|
|
8e6d7c280f | ||
|
|
dac71afedc | ||
|
|
a6b3384032 | ||
|
|
11bf39cd66 | ||
|
|
bc60b41445 | ||
|
|
f9b1f87e8d | ||
|
|
7e580dc005 | ||
|
|
d51f7ada14 | ||
|
|
d1e821c625 | ||
|
|
69d408b321 | ||
|
|
92ac231138 | ||
|
|
772258f73e | ||
|
|
9ad7528c9b | ||
|
|
6b3028ad58 | ||
|
|
59997594ac | ||
|
|
f6367d63ae | ||
|
|
5137e86d16 | ||
|
|
a3d505384d | ||
|
|
bdb9a94033 | ||
|
|
0ab95586fb | ||
|
|
39f27d285d | ||
|
|
aba96e25b3 | ||
|
|
577f5501a6 | ||
|
|
dcd6e39c96 | ||
|
|
ad5bc4b819 | ||
|
|
fb3b293ace | ||
|
|
8da13f02f8 | ||
|
|
28bc79566f | ||
|
|
12f3790994 | ||
|
|
2df273ecfc | ||
|
|
a29c84208e | ||
|
|
1198615d19 | ||
|
|
f8e2e89edc | ||
|
|
4423ec33d5 | ||
|
|
26769ee37f | ||
|
|
968b953a51 | ||
|
|
e23f1f45d3 | ||
|
|
793b988d33 | ||
|
|
50258635a8 | ||
|
|
c9289e3a1d | ||
|
|
d12323e7f1 | ||
|
|
a0d3e64099 | ||
|
|
696acdbc7b | ||
|
|
91d15a8943 | ||
|
|
6bea1a7c21 | ||
|
|
0730d95200 | ||
|
|
32d637a446 | ||
|
|
bc2dbf3824 | ||
|
|
cb0545ecb3 | ||
|
|
300cdaa250 | ||
|
|
10da2f97fd | ||
|
|
92f9d06af9 | ||
|
|
6e361e05cc | ||
|
|
a686932c7e | ||
|
|
154525beb8 | ||
|
|
9b522d412c | ||
|
|
cbc540a679 | ||
|
|
4aeb0033e0 | ||
|
|
eae063700f | ||
|
|
af96bd5cb0 | ||
|
|
5e5979973e | ||
|
|
bdced206dc | ||
|
|
39e7f8cbe0 | ||
|
|
f90638a9ec | ||
|
|
333fd17d28 | ||
|
|
a4a55a23fc | ||
|
|
085fb93a87 | ||
|
|
c321b94085 | ||
|
|
5a1f121e6b | ||
|
|
6138a3cbef | ||
|
|
568cb6d77f | ||
|
|
6b74506695 | ||
|
|
5f29243e87 | ||
|
|
1fbe244e49 | ||
|
|
863e4a1ecd | ||
|
|
9c0c0dc851 | ||
|
|
71739d85d1 | ||
|
|
cc258aaffd | ||
|
|
fe5a3aa99d | ||
|
|
10e75b977f | ||
|
|
cf0a551f7b | ||
|
|
9018fa23cd | ||
|
|
0c5b9c57d3 | ||
|
|
9083df9f5c | ||
|
|
0901d044d3 | ||
|
|
08265c1135 | ||
|
|
5713fd20a7 | ||
|
|
9cf30bbbef | ||
|
|
a51fb7ce11 | ||
|
|
fce1fdac57 | ||
|
|
7171e6c022 | ||
|
|
9fbdd71941 | ||
|
|
2add759f40 | ||
|
|
bb1a1671ec | ||
|
|
8159afca43 | ||
|
|
44f59ede07 | ||
|
|
ca26c1b83b | ||
|
|
f887063434 | ||
|
|
6287acd591 | ||
|
|
ee59caa577 | ||
|
|
fc25a70d75 | ||
|
|
003312f4ff | ||
|
|
31d607f6b3 | ||
|
|
d6c5f97c13 | ||
|
|
d9683e28be | ||
|
|
2af41c561b | ||
|
|
5b81cca684 | ||
|
|
d305be6089 | ||
|
|
53af53b4a1 | ||
|
|
183e23edb3 | ||
|
|
970ca2d04f | ||
|
|
f2b246ef03 | ||
|
|
963ab8292a | ||
|
|
16fbb7ebeb | ||
|
|
21825cbfbc | ||
|
|
82a65ad2a2 | ||
|
|
83f20b64fe | ||
|
|
5b45f35888 | ||
|
|
e2acbfb566 | ||
|
|
3872a68ff7 | ||
|
|
989162d28f | ||
|
|
cdf03fb107 | ||
|
|
6a9d8014e5 | ||
|
|
c90129285c | ||
|
|
66f9950a30 | ||
|
|
4abcd0e4f9 | ||
|
|
2a1c47fa76 | ||
|
|
9db1d2391a | ||
|
|
14a59c863c | ||
|
|
bff13129c4 | ||
|
|
6499794c12 | ||
|
|
3d347a368a | ||
|
|
0aca40cf3a | ||
|
|
9837315308 | ||
|
|
c1fa5094be | ||
|
|
4d49b0f892 | ||
|
|
efe76d3add | ||
|
|
273d9962d1 | ||
|
|
9e3b5f366e | ||
|
|
6edd80d9f3 | ||
|
|
1c7a0cef66 | ||
|
|
6052a60d22 | ||
|
|
7f7f0d6464 | ||
|
|
05e7c43b27 | ||
|
|
2ffc57cf40 | ||
|
|
aab0e9ade0 | ||
|
|
978a03b42d | ||
|
|
bd9f461f70 | ||
|
|
3b93024993 | ||
|
|
85d321cf33 | ||
|
|
63a233f3ed | ||
|
|
7a9d01342a | ||
|
|
9b2f02681d | ||
|
|
7a6bad29fe | ||
|
|
bea00448d3 | ||
|
|
deaa3d7a98 | ||
|
|
cbb8ec41e8 | ||
|
|
c6ce332d49 | ||
|
|
8e5f66501a | ||
|
|
639e1c3a93 | ||
|
|
6096a22c3d | ||
|
|
fefc2d44a2 | ||
|
|
d297dd6217 | ||
|
|
974d7ea57a | ||
|
|
ab0cfd2362 | ||
|
|
d22d0a3754 | ||
|
|
5beef0aa01 | ||
|
|
cf28a8c0d5 | ||
|
|
a06682981c | ||
|
|
bc027c921d | ||
|
|
e0bd7088fa | ||
|
|
313e35a98f | ||
|
|
122e6d6863 | ||
|
|
d488e8a2ad | ||
|
|
b9dcbb5152 | ||
|
|
f27fd260f7 | ||
|
|
eedf680a8c | ||
|
|
ac3a15bfaa |
@@ -4,4 +4,3 @@ exclude_paths:
|
|||||||
- "plugins/**"
|
- "plugins/**"
|
||||||
- "assets/**"
|
- "assets/**"
|
||||||
- "test/**"
|
- "test/**"
|
||||||
- "scripts/debug/**"
|
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
# Cursor Helper Files for LEDMatrix Plugin Development
|
|
||||||
|
|
||||||
This directory contains Cursor-specific helper files to assist with plugin development in the LEDMatrix project.
|
|
||||||
|
|
||||||
## Files Overview
|
|
||||||
|
|
||||||
### `.cursorrules`
|
|
||||||
Comprehensive rules file that Cursor uses to understand plugin development patterns, best practices, and workflows. This file is automatically loaded by Cursor and helps guide AI-assisted development.
|
|
||||||
|
|
||||||
### `plugins_guide.md`
|
|
||||||
Detailed guide covering:
|
|
||||||
- Plugin system overview
|
|
||||||
- Creating new plugins
|
|
||||||
- Running plugins (emulator and hardware)
|
|
||||||
- Loading and configuring plugins
|
|
||||||
- Development workflow
|
|
||||||
- Testing strategies
|
|
||||||
- Troubleshooting
|
|
||||||
|
|
||||||
### `plugin_templates/`
|
|
||||||
Template files for quick plugin creation:
|
|
||||||
- `manifest.json.template` - Plugin metadata template
|
|
||||||
- `manager.py.template` - Plugin class template
|
|
||||||
- `config_schema.json.template` - Configuration schema template
|
|
||||||
- `README.md.template` - Plugin documentation template
|
|
||||||
- `requirements.txt.template` - Dependencies template
|
|
||||||
- `QUICK_START.md` - Quick start guide for using templates
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### Creating a New Plugin
|
|
||||||
|
|
||||||
1. **Using templates** (recommended):
|
|
||||||
```bash
|
|
||||||
# See QUICK_START.md in plugin_templates/
|
|
||||||
cd plugins
|
|
||||||
mkdir my-plugin
|
|
||||||
cd my-plugin
|
|
||||||
cp ../../.cursor/plugin_templates/*.template .
|
|
||||||
# Edit files, replacing PLUGIN_ID and other placeholders
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Using dev_plugin_setup.sh**:
|
|
||||||
```bash
|
|
||||||
# Link from GitHub
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link-github my-plugin
|
|
||||||
|
|
||||||
# Link local repo
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link my-plugin /path/to/repo
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running the Display
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Emulator mode (development, no hardware required)
|
|
||||||
python3 run.py --emulator
|
|
||||||
# (equivalent: EMULATOR=true python3 run.py)
|
|
||||||
|
|
||||||
# Hardware (production, requires the rpi-rgb-led-matrix submodule built)
|
|
||||||
python3 run.py
|
|
||||||
|
|
||||||
# As a systemd service
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List plugins
|
|
||||||
./scripts/dev/dev_plugin_setup.sh list
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
./scripts/dev/dev_plugin_setup.sh status
|
|
||||||
|
|
||||||
# Update plugin(s)
|
|
||||||
./scripts/dev/dev_plugin_setup.sh update [plugin-name]
|
|
||||||
|
|
||||||
# Unlink plugin
|
|
||||||
./scripts/dev/dev_plugin_setup.sh unlink <plugin-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using These Files with Cursor
|
|
||||||
|
|
||||||
### `.cursorrules`
|
|
||||||
Cursor automatically reads this file to understand:
|
|
||||||
- Plugin structure and requirements
|
|
||||||
- Development workflows
|
|
||||||
- Best practices
|
|
||||||
- Common patterns
|
|
||||||
- API reference
|
|
||||||
|
|
||||||
When asking Cursor to help with plugins, it will use this context to provide better assistance.
|
|
||||||
|
|
||||||
### Plugin Templates
|
|
||||||
Use templates when creating new plugins:
|
|
||||||
1. Copy templates from `.cursor/plugin_templates/`
|
|
||||||
2. Replace placeholders (PLUGIN_ID, PluginClassName, etc.)
|
|
||||||
3. Customize for your plugin's needs
|
|
||||||
4. Follow the guide in `plugins_guide.md`
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
Refer to `plugins_guide.md` for:
|
|
||||||
- Detailed explanations
|
|
||||||
- Troubleshooting steps
|
|
||||||
- Best practices
|
|
||||||
- Examples and patterns
|
|
||||||
|
|
||||||
## Plugin Development Workflow
|
|
||||||
|
|
||||||
1. **Plan**: Determine plugin functionality and requirements
|
|
||||||
2. **Create**: Use templates or dev_plugin_setup.sh to create plugin structure
|
|
||||||
3. **Develop**: Implement plugin logic following BasePlugin interface
|
|
||||||
4. **Test**: Test with emulator first, then on hardware
|
|
||||||
5. **Configure**: Add plugin config to config/config.json
|
|
||||||
6. **Iterate**: Refine based on testing and feedback
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- **Plugin System**: `src/plugin_system/`
|
|
||||||
- **Base Plugin**: `src/plugin_system/base_plugin.py`
|
|
||||||
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
|
|
||||||
- **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`
|
|
||||||
- **Development Setup**: `scripts/dev/dev_plugin_setup.sh`
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
1. Check `plugins_guide.md` for detailed documentation
|
|
||||||
2. Review `.cursorrules` for development patterns
|
|
||||||
3. Look at existing plugins for examples
|
|
||||||
4. Check logs for error messages
|
|
||||||
5. Review plugin system code in `src/plugin_system/`
|
|
||||||
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# Implementation Plan: Fix Config Schema Validation Issues
|
|
||||||
|
|
||||||
Based on audit results showing 186 issues across 20 plugins.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Three priority fixes identified from audit:
|
|
||||||
1. **Priority 1 (HIGH)**: Remove core properties from required array - will fix ~150 issues
|
|
||||||
2. **Priority 2 (MEDIUM)**: Verify default merging logic - will fix remaining required field issues
|
|
||||||
3. **Priority 3 (LOW)**: Calendar plugin schema cleanup - will fix 3 extra field warnings
|
|
||||||
|
|
||||||
## Priority 1: Remove Core Properties from Required Array
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
Core properties (`enabled`, `display_duration`, `live_priority`) are system-managed but listed in schema `required` arrays. SchemaManager injects them into properties but doesn't remove them from `required`, causing validation failures.
|
|
||||||
|
|
||||||
### Solution
|
|
||||||
**File**: `src/plugin_system/schema_manager.py`
|
|
||||||
**Location**: `validate_config_against_schema()` method, after line 295
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. **Add code to remove core properties from required array**:
|
|
||||||
```python
|
|
||||||
# After injecting core properties (around line 295), add:
|
|
||||||
# Remove core properties from required array (they're system-managed)
|
|
||||||
if "required" in enhanced_schema:
|
|
||||||
core_prop_names = list(core_properties.keys())
|
|
||||||
enhanced_schema["required"] = [
|
|
||||||
field for field in enhanced_schema["required"]
|
|
||||||
if field not in core_prop_names
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Add logging for debugging** (optional but helpful):
|
|
||||||
```python
|
|
||||||
if "required" in enhanced_schema and core_prop_names:
|
|
||||||
removed_from_required = [
|
|
||||||
field for field in enhanced_schema.get("required", [])
|
|
||||||
if field in core_prop_names
|
|
||||||
]
|
|
||||||
if removed_from_required and plugin_id:
|
|
||||||
self.logger.debug(
|
|
||||||
f"Removed core properties from required array for {plugin_id}: {removed_from_required}"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test the fix**:
|
|
||||||
- Run audit script: `python scripts/audit_plugin_configs.py`
|
|
||||||
- Expected: Issue count drops from 186 to ~30-40
|
|
||||||
- All "enabled" related errors should be eliminated
|
|
||||||
|
|
||||||
### Expected Outcome
|
|
||||||
- All 20 plugins should no longer fail validation due to missing `enabled` field
|
|
||||||
- ~150 issues resolved (all enabled-related validation errors)
|
|
||||||
|
|
||||||
## Priority 2: Verify Default Merging Logic
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
Some plugins have required fields with defaults that should be applied before validation. Need to verify the default merging happens correctly and handles nested objects.
|
|
||||||
|
|
||||||
### Solution
|
|
||||||
**File**: `web_interface/blueprints/api_v3.py`
|
|
||||||
**Location**: `save_plugin_config()` method, around lines 3218-3221
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. **Review current default merging logic**:
|
|
||||||
- Check that `merge_with_defaults()` is called before validation (line 3220)
|
|
||||||
- Verify it's called after preserving enabled state but before validation
|
|
||||||
|
|
||||||
2. **Verify merge_with_defaults handles nested objects**:
|
|
||||||
- Check `src/plugin_system/schema_manager.py` → `merge_with_defaults()` method
|
|
||||||
- Ensure it recursively merges nested objects (it does use deep_merge)
|
|
||||||
- Test with plugins that have nested required fields
|
|
||||||
|
|
||||||
3. **Check if defaults are applied for nested required fields**:
|
|
||||||
- Review how `generate_default_config()` extracts defaults from nested schemas
|
|
||||||
- Verify nested required fields with defaults are included
|
|
||||||
|
|
||||||
4. **Test with problematic plugins**:
|
|
||||||
- `ledmatrix-weather`: required fields `api_key`, `location_city` (check if defaults exist)
|
|
||||||
- `mqtt-notifications`: required field `mqtt` object (check if default exists)
|
|
||||||
- `text-display`: required field `text` (check if default exists)
|
|
||||||
- `ledmatrix-music`: required field `preferred_source` (check if default exists)
|
|
||||||
|
|
||||||
5. **If defaults don't exist in schemas**:
|
|
||||||
- Either add defaults to schemas, OR
|
|
||||||
- Make fields optional in schemas if they're truly optional
|
|
||||||
|
|
||||||
### Expected Outcome
|
|
||||||
- Plugins with required fields that have schema defaults should pass validation
|
|
||||||
- Issue count further reduced from ~30-40 to ~5-10
|
|
||||||
|
|
||||||
## Priority 3: Calendar Plugin Schema Cleanup
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
Calendar plugin config has fields not in schema:
|
|
||||||
- `show_all_day` (config) but schema has `show_all_day_events` (field name mismatch)
|
|
||||||
- `date_format` (not in schema, not used in manager.py)
|
|
||||||
- `time_format` (not in schema, not used in manager.py)
|
|
||||||
|
|
||||||
### Investigation Results
|
|
||||||
- Schema defines: `show_all_day_events` (boolean, default: true)
|
|
||||||
- Manager.py uses: `show_all_day_events` (line 82: `config.get('show_all_day_events', True)`)
|
|
||||||
- Config has: `show_all_day` (wrong field name - should be `show_all_day_events`)
|
|
||||||
- `date_format` and `time_format` appear to be deprecated (not used in manager.py)
|
|
||||||
|
|
||||||
### Solution
|
|
||||||
|
|
||||||
**File**: `config/config.json` → `calendar` section
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. **Fix field name mismatch**:
|
|
||||||
- Rename `show_all_day` → `show_all_day_events` in config.json
|
|
||||||
- This matches the schema and manager.py code
|
|
||||||
|
|
||||||
2. **Remove deprecated fields**:
|
|
||||||
- Remove `date_format` from config (not used in code)
|
|
||||||
- Remove `time_format` from config (not used in code)
|
|
||||||
|
|
||||||
3. **Alternative (if fields are needed)**: Add `date_format` and `time_format` to schema
|
|
||||||
- Only if these fields should be supported
|
|
||||||
- Check if they're used anywhere else in the codebase
|
|
||||||
|
|
||||||
4. **Test calendar plugin**:
|
|
||||||
- Run audit for calendar plugin specifically
|
|
||||||
- Verify no extra field warnings remain
|
|
||||||
- Test calendar plugin functionality to ensure it still works
|
|
||||||
|
|
||||||
### Expected Outcome
|
|
||||||
- Calendar plugin shows 0 extra field warnings
|
|
||||||
- Final issue count: ~3-5 (only edge cases remain)
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### After Each Priority Fix
|
|
||||||
|
|
||||||
1. **Run local audit**:
|
|
||||||
```bash
|
|
||||||
python scripts/audit_plugin_configs.py
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Check issue count reduction**:
|
|
||||||
- Priority 1: Should drop from 186 to ~30-40
|
|
||||||
- Priority 2: Should drop from ~30-40 to ~5-10
|
|
||||||
- Priority 3: Should drop from ~5-10 to ~3-5
|
|
||||||
|
|
||||||
3. **Review specific plugin results**:
|
|
||||||
```bash
|
|
||||||
python scripts/audit_plugin_configs.py --plugin <plugin-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### After All Fixes
|
|
||||||
|
|
||||||
1. **Full audit run**:
|
|
||||||
```bash
|
|
||||||
python scripts/audit_plugin_configs.py
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Deploy to Pi**:
|
|
||||||
```bash
|
|
||||||
./scripts/deploy_to_pi.sh src/plugin_system/schema_manager.py web_interface/blueprints/api_v3.py
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Run audit on Pi**:
|
|
||||||
```bash
|
|
||||||
./scripts/run_audit_on_pi.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Manual web interface testing**:
|
|
||||||
- Access each problematic plugin's config page
|
|
||||||
- Try saving configuration
|
|
||||||
- Verify no validation errors appear
|
|
||||||
- Check that configs save successfully
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- [ ] Priority 1: All "enabled" related validation errors eliminated
|
|
||||||
- [ ] Priority 1: Issue count reduced from 186 to ~30-40
|
|
||||||
- [ ] Priority 2: Plugins with required fields + defaults pass validation
|
|
||||||
- [ ] Priority 2: Issue count reduced to ~5-10
|
|
||||||
- [ ] Priority 3: Calendar plugin extra field warnings resolved
|
|
||||||
- [ ] Priority 3: Final issue count at ~3-5 (only edge cases)
|
|
||||||
- [ ] All fixes work on Pi (not just local)
|
|
||||||
- [ ] Web interface saves configs without validation errors
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
|
|
||||||
1. `src/plugin_system/schema_manager.py` - Remove core properties from required array
|
|
||||||
2. `plugins/calendar/config_schema.json` OR `config/config.json` - Calendar cleanup (if needed)
|
|
||||||
3. `web_interface/blueprints/api_v3.py` - May need minor adjustments for default merging (if needed)
|
|
||||||
|
|
||||||
## Risk Assessment
|
|
||||||
|
|
||||||
**Priority 1**: Low risk - Only affects validation logic, doesn't change behavior
|
|
||||||
**Priority 2**: Low risk - Only ensures defaults are applied (already intended behavior)
|
|
||||||
**Priority 3**: Very low risk - Only affects calendar plugin, cosmetic issue
|
|
||||||
|
|
||||||
All changes are backward compatible and improve the system rather than changing core functionality.
|
|
||||||
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
# Quick Start: Creating a New Plugin
|
|
||||||
|
|
||||||
This guide will help you create a new plugin using the templates in `.cursor/plugin_templates/`.
|
|
||||||
|
|
||||||
## Step 1: Create Plugin Directory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/LEDMatrix
|
|
||||||
mkdir -p plugins/my-plugin
|
|
||||||
cd plugins/my-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 2: Copy Templates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Copy all template files
|
|
||||||
cp ../../.cursor/plugin_templates/manifest.json.template ./manifest.json
|
|
||||||
cp ../../.cursor/plugin_templates/manager.py.template ./manager.py
|
|
||||||
cp ../../.cursor/plugin_templates/config_schema.json.template ./config_schema.json
|
|
||||||
cp ../../.cursor/plugin_templates/README.md.template ./README.md
|
|
||||||
cp ../../.cursor/plugin_templates/requirements.txt.template ./requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3: Customize Files
|
|
||||||
|
|
||||||
### manifest.json
|
|
||||||
|
|
||||||
Replace placeholders:
|
|
||||||
- `PLUGIN_ID` → `my-plugin` (lowercase, use hyphens)
|
|
||||||
- `Plugin Name` → Your plugin's display name
|
|
||||||
- `PluginClassName` → `MyPlugin` (PascalCase)
|
|
||||||
- Update description, author, homepage, etc.
|
|
||||||
|
|
||||||
### manager.py
|
|
||||||
|
|
||||||
Replace placeholders:
|
|
||||||
- `PluginClassName` → `MyPlugin` (must match manifest)
|
|
||||||
- Implement `_fetch_data()` method
|
|
||||||
- Implement `_render_content()` method
|
|
||||||
- Add any custom validation in `validate_config()`
|
|
||||||
|
|
||||||
### config_schema.json
|
|
||||||
|
|
||||||
Customize:
|
|
||||||
- Update description
|
|
||||||
- Add/remove configuration properties
|
|
||||||
- Set default values
|
|
||||||
- Add validation rules
|
|
||||||
|
|
||||||
### README.md
|
|
||||||
|
|
||||||
Replace placeholders:
|
|
||||||
- `PLUGIN_ID` → `my-plugin`
|
|
||||||
- `Plugin Name` → Your plugin's name
|
|
||||||
- Fill in features, installation, configuration sections
|
|
||||||
|
|
||||||
### requirements.txt
|
|
||||||
|
|
||||||
Add your plugin's dependencies:
|
|
||||||
```txt
|
|
||||||
requests>=2.28.0
|
|
||||||
pillow>=9.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 4: Enable Plugin
|
|
||||||
|
|
||||||
Edit `config/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"my-plugin": {
|
|
||||||
"enabled": true,
|
|
||||||
"display_duration": 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 5: Test Plugin
|
|
||||||
|
|
||||||
### Test with Emulator
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/LEDMatrix
|
|
||||||
python run.py --emulator
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Plugin Loading
|
|
||||||
|
|
||||||
Look for logs like:
|
|
||||||
```
|
|
||||||
[INFO] Discovered 1 plugin(s)
|
|
||||||
[INFO] Loaded plugin: my-plugin v1.0.0
|
|
||||||
[INFO] Added plugin mode: my-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Plugin Display
|
|
||||||
|
|
||||||
The plugin should appear in the display rotation. Check logs for any errors.
|
|
||||||
|
|
||||||
## Step 6: Develop and Iterate
|
|
||||||
|
|
||||||
1. Edit `manager.py` to implement your plugin logic
|
|
||||||
2. Test with emulator: `python run.py --emulator`
|
|
||||||
3. Check logs for errors
|
|
||||||
4. Iterate until working correctly
|
|
||||||
|
|
||||||
## Step 7: Test on Hardware (Optional)
|
|
||||||
|
|
||||||
When ready, test on Raspberry Pi:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Deploy to Pi
|
|
||||||
rsync -avz plugins/my-plugin/ pi@raspberrypi:/path/to/LEDMatrix/plugins/my-plugin/
|
|
||||||
|
|
||||||
# Or if using git
|
|
||||||
ssh pi@raspberrypi "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
|
|
||||||
|
|
||||||
# Restart service
|
|
||||||
ssh pi@raspberrypi "sudo systemctl restart ledmatrix"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Customizations
|
|
||||||
|
|
||||||
### Adding API Integration
|
|
||||||
|
|
||||||
1. Add API key to `config_schema.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api_key": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "API key for service"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Implement API call in `_fetch_data()`:
|
|
||||||
```python
|
|
||||||
import requests
|
|
||||||
|
|
||||||
def _fetch_data(self):
|
|
||||||
response = requests.get(
|
|
||||||
"https://api.example.com/data",
|
|
||||||
headers={"Authorization": f"Bearer {self.api_key}"}
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Store API key in `config/config_secrets.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"my-plugin": {
|
|
||||||
"api_key": "your-secret-key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
def _render_content(self):
|
|
||||||
# Load and paste image onto the display canvas
|
|
||||||
image = Image.open("assets/logo.png").convert("RGB")
|
|
||||||
self.display_manager.image.paste(image, (0, 0))
|
|
||||||
|
|
||||||
# Draw text overlay
|
|
||||||
self.display_manager.draw_text(
|
|
||||||
"Text",
|
|
||||||
x=10, y=20,
|
|
||||||
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
|
|
||||||
|
|
||||||
1. Enable in config:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"my-plugin": {
|
|
||||||
"live_priority": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Implement `has_live_content()`:
|
|
||||||
```python
|
|
||||||
def has_live_content(self) -> bool:
|
|
||||||
return self.data and self.data.get("is_live", False)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Override `get_live_modes()` if needed:
|
|
||||||
```python
|
|
||||||
def get_live_modes(self) -> list:
|
|
||||||
return ["my_plugin_live_mode"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Plugin Not Loading
|
|
||||||
|
|
||||||
- Check `manifest.json` syntax (must be valid JSON)
|
|
||||||
- Verify `entry_point` file exists
|
|
||||||
- Ensure `class_name` matches class name in manager.py
|
|
||||||
- Check for import errors in logs
|
|
||||||
|
|
||||||
### Configuration Errors
|
|
||||||
|
|
||||||
- Validate config against `config_schema.json`
|
|
||||||
- Check required fields are present
|
|
||||||
- Verify data types match schema
|
|
||||||
|
|
||||||
### Display Issues
|
|
||||||
|
|
||||||
- Check display dimensions: `display_manager.width`, `display_manager.height`
|
|
||||||
- Verify coordinates are within bounds
|
|
||||||
- Ensure `update_display()` is called
|
|
||||||
- Test with emulator first
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- Review existing plugins for patterns:
|
|
||||||
- `plugins/hockey-scoreboard/` - Sports scoreboard example
|
|
||||||
- `plugins/ledmatrix-music/` - Real-time data example
|
|
||||||
- `plugins/ledmatrix-stocks/` - Data display example
|
|
||||||
|
|
||||||
- Read full documentation:
|
|
||||||
- `.cursor/plugins_guide.md` - Comprehensive guide
|
|
||||||
- `docs/PLUGIN_ARCHITECTURE_SPEC.md` - Architecture details
|
|
||||||
- `.cursorrules` - Development rules
|
|
||||||
|
|
||||||
- Check plugin system code:
|
|
||||||
- `src/plugin_system/base_plugin.py` - Base class
|
|
||||||
- `src/plugin_system/plugin_manager.py` - Plugin manager
|
|
||||||
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# Plugin Name
|
|
||||||
|
|
||||||
Brief description of what this plugin does.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Feature 1
|
|
||||||
- Feature 2
|
|
||||||
- Feature 3
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. Link the plugin to your LEDMatrix installation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/LEDMatrix
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link-github PLUGIN_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
Or for local development:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link PLUGIN_ID /path/to/plugin/repo
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r plugins/PLUGIN_ID/requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Configure the plugin in `config/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"PLUGIN_ID": {
|
|
||||||
"enabled": true,
|
|
||||||
"display_duration": 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** API keys and other sensitive credentials must be stored in `config/config_secrets.json`, not in `config/config.json`.
|
|
||||||
|
|
||||||
4. Store API keys in `config/config_secrets.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"PLUGIN_ID": {
|
|
||||||
"api_key": "your-secret-api-key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Required Settings
|
|
||||||
|
|
||||||
- `enabled` (boolean): Enable or disable the plugin
|
|
||||||
- `api_key` (string): API key for external service (if required)
|
|
||||||
|
|
||||||
### Optional Settings
|
|
||||||
|
|
||||||
- `display_duration` (number): How long to display this plugin (default: 15 seconds)
|
|
||||||
- `refresh_interval` (integer): How often to refresh data in seconds (default: 60)
|
|
||||||
- `live_priority` (boolean): Enable live priority takeover (default: false)
|
|
||||||
|
|
||||||
## Display Modes
|
|
||||||
|
|
||||||
This plugin provides the following display modes:
|
|
||||||
|
|
||||||
- `PLUGIN_ID`: Main display mode
|
|
||||||
|
|
||||||
## API Requirements
|
|
||||||
|
|
||||||
This plugin requires:
|
|
||||||
|
|
||||||
- **API Name**: Description of API requirements
|
|
||||||
- URL: https://api.example.com
|
|
||||||
- Rate Limit: X requests per minute
|
|
||||||
- Authentication: API key required
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd plugins/PLUGIN_ID
|
|
||||||
python test_PLUGIN_ID.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing with Emulator
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/LEDMatrix
|
|
||||||
python run.py --emulator
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debugging
|
|
||||||
|
|
||||||
Enable debug logging in `config/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"logging": {
|
|
||||||
"level": "DEBUG"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Check logs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On Raspberry Pi (if running as service)
|
|
||||||
journalctl -u ledmatrix -f
|
|
||||||
|
|
||||||
# Direct execution
|
|
||||||
python run.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Plugin Not Loading
|
|
||||||
|
|
||||||
1. Check that `manifest.json` exists and is valid
|
|
||||||
2. Verify `entry_point` file exists
|
|
||||||
3. Check that `class_name` matches the class in manager.py
|
|
||||||
4. Review logs for import errors
|
|
||||||
|
|
||||||
### Configuration Errors
|
|
||||||
|
|
||||||
1. Validate config against `config_schema.json`
|
|
||||||
2. Check required fields are present
|
|
||||||
3. Verify data types match schema
|
|
||||||
|
|
||||||
### API Errors
|
|
||||||
|
|
||||||
1. Verify API key is correct
|
|
||||||
2. Check API rate limits
|
|
||||||
3. Review network connectivity
|
|
||||||
4. Check API service status
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[License information]
|
|
||||||
|
|
||||||
## Author
|
|
||||||
|
|
||||||
Your Name
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- GitHub: https://github.com/username/ledmatrix-PLUGIN_ID
|
|
||||||
- Documentation: [Link to docs]
|
|
||||||
- Issues: https://github.com/username/ledmatrix-PLUGIN_ID/issues
|
|
||||||
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
|
||||||
"title": "Plugin Configuration Schema",
|
|
||||||
"description": "Configuration schema for Plugin Name",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Enable or disable this plugin"
|
|
||||||
},
|
|
||||||
"display_duration": {
|
|
||||||
"type": "number",
|
|
||||||
"default": 15,
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 300,
|
|
||||||
"description": "How long to display this plugin in seconds"
|
|
||||||
},
|
|
||||||
"live_priority": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false,
|
|
||||||
"description": "Enable live priority takeover when plugin has live content"
|
|
||||||
},
|
|
||||||
"refresh_interval": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 60,
|
|
||||||
"minimum": 1,
|
|
||||||
"description": "How often to refresh data in seconds"
|
|
||||||
},
|
|
||||||
"api_key": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "API key for external service (store in config_secrets.json)",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"custom_setting": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Example custom setting - replace with your plugin's settings",
|
|
||||||
"default": "default_value"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["enabled"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
"""
|
|
||||||
Plugin Name
|
|
||||||
|
|
||||||
Brief description of what this plugin does.
|
|
||||||
|
|
||||||
API Version: 1.0.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from src.plugin_system.base_plugin import BasePlugin
|
|
||||||
from PIL import Image
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class PluginClassName(BasePlugin):
|
|
||||||
"""
|
|
||||||
Plugin class that inherits from BasePlugin.
|
|
||||||
|
|
||||||
This plugin demonstrates the basic structure and common patterns
|
|
||||||
for LEDMatrix plugins.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
plugin_id: str,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
display_manager,
|
|
||||||
cache_manager,
|
|
||||||
plugin_manager,
|
|
||||||
):
|
|
||||||
"""Initialize the plugin."""
|
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
|
||||||
|
|
||||||
# Initialize plugin-specific data
|
|
||||||
self.data = None
|
|
||||||
self.last_update_time = None
|
|
||||||
|
|
||||||
# Load configuration values
|
|
||||||
self.api_key = config.get("api_key", "")
|
|
||||||
self.refresh_interval = config.get("refresh_interval", 60)
|
|
||||||
|
|
||||||
self.logger.info(f"Plugin {plugin_id} initialized")
|
|
||||||
|
|
||||||
def update(self) -> None:
|
|
||||||
"""
|
|
||||||
Fetch/update data for this plugin.
|
|
||||||
|
|
||||||
This method is called periodically based on update_interval
|
|
||||||
specified in the manifest. Use cache_manager to avoid
|
|
||||||
excessive API calls.
|
|
||||||
"""
|
|
||||||
cache_key = f"{self.plugin_id}_data"
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
cached = self.cache_manager.get(cache_key, max_age=self.refresh_interval)
|
|
||||||
if cached:
|
|
||||||
self.data = cached
|
|
||||||
self.logger.debug("Using cached data")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Fetch new data
|
|
||||||
self.data = self._fetch_data()
|
|
||||||
|
|
||||||
# Cache the data
|
|
||||||
self.cache_manager.set(cache_key, self.data, ttl=self.refresh_interval)
|
|
||||||
self.last_update_time = time.time()
|
|
||||||
|
|
||||||
self.logger.info("Data updated successfully")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to update data: {e}")
|
|
||||||
# Use cached data if available, even if expired
|
|
||||||
# Use a very large max_age (1 year) to effectively bypass expiration for fallback
|
|
||||||
expired_cached = self.cache_manager.get(cache_key, max_age=31536000)
|
|
||||||
if expired_cached:
|
|
||||||
self.data = expired_cached
|
|
||||||
self.logger.warning("Using expired cache due to update failure")
|
|
||||||
|
|
||||||
def display(self, force_clear: bool = False) -> None:
|
|
||||||
"""
|
|
||||||
Render this plugin's display.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
force_clear: If True, clear display before rendering
|
|
||||||
"""
|
|
||||||
if force_clear:
|
|
||||||
self.display_manager.clear()
|
|
||||||
|
|
||||||
# Check if we have data to display
|
|
||||||
if not self.data:
|
|
||||||
self._display_error("No data available")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Render plugin content
|
|
||||||
self._render_content()
|
|
||||||
|
|
||||||
# Update the display
|
|
||||||
self.display_manager.update_display()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Display error: {e}")
|
|
||||||
self._display_error("Display error")
|
|
||||||
|
|
||||||
def _fetch_data(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Fetch data from external source.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing fetched data
|
|
||||||
"""
|
|
||||||
# TODO: Implement data fetching logic
|
|
||||||
# Example:
|
|
||||||
# import requests
|
|
||||||
# response = requests.get("https://api.example.com/data",
|
|
||||||
# headers={"Authorization": f"Bearer {self.api_key}"})
|
|
||||||
# return response.json()
|
|
||||||
|
|
||||||
# Placeholder
|
|
||||||
return {
|
|
||||||
"message": "Hello, World!",
|
|
||||||
"timestamp": time.time()
|
|
||||||
}
|
|
||||||
|
|
||||||
def _render_content(self) -> None:
|
|
||||||
"""Render the plugin content on the display."""
|
|
||||||
# Get display dimensions
|
|
||||||
width = self.display_manager.width
|
|
||||||
height = self.display_manager.height
|
|
||||||
|
|
||||||
# Example: Draw text
|
|
||||||
text = self.data.get("message", "No data")
|
|
||||||
x = 5
|
|
||||||
y = height // 2
|
|
||||||
|
|
||||||
self.display_manager.draw_text(
|
|
||||||
text,
|
|
||||||
x=x,
|
|
||||||
y=y,
|
|
||||||
color=(255, 255, 255) # White
|
|
||||||
)
|
|
||||||
|
|
||||||
# Example: Draw image
|
|
||||||
# if hasattr(self, 'logo_image'):
|
|
||||||
# self.display_manager.draw_image(
|
|
||||||
# self.logo_image,
|
|
||||||
# x=0,
|
|
||||||
# y=0
|
|
||||||
# )
|
|
||||||
|
|
||||||
def _display_error(self, message: str) -> None:
|
|
||||||
"""Display an error message."""
|
|
||||||
self.display_manager.clear()
|
|
||||||
width = self.display_manager.width
|
|
||||||
height = self.display_manager.height
|
|
||||||
|
|
||||||
self.display_manager.draw_text(
|
|
||||||
message,
|
|
||||||
x=5,
|
|
||||||
y=height // 2,
|
|
||||||
color=(255, 0, 0) # Red
|
|
||||||
)
|
|
||||||
self.display_manager.update_display()
|
|
||||||
|
|
||||||
def validate_config(self) -> bool:
|
|
||||||
"""
|
|
||||||
Validate plugin configuration.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if config is valid, False otherwise
|
|
||||||
"""
|
|
||||||
# Call parent validation first
|
|
||||||
if not super().validate_config():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Add custom validation
|
|
||||||
# Example: Check for required API key
|
|
||||||
# if self.config.get("require_api_key", True):
|
|
||||||
# if not self.api_key:
|
|
||||||
# self.logger.error("API key is required but not provided")
|
|
||||||
# return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def has_live_content(self) -> bool:
|
|
||||||
"""
|
|
||||||
Check if plugin has live content to display.
|
|
||||||
|
|
||||||
Override this method to enable live priority features.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if plugin has live content, False otherwise
|
|
||||||
"""
|
|
||||||
# Example: Check if there's live data
|
|
||||||
# return self.data and self.data.get("is_live", False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Return plugin info for display in web UI.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with plugin information
|
|
||||||
"""
|
|
||||||
info = super().get_info()
|
|
||||||
|
|
||||||
# Add plugin-specific info
|
|
||||||
info.update({
|
|
||||||
"data_available": self.data is not None,
|
|
||||||
"last_update": self.last_update_time,
|
|
||||||
# Add more info as needed
|
|
||||||
})
|
|
||||||
|
|
||||||
return info
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
"""Cleanup resources when plugin is unloaded."""
|
|
||||||
# Clean up any resources (threads, connections, etc.)
|
|
||||||
# Example:
|
|
||||||
# if hasattr(self, 'api_client'):
|
|
||||||
# self.api_client.close()
|
|
||||||
|
|
||||||
super().cleanup()
|
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "PLUGIN_ID",
|
|
||||||
"name": "Plugin Name",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "Your Name",
|
|
||||||
"description": "Brief description of what this plugin does",
|
|
||||||
"homepage": "https://github.com/username/ledmatrix-PLUGIN_ID",
|
|
||||||
"entry_point": "manager.py",
|
|
||||||
"class_name": "PluginClassName",
|
|
||||||
"category": "custom",
|
|
||||||
"tags": ["custom", "example"],
|
|
||||||
"icon": "fas fa-icon-name",
|
|
||||||
"compatible_versions": [">=2.0.0"],
|
|
||||||
"min_ledmatrix_version": "2.0.0",
|
|
||||||
"max_ledmatrix_version": "3.0.0",
|
|
||||||
"requires": {
|
|
||||||
"python": ">=3.9",
|
|
||||||
"display_size": {
|
|
||||||
"min_width": 64,
|
|
||||||
"min_height": 32
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config_schema": "config_schema.json",
|
|
||||||
"assets": {
|
|
||||||
"logos": "Optional: Description of asset requirements"
|
|
||||||
},
|
|
||||||
"update_interval": 60,
|
|
||||||
"default_duration": 15,
|
|
||||||
"display_modes": [
|
|
||||||
"PLUGIN_ID"
|
|
||||||
],
|
|
||||||
"api_requirements": [
|
|
||||||
{
|
|
||||||
"name": "API Name",
|
|
||||||
"required": false,
|
|
||||||
"description": "Description of API requirements",
|
|
||||||
"url": "https://api.example.com",
|
|
||||||
"rate_limit": "Rate limit information"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"download_url_template": "https://github.com/username/ledmatrix-PLUGIN_ID/archive/refs/tags/v{version}.zip",
|
|
||||||
"versions": [
|
|
||||||
{
|
|
||||||
"released": "2025-01-01",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"ledmatrix_min_version": "2.0.0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"last_updated": "2025-01-01",
|
|
||||||
"stars": 0,
|
|
||||||
"downloads": 0,
|
|
||||||
"verified": false,
|
|
||||||
"screenshot": ""
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Plugin Dependencies
|
|
||||||
# Add your plugin's Python dependencies here
|
|
||||||
|
|
||||||
# Example dependencies (uncomment and modify as needed):
|
|
||||||
# requests>=2.28.0
|
|
||||||
# pillow>=9.0.0
|
|
||||||
# python-dateutil>=2.8.0
|
|
||||||
|
|
||||||
# Note: Core LEDMatrix dependencies are already available:
|
|
||||||
# - PIL/Pillow (for image handling)
|
|
||||||
# - Core plugin system classes
|
|
||||||
# - Display manager, cache manager, config manager
|
|
||||||
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""
|
|
||||||
Test file for Plugin Name plugin.
|
|
||||||
|
|
||||||
This file provides example unit tests for your plugin.
|
|
||||||
Run tests with: python -m pytest test_manager.py
|
|
||||||
Or: python test_manager.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add project root to path
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from src.plugin_system.testing import PluginTestCase
|
|
||||||
from manager import PluginClassName
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginClassName(PluginTestCase):
|
|
||||||
"""Test cases for PluginClassName plugin."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up test fixtures."""
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
# Update plugin_id to match the plugin being tested
|
|
||||||
self.plugin_id = 'PLUGIN_ID'
|
|
||||||
|
|
||||||
# Create plugin instance
|
|
||||||
self.plugin = self.create_plugin_instance(
|
|
||||||
PluginClassName,
|
|
||||||
plugin_id='PLUGIN_ID',
|
|
||||||
config=self.get_mock_config()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_plugin_initialization(self):
|
|
||||||
"""Test that plugin initializes correctly."""
|
|
||||||
self.assert_plugin_initialized(self.plugin)
|
|
||||||
self.assertTrue(self.plugin.enabled)
|
|
||||||
|
|
||||||
def test_config_validation(self):
|
|
||||||
"""Test configuration validation."""
|
|
||||||
# Valid config should pass
|
|
||||||
self.assertTrue(self.plugin.validate_config())
|
|
||||||
|
|
||||||
# Test with invalid config if applicable
|
|
||||||
# invalid_config = self.get_mock_config(enabled='not-a-boolean')
|
|
||||||
# invalid_plugin = self.create_plugin_instance(
|
|
||||||
# PluginClassName,
|
|
||||||
# config=invalid_config
|
|
||||||
# )
|
|
||||||
# self.assertFalse(invalid_plugin.validate_config())
|
|
||||||
|
|
||||||
def test_update_method(self):
|
|
||||||
"""Test the update() method."""
|
|
||||||
# Reset mocks
|
|
||||||
self.cache_manager.reset()
|
|
||||||
|
|
||||||
# Call update
|
|
||||||
self.plugin.update()
|
|
||||||
|
|
||||||
# Assertions
|
|
||||||
# Example: Check that cache was used
|
|
||||||
# self.assert_cache_get('PLUGIN_ID_data')
|
|
||||||
|
|
||||||
# Example: Check that data was fetched and cached
|
|
||||||
# self.assert_cache_set('PLUGIN_ID_data')
|
|
||||||
|
|
||||||
def test_display_method(self):
|
|
||||||
"""Test the display() method."""
|
|
||||||
# Ensure plugin has data (call update first if needed)
|
|
||||||
# self.plugin.update()
|
|
||||||
|
|
||||||
# Call display
|
|
||||||
self.plugin.display(force_clear=True)
|
|
||||||
|
|
||||||
# Assertions
|
|
||||||
self.assert_display_cleared()
|
|
||||||
self.assert_display_updated()
|
|
||||||
|
|
||||||
# Example: Check that text was drawn
|
|
||||||
# self.assert_text_drawn("Expected Text")
|
|
||||||
|
|
||||||
# Example: Check that image was drawn
|
|
||||||
# self.assert_image_drawn()
|
|
||||||
|
|
||||||
def test_display_without_data(self):
|
|
||||||
"""Test display() behavior when no data is available."""
|
|
||||||
# Clear any cached data
|
|
||||||
self.cache_manager.reset()
|
|
||||||
|
|
||||||
# Call display
|
|
||||||
self.plugin.display()
|
|
||||||
|
|
||||||
# Should handle gracefully (no exceptions)
|
|
||||||
# May show error message or fallback content
|
|
||||||
self.assert_display_updated()
|
|
||||||
|
|
||||||
def test_get_display_duration(self):
|
|
||||||
"""Test display duration configuration."""
|
|
||||||
duration = self.plugin.get_display_duration()
|
|
||||||
self.assertIsInstance(duration, (int, float))
|
|
||||||
self.assertGreater(duration, 0)
|
|
||||||
|
|
||||||
# Test with custom duration
|
|
||||||
custom_config = self.get_mock_config(display_duration=30.0)
|
|
||||||
custom_plugin = self.create_plugin_instance(
|
|
||||||
PluginClassName,
|
|
||||||
config=custom_config
|
|
||||||
)
|
|
||||||
self.assertEqual(custom_plugin.get_display_duration(), 30.0)
|
|
||||||
|
|
||||||
def test_enable_disable(self):
|
|
||||||
"""Test plugin enable/disable functionality."""
|
|
||||||
self.assertTrue(self.plugin.enabled)
|
|
||||||
|
|
||||||
self.plugin.on_disable()
|
|
||||||
self.assertFalse(self.plugin.enabled)
|
|
||||||
|
|
||||||
self.plugin.on_enable()
|
|
||||||
self.assertTrue(self.plugin.enabled)
|
|
||||||
|
|
||||||
def test_config_change(self):
|
|
||||||
"""Test configuration change handling."""
|
|
||||||
new_config = self.get_mock_config(display_duration=20.0)
|
|
||||||
self.plugin.on_config_change(new_config)
|
|
||||||
|
|
||||||
self.assertEqual(self.plugin.config.get('display_duration'), 20.0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
|
|
||||||
@@ -1,751 +0,0 @@
|
|||||||
# LEDMatrix Plugin Development Guide
|
|
||||||
|
|
||||||
This guide provides comprehensive instructions for creating, running, and loading plugins in the LEDMatrix project.
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [Plugin System Overview](#plugin-system-overview)
|
|
||||||
2. [Creating a New Plugin](#creating-a-new-plugin)
|
|
||||||
3. [Running Plugins](#running-plugins)
|
|
||||||
4. [Loading Plugins](#loading-plugins)
|
|
||||||
5. [Plugin Development Workflow](#plugin-development-workflow)
|
|
||||||
6. [Testing Plugins](#testing-plugins)
|
|
||||||
7. [Troubleshooting](#troubleshooting)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Plugin System Overview
|
|
||||||
|
|
||||||
The LEDMatrix project uses a plugin-based architecture where all display functionality (except core calendar) is implemented as plugins. Plugins are dynamically loaded from the `plugins/` directory and integrated into the display rotation.
|
|
||||||
|
|
||||||
### Plugin Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
LEDMatrix Core
|
|
||||||
├── Plugin Manager (discovers, loads, manages plugins)
|
|
||||||
├── Display Manager (handles LED matrix rendering)
|
|
||||||
├── Cache Manager (data persistence)
|
|
||||||
├── Config Manager (configuration management)
|
|
||||||
└── Plugins/ (plugin directory)
|
|
||||||
├── plugin-1/
|
|
||||||
├── plugin-2/
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plugin Lifecycle
|
|
||||||
|
|
||||||
1. **Discovery**: PluginManager scans `plugins/` for directories with `manifest.json`
|
|
||||||
2. **Loading**: Plugin module is imported and class is instantiated
|
|
||||||
3. **Configuration**: Plugin config is loaded from `config/config.json`
|
|
||||||
4. **Validation**: `validate_config()` is called to verify configuration
|
|
||||||
5. **Registration**: Plugin is added to available display modes
|
|
||||||
6. **Execution**: `update()` is called periodically, `display()` is called during rotation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Creating a New Plugin
|
|
||||||
|
|
||||||
### Method 1: Using dev_plugin_setup.sh (Recommended)
|
|
||||||
|
|
||||||
This method is best for plugins stored in separate Git repositories.
|
|
||||||
|
|
||||||
#### From GitHub Repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Link a plugin from GitHub (auto-detects URL)
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
|
||||||
|
|
||||||
# Example: Link hockey-scoreboard plugin
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link-github hockey-scoreboard
|
|
||||||
|
|
||||||
# With custom URL
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name> https://github.com/user/repo.git
|
|
||||||
```
|
|
||||||
|
|
||||||
The script will:
|
|
||||||
- Clone the repository to `~/.ledmatrix-dev-plugins/` (or configured directory)
|
|
||||||
- Create a symlink in `plugins/<plugin-name>/` pointing to the cloned repo
|
|
||||||
- Validate the plugin structure
|
|
||||||
|
|
||||||
#### From Local Repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Link a local plugin repository
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
|
||||||
|
|
||||||
# Example: Link a local plugin
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method 2: Manual Plugin Creation
|
|
||||||
|
|
||||||
1. **Create Plugin Directory**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p plugins/my-plugin
|
|
||||||
cd plugins/my-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create manifest.json**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "my-plugin",
|
|
||||||
"name": "My Plugin",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "Your Name",
|
|
||||||
"description": "Description of what this plugin does",
|
|
||||||
"entry_point": "manager.py",
|
|
||||||
"class_name": "MyPlugin",
|
|
||||||
"category": "custom",
|
|
||||||
"tags": ["custom", "example"],
|
|
||||||
"display_modes": ["my_plugin"],
|
|
||||||
"update_interval": 60,
|
|
||||||
"default_duration": 15,
|
|
||||||
"requires": {
|
|
||||||
"python": ">=3.9"
|
|
||||||
},
|
|
||||||
"config_schema": "config_schema.json"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Create manager.py**
|
|
||||||
|
|
||||||
```python
|
|
||||||
from src.plugin_system.base_plugin import BasePlugin
|
|
||||||
from PIL import Image
|
|
||||||
import logging
|
|
||||||
|
|
||||||
class MyPlugin(BasePlugin):
|
|
||||||
"""My custom plugin implementation."""
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
"""Fetch/update data for this plugin."""
|
|
||||||
# Fetch data from API, files, etc.
|
|
||||||
# Use self.cache_manager for caching
|
|
||||||
cache_key = f"{self.plugin_id}_data"
|
|
||||||
cached = self.cache_manager.get(cache_key, max_age=3600)
|
|
||||||
if cached:
|
|
||||||
self.data = cached
|
|
||||||
return
|
|
||||||
|
|
||||||
# Fetch new data
|
|
||||||
self.data = self._fetch_data()
|
|
||||||
self.cache_manager.set(cache_key, self.data)
|
|
||||||
|
|
||||||
def display(self, force_clear=False):
|
|
||||||
"""Render this plugin's display."""
|
|
||||||
if force_clear:
|
|
||||||
self.display_manager.clear()
|
|
||||||
|
|
||||||
# Render content using display_manager
|
|
||||||
self.display_manager.draw_text(
|
|
||||||
"Hello, World!",
|
|
||||||
x=10, y=15,
|
|
||||||
color=(255, 255, 255)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.display_manager.update_display()
|
|
||||||
|
|
||||||
def _fetch_data(self):
|
|
||||||
"""Fetch data from external source."""
|
|
||||||
# Implement your data fetching logic
|
|
||||||
return {"message": "Hello, World!"}
|
|
||||||
|
|
||||||
def validate_config(self):
|
|
||||||
"""Validate plugin configuration."""
|
|
||||||
# Check required config fields
|
|
||||||
if not super().validate_config():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Add custom validation
|
|
||||||
required_fields = ['api_key'] # Example
|
|
||||||
for field in required_fields:
|
|
||||||
if field not in self.config:
|
|
||||||
self.logger.error(f"Missing required field: {field}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Create config_schema.json**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Enable or disable this plugin"
|
|
||||||
},
|
|
||||||
"display_duration": {
|
|
||||||
"type": "number",
|
|
||||||
"default": 15,
|
|
||||||
"minimum": 1,
|
|
||||||
"description": "How long to display this plugin (seconds)"
|
|
||||||
},
|
|
||||||
"api_key": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "API key for external service"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["enabled"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Create requirements.txt** (if needed)
|
|
||||||
|
|
||||||
```
|
|
||||||
requests>=2.28.0
|
|
||||||
pillow>=9.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Create README.md**
|
|
||||||
|
|
||||||
Document your plugin's functionality, configuration options, and usage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running Plugins
|
|
||||||
|
|
||||||
### Development Mode (Emulator)
|
|
||||||
|
|
||||||
Run the LEDMatrix system with emulator for plugin testing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using run.py
|
|
||||||
python run.py --emulator
|
|
||||||
|
|
||||||
# Using emulator script
|
|
||||||
./run_emulator.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The emulator will:
|
|
||||||
- Load all enabled plugins
|
|
||||||
- Display plugin content in a window (simulating LED matrix)
|
|
||||||
- Show logs for plugin loading and execution
|
|
||||||
- Allow testing without Raspberry Pi hardware
|
|
||||||
|
|
||||||
### Production Mode (Raspberry Pi)
|
|
||||||
|
|
||||||
Run on actual Raspberry Pi hardware:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Direct execution
|
|
||||||
python run.py
|
|
||||||
|
|
||||||
# As systemd service
|
|
||||||
sudo systemctl start ledmatrix
|
|
||||||
sudo systemctl status ledmatrix
|
|
||||||
sudo journalctl -u ledmatrix -f # View logs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plugin-Specific Testing
|
|
||||||
|
|
||||||
Test individual plugin loading:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# test_my_plugin.py
|
|
||||||
from src.plugin_system.plugin_manager import PluginManager
|
|
||||||
from src.config_manager import ConfigManager
|
|
||||||
from src.display_manager import DisplayManager
|
|
||||||
from src.cache_manager import CacheManager
|
|
||||||
|
|
||||||
# Initialize managers
|
|
||||||
config_manager = ConfigManager()
|
|
||||||
config = config_manager.load_config()
|
|
||||||
display_manager = DisplayManager(config)
|
|
||||||
cache_manager = CacheManager()
|
|
||||||
|
|
||||||
# Initialize plugin manager
|
|
||||||
plugin_manager = PluginManager(
|
|
||||||
plugins_dir="plugins",
|
|
||||||
config_manager=config_manager,
|
|
||||||
display_manager=display_manager,
|
|
||||||
cache_manager=cache_manager
|
|
||||||
)
|
|
||||||
|
|
||||||
# Discover and load plugin
|
|
||||||
plugins = plugin_manager.discover_plugins()
|
|
||||||
print(f"Discovered plugins: {plugins}")
|
|
||||||
|
|
||||||
if "my-plugin" in plugins:
|
|
||||||
if plugin_manager.load_plugin("my-plugin"):
|
|
||||||
plugin = plugin_manager.get_plugin("my-plugin")
|
|
||||||
plugin.update()
|
|
||||||
plugin.display()
|
|
||||||
print("Plugin loaded and displayed successfully!")
|
|
||||||
else:
|
|
||||||
print("Failed to load plugin")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Loading Plugins
|
|
||||||
|
|
||||||
### Enabling Plugins
|
|
||||||
|
|
||||||
Plugins are enabled/disabled in `config/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"my-plugin": {
|
|
||||||
"enabled": true,
|
|
||||||
"display_duration": 15,
|
|
||||||
"api_key": "your-api-key-here"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plugin Configuration Structure
|
|
||||||
|
|
||||||
Each plugin has its own section in `config/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"<plugin-id>": {
|
|
||||||
"enabled": true, // Enable/disable plugin
|
|
||||||
"display_duration": 15, // Display duration in seconds
|
|
||||||
"live_priority": false, // Enable live priority takeover
|
|
||||||
"high_performance_transitions": false, // Use 120 FPS transitions
|
|
||||||
"transition": { // Transition configuration
|
|
||||||
"type": "redraw", // Transition type
|
|
||||||
"speed": 2, // Transition speed
|
|
||||||
"enabled": true // Enable transitions
|
|
||||||
},
|
|
||||||
// ... plugin-specific configuration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Secrets Management
|
|
||||||
|
|
||||||
Store sensitive data (API keys, tokens) in `config/config_secrets.json`
|
|
||||||
under the same plugin id you use in `config/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"my-plugin": {
|
|
||||||
"api_key": "secret-api-key-here"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyPlugin(BasePlugin):
|
|
||||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
|
||||||
self.api_key = config.get("api_key") # already merged from secrets
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
Plugins are automatically discovered when:
|
|
||||||
- Directory exists in `plugins/`
|
|
||||||
- Directory contains `manifest.json`
|
|
||||||
- Manifest has required fields (`id`, `entry_point`, `class_name`)
|
|
||||||
|
|
||||||
Check discovered plugins:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using dev_plugin_setup.sh
|
|
||||||
./scripts/dev/dev_plugin_setup.sh list
|
|
||||||
|
|
||||||
# Output shows:
|
|
||||||
# ✓ plugin-name (symlink)
|
|
||||||
# → /path/to/repo
|
|
||||||
# ✓ Git repo is clean (branch: main)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plugin Status
|
|
||||||
|
|
||||||
Check plugin status and git information:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/dev/dev_plugin_setup.sh status
|
|
||||||
|
|
||||||
# Output shows:
|
|
||||||
# ✓ plugin-name
|
|
||||||
# Path: /path/to/repo
|
|
||||||
# Branch: main
|
|
||||||
# Remote: https://github.com/user/repo.git
|
|
||||||
# Status: Clean and up to date
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Plugin Development Workflow
|
|
||||||
|
|
||||||
### 1. Initial Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create or clone plugin repository
|
|
||||||
git clone https://github.com/user/ledmatrix-my-plugin.git
|
|
||||||
cd ledmatrix-my-plugin
|
|
||||||
|
|
||||||
# Link to LEDMatrix project
|
|
||||||
cd /path/to/LEDMatrix
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Development Cycle
|
|
||||||
|
|
||||||
1. **Edit plugin code** in linked repository
|
|
||||||
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
|
|
||||||
4. **Update configuration** in `config/config.json` if needed
|
|
||||||
5. **Iterate** until plugin works correctly
|
|
||||||
|
|
||||||
### 3. Testing on Hardware
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Deploy to Raspberry Pi
|
|
||||||
rsync -avz plugins/my-plugin/ ledpi@your-pi-ip:/path/to/LEDMatrix/plugins/my-plugin/
|
|
||||||
|
|
||||||
# Or if using git, pull on Pi
|
|
||||||
ssh ledpi@your-pi-ip "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
|
|
||||||
|
|
||||||
# Restart service
|
|
||||||
ssh ledpi@your-pi-ip "sudo systemctl restart ledmatrix"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Updating Plugins
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update single plugin from git
|
|
||||||
./scripts/dev/dev_plugin_setup.sh update my-plugin
|
|
||||||
|
|
||||||
# Update all linked plugins
|
|
||||||
./scripts/dev/dev_plugin_setup.sh update
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Unlinking Plugins
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Remove symlink (preserves repository)
|
|
||||||
./scripts/dev/dev_plugin_setup.sh unlink my-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Plugins
|
|
||||||
|
|
||||||
### Unit Testing
|
|
||||||
|
|
||||||
Create test files in plugin directory:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# plugins/my-plugin/test_my_plugin.py
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import Mock, MagicMock
|
|
||||||
from manager import MyPlugin
|
|
||||||
|
|
||||||
class TestMyPlugin(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.config = {"enabled": True}
|
|
||||||
self.display_manager = Mock()
|
|
||||||
self.cache_manager = Mock()
|
|
||||||
self.plugin_manager = Mock()
|
|
||||||
|
|
||||||
self.plugin = MyPlugin(
|
|
||||||
plugin_id="my-plugin",
|
|
||||||
config=self.config,
|
|
||||||
display_manager=self.display_manager,
|
|
||||||
cache_manager=self.cache_manager,
|
|
||||||
plugin_manager=self.plugin_manager
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_plugin_initialization(self):
|
|
||||||
self.assertEqual(self.plugin.plugin_id, "my-plugin")
|
|
||||||
self.assertTrue(self.plugin.enabled)
|
|
||||||
|
|
||||||
def test_config_validation(self):
|
|
||||||
self.assertTrue(self.plugin.validate_config())
|
|
||||||
|
|
||||||
def test_update(self):
|
|
||||||
self.cache_manager.get.return_value = None
|
|
||||||
self.plugin.update()
|
|
||||||
# Assert data was fetched and cached
|
|
||||||
|
|
||||||
def test_display(self):
|
|
||||||
self.plugin.display()
|
|
||||||
self.display_manager.draw_text.assert_called()
|
|
||||||
self.display_manager.update_display.assert_called()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
```
|
|
||||||
|
|
||||||
Run tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd plugins/my-plugin
|
|
||||||
python -m pytest test_my_plugin.py
|
|
||||||
# or
|
|
||||||
python test_my_plugin.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration Testing
|
|
||||||
|
|
||||||
Test plugin with actual managers:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# test_plugin_integration.py
|
|
||||||
from src.plugin_system.plugin_manager import PluginManager
|
|
||||||
from src.config_manager import ConfigManager
|
|
||||||
from src.display_manager import DisplayManager
|
|
||||||
from src.cache_manager import CacheManager
|
|
||||||
|
|
||||||
def test_plugin_loading():
|
|
||||||
config_manager = ConfigManager()
|
|
||||||
config = config_manager.load_config()
|
|
||||||
display_manager = DisplayManager(config)
|
|
||||||
cache_manager = CacheManager()
|
|
||||||
|
|
||||||
plugin_manager = PluginManager(
|
|
||||||
plugins_dir="plugins",
|
|
||||||
config_manager=config_manager,
|
|
||||||
display_manager=display_manager,
|
|
||||||
cache_manager=cache_manager
|
|
||||||
)
|
|
||||||
|
|
||||||
plugins = plugin_manager.discover_plugins()
|
|
||||||
assert "my-plugin" in plugins
|
|
||||||
|
|
||||||
assert plugin_manager.load_plugin("my-plugin")
|
|
||||||
plugin = plugin_manager.get_plugin("my-plugin")
|
|
||||||
assert plugin is not None
|
|
||||||
assert plugin.enabled
|
|
||||||
|
|
||||||
plugin.update()
|
|
||||||
plugin.display()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Emulator Testing
|
|
||||||
|
|
||||||
Test plugin rendering visually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run with emulator
|
|
||||||
python run.py --emulator
|
|
||||||
|
|
||||||
# Plugin should appear in display rotation
|
|
||||||
# Check logs for plugin loading and execution
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hardware Testing
|
|
||||||
|
|
||||||
1. Deploy plugin to Raspberry Pi
|
|
||||||
2. Enable in `config/config.json`
|
|
||||||
3. Restart LEDMatrix service
|
|
||||||
4. Observe LED matrix display
|
|
||||||
5. Check logs: `journalctl -u ledmatrix -f`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Plugin Not Loading
|
|
||||||
|
|
||||||
**Symptoms**: Plugin doesn't appear in available modes, no logs about plugin
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Check plugin directory exists: `ls plugins/my-plugin/`
|
|
||||||
2. Verify `manifest.json` exists and is valid JSON
|
|
||||||
3. Check manifest has required fields: `id`, `entry_point`, `class_name`
|
|
||||||
4. Verify entry_point file exists: `ls plugins/my-plugin/manager.py`
|
|
||||||
5. Check class name matches: `grep "class.*Plugin" plugins/my-plugin/manager.py`
|
|
||||||
6. Review logs for import errors
|
|
||||||
|
|
||||||
### Plugin Loading but Not Displaying
|
|
||||||
|
|
||||||
**Symptoms**: Plugin loads successfully but doesn't appear in rotation
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Check plugin is enabled: `config/config.json` has `"enabled": true`
|
|
||||||
2. Verify display_modes in manifest match config
|
|
||||||
3. Check plugin is in rotation schedule
|
|
||||||
4. Review `display()` method for errors
|
|
||||||
5. Check logs for runtime errors
|
|
||||||
|
|
||||||
### Configuration Errors
|
|
||||||
|
|
||||||
**Symptoms**: Plugin fails to load, validation errors in logs
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Validate config against `config_schema.json`
|
|
||||||
2. Check required fields are present
|
|
||||||
3. Verify data types match schema
|
|
||||||
4. Check for typos in config keys
|
|
||||||
5. Review `validate_config()` method
|
|
||||||
|
|
||||||
### Import Errors
|
|
||||||
|
|
||||||
**Symptoms**: ModuleNotFoundError or ImportError in logs
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Install plugin dependencies: `pip install -r plugins/my-plugin/requirements.txt`
|
|
||||||
2. Check Python path includes plugin directory
|
|
||||||
3. Verify relative imports are correct
|
|
||||||
4. Check for circular import issues
|
|
||||||
5. Ensure all dependencies are in requirements.txt
|
|
||||||
|
|
||||||
### Display Issues
|
|
||||||
|
|
||||||
**Symptoms**: Plugin renders incorrectly or not at all
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Check display dimensions: `display_manager.width`, `display_manager.height`
|
|
||||||
2. Verify coordinates are within display bounds
|
|
||||||
3. Check color values are valid (0-255)
|
|
||||||
4. Ensure `update_display()` is called after rendering
|
|
||||||
5. Test with emulator first to debug rendering
|
|
||||||
|
|
||||||
### Performance Issues
|
|
||||||
|
|
||||||
**Symptoms**: Slow display updates, high CPU usage
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Use `cache_manager` to avoid excessive API calls
|
|
||||||
2. Implement background data fetching
|
|
||||||
3. Optimize rendering code
|
|
||||||
4. Consider using `high_performance_transitions`
|
|
||||||
5. Profile plugin code to identify bottlenecks
|
|
||||||
|
|
||||||
### Git/Symlink Issues
|
|
||||||
|
|
||||||
**Symptoms**: Plugin changes not appearing, broken symlinks
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Check symlink: `ls -la plugins/my-plugin`
|
|
||||||
2. Verify target exists: `readlink -f plugins/my-plugin`
|
|
||||||
3. Update plugin: `./scripts/dev/dev_plugin_setup.sh update my-plugin`
|
|
||||||
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`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
|
|
||||||
- Keep plugin code in `plugins/<plugin-id>/` directory
|
|
||||||
- Use descriptive class and method names
|
|
||||||
- Follow existing plugin patterns
|
|
||||||
- Place shared utilities in `src/common/` if reusable
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
- Always use `config_schema.json` for validation
|
|
||||||
- Store secrets in `config_secrets.json`
|
|
||||||
- Provide sensible defaults
|
|
||||||
- Document all configuration options in README
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- Use plugin logger for all logging
|
|
||||||
- Handle API failures gracefully
|
|
||||||
- Provide fallback displays when data unavailable
|
|
||||||
- Cache data to avoid excessive requests
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- Cache API responses appropriately
|
|
||||||
- Use background data fetching for long operations
|
|
||||||
- Optimize rendering for Pi's limited resources
|
|
||||||
- Test performance on actual hardware
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- Write unit tests for core logic
|
|
||||||
- Test with emulator before hardware
|
|
||||||
- Test on Raspberry Pi before deploying
|
|
||||||
- Test with other plugins enabled
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Document plugin functionality in README
|
|
||||||
- Include configuration examples
|
|
||||||
- Document API requirements and rate limits
|
|
||||||
- Provide usage examples
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- **Plugin System Documentation**: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
|
||||||
- **Base Plugin Class**: `src/plugin_system/base_plugin.py`
|
|
||||||
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
|
|
||||||
- **Example Plugins**:
|
|
||||||
- `plugins/hockey-scoreboard/` - Sports scoreboard example
|
|
||||||
- `plugins/football-scoreboard/` - Complex multi-league example
|
|
||||||
- `plugins/ledmatrix-music/` - Real-time data example
|
|
||||||
- **Development Setup**: `dev_plugin_setup.sh`
|
|
||||||
- **Example Config**: `dev_plugins.json.example`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### Common Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Link plugin from GitHub
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link-github <name>
|
|
||||||
|
|
||||||
# Link local plugin
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link <name> <path>
|
|
||||||
|
|
||||||
# List all plugins
|
|
||||||
./scripts/dev/dev_plugin_setup.sh list
|
|
||||||
|
|
||||||
# Check plugin status
|
|
||||||
./scripts/dev/dev_plugin_setup.sh status
|
|
||||||
|
|
||||||
# Update plugin(s)
|
|
||||||
./scripts/dev/dev_plugin_setup.sh update [name]
|
|
||||||
|
|
||||||
# Unlink plugin
|
|
||||||
./scripts/dev/dev_plugin_setup.sh unlink <name>
|
|
||||||
|
|
||||||
# Run with emulator
|
|
||||||
python run.py --emulator
|
|
||||||
|
|
||||||
# Run on Pi
|
|
||||||
python run.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plugin File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
plugins/my-plugin/
|
|
||||||
├── manifest.json # Required: Plugin metadata
|
|
||||||
├── manager.py # Required: Plugin class
|
|
||||||
├── config_schema.json # Required: Config validation
|
|
||||||
├── requirements.txt # Optional: Dependencies
|
|
||||||
├── README.md # Optional: Documentation
|
|
||||||
└── ... # Plugin-specific files
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required Manifest Fields
|
|
||||||
|
|
||||||
- `id`: Plugin identifier
|
|
||||||
- `entry_point`: Python file (usually "manager.py")
|
|
||||||
- `class_name`: Plugin class name
|
|
||||||
- `display_modes`: Array of mode names
|
|
||||||
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
globs: *.py
|
|
||||||
---
|
|
||||||
|
|
||||||
# Python Coding Standards
|
|
||||||
|
|
||||||
## Code Quality Principles
|
|
||||||
- **Simplicity First**: Prefer clear, readable code over clever optimizations
|
|
||||||
- **Explicit over Implicit**: Make intentions clear through naming and structure
|
|
||||||
- **Fail Fast**: Validate inputs and handle errors early
|
|
||||||
- **Documentation**: Use docstrings for classes and complex functions
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
- **Classes**: PascalCase (e.g., `NHLRecentManager`)
|
|
||||||
- **Functions/Variables**: snake_case (e.g., `fetch_game_data`)
|
|
||||||
- **Constants**: UPPER_SNAKE_CASE (e.g., `ESPN_NHL_SCOREBOARD_URL`)
|
|
||||||
- **Private methods**: Leading underscore (e.g., `_fetch_data`)
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
- **Logging**: Use structured logging with context (e.g., `[NHL Recent]`)
|
|
||||||
- **Exceptions**: Catch specific exceptions, not bare `except:`
|
|
||||||
- **User-friendly messages**: Explain what went wrong and potential solutions
|
|
||||||
- **Graceful degradation**: Continue operation when non-critical features fail
|
|
||||||
|
|
||||||
## Manager Pattern
|
|
||||||
All sports managers should follow this structure:
|
|
||||||
```python
|
|
||||||
class BaseManager:
|
|
||||||
def __init__(self, config, display_manager, cache_manager)
|
|
||||||
def update(self) # Fetch and process data
|
|
||||||
def display(self, force_clear=False) # Render to display
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Management
|
|
||||||
- **Type hints**: Use for function parameters and return values
|
|
||||||
- **Configuration validation**: Check required fields on initialization
|
|
||||||
- **Default values**: Provide sensible defaults in code, not config
|
|
||||||
- **Environment awareness**: Handle different deployment contexts
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
globs: config/*.json,src/*.py
|
|
||||||
---
|
|
||||||
|
|
||||||
# Configuration Management
|
|
||||||
|
|
||||||
## Configuration Structure
|
|
||||||
- **Main config**: [config/config.json](mdc:config/config.json) - Primary configuration
|
|
||||||
- **Secrets**: [config/config_secrets.json](mdc:config/config_secrets.json) - API keys and sensitive data
|
|
||||||
- **Templates**: [config/config.template.json](mdc:config/config.template.json) - Default values
|
|
||||||
|
|
||||||
## Configuration Principles
|
|
||||||
- **Validation**: Check required fields and data types on startup
|
|
||||||
- **Defaults**: Provide sensible defaults in code, not just config
|
|
||||||
- **Environment awareness**: Handle development vs production differences
|
|
||||||
- **Security**: Never commit secrets to version control
|
|
||||||
|
|
||||||
## Manager Configuration Pattern
|
|
||||||
```python
|
|
||||||
def __init__(self, config, display_manager, cache_manager):
|
|
||||||
self.mode_config = config.get("sport_scoreboard", {})
|
|
||||||
self.favorite_teams = self.mode_config.get("favorite_teams", [])
|
|
||||||
self.show_favorite_only = self.mode_config.get("show_favorite_teams_only", False)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Required Configuration Sections
|
|
||||||
- **Display settings**: Update intervals, display durations
|
|
||||||
- **API settings**: Timeouts, retry logic, rate limiting
|
|
||||||
- **Background service**: Threading, caching, priority settings
|
|
||||||
- **Team preferences**: Favorite teams, filtering options
|
|
||||||
|
|
||||||
## Configuration Validation
|
|
||||||
- **Type checking**: Ensure numeric values are numbers, lists are lists
|
|
||||||
- **Range validation**: Check that intervals are reasonable
|
|
||||||
- **Dependency checking**: Verify required services are available
|
|
||||||
- **Fallback values**: Provide defaults when config is missing or invalid
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
- **Documentation**: Comment complex configuration options
|
|
||||||
- **Examples**: Provide working examples in templates
|
|
||||||
- **Migration**: Handle configuration changes between versions
|
|
||||||
- **Testing**: Validate configuration in test environments
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
globs: src/*.py
|
|
||||||
---
|
|
||||||
|
|
||||||
# Error Handling and Logging
|
|
||||||
|
|
||||||
## Logging Standards
|
|
||||||
- **Structured prefixes**: Use consistent tags like `[NHL Recent]`, `[NFL Live]`
|
|
||||||
- **Context information**: Include relevant details (team names, game status, dates)
|
|
||||||
- **Appropriate levels**:
|
|
||||||
- `info`: Normal operations and status updates
|
|
||||||
- `debug`: Detailed information for troubleshooting
|
|
||||||
- `warning`: Non-critical issues that should be noted
|
|
||||||
- `error`: Problems that need attention
|
|
||||||
|
|
||||||
## Error Handling Patterns
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
data = self._fetch_data()
|
|
||||||
if not data or 'events' not in data:
|
|
||||||
self.logger.warning("[Manager] No events found in API response")
|
|
||||||
return
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
self.logger.error(f"[Manager] API error: {e}")
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
## User-Friendly Messages
|
|
||||||
- **Explain the situation**: "No games available during off-season"
|
|
||||||
- **Provide context**: "NHL season typically runs October-June"
|
|
||||||
- **Suggest solutions**: "Check back when season starts"
|
|
||||||
- **Distinguish issues**: API problems vs no data vs filtering results
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
- **Fallback content**: Show alternative games when favorites unavailable
|
|
||||||
- **Cached data**: Use cached data when API fails
|
|
||||||
- **Service continuity**: Continue operation when non-critical features fail
|
|
||||||
- **Clear communication**: Explain what's happening to users
|
|
||||||
|
|
||||||
## Debugging Support
|
|
||||||
- **Comprehensive logging**: Log API responses, filtering results, display updates
|
|
||||||
- **State tracking**: Log current state and transitions
|
|
||||||
- **Performance monitoring**: Track timing and resource usage
|
|
||||||
- **Error context**: Include stack traces for debugging
|
|
||||||
|
|
||||||
## Off-Season Awareness
|
|
||||||
- **Seasonal messaging**: Different messages for different times of year
|
|
||||||
- **Helpful context**: Explain why no games are available
|
|
||||||
- **Future planning**: Mention when season starts
|
|
||||||
- **Realistic expectations**: Set appropriate expectations during off-season
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# Git Workflow and Branching
|
|
||||||
|
|
||||||
## Branch Naming Conventions
|
|
||||||
- **Features**: `feature/description-of-feature` (e.g., `feature/weather-forecast-improvements`)
|
|
||||||
- **Bug fixes**: `fix/description-of-bug` (e.g., `fix/nhl-manager-improvements`)
|
|
||||||
- **Hotfixes**: `hotfix/critical-issue-description`
|
|
||||||
- **Refactoring**: `refactor/description-of-refactor`
|
|
||||||
|
|
||||||
## Commit Message Format
|
|
||||||
```
|
|
||||||
type(scope): description
|
|
||||||
|
|
||||||
[optional body]
|
|
||||||
|
|
||||||
[optional footer]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Types**: feat, fix, docs, style, refactor, test, chore
|
|
||||||
**Examples**:
|
|
||||||
- `feat(nhl): Add enhanced logging for data visibility`
|
|
||||||
- `fix(display): Resolve rendering performance issue`
|
|
||||||
- `docs(api): Update ESPN API integration guide`
|
|
||||||
|
|
||||||
## Pull Request Guidelines
|
|
||||||
- **Self-review**: Review your own PR before requesting review
|
|
||||||
- **Testing**: Test thoroughly on Raspberry Pi hardware
|
|
||||||
- **Documentation**: Update relevant documentation if needed
|
|
||||||
- **Clean history**: Squash commits if necessary for clean history
|
|
||||||
|
|
||||||
## Code Review Checklist
|
|
||||||
- **Code Quality**: Proper error handling, logging, type hints
|
|
||||||
- **Architecture**: Follows project patterns, doesn't break existing functionality
|
|
||||||
- **Performance**: No negative impact on display performance
|
|
||||||
- **Testing**: Works on Raspberry Pi hardware
|
|
||||||
- **Documentation**: Comments added for complex logic
|
|
||||||
|
|
||||||
## Merge Strategies
|
|
||||||
- **Squash and Merge**: Preferred for feature branches and bug fixes
|
|
||||||
- **Merge Commit**: For complex features with multiple logical commits
|
|
||||||
- **Rebase and Merge**: For simple, single-commit changes
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
- **Keep branches small and focused**
|
|
||||||
- **Commit frequently with meaningful messages**
|
|
||||||
- **Update branch regularly with main**
|
|
||||||
- **Test changes incrementally**
|
|
||||||
- **Delete feature branches after merge**
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
---
|
|
||||||
description: GitHub branching and pull request best practices for LEDMatrix project
|
|
||||||
globs: ["**/*.py", "**/*.md", "**/*.json", "**/*.sh"]
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# GitHub Branching and Pull Request Guidelines
|
|
||||||
|
|
||||||
## Branch Naming Conventions
|
|
||||||
|
|
||||||
### Feature Branches
|
|
||||||
- **Format**: `feature/description-of-feature`
|
|
||||||
- **Examples**:
|
|
||||||
- `feature/weather-forecast-improvements`
|
|
||||||
- `feature/stock-api-integration`
|
|
||||||
- `feature/nba-live-scores`
|
|
||||||
|
|
||||||
### Bug Fix Branches
|
|
||||||
- **Format**: `fix/description-of-bug`
|
|
||||||
- **Examples**:
|
|
||||||
- `fix/leaderboard-scrolling-performance`
|
|
||||||
- `fix/weather-api-timeout`
|
|
||||||
- `fix/display-rendering-issue`
|
|
||||||
|
|
||||||
### Hotfix Branches
|
|
||||||
- **Format**: `hotfix/critical-issue-description`
|
|
||||||
- **Examples**:
|
|
||||||
- `hotfix/display-crash-fix`
|
|
||||||
- `hotfix/api-rate-limit-fix`
|
|
||||||
|
|
||||||
### Refactoring Branches
|
|
||||||
- **Format**: `refactor/description-of-refactor`
|
|
||||||
- **Examples**:
|
|
||||||
- `refactor/sports-manager-architecture`
|
|
||||||
- `refactor/cache-management-system`
|
|
||||||
|
|
||||||
## Branch Management Rules
|
|
||||||
|
|
||||||
### Main Branch Protection
|
|
||||||
- **`main`** branch is protected and requires PR reviews
|
|
||||||
- Never commit directly to `main`
|
|
||||||
- All changes must go through pull requests
|
|
||||||
|
|
||||||
### Branch Lifecycle
|
|
||||||
1. **Create** branch from `main` when starting work
|
|
||||||
2. **Keep** branch up-to-date with `main` regularly
|
|
||||||
3. **Test** thoroughly before creating PR
|
|
||||||
4. **Delete** branch after successful merge
|
|
||||||
|
|
||||||
### Branch Updates
|
|
||||||
```bash
|
|
||||||
# Before starting new work
|
|
||||||
git checkout main
|
|
||||||
git pull origin main
|
|
||||||
|
|
||||||
# Create new branch
|
|
||||||
git checkout -b feature/your-feature-name
|
|
||||||
|
|
||||||
# Keep branch updated during development
|
|
||||||
git checkout main
|
|
||||||
git pull origin main
|
|
||||||
git checkout feature/your-feature-name
|
|
||||||
git merge main
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pull Request Guidelines
|
|
||||||
|
|
||||||
### PR Title Format
|
|
||||||
- **Feature**: `feat: Add weather forecast improvements`
|
|
||||||
- **Fix**: `fix: Resolve leaderboard scrolling performance issue`
|
|
||||||
- **Refactor**: `refactor: Improve sports manager architecture`
|
|
||||||
- **Docs**: `docs: Update API integration guide`
|
|
||||||
- **Test**: `test: Add unit tests for weather manager`
|
|
||||||
|
|
||||||
### PR Description Template
|
|
||||||
```markdown
|
|
||||||
## Description
|
|
||||||
Brief description of changes and motivation.
|
|
||||||
|
|
||||||
## Type of Change
|
|
||||||
- [ ] Bug fix (non-breaking change)
|
|
||||||
- [ ] New feature (non-breaking change)
|
|
||||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
|
||||||
- [ ] Documentation update
|
|
||||||
- [ ] Performance improvement
|
|
||||||
- [ ] Refactoring
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- [ ] Tested on Raspberry Pi hardware
|
|
||||||
- [ ] Verified display rendering works correctly
|
|
||||||
- [ ] Checked API integration functionality
|
|
||||||
- [ ] Tested error handling scenarios
|
|
||||||
|
|
||||||
## Screenshots/Videos
|
|
||||||
(If applicable, add screenshots or videos of the changes)
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
- [ ] Code follows project style guidelines
|
|
||||||
- [ ] Self-review completed
|
|
||||||
- [ ] Comments added for complex logic
|
|
||||||
- [ ] No hardcoded values or API keys
|
|
||||||
- [ ] Error handling implemented
|
|
||||||
- [ ] Logging added where appropriate
|
|
||||||
```
|
|
||||||
|
|
||||||
### PR Review Requirements
|
|
||||||
|
|
||||||
#### For Reviewers
|
|
||||||
- **Code Quality**: Check for proper error handling, logging, and type hints
|
|
||||||
- **Architecture**: Ensure changes follow project patterns and don't break existing functionality
|
|
||||||
- **Performance**: Verify changes don't negatively impact display performance
|
|
||||||
- **Testing**: Confirm changes work on Raspberry Pi hardware
|
|
||||||
- **Documentation**: Check if documentation needs updates
|
|
||||||
|
|
||||||
#### For Authors
|
|
||||||
- **Self-Review**: Review your own PR before requesting review
|
|
||||||
- **Testing**: Test thoroughly on Pi hardware before submitting
|
|
||||||
- **Documentation**: Update relevant documentation if needed
|
|
||||||
- **Clean History**: Squash commits if necessary for clean history
|
|
||||||
|
|
||||||
## Commit Message Guidelines
|
|
||||||
|
|
||||||
### Format
|
|
||||||
```
|
|
||||||
type(scope): description
|
|
||||||
|
|
||||||
[optional body]
|
|
||||||
|
|
||||||
[optional footer]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Types
|
|
||||||
- **feat**: New feature
|
|
||||||
- **fix**: Bug fix
|
|
||||||
- **docs**: Documentation changes
|
|
||||||
- **style**: Code style changes (formatting, etc.)
|
|
||||||
- **refactor**: Code refactoring
|
|
||||||
- **test**: Adding or updating tests
|
|
||||||
- **chore**: Maintenance tasks
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
```
|
|
||||||
feat(weather): Add hourly forecast display
|
|
||||||
fix(nba): Resolve live score update issue
|
|
||||||
docs(api): Update ESPN API integration guide
|
|
||||||
refactor(sports): Improve base class architecture
|
|
||||||
```
|
|
||||||
|
|
||||||
## Merge Strategies
|
|
||||||
|
|
||||||
### Squash and Merge (Preferred)
|
|
||||||
- Use for feature branches and bug fixes
|
|
||||||
- Creates clean, linear history
|
|
||||||
- Combines all commits into single commit
|
|
||||||
|
|
||||||
### Merge Commit
|
|
||||||
- Use for complex features with multiple logical commits
|
|
||||||
- Preserves commit history
|
|
||||||
- Use when commit messages are meaningful
|
|
||||||
|
|
||||||
### Rebase and Merge
|
|
||||||
- Use sparingly for simple, single-commit changes
|
|
||||||
- Creates linear history without merge commits
|
|
||||||
|
|
||||||
## Release Management
|
|
||||||
|
|
||||||
### Version Tags
|
|
||||||
- Use semantic versioning: `v1.2.3`
|
|
||||||
- Tag releases on `main` branch
|
|
||||||
- Create release notes with technical details
|
|
||||||
|
|
||||||
### Release Branches
|
|
||||||
- **Format**: `release/v1.2.3`
|
|
||||||
- Use for release preparation
|
|
||||||
- Include version bumps and final testing
|
|
||||||
|
|
||||||
## Emergency Procedures
|
|
||||||
|
|
||||||
### Hotfix Process
|
|
||||||
1. Create `hotfix/` branch from `main`
|
|
||||||
2. Make minimal fix
|
|
||||||
3. Test thoroughly
|
|
||||||
4. Create PR with expedited review
|
|
||||||
5. Merge to `main` and tag release
|
|
||||||
6. Cherry-pick to other branches if needed
|
|
||||||
|
|
||||||
### Rollback Process
|
|
||||||
1. Identify last known good commit
|
|
||||||
2. Create revert PR if possible
|
|
||||||
3. Use `git revert` for clean rollback
|
|
||||||
4. Tag rollback release
|
|
||||||
5. Document issue and resolution
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Before Creating PR
|
|
||||||
- [ ] Run all tests locally
|
|
||||||
- [ ] Test on Raspberry Pi hardware
|
|
||||||
- [ ] Check for linting errors
|
|
||||||
- [ ] Update documentation if needed
|
|
||||||
- [ ] Ensure commit messages are clear
|
|
||||||
|
|
||||||
### During Development
|
|
||||||
- [ ] Keep branches small and focused
|
|
||||||
- [ ] Commit frequently with meaningful messages
|
|
||||||
- [ ] Update branch regularly with main
|
|
||||||
- [ ] Test changes incrementally
|
|
||||||
|
|
||||||
### After PR Approval
|
|
||||||
- [ ] Delete feature branch after merge
|
|
||||||
- [ ] Update local main branch
|
|
||||||
- [ ] Verify changes work in production
|
|
||||||
- [ ] Update any related documentation
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# LEDMatrix Project Structure
|
|
||||||
|
|
||||||
## Core Architecture
|
|
||||||
- **Main entry point**: [run.py](mdc:run.py) - Primary application launcher
|
|
||||||
- **Configuration**: [config/config.json](mdc:config/config.json) - Main configuration file
|
|
||||||
- **Display management**: [src/display_controller.py](mdc:src/display_controller.py) - Core display logic
|
|
||||||
- **Web interface**: [web_interface_v2.py](mdc:web_interface_v2.py) - Modern web UI
|
|
||||||
|
|
||||||
## Source Code Organization
|
|
||||||
- **Managers**: [src/](mdc:src/) - All sports/weather/stock managers
|
|
||||||
- **Assets**: [assets/](mdc:assets/) - Logos, fonts, and static resources
|
|
||||||
- **Tests**: [test/](mdc:test/) - Unit and integration tests
|
|
||||||
- **Documentation**: [LEDMatrix.wiki/](mdc:LEDMatrix.wiki/) - Comprehensive guides
|
|
||||||
|
|
||||||
## Key Design Principles
|
|
||||||
- **Single Responsibility**: Each manager handles one sport/domain
|
|
||||||
- **Consistent Patterns**: All managers follow similar structure
|
|
||||||
- **Configuration-Driven**: Behavior controlled via [config/config.json](mdc:config/config.json)
|
|
||||||
- **Raspberry Pi Focus**: Optimized for Pi hardware, not Windows development
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# Raspberry Pi Development Guidelines
|
|
||||||
|
|
||||||
## Hardware Constraints
|
|
||||||
- **Pi-only execution**: Code must run on Raspberry Pi, not Windows development machine
|
|
||||||
- **LED matrix library**: Uses [rpi-rgb-led-matrix-master/](mdc:rpi-rgb-led-matrix-master/) for hardware control
|
|
||||||
- **Memory limitations**: Optimize for Pi's limited RAM
|
|
||||||
- **Performance**: Consider Pi's CPU capabilities in design
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
- **Local development**: Write and test code on Windows
|
|
||||||
- **Pi deployment**: Deploy and test on actual Pi hardware
|
|
||||||
- **SSH access**: Use SSH for Pi-based testing and debugging
|
|
||||||
- **Service management**: Use systemd services for production deployment
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
- **Unit tests**: Test logic without hardware dependencies
|
|
||||||
- **Integration tests**: Test with mock display managers
|
|
||||||
- **Hardware tests**: Validate on actual Pi with LED matrix
|
|
||||||
- **Performance tests**: Monitor memory and CPU usage
|
|
||||||
|
|
||||||
## Deployment Considerations
|
|
||||||
- **Service files**: [ledmatrix.service](mdc:ledmatrix.service), [ledmatrix-web.service](mdc:ledmatrix-web.service)
|
|
||||||
- **Installation scripts**: [first_time_install.sh](mdc:first_time_install.sh), [install_service.sh](mdc:install_service.sh)
|
|
||||||
- **Dependencies**: [requirements.txt](mdc:requirements.txt) for Pi environment
|
|
||||||
- **Permissions**: Handle file permissions for Pi user
|
|
||||||
|
|
||||||
## Performance Optimization
|
|
||||||
- **Caching**: Use [src/cache_manager.py](mdc:src/cache_manager.py) for data persistence
|
|
||||||
- **Background services**: Non-blocking data fetching
|
|
||||||
- **Memory management**: Clean up resources regularly
|
|
||||||
- **Display optimization**: Minimize unnecessary redraws
|
|
||||||
|
|
||||||
## Debugging on Pi
|
|
||||||
- **Logging**: Comprehensive logging for remote debugging
|
|
||||||
- **Error reporting**: Clear error messages for troubleshooting
|
|
||||||
- **Status monitoring**: Health checks and status reporting
|
|
||||||
- **Remote access**: Web interface for configuration and monitoring
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
globs: src/*_managers.py
|
|
||||||
---
|
|
||||||
|
|
||||||
# Sports Manager Development
|
|
||||||
|
|
||||||
## Manager Architecture
|
|
||||||
All sports managers inherit from base classes and follow consistent patterns:
|
|
||||||
- **Base classes**: [src/nhl_managers.py](mdc:src/nhl_managers.py), [src/nfl_managers.py](mdc:src/nfl_managers.py)
|
|
||||||
- **Common functionality**: Data fetching, caching, display rendering
|
|
||||||
- **Configuration-driven**: Behavior controlled via config sections
|
|
||||||
|
|
||||||
## Required Methods
|
|
||||||
```python
|
|
||||||
def __init__(self, config, display_manager, cache_manager)
|
|
||||||
def update(self) # Fetch fresh data
|
|
||||||
def display(self, force_clear=False) # Render current data
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow Pattern
|
|
||||||
1. **Fetch**: Get data from API (with caching)
|
|
||||||
2. **Process**: Extract relevant game information
|
|
||||||
3. **Filter**: Apply favorite team preferences
|
|
||||||
4. **Display**: Render to LED matrix
|
|
||||||
|
|
||||||
## Logging Standards
|
|
||||||
- **Structured prefixes**: `[NHL Recent]`, `[NFL Live]`, etc.
|
|
||||||
- **Context information**: Include team names, game status, dates
|
|
||||||
- **Debug levels**: Use appropriate log levels (info, debug, warning, error)
|
|
||||||
- **User-friendly messages**: Explain what's happening and why
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
- **API failures**: Log and continue with cached data if available
|
|
||||||
- **No data scenarios**: Distinguish between API issues vs no games available
|
|
||||||
- **Off-season awareness**: Provide helpful context during non-active periods
|
|
||||||
- **Fallback behavior**: Show alternative content when preferred content unavailable
|
|
||||||
|
|
||||||
## Configuration Integration
|
|
||||||
- **Required settings**: Validate on initialization
|
|
||||||
- **Optional settings**: Provide sensible defaults
|
|
||||||
- **Background service**: Use for non-blocking data fetching
|
|
||||||
- **Caching strategy**: Implement intelligent cache management
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
globs: test/*.py,src/*.py
|
|
||||||
---
|
|
||||||
|
|
||||||
# Testing Standards
|
|
||||||
|
|
||||||
## Test Organization
|
|
||||||
- **Test directory**: [test/](mdc:test/) - All test files
|
|
||||||
- **Unit tests**: Test individual components in isolation
|
|
||||||
- **Integration tests**: Test component interactions
|
|
||||||
- **Hardware tests**: Validate on Raspberry Pi with actual LED matrix
|
|
||||||
|
|
||||||
## Testing Principles
|
|
||||||
- **Test behavior, not implementation**: Focus on what the code does, not how
|
|
||||||
- **Mock external dependencies**: Use mocks for APIs, display managers, cache
|
|
||||||
- **Test edge cases**: Empty data, API failures, configuration errors
|
|
||||||
- **Pi-specific testing**: Validate hardware integration
|
|
||||||
|
|
||||||
## Test Structure
|
|
||||||
```python
|
|
||||||
def test_manager_initialization():
|
|
||||||
"""Test that manager initializes with valid config"""
|
|
||||||
config = {"sport_scoreboard": {"enabled": True}}
|
|
||||||
manager = ManagerClass(config, mock_display, mock_cache)
|
|
||||||
assert manager.enabled == True
|
|
||||||
|
|
||||||
def test_api_failure_handling():
|
|
||||||
"""Test graceful handling of API failures"""
|
|
||||||
# Test that system continues when API fails
|
|
||||||
# Verify fallback to cached data
|
|
||||||
# Check appropriate error logging
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mock Patterns
|
|
||||||
- **Display Manager**: Mock for testing without hardware
|
|
||||||
- **Cache Manager**: Mock for testing data persistence
|
|
||||||
- **API responses**: Mock for consistent test data
|
|
||||||
- **Configuration**: Use test-specific configs
|
|
||||||
|
|
||||||
## Test Categories
|
|
||||||
- **Unit tests**: Individual manager methods
|
|
||||||
- **Integration tests**: Manager interactions with services
|
|
||||||
- **Configuration tests**: Validate config loading and validation
|
|
||||||
- **Error handling tests**: API failures, invalid data, edge cases
|
|
||||||
|
|
||||||
## Testing Best Practices
|
|
||||||
- **Descriptive names**: Test names should explain what they test
|
|
||||||
- **Single responsibility**: Each test should verify one thing
|
|
||||||
- **Independent tests**: Tests should not depend on each other
|
|
||||||
- **Clean setup/teardown**: Reset state between tests
|
|
||||||
- **Pi compatibility**: Ensure tests work in Pi environment
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
# LEDMatrix Plugin Development Rules
|
|
||||||
|
|
||||||
## 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 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
|
|
||||||
|
|
||||||
### Required Files
|
|
||||||
- **manifest.json**: Plugin metadata, entry point, class name, dependencies
|
|
||||||
- **manager.py**: Main plugin class (must inherit from `BasePlugin`)
|
|
||||||
- **config_schema.json**: JSON schema for plugin configuration validation
|
|
||||||
- **requirements.txt**: Python dependencies (if any)
|
|
||||||
- **README.md**: Plugin documentation
|
|
||||||
|
|
||||||
### Plugin Class Requirements
|
|
||||||
- Must inherit from `src.plugin_system.base_plugin.BasePlugin`
|
|
||||||
- Must implement `update()` method for data fetching
|
|
||||||
- Must implement `display()` method for rendering
|
|
||||||
- Should implement `validate_config()` for configuration validation
|
|
||||||
- Optional: Override `has_live_content()` for live priority features
|
|
||||||
|
|
||||||
## Plugin Development Workflow
|
|
||||||
|
|
||||||
### 1. Creating a New Plugin
|
|
||||||
|
|
||||||
**Option A: Use dev_plugin_setup.sh (Recommended)**
|
|
||||||
```bash
|
|
||||||
# Link from GitHub
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
|
||||||
|
|
||||||
# Link local repository
|
|
||||||
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option B: Manual Setup**
|
|
||||||
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
|
|
||||||
3. Create `manager.py` with plugin class
|
|
||||||
4. Add `config_schema.json` for configuration
|
|
||||||
5. Enable plugin in `config/config.json` under `"<plugin-id>": {"enabled": true}`
|
|
||||||
|
|
||||||
### 2. Plugin Configuration
|
|
||||||
|
|
||||||
Plugins are configured in `config/config.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"<plugin-id>": {
|
|
||||||
"enabled": true,
|
|
||||||
"display_duration": 15,
|
|
||||||
"live_priority": false,
|
|
||||||
"high_performance_transitions": false,
|
|
||||||
"transition": {
|
|
||||||
"type": "redraw",
|
|
||||||
"speed": 2,
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
// ... plugin-specific config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Testing Plugins
|
|
||||||
|
|
||||||
**On Development Machine:**
|
|
||||||
- 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
|
|
||||||
- Validate configuration: Ensure config matches `config_schema.json`
|
|
||||||
|
|
||||||
**On Raspberry Pi:**
|
|
||||||
- Deploy and test on actual hardware
|
|
||||||
- Monitor logs: `journalctl -u ledmatrix -f` (if running as service)
|
|
||||||
- Check plugin status in web interface
|
|
||||||
|
|
||||||
### 4. Plugin Development Best Practices
|
|
||||||
|
|
||||||
**Code Organization:**
|
|
||||||
- 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
|
|
||||||
- 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
|
|
||||||
|
|
||||||
**Configuration Management:**
|
|
||||||
- Use `config_schema.json` for validation
|
|
||||||
- Store secrets in `config/config_secrets.json` under the same plugin
|
|
||||||
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()`
|
|
||||||
|
|
||||||
**Error Handling:**
|
|
||||||
- Use plugin's logger: `self.logger.info/error/warning()`
|
|
||||||
- Handle API failures gracefully
|
|
||||||
- Cache data to avoid excessive API calls
|
|
||||||
- Provide fallback displays when data unavailable
|
|
||||||
|
|
||||||
**Performance:**
|
|
||||||
- Use `cache_manager` for API response caching
|
|
||||||
- Implement background data fetching if needed
|
|
||||||
- Use `high_performance_transitions` for smoother animations
|
|
||||||
- Optimize rendering for Pi's limited resources
|
|
||||||
|
|
||||||
**Display Rendering:**
|
|
||||||
- Use `display_manager` for all drawing operations
|
|
||||||
- Support different display sizes (check `display_manager.width/height`)
|
|
||||||
- Use `apply_transition()` for smooth transitions between displays
|
|
||||||
- Clear display before rendering: `display_manager.clear()`
|
|
||||||
- Always call `display_manager.update_display()` after rendering
|
|
||||||
|
|
||||||
## Plugin API Reference
|
|
||||||
|
|
||||||
### BasePlugin Class
|
|
||||||
Located in: `src/plugin_system/base_plugin.py`
|
|
||||||
|
|
||||||
**Required Methods:**
|
|
||||||
- `update()`: Fetch/update data (called based on `update_interval` in manifest)
|
|
||||||
- `display(force_clear=False)`: Render plugin content
|
|
||||||
|
|
||||||
**Optional Methods:**
|
|
||||||
- `validate_config()`: Validate plugin configuration
|
|
||||||
- `has_live_content()`: Return True if plugin has live/urgent content
|
|
||||||
- `get_live_modes()`: Return list of modes for live priority
|
|
||||||
- `cleanup()`: Clean up resources on unload
|
|
||||||
- `on_config_change(new_config)`: Handle config updates
|
|
||||||
- `on_enable()`: Called when plugin enabled
|
|
||||||
- `on_disable()`: Called when plugin disabled
|
|
||||||
|
|
||||||
**Available Properties:**
|
|
||||||
- `self.plugin_id`: Plugin identifier
|
|
||||||
- `self.config`: Plugin configuration dict
|
|
||||||
- `self.display_manager`: Display manager instance
|
|
||||||
- `self.cache_manager`: Cache manager instance
|
|
||||||
- `self.plugin_manager`: Plugin manager reference
|
|
||||||
- `self.logger`: Plugin-specific logger
|
|
||||||
- `self.enabled`: Boolean enabled status
|
|
||||||
- `self.transition_manager`: Transition system (if available)
|
|
||||||
|
|
||||||
### Display Manager
|
|
||||||
Located in: `src/display_manager.py`
|
|
||||||
|
|
||||||
**Key Methods:**
|
|
||||||
- `clear()`: Clear the display
|
|
||||||
- `draw_text(text, x, y, color, font, small_font, centered)`: Draw text
|
|
||||||
- `update_display()`: Push the buffer to the physical display
|
|
||||||
- `draw_weather_icon(condition, x, y, size)`: Draw a weather icon
|
|
||||||
- `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
|
|
||||||
Located in: `src/cache_manager.py`
|
|
||||||
|
|
||||||
**Key Methods:**
|
|
||||||
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
|
|
||||||
- `set(key, value, ttl=None)`: Cache a 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
|
|
||||||
|
|
||||||
Required fields in `manifest.json`:
|
|
||||||
- `id`: Unique plugin identifier (matches directory name)
|
|
||||||
- `name`: Human-readable plugin name
|
|
||||||
- `version`: Semantic version (e.g., "1.0.0")
|
|
||||||
- `entry_point`: Python file (usually "manager.py")
|
|
||||||
- `class_name`: Plugin class name (must match class in entry_point)
|
|
||||||
- `display_modes`: Array of mode names this plugin provides
|
|
||||||
|
|
||||||
Common optional fields:
|
|
||||||
- `description`: Plugin description
|
|
||||||
- `author`: Plugin author
|
|
||||||
- `homepage`: Plugin homepage URL
|
|
||||||
- `category`: Plugin category (e.g., "sports", "weather")
|
|
||||||
- `tags`: Array of tags
|
|
||||||
- `update_interval`: Seconds between update() calls (default: 60)
|
|
||||||
- `default_duration`: Default display duration (default: 15)
|
|
||||||
- `requires`: Python version, display size requirements
|
|
||||||
- `config_schema`: Path to config schema file
|
|
||||||
- `api_requirements`: API dependencies and rate limits
|
|
||||||
|
|
||||||
## Plugin Loading Process
|
|
||||||
|
|
||||||
1. **Discovery**: PluginManager scans `plugins/` directory for directories containing `manifest.json`
|
|
||||||
2. **Validation**: Validates manifest structure and required fields
|
|
||||||
3. **Loading**: Imports plugin module and instantiates plugin class
|
|
||||||
4. **Configuration**: Loads plugin config from `config/config.json`
|
|
||||||
5. **Validation**: Calls `validate_config()` on plugin instance
|
|
||||||
6. **Registration**: Adds plugin to available modes and stores instance
|
|
||||||
7. **Enablement**: Calls `on_enable()` if plugin is enabled
|
|
||||||
|
|
||||||
## Common Plugin Patterns
|
|
||||||
|
|
||||||
### Sports Scoreboard Plugin
|
|
||||||
- Use `background_data_service.py` pattern for API fetching
|
|
||||||
- Implement live/recent/upcoming game modes
|
|
||||||
- Use `scoreboard_renderer.py` for consistent rendering
|
|
||||||
- Support team filtering and game filtering
|
|
||||||
- Use shared sports logos from `assets/sports/`
|
|
||||||
|
|
||||||
### Data Display Plugin
|
|
||||||
- Fetch data in `update()` method
|
|
||||||
- Cache API responses using `cache_manager`
|
|
||||||
- Render in `display()` method
|
|
||||||
- Handle API errors gracefully
|
|
||||||
- Provide configuration for refresh intervals
|
|
||||||
|
|
||||||
### Real-time Content Plugin
|
|
||||||
- Implement `has_live_content()` for live priority
|
|
||||||
- Use `get_live_modes()` to specify which modes are live
|
|
||||||
- Set `live_priority: true` in config to enable live takeover
|
|
||||||
- Update data frequently when live content exists
|
|
||||||
|
|
||||||
## Debugging Plugins
|
|
||||||
|
|
||||||
**Check Plugin Loading:**
|
|
||||||
- Review logs for plugin discovery messages
|
|
||||||
- Verify manifest.json syntax is valid JSON
|
|
||||||
- Check that class_name matches actual class name
|
|
||||||
- Ensure entry_point file exists and is importable
|
|
||||||
|
|
||||||
**Check Plugin Execution:**
|
|
||||||
- Add logging statements in `update()` and `display()`
|
|
||||||
- Use `self.logger` for plugin-specific logging
|
|
||||||
- Check cache_manager for cached data
|
|
||||||
- Verify display_manager is rendering correctly
|
|
||||||
|
|
||||||
**Common Issues:**
|
|
||||||
- Import errors: Check Python path and dependencies
|
|
||||||
- Config errors: Validate against config_schema.json
|
|
||||||
- Display issues: Check display dimensions and coordinate calculations
|
|
||||||
- Performance: Monitor CPU/memory usage on Pi
|
|
||||||
|
|
||||||
## Plugin Testing
|
|
||||||
|
|
||||||
**Unit Tests:**
|
|
||||||
- Test plugin class instantiation
|
|
||||||
- Test `update()` data fetching logic
|
|
||||||
- Test `display()` rendering logic
|
|
||||||
- Test `validate_config()` with various configs
|
|
||||||
- Mock `display_manager` and `cache_manager` for testing
|
|
||||||
|
|
||||||
**Integration Tests:**
|
|
||||||
- Test plugin loading via PluginManager
|
|
||||||
- Test plugin with actual config
|
|
||||||
- Test plugin with emulator display
|
|
||||||
- Test plugin with cache_manager
|
|
||||||
|
|
||||||
**Hardware Tests:**
|
|
||||||
- Test on Raspberry Pi with LED matrix
|
|
||||||
- Verify display rendering on actual hardware
|
|
||||||
- Test performance under load
|
|
||||||
- Test with other plugins enabled
|
|
||||||
|
|
||||||
## File Organization
|
|
||||||
|
|
||||||
```
|
|
||||||
plugins/
|
|
||||||
<plugin-id>/
|
|
||||||
manifest.json # Plugin metadata
|
|
||||||
manager.py # Main plugin class
|
|
||||||
config_schema.json # Config validation schema
|
|
||||||
requirements.txt # Python dependencies
|
|
||||||
README.md # Plugin documentation
|
|
||||||
# Plugin-specific files
|
|
||||||
data_manager.py
|
|
||||||
renderer.py
|
|
||||||
etc.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Git Workflow for Plugins
|
|
||||||
|
|
||||||
**Plugin Development:**
|
|
||||||
- Plugins are typically separate repositories
|
|
||||||
- Use `dev_plugin_setup.sh` to link plugins for development
|
|
||||||
- Symlinks are used to connect plugin repos to `plugins/` directory
|
|
||||||
- Plugin repos follow naming: `ledmatrix-<plugin-name>`
|
|
||||||
|
|
||||||
**Branching:**
|
|
||||||
- Develop plugins in feature branches
|
|
||||||
- Follow project branching conventions
|
|
||||||
- Test plugins before merging to main
|
|
||||||
|
|
||||||
**Automatic Version Bumping:**
|
|
||||||
- **Automatic Version Management**: Version bumping is handled automatically via the pre-push git hook - no manual version bumping is required for normal development workflows
|
|
||||||
- **GitHub as Source of Truth**: Plugin store always fetches latest versions from GitHub (releases/tags/manifest/commit)
|
|
||||||
- **Pre-Push Hook**: Automatically bumps patch version and creates git tags when pushing code changes
|
|
||||||
- The hook is self-contained (no external dependencies) and works on any dev machine
|
|
||||||
- Installation: Copy the hook from LEDMatrix repo to your plugin repo:
|
|
||||||
```bash
|
|
||||||
# From your plugin repository directory
|
|
||||||
cp /path/to/LEDMatrix/scripts/git-hooks/pre-push-plugin-version .git/hooks/pre-push
|
|
||||||
chmod +x .git/hooks/pre-push
|
|
||||||
```
|
|
||||||
- Or use the installer script from the main LEDMatrix repo (one-time setup)
|
|
||||||
- The hook automatically:
|
|
||||||
1. Bumps the patch version (x.y.Z) in manifest.json when code changes are detected
|
|
||||||
2. Creates a git tag (v{version}) for the new version
|
|
||||||
3. Stages manifest.json for commit
|
|
||||||
- Skip auto-tagging: Set `SKIP_TAG=1` environment variable before pushing
|
|
||||||
- **Manual Version Bumping (Edge Cases Only)**: Manual version bumps are only needed in rare circumstances:
|
|
||||||
- CI/CD pipelines that bypass git hooks
|
|
||||||
- Forked repositories without the pre-push hook installed
|
|
||||||
- Major/minor version bumps (hook only handles patch versions)
|
|
||||||
- When skipping auto-tagging but still needing a version bump
|
|
||||||
- For manual bumps, use the standalone script: `scripts/bump_plugin_version.py`
|
|
||||||
- **Registry**: The plugin registry (plugins.json) stores only metadata (name, description, repo URL) - no versions
|
|
||||||
- **Version Priority**: Plugin store checks versions in this order: GitHub Releases → GitHub Tags → Manifest from branch → Git commit hash
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- Plugin System Docs: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
|
||||||
- Plugin Examples: `plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`
|
|
||||||
- Base Plugin: `src/plugin_system/base_plugin.py`
|
|
||||||
- Plugin Manager: `src/plugin_system/plugin_manager.py`
|
|
||||||
- Development Setup: `dev_plugin_setup.sh`
|
|
||||||
- Example Config: `dev_plugins.json.example`
|
|
||||||
|
|
||||||
@@ -1,2 +1,12 @@
|
|||||||
# Auto detect text files and perform LF normalization
|
# Auto detect text files and perform LF normalization
|
||||||
* text=auto
|
* text=auto
|
||||||
|
|
||||||
|
# Files the Pi executes must stay LF even in a Windows checkout with
|
||||||
|
# core.autocrlf=true: a CRLF shebang line fails with "bad interpreter",
|
||||||
|
# and systemd rejects CRLF unit files.
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.service text eol=lf
|
||||||
|
|
||||||
|
# Generated by scripts/build_css.py; collapsed in diffs, not hand-edited.
|
||||||
|
web_interface/static/v3/tailwind.css linguist-generated=true
|
||||||
|
web_interface/static/v3/plugin-frame.css linguist-generated=true
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
# Pull requests from forks get no repository secrets, so without this
|
||||||
|
# guard every outside contributor's PR showed this check red for a reason
|
||||||
|
# they can't fix. Skipped checks don't block merging.
|
||||||
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
id: claude-review
|
||||||
|
uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
# Review PRs opened by the Claude GitHub App. Without this the action
|
||||||
|
# aborts before reading the diff ("Workflow initiated by non-human
|
||||||
|
# actor"), so every such PR shows this check red. Named rather than
|
||||||
|
# '*': the allow-list is matched against the triggering actor, so
|
||||||
|
# this admits claude[bot] alone and no other app.
|
||||||
|
allowed_bots: 'claude'
|
||||||
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
|
plugins: 'code-review@claude-code-plugins'
|
||||||
|
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Release version check
|
||||||
|
|
||||||
|
# A release tag, the CHANGELOG, and src.__version__ must agree. They have not
|
||||||
|
# always: v3.1.0 was tagged while src/__init__.py still said "1.0.0", which
|
||||||
|
# silently exempted every device installed from that release from plugin
|
||||||
|
# compatibility warnings. See docs/SPORTS_UNIFICATION.md (phase B4).
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
# Pre-flight: run this against the tag you are about to create.
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Tag to check (e.g. v3.2.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version-matches-tag:
|
||||||
|
name: Tag matches src.__version__
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# No dependencies: the script reads src/__init__.py and CHANGELOG.md only.
|
||||||
|
- name: Assert the tag, CHANGELOG and src.__version__ agree
|
||||||
|
run: python scripts/check_release_version.py "${TAG}"
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag || github.ref_name }}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# Manual runs against any branch — useful when a PR's automatic run
|
||||||
|
# needs a re-run or didn't get created.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# The jobs only check out the repo and run the tests.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
plugin-safety:
|
||||||
|
name: Plugin safety harness + unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
# The bundled fixture plugin gives the harness at least one real plugin
|
||||||
|
# to render, and REQUIRE_PLUGINS turns "discovered zero plugins" into a
|
||||||
|
# hard failure instead of a silent all-skip green run.
|
||||||
|
LEDMATRIX_PLUGINS_DIR: test/fixtures/plugins
|
||||||
|
LEDMATRIX_REQUIRE_PLUGINS: "1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt -r web_interface/requirements.txt -r requirements-test.txt
|
||||||
|
pip install RGBMatrixEmulator
|
||||||
|
|
||||||
|
- name: Run plugin safety harness
|
||||||
|
run: |
|
||||||
|
pytest --no-cov test/plugins/
|
||||||
|
|
||||||
|
unit-tests:
|
||||||
|
name: Core unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt -r web_interface/requirements.txt -r requirements-test.txt
|
||||||
|
pip install RGBMatrixEmulator
|
||||||
|
|
||||||
|
# Run the ENTIRE test tree (except test/plugins, which the
|
||||||
|
# plugin-safety job owns). New test files are enrolled automatically;
|
||||||
|
# excluding anything requires a visible, commented --ignore here.
|
||||||
|
# Coverage is measured and enforced only in this step — pytest.ini
|
||||||
|
# deliberately carries no coverage flags so local runs stay fast.
|
||||||
|
- name: Run core unit suites
|
||||||
|
run: |
|
||||||
|
pytest -m "not hardware" test/ \
|
||||||
|
--ignore=test/plugins \
|
||||||
|
--cov=src --cov=web_interface \
|
||||||
|
--cov-report=term \
|
||||||
|
--cov-fail-under=52
|
||||||
|
|
||||||
|
js-tests:
|
||||||
|
name: Web UI JS tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt -r web_interface/requirements.txt
|
||||||
|
npm install --no-audit --no-fund --prefix test/js
|
||||||
|
|
||||||
|
# The DOM suites test the real server-rendered pages and API, so they
|
||||||
|
# need the web interface running. REQUIRE_DOM turns "couldn't reach it"
|
||||||
|
# into a failure instead of a silent skip.
|
||||||
|
- name: Start the web interface
|
||||||
|
run: |
|
||||||
|
EMULATOR=true python -c "from web_interface.app import app; app.run(host='127.0.0.1', port=5000, threaded=True)" > web.log 2>&1 &
|
||||||
|
for i in $(seq 60); do curl -sf -o /dev/null http://127.0.0.1:5000/ && exit 0; sleep 1; done
|
||||||
|
cat web.log
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Run JS suites
|
||||||
|
env:
|
||||||
|
BASE: http://127.0.0.1:5000
|
||||||
|
REQUIRE_DOM: "1"
|
||||||
|
run: node test/js/run_all.js
|
||||||
|
|
||||||
|
css-build:
|
||||||
|
name: Tailwind CSS is up to date
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# Downloads the pinned standalone Tailwind CLI (SHA-256 checked; no
|
||||||
|
# Node), rebuilds static/v3/tailwind.css and plugin-frame.css from the
|
||||||
|
# templates and JS, and fails if the committed files differ. Fix a
|
||||||
|
# failure by running `python3 scripts/build_css.py` and committing.
|
||||||
|
- name: Check the committed CSS matches a fresh build
|
||||||
|
run: python scripts/build_css.py --check
|
||||||
|
|
||||||
|
type-check:
|
||||||
|
name: Type check (mypy ratchet)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
# The runtime requirements are installed so mypy sees the real types of
|
||||||
|
# PIL, requests, psutil and friends -- missing, they'd be Any and the
|
||||||
|
# result would differ from a developer's machine. mypy and the stubs are
|
||||||
|
# pinned so a new release can't turn this red without a code change.
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt -r web_interface/requirements.txt
|
||||||
|
pip install mypy==1.20.2 types-requests==2.33.0.20260906 types-pytz==2026.4.0.20260926
|
||||||
|
|
||||||
|
# mypy on exactly the modules in mypy-clean.txt; fails on any error in
|
||||||
|
# them, or if a listed file is missing. See CONTRIBUTING.md.
|
||||||
|
- name: Run mypy on the ratchet list
|
||||||
|
run: python scripts/check_types.py
|
||||||
|
|
||||||
|
sports-drift-report:
|
||||||
|
name: Sports drift report (report only)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# A progress measure for docs/SPORTS_UNIFICATION.md, never a gate: the
|
||||||
|
# monorepo's own check_sports_drift.py is the gate. The step summary shows
|
||||||
|
# how many bodies each scoreboard method family still has.
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Check out ledmatrix-plugins (main)
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
repository: ChuckBuilds/ledmatrix-plugins
|
||||||
|
path: ledmatrix-plugins
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# Stdlib only; exits 0 whatever it finds.
|
||||||
|
- name: Report method-family drift across the nine scoreboards
|
||||||
|
run: |
|
||||||
|
python scripts/sports_drift_report.py --plugins ledmatrix-plugins \
|
||||||
|
--markdown --json sports-drift.json >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
python scripts/sports_drift_report.py --plugins ledmatrix-plugins
|
||||||
|
|
||||||
|
- name: Upload the full report
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: sports-drift-report
|
||||||
|
path: sports-drift.json
|
||||||
@@ -3,11 +3,13 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# Secrets
|
# Secrets and per-device state. Everything the software writes into config/
|
||||||
config/config_secrets.json
|
# is local to one device -- config.json, config_secrets.json, wifi_config.json,
|
||||||
config/config.json
|
# ytm_auth.json (a login session), saved_repositories.json, font_overrides.json,
|
||||||
config/config.json.backup
|
# and the temp files atomic writes leave behind when interrupted -- so only
|
||||||
config/wifi_config.json
|
# the templates are tracked. Listing files one by one missed several.
|
||||||
|
config/*
|
||||||
|
!config/*.template.json
|
||||||
credentials.json
|
credentials.json
|
||||||
token.pickle
|
token.pickle
|
||||||
|
|
||||||
@@ -35,11 +37,12 @@ htmlcov/
|
|||||||
# Cache directory (root level only, not src/cache which is source code)
|
# Cache directory (root level only, not src/cache which is source code)
|
||||||
/cache/
|
/cache/
|
||||||
|
|
||||||
# Development plugins directory
|
# Development plugins directory: symlinks into a ledmatrix-plugins checkout
|
||||||
# Plugins are managed as separate repositories via multi-root workspace
|
# See docs/PLUGIN_DEVELOPMENT_GUIDE.md and docs/MULTI_ROOT_WORKSPACE_SETUP.md
|
||||||
# See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details
|
|
||||||
plugins/*
|
plugins/*
|
||||||
!plugins/.gitkeep
|
!plugins/.gitkeep
|
||||||
|
# Local settings for scripts/dev/dev_plugin_setup.sh (template: dev_plugins.json.example)
|
||||||
|
/dev_plugins.json
|
||||||
|
|
||||||
# Binary files and backups
|
# Binary files and backups
|
||||||
bin/pixlet/
|
bin/pixlet/
|
||||||
@@ -47,3 +50,40 @@ config/backups/
|
|||||||
|
|
||||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
|
|
||||||
|
# JS test deps (test/js)
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Team logos fetched at runtime.
|
||||||
|
#
|
||||||
|
# src/logo_downloader.py and LogoHelper write into assets/sports/<league>_logos/
|
||||||
|
# whenever a plugin meets a team whose logo is not on disk. Those directories are
|
||||||
|
# also tracked -- 209 NCAA logos and 153 soccer ones ship with the repo -- so
|
||||||
|
# every rig accumulated untracked files it was never meant to commit and
|
||||||
|
# `git status` was permanently dirty. That noise is not harmless: it trains
|
||||||
|
# everyone to ignore the one signal that says a checkout is not what you think
|
||||||
|
# it is, which is how a stale tree sat unnoticed on a rig until a restart
|
||||||
|
# surfaced four dead sports plugins.
|
||||||
|
#
|
||||||
|
# Ignoring a directory does not untrack what is already in it, so the logos that
|
||||||
|
# ship keep shipping. Only new downloads are hidden.
|
||||||
|
#
|
||||||
|
# Adding a logo on purpose is rare and deliberate -- the last time was #415, four
|
||||||
|
# named NCAA logos a plugin needed, and there has been no other in a year. Do it
|
||||||
|
# with an explicit override:
|
||||||
|
# git add -f assets/sports/ncaa_logos/DUKE.png
|
||||||
|
assets/sports/*_logos/
|
||||||
|
assets/stocks/ticker_icons/
|
||||||
|
assets/stocks/crypto_icons/
|
||||||
|
|
||||||
|
# Plugin operation state written at runtime.
|
||||||
|
#
|
||||||
|
# web_interface/app.py writes data/plugin_state.json and data/operation_history.json
|
||||||
|
# (older releases also data/plugin_operations.json) as the web interface runs, into
|
||||||
|
# a directory that ships tracked (data/.gitkeep). Unignored, every rig that ever
|
||||||
|
# opened the web UI -- and every test run that constructs the app -- would leave
|
||||||
|
# untracked files behind and a permanently dirty `git status`. Same reasoning as
|
||||||
|
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
|||||||
@@ -37,14 +37,22 @@ repos:
|
|||||||
types: [python]
|
types: [python]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
# The mypy ratchet -- the same check as CI's "Type check (mypy ratchet)"
|
||||||
rev: v1.8.0
|
# job: mypy on exactly the modules listed in mypy-clean.txt. Run it with
|
||||||
|
# pre-commit run mypy --hook-stage manual
|
||||||
|
# A local hook rather than mirrors-mypy so mypy sees the packages installed
|
||||||
|
# from requirements.txt, as CI does; an isolated hook env without them types
|
||||||
|
# PIL, requests and friends as Any and reports different errors. Needs
|
||||||
|
# mypy==1.20.2 (the version CI pins) in the environment you commit from.
|
||||||
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
additional_dependencies: [types-requests, types-pytz]
|
name: mypy (ratchet, mypy-clean.txt)
|
||||||
args: [--ignore-missing-imports, --no-error-summary]
|
entry: python scripts/check_types.py
|
||||||
|
language: system
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
files: ^src/
|
always_run: true
|
||||||
|
stages: [manual]
|
||||||
|
|
||||||
- repo: https://github.com/PyCQA/bandit
|
- repo: https://github.com/PyCQA/bandit
|
||||||
rev: 1.8.3
|
rev: 1.8.3
|
||||||
|
|||||||
@@ -6,32 +6,55 @@
|
|||||||
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
||||||
- `plugin-repos/` — **Default** plugin install directory used by the
|
- `plugin-repos/` — **Default** plugin install directory used by the
|
||||||
Plugin Store, set by `plugin_system.plugins_directory` in
|
Plugin Store, set by `plugin_system.plugins_directory` in
|
||||||
`config.json` (default per `config/config.template.json:130`).
|
`config.json` (default per `config/config.template.json`).
|
||||||
Not gitignored.
|
Not gitignored.
|
||||||
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
||||||
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
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/`
|
loader does NOT fall back to it — `PluginManager.discover_plugins()`
|
||||||
(`src/plugin_system/schema_manager.py:77`).
|
(`src/plugin_system/plugin_manager.py`) scans only the configured
|
||||||
|
directory. Fallbacks exist in two narrower places: store operations
|
||||||
|
(`PluginStoreManager._find_plugin_path()` in `store_manager.py`, which
|
||||||
|
searches `store_search_dirs()` from `plugin_dirs.py`) and schema lookup
|
||||||
|
(`SchemaManager.get_schema_path()` in `schema_manager.py`, which probes
|
||||||
|
`plugins/` *before* `plugin-repos/`).
|
||||||
|
- `src/plugin_system/plugin_dirs.py` — the one resolver for "which directory
|
||||||
|
holds plugin X" (manifest `id` first, then `<id>` / `ledmatrix-<id>`)
|
||||||
|
|
||||||
## 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`
|
||||||
- Required abstract methods: `update()`, `display(force_clear=False)`
|
- Required abstract methods: `update()`, `display(force_clear=False)`
|
||||||
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
|
- Each plugin needs: `manifest.json`, `config_schema.json`, and the entry point (`manager.py` by default); `requirements.txt` if it has dependencies. Required manifest fields: `docs/PLUGIN_API_REFERENCE.md#manifest-required-fields`
|
||||||
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
||||||
- Config schemas use JSON Schema Draft-7
|
- Config schemas use JSON Schema Draft-7
|
||||||
- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height`
|
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
|
||||||
|
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
|
||||||
|
via `"x-secret": true` in the plugin's config schema, and deep-merged into
|
||||||
|
the plugin's config dict at load time — plugins read them with plain
|
||||||
|
`config.get(...)`, never a separate accessor
|
||||||
|
|
||||||
|
## Dev Workflow
|
||||||
|
- Link a plugin for development: `./scripts/dev/dev_plugin_setup.sh link-github <name>` clones the `ledmatrix-plugins` monorepo into `~/.ledmatrix-dev-plugins/` and links its `plugins/<name>` under the manifest id (add a repo URL for a plugin with its own repo; or `link <name> <path>`); symlinks land in `plugins/` — set `plugin_system.plugins_directory` to `plugins` so discovery picks them up. Fork/location overrides: `dev_plugins.json` (from `dev_plugins.json.example`)
|
||||||
|
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
|
||||||
|
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
|
||||||
|
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
|
||||||
|
- Soak a rig for frame timing (on the Pi, service running): `python3 scripts/frame_soak.py --preview` — late-frame rate across every scroller; see `docs/SCROLL_PERFORMANCE.md`
|
||||||
|
|
||||||
## Plugin Store Architecture
|
## Plugin Store Architecture
|
||||||
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
||||||
- Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`)
|
- Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`)
|
||||||
- `plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json`
|
- `plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json`
|
||||||
- Store manager (`src/plugin_system/store_manager.py`) handles install/update/uninstall
|
- Store manager (`PluginStoreManager` in `src/plugin_system/store_manager.py`) handles install/update/uninstall
|
||||||
- Monorepo plugins are installed via ZIP extraction (no `.git` directory)
|
- Monorepo plugins are installed without a `.git` directory: GitHub Trees API + raw downloads, falling back to ZIP extraction
|
||||||
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
|
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
|
||||||
|
- Optional registry entry fields (`store_registry.py`): `ledmatrix_min_version` refuses an incompatible install/update before the download (the post-download manifest gate stays as the fallback); `aliases` are the entry's other ids (manifest id `ledmatrix-weather` for `weather`), used with the `plugin_path` name by update/uninstall/reinstall to find the install — only this registry proof counts, never a bare `ledmatrix-<id>` folder (owner decision, #686; such a folder is only logged); `commit` is informational. An older plugins.json has none of them
|
||||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||||
|
|
||||||
## Common Pitfalls
|
## Common Pitfalls
|
||||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
- paho-mqtt 2.x requires a `CallbackAPIVersion` argument: `VERSION1` for code written against v1 callback signatures (the MQTT bridge uses `VERSION2`)
|
||||||
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||||
|
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
|
||||||
|
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
||||||
|
(use a mask for transparency: `image.paste(rgba, (x, y), rgba)`)
|
||||||
- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
|
- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
|
||||||
|
- `src/pi5_matrix_support.py` hardcodes what the pinned `rpi-rgb-led-matrix-master` can drive on a Raspberry Pi 5 (`Rp1PioConfigSupported()` in `lib/rp1/rp1_pio_backend.cc`). Re-check it whenever the submodule is bumped: a stale rule blocks Pi 5 settings the new library supports, and a missing one lets the display service crash-loop. `src/matrix_support.py` holds the same kind of rules for every board (rows, chain length, mapping names, parallel per mapping) and needs the same re-check
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ ChuckBuilds, and any other forums hosted by or affiliated with the project.
|
|||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
reported to the community leaders responsible for enforcement on the
|
reported to the community leaders responsible for enforcement on the
|
||||||
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT) (DM a moderator or
|
[LEDMatrix Discord](https://discord.gg/RdrC37rEag) (DM a moderator or
|
||||||
ChuckBuilds directly) or by opening a private GitHub Security Advisory if
|
ChuckBuilds directly) or by opening a private GitHub Security Advisory if
|
||||||
the issue involves account safety. All complaints will be reviewed and
|
the issue involves account safety. All complaints will be reviewed and
|
||||||
investigated promptly and fairly.
|
investigated promptly and fairly.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ improvements, and code changes.
|
|||||||
- **Bugs / feature requests**: open an issue using one of the templates
|
- **Bugs / feature requests**: open an issue using one of the templates
|
||||||
in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/).
|
in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/).
|
||||||
- **Real-time discussion**: the
|
- **Real-time discussion**: the
|
||||||
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT).
|
[LEDMatrix Discord](https://discord.gg/RdrC37rEag).
|
||||||
- **Plugin development**:
|
- **Plugin development**:
|
||||||
[`docs/PLUGIN_DEVELOPMENT_GUIDE.md`](docs/PLUGIN_DEVELOPMENT_GUIDE.md)
|
[`docs/PLUGIN_DEVELOPMENT_GUIDE.md`](docs/PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||||
and the [`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
and the [`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
@@ -40,7 +40,7 @@ improvements, and code changes.
|
|||||||
## Running the tests
|
## Running the tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,9 +57,25 @@ integration tests.
|
|||||||
`docs/<short-description>`.
|
`docs/<short-description>`.
|
||||||
3. **Keep PRs focused.** One conceptual change per PR. If you find
|
3. **Keep PRs focused.** One conceptual change per PR. If you find
|
||||||
adjacent bugs while working, fix them in a separate PR.
|
adjacent bugs while working, fix them in a separate PR.
|
||||||
4. **Follow the existing code style.** Python code uses standard
|
4. **Follow the existing code style.** The pre-commit hooks run
|
||||||
`black`/`ruff` conventions; HTML/JS in `web_interface/` follows the
|
`flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `bandit`,
|
||||||
patterns already in `templates/v3/` and `static/v3/`.
|
and `gitleaks` — install the CLI with
|
||||||
|
`python -m pip install pre-commit`, then run
|
||||||
|
`pre-commit install` so they run on every commit. Type checking
|
||||||
|
is a ratchet while the existing mypy errors in `src/` are paid
|
||||||
|
down: `mypy-clean.txt` lists the modules that type-check clean, and
|
||||||
|
CI runs `python scripts/check_types.py` (also the manual hook
|
||||||
|
`pre-commit run mypy --hook-stage manual`) to keep every listed
|
||||||
|
module clean. When you make another module clean, add it to the
|
||||||
|
list (sorted); don't take one off to get CI green. Keep type fixes
|
||||||
|
annotation-only where you can -- widen a hint rather than delete a
|
||||||
|
defensive runtime check mypy calls unreachable. HTML/JS in
|
||||||
|
`web_interface/` follows the patterns already in `templates/v3/`
|
||||||
|
and `static/v3/`. If you change a template or a static JS file,
|
||||||
|
run `python3 scripts/build_css.py` and commit the regenerated
|
||||||
|
`static/v3/tailwind.css` with it -- CI fails when the committed CSS
|
||||||
|
is out of date. It needs no Node; see
|
||||||
|
[`web_interface/README.md`](web_interface/README.md#styling-tailwind-css).
|
||||||
5. **Update documentation** alongside code changes. If you add a
|
5. **Update documentation** alongside code changes. If you add a
|
||||||
config key, document it in the relevant `*.md` file (or, for
|
config key, document it in the relevant `*.md` file (or, for
|
||||||
plugins, in `config_schema.json` so the form is auto-generated).
|
plugins, in `config_schema.json` so the form is auto-generated).
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
<!-- impeccable:product-schema 1 -->
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
|
||||||
|
web
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
Designed novice-first, with power tools kept within reach.
|
||||||
|
|
||||||
|
- **Primary: hobbyist builders.** People who assembled an LED matrix panel on a Raspberry Pi, often by following the install video, and are frequently new to Linux and the Pi. They set the display up once (panel size, timezone, WiFi), install and enable a few plugins, then come back occasionally to tweak what the panel shows. They usually reach the control panel from a phone or laptop on their home network, sometimes as an installed home-screen app.
|
||||||
|
- **Secondary: tinkerers and plugin developers.** Comfortable with SSH, `config.json`, and GitHub. They lean on the Config Editor, Logs, Cache, Operation History, Tools, GitHub-repo installs, and per-plugin config while building or debugging. Their tools must stay reachable without sitting in the novice's path.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
LEDMatrix turns a Raspberry Pi and an RGB LED matrix panel into an information-rich display (clock, weather, calendar, sports scores, stocks, music, and more) through a plugin platform. The web control panel ("LED Matrix Control") is where the display gets configured, extended, and kept healthy.
|
||||||
|
|
||||||
|
Success means a builder gets from a freshly flashed Pi to a working, personalized display without needing a terminal, and can keep it running (updates, recovery, troubleshooting) the same way.
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
Four strengths define LEDMatrix, and future work must protect all of them:
|
||||||
|
|
||||||
|
1. **Plugin ecosystem.** The core ships only `starlark-apps` and `web-ui-info`; everything else comes from the built-in Plugin Store (the official `ledmatrix-plugins` monorepo), third-party GitHub repos, or Starlark (Tidbyt-style) apps. Each installed plugin gets its own configuration tab, generated from its schema.
|
||||||
|
2. **Runs on tiny Pis.** The UI is served by the same device that drives the matrix, on boards as small as the Pi Zero 2 W (512 MB), Pi 3/3B+, and the 1 GB Pi 4.
|
||||||
|
3. **Recovers without SSH.** WiFi access-point fallback with a captive setup page, backup & restore, in-UI updates, live logs, diagnostics, service control, and plugin health let users fix problems from the browser.
|
||||||
|
4. **Open and community-led.** GPL-3.0, a Discord community, and contributions welcome. The maintainer (ChuckBuilds) builds in public and openly relies on AI development tools.
|
||||||
|
|
||||||
|
## Operating Context
|
||||||
|
|
||||||
|
- **Access.** Served on the local network at `http://<pi-ip>:5000` by the `ledmatrix-web` service. It is installable as a PWA (`web_interface/static/v3/manifest.json`, short name "LEDMatrix").
|
||||||
|
- **First run.** When the Pi has no network it creates its own WiFi access point, so the captive setup page (`templates/v3/captive_setup.html`) may be the very first screen a user sees, on a phone, with no internet connection.
|
||||||
|
- **Navigation.**
|
||||||
|
- System tabs: Overview, General, WiFi, Schedule, Display, Rotation, Config Editor, Backup & Restore, Fonts, Logs, Cache, Operation History, Tools.
|
||||||
|
- A second row holds Plugin Manager (with the Plugin Store), Starlark Apps, and one tab per installed plugin.
|
||||||
|
- **Live data.** The Overview shows system stats (CPU, memory, temperature, power/throttling) and a live display preview, streamed over SSE.
|
||||||
|
- **Getting Started checklist.** The Overview's first-run checklist runs: set panel size → set timezone → install a plugin → enable it → configure it.
|
||||||
|
- **Development.** `python3 scripts/dev_server.py` gives a browser preview without the display loop; `python3 run.py -e` runs the full display in emulator mode.
|
||||||
|
|
||||||
|
## Capabilities and Constraints
|
||||||
|
|
||||||
|
- **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working.
|
||||||
|
- **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls.
|
||||||
|
- **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets.
|
||||||
|
- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, on-demand, AP mode.
|
||||||
|
- **Open decisions** (offered during init, not adopted as constraints):
|
||||||
|
- Whether the UI must work fully offline, with no CDN fallbacks at runtime.
|
||||||
|
- Whether a Node/CSS build step is acceptable for contributors.
|
||||||
|
- Whether a formal accessibility standard (e.g. WCAG 2.2 AA) is a requirement.
|
||||||
|
|
||||||
|
## Brand Commitments
|
||||||
|
|
||||||
|
- **Names.** The product is "LEDMatrix" and the web UI is titled "LED Matrix Control". The maintainer brand is ChuckBuilds.
|
||||||
|
- **Voice.** Friendly, honest, and learning-in-public, as in the README.
|
||||||
|
- **App icons.** They live in `web_interface/static/v3/icons/`.
|
||||||
|
|
||||||
|
No other visual identity has been made binding.
|
||||||
|
|
||||||
|
## Evidence on Hand
|
||||||
|
|
||||||
|
- **Photos.** Real photographs of running displays are linked in `README.md` (clock, weather, calendar, NHL/MLB/NFL/NCAA, stocks, music).
|
||||||
|
- **Video.** YouTube install and walkthrough videos from ChuckBuilds.
|
||||||
|
- **Docs.** Extensive documentation in `docs/`, e.g. `WEB_INTERFACE_GUIDE.md`, `GETTING_STARTED.md`, `WIFI_NETWORK_SETUP.md`, `LOW_MEMORY_BOARDS.md`, `PLUGIN_STORE_GUIDE.md`.
|
||||||
|
- **Absences.** There are no testimonials, user counts, or benchmark figures. Do not fabricate them.
|
||||||
|
|
||||||
|
## Product Principles
|
||||||
|
|
||||||
|
1. **Novice path first, power one click away.** Default views serve the first-time builder, while advanced tools stay discoverable for tinkerers.
|
||||||
|
2. **Never strand the user at a terminal.** Every setup, recovery, and troubleshooting task has a browser path, including from the AP-mode captive page.
|
||||||
|
3. **Respect the Pi.** Every feature is paid for in memory and CPU on a Pi Zero 2 W that is also driving the display.
|
||||||
|
4. **The ecosystem is the product.** Plugins, including third-party ones, must feel first-class and keep working across core UI changes.
|
||||||
|
5. **Honest and welcoming.** Plain language, truthful status, and no overstated claims, in keeping with an open, community-built project.
|
||||||
@@ -33,7 +33,7 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
|
|||||||
- Show support on Youtube: https://www.youtube.com/@ChuckBuilds
|
- Show support on Youtube: https://www.youtube.com/@ChuckBuilds
|
||||||
- Check out the write-up on my website: https://www.chuck-builds.com/led-matrix/
|
- Check out the write-up on my website: https://www.chuck-builds.com/led-matrix/
|
||||||
- Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/
|
- Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/
|
||||||
- Want to chat? Reach out on the LEDMatrix Discord: [https://discord.com/invite/uW36dVAtcT](https://discord.gg/dfFwsasa6W)
|
- Want to chat? Reach out on the LEDMatrix Discord: [https://discord.gg/RdrC37rEag](https://discord.gg/RdrC37rEag)
|
||||||
- Feeling Generous? Consider sponsoring this project or sending a donation (these AI credits aren't cheap!)
|
- Feeling Generous? Consider sponsoring this project or sending a donation (these AI credits aren't cheap!)
|
||||||
|
|
||||||
-----------------------------------------------------------------------------------
|
-----------------------------------------------------------------------------------
|
||||||
@@ -50,7 +50,15 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
|
|||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Core Features</summary>
|
<summary>Core Features</summary>
|
||||||
The following plugins are available inside of the LEDMatrix project. These modular, rotating Displays that can be individually enabled or disabled per the user's needs with some configuration around display durations, teams, stocks, weather, timezones, and more. Displays include:
|
LEDMatrix is a plugin platform: the displays below are plugins installed
|
||||||
|
from the built-in Plugin Store (web interface → Plugins), where each can be
|
||||||
|
individually enabled, ordered, and configured — display durations, teams,
|
||||||
|
stocks, weather, timezones, and more. The core repo ships with just two
|
||||||
|
bundled plugins (`starlark-apps` and `web-ui-info`); the official plugins
|
||||||
|
live in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
monorepo and install with one click, and third-party plugins can be
|
||||||
|
installed from their own GitHub repositories. Displays available in the
|
||||||
|
store include:
|
||||||
|
|
||||||
### Time and Weather
|
### Time and Weather
|
||||||
- Real-time clock display (2x 64x32 Displays 4mm Pixel Pitch)
|
- Real-time clock display (2x 64x32 Displays 4mm Pixel Pitch)
|
||||||
@@ -132,29 +140,31 @@ The system supports live, recent, and upcoming game information for multiple spo
|
|||||||
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
||||||
|
|
||||||
### Raspberry Pi
|
### Raspberry Pi
|
||||||
- Raspberry Pi Zero's don't have enough processing power for this project.
|
- **Raspberry Pi 3B, 4, or 5** (a Pi Zero 2 W also works, with the limits described under the 1GB/low-memory bullet below; the original Pi Zero / Zero W doesn't have enough processing power for this project)
|
||||||
- **Raspberry Pi 3B, 4, or 5**
|
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
||||||
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
|
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
|
||||||
```bash
|
```bash
|
||||||
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
||||||
```
|
```
|
||||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and start `gpio_slowdown` at `1`, raising it a step at a time if the image flickers or shows garbage (see `gpio_slowdown` under Display Settings).
|
||||||
|
- **1GB models (Pi 3B / 3B+), the 512MB Pi Zero 2 W and other low-memory boards**: supported, but the `rpi-rgb-led-matrix` C++ build needs more memory than the Pi has. The installer detects this automatically, compiles with fewer parallel jobs, and adds a temporary swapfile for the build which it removes afterwards. Expect that step to take 15-25 minutes instead of 2-5, and leave at least **3GB free** on the SD card. If you manage swap yourself, opt out with `--skip-swap`. To pin the compiler down further, use `--build-jobs 1`. Once running, keep an eye on memory: see [docs/LOW_MEMORY_BOARDS.md](docs/LOW_MEMORY_BOARDS.md).
|
||||||
|
|
||||||
|
|
||||||
### RGB Matrix Bonnet / HAT
|
### RGB Matrix Bonnet / HAT
|
||||||
- [Adafruit RGB Matrix Bonnet/HAT](https://www.adafruit.com/product/3211) – supports one “chain” of horizontally connected displays
|
- [Adafruit RGB Matrix Bonnet/HAT](https://www.adafruit.com/product/3211) – supports one “chain” of horizontally connected displays
|
||||||
- [Adafruit Triple LED Matrix Bonnet](https://www.adafruit.com/product/6358) – supports up to 3 vertical “chains” of horizontally connected displays *(use `regular-pi1` as hardware mapping)*
|
- [Adafruit Triple LED Matrix Bonnet](https://www.adafruit.com/product/6358) – supports up to 3 vertical “chains” of horizontally connected displays *(use `regular` as hardware mapping)*
|
||||||
- [Electrodragon RGB HAT](https://www.electrodragon.com/product/rgb-matrix-panel-drive-board-raspberry-pi/) – supports up to 3 vertical “chains”
|
- [Electrodragon RGB HAT](https://www.electrodragon.com/product/rgb-matrix-panel-drive-board-raspberry-pi/) – supports up to 3 vertical “chains”
|
||||||
- [Seengreat Matrix Adapter Board](https://amzn.to/3KsnT3j) – single-chain LED Matrix *(use `regular` as hardware mapping)*
|
- [Seengreat Matrix Adapter Board](https://amzn.to/3KsnT3j) – single-chain LED Matrix *(use `regular` as hardware mapping)*
|
||||||
|
|
||||||
### LED Matrix Panels
|
### LED Matrix Panels
|
||||||
(2x in a horizontal chain is recommended)
|
(2x in a horizontal chain is recommended)
|
||||||
- [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
|
- [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
|
||||||
|
**Warning: Lately the Waveshare Panels have had different variations - only some are compatible with this project. I hope to identify what is different to fix it but so far there is a decent chance you get a mis-matched set of panels if you don't buy them all at once! **
|
||||||
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
|
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
|
||||||
- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
|
- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
|
||||||
> Amazon Affiliate Link – ChuckBuilds receives a small commission on purchases
|
- There are some Panels on Aliexpress that have worked fine for me, shop around! I think Adafruit is probably the "safest" but they do have some limitation on resolution and layout.
|
||||||
|
> Amazon Affiliate Links – ChuckBuilds receives a small commission on purchases
|
||||||
|
|
||||||
### Power Supply
|
### Power Supply
|
||||||
- [5V 4A DC Power Supply](https://www.adafruit.com/product/658) (good for 2 -3 displays, depending on brightness and pixel density, you'll need higher amperage for more)
|
- [5V 4A DC Power Supply](https://www.adafruit.com/product/658) (good for 2 -3 displays, depending on brightness and pixel density, you'll need higher amperage for more)
|
||||||
@@ -162,7 +172,7 @@ The system supports live, recent, and upcoming game information for multiple spo
|
|||||||
|
|
||||||
## Optional but recommended mod for Adafruit RGB Matrix Bonnet
|
## Optional but recommended mod for Adafruit RGB Matrix Bonnet
|
||||||
- By soldering a jumper between pins 4 and 18, you can run a specialized command for polling the matrix display. This provides better brightness, less flicker, and better color.
|
- By soldering a jumper between pins 4 and 18, you can run a specialized command for polling the matrix display. This provides better brightness, less flicker, and better color.
|
||||||
- If you do the mod, we will use the default config with led-gpio-mapping=adafruit-hat-pwm, otherwise just adjust your mapping in config.json to adafruit-hat
|
- The default config uses `hardware_mapping` `adafruit-hat`. If you do the mod, change it to `adafruit-hat-pwm` (Display settings in the web interface, or `config.json`)
|
||||||
- More information available: https://github.com/hzeller/rpi-rgb-led-matrix/tree/master?tab=readme-ov-file
|
- More information available: https://github.com/hzeller/rpi-rgb-led-matrix/tree/master?tab=readme-ov-file
|
||||||

|

|
||||||
|
|
||||||
@@ -314,12 +324,13 @@ curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/
|
|||||||
```
|
```
|
||||||
|
|
||||||
This one-shot installer will automatically:
|
This one-shot installer will automatically:
|
||||||
- Check system prerequisites (network, disk space, sudo access)
|
- Check system prerequisites (network, disk space, memory, sudo access)
|
||||||
- Install required system packages (git, python3, build tools, etc.)
|
- Install required system packages (git, python3, build tools, etc.)
|
||||||
- Clone or update the LEDMatrix repository
|
- Clone or update the LEDMatrix repository
|
||||||
- Run the complete first-time installation script
|
- Run the complete first-time installation script
|
||||||
|
- Print the web interface address, then **reboot the Pi automatically** (your SSH session will disconnect; give it a few minutes to come back)
|
||||||
|
|
||||||
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. All errors are reported explicitly with actionable fixes.
|
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes.
|
||||||
|
|
||||||
**Note:** The script is safe to run multiple times and will handle existing installations gracefully.
|
**Note:** The script is safe to run multiple times and will handle existing installations gracefully.
|
||||||
|
|
||||||
@@ -336,10 +347,10 @@ If you prefer to install manually or the one-shot installer doesn't work for you
|
|||||||
ssh ledpi@ledpi
|
ssh ledpi@ledpi
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Update repositories, upgrade Raspberry Pi OS, and install prerequisites:
|
2. Update repositories, upgrade Raspberry Pi OS, and install git (`first_time_install.sh` installs the build dependencies itself: `python3-pip`, `python-dev-is-python3`, `build-essential`, `cmake`, `ninja-build` and the rest):
|
||||||
```bash
|
```bash
|
||||||
sudo apt update && sudo apt upgrade -y
|
sudo apt update && sudo apt upgrade -y
|
||||||
sudo apt install -y git python3-pip cython3 build-essential python3-dev python3-pillow scons
|
sudo apt install -y git
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Clone this repository:
|
3. Clone this repository:
|
||||||
@@ -356,6 +367,12 @@ sudo bash ./first_time_install.sh
|
|||||||
|
|
||||||
This single script installs services, dependencies, configures permissions and sudoers, and validates the setup.
|
This single script installs services, dependencies, configures permissions and sudoers, and validates the setup.
|
||||||
|
|
||||||
|
It finishes by asking whether to reboot. If you run it non-interactively — piped, over a script, or with `-y` — there is no one to ask, so **it reboots immediately without prompting**. Pass `--no-reboot-prompt` to install without rebooting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash ./first_time_install.sh -y --no-reboot-prompt
|
||||||
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -371,6 +388,10 @@ This single script installs services, dependencies, configures permissions and s
|
|||||||
|
|
||||||
### Initial Setup
|
### Initial Setup
|
||||||
|
|
||||||
|
For a complete list of every key in `config.json` and
|
||||||
|
`config_secrets.json`, see
|
||||||
|
[docs/CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md).
|
||||||
|
|
||||||
For most settings I recommend using the web interface:
|
For most settings I recommend using the web interface:
|
||||||
Edit the project via the web interface at http://[IP ADDRESS or HOSTNAME]:5000 or http://ledpi:5000 .
|
Edit the project via the web interface at http://[IP ADDRESS or HOSTNAME]:5000 or http://ledpi:5000 .
|
||||||
|
|
||||||
@@ -379,7 +400,7 @@ If you need to manually edit your config file, you can follow the steps below:
|
|||||||
<summary>Manual Config.json editing </summary>
|
<summary>Manual Config.json editing </summary>
|
||||||
|
|
||||||
1. **First-time setup**:
|
1. **First-time setup**:
|
||||||
The previous "First_time_install.sh" script should've already copied the template to create your config.json:
|
The previous `first_time_install.sh` script should've already copied the template to create your config.json:
|
||||||
|
|
||||||
2. **Edit your configuration**:
|
2. **Edit your configuration**:
|
||||||
```bash
|
```bash
|
||||||
@@ -416,7 +437,7 @@ I recommend using the web-ui "Quick Actions" to control the Display.
|
|||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
LEDMatrix uses a plugin-based architecture where all display functionality (except the core calendar) is implemented as plugins. All managers that were previously built into the core system are now available as plugins through the Plugin Store.
|
LEDMatrix uses a plugin-based architecture where all display functionality is implemented as plugins. All managers that were previously built into the core system are now available as plugins through the Plugin Store.
|
||||||
|
|
||||||
### Plugin Store
|
### Plugin Store
|
||||||
See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-plugins) for detailed installation instructions.
|
See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-plugins) for detailed installation instructions.
|
||||||
@@ -438,9 +459,9 @@ You can also install plugins directly from GitHub repositories:
|
|||||||
|
|
||||||
See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-plugins) for detailed installation instructions.
|
See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-plugins) for detailed installation instructions.
|
||||||
|
|
||||||
For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template.
|
For plugin development, the `plugins/hello-world/` plugin in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository is a starter template.
|
||||||
|
|
||||||
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
**Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Detailed Information
|
## Detailed Information
|
||||||
@@ -455,6 +476,10 @@ If you are copying my exact setup, you can likely leave the defaults alone. Howe
|
|||||||
|
|
||||||
The display settings are located in `config/config.json` under the `"display"` key and are organized into three main sections: `hardware`, `runtime`, and `display_durations`.
|
The display settings are located in `config/config.json` under the `"display"` key and are organized into three main sections: `hardware`, `runtime`, and `display_durations`.
|
||||||
|
|
||||||
|
The defaults below are the values in `config/config.template.json`. They are what applies when you haven't set a key: on every load, LEDMatrix adds any key your `config.json` lacks from the template, so `DisplayManager`'s own fallbacks are never reached on a normal install.
|
||||||
|
|
||||||
|
The web UI and the config API refuse values the rgbmatrix library can't start with. If one is written into `config.json` by hand anyway, the display logs which setting it is (`Failed to initialize RGB Matrix` in `sudo journalctl -u ledmatrix`), runs in fallback mode, and the Display tab shows the message.
|
||||||
|
|
||||||
### Hardware Configuration (`display.hardware`)
|
### Hardware Configuration (`display.hardware`)
|
||||||
|
|
||||||
These settings control the physical hardware configuration and how the matrix is driven.
|
These settings control the physical hardware configuration and how the matrix is driven.
|
||||||
@@ -464,15 +489,18 @@ These settings control the physical hardware configuration and how the matrix is
|
|||||||
- **`rows`** (integer, default: 32)
|
- **`rows`** (integer, default: 32)
|
||||||
- Number of LED rows (vertical pixels) in each panel
|
- Number of LED rows (vertical pixels) in each panel
|
||||||
- Common values: 16, 32, 48, 64
|
- Common values: 16, 32, 48, 64
|
||||||
|
- An even number from 8 to 64, the most the rgbmatrix library drives per panel
|
||||||
- Must match your physical panel configuration
|
- Must match your physical panel configuration
|
||||||
|
|
||||||
- **`cols`** (integer, default: 64)
|
- **`cols`** (integer, default: 64)
|
||||||
- Number of LED columns (horizontal pixels) in each panel
|
- Number of LED columns (horizontal pixels) in each panel
|
||||||
- Common values: 32, 64, 96, 128
|
- Common values: 32, 64, 96, 128
|
||||||
|
- At least 16, with no upper limit
|
||||||
- Must match your physical panel configuration
|
- Must match your physical panel configuration
|
||||||
|
|
||||||
- **`chain_length`** (integer, default: 2)
|
- **`chain_length`** (integer, default: 2)
|
||||||
- Number of LED panels chained together horizontally
|
- Number of LED panels chained together horizontally
|
||||||
|
- 1 to 255 (the library's Python binding stores it in one byte); longer chains lower the refresh rate
|
||||||
- If you have 2 panels side-by-side, set to 2
|
- If you have 2 panels side-by-side, set to 2
|
||||||
- If you have 4 panels in a row, set to 4
|
- If you have 4 panels in a row, set to 4
|
||||||
- Total display width = `cols × chain_length`
|
- Total display width = `cols × chain_length`
|
||||||
@@ -481,68 +509,70 @@ These settings control the physical hardware configuration and how the matrix is
|
|||||||
- Number of parallel chains (panels stacked vertically)
|
- Number of parallel chains (panels stacked vertically)
|
||||||
- Use 1 for a single row of panels
|
- Use 1 for a single row of panels
|
||||||
- Use 2 if you have panels stacked in two rows
|
- Use 2 if you have panels stacked in two rows
|
||||||
|
- 1–3, and no more than your `hardware_mapping` has outputs: `regular` and `classic` have 3 (e.g. the Adafruit Triple LED Matrix Bonnet); `adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1` and `classic-pi1` have 1. The library stops the display service outright on a mismatch, so it is refused
|
||||||
- Total display height = `rows × parallel`
|
- Total display height = `rows × parallel`
|
||||||
|
|
||||||
#### Brightness and Visual Settings
|
#### Brightness and Visual Settings
|
||||||
|
|
||||||
- **`brightness`** (integer, 0-100, default: 90)
|
- **`brightness`** (integer, 1-100, default: 90)
|
||||||
- Display brightness level
|
- Display brightness level
|
||||||
- Lower values (0-50) are dimmer, higher values (50-100) are brighter
|
- Lower values (1-50) are dimmer, higher values (50-100) are brighter
|
||||||
- Recommended: 70-90 for indoor use, 90-100 for bright environments
|
- Recommended: 70-90 for indoor use, 90-100 for bright environments
|
||||||
- Very high brightness may cause distortion or require more power
|
- Very high brightness may cause distortion or require more power
|
||||||
|
|
||||||
#### Hardware Mapping
|
#### Hardware Mapping
|
||||||
|
|
||||||
- **`hardware_mapping`** (string, default: "adafruit-hat-pwm")
|
- **`hardware_mapping`** (string, default: "adafruit-hat")
|
||||||
- Specifies which GPIO pin mapping to use for your hardware
|
- Specifies which GPIO pin mapping to use for your hardware
|
||||||
- **`"adafruit-hat-pwm"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITH the jumper mod (PWM enabled). This is the recommended setting for Adafruit hardware with the PWM jumper soldered.
|
- **`"adafruit-hat-pwm"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITH the jumper mod (PWM enabled). This is the recommended setting for Adafruit hardware with the PWM jumper soldered.
|
||||||
- **`"adafruit-hat"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITHOUT the jumper mod (no PWM). Remove `-pwm` from the value if you did not solder the jumper.
|
- **`"adafruit-hat"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITHOUT the jumper mod (no PWM). Remove `-pwm` from the value if you did not solder the jumper.
|
||||||
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic)
|
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic). Also the right choice for the Adafruit Triple LED Matrix Bonnet
|
||||||
- **`"regular-pi1"`**: Standard GPIO pin mapping for Raspberry Pi 1 (older hardware or non-standard hat mapping)
|
- **`"regular-pi1"`**: Standard GPIO pin mapping for Raspberry Pi 1 (older hardware or non-standard hat mapping)
|
||||||
|
- **`"classic"`** / **`"classic-pi1"`**: the library's original pin-outs, for old adapter boards wired to them. Not used by current HATs
|
||||||
|
- Any other name is refused. `compute-module` is only compiled in when the library is built with `ENABLE_WIDE_GPIO_COMPUTE_MODULE`, which the installer doesn't do. On a Raspberry Pi 5, `classic-pi1` isn't supported
|
||||||
- Choose the option that matches your specific hardware setup, if aren't sure try them all.
|
- Choose the option that matches your specific hardware setup, if aren't sure try them all.
|
||||||
|
- Hardware pulsing (see `disable_hardware_pulsing`) needs the panel's OE line on GPIO 18, which `adafruit-hat-pwm` and `regular` provide and `adafruit-hat` does not
|
||||||
|
|
||||||
#### PWM (Pulse Width Modulation) Settings
|
#### PWM (Pulse Width Modulation) Settings
|
||||||
|
|
||||||
These settings affect color fidelity and smoothness of color transitions:
|
These settings affect color fidelity and smoothness of color transitions:
|
||||||
|
|
||||||
- **`pwm_bits`** (integer, default: 9)
|
- **`pwm_bits`** (integer, 1-11, default: 9)
|
||||||
- Number of bits used for PWM (affects color depth)
|
- Color depth per channel: how many brightness levels each LED gets
|
||||||
- Higher values (9-11) = more color levels, smoother gradients
|
- Higher values (9-11) = more color levels, smoother gradients, lower refresh rate
|
||||||
- Lower values (7-8) = fewer color levels, but may improve stability on some hardware
|
- Lower values (7-8) = the subtlest shades are dropped for a higher refresh rate; `1` gives 8 colors
|
||||||
- Range: 1-11, recommended: 9-10
|
- Recommended: 9-10
|
||||||
|
|
||||||
- **`pwm_dither_bits`** (integer, default: 1)
|
- **`pwm_dither_bits`** (integer, 0-2, default: 1)
|
||||||
- Additional dithering bits for smoother color transitions
|
- Time-dithers the lowest color bits: their brightness comes from showing them on only some frames
|
||||||
- Helps reduce color banding in gradients
|
- Raises the refresh rate; the cost is that dark shades can shimmer slightly
|
||||||
- Higher values (1-2) = smoother gradients but may impact performance
|
- `0` = steadiest dim colors, `2` = fastest
|
||||||
- Range: 0-2, recommended: 1
|
- The rgbmatrix library accepts only 0-2; a higher value stops the display starting
|
||||||
|
|
||||||
- **`pwm_lsb_nanoseconds`** (integer, default: 130)
|
- **`pwm_lsb_nanoseconds`** (integer, 50-3000, default: 130)
|
||||||
- Least significant bit timing in nanoseconds
|
- On-time of the least significant color bit; each higher bit doubles it
|
||||||
- Controls the base timing for PWM signals
|
- Lower values = higher refresh rate, but can cost color accuracy or add ghosting on some panels
|
||||||
- Lower values = faster PWM, higher values = slower PWM
|
- Higher values = less ghosting (faint trails behind bright text on black), lower refresh rate
|
||||||
- Typical range: 100-300 nanoseconds
|
- Typical range: 100-300 nanoseconds
|
||||||
- May need adjustment if you see flickering or color issues
|
|
||||||
|
|
||||||
#### Advanced Hardware Settings
|
#### Advanced Hardware Settings
|
||||||
|
|
||||||
- **`scan_mode`** (integer, default: 0)
|
- **`scan_mode`** (integer, 0-1, default: 0)
|
||||||
- Panel scan mode (how rows are addressed)
|
- Order the rows are refreshed in: `0` = progressive, `1` = interlaced
|
||||||
- Common values: 0 (progressive), 1 (interlaced)
|
- Interlaced can look a little smoother when the refresh rate is very low, but usually shows a comb effect on anything moving
|
||||||
- Most panels use 0, but some require 1
|
- Leave at `0` unless you are tuning a slow setup
|
||||||
- Check your panel datasheet if colors appear incorrect
|
|
||||||
|
|
||||||
- **`limit_refresh_rate_hz`** (integer, default: 100)
|
- **`limit_refresh_rate_hz`** (integer, default: 100)
|
||||||
- Maximum refresh rate in Hz (frames per second)
|
- Caps the panel refresh rate in Hz; `0` = no cap
|
||||||
- Caps the refresh rate for better stability
|
- A steady cap reduces flicker caused by other activity on the Pi, and in camera recordings
|
||||||
- Lower values (60-80) = more stable, less CPU usage
|
- Scroll speeds are worked out against this value (against 100 Hz when it is `0`), so a cap the panel can actually hold keeps scrolling even
|
||||||
- Higher values (100-120) = smoother animations, more CPU usage
|
- Recommended: 80-120. `sudo python3 scripts/scroll_speeds.py --measure` reports the rate your panel really achieves
|
||||||
- Recommended: 80-100 for most setups
|
|
||||||
|
|
||||||
- **`disable_hardware_pulsing`** (boolean, default: false)
|
- **`disable_hardware_pulsing`** (boolean, default: false)
|
||||||
- Disables hardware pulsing (usually leave as false)
|
- `false` = the Pi's hardware PWM times each brightness pulse; `true` = software timing
|
||||||
- Set to `true` only if you experience timing issues
|
- Leave `false` where possible. Software timing is less exact, so a row, or the whole panel, can briefly flash brighter
|
||||||
- Most users should leave this as `false`
|
- Hardware pulsing needs the panel's OE line on GPIO 18 (`adafruit-hat-pwm`, `regular`, the Adafruit Triple LED Matrix Bonnet). With `adafruit-hat` the library uses software timing anyway
|
||||||
|
- It also needs the Pi's onboard sound driver (`snd_bcm2835`) disabled, which `first_time_install.sh` does. Set `true` only if you need the Pi's own audio
|
||||||
|
|
||||||
- **`inverse_colors`** (boolean, default: false)
|
- **`inverse_colors`** (boolean, default: false)
|
||||||
- Inverts all colors (red becomes cyan, etc.)
|
- Inverts all colors (red becomes cyan, etc.)
|
||||||
@@ -550,9 +580,9 @@ These settings affect color fidelity and smoothness of color transitions:
|
|||||||
- Set to `true` only if colors appear inverted
|
- Set to `true` only if colors appear inverted
|
||||||
|
|
||||||
- **`show_refresh_rate`** (boolean, default: false)
|
- **`show_refresh_rate`** (boolean, default: false)
|
||||||
- Displays the current refresh rate on the matrix (for debugging)
|
- Prints the live refresh rate to the console; nothing is drawn on the panel
|
||||||
- Set to `true` to see FPS on the display
|
- Readable when you stop the service and run `sudo python3 run.py` in a terminal; under the service the output is buffered
|
||||||
- Useful for troubleshooting performance issues
|
- `sudo python3 scripts/scroll_speeds.py --measure` is an easier way to see the real refresh rate
|
||||||
|
|
||||||
#### Advanced Panel Configuration (Advanced Users Only)
|
#### Advanced Panel Configuration (Advanced Users Only)
|
||||||
|
|
||||||
@@ -562,6 +592,7 @@ These settings are typically only needed for non-standard panels or custom confi
|
|||||||
- Color channel order for your LED panel
|
- Color channel order for your LED panel
|
||||||
- Common values: "RGB", "RBG", "GRB", "GBR", "BRG", "BGR"
|
- Common values: "RGB", "RBG", "GRB", "GBR", "BRG", "BGR"
|
||||||
- Most panels use "RGB", but some use "GRB" or other orders
|
- Most panels use "RGB", but some use "GRB" or other orders
|
||||||
|
- If red shows as blue, try "BGR" (the Waveshare 96x48 V2 needs it)
|
||||||
- Check your panel datasheet if colors appear wrong
|
- Check your panel datasheet if colors appear wrong
|
||||||
|
|
||||||
- **`pixel_mapper_config`** (string, default: "")
|
- **`pixel_mapper_config`** (string, default: "")
|
||||||
@@ -571,40 +602,76 @@ These settings are typically only needed for non-standard panels or custom confi
|
|||||||
- Leave empty unless you need custom mapping
|
- Leave empty unless you need custom mapping
|
||||||
- See rpi-rgb-led-matrix documentation for full options
|
- See rpi-rgb-led-matrix documentation for full options
|
||||||
|
|
||||||
|
- **`orientation`** (string, default: "normal")
|
||||||
|
- Rotates the rendered image to match how the panel is physically mounted
|
||||||
|
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
|
||||||
|
settings) if the panel is mounted upside down — useful for optimizing
|
||||||
|
where the Raspberry Pi and wiring sit relative to the mounting location
|
||||||
|
- `"90"` and `"270"` are for a panel mounted on its side; they swap the
|
||||||
|
display's width and height
|
||||||
|
- Applied independently of `pixel_mapper_config` (appended as a trailing
|
||||||
|
`Rotate:<degrees>` mapper), so custom mapper configs keep working alongside it
|
||||||
|
|
||||||
- **`row_address_type`** (integer, default: 0)
|
- **`row_address_type`** (integer, default: 0)
|
||||||
- How rows are addressed on the panel
|
- How rows are addressed on the panel
|
||||||
- Most panels use 0 (direct addressing)
|
- Most panels use 0 (direct addressing)
|
||||||
- Some panels require 1 (AB addressing) or 2 (ABC addressing)
|
- 1 = AB-addressed, 2 = direct row select, 3 = ABC-addressed,
|
||||||
|
4 = ABC shift + DE direct (SM5266), 5 = SM5368 / B707 row shift register
|
||||||
|
- ABC panels (no E line, e.g. many 128x64 FM6124 panels) use 3
|
||||||
|
- Panels with SM5368 row drivers use 5 with `led_rgb_sequence` `"BGR"` —
|
||||||
|
e.g. the Waveshare 96x48 V2 (back silkscreen `24S-A1`; the V1, `24S-A2.1`,
|
||||||
|
uses the defaults). This is what Waveshare's `96X48_1_24_SM5368` panel
|
||||||
|
type sets in their library fork.
|
||||||
|
- SM5368 row drivers are timing-sensitive: if rows jump up and down or the
|
||||||
|
bottom row shows a copy of other rows, raise `gpio_slowdown`. On a Pi 4
|
||||||
|
with an Adafruit Triple LED Matrix Bonnet, 4 left rows jumping; 6–8 gave a
|
||||||
|
stable image.
|
||||||
|
- On a Raspberry Pi 5 the rgbmatrix library currently supports only 0 and 2
|
||||||
|
(and `parallel` 1-3). Anything else would crash the display service, so on
|
||||||
|
a Pi 5 the web UI offers only 0 and 2, the config API refuses the others,
|
||||||
|
and if one is set in `config.json` anyway the display logs why and runs in
|
||||||
|
fallback mode
|
||||||
- Check your panel datasheet if display appears corrupted
|
- Check your panel datasheet if display appears corrupted
|
||||||
|
|
||||||
- **`multiplexing`** (integer, default: 0)
|
- **`multiplexing`** (integer, 0-22, default: 0)
|
||||||
- Panel multiplexing type
|
- How pixels are wired on outdoor/specialty panels (P10, P8, P4 and P3 outdoor modules and similar) whose LEDs aren't laid out in straight rows
|
||||||
- 0 = no multiplexing (standard panels)
|
- `0` = direct (standard indoor panels)
|
||||||
- Higher values for panels with different multiplexing schemes
|
- `1` Stripe, `2` Checkered, `3` Spiral, `4` ZStripe, `5` ZnMirrorZStripe,
|
||||||
- Check your panel datasheet for the correct value
|
`6` Coreman, `7` Kaler2Scan, `8` ZStripeUneven, `9` P10-128x4-Z,
|
||||||
|
`10` QiangLiQ8, `11` InversedZStripe, `12`–`14` P10Outdoor1R1G1B v1–v3,
|
||||||
|
`15` P10CoremanMapper, `16` P8Outdoor1R1G1B, `17` FlippedStripe,
|
||||||
|
`18` P10-32x16-HalfScan, `19` P10-32x16-QuarterScan, `20` P3Outdoor-64x64,
|
||||||
|
`21` DoubleZMultiplex, `22` P4Outdoor-80x40
|
||||||
|
- If the image is scrambled in a repeating pattern, try the value named after your panel first
|
||||||
|
|
||||||
|
- **`panel_type`** (string, default: `""`)
|
||||||
|
- Sends a start-up initialization sequence to driver chips that need one
|
||||||
|
- `""` = Standard (no initialization) — right for most panels, including FM6124 / FM6124D / FM6124DJ
|
||||||
|
- `"FM6126A"` or `"FM6127"` for panels with those chips; try `"FM6126A"` if the panel stays dark or lights only the first pixel on Standard
|
||||||
|
|
||||||
### Runtime Configuration (`display.runtime`)
|
### Runtime Configuration (`display.runtime`)
|
||||||
|
|
||||||
These settings control runtime behavior and GPIO timing:
|
These settings control runtime behavior and GPIO timing:
|
||||||
|
|
||||||
- **`gpio_slowdown`** (integer, default: 3)
|
- **`gpio_slowdown`** (integer, default: 3)
|
||||||
- GPIO timing slowdown factor
|
- GPIO timing slowdown factor (0-10): slows GPIO writes so the panel electronics keep up. Higher is more reliable but lowers the refresh rate
|
||||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
- **Critical setting**: depends on your Raspberry Pi model and your panel
|
||||||
- **Raspberry Pi 3**: Use 3
|
- **Raspberry Pi Zero/1**: 0-1
|
||||||
- **Raspberry Pi 4**: Use 4
|
- **Raspberry Pi 2/3**: 1-3
|
||||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
- **Raspberry Pi 4**: 2-4 (the config template ships 3)
|
||||||
- **Raspberry Pi Zero/1**: Use 1-2
|
- **Raspberry Pi 5**: 1–3 in PIO mode (`rp1_rio: 0`, the default). Start at `1` (the library treats `0` as `1` there) and raise it a step at a time if the image flickers or shows garbage — chained panels are the likeliest to need it
|
||||||
- Incorrect values can cause display corruption, flickering, or system instability
|
- Panels on `row_address_type` 5 (SM5368 row drivers) can need 6-8 on a Pi 4
|
||||||
|
- Too low: garbage, flicker or rows jumping. Too high: a lower refresh rate
|
||||||
- If you experience issues, try adjusting this value up or down by 1
|
- If you experience issues, try adjusting this value up or down by 1
|
||||||
|
|
||||||
|
- **`rp1_rio`** (integer, 0 or 1, default: 0) — Raspberry Pi 5 only
|
||||||
|
- Which driver the Pi 5's RP1 chip uses: `0` = PIO (default, less CPU), `1` = RIO (registered I/O, can reach a higher refresh rate)
|
||||||
|
- In RIO mode the effect of `gpio_slowdown` is inverted: higher values may be faster
|
||||||
|
- Ignored on a Pi 0-4, and applied only if the installed rgbmatrix library supports it
|
||||||
|
|
||||||
### Display Durations (`display.display_durations`)
|
### Display Durations (`display.display_durations`)
|
||||||
|
|
||||||
Controls how long each display module stays visible in seconds before switching to the next one.
|
Controls how long each installed plugin stays visible in seconds before switching to the next one, keyed by plugin id.
|
||||||
|
|
||||||
- **`calendar`** (integer, default: 30)
|
|
||||||
- Duration in seconds for the calendar display
|
|
||||||
- Increase for more time to read dates/events
|
|
||||||
- Decrease to cycle through other displays faster
|
|
||||||
|
|
||||||
- **Plugin-specific durations**
|
- **Plugin-specific durations**
|
||||||
- Each plugin can have its own duration setting
|
- Each plugin can have its own duration setting
|
||||||
@@ -623,9 +690,10 @@ Controls how long each display module stays visible in seconds before switching
|
|||||||
### Display Format Settings
|
### Display Format Settings
|
||||||
|
|
||||||
- **`use_short_date_format`** (boolean, default: true)
|
- **`use_short_date_format`** (boolean, default: true)
|
||||||
- Use short date format (e.g., "Jan 15") instead of long format (e.g., "January 15th")
|
- Currently has no effect. The web UI still saves it, but no core code
|
||||||
- Set to `false` for longer, more readable dates
|
reads it. Scoreboard plugins that offer a short date format read the
|
||||||
- Set to `true` to save space and show more information
|
setting from their own plugin config instead. See
|
||||||
|
[CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md#display--other-keys).
|
||||||
|
|
||||||
### Dynamic Duration Settings (`display.dynamic_duration`)
|
### Dynamic Duration Settings (`display.dynamic_duration`)
|
||||||
|
|
||||||
@@ -634,7 +702,7 @@ Controls how long each display module stays visible in seconds before switching
|
|||||||
- Some plugins can automatically adjust their display time based on content
|
- Some plugins can automatically adjust their display time based on content
|
||||||
- This setting limits how long they can extend (prevents one display from dominating)
|
- This setting limits how long they can extend (prevents one display from dominating)
|
||||||
- Example: If set to 60, a plugin can extend up to 60 seconds even if it requests longer
|
- Example: If set to 60, a plugin can extend up to 60 seconds even if it requests longer
|
||||||
- Leave unset to use the default cap (typically 90 seconds)
|
- Leave unset to use the default cap (180 seconds; the web UI accepts 30-1800)
|
||||||
|
|
||||||
### Example Configuration
|
### Example Configuration
|
||||||
|
|
||||||
@@ -681,6 +749,14 @@ Controls how long each display module stays visible in seconds before switching
|
|||||||
- Verify `hardware_mapping` matches your HAT/connection type
|
- Verify `hardware_mapping` matches your HAT/connection type
|
||||||
- Try adjusting `gpio_slowdown`
|
- Try adjusting `gpio_slowdown`
|
||||||
- Ensure your display doesn't need the E-Addressable line
|
- Ensure your display doesn't need the E-Addressable line
|
||||||
|
- If it went blank right after a settings change, the Display tab shows a "simulation mode" banner, and `sudo journalctl -u ledmatrix` shows `Failed to initialize RGB Matrix` followed by the reason. When LEDMatrix refused the settings (for example more than 64 `rows`, `parallel` 2 on an `adafruit-hat` mapping, a misspelled `hardware_mapping`, or on a Raspberry Pi 5 a `row_address_type` other than 0 or 2), the message names each one: change them, save, and restart the display service. Otherwise the library itself failed, and its own message just before names the problem
|
||||||
|
- A repeating scramble points at `row_address_type` or `multiplexing`; a panel that stays dark, at `panel_type`
|
||||||
|
|
||||||
|
**Rows jump up and down, or the bottom row repeats other rows:**
|
||||||
|
- Raise `gpio_slowdown` a step at a time (SM5368 panels on `row_address_type` 5 can need 6-8 on a Pi 4)
|
||||||
|
|
||||||
|
**A row or the whole panel briefly flashes brighter:**
|
||||||
|
- Set `disable_hardware_pulsing` to `false` (needs the OE line on GPIO 18; see `hardware_mapping`)
|
||||||
|
|
||||||
**Colors are wrong or inverted:**
|
**Colors are wrong or inverted:**
|
||||||
- Check `led_rgb_sequence` (try "GRB" if "RGB" doesn't work)
|
- Check `led_rgb_sequence` (try "GRB" if "RGB" doesn't work)
|
||||||
@@ -705,15 +781,21 @@ Controls how long each display module stays visible in seconds before switching
|
|||||||
<details>
|
<details>
|
||||||
<summary>Manual SSH Commands (for reference)</summary>
|
<summary>Manual SSH Commands (for reference)</summary>
|
||||||
|
|
||||||
The quick actions essentially just execute the following commands on the Pi.
|
The web interface's quick actions (Start/Stop/Restart Display) call
|
||||||
|
`sudo systemctl start|stop|restart ledmatrix.service` — see
|
||||||
|
`execute_system_action()` in
|
||||||
|
[`web_interface/blueprints/api_v3/system.py`](web_interface/blueprints/api_v3/system.py).
|
||||||
|
The service runs [`run.py`](run.py) as root.
|
||||||
|
|
||||||
From the project root directory (ex: /home/ledpi/LEDMatrix):
|
To run the display in the foreground instead (for debugging), stop the service
|
||||||
|
first, then from the project root (e.g. `/home/ledpi/LEDMatrix`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo python3 display_controller.py
|
sudo systemctl stop ledmatrix.service
|
||||||
|
sudo python3 run.py # add -d for debug logging
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start the display cycle but only stays active as long as your ssh session is active.
|
This only runs as long as your SSH session stays open.
|
||||||
|
|
||||||
### Convenience Scripts
|
### Convenience Scripts
|
||||||
|
|
||||||
@@ -755,9 +837,11 @@ sudo ./scripts/install/install_service.sh
|
|||||||
|
|
||||||
The script will:
|
The script will:
|
||||||
- Detect your user account and home directory
|
- Detect your user account and home directory
|
||||||
- Install the service file with the correct paths
|
- Install `ledmatrix.service` (display, runs as root), `ledmatrix-web.service`
|
||||||
- Enable the service to start on boot
|
(web interface, runs as your user) and the `ledmatrix-update-verify` units,
|
||||||
- Start the service immediately
|
with the correct paths
|
||||||
|
- Enable them to start on boot
|
||||||
|
- Start them immediately
|
||||||
|
|
||||||
### Managing the Service
|
### Managing the Service
|
||||||
|
|
||||||
@@ -864,7 +948,7 @@ sudo systemctl enable ledmatrix-web.service
|
|||||||
- **On-Demand Controls**: Start specific displays (weather, stocks, sports) on demand
|
- **On-Demand Controls**: Start specific displays (weather, stocks, sports) on demand
|
||||||
- **Service Management**: Start/stop the main display service
|
- **Service Management**: Start/stop the main display service
|
||||||
- **System Controls**: Restart, update code, and manage the system
|
- **System Controls**: Restart, update code, and manage the system
|
||||||
- **API Metrics**: Monitor API usage and system performance
|
- **System Stats**: CPU, memory and temperature on the Overview tab
|
||||||
- **Logs**: View system logs in real-time
|
- **Logs**: View system logs in real-time
|
||||||
|
|
||||||
### Troubleshooting Web Interface
|
### Troubleshooting Web Interface
|
||||||
@@ -881,9 +965,10 @@ sudo systemctl enable ledmatrix-web.service
|
|||||||
3. Check if another service is using port 5000
|
3. Check if another service is using port 5000
|
||||||
|
|
||||||
**Service Fails to Start:**
|
**Service Fails to Start:**
|
||||||
1. Check Python dependencies are installed
|
1. Check Python dependencies are installed. The installer puts them in the
|
||||||
2. Verify the virtual environment is set up correctly
|
system Python with `pip install --break-system-packages` (there is no
|
||||||
3. Check file permissions and ownership
|
virtual environment), so `python3 -c "import flask"` should succeed.
|
||||||
|
2. Check file permissions and ownership
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Use one of these channels, in order of preference:
|
|||||||
maintainer.
|
maintainer.
|
||||||
- Direct link: <https://github.com/ChuckBuilds/LEDMatrix/security/advisories/new>
|
- Direct link: <https://github.com/ChuckBuilds/LEDMatrix/security/advisories/new>
|
||||||
2. **Discord DM**. Send a direct message to a moderator on the
|
2. **Discord DM**. Send a direct message to a moderator on the
|
||||||
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT). Don't post in
|
[LEDMatrix Discord](https://discord.gg/RdrC37rEag). Don't post in
|
||||||
public channels.
|
public channels.
|
||||||
|
|
||||||
Please include:
|
Please include:
|
||||||
@@ -61,8 +61,31 @@ Out of scope (please report upstream):
|
|||||||
LEDMatrix is designed for trusted local networks. Several limitations
|
LEDMatrix is designed for trusted local networks. Several limitations
|
||||||
are intentional rather than vulnerabilities:
|
are intentional rather than vulnerabilities:
|
||||||
|
|
||||||
- **No web UI authentication.** The web interface assumes the network
|
- **Web UI authentication is optional and off by default.** Out of the
|
||||||
it's running on is trusted. Don't expose port 5000 to the internet.
|
box the web interface assumes the network it's running on is trusted.
|
||||||
|
Setting a password under **General > Security** makes every page and
|
||||||
|
API route require a login or an API token (`Authorization: Bearer`),
|
||||||
|
with wrong passwords rate-limited per address
|
||||||
|
(`web_interface/auth.py`). Deliberately left open even then: requests
|
||||||
|
from the Pi itself (loopback without proxy headers; a reverse proxy on
|
||||||
|
the Pi must add `X-Forwarded-For`, or every request it relays counts as
|
||||||
|
local), the Wi-Fi setup flow while the Pi is in access-point mode,
|
||||||
|
static files, and a status-only `/api/v3/health`. The password is a
|
||||||
|
werkzeug hash and tokens are stored as SHA-256, in
|
||||||
|
`config/config_secrets.json`, which no API returns. There is no TLS:
|
||||||
|
over plain HTTP the password and tokens cross the LAN in the clear, so
|
||||||
|
still don't expose port 5000 to the internet; put a TLS reverse proxy
|
||||||
|
or a VPN in front for remote access. Anyone with shell access to the Pi
|
||||||
|
can turn login off (`scripts/reset_web_password.py`), which is the
|
||||||
|
documented recovery path.
|
||||||
|
"Trusted network" does not mean "trusted websites", though: any page
|
||||||
|
a LAN user opens could make their browser POST to the Pi. So the
|
||||||
|
interface refuses a `POST`/`PUT`/`PATCH`/`DELETE` whose `Origin` (or
|
||||||
|
`Referer`) header names another site (`web_interface/origin_guard.py`),
|
||||||
|
and `/api/v3/system/action` only accepts JSON or HTMX requests. Tools
|
||||||
|
that send neither header (curl, Home Assistant, the MQTT bridge) are
|
||||||
|
unaffected. Not covered: DNS rebinding, and anyone who can reach the
|
||||||
|
port directly.
|
||||||
- **Plugins run unsandboxed.** Installed plugins execute in the same
|
- **Plugins run unsandboxed.** Installed plugins execute in the same
|
||||||
Python process as the display loop with full file-system and
|
Python process as the display loop with full file-system and
|
||||||
network access. Review plugin code (especially third-party plugins
|
network access. Review plugin code (especially third-party plugins
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# assets/
|
||||||
|
|
||||||
|
Static assets bundled with LEDMatrix. **Do not delete these directories** —
|
||||||
|
several look unused from core code alone but are resolved at runtime by
|
||||||
|
installed store plugins.
|
||||||
|
|
||||||
|
| Directory | Used by |
|
||||||
|
|---|---|
|
||||||
|
| `fonts/` | Core (`FontManager`, `DisplayManager`) and most plugins |
|
||||||
|
| `sports/` | Core logo tooling (`src/logo_downloader.py`) and the sports scoreboard plugins; team logos are downloaded here on demand |
|
||||||
|
| `stocks/` | `ledmatrix-stocks` plugin (`crypto_icons/`, `ticker_icons/`) |
|
||||||
|
| `weather/` | `ledmatrix-weather` plugin (weather icons) |
|
||||||
|
| `news_logos/` | `news` plugin |
|
||||||
|
| `broadcast_logos/` | `news` and `odds-ticker` plugins |
|
||||||
|
| `static_images/` | Legacy examples referenced in the `static-image` plugin's docs; the plugin itself stores uploads under `assets/plugins/<plugin-id>/uploads/` |
|
||||||
|
| `plugins/` | Per-plugin uploaded files (`assets/plugins/<plugin-id>/uploads/`), served by the web interface |
|
||||||
|
|
||||||
|
Plugins resolve these paths relative to the LEDMatrix install directory, so
|
||||||
|
the directories are part of the de-facto plugin API even where no file in
|
||||||
|
this repo references them. New plugins should bundle their own assets or
|
||||||
|
use the per-plugin upload directory instead of adding top-level
|
||||||
|
directories here.
|
||||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 467 B |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
# bandit.yaml — LEDMatrix bandit configuration
|
||||||
|
# https://bandit.readthedocs.io/en/latest/config.html
|
||||||
|
#
|
||||||
|
# Skips are justified by the specific codebase context documented below.
|
||||||
|
# Do not remove skips without updating the justification comment.
|
||||||
|
|
||||||
|
skips:
|
||||||
|
# B104: Binding to all interfaces (0.0.0.0)
|
||||||
|
# Intentional — the Flask server binds 0.0.0.0 for LAN access on a Raspberry Pi.
|
||||||
|
# This is not internet-facing and is documented in web_interface/app.py.
|
||||||
|
- B104
|
||||||
|
|
||||||
|
# B603: subprocess call without shell=True
|
||||||
|
# All subprocess.run() calls in this codebase use list arguments (confirmed by
|
||||||
|
# grep — zero uses of shell=True in src/ or web_interface/). List args prevent
|
||||||
|
# shell injection. See src/common/permission_utils.py for the primary usage.
|
||||||
|
- B603
|
||||||
|
|
||||||
|
# B607: Starting a process with a partial executable path
|
||||||
|
# The subprocess calls invoke system utilities (systemctl, sudo, git) by name.
|
||||||
|
# These are fixed-list invocations, not user-controlled, and rely on PATH.
|
||||||
|
- B607
|
||||||
|
|
||||||
|
exclude_dirs:
|
||||||
|
- tests
|
||||||
|
- test
|
||||||
|
- venv
|
||||||
|
- .venv
|
||||||
|
- rpi-rgb-led-matrix-master
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"web_display_autostart": true,
|
"web_display_autostart": true,
|
||||||
|
"auto_update": {
|
||||||
|
"enabled": false,
|
||||||
|
"channel": "stable"
|
||||||
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"mode": "per-day",
|
"mode": "per-day",
|
||||||
@@ -88,6 +92,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timezone": "America/New_York",
|
"timezone": "America/New_York",
|
||||||
|
"target_fps": 100,
|
||||||
"location": {
|
"location": {
|
||||||
"city": "Tampa",
|
"city": "Tampa",
|
||||||
"state": "Florida",
|
"state": "Florida",
|
||||||
@@ -109,22 +114,56 @@
|
|||||||
"inverse_colors": false,
|
"inverse_colors": false,
|
||||||
"show_refresh_rate": false,
|
"show_refresh_rate": false,
|
||||||
"led_rgb_sequence": "RGB",
|
"led_rgb_sequence": "RGB",
|
||||||
"limit_refresh_rate_hz": 100
|
"limit_refresh_rate_hz": 100,
|
||||||
|
"pixel_mapper_config": "",
|
||||||
|
"orientation": "normal",
|
||||||
|
"row_address_type": 0,
|
||||||
|
"multiplexing": 0,
|
||||||
|
"panel_type": ""
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"gpio_slowdown": 3,
|
"gpio_slowdown": 3,
|
||||||
"rp1_rio": 0
|
"rp1_rio": 0
|
||||||
},
|
},
|
||||||
|
"double_sided": {
|
||||||
|
"enabled": false,
|
||||||
|
"copies": 2,
|
||||||
|
"axis": "horizontal"
|
||||||
|
},
|
||||||
"display_durations": {},
|
"display_durations": {},
|
||||||
|
"plugin_rotation_order": [],
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
|
"live_in_ticker": false,
|
||||||
|
"live_weight": 3,
|
||||||
|
"favorite_live_weight": 5,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"scroll_speed": 50,
|
"scroll_speed": 50,
|
||||||
"separator_width": 32,
|
"separator_width": 32,
|
||||||
"plugin_order": [],
|
"plugin_order": [],
|
||||||
"excluded_plugins": [],
|
"excluded_plugins": [],
|
||||||
"target_fps": 125,
|
"target_fps": 125,
|
||||||
"buffer_ahead": 2
|
"buffer_ahead": 2,
|
||||||
|
"intra_plugin_gap": 8,
|
||||||
|
"render_width_pct": 100,
|
||||||
|
"min_content_separation": 24,
|
||||||
|
"min_cut_gap": 6,
|
||||||
|
"continuous_scroll": true,
|
||||||
|
"smooth_scroll": true,
|
||||||
|
"extend_threshold_screens": 2.0,
|
||||||
|
"auto_trim": true,
|
||||||
|
"trim_threshold": 10,
|
||||||
|
"content_padding": 8,
|
||||||
|
"min_plugin_width": 8,
|
||||||
|
"lead_in_width": 0,
|
||||||
|
"plugins_per_cycle": 6,
|
||||||
|
"max_plugin_width_ratio": 0.0,
|
||||||
|
"overflow_mode": "rotate",
|
||||||
|
"dynamic_duration_enabled": true,
|
||||||
|
"min_cycle_duration": 60,
|
||||||
|
"max_cycle_duration": 240,
|
||||||
|
"frame_based_scrolling": true,
|
||||||
|
"scroll_delay": 0.02
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
@@ -133,9 +172,7 @@
|
|||||||
"follower_position": "left"
|
"follower_position": "left"
|
||||||
},
|
},
|
||||||
"plugin_system": {
|
"plugin_system": {
|
||||||
"plugins_directory": "plugin-repos",
|
"plugins_directory": "plugin-repos"
|
||||||
"auto_discover": true,
|
|
||||||
"auto_load_enabled": true
|
|
||||||
},
|
},
|
||||||
"web-ui-info": {
|
"web-ui-info": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
{
|
{
|
||||||
"youtube": {
|
|
||||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
|
||||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
|
||||||
},
|
|
||||||
"github": {
|
"github": {
|
||||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dev_plugins_dir": "~/.ledmatrix-dev-plugins",
|
||||||
|
"github_user": "ChuckBuilds",
|
||||||
|
"plugins_repo": "ledmatrix-plugins",
|
||||||
|
"plugins_branch": "main"
|
||||||
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
"""Legacy entry point: runs ``run.py``, which is the one to use.
|
||||||
|
|
||||||
|
``python3 run.py`` (``-e`` for the emulator, ``-d`` for debug logging) is how
|
||||||
|
the display service and the docs start LEDMatrix. This file used to import
|
||||||
|
``src.display_controller.main`` directly, which skipped what run.py sets up
|
||||||
|
first -- ``sys.dont_write_bytecode`` (root-owned ``__pycache__`` in plugin
|
||||||
|
directories blocks the web service from updating them), the ``-e``/``-d``
|
||||||
|
flags, and the logging configuration. It now runs run.py exactly as
|
||||||
|
``python3 run.py`` would, with the same arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import runpy
|
||||||
|
|
||||||
# Add the project root directory to Python path
|
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
from src.display_controller import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
runpy.run_path(
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "run.py"),
|
||||||
|
run_name="__main__",
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Adaptive Layout & Font Scaling
|
||||||
|
|
||||||
|
`src/adaptive_layout.py` lets a plugin render legibly on **any** panel size
|
||||||
|
(64x32, 128x32, 96x48, 128x64, 256x64, ...) without hand-tuned per-display
|
||||||
|
layouts. It is **opt-in**: nothing changes for plugins that don't use it.
|
||||||
|
|
||||||
|
It generalizes three patterns proven in the plugin ecosystem:
|
||||||
|
|
||||||
|
| Pattern | Origin | Core API |
|
||||||
|
|---|---|---|
|
||||||
|
| Geometry scale factor vs. a design size | f1-scoreboard | `ctx.px(base)` / `ctx.scale` |
|
||||||
|
| Breakpoint tiers | masters-tournament | `ctx.tier` / `ctx.by_tier({...})` |
|
||||||
|
| "Largest crisp font that fits" ladder | baseball-scoreboard | `ctx.fit_text(...)` and friends |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Every `BasePlugin` has a lazy `self.layout` (a `LayoutContext` for the
|
||||||
|
current logical display size, rebuilt automatically if the size changes)
|
||||||
|
and a one-liner `self.draw_fit(...)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
from src.adaptive_layout import LADDER_ARCADE
|
||||||
|
|
||||||
|
b = self.layout.bounds.inset(1) # Region(0,0,W,H) minus 1px margin
|
||||||
|
rows = b.split_v(3, 1, 1, gap=1) # 3/5 for time, 1/5 each for the rest
|
||||||
|
|
||||||
|
self.draw_fit(self.time_str, rows[0], ladder=LADDER_ARCADE)
|
||||||
|
self.draw_fit(self.weekday, rows[1]) # default LADDER_GRID
|
||||||
|
self.draw_fit(self.date_str, rows[2])
|
||||||
|
self.display_manager.update_display()
|
||||||
|
```
|
||||||
|
|
||||||
|
On 128x64 the time renders at press_start 24px; on 64x32 it steps down to
|
||||||
|
8px. The rows partition the height, so bands can never overlap — no more
|
||||||
|
`y = height - 7` magic numbers.
|
||||||
|
|
||||||
|
## Region — rect algebra
|
||||||
|
|
||||||
|
`Region(x, y, w, h)` is a frozen dataclass. All carving clamps to
|
||||||
|
non-negative dimensions, so degenerate panels behave.
|
||||||
|
|
||||||
|
- Carving: `inset(dx, dy)`, `top_band(h)`, `bottom_band(h)`,
|
||||||
|
`middle(top_h, bottom_h)`, `left_col(w)`, `right_col(w)`,
|
||||||
|
`split_h(*weights, gap=0)`, `split_v(*weights, gap=0)`
|
||||||
|
- Placement: `align_xy(w, h, align, valign)`, `center_xy(w, h)`,
|
||||||
|
`contains(w, h)`, `.center`, `.right`, `.bottom`
|
||||||
|
|
||||||
|
Scoreboard-style layout:
|
||||||
|
|
||||||
|
```python
|
||||||
|
b = self.layout.bounds
|
||||||
|
status = b.top_band(self.layout.px(7))
|
||||||
|
detail = b.bottom_band(self.layout.px(7))
|
||||||
|
score_area = b.middle(status.h, detail.h)
|
||||||
|
away_slot, home_slot = b.left_col(b.h), b.right_col(b.h)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Font ladders — discrete, never fractional
|
||||||
|
|
||||||
|
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes, so
|
||||||
|
fonts are never scaled continuously. A `FontLadder` is an ordered tuple of
|
||||||
|
`FontStep(family, size_px)` rungs, largest first; fitting walks down until
|
||||||
|
the measured text fits.
|
||||||
|
|
||||||
|
- `LADDER_GRID` (default): X11 BDFs at native sizes — 10x20 → 9x18 → 9x15 →
|
||||||
|
8x13 → 7x13 → 6x13 → 6x12 → 6x10 → 6x9 → 5x8 → 5x7 → 4x6 → tom-thumb.
|
||||||
|
Body text, labels, multi-row content.
|
||||||
|
- `LADDER_ARCADE`: PressStart2P at 32/24/16/8 (integer multiples of its 8px
|
||||||
|
grid). Headline text: clocks, scores.
|
||||||
|
|
||||||
|
Custom ladders are just tuples — e.g. to add your plugin's registered font
|
||||||
|
on top: `(FontStep("myplugin::digits", 16),) + LADDER_GRID`.
|
||||||
|
|
||||||
|
## LayoutContext
|
||||||
|
|
||||||
|
Built per (width, height); exposes facts and fit queries:
|
||||||
|
|
||||||
|
- `bounds`, `width`, `height`, `aspect`
|
||||||
|
- `tier` by height (`xs`≤16, `sm`≤32, `md`≤48, `lg`≤64, `xl`) and
|
||||||
|
`width_tier` (`narrow`≤64, `normal`≤128, `wide`≤256, `ultrawide`)
|
||||||
|
- `is_wide_short` — aspect ≥ 2.5 and height ≤ 32 (the classic 128x32 shape)
|
||||||
|
- `scale` — `min(w/design_w, h/design_h)` vs. your manifest's
|
||||||
|
`display.design_size` (default 128x32). **Geometry only** — gaps, icon
|
||||||
|
and logo sizes via `px(base, minimum, maximum)`; fonts use ladders.
|
||||||
|
- `by_tier({"sm": 10, "lg": 18})` — value for the nearest defined tier
|
||||||
|
at-or-below the panel's tier.
|
||||||
|
- `fit_text(text, box, ladder, ellipsis=True)` → `FitResult` — largest rung
|
||||||
|
that fits; ellipsizes as a last resort. Cached per (text, box, ladder).
|
||||||
|
- `fit_text_proportional(text, box, base_size_px, ladder, ellipsis=True, scale=None)` —
|
||||||
|
rung closest to (not exceeding) `base_size_px * scale`, still capped to
|
||||||
|
what fits the box. Use this instead of `fit_text` when several
|
||||||
|
independently-fitted elements need to stay visually harmonious as the
|
||||||
|
panel grows — `fit_text` maximizes *each one* within its own region,
|
||||||
|
which can make one element (e.g. a score with a generous box) balloon
|
||||||
|
out of proportion to a neighbor that scales by geometry (e.g. logos
|
||||||
|
sized via `px()`), even though each individual pick is "correct" in
|
||||||
|
isolation. `base_size_px` is normally the element's existing classic/
|
||||||
|
fixed font size. `scale` defaults to `self.scale` (the conservative
|
||||||
|
min-of-both-axes factor `px()` uses); pass an axis-specific value when
|
||||||
|
the surrounding composition already scales that way — e.g. a scoreboard
|
||||||
|
whose logo slots track height alone (`min(height, width // 2)`) should
|
||||||
|
size its text by `height / design_height` too, or the text reads as
|
||||||
|
under-scaled next to bigger logos on a panel that only grew taller.
|
||||||
|
- `fit_lines(lines, box, ladder, spacing)` — every line fits the width and
|
||||||
|
the stack fits the height (measures the actual strings).
|
||||||
|
- `font_for_rows(rows, box_h, ladder)` — largest rung whose line height
|
||||||
|
fits `rows` rows.
|
||||||
|
|
||||||
|
`FitResult` carries the ready-to-use `font` (drops straight into
|
||||||
|
`display_manager.draw_text(font=...)`), the possibly-ellipsized `text`,
|
||||||
|
ink `width`/`height`, `baseline`, `y_offset`, `line_height`, and `fits`.
|
||||||
|
|
||||||
|
## Adaptive images
|
||||||
|
|
||||||
|
`src/adaptive_images.py` is the image counterpart to `fit_text`, exposed as
|
||||||
|
`self.layout.fit_image(...)` (cached per panel size) and the one-liner
|
||||||
|
`self.draw_image(...)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Team logo: trim its transparent padding, fill the slot height (the
|
||||||
|
# football/hockey pattern), cached across frames by a stable key
|
||||||
|
self.draw_image(logo, regs.away_slot, mode="fill_height",
|
||||||
|
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||||
|
|
||||||
|
# Album art: cover-crop a square, faces kept by the top anchor
|
||||||
|
self.draw_image(art, row.art, mode="cover", anchor="top")
|
||||||
|
|
||||||
|
# Pixel flags / sprite icons: NEAREST keeps hard edges
|
||||||
|
from src.adaptive_images import RESAMPLE_NEAREST
|
||||||
|
self.draw_image(flag, box, resample=RESAMPLE_NEAREST)
|
||||||
|
```
|
||||||
|
|
||||||
|
Modes: `contain` (letterbox, default), `cover` (crop-to-fill),
|
||||||
|
`fill_height` (logo-style), `stretch`. Unlike PIL's `thumbnail()`
|
||||||
|
(downscale-only — why imagery stays tiny on big panels) fitting **upscales
|
||||||
|
by default**; pass `upscale=False` for the legacy behavior. Results are
|
||||||
|
cached per (image, box size, options) with a bounded LRU — always pass a
|
||||||
|
stable `cache_key` (e.g. `"logo:KC"`) for images you reload. The module
|
||||||
|
also exports the Pillow-compat `RESAMPLE_LANCZOS`/`RESAMPLE_NEAREST`
|
||||||
|
constants so plugins can drop their local shims.
|
||||||
|
|
||||||
|
## Composite layouts
|
||||||
|
|
||||||
|
Pre-carved Region arrangements for the layouts plugins keep rebuilding:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.adaptive_layout import scoreboard_regions, media_row
|
||||||
|
|
||||||
|
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||||
|
# regs.away_slot / home_slot — logo slots (logo_slot = min(H, W // 2),
|
||||||
|
# capped so a center reserve always exists —
|
||||||
|
# see below)
|
||||||
|
# regs.status_band — top band (replaces the magic y = 1)
|
||||||
|
# regs.score_area — center gap, plus a controlled bleed into
|
||||||
|
# each logo slot (replaces y = H//2 - 3)
|
||||||
|
# regs.detail_band — bottom band (replaces y = H - 7)
|
||||||
|
# regs.bottom_left / bottom_right — record/timeout corners
|
||||||
|
|
||||||
|
row = media_row(self.layout.bounds, ctx=self.layout) # art left, text right
|
||||||
|
```
|
||||||
|
|
||||||
|
Both work on the full panel or on a scroll-mode card Region. They return
|
||||||
|
Regions and never draw — compose them with `draw_fit`/`draw_image`.
|
||||||
|
|
||||||
|
**`scoreboard_regions`'s center reserve.** The raw `logo_slot = min(H, W//2)`
|
||||||
|
formula has a blind spot: at exactly 2:1 aspect ratio (width = 2×height —
|
||||||
|
two, four, or more square modules stacked into a taller panel, e.g.
|
||||||
|
96x48, 128x64, 256x128) the two logo slots mathematically claim the
|
||||||
|
*entire* width, leaving zero pixels for a center column no matter how
|
||||||
|
big the panel gets. Wide panels (the 128x32 design baseline, 192x48,
|
||||||
|
256x32) never hit this, since height is already the tighter constraint
|
||||||
|
there. Two parameters fix it without any plugin-side code:
|
||||||
|
`min_center_fraction`/`min_center_design_px` guarantee a real minimum
|
||||||
|
center reserve at any aspect ratio, and `score_bleed_fraction` lets the
|
||||||
|
score's *fit box* extend a controlled amount into each logo slot — the
|
||||||
|
same way a real broadcast scoreboard's numbers cross slightly into the
|
||||||
|
team marks flanking them — so a short score string never has to truncate
|
||||||
|
even on the tightest aspect ratios. All three have sane defaults; override
|
||||||
|
them per call if a plugin's card proportions genuinely differ.
|
||||||
|
|
||||||
|
## Preserving user customization
|
||||||
|
|
||||||
|
Adaptive layout supplies *defaults*; explicit user configuration wins:
|
||||||
|
|
||||||
|
- **User-set fonts win.** If the plugin's config has an explicit
|
||||||
|
`font`/`font_size` for an element, load it as before and skip the ladder —
|
||||||
|
fit only when the user hasn't overridden (see the football-scoreboard
|
||||||
|
`_resolve_element_fit` pattern).
|
||||||
|
- **Offsets apply on top.** `customization.layout.<element>.{x_offset,y_offset}`
|
||||||
|
style knobs translate the *computed* region as a final step:
|
||||||
|
`region.offset(user_dx, user_dy)`. `draw_image(..., offset=(dx, dy))`
|
||||||
|
does the same for images.
|
||||||
|
- **Colors pass through.** `draw_fit`/`draw_fitted_text` take explicit
|
||||||
|
`color=` params; adaptive mode never repaints semantic or user-chosen
|
||||||
|
colors.
|
||||||
|
|
||||||
|
## Manifest declaration
|
||||||
|
|
||||||
|
Declare the size your layout was authored against so `ctx.scale` means
|
||||||
|
something:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"display": { "design_size": { "width": 128, "height": 32 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Also available under `requires.display_size`: `min_width`, `min_height`,
|
||||||
|
`max_width`, `max_height`.
|
||||||
|
|
||||||
|
## Performance notes (Pi)
|
||||||
|
|
||||||
|
Fit queries are cached, so cost is O(unique strings). For per-second text
|
||||||
|
(clocks, live scores), fit on a **shape placeholder** and reuse the font:
|
||||||
|
|
||||||
|
```python
|
||||||
|
fit = self.layout.fit_text("00:00", box, ladder=LADDER_ARCADE) # cached once
|
||||||
|
self.display_manager.draw_text(current_time, font=fit.font, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing across sizes
|
||||||
|
|
||||||
|
The harness already renders every plugin at a spread of sizes (now
|
||||||
|
including 96x48):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/check_plugin.py --plugin <plugin-id> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||||
|
python scripts/render_plugin.py --plugin <plugin-id> --width 96 --height 48
|
||||||
|
```
|
||||||
|
|
||||||
|
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||||
|
mediated draw calls with negative coordinates in
|
||||||
|
`negative_coordinate_calls` (raw-PIL draws remain uncovered).
|
||||||
|
|
||||||
|
Reference migration: the **text-display** plugin's `font_mode: "auto"`.
|
||||||
@@ -10,22 +10,27 @@ This guide covers advanced LEDMatrix features for users and developers, includin
|
|||||||
|
|
||||||
Vegas scroll mode displays content from multiple plugins in a continuous horizontal scroll, similar to news tickers seen in Las Vegas casinos. Plugins contribute content segments that flow across the display in a seamless ticker-style presentation.
|
Vegas scroll mode displays content from multiple plugins in a continuous horizontal scroll, similar to news tickers seen in Las Vegas casinos. Plugins contribute content segments that flow across the display in a seamless ticker-style presentation.
|
||||||
|
|
||||||
### Display Modes
|
### How a Plugin Takes Part
|
||||||
|
|
||||||
**SCROLL (Continuous Scrolling):**
|
Each plugin has a *Vegas participation*:
|
||||||
- Content scrolls continuously left
|
|
||||||
- Smooth, fluid motion
|
|
||||||
- Best for news-ticker style displays
|
|
||||||
|
|
||||||
**FIXED_SEGMENT (Fixed-Width Block):**
|
**`scroll` (the default):**
|
||||||
- Plugin gets fixed-width block on display
|
- The plugin's content scrolls by with everyone else's
|
||||||
- Content doesn't scroll out of its segment
|
- Best for news-ticker style content: scores, headlines, prices, the time
|
||||||
- Multiple plugins can share the display simultaneously
|
|
||||||
|
|
||||||
**STATIC (Scroll Pauses):**
|
**`pause`:**
|
||||||
- Scrolling pauses when content is fully visible
|
- The scroll stops when the plugin's turn comes round
|
||||||
- Displays for specified duration, then resumes scrolling
|
- The plugin draws the whole panel for its display duration, then the
|
||||||
- Best for content that needs to be fully read
|
scroll resumes
|
||||||
|
- Best for content that needs to be read in full, or alerts
|
||||||
|
|
||||||
|
**`exclude`:**
|
||||||
|
- The plugin is left out of Vegas mode
|
||||||
|
|
||||||
|
A plugin declares its default; set `vegas_participation` in a plugin's
|
||||||
|
config to override it (see [Per-Plugin Configuration](#per-plugin-configuration)).
|
||||||
|
Older documentation also describes a *fixed segment* mode; Vegas never
|
||||||
|
implemented one, and it has always behaved exactly like `scroll`.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@@ -47,6 +52,11 @@ Enable Vegas mode in `config/config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Vegas mode can also be configured entirely from the web UI — the
|
||||||
|
**Display** tab has a Vegas Scroll Mode section (enable toggle, scroll
|
||||||
|
speed, separator width, dynamic duration, and more), so hand-editing
|
||||||
|
JSON is optional.
|
||||||
|
|
||||||
**Configuration Options:**
|
**Configuration Options:**
|
||||||
|
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
@@ -57,7 +67,99 @@ Enable Vegas mode in `config/config.json`:
|
|||||||
| `plugin_order` | `[]` | Plugin display order (empty = auto) |
|
| `plugin_order` | `[]` | Plugin display order (empty = auto) |
|
||||||
| `excluded_plugins` | `[]` | Plugins to exclude from Vegas mode |
|
| `excluded_plugins` | `[]` | Plugins to exclude from Vegas mode |
|
||||||
| `target_fps` | `125` | Target frame rate |
|
| `target_fps` | `125` | Target frame rate |
|
||||||
| `buffer_ahead` | `2` | Number of panels to render ahead |
|
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
||||||
|
|
||||||
|
This table is a subset — `display.vegas_scroll` supports 30 keys in
|
||||||
|
total. See the full list in
|
||||||
|
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
||||||
|
|
||||||
|
### Live Content in the Ticker
|
||||||
|
|
||||||
|
By default, live content **preempts** Vegas mode: while any plugin reports
|
||||||
|
live priority, the display controller refuses to run the ticker and shows
|
||||||
|
that plugin's full-screen display instead. You get a big readable scoreboard,
|
||||||
|
but the marquee stops entirely for the duration of the game.
|
||||||
|
|
||||||
|
Set `live_in_ticker` to keep the ticker running and let live content take
|
||||||
|
**extra turns inside it** instead:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"vegas_scroll": {
|
||||||
|
"live_in_ticker": true,
|
||||||
|
"live_weight": 3,
|
||||||
|
"favorite_live_weight": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Why weights exist
|
||||||
|
|
||||||
|
The rotation is otherwise a strict round robin — every plugin appears exactly
|
||||||
|
once per cycle. With a dozen plugins enabled, a live score comes round once a
|
||||||
|
lap and can be minutes old by the time you see it. A weight of *N* gives a
|
||||||
|
plugin *N* slots per cycle.
|
||||||
|
|
||||||
|
The slots are placed by **Smooth Weighted Round-Robin**, the same scheduler
|
||||||
|
the sports plugins use internally to rotate their own games. The important
|
||||||
|
property is that repeats are *spread through the cycle* rather than clumped:
|
||||||
|
three appearances in a row followed by a long silence would be worse than not
|
||||||
|
boosting at all.
|
||||||
|
|
||||||
|
Twelve plugins, with a favorite's baseball game and an ordinary live hockey
|
||||||
|
game (`live_weight: 3`, `favorite_live_weight: 5`):
|
||||||
|
|
||||||
|
```
|
||||||
|
baseball > hockey > weather > clock > baseball
|
||||||
|
stocks > news > flights > baseball > hockey
|
||||||
|
calendar > f1 > music > baseball > tides
|
||||||
|
birds > hockey > baseball
|
||||||
|
```
|
||||||
|
|
||||||
|
18 slots for 12 plugins. Baseball appears 5 times, hockey 3, everything else
|
||||||
|
once, and no plugin ever appears twice in a row — **including across the seam**
|
||||||
|
where the cycle loops back on itself. Smooth Weighted Round-Robin schedules the
|
||||||
|
heaviest item first and usually last as well, so the strip would otherwise show
|
||||||
|
it twice running at exactly the one join a within-cycle check cannot see. The
|
||||||
|
trailing repeat is moved into the widest remaining gap. Where a double is
|
||||||
|
unavoidable — a plugin holding most of the slots has to neighbour itself — the
|
||||||
|
schedule is left as it is.
|
||||||
|
|
||||||
|
#### Where the weight comes from
|
||||||
|
|
||||||
|
For each plugin in the rotation, in order:
|
||||||
|
|
||||||
|
1. **The plugin's own answer.** If it implements
|
||||||
|
`get_vegas_priority_weight()` and returns a number, that wins. This is the
|
||||||
|
only route for favorite-team awareness — the core can see *that* a game is
|
||||||
|
live, but not *whose*, so a scoreboard has to say so itself.
|
||||||
|
2. **The core's default.** When the plugin returns `None` (the base-class
|
||||||
|
default), a plugin where both `has_live_priority()` and `has_live_content()`
|
||||||
|
are true gets `live_weight`.
|
||||||
|
3. **Everything else** gets 1.
|
||||||
|
|
||||||
|
Because of step 2, **existing plugins need no changes** — any scoreboard with
|
||||||
|
`live_priority` enabled already gets extra turns. Step 1 is opt-in, for
|
||||||
|
plugins that want to distinguish a favorite's game from any other live game.
|
||||||
|
|
||||||
|
Weights are clamped to 1–10. A weight of 1 is no boost; a weight below 1 would
|
||||||
|
drop the plugin from the rotation entirely, which is never what is meant.
|
||||||
|
|
||||||
|
#### Things worth knowing
|
||||||
|
|
||||||
|
- **Weights are per plugin, not per game.** A scoreboard showing four live
|
||||||
|
games still occupies one slot at a time, rotating its own games within that
|
||||||
|
slot using its own `favorite_live_boost`. This controls how often the
|
||||||
|
*plugin* comes round.
|
||||||
|
- **The ticker is zero-sum.** Giving baseball 5 slots does not make the cycle
|
||||||
|
faster; it makes the cycle *longer* and everything else proportionally
|
||||||
|
rarer. If you want live scores sooner in wall-clock terms, pair this with a
|
||||||
|
smaller `plugins_per_cycle`.
|
||||||
|
- **Frequency is not freshness.** Each appearance redraws from the plugin's
|
||||||
|
current data (`refresh_updated_plugins()` drops cached content when a
|
||||||
|
plugin's data changes), but how current that data is depends on the
|
||||||
|
plugin's own `live_update_interval`. Showing a stale score five times a lap
|
||||||
|
is no better than showing it once.
|
||||||
|
- **Everything still appears.** A boost never starves another plugin out of
|
||||||
|
the cycle; low-weight plugins keep their single slot.
|
||||||
|
|
||||||
### Per-Plugin Configuration
|
### Per-Plugin Configuration
|
||||||
|
|
||||||
@@ -67,8 +169,7 @@ Override Vegas behavior for specific plugins:
|
|||||||
{
|
{
|
||||||
"my_plugin": {
|
"my_plugin": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"vegas_mode": "scroll",
|
"vegas_participation": "pause",
|
||||||
"vegas_panel_count": 2,
|
|
||||||
"display_duration": 10
|
"display_duration": 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,77 +179,80 @@ Override Vegas behavior for specific plugins:
|
|||||||
|
|
||||||
| Setting | Values | Description |
|
| Setting | Values | Description |
|
||||||
|---------|--------|-------------|
|
|---------|--------|-------------|
|
||||||
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin |
|
| `vegas_participation` | `scroll`, `pause`, `exclude` | How this plugin takes part: its content scrolls by, the scroll pauses for its turn and shows it full screen, or it is left out. Unset uses the plugin's own default |
|
||||||
| `vegas_panel_count` | `1-10` | Width in panels (1 panel = display width) |
|
| `display_duration` | seconds | How long a `pause` plugin holds the screen |
|
||||||
| `display_duration` | seconds | Pause duration for STATIC mode |
|
| `vegas_width_pct` | 10–100 | Width of this plugin's card, as a percentage of the panel |
|
||||||
|
| `vegas_overflow` | `rotate`, `truncate` | What to do when its content is wider than its allowance |
|
||||||
|
| `vegas_max_width_screens` | number of screens | The widest its card may be |
|
||||||
|
|
||||||
|
These are core-owned settings (see
|
||||||
|
[PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md)): every
|
||||||
|
plugin accepts them whether or not its own schema lists them. Set them in
|
||||||
|
the plugin's section of config.json, in the web UI's **Config Editor**
|
||||||
|
tab.
|
||||||
|
|
||||||
|
Some plugins also offer a `vegas_mode` setting of their own (`scroll`,
|
||||||
|
`fixed` or `static`). It still works — `static` pauses, the other two scroll
|
||||||
|
— but `vegas_participation` takes precedence, and `fixed` has never done
|
||||||
|
anything different from `scroll`. The old `vegas_panel_count` setting never
|
||||||
|
had an effect and is deprecated (removed in 3.9.0).
|
||||||
|
|
||||||
### Plugin Integration (Developer Guide)
|
### Plugin Integration (Developer Guide)
|
||||||
|
|
||||||
|
All of these have defaults in
|
||||||
|
[`BasePlugin`](../src/plugin_system/base_plugin.py); override only what you
|
||||||
|
need. The reference is
|
||||||
|
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-scroll-hooks).
|
||||||
|
|
||||||
**1. Implement Content Method:**
|
**1. Implement Content Method:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_vegas_content(self):
|
def get_vegas_content(self):
|
||||||
"""
|
# Return a PIL Image, a list of Images, or None.
|
||||||
Return PIL Image or list of Images for Vegas mode.
|
# A single image is one block; a list becomes one item per image.
|
||||||
|
return [self._render_game(game) for game in self.games]
|
||||||
Returns:
|
|
||||||
PIL.Image or list[PIL.Image]: Content to display
|
|
||||||
- Single image: fixed-width content
|
|
||||||
- List of images: multiple segments
|
|
||||||
- None: skip this cycle
|
|
||||||
"""
|
|
||||||
# Example: Return single wide image
|
|
||||||
img = Image.new('RGB', (256, 32))
|
|
||||||
# ... render your content ...
|
|
||||||
return img
|
|
||||||
|
|
||||||
# Example: Return multiple segments
|
|
||||||
return [image1, image2, image3]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Specify Content Type:**
|
If it returns `None` (the default), Vegas falls back to the plugin's
|
||||||
|
`scroll_helper` image, then to capturing `display()` output
|
||||||
|
(`PluginAdapter.get_content()` in
|
||||||
|
[`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)).
|
||||||
|
|
||||||
|
**2. Declare how the plugin takes part:**
|
||||||
|
|
||||||
|
Most plugins need nothing: the default is `scroll`. A plugin that should
|
||||||
|
pause the scroll, or stay out of Vegas, says so in `manifest.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vegas_participation": "pause"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The user's own `vegas_participation` setting overrides the manifest. When
|
||||||
|
the answer depends on state, override the method instead:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_vegas_content_type(self):
|
def get_vegas_participation(self):
|
||||||
"""
|
# 'scroll' | 'pause' | 'exclude'
|
||||||
Specify how content should be handled.
|
return 'pause' if self._alert_is_live() else 'scroll'
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 'multi' | 'static' | 'none'
|
|
||||||
"""
|
|
||||||
return 'multi' # Default for most plugins
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Optionally Specify Display Mode:**
|
A plugin written for an older core that declares nothing keeps its
|
||||||
|
behaviour: `get_vegas_display_mode()` returning `VegasDisplayMode.STATIC`
|
||||||
```python
|
pauses, `get_vegas_content_type()` returning `'none'` excludes, and
|
||||||
def get_vegas_display_mode(self):
|
everything else scrolls. `get_supported_vegas_modes()`,
|
||||||
"""
|
`get_vegas_segment_width()` and the SCROLL / FIXED_SEGMENT distinction are
|
||||||
Preferred display mode for this plugin.
|
deprecated (removed in 3.9.0): Vegas never read them.
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 'scroll' | 'fixed' | 'static'
|
|
||||||
"""
|
|
||||||
return 'scroll'
|
|
||||||
|
|
||||||
def get_supported_vegas_modes(self):
|
|
||||||
"""
|
|
||||||
List of supported modes.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: ['scroll', 'fixed', 'static']
|
|
||||||
"""
|
|
||||||
return ['scroll', 'static']
|
|
||||||
```
|
|
||||||
|
|
||||||
### Content Rendering Guidelines
|
### Content Rendering Guidelines
|
||||||
|
|
||||||
**Image Dimensions:**
|
**Image Dimensions:**
|
||||||
- **Height:** Must match display height (typically 32 pixels)
|
- **Height:** Must match display height (typically 32 pixels)
|
||||||
- **Width:** Varies by mode:
|
- **Width:** Any width for `scroll` (recommended 64-512 pixels);
|
||||||
- SCROLL: Any width (recommended 64-512 pixels)
|
`get_vegas_render_width()` is the width Vegas would like, and it narrows
|
||||||
- FIXED_SEGMENT: `panel_count * display_width`
|
`display_manager` to match while it asks. A `pause` plugin draws the
|
||||||
- STATIC: Any width, optimized for readability
|
whole panel in `display()`.
|
||||||
|
|
||||||
**Color Mode:**
|
**Color Mode:**
|
||||||
- Use RGB color mode
|
- Use RGB color mode
|
||||||
@@ -198,17 +302,10 @@ class WeatherPlugin(BasePlugin):
|
|||||||
def get_vegas_content(self):
|
def get_vegas_content(self):
|
||||||
"""Return cached Vegas image"""
|
"""Return cached Vegas image"""
|
||||||
return self.vegas_image
|
return self.vegas_image
|
||||||
|
|
||||||
def get_vegas_content_type(self):
|
|
||||||
return 'multi'
|
|
||||||
|
|
||||||
def get_vegas_display_mode(self):
|
|
||||||
return 'scroll'
|
|
||||||
|
|
||||||
def get_supported_vegas_modes(self):
|
|
||||||
return ['scroll', 'static']
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
It scrolls, the default participation, so it declares nothing else.
|
||||||
|
|
||||||
### System Architecture
|
### System Architecture
|
||||||
|
|
||||||
Vegas mode consists of four core components working together to provide smooth 125 FPS continuous scrolling:
|
Vegas mode consists of four core components working together to provide smooth 125 FPS continuous scrolling:
|
||||||
@@ -276,15 +373,23 @@ Vegas mode consists of four core components working together to provide smooth 1
|
|||||||
5. Compose into continuous stream with separators
|
5. Compose into continuous stream with separators
|
||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
- `get_stream_content()` - Returns current stream content as PIL Image
|
- `get_next_segment()` - Returns the next buffered `ContentSegment` (or `None`)
|
||||||
- `advance_stream(pixels)` - Advances stream by N pixels
|
- `take_next_group(count=None, offscreen_only=False)` - Hands over the next
|
||||||
- `refresh_stream()` - Regenerates stream from current plugins
|
slice of the rotation as `(plugin_id, images)` groups
|
||||||
|
- `get_grouped_content_for_composition()` - Buffered images grouped by plugin
|
||||||
|
- `mark_plugin_updated(plugin_id)` / `process_updates()` - Refresh one
|
||||||
|
plugin's segment in place when its data changes
|
||||||
|
- `refresh()` - Re-read the plugin list and config
|
||||||
|
- `advance_cycle()` - Clear the active buffer when a scroll cycle completes
|
||||||
|
|
||||||
|
(`src/vegas_mode/stream_manager.py`)
|
||||||
|
|
||||||
#### 3. PluginAdapter
|
#### 3. PluginAdapter
|
||||||
|
|
||||||
**Responsibilities:**
|
**Responsibilities:**
|
||||||
- Convert plugin content to scrollable images
|
- Convert plugin content to scrollable images
|
||||||
- Handle different Vegas display modes (SCROLL, FIXED, STATIC)
|
- Fetch the content of `scroll` plugins (a `pause` plugin is drawn by
|
||||||
|
its own `display()` when the scroll pauses; see StreamManager)
|
||||||
- Manage fallback for plugins without Vegas support
|
- Manage fallback for plugins without Vegas support
|
||||||
- Cache plugin content for performance
|
- Cache plugin content for performance
|
||||||
|
|
||||||
@@ -293,21 +398,16 @@ Vegas mode consists of four core components working together to provide smooth 1
|
|||||||
- Calls `get_vegas_content()` if available
|
- Calls `get_vegas_content()` if available
|
||||||
- Falls back to `display()` method if not
|
- Falls back to `display()` method if not
|
||||||
|
|
||||||
2. **Handle display mode:**
|
2. **Participation** is decided by the StreamManager, not here
|
||||||
- SCROLL: Returns image as-is for continuous scrolling
|
(`resolve_vegas_participation()` in
|
||||||
- FIXED_SEGMENT: Creates fixed-width block (panel_count * display_width)
|
[`base_plugin.py`](../src/plugin_system/base_plugin.py)): `exclude`
|
||||||
- STATIC: Marks content for pause-when-visible behavior
|
plugins never reach the adapter, and `pause` plugins are not fetched.
|
||||||
|
|
||||||
3. **Content type handling:**
|
|
||||||
- `multi`: Multiple segments (list of images)
|
|
||||||
- `static`: Single static image
|
|
||||||
- `none`: Skip this plugin in current cycle
|
|
||||||
|
|
||||||
**Fallback Behavior:**
|
**Fallback Behavior:**
|
||||||
- If plugin doesn't implement Vegas methods:
|
- If plugin doesn't implement Vegas methods:
|
||||||
- Calls plugin's `display()` method
|
- Calls plugin's `display()` method
|
||||||
- Captures rendered display as static image
|
- Captures rendered display as static image
|
||||||
- Treats as fixed segment
|
- Scrolls it by as one block
|
||||||
- Ensures all plugins work in Vegas mode without explicit support
|
- Ensures all plugins work in Vegas mode without explicit support
|
||||||
|
|
||||||
#### 4. RenderPipeline
|
#### 4. RenderPipeline
|
||||||
@@ -332,10 +432,14 @@ Vegas mode consists of four core components working together to provide smooth 1
|
|||||||
- **Frame Rate Control:** Precise timing to maintain 125 FPS
|
- **Frame Rate Control:** Precise timing to maintain 125 FPS
|
||||||
- **Pre-rendered Content:** Plugins pre-render during update()
|
- **Pre-rendered Content:** Plugins pre-render during update()
|
||||||
|
|
||||||
**Scroll Speed Calculation:**
|
**Scroll Speed Calculation:** motion is by elapsed time; `target_fps` paces
|
||||||
|
the render loop, not the speed.
|
||||||
```python
|
```python
|
||||||
pixels_per_frame = (scroll_speed / target_fps)
|
# frame_based_scrolling: false
|
||||||
scroll_position += pixels_per_frame * elapsed_time
|
scroll_position += scroll_speed * elapsed_time # scroll_speed in px/s
|
||||||
|
# frame_based_scrolling: true (the default) -- not stepping, just a clamp
|
||||||
|
applied = clamp(scroll_speed * scroll_delay, 0.1, 5) / scroll_delay
|
||||||
|
scroll_position += applied * elapsed_time
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Component Interactions
|
#### Component Interactions
|
||||||
@@ -406,7 +510,7 @@ All components use thread-safe patterns:
|
|||||||
If a plugin doesn't implement Vegas methods:
|
If a plugin doesn't implement Vegas methods:
|
||||||
- System calls the plugin's `display()` method
|
- System calls the plugin's `display()` method
|
||||||
- Captures the rendered display as a static image
|
- Captures the rendered display as a static image
|
||||||
- Treats it as a fixed segment
|
- Scrolls it by as one block
|
||||||
|
|
||||||
This ensures all plugins work in Vegas mode, even without explicit support.
|
This ensures all plugins work in Vegas mode, even without explicit support.
|
||||||
|
|
||||||
@@ -451,7 +555,8 @@ time when something is active.
|
|||||||
|
|
||||||
### REST API Reference
|
### REST API Reference
|
||||||
|
|
||||||
The API is mounted at `/api/v3` (`web_interface/app.py:144`).
|
The API is mounted at `/api/v3` (the `api_v3` blueprint, registered in
|
||||||
|
`web_interface/app.py`). Full details: [REST_API_REFERENCE.md](REST_API_REFERENCE.md#display-control).
|
||||||
|
|
||||||
#### Start On-Demand Display
|
#### Start On-Demand Display
|
||||||
|
|
||||||
@@ -507,24 +612,36 @@ curl http://localhost:5000/api/v3/display/on-demand/status
|
|||||||
|
|
||||||
# Response:
|
# Response:
|
||||||
{
|
{
|
||||||
"active": true,
|
"status": "success",
|
||||||
"plugin_id": "weather",
|
"data": {
|
||||||
"mode": "weather",
|
"state": {
|
||||||
"remaining": 25.5,
|
"active": true,
|
||||||
"pinned": false,
|
"plugin_id": "weather",
|
||||||
"status": "active"
|
"mode": "weather",
|
||||||
|
"duration": 30,
|
||||||
|
"pinned": false,
|
||||||
|
"status": "running",
|
||||||
|
"last_updated": 1234567890.1
|
||||||
|
},
|
||||||
|
"service": {"active": true, "returncode": 0, "stdout": "active", "stderr": ""}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When nothing is running on demand, `data.state` is
|
||||||
|
`{"active": false, "status": "idle", "last_updated": null}`.
|
||||||
|
|
||||||
> There is no public Python on-demand API. The display controller's
|
> There is no public Python on-demand API. The display controller's
|
||||||
> on-demand machinery is internal — drive it through the REST endpoints
|
> on-demand machinery is internal — drive it through the REST endpoints
|
||||||
> above (or the web UI buttons), which write a request into the cache
|
> above (or the web UI buttons). The API handlers
|
||||||
> manager under the `display_on_demand_request` key
|
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||||
> (`web_interface/blueprints/api_v3.py:1622,1687`) that the controller
|
> `web_interface/blueprints/api_v3/display.py`) write a request into the cache
|
||||||
> polls at `src/display_controller.py:921`. A separate
|
> manager under the `display_on_demand_request` key, which
|
||||||
|
> `DisplayController._poll_on_demand_requests()`
|
||||||
|
> (`src/display_controller.py`) picks up. A separate
|
||||||
> `display_on_demand_config` key is used by the controller itself
|
> `display_on_demand_config` key is used by the controller itself
|
||||||
> during activation to track what's currently running (written at
|
> during activation (`_activate_on_demand()`) to track what's
|
||||||
> `display_controller.py:1195`, cleared at `:1221`).
|
> currently running, and is cleared by `_clear_on_demand()`.
|
||||||
|
|
||||||
### Duration Modes
|
### Duration Modes
|
||||||
|
|
||||||
@@ -646,13 +763,13 @@ keys helps troubleshoot stuck states.
|
|||||||
**When Set:** Every display loop iteration
|
**When Set:** Every display loop iteration
|
||||||
**Auto-Cleared:** Never (continuously updated)
|
**Auto-Cleared:** Never (continuously updated)
|
||||||
|
|
||||||
**4. display_on_demand_processed_id** (TTL: 5 minutes)
|
**4. display_on_demand_processed_id** (TTL: 1 hour)
|
||||||
```
|
```text
|
||||||
"uuid-string-of-last-processed-request"
|
"uuid-string-of-last-processed-request"
|
||||||
```
|
```
|
||||||
**Purpose:** Prevents duplicate request processing
|
**Purpose:** Prevents duplicate request processing
|
||||||
**When Set:** After processing request
|
**When Set:** After processing request
|
||||||
**Auto-Cleared:** After 5 minutes TTL
|
**Auto-Cleared:** After 1 hour TTL
|
||||||
|
|
||||||
### When Manual Clearing is Needed
|
### When Manual Clearing is Needed
|
||||||
|
|
||||||
@@ -685,9 +802,9 @@ keys helps troubleshoot stuck states.
|
|||||||
The cache is stored as JSON files under one of:
|
The cache is stored as JSON files under one of:
|
||||||
|
|
||||||
- `/var/cache/ledmatrix/` (preferred when the service has permission)
|
- `/var/cache/ledmatrix/` (preferred when the service has permission)
|
||||||
- `~/.cache/ledmatrix/`
|
- `~/.ledmatrix_cache/`
|
||||||
- `/opt/ledmatrix/cache/`
|
- `/opt/ledmatrix/cache/`
|
||||||
- `/tmp/ledmatrix-cache/` (fallback)
|
- `$TMPDIR/ledmatrix_cache/` (fallback)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Find the cache dir actually in use
|
# Find the cache dir actually in use
|
||||||
@@ -711,8 +828,9 @@ cache.clear_cache('display_on_demand_request')
|
|||||||
cache.clear_cache('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
|
> `CacheManager` also has a `delete(key)` method — a thin wrapper over
|
||||||
> `delete()` method on `CacheManager`.
|
> `clear_cache(key)` — so `cache.delete('display_on_demand_config')`
|
||||||
|
> works equally well.
|
||||||
|
|
||||||
### Cache Impact on Running Service
|
### Cache Impact on Running Service
|
||||||
|
|
||||||
@@ -730,7 +848,7 @@ The display controller automatically handles cleanup:
|
|||||||
- **Config key**: Cleared when on-demand stops
|
- **Config key**: Cleared when on-demand stops
|
||||||
- **State key**: Updated every display loop iteration
|
- **State key**: Updated every display loop iteration
|
||||||
- **Request key**: Expires after 1 hour TTL (or after processing)
|
- **Request key**: Expires after 1 hour TTL (or after processing)
|
||||||
- **Processed ID**: Expires after 5 minutes TTL
|
- **Processed ID**: Expires after 1 hour TTL
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -760,7 +878,13 @@ Cache Check → Background Fetch → Partial Data → Completion → Cache
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Enable background service per plugin in `config/config.json`:
|
Core does not read a `background_service` config block: the service itself
|
||||||
|
(`src/background_data_service.py`) is a process-wide singleton, and its
|
||||||
|
worker count is whatever the first caller of `get_background_service()`
|
||||||
|
passes. The sports scoreboard plugins read their own
|
||||||
|
`background_service` settings and pass them to it, so the exact keys and
|
||||||
|
where they sit (top level or per league) are defined by each plugin's
|
||||||
|
`config_schema.json`. A typical block looks like:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -781,11 +905,11 @@ Enable background service per plugin in `config/config.json`:
|
|||||||
|
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `enabled` | `false` | Enable background service for this plugin |
|
| `enabled` | plugin-defined | Use the background service for this plugin's fetches |
|
||||||
| `max_workers` | `3` | Max concurrent background tasks |
|
| `max_workers` | `3` | Max concurrent background tasks |
|
||||||
| `request_timeout` | `30` | Timeout per API request (seconds) |
|
| `request_timeout` | `30` | Timeout per API request (seconds) |
|
||||||
| `max_retries` | `3` | Retry attempts on failure |
|
| `max_retries` | `3` | Retry attempts on failure |
|
||||||
| `priority` | `1` | Task priority (1=highest, 10=lowest) |
|
| `priority` | `1` | Stored on each request (higher number = higher priority, per `FetchRequest`), but the service runs requests in submission order; it does not reorder by priority |
|
||||||
|
|
||||||
### Performance Impact
|
### Performance Impact
|
||||||
|
|
||||||
@@ -802,9 +926,9 @@ Enable background service per plugin in `config/config.json`:
|
|||||||
|
|
||||||
The background data service is used by all of the sports scoreboard
|
The background data service is used by all of the sports scoreboard
|
||||||
plugins (football, hockey, baseball/MLB, basketball, soccer, lacrosse,
|
plugins (football, hockey, baseball/MLB, basketball, soccer, lacrosse,
|
||||||
F1, UFC), the odds ticker, and the leaderboard plugin. Each plugin's
|
F1, UFC), the odds ticker, and the leaderboard plugin. Each plugin reads
|
||||||
`background_service` block (under its own config namespace) follows the
|
its own `background_service` block (under its own config namespace); check
|
||||||
same shape as the example above.
|
that plugin's `config_schema.json` for the keys it accepts.
|
||||||
|
|
||||||
### Error Handling & Fallback
|
### Error Handling & Fallback
|
||||||
|
|
||||||
@@ -821,9 +945,6 @@ same shape as the example above.
|
|||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run background service test
|
|
||||||
python test_background_service.py
|
|
||||||
|
|
||||||
# Check logs for background operations
|
# Check logs for background operations
|
||||||
sudo journalctl -u ledmatrix -f | grep "background"
|
sudo journalctl -u ledmatrix -f | grep "background"
|
||||||
```
|
```
|
||||||
@@ -832,15 +953,21 @@ sudo journalctl -u ledmatrix -f | grep "background"
|
|||||||
|
|
||||||
**View Statistics:**
|
**View Statistics:**
|
||||||
```python
|
```python
|
||||||
from src.background_data_service import BackgroundDataService
|
from src.background_data_service import get_background_service
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
|
||||||
service = BackgroundDataService()
|
service = get_background_service(CacheManager())
|
||||||
stats = service.get_statistics()
|
stats = service.get_statistics()
|
||||||
print(f"Active tasks: {stats['active_tasks']}")
|
print(f"Active: {stats['active_requests']}")
|
||||||
print(f"Completed: {stats['completed']}")
|
print(f"Completed: {stats['completed_requests']}")
|
||||||
print(f"Failed: {stats['failed']}")
|
print(f"Failed: {stats['failed_requests']}")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Other keys: `total_requests`, `cached_hits`, `cache_misses`,
|
||||||
|
`average_fetch_time`, `completed_requests_count` (results currently held in
|
||||||
|
memory) — see `BackgroundDataService.get_statistics()` in
|
||||||
|
[`src/background_data_service.py`](../src/background_data_service.py).
|
||||||
|
|
||||||
**Enable Debug Logging:**
|
**Enable Debug Logging:**
|
||||||
```python
|
```python
|
||||||
import logging
|
import logging
|
||||||
@@ -851,6 +978,10 @@ logging.getLogger('src.background_data_service').setLevel(logging.DEBUG)
|
|||||||
|
|
||||||
## 5. Permission Management
|
## 5. Permission Management
|
||||||
|
|
||||||
|
Ownership, modes, sudo rules and the repair scripts are listed in
|
||||||
|
[PERMISSIONS.md](PERMISSIONS.md). This section covers the helpers code uses
|
||||||
|
to keep files shareable.
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
LEDMatrix uses a dual-user architecture: the display service runs as root (hardware access), while the web interface runs as a non-privileged user. Centralized permission management ensures both can access necessary files.
|
LEDMatrix uses a dual-user architecture: the display service runs as root (hardware access), while the web interface runs as a non-privileged user. Centralized permission management ensures both can access necessary files.
|
||||||
@@ -875,6 +1006,7 @@ from src.common.permission_utils import (
|
|||||||
ensure_file_permissions,
|
ensure_file_permissions,
|
||||||
get_config_file_mode,
|
get_config_file_mode,
|
||||||
get_assets_file_mode,
|
get_assets_file_mode,
|
||||||
|
get_assets_dir_mode,
|
||||||
get_plugin_file_mode,
|
get_plugin_file_mode,
|
||||||
get_cache_dir_mode
|
get_cache_dir_mode
|
||||||
)
|
)
|
||||||
@@ -883,7 +1015,10 @@ from src.common.permission_utils import (
|
|||||||
ensure_directory_permissions(Path("assets/sports"), get_assets_dir_mode())
|
ensure_directory_permissions(Path("assets/sports"), get_assets_dir_mode())
|
||||||
|
|
||||||
# Set file permissions after writing
|
# Set file permissions after writing
|
||||||
ensure_file_permissions(Path("config/config.json"), get_config_file_mode())
|
# (get_config_file_mode requires the file path — secrets files get a
|
||||||
|
# stricter mode than the main config)
|
||||||
|
config_path = Path("config/config.json")
|
||||||
|
ensure_file_permissions(config_path, get_config_file_mode(config_path))
|
||||||
```
|
```
|
||||||
|
|
||||||
### When to Use Utilities
|
### When to Use Utilities
|
||||||
@@ -910,7 +1045,7 @@ ensure_file_permissions(Path("config/config.json"), get_config_file_mode())
|
|||||||
| Config (secrets) | `rw-r-----` | `0o640` | Owner write, group read |
|
| Config (secrets) | `rw-r-----` | `0o640` | Owner write, group read |
|
||||||
| Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
| Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
||||||
| Plugins | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
| Plugins | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
||||||
| Cache files | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
| Cache files | `rw-rw----` | `0o660` | Owner/group write, no world access (`_CACHE_FILE_MODE` in `src/cache/disk_cache.py`) |
|
||||||
|
|
||||||
**Directory Permissions:**
|
**Directory Permissions:**
|
||||||
|
|
||||||
@@ -938,7 +1073,7 @@ from src.common.permission_utils import ensure_file_permissions, get_config_file
|
|||||||
config_path = Path("config/config.json")
|
config_path = Path("config/config.json")
|
||||||
with open(config_path, 'w') as f:
|
with open(config_path, 'w') as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
ensure_file_permissions(config_path, get_config_file_mode())
|
ensure_file_permissions(config_path, get_config_file_mode(config_path))
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pattern 3: Downloading Logo**
|
**Pattern 3: Downloading Logo**
|
||||||
@@ -981,43 +1116,28 @@ These core utilities **already handle permissions** - you don't need to call per
|
|||||||
|
|
||||||
### Manual Fixes
|
### Manual Fixes
|
||||||
|
|
||||||
If you encounter permission issues:
|
[PERMISSIONS.md](PERMISSIONS.md) lists who owns what on an installed system,
|
||||||
|
the expected modes, and which `scripts/fix_perms/` script to run as which
|
||||||
|
user. In short:
|
||||||
|
|
||||||
```bash
|
- `fix_assets_permissions.sh`, `fix_cache_permissions.sh` and
|
||||||
# Fix all permissions at once
|
`fix_plugin_permissions.sh` are run with `sudo`.
|
||||||
sudo ./scripts/fix_permissions.sh
|
- `fix_web_permissions.sh` is run as the web interface user, without
|
||||||
|
`sudo` (it refuses to run as root and calls `sudo` itself where needed).
|
||||||
|
It resets project file ownership for that user, then makes the two
|
||||||
|
helper scripts the web user may run as root (`safe_plugin_rm.sh`,
|
||||||
|
`safe_pip_install.sh`) root-owned again and restores `config_secrets.json`
|
||||||
|
to its owner, the `ledmatrix` group and mode `640`. It does not write
|
||||||
|
sudoers rules; `scripts/install/configure_web_sudo.sh` does that.
|
||||||
|
|
||||||
# Fix specific directory
|
Do not `chmod` the whole `config/` directory: `config_secrets.json` must stay
|
||||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config
|
`640`.
|
||||||
sudo chmod -R 2775 /home/ledpi/LEDMatrix/config
|
|
||||||
sudo find /home/ledpi/LEDMatrix/config -type f -exec chmod 664 {} \;
|
|
||||||
|
|
||||||
# Verify permissions
|
|
||||||
ls -la config/
|
|
||||||
ls -la assets/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check directory has setgid bit
|
|
||||||
ls -ld assets/
|
|
||||||
# Should show: drwxrwsr-x (note the 's')
|
|
||||||
|
|
||||||
# Check file has correct group
|
|
||||||
ls -l assets/logo.png
|
|
||||||
# Should show group 'ledpi'
|
|
||||||
|
|
||||||
# Check file permissions
|
|
||||||
stat -c "%a %n" config/config.json
|
|
||||||
# Should show: 644 config/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Creating plugins with Vegas/on-demand support
|
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Creating plugins with Vegas/on-demand support
|
||||||
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Using on-demand controls in web UI
|
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Using on-demand controls in web UI
|
||||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Complete API documentation
|
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Complete API documentation
|
||||||
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment and testing
|
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment and testing
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
||||||
|
|
||||||
|
> **Adaptive layout:** for plugins that should render legibly on any panel
|
||||||
|
> size (fonts that grow on big panels, layouts that degrade gracefully on
|
||||||
|
> small ones), use the adaptive layout system — `self.layout`, `draw_fit`,
|
||||||
|
> `draw_image`, `scoreboard_regions` — documented in
|
||||||
|
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Using Weather Icons](#using-weather-icons)
|
- [Using Weather Icons](#using-weather-icons)
|
||||||
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
|
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
|
||||||
- [Cache Strategy Patterns](#cache-strategy-patterns)
|
- [Cache Strategy Patterns](#cache-strategy-patterns)
|
||||||
- [Font Management and Overrides](#font-management-and-overrides)
|
- [Font Management](#font-management)
|
||||||
- [Error Handling Best Practices](#error-handling-best-practices)
|
- [Error Handling Best Practices](#error-handling-best-practices)
|
||||||
- [Performance Optimization](#performance-optimization)
|
- [Performance Optimization](#performance-optimization)
|
||||||
- [Testing Plugins with Mocks](#testing-plugins-with-mocks)
|
- [Testing Plugins with Mocks](#testing-plugins-with-mocks)
|
||||||
@@ -19,69 +25,12 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
|
|||||||
|
|
||||||
## Using Weather Icons
|
## Using Weather Icons
|
||||||
|
|
||||||
The Display Manager provides built-in weather icon drawing methods for easy visual representation of weather conditions.
|
The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
|
||||||
|
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` —
|
||||||
### Basic Weather Icon Usage
|
are deprecated, removed in 3.8.0. Draw your own icons instead: render them
|
||||||
|
onto a PIL image and paste it onto `self.display_manager.image`, or ship
|
||||||
```python
|
icon images with the plugin. The weather plugin's `WeatherIcons` class is an
|
||||||
def display(self, force_clear=False):
|
example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||||
if force_clear:
|
|
||||||
self.display_manager.clear()
|
|
||||||
|
|
||||||
# Draw weather icon based on condition
|
|
||||||
condition = self.data.get('condition', 'clear')
|
|
||||||
self.display_manager.draw_weather_icon(condition, x=5, y=5, size=16)
|
|
||||||
|
|
||||||
# Draw temperature next to icon
|
|
||||||
temp = self.data.get('temp', 72)
|
|
||||||
self.display_manager.draw_text(
|
|
||||||
f"{temp}°F",
|
|
||||||
x=25, y=10,
|
|
||||||
color=(255, 255, 255)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.display_manager.update_display()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Supported Weather Conditions
|
|
||||||
|
|
||||||
The `draw_weather_icon()` method automatically maps condition strings to appropriate icons:
|
|
||||||
|
|
||||||
- `"clear"`, `"sunny"` → Sun icon
|
|
||||||
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
|
|
||||||
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
|
|
||||||
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
|
|
||||||
- `"thunderstorm"`, `"storm"` → Storm icon
|
|
||||||
|
|
||||||
### Custom Weather Icons
|
|
||||||
|
|
||||||
For more control, use individual icon methods:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Draw specific icons
|
|
||||||
self.display_manager.draw_sun(x=10, y=10, size=16)
|
|
||||||
self.display_manager.draw_cloud(x=10, y=10, size=16, color=(150, 150, 150))
|
|
||||||
self.display_manager.draw_rain(x=10, y=10, size=16)
|
|
||||||
self.display_manager.draw_snow(x=10, y=10, size=16)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Text with Weather Icons
|
|
||||||
|
|
||||||
Use `draw_text_with_icons()` to combine text and icons:
|
|
||||||
|
|
||||||
```python
|
|
||||||
icons = [
|
|
||||||
("sun", 5, 5), # Sun icon at (5, 5)
|
|
||||||
("cloud", 100, 5) # Cloud icon at (100, 5)
|
|
||||||
]
|
|
||||||
|
|
||||||
self.display_manager.draw_text_with_icons(
|
|
||||||
"Weather: Sunny, Cloudy",
|
|
||||||
icons=icons,
|
|
||||||
x=10, y=20,
|
|
||||||
color=(255, 255, 255)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -91,31 +40,53 @@ For plugins that scroll content (tickers, news feeds, etc.), use scrolling state
|
|||||||
|
|
||||||
### Basic Scrolling Implementation
|
### Basic Scrolling Implementation
|
||||||
|
|
||||||
|
Scroll with `ScrollHelper`, configured by `src.common.scroll_config`, and
|
||||||
|
render one frame per `display()` call. Don't pace the scroll with
|
||||||
|
`time.sleep()`: `update_display()` blocks on the panel's
|
||||||
|
vsync, which is what paces a scroll. Pass the `frame_hold` that
|
||||||
|
`scroll_config.configure()` returned to `set_scrolling_state()`, or the
|
||||||
|
scroll runs faster than the configured speed (see
|
||||||
|
`set_scrolling_state()` in [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from src.common import scroll_config
|
||||||
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.scroll_helper = ScrollHelper(
|
||||||
|
self.display_manager.width, self.display_manager.height, self.logger)
|
||||||
|
self.scroll_settings = scroll_config.configure(
|
||||||
|
self.scroll_helper,
|
||||||
|
plugin_config=self.config,
|
||||||
|
global_config=self.global_config,
|
||||||
|
display_manager=self.display_manager,
|
||||||
|
plugin_logger=self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_scroll_image(self, text):
|
||||||
|
font = self.display_manager.regular_font
|
||||||
|
width = self.display_manager.get_text_width(text, font)
|
||||||
|
img = Image.new("RGB", (width, self.display_manager.height))
|
||||||
|
ImageDraw.Draw(img).text((0, 0), text, font=font, fill=(255, 255, 255))
|
||||||
|
self.scroll_helper.set_scrolling_image(img)
|
||||||
|
|
||||||
def display(self, force_clear=False):
|
def display(self, force_clear=False):
|
||||||
if force_clear:
|
if force_clear or self.scroll_helper.cached_image is None:
|
||||||
self.display_manager.clear()
|
self._build_scroll_image(
|
||||||
|
"This is a long scrolling message that needs to scroll across the display...")
|
||||||
|
|
||||||
# Mark as scrolling
|
# Mark as scrolling (calling it every frame is fine)
|
||||||
self.display_manager.set_scrolling_state(True)
|
self.display_manager.set_scrolling_state(
|
||||||
|
True, frame_hold=self.scroll_settings.frame_hold)
|
||||||
|
self.scroll_helper.update_scroll_position()
|
||||||
|
self.display_manager.image = self.scroll_helper.get_visible_portion()
|
||||||
|
self.display_manager.update_display()
|
||||||
|
|
||||||
try:
|
if self.scroll_helper.is_scroll_complete():
|
||||||
# Scroll content
|
# Mark as not scrolling when done
|
||||||
text = "This is a long scrolling message that needs to scroll across the display..."
|
|
||||||
text_width = self.display_manager.get_text_width(text, self.display_manager.regular_font)
|
|
||||||
display_width = self.display_manager.width
|
|
||||||
|
|
||||||
# Scroll from right to left
|
|
||||||
for x in range(display_width, -text_width, -2):
|
|
||||||
self.display_manager.clear()
|
|
||||||
self.display_manager.draw_text(text, x=x, y=16, color=(255, 255, 255))
|
|
||||||
self.display_manager.update_display()
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
# Update scroll activity timestamp
|
|
||||||
self.display_manager.set_scrolling_state(True)
|
|
||||||
finally:
|
|
||||||
# Always mark as not scrolling when done
|
|
||||||
self.display_manager.set_scrolling_state(False)
|
self.display_manager.set_scrolling_state(False)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -223,11 +194,8 @@ def update(self):
|
|||||||
sport_key = "nhl"
|
sport_key = "nhl"
|
||||||
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
||||||
|
|
||||||
# Uses sport-specific live_update_interval from config
|
# get_background_cached_data() is deprecated, removed in 3.8.0 — use get()
|
||||||
cached = self.cache_manager.get_background_cached_data(
|
cached = self.cache_manager.get(cache_key, max_age=60)
|
||||||
cache_key,
|
|
||||||
sport_key=sport_key
|
|
||||||
)
|
|
||||||
|
|
||||||
if cached:
|
if cached:
|
||||||
self.games = cached
|
self.games = cached
|
||||||
@@ -254,9 +222,9 @@ def on_config_change(self, new_config):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Font Management and Overrides
|
## Font Management
|
||||||
|
|
||||||
Use the Font Manager for advanced font handling and user customization.
|
The display manager's built-in fonts and text measurement. For fonts shipped with a plugin, see [FONT_MANAGER.md](FONT_MANAGER.md).
|
||||||
|
|
||||||
### Using Different Fonts
|
### Using Different Fonts
|
||||||
|
|
||||||
@@ -628,14 +596,12 @@ def update(self):
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
def update(self):
|
def update(self):
|
||||||
# Check if another plugin is enabled
|
# get_enabled_plugins() is deprecated, removed in 3.8.0 — check the
|
||||||
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
# instance's `enabled` flag instead
|
||||||
if "weather" in enabled_plugins:
|
weather_plugin = self.plugin_manager.get_plugin("weather")
|
||||||
# Weather plugin is available
|
if weather_plugin is not None and weather_plugin.enabled:
|
||||||
weather_plugin = self.plugin_manager.get_plugin("weather")
|
# Use weather data
|
||||||
if weather_plugin:
|
pass
|
||||||
# Use weather data
|
|
||||||
pass
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sharing Data Between Plugins
|
### Sharing Data Between Plugins
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
A map of the codebase for a new contributor: which process does what, how
|
||||||
|
they talk to each other, and where to start reading for common changes.
|
||||||
|
|
||||||
|
## Processes
|
||||||
|
|
||||||
|
| systemd unit | Runs as | Runs | Installed by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ledmatrix.service` | root | [`run.py`](../run.py) → `DisplayController` | [`install_service.sh`](../scripts/install/install_service.sh) |
|
||||||
|
| `ledmatrix-web.service` | the installing user | [`start_web_conditionally.py`](../scripts/utils/start_web_conditionally.py) → [`web_interface/start.py`](../web_interface/start.py) (Flask, port 5000) | `install_service.sh`, [`install_web_service.sh`](../scripts/install/install_web_service.sh) |
|
||||||
|
| `ledmatrix-update-verify.path` / `.service` | the web user | Health check after an automatic update | the same installers, or [`src/auto_update_setup.py`](../src/auto_update_setup.py) at runtime |
|
||||||
|
| `ledmatrix-wifi-monitor.service` | root | [`wifi_monitor_daemon.py`](../scripts/utils/wifi_monitor_daemon.py) | [`install_wifi_monitor.sh`](../scripts/install/install_wifi_monitor.sh) |
|
||||||
|
| `ledmatrix-mqtt-bridge.service` | root | [MQTT bridge](../integrations/mqtt_bridge/README.md) (optional) | [`install_mqtt_bridge.sh`](../scripts/install/install_mqtt_bridge.sh) |
|
||||||
|
| `ledmatrix-dns-fix.service` | root | DNS workaround (optional) | [`install_dns_fix.sh`](../scripts/install/install_dns_fix.sh) |
|
||||||
|
|
||||||
|
Unit templates are in [`systemd/`](../systemd/README.md). The display runs as
|
||||||
|
root because the LED matrix library needs direct GPIO access. The web
|
||||||
|
interface runs unprivileged and uses a fixed list of `sudo` rules for the
|
||||||
|
few privileged things it does; see [PERMISSIONS.md](PERMISSIONS.md).
|
||||||
|
|
||||||
|
`start_web_conditionally.py` exits without starting Flask when
|
||||||
|
`web_display_autostart` is explicitly false in `config.json`.
|
||||||
|
|
||||||
|
## How the two main processes share state
|
||||||
|
|
||||||
|
The display and the web interface are separate processes that never call
|
||||||
|
each other. They share three things:
|
||||||
|
|
||||||
|
1. **`config/config.json` and `config/config_secrets.json`.** The web
|
||||||
|
interface writes them through `ConfigManager`
|
||||||
|
([`src/config_manager.py`](../src/config_manager.py)); the display
|
||||||
|
notices through `ConfigService` (below).
|
||||||
|
2. **The disk cache**, `/var/cache/ledmatrix` (owned `root:ledmatrix`,
|
||||||
|
setgid, files `0660`), read and written through `CacheManager`
|
||||||
|
([`src/cache_manager.py`](../src/cache_manager.py),
|
||||||
|
[`src/cache/disk_cache.py`](../src/cache/disk_cache.py)). Readers in the
|
||||||
|
other process pass `memory_ttl=0` so they do not serve a stale in-memory
|
||||||
|
copy.
|
||||||
|
3. **A few files in `/tmp`.**
|
||||||
|
|
||||||
|
| State | Where | Written by | Read by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| On-demand request | cache `display_on_demand_request` | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py) | display: `_poll_on_demand_requests()` |
|
||||||
|
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
||||||
|
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
|
||||||
|
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||||
|
| Error clear | cache `plugin_error_clear_request` | web | display |
|
||||||
|
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||||
|
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||||
|
| Plugin runtime (loaded, state, last error, version) | cache `plugin_runtime_snapshot` | display: `PluginRuntimePublisher` ([`src/plugin_system/plugin_runtime.py`](../src/plugin_system/plugin_runtime.py)) | web: `read_plugin_runtime()` for `/api/v3/plugins/installed`, `/plugins/state`, reconciliation |
|
||||||
|
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py) | web: display SSE stream, `/api/v3/health` (file age) |
|
||||||
|
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, while a preview is open | display: writes full-rate snapshots only while it is fresh |
|
||||||
|
| Hardware init status | `/tmp/led_matrix_hw_status.json` | display | web: `/api/v3/hardware/status` |
|
||||||
|
| Render-loop heartbeat | `/run/ledmatrix/display-heartbeat.json` (tmpfs) | display: the render thread, via [`display_watchdog`](../src/display_watchdog.py) | web: `/api/v3/health` (`checks.display_loop`); the update health check |
|
||||||
|
|
||||||
|
The on-demand start route starts `ledmatrix.service` when it is not running
|
||||||
|
(`start_service`, on by default) but never restarts a running one: the display
|
||||||
|
reads the mailbox every `ON_DEMAND_POLL_INTERVAL` (0.25s), from its dwell
|
||||||
|
sleep, its render loops and Vegas's interrupt check as well as the main loop.
|
||||||
|
|
||||||
|
### Web and display processes: who runs plugins
|
||||||
|
|
||||||
|
Only the display process imports plugin code, instantiates plugins and calls
|
||||||
|
their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`,
|
||||||
|
`on_disable`). The web process is metadata-only: it reads plugins as files
|
||||||
|
through `PluginCatalog`
|
||||||
|
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py))
|
||||||
|
-- manifests, config schemas (through `SchemaManager`), each plugin's
|
||||||
|
section of `config.json`, and installed versions. The catalog keeps the
|
||||||
|
read-only method names of `PluginManager` and has nothing that can run a
|
||||||
|
plugin (no `load_plugin`, `get_plugin` or `plugins`).
|
||||||
|
|
||||||
|
How a web-side change reaches the running plugins:
|
||||||
|
|
||||||
|
| Change | How the display picks it up |
|
||||||
|
|---|---|
|
||||||
|
| Plugin settings saved, config reset | `ConfigService` sees the new `config.json` and calls the plugin's `on_config_change` with the prepared section |
|
||||||
|
| Plugin enabled or disabled | `ConfigService` → `_controller_config_change` flags a reconcile; `_reconcile_enabled_plugins` loads it (fresh from disk) or unloads it on the render thread |
|
||||||
|
| Plugin uninstalled (config removed) | the removed section flips its `enabled` flag, and the reconcile unloads it |
|
||||||
|
| Plugin installed, not enabled | nothing to do until it is enabled, which loads it |
|
||||||
|
| Plugin installed while already enabled, updated while enabled, or uninstalled with its config kept | **not picked up**: the display keeps running what it loaded. The route answers `restart_required: true` and the UI shows its restart banner |
|
||||||
|
|
||||||
|
`display_restart_required()` in `plugin_catalog.py` holds that last rule;
|
||||||
|
routes return it as `restart_required` (with the banner's wording in
|
||||||
|
`restart_message`), and `window.noteRestartRequired()` in
|
||||||
|
`static/v3/app.js` raises the banner for any response that carries it,
|
||||||
|
`POST /api/v3/config/main` included.
|
||||||
|
|
||||||
|
Runtime state shown in the UI comes from what the display publishes to the
|
||||||
|
shared cache: health and metrics (`/api/v3/plugins/health`,
|
||||||
|
`/plugins/metrics`), errors (`/api/v3/errors/*`), the current mode, and the
|
||||||
|
plugin runtime snapshot described below. `enabled` is read from
|
||||||
|
`config.json` by the display's rule (a missing flag is disabled).
|
||||||
|
|
||||||
|
Plugin code still runs in the web process in one place,
|
||||||
|
`_import_plugin_code_in_web_process()` in
|
||||||
|
[`api_v3/__init__.py`](../web_interface/blueprints/api_v3/__init__.py): the
|
||||||
|
Starlark routes import the starlark-apps plugin's `tronbyte_repository` and
|
||||||
|
`pixlet_renderer` helper modules (never the plugin class), and a web-UI
|
||||||
|
action with `oauth_flow` imports its script for `get_auth_url()`. Every
|
||||||
|
other web-UI action runs its script as a subprocess. A later, explicit
|
||||||
|
**plugin web-entry contract** -- a declared entry point for plugin web code
|
||||||
|
-- replaces that function.
|
||||||
|
|
||||||
|
Next stages: a **control socket** from the web process to the display
|
||||||
|
(reload one plugin, ask for its state) in place of `restart_required` and
|
||||||
|
the cache-key mailboxes, and the plugin web-entry contract above.
|
||||||
|
|
||||||
|
### Plugin state: desired, observed, and who owns it
|
||||||
|
|
||||||
|
There is one plugin state machine, and the display owns it:
|
||||||
|
`PluginStateManager` in
|
||||||
|
[`plugin_state.py`](../src/plugin_system/plugin_state.py) (unloaded →
|
||||||
|
loaded → enabled ⇄ running, error, disabled), held by the display's
|
||||||
|
`PluginManager`. It also records, per loaded plugin, the manifest version it
|
||||||
|
loaded and when. Nothing else keeps plugin state:
|
||||||
|
|
||||||
|
| Question | Answered by |
|
||||||
|
|---|---|
|
||||||
|
| Is it installed, at which version? | the plugins directory (`manifest.json`) |
|
||||||
|
| Should it run? | `config.json` (`<id>.enabled`, missing = disabled) |
|
||||||
|
| Has the user uninstalled it for good? | the store's uninstalled-plugins record |
|
||||||
|
| Is the display running it, at which version, and why not? | the display's runtime snapshot |
|
||||||
|
|
||||||
|
**The runtime snapshot.** `PluginRuntimePublisher`
|
||||||
|
([`plugin_runtime.py`](../src/plugin_system/plugin_runtime.py)), started by
|
||||||
|
`DisplayController` right after it creates the `PluginManager`, writes the
|
||||||
|
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
||||||
|
(type, a redacted message of at most 200 characters, when, recoverable),
|
||||||
|
`version` and `loaded_at`, plus `published_at`, `stale_after` and `running`.
|
||||||
|
The cache is on disk, usually the SD card, so it writes when something a
|
||||||
|
reader sees changes -- throttled to once per 10 s -- and otherwise once a
|
||||||
|
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
||||||
|
published as ENABLED, so plugin updates alone never cause a write.
|
||||||
|
`cleanup()` publishes `running: false`.
|
||||||
|
|
||||||
|
**Reading it.** `read_plugin_runtime()` judges the snapshot before anyone
|
||||||
|
uses it: `live` (fresh, from a running display), `stale` (older than
|
||||||
|
`stale_after`, 3 minutes: a hung or crashed display), `stopped` or
|
||||||
|
`unknown` (none, unreadable, or another schema). Only a live view reports
|
||||||
|
per-plugin facts; every other status answers `null` for them, so stale
|
||||||
|
truth cannot leak into a response. `/api/v3/plugins/installed` returns
|
||||||
|
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
||||||
|
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
||||||
|
`/api/v3/plugins/state` returns the same beside the desired state.
|
||||||
|
|
||||||
|
**Reconciliation**
|
||||||
|
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
||||||
|
compares desired state (config + disk) with observed state (the snapshot).
|
||||||
|
It fixes desired-state gaps -- a plugin on disk with no config section gets
|
||||||
|
`{"enabled": false}`, a configured plugin missing from disk is reinstalled
|
||||||
|
unless the user uninstalled it -- and only reports observed-state gaps
|
||||||
|
(enabled but not loaded, loaded at an older version): the display loads and
|
||||||
|
unloads by config on its own, and a version gap needs a restart.
|
||||||
|
|
||||||
|
**`data/plugin_state.json` is retired.** The web process used to keep a
|
||||||
|
second `PluginStateManager` (`state_manager.py`) persisted to that file:
|
||||||
|
per plugin an enabled flag copied from config, a version copied from the
|
||||||
|
manifest (when set at all), a status derived from those, and install/update
|
||||||
|
timestamps. Reconciliation mostly synced it back to config and backups
|
||||||
|
merged it into their plugin list. Every field is derivable (the timestamps
|
||||||
|
from the operation history), so nothing is migrated: no code reads or
|
||||||
|
writes the file, and a copy left on a device is inert and safe to delete.
|
||||||
|
The two classes shared a name but not a concern -- a persisted install
|
||||||
|
record versus the live lifecycle -- so they were not merged; the persisted
|
||||||
|
one had nothing left to hold and was removed.
|
||||||
|
|
||||||
|
## Display loop
|
||||||
|
|
||||||
|
[`src/display_controller.py`](../src/display_controller.py), class
|
||||||
|
`DisplayController`. `__init__` loads config, starts the cache and the
|
||||||
|
error-snapshot publisher, runs the startup validator, creates the
|
||||||
|
`DisplayManager` ([`src/display_manager.py`](../src/display_manager.py)),
|
||||||
|
`FontManager` and `PluginManager`, loads the enabled plugins in parallel,
|
||||||
|
runs an initial `update()` pass within a 20-second budget
|
||||||
|
(`_INITIAL_UPDATE_BUDGET_SECONDS`; a plugin that misses it is deferred to
|
||||||
|
the scheduler), and sets up Vegas mode.
|
||||||
|
|
||||||
|
`run()` is the main loop. Each pass, in order: apply a pending plugin
|
||||||
|
enable/disable, poll on-demand requests, run scheduled plugin updates, check
|
||||||
|
the on/off schedule and brightness, then show one screen. Priority is
|
||||||
|
on-demand, then WiFi status messages, then live priority, then Vegas mode,
|
||||||
|
then normal rotation.
|
||||||
|
|
||||||
|
- **Rotation.** `available_modes` is the ordered list of display modes;
|
||||||
|
`current_mode_index` advances after each screen.
|
||||||
|
`_apply_plugin_rotation_order()` applies `display.plugin_rotation_order`.
|
||||||
|
- **Durations.** `_get_display_duration()`: `display.display_durations[mode]`,
|
||||||
|
else the plugin's `get_display_duration()`, else 30 s. Plugins that
|
||||||
|
support dynamic duration run until `is_cycle_complete()`, capped by
|
||||||
|
`display.dynamic_duration.max_duration_seconds` (default 180 s).
|
||||||
|
- **On-demand.** A request from the web interface pins one plugin (or mode)
|
||||||
|
for a duration. `_activate_on_demand()` / `_clear_on_demand()`; the
|
||||||
|
session is saved under `display_on_demand_config` so it survives a
|
||||||
|
restart. It also keeps the display on during scheduled off hours. A
|
||||||
|
request for a plugin that is disabled in config loads it live
|
||||||
|
(`_load_plugin_for_on_demand()`, `load_plugin(force_enabled=True)`)
|
||||||
|
without writing `config.json`; the main loop unloads it once on-demand
|
||||||
|
moves off it (`_release_on_demand_plugins()`).
|
||||||
|
- **Live priority.** `_check_live_priority()` looks for a plugin whose
|
||||||
|
`has_live_priority()` and `has_live_content()` are both true and switches
|
||||||
|
to it, rotating between several live games.
|
||||||
|
- **Schedule and dim schedule.** `_check_schedule()` reads `schedule`;
|
||||||
|
`_check_dim_schedule()` reads `dim_schedule` and
|
||||||
|
`display.hardware.brightness`. Both are re-evaluated once a minute.
|
||||||
|
- **Long screens.** While a screen is showing (a dwell, a scroll, a Vegas
|
||||||
|
iteration), `_service_pending_changes()` repeats the on-demand, schedule
|
||||||
|
and brightness checks every 0.25 s, so a change does not wait for the
|
||||||
|
screen to end.
|
||||||
|
- **Config hot reload.** `ConfigService`
|
||||||
|
([`src/config_service.py`](../src/config_service.py)) polls the config and
|
||||||
|
secrets files' mtimes every 2 s and notifies subscribers when the content
|
||||||
|
changes. The controller refreshes its cached settings; enabling or
|
||||||
|
disabling a plugin queues `_reconcile_enabled_plugins()`, which loads or
|
||||||
|
unloads it on the display thread; each plugin gets `on_config_change()`
|
||||||
|
for its own section, under its plugin lock
|
||||||
|
(`PluginManager.apply_config_change()`). Set `LEDMATRIX_HOT_RELOAD=false` to turn this off.
|
||||||
|
Matrix hardware settings are only read at start-up.
|
||||||
|
- **Vegas mode.** [`src/vegas_mode/`](../src/vegas_mode/): the display loop
|
||||||
|
calls `VegasModeCoordinator.run_iteration()`
|
||||||
|
([`coordinator.py`](../src/vegas_mode/coordinator.py)) when
|
||||||
|
`display.vegas_scroll.enabled` is set. `PluginAdapter` gets each plugin's
|
||||||
|
content (`get_vegas_content()`, else its `scroll_helper` image, else a
|
||||||
|
capture of `display()`), `StreamManager` orders it and `RenderPipeline`
|
||||||
|
scrolls it. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md).
|
||||||
|
- **Multi-display sync.** `DisplaySyncManager`
|
||||||
|
([`src/common/sync_manager.py`](../src/common/sync_manager.py)), enabled by
|
||||||
|
`sync.role`: a leader sends a follower its share of each frame over UDP
|
||||||
|
(port 5765).
|
||||||
|
|
||||||
|
### Liveness
|
||||||
|
|
||||||
|
A render thread stuck inside a plugin leaves the service "active" and the
|
||||||
|
panel frozen, so liveness is reported by the render thread itself
|
||||||
|
([`src/display_watchdog.py`](../src/display_watchdog.py), standard library
|
||||||
|
only). `beat()` from any other thread is ignored: the update worker, Vegas's
|
||||||
|
tick thread and the prefetcher keep running while the render thread is stuck,
|
||||||
|
and must not vouch for it.
|
||||||
|
|
||||||
|
- **Check-in points.** The top of `run()`'s loop (`loop_pass()`), every
|
||||||
|
dwell second (`_sleep_with_plugin_updates`), every frame of the per-screen
|
||||||
|
loops (`_display_once`), every frame of Vegas's own loop and static pause
|
||||||
|
(`coordinator.run_iteration`), each plugin fetched for a Vegas cycle
|
||||||
|
(`StreamManager._fetch_plugin_content`), each update on the
|
||||||
|
`synchronous_updates` path, and every frame pushed
|
||||||
|
(`DisplayManager.update_display` -> `note_frame()`). Beats are
|
||||||
|
rate-limited to one ping and one heartbeat write every 5 s.
|
||||||
|
- **systemd watchdog.** `ledmatrix.service` is `Type=simple` with
|
||||||
|
`WatchdogSec=120` and `NotifyAccess=main`. `run.py` sends
|
||||||
|
`WATCHDOG_USEC` = 15 minutes before importing anything heavy (start-up loads
|
||||||
|
plugins and runs the 20 s update budget, and the watchdog clock starts with
|
||||||
|
the process). After the first frame -- or the first full pass, when there is
|
||||||
|
nothing to draw -- the loop sends `READY=1`, restores the unit's 120 s and
|
||||||
|
pings. `PluginManager.load_plugin()` on the render thread (a plugin enabled
|
||||||
|
from the web UI, or loaded for on-demand) gets 15 minutes again, since it
|
||||||
|
can run pip. A missed deadline is a SIGABRT; faulthandler, enabled on
|
||||||
|
arming, dumps every thread's stack to the journal.
|
||||||
|
- **Heartbeat.** `/run/ledmatrix/display-heartbeat.json`
|
||||||
|
(`{"pid", "mono", "wall"}`; `RuntimeDirectory=ledmatrix`, 0755, file 0644 so
|
||||||
|
the web user can read it). Readers compare `mono` with their own
|
||||||
|
`time.monotonic()` -- CLOCK_MONOTONIC is shared by every process and does not
|
||||||
|
jump when NTP first sets an RTC-less Pi's clock. `/api/v3/health` calls it
|
||||||
|
`stalled` past 60 s; no file is `not_reported` and changes nothing. A clean
|
||||||
|
stop removes it. Without `RuntimeDirectory=` (an older unit) the display,
|
||||||
|
as root, creates the directory itself; off Linux, or without root, there
|
||||||
|
is no heartbeat.
|
||||||
|
|
||||||
|
## Plugin system
|
||||||
|
|
||||||
|
[`src/plugin_system/`](../src/plugin_system/):
|
||||||
|
|
||||||
|
| Area | Where |
|
||||||
|
|---|---|
|
||||||
|
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
|
||||||
|
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
|
||||||
|
| Discovery, load, unload, scheduled updates (display process) | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
|
||||||
|
| Manifest, schema, config and version reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) |
|
||||||
|
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
|
||||||
|
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
|
||||||
|
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
|
||||||
|
| Resource metrics | [`resource_monitor.py`](../src/plugin_system/resource_monitor.py) |
|
||||||
|
| Config schemas and defaults | [`schema_manager.py`](../src/plugin_system/schema_manager.py) |
|
||||||
|
| Install, update, uninstall | [`store_manager.py`](../src/plugin_system/store_manager.py) (`PluginStoreManager`), with its methods split across [`store_registry.py`](../src/plugin_system/store_registry.py) (registry, GitHub), [`store_install.py`](../src/plugin_system/store_install.py) and [`store_update.py`](../src/plugin_system/store_update.py) |
|
||||||
|
| Core-version gate | [`compatibility.py`](../src/plugin_system/compatibility.py) |
|
||||||
|
|
||||||
|
Discovery scans only `plugin_system.plugins_directory` (default
|
||||||
|
`plugin-repos/`). Scheduled `update()` calls run on one background worker
|
||||||
|
thread; a per-plugin lock keeps `display()` from running during an update.
|
||||||
|
|
||||||
|
**Store flow.** `install_plugin()` renames any existing copy aside
|
||||||
|
(`<id>.standalone-backup-preinstall`), installs the new one, and puts the old
|
||||||
|
copy back if the install fails. Monorepo plugins come from the GitHub Trees
|
||||||
|
API, falling back to the repository ZIP; other plugins by `git clone` or
|
||||||
|
download. The manifest is checked (see
|
||||||
|
[required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)), the core
|
||||||
|
version gate runs, then dependencies are installed as root through
|
||||||
|
`scripts/fix_perms/safe_pip_install.sh`. `update_plugin()` pulls git
|
||||||
|
installs, undoing a pull whose new version is incompatible, and reinstalls
|
||||||
|
everything else through `_reinstall_with_rollback()`.
|
||||||
|
|
||||||
|
## Web interface
|
||||||
|
|
||||||
|
- **App.** [`web_interface/app.py`](../web_interface/app.py) builds the
|
||||||
|
Flask `app` at import time, creates the managers -- a `PluginCatalog`,
|
||||||
|
never a `PluginManager` -- and registers two blueprints.
|
||||||
|
`web_interface/start.py` runs it on port 5000.
|
||||||
|
- **Pages.** [`blueprints/pages_v3.py`](../web_interface/blueprints/pages_v3.py)
|
||||||
|
serves the shell `templates/v3/base.html` at `/` and each tab as a
|
||||||
|
partial at `/partials/<name>` (templates in
|
||||||
|
`web_interface/templates/v3/partials/`). Plugin configuration tabs are
|
||||||
|
rendered from the plugin's schema by `plugin_config.html`.
|
||||||
|
- **API.** [`blueprints/api_v3/`](../web_interface/blueprints/api_v3/) is one
|
||||||
|
blueprint at `/api/v3`, split by area: `backup.py`, `config.py`,
|
||||||
|
`display.py`, `fonts.py`, `misc.py` (health, logs, errors, cache, sync),
|
||||||
|
`starlark.py`, `system.py` (service actions, updates, git), `wifi.py`, and
|
||||||
|
the plugin routes: `plugins.py` (installed list, enable/disable, plugin
|
||||||
|
actions), `plugin_store.py` (install, update, uninstall, store),
|
||||||
|
`plugin_config.py` (config, schema, reset), `plugin_assets.py` (uploads,
|
||||||
|
plugin static files), `plugin_health.py` (health, metrics, limits),
|
||||||
|
`plugin_operations.py` (operation history, state reconciliation) and
|
||||||
|
`plugin_calendar.py`. `__init__.py` defines the blueprint and shared helpers and
|
||||||
|
imports the modules so their routes register. Endpoints are listed in
|
||||||
|
[REST_API_REFERENCE.md](REST_API_REFERENCE.md).
|
||||||
|
- **Front end.** HTMX loads each tab's partial on first open
|
||||||
|
(`hx-trigger="loadtab"`); Alpine.js holds page state. Scripts are in
|
||||||
|
`web_interface/static/v3/js/`; form widgets are bundled from
|
||||||
|
[`js/widgets/`](../web_interface/static/v3/js/widgets/README.md).
|
||||||
|
- **Server-sent events** (`app.py`): `/api/v3/stream/stats` (CPU, memory,
|
||||||
|
temperature, service state, every 10 s), `/api/v3/stream/display` (preview
|
||||||
|
frames when the PNG changes) and `/api/v3/stream/logs` (journal of both
|
||||||
|
services). One generator thread per stream is shared by all clients.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
- **Update Code** on the Overview tab and the automatic updater both call
|
||||||
|
`perform_core_update()` in
|
||||||
|
[`api_v3/system.py`](../web_interface/blueprints/api_v3/system.py):
|
||||||
|
fetch branches and tags, move the checkout for the update channel, reinstall
|
||||||
|
changed requirement files, report whether a restart is needed.
|
||||||
|
- **Update channels** (`auto_update.channel`):
|
||||||
|
[`web_interface/update_channel.py`](../web_interface/update_channel.py)
|
||||||
|
decides the move. `stable` checks out the newest `vX.Y.Z` tag (detached
|
||||||
|
HEAD) when it contains the current commit; `beta` is
|
||||||
|
`git pull --rebase --autostash` on the current branch, and leaves a
|
||||||
|
detached release for `main` first. A stable device newer than the newest
|
||||||
|
release keeps pulling `main` until a release contains its commit, so no
|
||||||
|
update ever moves backwards; a config without the key is written as
|
||||||
|
`stable` once the device reaches a release. Checkouts carry uncommitted
|
||||||
|
edits across with `git stash create`/`apply`, and keep them in the stash
|
||||||
|
list if they no longer apply.
|
||||||
|
- **Automatic updates** (`auto_update.enabled`, off by default):
|
||||||
|
`AutoUpdater` in [`web_interface/auto_update.py`](../web_interface/auto_update.py)
|
||||||
|
runs in the web process, checks every 30 minutes, and updates at most
|
||||||
|
weekly between 02:00 and 05:00. Before pulling it copies
|
||||||
|
[`scripts/utils/auto_update_verify.py`](../scripts/utils/auto_update_verify.py)
|
||||||
|
to `data/auto_update_verifier.py`, then writes
|
||||||
|
`data/auto_update_verify.request`. That file triggers
|
||||||
|
`ledmatrix-update-verify.path`, which runs the verifier as a separate unit
|
||||||
|
(so restarting the web service does not kill it). The verifier restarts
|
||||||
|
both services, waits for the web API to answer and the display service to
|
||||||
|
stay up -- and, when the display wrote a heartbeat before the update, to
|
||||||
|
keep one fresh from the restarted process (see Liveness) -- and on failure
|
||||||
|
returns to where HEAD was (the branch, or detached on the previous
|
||||||
|
release; `old_ref` in the pending file), resets to the previous commit
|
||||||
|
and restarts again.
|
||||||
|
Plugin updates run only after a verified core update. State is in
|
||||||
|
`data/auto_update_state.json` and `data/auto_update_pending.json`.
|
||||||
|
- **Startup validator.** `StartupValidator`
|
||||||
|
([`src/startup_validator.py`](../src/startup_validator.py)) runs twice in
|
||||||
|
`DisplayController.__init__`: config and cache directory first, then
|
||||||
|
enabled plugins once the plugin manager exists. It also warns when an
|
||||||
|
installed systemd unit differs from its template in `systemd/`. Results
|
||||||
|
are logged; startup continues either way. Nothing rewrites installed units
|
||||||
|
on update: a unit change such as the watchdog reaches an existing install
|
||||||
|
only when `install_service.sh` is re-run.
|
||||||
|
|
||||||
|
## Where to start reading
|
||||||
|
|
||||||
|
| Task | Start with |
|
||||||
|
|---|---|
|
||||||
|
| Change rotation, durations or priorities | `DisplayController.run()` and `_get_display_duration()` in [`display_controller.py`](../src/display_controller.py) |
|
||||||
|
| Add a config key | [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md), [`config/config.template.json`](../config/config.template.json), the tab's partial and `api_v3/config.py` |
|
||||||
|
| Change drawing or fonts | [`display_manager.py`](../src/display_manager.py), [`font_manager.py`](../src/font_manager.py), [`src/common/bdf_font.py`](../src/common/bdf_font.py) |
|
||||||
|
| Add a plugin-facing API | [`base_plugin.py`](../src/plugin_system/base_plugin.py) or [`src/common/`](../src/common/README.md); document it in [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) |
|
||||||
|
| Plugin install/update bugs | `PluginStoreManager` in [`store_manager.py`](../src/plugin_system/store_manager.py) |
|
||||||
|
| A plugin that won't load | `PluginManager.load_plugin()` and `PluginLoader.load_plugin()`; `python3 scripts/check_plugin.py --plugin <id>` |
|
||||||
|
| Add an API endpoint | the matching module in [`api_v3/`](../web_interface/blueprints/api_v3/) |
|
||||||
|
| Add a web UI tab or control | `templates/v3/base.html`, the tab's partial, `pages_v3.py` |
|
||||||
|
| Vegas scroll | [`src/vegas_mode/coordinator.py`](../src/vegas_mode/coordinator.py) |
|
||||||
|
| Installer or permissions | [`first_time_install.sh`](../first_time_install.sh), [`scripts/install/`](../scripts/install/), [PERMISSIONS.md](PERMISSIONS.md) |
|
||||||
|
| Work without a Pi | [DEV_PREVIEW.md](DEV_PREVIEW.md), [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md), [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) |
|
||||||
@@ -172,10 +172,14 @@ ERROR - Plugin football-scoreboard configuration validation failed: 'api_key' is
|
|||||||
|
|
||||||
### Enable Debug Logging
|
### Enable Debug Logging
|
||||||
|
|
||||||
Set environment variable:
|
Run the display in the foreground with `-d`, or set `LEDMATRIX_DEBUG=true`
|
||||||
|
(the value must be `true`; `1` is ignored — see `setup_logging()` in
|
||||||
|
[`src/logging_config.py`](../src/logging_config.py)):
|
||||||
```bash
|
```bash
|
||||||
export LEDMATRIX_DEBUG=1
|
sudo systemctl stop ledmatrix.service
|
||||||
python run.py
|
sudo python3 run.py -d
|
||||||
|
# or
|
||||||
|
sudo LEDMATRIX_DEBUG=true python3 run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Merged Configuration
|
### Check Merged Configuration
|
||||||
@@ -250,14 +254,21 @@ 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`).
|
The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||||
|
`/api/v3` in `web_interface/app.py`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Get full main config (includes all plugin sections)
|
# Get full main config (includes all plugin sections; credential-named
|
||||||
|
# fields are blanked in the response)
|
||||||
curl http://localhost:5000/api/v3/config/main
|
curl http://localhost:5000/api/v3/config/main
|
||||||
|
|
||||||
# Save updated main config
|
# Change some settings: only the keys you send are changed
|
||||||
curl -X POST http://localhost:5000/api/v3/config/main \
|
curl -X POST http://localhost:5000/api/v3/config/main \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"timezone": "America/Chicago", "brightness": 80}'
|
||||||
|
|
||||||
|
# Replace config.json wholesale (advanced)
|
||||||
|
curl -X POST http://localhost:5000/api/v3/config/raw/main \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d @new-config.json
|
-d @new-config.json
|
||||||
|
|
||||||
@@ -269,8 +280,10 @@ curl "http://localhost:5000/api/v3/plugins/config?plugin_id=football-scoreboard"
|
|||||||
```
|
```
|
||||||
|
|
||||||
> There is no dedicated `/config/plugin/<id>` or `/config/validate`
|
> There is no dedicated `/config/plugin/<id>` or `/config/validate`
|
||||||
> endpoint — config validation runs server-side automatically when you
|
> endpoint. `POST /plugins/config` validates against the plugin's schema
|
||||||
> POST to `/config/main` or `/plugins/config`. See
|
> and rejects an invalid config with `400`; `POST /config/main` checks the
|
||||||
|
> individual fields it knows (display hardware values, durations, Vegas
|
||||||
|
> and sync settings). See
|
||||||
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for the full list.
|
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for the full list.
|
||||||
|
|
||||||
## Backup and Recovery
|
## Backup and Recovery
|
||||||
@@ -283,9 +296,12 @@ cp config/config.json config/config.backup.json
|
|||||||
|
|
||||||
### Automatic Backups
|
### Automatic Backups
|
||||||
|
|
||||||
LEDMatrix creates backups before saves:
|
LEDMatrix creates backups before saves (`src/config_manager_atomic.py`):
|
||||||
- Location: `config/backups/`
|
- Location: `config/backups/`
|
||||||
- Format: `config_YYYYMMDD_HHMMSS.json`
|
- Format: `config.json.backup.YYYYMMDD_HHMMSS_ffffff` (microseconds last),
|
||||||
|
plus a matching `config_secrets.json.backup.<timestamp>` when a secrets
|
||||||
|
file exists
|
||||||
|
- The five most recent are kept
|
||||||
|
|
||||||
### Recovery
|
### Recovery
|
||||||
|
|
||||||
@@ -294,7 +310,7 @@ LEDMatrix creates backups before saves:
|
|||||||
ls -la config/backups/
|
ls -la config/backups/
|
||||||
|
|
||||||
# Restore from backup
|
# Restore from backup
|
||||||
cp config/backups/config_20240115_120000.json config/config.json
|
cp config/backups/config.json.backup.20240115_120000_000000 config/config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting Checklist
|
## Troubleshooting Checklist
|
||||||
@@ -309,8 +325,10 @@ cp config/backups/config_20240115_120000.json config/config.json
|
|||||||
|
|
||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
1. Check logs: `tail -f logs/ledmatrix.log`
|
1. Check logs. Both services log to journald, not to a file:
|
||||||
2. Enable debug: `LEDMATRIX_DEBUG=1`
|
`sudo journalctl -u ledmatrix.service -f` (display) and
|
||||||
|
`sudo journalctl -u ledmatrix-web.service -f` (web interface)
|
||||||
|
2. Enable debug: `LEDMATRIX_DEBUG=true` or `python3 run.py -d`
|
||||||
3. Check error dashboard: `/api/v3/errors/summary`
|
3. Check error dashboard: `/api/v3/errors/summary`
|
||||||
4. Validate JSON: https://jsonlint.com/
|
4. Validate JSON: https://jsonlint.com/
|
||||||
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
|
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Configuration Reference
|
||||||
|
|
||||||
|
Every key in `config/config.json`, what it does, its default, and where the
|
||||||
|
code reads it. The file is created from `config/config.template.json` on
|
||||||
|
first run, and `ConfigManager._migrate_config()` merges any template keys
|
||||||
|
added by later releases into your existing config (your values are never
|
||||||
|
overwritten). Secrets live in `config/config_secrets.json` and are merged
|
||||||
|
into the config at load time.
|
||||||
|
|
||||||
|
Most settings are editable from the web interface; this page documents the
|
||||||
|
underlying keys for people editing `config.json` directly or writing
|
||||||
|
tooling against it.
|
||||||
|
|
||||||
|
## Top level
|
||||||
|
|
||||||
|
| Key | Type / default | Meaning | Read by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||||
|
| `auto_update.enabled` | bool, `false` | Weekly automatic updates: LEDMatrix code first (health-checked, rolled back on failure), then installed plugins. Toggle in the General tab or install with `first_time_install.sh --enable-auto-update` | `web_interface/auto_update.py`, `src/auto_update_setup.py` (`is_enabled()`) |
|
||||||
|
| `auto_update.channel` | `"stable"` or `"beta"`, `"stable"` (template) | What Update Code and the weekly update install. `stable`: the newest `vX.Y.Z` release tag (pre-releases ignored), checked out with a detached HEAD. `beta`: `main`. Never moves a device backwards: one newer than the newest release keeps following `main` until a release contains its commit. Missing (configs from before channels) behaves like `stable` and is saved as `stable` once the device is on a release. General tab, Update Channel | `web_interface/update_channel.py` (`resolve()`) |
|
||||||
|
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||||
|
| `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` |
|
||||||
|
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. Starlark (Tidbyt) apps get the same treatment: a `Location` field left blank on the app renders at this city (geocoded once via Open-Meteo, coordinates cached permanently) instead of the app author's default, which is usually San Francisco. If the city can't be looked up (no match, or the geocoder is unreachable; retried after 30 minutes), the app keeps its own default. | `SchemaManager.apply_device_location()`, then plugins via merged config; `src/device_location.py` for Starlark apps |
|
||||||
|
|
||||||
|
## `schedule` — display on/off hours
|
||||||
|
|
||||||
|
| Key | Type / default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `enabled` | bool, `false` | Master switch for scheduled display on/off |
|
||||||
|
| `mode` | `"global"` or `"per-day"`, template uses `"per-day"` | Whether one time range applies to all days or each day has its own |
|
||||||
|
| `start_time` / `end_time` | `"HH:MM"`, `07:00`–`23:00` | Global-mode on/off times |
|
||||||
|
| `days.<weekday>.{enabled,start_time,end_time}` | per-day objects | Per-day-mode overrides |
|
||||||
|
|
||||||
|
Read by `DisplayController._check_schedule()` (`src/display_controller.py`).
|
||||||
|
Managed in the web UI under Schedule.
|
||||||
|
|
||||||
|
## `dim_schedule` — scheduled brightness dimming
|
||||||
|
|
||||||
|
Same shape as `schedule` (the template sets its `mode` to `"global"`), plus:
|
||||||
|
|
||||||
|
| Key | Type / default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `dim_brightness` | int, `30` | Brightness percentage applied while the dim window is active |
|
||||||
|
|
||||||
|
Read by `DisplayController._check_dim_schedule()` (`src/display_controller.py`;
|
||||||
|
saved via `POST /api/v3/config/dim-schedule`). The display returns to
|
||||||
|
`display.hardware.brightness` outside the window.
|
||||||
|
|
||||||
|
## `display.hardware` — matrix panel hardware
|
||||||
|
|
||||||
|
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
|
||||||
|
in `DisplayManager._setup_matrix` (`src/display_manager.py`). Defaults are the
|
||||||
|
`config/config.template.json` values: `ConfigManager` adds any key missing from
|
||||||
|
`config.json` from the template on load, so `DisplayManager`'s own fallbacks
|
||||||
|
don't apply on a normal install.
|
||||||
|
|
||||||
|
The ranges are what the pinned rgbmatrix library and its Python binding accept
|
||||||
|
(`src/matrix_support.py`). The config API refuses anything else; a value
|
||||||
|
hand-edited into `config.json` makes the display log the setting and run in
|
||||||
|
fallback mode instead of starting the matrix.
|
||||||
|
|
||||||
|
| Key | Type / default |
|
||||||
|
|---|---|
|
||||||
|
| `rows` / `cols` | int, `32` / `64` — rows: even, 8–64; cols: at least 16 |
|
||||||
|
| `chain_length` | int, `2` — 1–255 (the Python binding stores it in one byte) |
|
||||||
|
| `parallel` | int, `1` — 1–3, and no more than `hardware_mapping` has outputs (`regular`, `classic`: 3; the others: 1) |
|
||||||
|
| `brightness` | int, `90` — 1–100 |
|
||||||
|
| `hardware_mapping` | string, `"adafruit-hat"` — `"adafruit-hat-pwm"`, `"adafruit-hat"`, `"regular"`, `"regular-pi1"`, `"classic"` or `"classic-pi1"` (case-insensitive; `compute-module` isn't in the installed build). A Pi 5 doesn't support `"classic-pi1"` |
|
||||||
|
| `scan_mode` | int, `0` — `0` progressive, `1` interlaced |
|
||||||
|
| `pwm_bits` | int, `9` — 1–11 |
|
||||||
|
| `pwm_dither_bits` | int, `1` — 0–2 |
|
||||||
|
| `pwm_lsb_nanoseconds` | int, `130` — 50–3000 |
|
||||||
|
| `disable_hardware_pulsing` | bool, `false` — `true` times brightness pulses in software (less exact); hardware pulsing needs the OE line on GPIO 18 and the Pi's onboard sound driver off |
|
||||||
|
| `inverse_colors` | bool, `false` |
|
||||||
|
| `show_refresh_rate` | bool, `false` — prints the refresh rate to stdout; draws nothing on the panel |
|
||||||
|
| `led_rgb_sequence` | string, `"RGB"` — `"RGB"`, `"RBG"`, `"GRB"`, `"GBR"`, `"BRG"` or `"BGR"` |
|
||||||
|
| `limit_refresh_rate_hz` | int, `100` — `0` = no cap; scroll timing assumes 100 Hz when `0` |
|
||||||
|
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"`; mappers that rotate or fold the chain change the display size plugins and the web preview see |
|
||||||
|
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); `"90"` / `"270"` for a panel on its side, swapping width and height; composed onto `pixel_mapper_config` as a trailing `Rotate:<degrees>` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||||
|
| `row_address_type` | int, `0` — non-standard panel row addressing: `1` AB, `2` direct row select, `3` ABC, `4` ABC shift + DE direct, `5` SM5368 / B707 row shift register (e.g. Waveshare 96x48 V2, with `led_rgb_sequence` `"BGR"`). On a Pi 5 the library supports only `0` and `2`, and LEDMatrix enforces that (`src/pi5_matrix_support.py`) |
|
||||||
|
| `multiplexing` | int, `0` — 0–22, pixel wiring scheme for outdoor/specialty panels (names listed in the README) |
|
||||||
|
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init; FM6124 / FM6124D / FM6124DJ panels need none, so leave it `""` |
|
||||||
|
|
||||||
|
## `display.runtime`
|
||||||
|
|
||||||
|
| Key | Type / default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis (0–10). On a Pi 5 in PIO mode start at `1` (`0` acts as `1`) and raise it if the image flickers or shows garbage. Panels on `row_address_type` `5` (SM5368 row drivers) can need 6–8 on a Pi 4 — lower values make rows jump |
|
||||||
|
| `rp1_rio` | int, `0` | Pi 5 only: `0` = PIO (less CPU), `1` = RIO (higher refresh; `gpio_slowdown` effect inverted). Applied only if the installed matrix library supports it |
|
||||||
|
|
||||||
|
## `display.double_sided`
|
||||||
|
|
||||||
|
Drives `_LogicalMatrix` in `src/display_manager.py` — renders the same
|
||||||
|
logical image to multiple chained physical panels.
|
||||||
|
|
||||||
|
| Key | Type / default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `enabled` | bool, `false` | Mirror output across panel copies |
|
||||||
|
| `copies` | int, `2` | Number of physical copies in the chain |
|
||||||
|
| `axis` | `"horizontal"`, default | Axis along which panels are chained |
|
||||||
|
|
||||||
|
## `display` — other keys
|
||||||
|
|
||||||
|
| Key | Type / default | Meaning | Read by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) |
|
||||||
|
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) |
|
||||||
|
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config |
|
||||||
|
| `scan_order_compensation` | string, `"auto"` | `"auto"` shows one half of each panel a refresh behind while something scrolls at one frame per refresh, which removes the 1px step a 1:N-scan panel shows across its middle; `"off"` disables it. Applies only to layouts whose row order is known: plain or parallel chains, 0 or 180 degree orientation, `multiplexing` 0, `scan_mode` 0, and not in the emulator | `DisplayManager._setup_scan_order_compensation()` (`src/display_manager.py`, `src/scan_order.py`) |
|
||||||
|
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) |
|
||||||
|
|
||||||
|
## `display.vegas_scroll` — continuous scroll mode
|
||||||
|
|
||||||
|
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||||
|
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details, including
|
||||||
|
[live content in the ticker](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||||
|
|
||||||
|
| Key | Type / default |
|
||||||
|
|---|---|
|
||||||
|
| `enabled` | bool, `false` |
|
||||||
|
| `scroll_speed` | int, `50` (px/s) |
|
||||||
|
| `separator_width` | int, `32` |
|
||||||
|
| `plugin_order` | array, `[]` |
|
||||||
|
| `excluded_plugins` | array, `[]` |
|
||||||
|
| `target_fps` | int, `125` |
|
||||||
|
| `buffer_ahead` | int, `2` |
|
||||||
|
| `intra_plugin_gap` | int, `8` |
|
||||||
|
| `render_width_pct` | int, `100` |
|
||||||
|
| `min_content_separation` | int, `24` |
|
||||||
|
| `min_cut_gap` | int, `6` |
|
||||||
|
| `continuous_scroll` | bool, `true` |
|
||||||
|
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||||
|
| `prefetch_gate` | bool, `true` — let that background thread run Python only while the render thread is waiting for the panel, so the render thread never waits for the GIL when a refresh comes round. Only takes effect with the rebuilt rgbmatrix binding (`scripts/build_rgbmatrix_nogil.sh`). See [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||||
|
| `switch_interval_ms` | float, `0` — experimental: shorten Python's GIL switch interval to this many ms while Vegas runs. `0` leaves the default (5 ms) alone |
|
||||||
|
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
|
||||||
|
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
|
||||||
|
| `extend_threshold_screens` | float, `2.0` |
|
||||||
|
| `auto_trim` | bool, `true` |
|
||||||
|
| `trim_threshold` | int, `10` |
|
||||||
|
| `content_padding` | int, `8` |
|
||||||
|
| `min_plugin_width` | int, `8` |
|
||||||
|
| `lead_in_width` | int, `0` |
|
||||||
|
| `plugins_per_cycle` | int, `6` |
|
||||||
|
| `max_plugin_width_ratio` | float, `0.0` |
|
||||||
|
| `overflow_mode` | string, `"rotate"` |
|
||||||
|
| `dynamic_duration_enabled` | bool, `true` |
|
||||||
|
| `min_cycle_duration` | int, `60` |
|
||||||
|
| `max_cycle_duration` | int, `240` |
|
||||||
|
| `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
|
||||||
|
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
|
||||||
|
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||||
|
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
|
||||||
|
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
|
||||||
|
|
||||||
|
## `sync` — multi-display synchronization
|
||||||
|
|
||||||
|
Read by `src/common/sync_manager.py` and `src/display_controller.py`.
|
||||||
|
|
||||||
|
| Key | Type / default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `role` | `"standalone"` (default), `"leader"`, or `"follower"` | This device's role in a synced pair |
|
||||||
|
| `port` | int, `5765` | TCP port used for sync traffic |
|
||||||
|
| `follower_position` | `"left"` (default) or `"right"` | Which half of the combined image this follower renders (`src/display_controller.py`) |
|
||||||
|
|
||||||
|
## `plugin_system`
|
||||||
|
|
||||||
|
| Key | Type / default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `plugins_directory` | string, `"plugin-repos"` | Where the Plugin Store installs plugins and the only directory the plugin loader scans. Read by `PluginManager` and `PluginStoreManager` (`src/plugin_system/`); editable under General settings |
|
||||||
|
| `auto_discover`, `auto_load_enabled`, `development_mode` | bool | **Unused.** Legacy keys, read by nothing and no longer in the template; older configs may still carry them. Plugins are always discovered, and every plugin with `enabled: true` is loaded — to keep a plugin installed but dormant, set its own `enabled` to `false`. Not shown in the web UI; may be left in or removed from config.json |
|
||||||
|
|
||||||
|
## Plugin config blocks
|
||||||
|
|
||||||
|
Every installed plugin stores its settings under a top-level key equal to
|
||||||
|
its plugin id (the template ships one for the bundled `web-ui-info`
|
||||||
|
plugin). The shape of each block is defined by that plugin's
|
||||||
|
`config_schema.json`; common keys are `enabled` and `display_duration`.
|
||||||
|
See [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md).
|
||||||
|
|
||||||
|
## `config/config_secrets.json`
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `github.api_token` | Optional GitHub token the Plugin Store uses to avoid API rate limits (`src/plugin_system/store_registry.py`) |
|
||||||
|
| `<plugin-id>.*` | Secrets a plugin declares with `"x-secret": true` in its config schema; merged into that plugin's config at load time |
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Deprecated plugin APIs: usage scan
|
||||||
|
|
||||||
|
Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it (see [How to re-run](#how-to-re-run)).
|
||||||
|
|
||||||
|
- Scanned: 2026-09-30, core 3.7.0
|
||||||
|
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4327c2e4), 46 plugins
|
||||||
|
- Third-party plugins: 8 with their own repo in `plugins.json` (f1-live, gif-player, pga-tour-leaderboard, plex-marquee, ledmatrix-dresden-departures, tidbyt-baseball-scoreboard, sleeper-fantasy, ledmatrix-nascar)
|
||||||
|
|
||||||
|
**37 deprecated methods: 36 unused, 1 still used, 0 need review.**
|
||||||
|
|
||||||
|
Counted per plugin: a *call* is `<receiver>.method` on an object named like the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot tell. *Internal* hits sit inside another deprecated core method and go with it. *Unrelated* hits are a different class's own method with the same name (a name collision), and never block removal; neither do hits in test files.
|
||||||
|
|
||||||
|
| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `CacheManager.has_data_changed` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.update_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.setup_persistent_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.get_sport_live_interval` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.get_sport_key_from_cache_key` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.get_background_cached_data` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.is_background_data_available` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.record_cache_hit` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.record_cache_miss` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.record_fetch_time` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.get_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.log_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `CacheManager.get_memory_cache_stats` | 3.8.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `DisplayManager.draw_sun` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `DisplayManager.draw_cloud` | 3.8.0 | core (2 internals) | — | ledmatrix-weather (1 unrelated) | unused — safe to remove in 3.8.0 |
|
||||||
|
| `DisplayManager.draw_rain` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `DisplayManager.draw_snow` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `DisplayManager.draw_weather_icon` | 3.8.0 | core (1 internal) | — | ledmatrix-weather (5 unrelateds) | unused — safe to remove in 3.8.0 |
|
||||||
|
| `DisplayManager.draw_text_with_icons` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `DisplayManager.get_scrolling_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.get_manager_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.get_detected_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.unregister_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.get_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.set_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.remove_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.get_overrides` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.get_available_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.get_size_tokens` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.get_performance_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.get_font_catalog` | 3.8.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.remove_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `FontManager.validate_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | blackjack (1 call, 1 override); calendar (1 override); olympics (1 override) | — | still used by blackjack, calendar, olympics — keep or migrate first |
|
||||||
|
| `BasePlugin.get_vegas_segment_width` | 3.9.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.9.0 |
|
||||||
|
| `PluginManager.get_enabled_plugins` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||||
|
|
||||||
|
## Unused — safe to remove (36)
|
||||||
|
|
||||||
|
`CacheManager.has_data_changed`, `CacheManager.update_cache`, `CacheManager.setup_persistent_cache`, `CacheManager.get_sport_live_interval`, `CacheManager.get_sport_key_from_cache_key`, `CacheManager.get_background_cached_data`, `CacheManager.is_background_data_available`, `CacheManager.record_cache_hit`, `CacheManager.record_cache_miss`, `CacheManager.record_fetch_time`, `CacheManager.get_cache_metrics`, `CacheManager.log_cache_metrics`, `CacheManager.get_memory_cache_stats`, `DisplayManager.draw_sun`, `DisplayManager.draw_cloud`, `DisplayManager.draw_rain`, `DisplayManager.draw_snow`, `DisplayManager.draw_weather_icon`, `DisplayManager.draw_text_with_icons`, `DisplayManager.get_scrolling_stats`, `FontManager.get_manager_fonts`, `FontManager.get_detected_fonts`, `FontManager.unregister_plugin_fonts`, `FontManager.get_plugin_fonts`, `FontManager.set_override`, `FontManager.remove_override`, `FontManager.get_overrides`, `FontManager.get_available_fonts`, `FontManager.get_size_tokens`, `FontManager.get_performance_stats`, `FontManager.get_font_catalog`, `FontManager.add_font`, `FontManager.remove_font`, `FontManager.validate_font`, `BasePlugin.get_vegas_segment_width`, `PluginManager.get_enabled_plugins`
|
||||||
|
|
||||||
|
## Still used — keep or migrate first (1)
|
||||||
|
|
||||||
|
`BasePlugin.get_supported_vegas_modes`
|
||||||
|
|
||||||
|
## Every hit
|
||||||
|
|
||||||
|
File paths are relative to the plugin's directory (core: the repo root).
|
||||||
|
|
||||||
|
| Method | Where | File:line | Kind | Code |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:28 | unrelated | `def get_sport_live_interval(self, sport_key: str) -> int:` |
|
||||||
|
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:60 | unrelated | `live_interval = self.get_sport_live_interval(sport_key)` |
|
||||||
|
| `CacheManager.get_sport_live_interval` | core | src/cache_manager.py:785 | unrelated | `return self._strategy_component.get_sport_live_interval(sport_key)` |
|
||||||
|
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache/cache_strategy.py:214 | unrelated | `def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:` |
|
||||||
|
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:806 | unrelated | `return self._strategy_component.get_sport_key_from_cache_key(key)` |
|
||||||
|
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:816 | unrelated | `sport_key = self._strategy_component.get_sport_key_from_cache_key(key)` |
|
||||||
|
| `CacheManager.record_cache_hit` | core | src/cache_manager.py:869 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_hit('background')` |
|
||||||
|
| `CacheManager.record_cache_miss` | core | src/cache_manager.py:876 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_miss('background')` |
|
||||||
|
| `CacheManager.record_fetch_time` | core | src/cache/cache_metrics.py:67 | unrelated | `def record_fetch_time(self, duration: float) -> None:` |
|
||||||
|
| `CacheManager.record_fetch_time` | core | src/cache_manager.py:922 | unrelated | `self._metrics_component.record_fetch_time(duration)` |
|
||||||
|
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:43 | test call | `stats = cm.get_memory_cache_stats()` |
|
||||||
|
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:63 | test call | `assert cm.get_memory_cache_stats()["last_cleanup"] >= before` |
|
||||||
|
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` |
|
||||||
|
| `DisplayManager.draw_sun` | core | src/plugin_system/testing/visual_display_manager.py:417 | unrelated | `def draw_sun(self, x: int, y: int, size: int = 16):` |
|
||||||
|
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1356 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
|
||||||
|
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1371 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
|
||||||
|
| `DisplayManager.draw_cloud` | core | src/plugin_system/testing/visual_display_manager.py:421 | unrelated | `def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):` |
|
||||||
|
| `DisplayManager.draw_cloud` | ledmatrix-weather | weather_icons.py:184 | unrelated | `def draw_cloud(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)):` |
|
||||||
|
| `DisplayManager.draw_rain` | core | src/plugin_system/testing/visual_display_manager.py:425 | unrelated | `def draw_rain(self, x: int, y: int, size: int = 16):` |
|
||||||
|
| `DisplayManager.draw_snow` | core | src/plugin_system/testing/visual_display_manager.py:429 | unrelated | `def draw_snow(self, x: int, y: int, size: int = 16):` |
|
||||||
|
| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1515 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||||
|
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:510 | unrelated | `def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:` |
|
||||||
|
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:533 | unrelated | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||||
|
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:76 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
|
||||||
|
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1265 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
|
||||||
|
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1544 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||||
|
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1635 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||||
|
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | weather_icons.py:168 | unrelated | `def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):` |
|
||||||
|
| `DisplayManager.draw_text_with_icons` | core | src/plugin_system/testing/visual_display_manager.py:526 | unrelated | `def draw_text_with_icons(self, text: str, icons: List[tuple] = None,` |
|
||||||
|
| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` |
|
||||||
|
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:356 | test review | `assert plugin.get_supported_vegas_modes() == [` |
|
||||||
|
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:358 | test review | `assert plugin.get_supported_vegas_modes()` |
|
||||||
|
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:732 | override | `def get_supported_vegas_modes(self):` |
|
||||||
|
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:695 | call | `if mode in self.get_supported_vegas_modes():` |
|
||||||
|
| `BasePlugin.get_supported_vegas_modes` | calendar | manager.py:875 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
|
||||||
|
| `BasePlugin.get_supported_vegas_modes` | olympics | manager.py:624 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
|
||||||
|
| `BasePlugin.get_vegas_segment_width` | core tests | test/test_vegas_participation.py:359 | test review | `assert plugin.get_vegas_segment_width() == 2` |
|
||||||
|
|
||||||
|
## Sources scanned
|
||||||
|
|
||||||
|
| Source | Group | Python files | Hits |
|
||||||
|
|---|---|---|---|
|
||||||
|
| core | core | 164 | 20 |
|
||||||
|
| core tests | core-tests | 323 | 17 |
|
||||||
|
| 7-segment-clock | monorepo | 3 | 0 |
|
||||||
|
| afl-scoreboard | monorepo | 34 | 0 |
|
||||||
|
| baseball-scoreboard | monorepo | 60 | 0 |
|
||||||
|
| basketball-scoreboard | monorepo | 48 | 0 |
|
||||||
|
| birdnet-go | monorepo | 2 | 0 |
|
||||||
|
| blackjack | monorepo | 7 | 2 |
|
||||||
|
| calendar | monorepo | 5 | 1 |
|
||||||
|
| christmas-countdown | monorepo | 3 | 0 |
|
||||||
|
| clock-simple | monorepo | 2 | 0 |
|
||||||
|
| countdown | monorepo | 5 | 0 |
|
||||||
|
| cricket-scoreboard | monorepo | 8 | 0 |
|
||||||
|
| f1-scoreboard | monorepo | 15 | 0 |
|
||||||
|
| fantasy-blitz | monorepo | 13 | 0 |
|
||||||
|
| football-scoreboard | monorepo | 73 | 0 |
|
||||||
|
| geochron | monorepo | 10 | 0 |
|
||||||
|
| hello-world | monorepo | 2 | 0 |
|
||||||
|
| hockey-scoreboard | monorepo | 51 | 0 |
|
||||||
|
| incoming-packages | monorepo | 8 | 0 |
|
||||||
|
| jellyfin-now-playing | monorepo | 4 | 0 |
|
||||||
|
| lacrosse-scoreboard | monorepo | 39 | 0 |
|
||||||
|
| ledmatrix-elections | monorepo | 12 | 0 |
|
||||||
|
| ledmatrix-flights | monorepo | 45 | 0 |
|
||||||
|
| ledmatrix-leaderboard | monorepo | 9 | 0 |
|
||||||
|
| ledmatrix-music | monorepo | 11 | 0 |
|
||||||
|
| ledmatrix-stocks | monorepo | 7 | 0 |
|
||||||
|
| ledmatrix-weather | monorepo | 14 | 6 |
|
||||||
|
| march-madness | monorepo | 4 | 0 |
|
||||||
|
| masters-tournament | monorepo | 10 | 0 |
|
||||||
|
| mqtt-notifications | monorepo | 4 | 0 |
|
||||||
|
| news | monorepo | 6 | 0 |
|
||||||
|
| nfl-draft | monorepo | 3 | 0 |
|
||||||
|
| nfl-stat-leaders | monorepo | 8 | 0 |
|
||||||
|
| nrl-scoreboard | monorepo | 29 | 0 |
|
||||||
|
| odds-ticker | monorepo | 9 | 0 |
|
||||||
|
| of-the-day | monorepo | 14 | 0 |
|
||||||
|
| olympics | monorepo | 16 | 1 |
|
||||||
|
| on-air | monorepo | 2 | 0 |
|
||||||
|
| pomodoro-timer | monorepo | 3 | 0 |
|
||||||
|
| soccer-scoreboard | monorepo | 46 | 0 |
|
||||||
|
| static-image | monorepo | 3 | 0 |
|
||||||
|
| stock-news | monorepo | 3 | 0 |
|
||||||
|
| text-display | monorepo | 4 | 0 |
|
||||||
|
| tide-display | monorepo | 3 | 0 |
|
||||||
|
| ufc-scoreboard | monorepo | 34 | 0 |
|
||||||
|
| web-ui-info | monorepo | 2 | 0 |
|
||||||
|
| youtube-stats | monorepo | 5 | 0 |
|
||||||
|
| f1-live | third-party | 10 | 0 |
|
||||||
|
| gif-player | third-party | 1 | 0 |
|
||||||
|
| pga-tour-leaderboard | third-party | 2 | 0 |
|
||||||
|
| plex-marquee | third-party | 1 | 0 |
|
||||||
|
| ledmatrix-dresden-departures | third-party | 1 | 0 |
|
||||||
|
| tidbyt-baseball-scoreboard | third-party | 2 | 0 |
|
||||||
|
| sleeper-fantasy | third-party | 1 | 0 |
|
||||||
|
| ledmatrix-nascar | third-party | 1 | 0 |
|
||||||
|
|
||||||
|
## How to re-run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clones the monorepo and each third-party plugin (depth 1) into a temp cache:
|
||||||
|
python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md
|
||||||
|
# Or scan a local monorepo checkout (read only) instead of cloning it:
|
||||||
|
python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
Before removing a method in its release, re-run the scan against the current monorepo and registry: a plugin added since this file was generated may have started calling it. Remove only methods the fresh scan reports unused; move the rest to a later release (the test in `test/test_deprecation.py` fails while a marker names a release at or below `src.__version__`).
|
||||||
@@ -31,7 +31,7 @@ POST /api/v3/system/action
|
|||||||
|
|
||||||
**Base URL**: `http://your-pi-ip:5000/api/v3`
|
**Base URL**: `http://your-pi-ip:5000/api/v3`
|
||||||
|
|
||||||
See [API_REFERENCE.md](API_REFERENCE.md) for complete documentation.
|
See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete documentation.
|
||||||
|
|
||||||
## Display Manager Quick Methods
|
## Display Manager Quick Methods
|
||||||
|
|
||||||
@@ -48,8 +48,14 @@ display_manager.draw_text("Centered", centered=True) # Auto-center
|
|||||||
width = display_manager.get_text_width("Text", font)
|
width = display_manager.get_text_width("Text", font)
|
||||||
height = display_manager.get_font_height(font)
|
height = display_manager.get_font_height(font)
|
||||||
|
|
||||||
# Weather icons
|
# Adaptive layout (recommended for multi-size support — text and images
|
||||||
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
# that scale to any panel; see docs/ADAPTIVE_LAYOUT.md)
|
||||||
|
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||||
|
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||||
|
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||||
|
|
||||||
|
# Weather icons: draw_weather_icon() is deprecated, removed in 3.8.0 —
|
||||||
|
# draw your own icons (the weather plugin ships WeatherIcons)
|
||||||
|
|
||||||
# Scrolling state
|
# Scrolling state
|
||||||
display_manager.set_scrolling_state(True)
|
display_manager.set_scrolling_state(True)
|
||||||
@@ -66,20 +72,23 @@ 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")
|
||||||
data = cache_manager.get_background_cached_data("key", sport_key="nhl")
|
|
||||||
|
|
||||||
# Strategy
|
# Strategy
|
||||||
strategy = cache_manager.get_cache_strategy("weather")
|
strategy = cache_manager.get_cache_strategy("weather")
|
||||||
interval = cache_manager.get_sport_live_interval("nhl")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()`
|
||||||
|
are deprecated, removed in 3.8.0. See
|
||||||
|
[Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||||
|
|
||||||
## Plugin Manager Quick Methods
|
## Plugin Manager Quick Methods
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Get plugins
|
# Get plugins
|
||||||
plugin = plugin_manager.get_plugin("plugin-id")
|
plugin = plugin_manager.get_plugin("plugin-id")
|
||||||
all_plugins = plugin_manager.get_all_plugins()
|
all_plugins = plugin_manager.get_all_plugins()
|
||||||
enabled = plugin_manager.get_enabled_plugins()
|
# get_enabled_plugins() is deprecated, removed in 3.8.0 — check `enabled`
|
||||||
|
# on the entries in plugin_manager.plugins
|
||||||
|
|
||||||
# Get info
|
# Get info
|
||||||
info = plugin_manager.get_plugin_info("plugin-id")
|
info = plugin_manager.get_plugin_info("plugin-id")
|
||||||
@@ -162,7 +171,7 @@ def display(self, force_clear=False):
|
|||||||
|
|
||||||
- [ ] Plugin inherits from `BasePlugin`
|
- [ ] Plugin inherits from `BasePlugin`
|
||||||
- [ ] Implements `update()` and `display()` methods
|
- [ ] Implements `update()` and `display()` methods
|
||||||
- [ ] `manifest.json` with required fields
|
- [ ] `manifest.json` with the [required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)
|
||||||
- [ ] `config_schema.json` for web UI (recommended)
|
- [ ] `config_schema.json` for web UI (recommended)
|
||||||
- [ ] `README.md` with documentation
|
- [ ] `README.md` with documentation
|
||||||
- [ ] Error handling implemented
|
- [ ] Error handling implemented
|
||||||
@@ -184,12 +193,13 @@ def display(self, force_clear=False):
|
|||||||
|
|
||||||
```
|
```
|
||||||
LEDMatrix/
|
LEDMatrix/
|
||||||
├── plugins/ # Installed plugins
|
├── plugin-repos/ # Installed plugins (default; plugins/ is only
|
||||||
|
│ # for dev symlinks via scripts/dev/dev_plugin_setup.sh)
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── config.json # Main configuration
|
│ ├── config.json # Main configuration
|
||||||
│ └── config_secrets.json # API keys and secrets
|
│ └── config_secrets.json # API keys and secrets
|
||||||
├── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
│ ├── API_REFERENCE.md
|
│ ├── REST_API_REFERENCE.md
|
||||||
│ ├── PLUGIN_API_REFERENCE.md
|
│ ├── PLUGIN_API_REFERENCE.md
|
||||||
│ └── ...
|
│ └── ...
|
||||||
└── src/
|
└── src/
|
||||||
@@ -201,7 +211,7 @@ LEDMatrix/
|
|||||||
|
|
||||||
## Quick Links
|
## Quick Links
|
||||||
|
|
||||||
- [Complete API Reference](API_REFERENCE.md)
|
- [Complete REST API Reference](REST_API_REFERENCE.md)
|
||||||
- [Plugin API Reference](PLUGIN_API_REFERENCE.md)
|
- [Plugin API Reference](PLUGIN_API_REFERENCE.md)
|
||||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||||
- [Advanced Patterns](ADVANCED_PLUGIN_DEVELOPMENT.md)
|
- [Advanced Patterns](ADVANCED_PLUGIN_DEVELOPMENT.md)
|
||||||
|
|||||||
@@ -43,16 +43,21 @@ git submodule update --init --recursive rpi-rgb-led-matrix-master
|
|||||||
|
|
||||||
#### Building the Submodule
|
#### Building the Submodule
|
||||||
|
|
||||||
After initializing the submodule, you need to build the Python bindings:
|
After initializing the submodule, build and install the `rgbmatrix` Python
|
||||||
|
package from the submodule root. Upstream's `pyproject.toml` builds it with
|
||||||
|
scikit-build-core, CMake and Ninja; there is no separate `make` step:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rpi-rgb-led-matrix-master
|
cd rpi-rgb-led-matrix-master
|
||||||
make build-python
|
|
||||||
cd bindings/python
|
|
||||||
python3 -m pip install --break-system-packages .
|
python3 -m pip install --break-system-packages .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The `first_time_install.sh` script automates this process during installation.
|
On a board with 1 GB of RAM or less, cap the compile so it doesn't run out of
|
||||||
|
memory: `CMAKE_BUILD_PARALLEL_LEVEL=1 python3 -m pip install --break-system-packages .`
|
||||||
|
|
||||||
|
**Note:** The `first_time_install.sh` script automates this process during
|
||||||
|
installation, including the parallelism cap and a temporary swapfile on
|
||||||
|
low-memory boards.
|
||||||
|
|
||||||
#### Troubleshooting
|
#### Troubleshooting
|
||||||
|
|
||||||
@@ -69,7 +74,7 @@ git submodule update --init --recursive rpi-rgb-led-matrix-master
|
|||||||
**Build fails:**
|
**Build fails:**
|
||||||
Ensure you have the required build dependencies installed:
|
Ensure you have the required build dependencies installed:
|
||||||
```bash
|
```bash
|
||||||
sudo apt install -y build-essential python3-dev cython3 scons
|
sudo apt install -y build-essential python-dev-is-python3 cmake ninja-build
|
||||||
```
|
```
|
||||||
|
|
||||||
**Import error for `rgbmatrix` module:**
|
**Import error for `rgbmatrix` module:**
|
||||||
@@ -97,8 +102,6 @@ When setting up CI/CD pipelines, ensure submodules are initialized before buildi
|
|||||||
- name: Build rpi-rgb-led-matrix
|
- name: Build rpi-rgb-led-matrix
|
||||||
run: |
|
run: |
|
||||||
cd rpi-rgb-led-matrix-master
|
cd rpi-rgb-led-matrix-master
|
||||||
make build-python
|
|
||||||
cd bindings/python
|
|
||||||
pip install .
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -110,8 +113,6 @@ variables:
|
|||||||
build:
|
build:
|
||||||
script:
|
script:
|
||||||
- cd rpi-rgb-led-matrix-master
|
- cd rpi-rgb-led-matrix-master
|
||||||
- make build-python
|
|
||||||
- cd bindings/python
|
|
||||||
- pip install .
|
- pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ Tools for rapid plugin development without deploying to the RPi.
|
|||||||
|
|
||||||
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
||||||
|
|
||||||
|
The size inputs have a preset dropdown with the harness's standard panel
|
||||||
|
sizes, and the **All Sizes** button renders the current config at every
|
||||||
|
harness size in a side-by-side gallery (`POST /api/render-matrix`) — the
|
||||||
|
quickest way to eyeball adaptive-layout behavior across panels
|
||||||
|
(see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)).
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ The LEDMatrix emulator allows you to run and test LEDMatrix displays on your com
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### System Requirements
|
### System Requirements
|
||||||
- Python 3.7 or higher
|
- Python 3.10 or higher
|
||||||
- Windows, macOS, or Linux
|
- Windows, macOS, or Linux
|
||||||
- At least 2GB RAM (4GB recommended)
|
- At least 2GB RAM (4GB recommended)
|
||||||
- Internet connection for plugin downloads
|
- Internet connection for plugin downloads
|
||||||
|
|
||||||
### Required Software
|
### Required Software
|
||||||
- Python 3.7+
|
- Python 3.10+
|
||||||
- pip (Python package manager)
|
- pip (Python package manager)
|
||||||
- Git (for plugin management)
|
- Git (for plugin management)
|
||||||
|
|
||||||
@@ -50,8 +50,7 @@ pip install -r requirements-emulator.txt
|
|||||||
```
|
```
|
||||||
|
|
||||||
This installs:
|
This installs:
|
||||||
- `RGBMatrixEmulator` - The core emulation library
|
- `RGBMatrixEmulator` - the emulation library (and whatever it depends on)
|
||||||
- Additional dependencies for display adapters
|
|
||||||
|
|
||||||
### 3. Install Standard Dependencies
|
### 3. Install Standard Dependencies
|
||||||
|
|
||||||
@@ -63,29 +62,31 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
### 1. Emulator Configuration File
|
### 1. Emulator Configuration File
|
||||||
|
|
||||||
The emulator uses `emulator_config.json` for configuration. Here's the
|
The emulator uses `emulator_config.json` for configuration. It isn't in
|
||||||
default configuration as it ships in the repo:
|
the repo (it's gitignored): RGBMatrixEmulator writes it on first run.
|
||||||
|
A typical file looks like this:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"pixel_outline": 0,
|
"pixel_outline": 0,
|
||||||
"pixel_size": 5,
|
"pixel_size": 16,
|
||||||
"pixel_style": "square",
|
"pixel_style": "square",
|
||||||
"pixel_glow": 6,
|
"pixel_glow": 6,
|
||||||
"display_adapter": "pygame",
|
"display_adapter": "browser",
|
||||||
|
"allow_adapter_fallback": true,
|
||||||
"icon_path": null,
|
"icon_path": null,
|
||||||
"emulator_title": null,
|
"emulator_title": null,
|
||||||
"suppress_font_warnings": false,
|
"suppress_font_warnings": false,
|
||||||
"suppress_adapter_load_errors": false,
|
|
||||||
"browser": {
|
"browser": {
|
||||||
"_comment": "For use with the browser adapter only.",
|
"_comment": "For use with the browser adapter only.",
|
||||||
"port": 8888,
|
"port": 8888,
|
||||||
"target_fps": 24,
|
"target_fps": 60,
|
||||||
"fps_display": false,
|
"fps_display": false,
|
||||||
"quality": 70,
|
"quality": 70,
|
||||||
"image_border": true,
|
"image_border": true,
|
||||||
"debug_text": false,
|
"debug_text": false,
|
||||||
"image_format": "JPEG"
|
"image_format": "JPEG",
|
||||||
|
"open_immediately": false
|
||||||
},
|
},
|
||||||
"log_level": "info"
|
"log_level": "info"
|
||||||
}
|
}
|
||||||
@@ -96,13 +97,13 @@ default configuration as it ships in the repo:
|
|||||||
| 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 | 5 | 1-64 (8–16 is typical for testing) |
|
| `pixel_size` | Size of each pixel | 16 | 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 | "browser" | "browser", "pygame" |
|
||||||
|
| `allow_adapter_fallback` | Fall back to another adapter if the configured one fails to load | true | true/false |
|
||||||
| `emulator_title` | Window title | null | Any string |
|
| `emulator_title` | Window title | null | Any string |
|
||||||
| `suppress_font_warnings` | Hide font warnings | false | true/false |
|
| `suppress_font_warnings` | Hide font warnings | false | true/false |
|
||||||
| `suppress_adapter_load_errors` | Hide adapter errors | false | true/false |
|
|
||||||
|
|
||||||
### 3. Browser Adapter Configuration
|
### 3. Browser Adapter Configuration
|
||||||
|
|
||||||
@@ -111,18 +112,32 @@ When using the browser adapter, additional options are available:
|
|||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| `port` | Web server port | 8888 |
|
| `port` | Web server port | 8888 |
|
||||||
| `target_fps` | Target frames per second | 24 |
|
| `target_fps` | Target frames per second | 60 |
|
||||||
| `fps_display` | Show FPS counter | false |
|
| `fps_display` | Show FPS counter | false |
|
||||||
| `quality` | Image compression quality | 70 |
|
| `quality` | Image compression quality | 70 |
|
||||||
| `image_border` | Show image border | true |
|
| `image_border` | Show image border | true |
|
||||||
| `debug_text` | Show debug information | false |
|
| `debug_text` | Show debug information | false |
|
||||||
| `image_format` | Image format | "JPEG" |
|
| `image_format` | Image format | "JPEG" |
|
||||||
|
| `open_immediately` | Open the browser page automatically on start | false |
|
||||||
|
|
||||||
## Running the Emulator
|
## Running the Emulator
|
||||||
|
|
||||||
### 1. Set Environment Variable
|
### 1. Use the `-e` Flag (Recommended)
|
||||||
|
|
||||||
Enable emulator mode by setting the `EMULATOR` environment variable:
|
`run.py` accepts exactly two flags: `-e`/`--emulator` and
|
||||||
|
`-d`/`--debug`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 run.py -e
|
||||||
|
|
||||||
|
# With verbose logging
|
||||||
|
python3 run.py -e -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Alternative: Set the Environment Variable
|
||||||
|
|
||||||
|
You can also enable emulator mode via the `EMULATOR` environment
|
||||||
|
variable:
|
||||||
|
|
||||||
**Windows (Command Prompt):**
|
**Windows (Command Prompt):**
|
||||||
```cmd
|
```cmd
|
||||||
@@ -137,15 +152,6 @@ python run.py
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Linux/macOS:**
|
**Linux/macOS:**
|
||||||
```bash
|
|
||||||
export EMULATOR=true
|
|
||||||
python3 run.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Alternative: Direct Python Execution
|
|
||||||
|
|
||||||
You can also run the emulator directly:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
EMULATOR=true python3 run.py
|
EMULATOR=true python3 run.py
|
||||||
```
|
```
|
||||||
@@ -153,7 +159,8 @@ EMULATOR=true python3 run.py
|
|||||||
### 3. Verify Emulator Mode
|
### 3. Verify Emulator Mode
|
||||||
|
|
||||||
When running in emulator mode, you should see:
|
When running in emulator mode, you should see:
|
||||||
- A window displaying the LED matrix simulation
|
- The emulated matrix — a web page at `http://localhost:8888` with the
|
||||||
|
default browser adapter, or a desktop window with the pygame adapter
|
||||||
- Console output indicating emulator mode
|
- Console output indicating emulator mode
|
||||||
- No hardware initialization errors
|
- No hardware initialization errors
|
||||||
|
|
||||||
@@ -161,7 +168,36 @@ When running in emulator mode, you should see:
|
|||||||
|
|
||||||
LEDMatrix supports two display adapters for the emulator:
|
LEDMatrix supports two display adapters for the emulator:
|
||||||
|
|
||||||
### 1. Pygame Adapter (Default)
|
### 1. Browser Adapter (Default)
|
||||||
|
|
||||||
|
The browser adapter runs a web server and displays the matrix as a web
|
||||||
|
page at `http://localhost:8888`. This is the adapter the shipped
|
||||||
|
`emulator_config.json` uses.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Web-based interface
|
||||||
|
- Remote access capability
|
||||||
|
- Mobile-friendly
|
||||||
|
- Screenshot capture
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"display_adapter": "browser",
|
||||||
|
"browser": {
|
||||||
|
"port": 8888,
|
||||||
|
"target_fps": 60,
|
||||||
|
"quality": 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
1. Start the emulator (`python3 run.py -e`)
|
||||||
|
2. Open browser to `http://localhost:8888`
|
||||||
|
3. View the LED matrix display
|
||||||
|
|
||||||
|
### 2. Pygame Adapter (Alternative)
|
||||||
|
|
||||||
The pygame adapter provides a native desktop window with real-time display.
|
The pygame adapter provides a native desktop window with real-time display.
|
||||||
|
|
||||||
@@ -186,33 +222,6 @@ The pygame adapter provides a native desktop window with real-time display.
|
|||||||
- `+/-` - Zoom in/out
|
- `+/-` - Zoom in/out
|
||||||
- `R` - Reset zoom
|
- `R` - Reset zoom
|
||||||
|
|
||||||
### 2. Browser Adapter
|
|
||||||
|
|
||||||
The browser adapter runs a web server and displays the matrix in a web browser.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Web-based interface
|
|
||||||
- Remote access capability
|
|
||||||
- Mobile-friendly
|
|
||||||
- Screenshot capture
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"display_adapter": "browser",
|
|
||||||
"browser": {
|
|
||||||
"port": 8888,
|
|
||||||
"target_fps": 24,
|
|
||||||
"quality": 70
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
1. Start the emulator with browser adapter
|
|
||||||
2. Open browser to `http://localhost:8888`
|
|
||||||
3. View the LED matrix display
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
@@ -274,8 +283,7 @@ Enable debug logging:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"log_level": "debug",
|
"log_level": "debug",
|
||||||
"suppress_font_warnings": false,
|
"suppress_font_warnings": false
|
||||||
"suppress_adapter_load_errors": false
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -299,17 +307,18 @@ Modify the display dimensions in your main config:
|
|||||||
|
|
||||||
### 2. Plugin Development
|
### 2. Plugin Development
|
||||||
|
|
||||||
For plugin development with the emulator:
|
`run.py` always runs the full rotation — it has no single-plugin flag.
|
||||||
|
To preview or check one plugin in isolation, use the dev tools:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Enable emulator mode
|
# Run the full display in emulator mode (optionally with debug logging)
|
||||||
export EMULATOR=true
|
python3 run.py -e -d
|
||||||
|
|
||||||
# Run with specific plugin
|
# Live single-plugin preview in the browser (port 5001)
|
||||||
python run.py --plugin my-plugin
|
python3 scripts/dev_server.py
|
||||||
|
|
||||||
# Debug mode
|
# Headless render/validation of one plugin
|
||||||
python run.py --debug
|
python3 scripts/check_plugin.py --plugin my-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Performance Tuning
|
### 3. Performance Tuning
|
||||||
@@ -344,11 +353,10 @@ The emulator can work alongside the web interface:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: Start emulator
|
# Terminal 1: Start emulator
|
||||||
export EMULATOR=true
|
python3 run.py -e
|
||||||
python run.py
|
|
||||||
|
|
||||||
# Terminal 2: Start web interface
|
# Terminal 2: Start web interface (supported entry point)
|
||||||
python web_interface/app.py
|
python3 web_interface/start.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Access the web interface at `http://localhost:5000` while the emulator runs.
|
Access the web interface at `http://localhost:5000` while the emulator runs.
|
||||||
@@ -365,13 +373,14 @@ Access the web interface at `http://localhost:5000` while the emulator runs.
|
|||||||
### 2. Plugin Testing
|
### 2. Plugin Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test specific plugin
|
# Test a specific plugin (headless check)
|
||||||
export EMULATOR=true
|
python3 scripts/check_plugin.py --plugin clock-simple
|
||||||
python run.py --plugin clock-simple
|
|
||||||
|
|
||||||
# Test all plugins
|
# Preview a single plugin live in the browser (port 5001)
|
||||||
export EMULATOR=true
|
python3 scripts/dev_server.py
|
||||||
python run.py --test-plugins
|
|
||||||
|
# Test the full rotation in the emulator
|
||||||
|
python3 run.py -e
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Configuration Management
|
### 3. Configuration Management
|
||||||
@@ -385,9 +394,8 @@ python run.py --test-plugins
|
|||||||
### Basic Clock Display
|
### Basic Clock Display
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start emulator with clock
|
# Start emulator with clock enabled in config.json
|
||||||
export EMULATOR=true
|
python3 run.py -e
|
||||||
python run.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sports Scores
|
### Sports Scores
|
||||||
@@ -395,16 +403,16 @@ python run.py
|
|||||||
```bash
|
```bash
|
||||||
# Configure for sports display
|
# Configure for sports display
|
||||||
# Edit config/config.json to enable sports plugins
|
# Edit config/config.json to enable sports plugins
|
||||||
export EMULATOR=true
|
python3 run.py -e
|
||||||
python run.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom Text Display
|
### Custom Text Display
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Use text display plugin
|
# Preview the text display plugin on its own
|
||||||
export EMULATOR=true
|
python3 scripts/check_plugin.py --plugin text-display
|
||||||
python run.py --plugin text-display --text "Hello World"
|
# or use the live dev preview server
|
||||||
|
python3 scripts/dev_server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|||||||
@@ -1,167 +1,95 @@
|
|||||||
# FontManager Usage Guide
|
# FontManager Usage Guide
|
||||||
|
|
||||||
|
> **Picking a size automatically:** if you want the *largest font that fits
|
||||||
|
> a given area* rather than a fixed size, use the adaptive layout system's
|
||||||
|
> font ladders, which resolve through this FontManager. `BasePlugin`
|
||||||
|
> subclasses get this as `self.layout.fit_text(...)`; other code can build
|
||||||
|
> a `LayoutContext(width, height, font_manager)` directly — see
|
||||||
|
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
[`src/font_manager.py`](../src/font_manager.py) loads and caches the TTF and
|
||||||
- Manager font registration and detection
|
BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
|
||||||
- Plugin font management
|
which plugin uses which font so the web UI can show it.
|
||||||
- Manual font overrides via web interface
|
|
||||||
- Performance monitoring and caching
|
|
||||||
- Dynamic font discovery
|
|
||||||
|
|
||||||
## Architecture
|
Several methods are deprecated and will be removed in LEDMatrix 3.8.0; they
|
||||||
|
log a warning on first call. They are listed in
|
||||||
|
[Deprecated methods](#deprecated-methods) below, and the full set is pinned in
|
||||||
|
[`test/test_deprecation.py`](../test/test_deprecation.py).
|
||||||
|
|
||||||
### Manager-Centric Design
|
## Getting the FontManager
|
||||||
|
|
||||||
Managers define their own fonts, but the FontManager:
|
There is one shared FontManager per display process. The display controller
|
||||||
1. **Loads and caches fonts** for performance
|
creates it and hands it to the `PluginManager`, so a plugin reaches it
|
||||||
2. **Detects font usage** for visibility
|
through its `plugin_manager`:
|
||||||
3. **Allows manual overrides** when needed
|
|
||||||
4. **Supports plugin fonts** with namespacing
|
|
||||||
|
|
||||||
### Font Resolution Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Manager requests font → Check manual overrides → Apply manager choice → Cache & return
|
|
||||||
```
|
|
||||||
|
|
||||||
## For Manager Developers
|
|
||||||
|
|
||||||
### Basic Font Usage
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from src.font_manager import FontManager
|
class MyPlugin(BasePlugin):
|
||||||
|
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||||
class MyManager:
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
def __init__(self, config, display_manager, cache_manager):
|
self.font_manager = self._get_font_manager()
|
||||||
self.font_manager = display_manager.font_manager # Access shared FontManager
|
|
||||||
self.manager_id = "my_manager"
|
|
||||||
|
|
||||||
def display(self):
|
|
||||||
# Define your font choices
|
|
||||||
element_key = "my_manager.title"
|
|
||||||
font_family = "press_start"
|
|
||||||
font_size_px = 10
|
|
||||||
color = (255, 255, 255) # RGB white
|
|
||||||
|
|
||||||
# Register your font choice (for detection and future overrides)
|
|
||||||
self.font_manager.register_manager_font(
|
|
||||||
manager_id=self.manager_id,
|
|
||||||
element_key=element_key,
|
|
||||||
family=font_family,
|
|
||||||
size_px=font_size_px,
|
|
||||||
color=color
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get the font (checks for manual overrides automatically)
|
|
||||||
font = self.font_manager.resolve_font(
|
|
||||||
element_key=element_key,
|
|
||||||
family=font_family,
|
|
||||||
size_px=font_size_px
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use the font for rendering
|
|
||||||
self.display_manager.draw_text(
|
|
||||||
"Hello World",
|
|
||||||
x=10, y=10,
|
|
||||||
color=color,
|
|
||||||
font=font
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Font Usage
|
`BasePlugin._get_font_manager()` returns `plugin_manager.font_manager`, or a
|
||||||
|
standalone FontManager when none is available (test harnesses, mocks).
|
||||||
|
`DisplayManager` has **no** `font_manager` attribute —
|
||||||
|
`display_manager.font_manager` raises `AttributeError`.
|
||||||
|
|
||||||
|
## Resolving a font
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class AdvancedManager:
|
element_key = f"{self.plugin_id}.title"
|
||||||
def __init__(self, config, display_manager, cache_manager):
|
|
||||||
self.font_manager = display_manager.font_manager
|
|
||||||
self.manager_id = "advanced_manager"
|
|
||||||
|
|
||||||
# Define your font specifications
|
# Register the choice so the web UI's Fonts tab can list it.
|
||||||
self.font_specs = {
|
self.font_manager.register_manager_font(
|
||||||
"title": {"family": "press_start", "size_px": 12, "color": (255, 255, 0)},
|
manager_id=self.plugin_id,
|
||||||
"body": {"family": "four_by_six", "size_px": 8, "color": (255, 255, 255)},
|
element_key=element_key,
|
||||||
"footer": {"family": "five_by_seven", "size_px": 7, "color": (128, 128, 128)}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Register all font specs
|
|
||||||
for element_type, spec in self.font_specs.items():
|
|
||||||
element_key = f"{self.manager_id}.{element_type}"
|
|
||||||
self.font_manager.register_manager_font(
|
|
||||||
manager_id=self.manager_id,
|
|
||||||
element_key=element_key,
|
|
||||||
family=spec["family"],
|
|
||||||
size_px=spec["size_px"],
|
|
||||||
color=spec["color"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_font(self, element_type: str):
|
|
||||||
"""Helper method to get fonts with override support."""
|
|
||||||
spec = self.font_specs[element_type]
|
|
||||||
element_key = f"{self.manager_id}.{element_type}"
|
|
||||||
|
|
||||||
return self.font_manager.resolve_font(
|
|
||||||
element_key=element_key,
|
|
||||||
family=spec["family"],
|
|
||||||
size_px=spec["size_px"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def display(self):
|
|
||||||
# Get fonts (automatically checks for overrides)
|
|
||||||
title_font = self.get_font("title")
|
|
||||||
body_font = self.get_font("body")
|
|
||||||
footer_font = self.get_font("footer")
|
|
||||||
|
|
||||||
# Render with fonts
|
|
||||||
self.display_manager.draw_text("Title", font=title_font, color=self.font_specs["title"]["color"])
|
|
||||||
self.display_manager.draw_text("Body Text", font=body_font, color=self.font_specs["body"]["color"])
|
|
||||||
self.display_manager.draw_text("Footer", font=footer_font, color=self.font_specs["footer"]["color"])
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Size Tokens
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Get available size tokens
|
|
||||||
tokens = self.font_manager.get_size_tokens()
|
|
||||||
# Returns: {'xs': 6, 'sm': 8, 'md': 10, 'lg': 12, 'xl': 14, 'xxl': 16}
|
|
||||||
|
|
||||||
# Use token to get size
|
|
||||||
size_px = tokens.get('md', 10) # 10px
|
|
||||||
|
|
||||||
# Then use in font resolution
|
|
||||||
font = self.font_manager.resolve_font(
|
|
||||||
element_key="my_manager.text",
|
|
||||||
family="press_start",
|
family="press_start",
|
||||||
size_px=size_px
|
size_px=10,
|
||||||
|
color=(255, 255, 255),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
font = self.font_manager.resolve_font(
|
||||||
|
element_key=element_key,
|
||||||
|
family="press_start",
|
||||||
|
size_px=10,
|
||||||
|
)
|
||||||
|
self.display_manager.draw_text("Hello", x=10, y=10, font=font)
|
||||||
```
|
```
|
||||||
|
|
||||||
## For Plugin Developers
|
`resolve_font()` applies any entry for `element_key` in
|
||||||
|
`config/font_overrides.json`, maps a plugin-local family to its namespaced
|
||||||
|
name when `plugin_id` is passed, and then calls `get_font(family, size_px)`.
|
||||||
|
On error it returns a fallback font rather than raising.
|
||||||
|
|
||||||
> **Note**: plugins that ship their own fonts via a `"fonts"` block
|
`get_font(family, size_px)` looks the family up in `font_catalog` and loads
|
||||||
> in `manifest.json` are registered automatically during plugin load
|
it (cached per family and size).
|
||||||
> (`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
|
## Font families
|
||||||
|
|
||||||
In your plugin's `manifest.json`:
|
At start-up the FontManager scans `assets/fonts/` for `.ttf` and `.bdf`
|
||||||
|
files. Each becomes a family named after the file, lower-cased and without
|
||||||
|
the extension (`PressStart2P-Regular.ttf` → `pressstart2p-regular`). Four
|
||||||
|
aliases are added on top:
|
||||||
|
|
||||||
|
| Alias | File |
|
||||||
|
|---|---|
|
||||||
|
| `press_start` | `assets/fonts/PressStart2P-Regular.ttf` |
|
||||||
|
| `four_by_six` | `assets/fonts/4x6-font.ttf` |
|
||||||
|
| `five_by_seven` | `assets/fonts/5x7.bdf` |
|
||||||
|
| `tom_thumb` | `assets/fonts/tom-thumb.bdf` |
|
||||||
|
|
||||||
|
Read the catalog directly: `font_manager.font_catalog` is a dict of family
|
||||||
|
name to file path. Files added later are picked up on the next start of the
|
||||||
|
display service.
|
||||||
|
|
||||||
|
## Plugin fonts
|
||||||
|
|
||||||
|
Plugins that ship their own fonts declare them in a `"fonts"` block in
|
||||||
|
`manifest.json`. The plugin manager calls
|
||||||
|
`FontManager.register_plugin_fonts()` during plugin load. `plugin://…`
|
||||||
|
sources are resolved relative to the plugin's install directory.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -172,216 +100,123 @@ In your plugin's `manifest.json`:
|
|||||||
{
|
{
|
||||||
"family": "custom_font",
|
"family": "custom_font",
|
||||||
"source": "plugin://fonts/custom.ttf",
|
"source": "plugin://fonts/custom.ttf",
|
||||||
"metadata": {
|
"metadata": {"description": "Custom plugin font", "license": "MIT"}
|
||||||
"description": "Custom plugin font",
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"family": "web_font",
|
"family": "web_font",
|
||||||
"source": "https://example.com/fonts/font.ttf",
|
"source": "https://example.com/fonts/font.ttf",
|
||||||
"metadata": {
|
"metadata": {"checksum": "sha256:abc123..."}
|
||||||
"description": "Downloaded font",
|
|
||||||
"checksum": "sha256:abc123..."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Plugin Fonts
|
Registered families are namespaced as `<plugin_id>::<family>`. Pass
|
||||||
|
`plugin_id` to `resolve_font()` to use the short name:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class PluginManager:
|
font = self.font_manager.resolve_font(
|
||||||
def __init__(self, config, display_manager, cache_manager, plugin_id):
|
element_key=f"{self.plugin_id}.text",
|
||||||
self.font_manager = display_manager.font_manager
|
family="custom_font", # resolved as "my-plugin::custom_font"
|
||||||
self.plugin_id = plugin_id
|
size_px=10,
|
||||||
|
plugin_id=self.plugin_id,
|
||||||
def display(self):
|
|
||||||
# Use plugin font (automatically namespaced)
|
|
||||||
font = self.font_manager.resolve_font(
|
|
||||||
element_key=f"{self.plugin_id}.text",
|
|
||||||
family="custom_font", # Will be resolved as "my-plugin::custom_font"
|
|
||||||
size_px=10,
|
|
||||||
plugin_id=self.plugin_id
|
|
||||||
)
|
|
||||||
|
|
||||||
self.display_manager.draw_text("Plugin Text", font=font)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manual Font Overrides
|
|
||||||
|
|
||||||
Users can override any font through the web interface:
|
|
||||||
|
|
||||||
1. Navigate to **Fonts** tab
|
|
||||||
2. View **Detected Manager Fonts** to see what's currently in use
|
|
||||||
3. In **Element Overrides** section:
|
|
||||||
- Select the element (e.g., "nfl.live.score")
|
|
||||||
- Choose a different font family
|
|
||||||
- Choose a different size
|
|
||||||
- Click **Add Override**
|
|
||||||
|
|
||||||
Overrides are stored in `config/font_overrides.json` and persist across restarts.
|
|
||||||
|
|
||||||
### Programmatic Overrides
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Set override
|
|
||||||
font_manager.set_override(
|
|
||||||
element_key="nfl.live.score",
|
|
||||||
family="four_by_six",
|
|
||||||
size_px=8
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove override
|
|
||||||
font_manager.remove_override("nfl.live.score")
|
|
||||||
|
|
||||||
# Get all overrides
|
|
||||||
overrides = font_manager.get_overrides()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Font Discovery
|
## Overrides
|
||||||
|
|
||||||
### Available Fonts
|
`resolve_font()` still honours `config/font_overrides.json` (a map of
|
||||||
|
element key to `family` and/or `size_px`), which is read once at start-up.
|
||||||
|
The methods that edit it — `set_override()`, `remove_override()`,
|
||||||
|
`get_overrides()` — are deprecated, and there is no web UI or REST endpoint
|
||||||
|
for overrides (the override editor and `/api/v3/fonts/overrides` were
|
||||||
|
removed). To let users choose a font, add a field to your plugin's config
|
||||||
|
schema.
|
||||||
|
|
||||||
The FontManager automatically scans `assets/fonts/` for TTF and BDF fonts:
|
## Font usage in the web UI
|
||||||
|
|
||||||
|
The web UI's **Fonts** tab lists, uploads, previews and deletes the font
|
||||||
|
files in `assets/fonts/`. The web interface runs in its own process and has
|
||||||
|
no FontManager, so the display service publishes which plugin uses which
|
||||||
|
font ([`src/font_usage.py`](../src/font_usage.py)), and the tab's **Used by**
|
||||||
|
column reads it:
|
||||||
|
|
||||||
|
- **Source**: `register_manager_font()` registrations of the loaded
|
||||||
|
plugins. `get_font()` and `resolve_font()` do not know the calling plugin
|
||||||
|
and are not counted, and neither is a plugin that opens a font file
|
||||||
|
directly with PIL — register the fonts your plugin draws with if you want
|
||||||
|
them listed.
|
||||||
|
- **Names**: a family, alias or path is resolved through `font_catalog` to
|
||||||
|
the file it loads and reported under that file's name without extension
|
||||||
|
(`PressStart2P-Regular`, `4x6-font`, `5x7`, `tom-thumb`), which is how the
|
||||||
|
Fonts tab keys its rows. Fonts outside `assets/fonts/` (a plugin's own
|
||||||
|
`plugin_id::family` fonts) and families that resolve to nothing are left
|
||||||
|
out.
|
||||||
|
- **When**: a daemon thread started once plugins have loaded checks every
|
||||||
|
10 seconds and writes the `font_usage_snapshot` cache key only when the
|
||||||
|
usage changed (and once a day, so the cache's cleanup never expires it).
|
||||||
|
Unloading a plugin drops its registrations (`forget_manager_fonts`).
|
||||||
|
- **Unknown**: until the display service has published, the column reads
|
||||||
|
"unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`.
|
||||||
|
- The tab warns before deleting a font that a loaded plugin registered.
|
||||||
|
|
||||||
|
## Text measurement
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Get all available fonts
|
|
||||||
fonts = font_manager.get_available_fonts()
|
|
||||||
# Returns: {'press_start': 'assets/fonts/PressStart2P-Regular.ttf', ...}
|
|
||||||
|
|
||||||
# Check if font exists
|
|
||||||
if "my_font" in fonts:
|
|
||||||
font = font_manager.get_font("my_font", 10)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Custom Fonts
|
|
||||||
|
|
||||||
Place font files in `assets/fonts/` directory:
|
|
||||||
- Supported formats: `.ttf`, `.bdf`
|
|
||||||
- Font family name is derived from filename (without extension)
|
|
||||||
- Will be automatically discovered on next initialization
|
|
||||||
|
|
||||||
## Performance Monitoring
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Get performance stats
|
|
||||||
stats = font_manager.get_performance_stats()
|
|
||||||
|
|
||||||
print(f"Cache hit rate: {stats['cache_hit_rate']*100:.1f}%")
|
|
||||||
print(f"Total fonts cached: {stats['total_fonts_cached']}")
|
|
||||||
print(f"Failed loads: {stats['failed_loads']}")
|
|
||||||
print(f"Manager fonts: {stats['manager_fonts']}")
|
|
||||||
print(f"Plugin fonts: {stats['plugin_fonts']}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Text Measurement
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Measure text dimensions
|
|
||||||
width, height, baseline = font_manager.measure_text("Hello", font)
|
width, height, baseline = font_manager.measure_text("Hello", font)
|
||||||
|
|
||||||
# Get font height
|
|
||||||
font_height = font_manager.get_font_height(font)
|
font_height = font_manager.get_font_height(font)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Best Practices
|
## Tips
|
||||||
|
|
||||||
### For Managers
|
- BDF fonts usually look better than TTF at small sizes on LED panels.
|
||||||
|
- Use `{plugin_id}.{element}` element keys.
|
||||||
1. **Register all fonts** you use for visibility
|
- Register the fonts you draw with, so the Fonts tab can warn before one is
|
||||||
2. **Use consistent element keys** (e.g., `{manager_id}.{element_type}`)
|
deleted.
|
||||||
3. **Cache font references** if using same font multiple times
|
- Replace direct `ImageFont.truetype("assets/fonts/...", 8)` calls with
|
||||||
4. **Use `resolve_font()`** not `get_font()` directly to support overrides
|
`resolve_font()`: it caches, resolves paths against the install directory,
|
||||||
5. **Define sensible defaults** that work well on LED matrix
|
and handles BDF files.
|
||||||
|
|
||||||
### For Plugins
|
|
||||||
|
|
||||||
1. **Use plugin-relative paths** (`plugin://fonts/...`)
|
|
||||||
2. **Include font metadata** (license, description)
|
|
||||||
3. **Provide fallback** fonts if custom fonts fail to load
|
|
||||||
4. **Test with different display sizes**
|
|
||||||
|
|
||||||
### General
|
|
||||||
|
|
||||||
1. **BDF fonts** are often better for small sizes on LED matrices
|
|
||||||
2. **TTF fonts** work well for larger sizes
|
|
||||||
3. **Monospace fonts** are easier to align
|
|
||||||
4. **Test on actual hardware** - what looks good on screen may not work on LED matrix
|
|
||||||
|
|
||||||
## Migration from Old System
|
|
||||||
|
|
||||||
### Old Way (Direct Font Loading)
|
|
||||||
```python
|
|
||||||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
|
||||||
```
|
|
||||||
|
|
||||||
### New Way (FontManager)
|
|
||||||
```python
|
|
||||||
element_key = f"{self.manager_id}.text"
|
|
||||||
self.font_manager.register_manager_font(
|
|
||||||
manager_id=self.manager_id,
|
|
||||||
element_key=element_key,
|
|
||||||
family="pressstart2p-regular",
|
|
||||||
size_px=8
|
|
||||||
)
|
|
||||||
self.font = self.font_manager.resolve_font(
|
|
||||||
element_key=element_key,
|
|
||||||
family="pressstart2p-regular",
|
|
||||||
size_px=8
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Font Not Found
|
**Font not found**
|
||||||
- Check font file exists in `assets/fonts/`
|
- Check the file exists in `assets/fonts/`.
|
||||||
- Verify font family name matches filename (without extension, lowercase)
|
- The family name is the filename without extension, lower-cased.
|
||||||
- Check logs for font discovery errors
|
- Check the display service log for font discovery errors.
|
||||||
|
|
||||||
### Override Not Working
|
**Plugin fonts not loading**
|
||||||
- Verify element key matches exactly what manager registered
|
- Check the manifest's `"fonts"` block.
|
||||||
- Check `config/font_overrides.json` for correct syntax
|
- Check the log for download or registration errors, and that font URLs are
|
||||||
- Restart application to ensure overrides are loaded
|
reachable.
|
||||||
|
|
||||||
### Performance Issues
|
## API reference
|
||||||
- Check cache hit rate in performance stats
|
|
||||||
- Reduce number of unique font/size combinations
|
|
||||||
- Clear cache if it grows too large: `font_manager.clear_cache()`
|
|
||||||
|
|
||||||
### Plugin Fonts Not Loading
|
Current methods:
|
||||||
- Verify plugin manifest syntax
|
|
||||||
- Check plugin directory structure
|
|
||||||
- Review logs for download/registration errors
|
|
||||||
- Ensure font URLs are accessible
|
|
||||||
|
|
||||||
## API Reference
|
| Method | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `register_manager_font(manager_id, element_key, family, size_px, color=None)` | Record a font choice (feeds the Fonts tab) |
|
||||||
|
| `forget_manager_fonts(manager_id)` | Drop a manager's registrations (core calls it when a plugin unloads) |
|
||||||
|
| `resolve_font(element_key, family, size_px, plugin_id=None)` | Get a font, applying overrides and plugin namespacing |
|
||||||
|
| `get_font(family, size_px)` | Get a font directly |
|
||||||
|
| `get_native_bdf_size(family)` | Native pixel size of a BDF family, or `None` |
|
||||||
|
| `measure_text(text, font)` | `(width, height, baseline)` |
|
||||||
|
| `get_font_height(font)` | Line height |
|
||||||
|
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
|
||||||
|
| `clear_cache()` | Drop cached fonts and metrics |
|
||||||
|
| `font_catalog` (attribute) | Family name → file path |
|
||||||
|
|
||||||
### FontManager Methods
|
### Deprecated methods
|
||||||
|
|
||||||
- `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage
|
Removed in 3.8.0. Each logs a warning on first call.
|
||||||
- `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support
|
|
||||||
- `get_font(family, size_px)` - Get font directly (bypasses overrides)
|
|
||||||
- `measure_text(text, font)` - Measure text dimensions
|
|
||||||
- `get_font_height(font)` - Get font height
|
|
||||||
- `set_override(element_key, family=None, size_px=None)` - Set manual override
|
|
||||||
- `remove_override(element_key)` - Remove override
|
|
||||||
- `get_overrides()` - Get all overrides
|
|
||||||
- `get_detected_fonts()` - Get all detected font usage
|
|
||||||
- `get_manager_fonts(manager_id=None)` - Get fonts by manager
|
|
||||||
- `get_available_fonts()` - Get font catalog
|
|
||||||
- `get_size_tokens()` - Get size token definitions
|
|
||||||
- `get_performance_stats()` - Get performance metrics
|
|
||||||
- `clear_cache()` - Clear font cache
|
|
||||||
- `register_plugin_fonts(plugin_id, font_manifest)` - Register plugin fonts
|
|
||||||
- `unregister_plugin_fonts(plugin_id)` - Unregister plugin fonts
|
|
||||||
|
|
||||||
## Example: Complete Manager Implementation
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
| Method | Use instead |
|
||||||
|
|---|---|
|
||||||
|
| `get_available_fonts()`, `get_font_catalog()` | read `font_catalog` |
|
||||||
|
| `get_size_tokens()` | pass a pixel size |
|
||||||
|
| `get_performance_stats()` | — |
|
||||||
|
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
||||||
|
| `get_manager_fonts()`, `get_detected_fonts()` | — |
|
||||||
|
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
|
||||||
|
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
|
||||||
|
|||||||
@@ -21,18 +21,30 @@ This guide will help you set up your LEDMatrix display for the first time and ge
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start (5 Minutes)
|
## Quick Start
|
||||||
|
|
||||||
### 1. First Boot
|
### 1. Install LEDMatrix
|
||||||
|
|
||||||
1. Insert the MicroSD card with LEDMatrix installed
|
There is no prebuilt SD card image — you install LEDMatrix onto stock
|
||||||
2. Connect the LED matrix to your Raspberry Pi
|
Raspberry Pi OS Lite yourself:
|
||||||
3. Plug in the power supply
|
|
||||||
4. Wait for the Pi to boot (about 60 seconds)
|
|
||||||
|
|
||||||
**Expected Behavior:**
|
1. Flash Raspberry Pi OS Lite to the MicroSD card (Raspberry Pi Imager)
|
||||||
|
2. Connect the LED matrix to your Raspberry Pi, insert the card, and
|
||||||
|
power on
|
||||||
|
3. SSH into the Pi and run the one-shot installer:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | bash
|
||||||
|
```
|
||||||
|
or clone the repo and run `sudo ./first_time_install.sh` — see the
|
||||||
|
[README Installation Steps / Quick Install](../README.md#installation-steps)
|
||||||
|
for full details
|
||||||
|
|
||||||
|
**Expected Behavior after install:**
|
||||||
- LED matrix will light up
|
- LED matrix will light up
|
||||||
- Display will show default plugins (clock, weather, etc.)
|
- A fresh install ships only the bundled `starlark-apps` and
|
||||||
|
`web-ui-info` plugins — clock, weather, sports, etc. must be
|
||||||
|
installed from the Plugin Store (web UI → Plugin Manager) before
|
||||||
|
anything else displays
|
||||||
- Pi creates WiFi network "LEDMatrix-Setup" if not connected
|
- Pi creates WiFi network "LEDMatrix-Setup" if not connected
|
||||||
|
|
||||||
### 2. Connect to WiFi
|
### 2. Connect to WiFi
|
||||||
@@ -71,10 +83,10 @@ You should see:
|
|||||||
|
|
||||||
1. Open the **Display** tab
|
1. Open the **Display** tab
|
||||||
2. Set your matrix configuration:
|
2. Set your matrix configuration:
|
||||||
- **Rows**: 32 or 64 (match your hardware)
|
- **Rows**: match your panel — commonly 32 or 64; any even number
|
||||||
- **Columns**: commonly 64 or 96; the web UI accepts any integer
|
from 8 to 64
|
||||||
in the 16–128 range, but 64 and 96 are the values the bundled
|
- **Columns**: match your panel — commonly 64 or 96; at least 16,
|
||||||
panel hardware ships with
|
with no upper limit
|
||||||
- **Chain Length**: Number of panels chained horizontally
|
- **Chain Length**: Number of panels chained horizontally
|
||||||
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
|
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
|
||||||
mod) or `adafruit-hat` (without). See the root README for the full list.
|
mod) or `adafruit-hat` (without). See the root README for the full list.
|
||||||
@@ -104,8 +116,8 @@ weather and other location-aware plugins.
|
|||||||
4. Wait for installation to finish — installed plugins appear in the
|
4. Wait for installation to finish — installed plugins appear in the
|
||||||
**Installed Plugins** section above and get their own tab in the second
|
**Installed Plugins** section above and get their own tab in the second
|
||||||
nav row
|
nav row
|
||||||
5. Toggle the plugin to enabled
|
5. Toggle the plugin to enabled. The running display loads it within a
|
||||||
6. From **Overview**, click **Restart Display Service**
|
few seconds; no restart is needed
|
||||||
|
|
||||||
You can also install community plugins straight from a GitHub URL using the
|
You can also install community plugins straight from a GitHub URL using the
|
||||||
**Install from GitHub** section further down the same tab — see
|
**Install from GitHub** section further down the same tab — see
|
||||||
@@ -115,10 +127,15 @@ You can also install community plugins straight from a GitHub URL using the
|
|||||||
|
|
||||||
1. Each installed plugin gets its own tab in the second navigation row
|
1. Each installed plugin gets its own tab in the second navigation row
|
||||||
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
|
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
|
||||||
update intervals, display duration, etc.)
|
update intervals, etc.)
|
||||||
3. Click **Save**
|
3. Click **Save**. The display service watches `config.json` and hands the
|
||||||
4. Restart the display service from **Overview** so the new settings take
|
new settings to the running plugin, so no restart is needed. If a plugin
|
||||||
effect
|
still shows old settings, restart the display service from **Overview**
|
||||||
|
|
||||||
|
**Note:** how long each plugin stays on screen is not set in the
|
||||||
|
plugin's own tab — use the **Rotation** tab's **Screen Durations**
|
||||||
|
section instead (saved to `display.display_durations` in
|
||||||
|
`config.json`).
|
||||||
|
|
||||||
**Example: Weather Plugin**
|
**Example: Weather Plugin**
|
||||||
- Set your location (city, state, country)
|
- Set your location (city, state, country)
|
||||||
@@ -180,14 +197,15 @@ The fastest way to verify a plugin works without waiting for the rotation:
|
|||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
1. Plugin is enabled (toggle on the **Plugin Manager** tab)
|
1. Plugin is enabled (toggle on the **Plugin Manager** tab)
|
||||||
2. Display service was restarted after enabling
|
2. Plugin's display duration is non-zero
|
||||||
3. Plugin's display duration is non-zero
|
3. No errors in the **Logs** tab for that plugin. A plugin whose
|
||||||
4. No errors in the **Logs** tab for that plugin
|
`validate_config()` fails is not loaded until its settings are fixed
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
1. Enable the plugin from **Plugin Manager**
|
1. Enable the plugin from **Plugin Manager**
|
||||||
2. Click **Restart Display Service** on **Overview**
|
2. Check the **Logs** tab for plugin-specific errors
|
||||||
3. Check the **Logs** tab for plugin-specific errors
|
3. If it still does not appear, click **Restart Display Service** on
|
||||||
|
**Overview**
|
||||||
|
|
||||||
### Weather Plugin Shows "No Data"
|
### Weather Plugin Shows "No Data"
|
||||||
|
|
||||||
@@ -208,18 +226,36 @@ The fastest way to verify a plugin works without waiting for the rotation:
|
|||||||
### Customize Your Display
|
### Customize Your Display
|
||||||
|
|
||||||
**Adjust display durations:**
|
**Adjust display durations:**
|
||||||
- Each plugin's tab has a **Display Duration (seconds)** field — set how
|
- Open the **Rotation** tab and use the **Screen Durations** section to
|
||||||
long that plugin stays on screen each rotation.
|
set how long each plugin stays on screen per rotation (saved to
|
||||||
|
`display.display_durations`).
|
||||||
|
|
||||||
**Organize plugin order:**
|
**Organize plugin order:**
|
||||||
- Use the **Plugin Manager** tab to enable/disable plugins. The display
|
- The **Rotation** tab also has a drag-and-drop **Rotation Order** list
|
||||||
cycles through enabled plugins in the order they appear.
|
(saved to `display.plugin_rotation_order`). Enable/disable plugins
|
||||||
|
from the **Plugin Manager** tab.
|
||||||
|
|
||||||
**Add more plugins:**
|
**Add more plugins:**
|
||||||
- Check the **Plugin Store** section of **Plugin Manager** for new plugins.
|
- Check the **Plugin Store** section of **Plugin Manager** for new plugins.
|
||||||
- Install community plugins straight from a GitHub URL via
|
- Install community plugins straight from a GitHub URL via
|
||||||
**Install from GitHub** on the same tab.
|
**Install from GitHub** on the same tab.
|
||||||
|
|
||||||
|
### Keep LEDMatrix Up to Date
|
||||||
|
|
||||||
|
- **Update Code** on the **Overview** tab installs the newest version, and a
|
||||||
|
banner at the top of the page says when one is available.
|
||||||
|
- **General → Automatic Updates** does it once a week, overnight, with a
|
||||||
|
health check that undoes an update that breaks the device.
|
||||||
|
- **General → Update Channel** picks which version that is. **Stable** (the
|
||||||
|
default) installs releases, which have been tested and have release
|
||||||
|
notes. **Beta** installs the newest code as soon as it is written, before
|
||||||
|
it is released: fixes arrive sooner, and so do new problems.
|
||||||
|
- Switching to Stable never installs an older version than the one you
|
||||||
|
have. If your device is already newer than the latest release (which is
|
||||||
|
normal if it was set up or updated from the newest code), it keeps
|
||||||
|
getting the newest code until the next release includes it, then follows
|
||||||
|
releases from there. The General tab says when this is the case.
|
||||||
|
|
||||||
### Enable Advanced Features
|
### Enable Advanced Features
|
||||||
|
|
||||||
**Vegas Scroll Mode:**
|
**Vegas Scroll Mode:**
|
||||||
@@ -280,10 +316,14 @@ sudo journalctl -u ledmatrix-web -f
|
|||||||
│ ├── config_secrets.json # API keys and secrets
|
│ ├── config_secrets.json # API keys and secrets
|
||||||
│ └── wifi_config.json # WiFi settings
|
│ └── wifi_config.json # WiFi settings
|
||||||
├── plugin-repos/ # Installed plugins (default location)
|
├── plugin-repos/ # Installed plugins (default location)
|
||||||
├── cache/ # Cached data
|
|
||||||
└── web_interface/ # Web interface files
|
└── web_interface/ # Web interface files
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Cached data does not live in the project directory — the cache manager
|
||||||
|
> uses the first writable location among `/var/cache/ledmatrix`,
|
||||||
|
> `~/.ledmatrix_cache`, `/opt/ledmatrix/cache`, and
|
||||||
|
> `$TMPDIR/ledmatrix_cache`.
|
||||||
|
>
|
||||||
> The plugin install location is configurable via
|
> The plugin install location is configurable via
|
||||||
> `plugin_system.plugins_directory` in `config.json`. The default is
|
> `plugin_system.plugins_directory` in `config.json`. The default is
|
||||||
> `plugin-repos/`. Plugin discovery (`PluginManager.discover_plugins()`)
|
> `plugin-repos/`. Plugin discovery (`PluginManager.discover_plugins()`)
|
||||||
@@ -303,11 +343,14 @@ System tabs:
|
|||||||
- WiFi Network selection and AP-mode setup
|
- WiFi Network selection and AP-mode setup
|
||||||
- Schedule Power and dim schedules
|
- Schedule Power and dim schedules
|
||||||
- Display Matrix hardware configuration
|
- Display Matrix hardware configuration
|
||||||
|
- Rotation Rotation order (drag-and-drop) and screen durations
|
||||||
- Config Editor Raw config.json editor
|
- Config Editor Raw config.json editor
|
||||||
|
- Backup & Restore Config backup and restore
|
||||||
- Fonts Upload and manage fonts
|
- Fonts Upload and manage fonts
|
||||||
- Logs Real-time log viewing
|
- Logs Real-time log viewing
|
||||||
- Cache Cached data inspection and cleanup
|
- Cache Cached data inspection and cleanup
|
||||||
- Operation History Recent service operations
|
- Operation History Recent service operations
|
||||||
|
- Tools System diagnostics, updates, dependencies, maintenance
|
||||||
|
|
||||||
Plugin tabs (second row):
|
Plugin tabs (second row):
|
||||||
- Plugin Manager Browse the Plugin Store, install/enable plugins
|
- Plugin Manager Browse the Plugin Store, install/enable plugins
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ Make sure you have the testing packages installed:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install all dependencies including test packages
|
# Install all dependencies including test packages
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
|
||||||
# Or install just the test dependencies
|
|
||||||
pip install pytest pytest-cov pytest-mock
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Set Environment Variables
|
### 2. Set Environment Variables
|
||||||
@@ -55,28 +52,26 @@ pytest test/test_display_controller.py test/test_plugin_system.py
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run a specific test class
|
# Run a specific test class
|
||||||
pytest test/test_display_controller.py::TestDisplayControllerModeRotation
|
pytest test/test_display_controller.py::TestDisplayControllerLivePriority
|
||||||
|
|
||||||
# Run a specific test function
|
# Run a specific test function
|
||||||
pytest test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
|
pytest test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run Tests by Marker
|
### Run Tests by Marker
|
||||||
|
|
||||||
The tests use markers to categorize them:
|
`pytest.ini` declares the markers `unit`, `integration`, `hardware`, `slow`
|
||||||
|
and `plugin` (with `--strict-markers`, so a typo in a marker name is an
|
||||||
|
error). Few tests are marked: only a handful carry `unit`, and none currently
|
||||||
|
carry `integration`, `slow` or `hardware`, so `-m integration` and `-m slow`
|
||||||
|
select nothing. Select tests by file, directory or `-k` instead.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run only unit tests (fast, isolated)
|
# What CI runs for the core suites (excludes anything marked hardware)
|
||||||
pytest -m unit
|
pytest -m "not hardware" test/ --ignore=test/plugins
|
||||||
|
|
||||||
# Run only integration tests
|
# Tests whose name matches an expression
|
||||||
pytest -m integration
|
pytest -k "config and not secrets"
|
||||||
|
|
||||||
# Run tests that don't require hardware
|
|
||||||
pytest -m "not hardware"
|
|
||||||
|
|
||||||
# Run slow tests
|
|
||||||
pytest -m slow
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run Tests in a Directory
|
### Run Tests in a Directory
|
||||||
@@ -103,7 +98,7 @@ When you run `pytest`, you'll see:
|
|||||||
|
|
||||||
```
|
```
|
||||||
test/test_display_controller.py::TestDisplayControllerInitialization::test_init_success PASSED
|
test/test_display_controller.py::TestDisplayControllerInitialization::test_init_success PASSED
|
||||||
test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation PASSED
|
test/test_display_controller.py::TestDisplayControllerOnDemand::test_activate_on_demand PASSED
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -141,58 +136,35 @@ pytest -sv
|
|||||||
|
|
||||||
## Coverage Reports
|
## Coverage Reports
|
||||||
|
|
||||||
The test suite is configured to generate coverage reports.
|
Coverage is not collected by a plain `pytest` run: `pytest.ini` deliberately
|
||||||
|
has no coverage flags, so local runs stay fast. Ask for it explicitly
|
||||||
### View Coverage in Terminal
|
(needs `pytest-cov`, which is in `requirements-test.txt`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Coverage is automatically shown when running pytest
|
# Terminal summary
|
||||||
pytest
|
pytest --cov=src --cov=web_interface --cov-report=term test/ --ignore=test/plugins
|
||||||
|
|
||||||
# The output will show something like:
|
# HTML report in htmlcov/
|
||||||
# ----------- coverage: platform linux, python 3.11.5 -----------
|
pytest --cov=src --cov=web_interface --cov-report=html test/ --ignore=test/plugins
|
||||||
# Name Stmts Miss Cover Missing
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# src/display_controller.py 450 120 73% 45-67, 89-102
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generate HTML Coverage Report
|
Then open `htmlcov/index.html` in your browser (`xdg-open` on Linux, `open`
|
||||||
|
on macOS, `start` on Windows).
|
||||||
```bash
|
|
||||||
# HTML report is automatically generated in htmlcov/
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Then open the report in your browser
|
|
||||||
# On Linux:
|
|
||||||
xdg-open htmlcov/index.html
|
|
||||||
|
|
||||||
# On macOS:
|
|
||||||
open htmlcov/index.html
|
|
||||||
|
|
||||||
# On Windows:
|
|
||||||
start htmlcov/index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
The HTML report shows:
|
|
||||||
- Line-by-line coverage
|
|
||||||
- Files with low coverage highlighted
|
|
||||||
- Interactive navigation
|
|
||||||
|
|
||||||
### Coverage Threshold
|
### Coverage Threshold
|
||||||
|
|
||||||
The tests are configured to fail if coverage drops below 30%. To change this, edit `pytest.ini`:
|
The only threshold is in CI: the core unit-test job in
|
||||||
|
[`.github/workflows/test.yml`](../.github/workflows/test.yml) runs with
|
||||||
```ini
|
`--cov-fail-under=52`. To check it locally, add that flag to the command
|
||||||
--cov-fail-under=30 # Change this value
|
above.
|
||||||
```
|
|
||||||
|
|
||||||
## Common Test Scenarios
|
## Common Test Scenarios
|
||||||
|
|
||||||
### Run Tests After Making Changes
|
### Run Tests After Making Changes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Quick test run (just unit tests)
|
# Quick run: just the tests for the area you changed
|
||||||
pytest -m unit
|
pytest test/test_config_manager.py
|
||||||
|
|
||||||
# Full test suite
|
# Full test suite
|
||||||
pytest
|
pytest
|
||||||
@@ -202,10 +174,10 @@ pytest
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run with maximum verbosity and show print statements
|
# Run with maximum verbosity and show print statements
|
||||||
pytest -vv -s test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
|
pytest -vv -s test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
|
||||||
|
|
||||||
# Run with Python debugger (pdb)
|
# Run with Python debugger (pdb)
|
||||||
pytest --pdb test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
|
pytest --pdb test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run Tests in Parallel (Faster)
|
### Run Tests in Parallel (Faster)
|
||||||
@@ -248,22 +220,15 @@ test/
|
|||||||
├── test_config_service.py # Config service tests
|
├── test_config_service.py # Config service tests
|
||||||
├── test_config_validation_edge_cases.py # Config edge cases
|
├── test_config_validation_edge_cases.py # Config edge cases
|
||||||
├── test_font_manager.py # Font manager tests
|
├── test_font_manager.py # Font manager tests
|
||||||
├── test_layout_manager.py # Layout manager tests
|
|
||||||
├── test_text_helper.py # Text helper tests
|
├── test_text_helper.py # Text helper tests
|
||||||
├── test_error_handling.py # Error handling tests
|
├── test_error_handling.py # Error handling tests
|
||||||
├── test_error_aggregator.py # Error aggregation tests
|
├── test_error_aggregator.py # Error aggregation tests
|
||||||
├── test_schema_manager.py # Schema manager tests
|
├── test_schema_manager.py # Schema manager tests
|
||||||
├── test_web_api.py # Web API tests
|
├── test_web_api.py # Web API tests
|
||||||
├── test_nba_*.py # NBA-specific test suites
|
├── plugins/ # Plugin rendering suites
|
||||||
├── plugins/ # Per-plugin test suites
|
│ ├── test_plugin_matrix.py # Every discovered plugin, across panel sizes
|
||||||
│ ├── test_clock_simple.py
|
│ ├── test_harness.py
|
||||||
│ ├── test_calendar.py
|
│ └── test_visual_rendering.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/
|
└── web_interface/
|
||||||
├── test_config_manager_atomic.py
|
├── test_config_manager_atomic.py
|
||||||
├── test_state_reconciliation.py
|
├── test_state_reconciliation.py
|
||||||
@@ -288,8 +253,8 @@ test/
|
|||||||
If you see import errors:
|
If you see import errors:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Make sure you're in the project root
|
# Make sure you're in the project root (wherever you cloned it)
|
||||||
cd /home/chuck/Github/LEDMatrix
|
cd ~/LEDMatrix
|
||||||
|
|
||||||
# Check Python path
|
# Check Python path
|
||||||
python -c "import sys; print(sys.path)"
|
python -c "import sys; print(sys.path)"
|
||||||
@@ -304,7 +269,7 @@ If tests fail due to missing packages:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install all dependencies
|
# Install all dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
|
||||||
# Or install specific missing package
|
# Or install specific missing package
|
||||||
pip install <package-name>
|
pip install <package-name>
|
||||||
@@ -330,37 +295,37 @@ If coverage reports aren't generating:
|
|||||||
# Make sure pytest-cov is installed
|
# Make sure pytest-cov is installed
|
||||||
pip install pytest-cov
|
pip install pytest-cov
|
||||||
|
|
||||||
# Run with explicit coverage
|
# Coverage is opt-in; ask for it explicitly
|
||||||
pytest --cov=src --cov-report=html
|
pytest --cov=src --cov=web_interface --cov-report=html
|
||||||
```
|
```
|
||||||
|
|
||||||
## Continuous Integration
|
## Continuous Integration
|
||||||
|
|
||||||
The repo runs
|
The repo runs the pytest suite via
|
||||||
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
|
[`.github/workflows/test.yml`](../.github/workflows/test.yml) on every
|
||||||
(bandit + semgrep) on every push. A pytest CI workflow at
|
push and pull request: a plugin-safety job that runs `test/plugins/`, and a
|
||||||
`.github/workflows/tests.yml` is queued to land alongside this
|
core unit-test job that runs the whole `test/` tree except `test/plugins/`
|
||||||
PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
|
with `-m "not hardware"` and enforces coverage (`--cov-fail-under=52`). New
|
||||||
the workflow file itself was held back from that PR because the
|
test files are picked up automatically. Release version consistency is checked by
|
||||||
push token lacked the GitHub `workflow` scope, so it needs to be
|
[`.github/workflows/release-version-check.yml`](../.github/workflows/release-version-check.yml).
|
||||||
committed separately by a maintainer. Once it's in, this section
|
Bandit, flake8, mypy and gitleaks run as pre-commit hooks (see
|
||||||
will be updated to describe what the job runs.
|
`.pre-commit-config.yaml`), not in CI.
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Run tests before committing**:
|
1. **Run tests before committing**:
|
||||||
```bash
|
```bash
|
||||||
pytest -m unit # Quick check
|
pytest test/test_<area>.py # Quick check of what you touched
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Run full suite before pushing**:
|
2. **Run full suite before pushing**:
|
||||||
```bash
|
```bash
|
||||||
pytest # Full test suite with coverage
|
pytest # Full test suite (add --cov flags for coverage)
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Fix failing tests immediately** - Don't let them accumulate
|
3. **Fix failing tests immediately** - Don't let them accumulate
|
||||||
|
|
||||||
4. **Keep coverage above threshold** - Aim for 70%+ coverage
|
4. **Keep coverage above threshold** - CI fails below 52%
|
||||||
|
|
||||||
5. **Write tests for new features** - Add tests when adding new functionality
|
5. **Write tests for new features** - Add tests when adding new functionality
|
||||||
|
|
||||||
@@ -368,9 +333,9 @@ will be updated to describe what the job runs.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Most common commands
|
# Most common commands
|
||||||
pytest # Run all tests with coverage
|
pytest # Run all tests (no coverage)
|
||||||
pytest -v # Verbose output
|
pytest -v # Verbose output
|
||||||
pytest -m unit # Run only unit tests
|
pytest test/test_x.py # Run one file
|
||||||
pytest -k "test_name" # Run tests matching pattern
|
pytest -k "test_name" # Run tests matching pattern
|
||||||
pytest --cov=src # Generate coverage report
|
pytest --cov=src # Generate coverage report
|
||||||
pytest -x # Stop on first failure
|
pytest -x # Stop on first failure
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Running on Low-Memory Boards
|
||||||
|
|
||||||
|
Applies to the Pi Zero 2 W (512 MB), Pi 3 / 3B+ (1 GB), and the 1 GB Pi 4.
|
||||||
|
If your board has 2 GB or more you can skip this document.
|
||||||
|
|
||||||
|
## The failure this prevents
|
||||||
|
|
||||||
|
The display process is the largest thing on the board. On a 1 GB Pi 3B+ with
|
||||||
|
around 20 plugins enabled it settles near **600 MB of 905 MB usable**, leaving
|
||||||
|
under 200 MB of headroom for everything else.
|
||||||
|
|
||||||
|
When that headroom runs out, the board does not crash cleanly. `fork()` starts
|
||||||
|
failing, and because a new process is needed to do almost anything, the
|
||||||
|
symptoms look nothing like "out of memory":
|
||||||
|
|
||||||
|
| What you see | Why |
|
||||||
|
|---|---|
|
||||||
|
| SSH accepts the connection then closes it instantly, before any banner | `sshd` forks a session per connection; the fork fails |
|
||||||
|
| The web UI still responds quickly | Already running, serves from existing threads, forks nothing |
|
||||||
|
| Ping is perfect, 0% loss | Handled entirely in the kernel |
|
||||||
|
| The panel is dark | The display process was killed and cannot be respawned |
|
||||||
|
| The clock is wrong after the next boot | `fake-hwclock`'s periodic save is a scheduled job, and it cannot fork either |
|
||||||
|
|
||||||
|
The board looks healthy from the outside and cannot be logged into. Only a
|
||||||
|
power cycle clears it. If you are here because SSH stopped working, also see
|
||||||
|
[SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md), which
|
||||||
|
covers the more common cause (AP mode).
|
||||||
|
|
||||||
|
## Check your headroom
|
||||||
|
|
||||||
|
```bash
|
||||||
|
free -m
|
||||||
|
ps -eo rss,comm --sort=-rss | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
If `MemAvailable` is under ~150 MB while the display is running, you are close
|
||||||
|
to the edge. To watch it over time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
watch -n 30 'free -m | head -2'
|
||||||
|
```
|
||||||
|
|
||||||
|
Available memory that falls steadily rather than holding flat means you will
|
||||||
|
reach the wall; it is a question of when.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
**1. Enable the memory cgroup controller.** Without it, the `MemoryMax=85%` in
|
||||||
|
`systemd/ledmatrix.service` is accepted by systemd and silently ignored, so the
|
||||||
|
service has no ceiling and a runaway takes the whole board down instead of just
|
||||||
|
restarting. Raspberry Pi firmware disables this controller by default.
|
||||||
|
|
||||||
|
`first_time_install.sh` does this for you. To check it took effect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep memory /sys/fs/cgroup/cgroup.controllers
|
||||||
|
```
|
||||||
|
|
||||||
|
If that prints nothing, add `cgroup_enable=memory cgroup_memory=1` to the
|
||||||
|
kernel command line and reboot. Edit whichever file your image uses —
|
||||||
|
`/boot/firmware/cmdline.txt` on current Raspberry Pi OS, `/boot/cmdline.txt` on
|
||||||
|
older layouts (the installer checks the first and falls back to the second).
|
||||||
|
Everything must stay on a single line.
|
||||||
|
|
||||||
|
This changes the failure mode from "the board becomes unreachable" to "the
|
||||||
|
display service restarts". It is a safety net, not a fix.
|
||||||
|
|
||||||
|
**2. Run fewer plugins.** This is the actual remedy. Every enabled plugin costs
|
||||||
|
memory permanently — its module, its parsed config, and its cached API
|
||||||
|
responses. On a 512 MB or 1 GB board, keep the enabled set small and prefer
|
||||||
|
plugins that poll infrequently.
|
||||||
|
|
||||||
|
**3. Lower the cache ceiling.** The in-memory cache is sized from total RAM
|
||||||
|
(150 entries at 1 GB and below, up to 1500 at 8 GB). To go lower still:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/ledmatrix.service.d/override.conf
|
||||||
|
[Service]
|
||||||
|
Environment=LEDMATRIX_CACHE_MAX_ENTRIES=75
|
||||||
|
```
|
||||||
|
|
||||||
|
Writing the file does not change the running service. Reload systemd and
|
||||||
|
restart it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart ledmatrix
|
||||||
|
```
|
||||||
|
|
||||||
|
Fewer entries means more API calls, so lower this only while you are actually
|
||||||
|
short of memory.
|
||||||
|
|
||||||
|
**4. Consider `MemoryHigh`.** `MemoryMax` kills and restarts. `MemoryHigh`
|
||||||
|
throttles and reclaims instead, which is gentler — but on a board where the
|
||||||
|
process genuinely wants more than the limit, sustained reclaim can stall the
|
||||||
|
render loop and show as visible stutter on the panel. Add it only if you prefer
|
||||||
|
degraded output to a restart:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
MemoryHigh=70%
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keep your logs
|
||||||
|
|
||||||
|
These images default to volatile journald storage, so every reboot destroys the
|
||||||
|
logs — including the ones explaining why the board rebooted. `first_time_install.sh`
|
||||||
|
enables persistent storage capped at 64 MB. To confirm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl --list-boots
|
||||||
|
```
|
||||||
|
|
||||||
|
More than one boot listed means logs are surviving reboots. If only one is
|
||||||
|
listed, journald is still writing to `/run` (tmpfs).
|
||||||
@@ -19,7 +19,6 @@ All installation scripts have been moved from the project root to `scripts/insta
|
|||||||
| `install_wifi_monitor.sh` | `scripts/install/install_wifi_monitor.sh` |
|
| `install_wifi_monitor.sh` | `scripts/install/install_wifi_monitor.sh` |
|
||||||
| `setup_cache.sh` | `scripts/install/setup_cache.sh` |
|
| `setup_cache.sh` | `scripts/install/setup_cache.sh` |
|
||||||
| `configure_web_sudo.sh` | `scripts/install/configure_web_sudo.sh` |
|
| `configure_web_sudo.sh` | `scripts/install/configure_web_sudo.sh` |
|
||||||
| `migrate_config.sh` | `scripts/install/migrate_config.sh` |
|
|
||||||
|
|
||||||
#### Permission Fix Scripts
|
#### Permission Fix Scripts
|
||||||
|
|
||||||
@@ -59,9 +58,12 @@ sudo ./scripts/install/install_service.sh
|
|||||||
After updating your scripts, verify they still work:
|
After updating your scripts, verify they still work:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test installation scripts (if needed)
|
# Check the installation scripts are at their new paths
|
||||||
ls scripts/install/*.sh
|
ls scripts/install/*.sh
|
||||||
sudo ./scripts/install/install_service.sh --help
|
./scripts/install/install_service.sh --help # prints usage only
|
||||||
|
# Note: running install_service.sh for real (with sudo, no --help)
|
||||||
|
# reinstalls, enables and restarts ledmatrix.service, ledmatrix-web.service
|
||||||
|
# and the update-verify units.
|
||||||
|
|
||||||
# Test permission scripts
|
# Test permission scripts
|
||||||
ls scripts/fix_perms/*.sh
|
ls scripts/fix_perms/*.sh
|
||||||
@@ -86,7 +88,7 @@ The plugin system has been enhanced but remains backward compatible with existin
|
|||||||
|
|
||||||
If you encounter issues during migration:
|
If you encounter issues during migration:
|
||||||
|
|
||||||
1. Check the [README.md](README.md) for current installation and usage instructions
|
1. Check the [project root README](../README.md) for current installation and usage instructions
|
||||||
2. Review script README files:
|
2. Review script README files:
|
||||||
- [`scripts/install/README.md`](../scripts/install/README.md) - Installation scripts documentation
|
- [`scripts/install/README.md`](../scripts/install/README.md) - Installation scripts documentation
|
||||||
- [`scripts/fix_perms/README.md`](../scripts/fix_perms/README.md) - Permission scripts documentation
|
- [`scripts/fix_perms/README.md`](../scripts/fix_perms/README.md) - Permission scripts documentation
|
||||||
|
|||||||
@@ -1,169 +1,149 @@
|
|||||||
# Multi-Root Workspace Setup Guide
|
# Multi-Root Workspace Setup Guide
|
||||||
|
|
||||||
This document explains how the LEDMatrix project uses a multi-root workspace to manage plugins as separate Git repositories.
|
This document explains how to work on LEDMatrix and the official plugins side
|
||||||
|
by side, with one editor workspace and the plugins loaded straight from your
|
||||||
|
plugin checkout.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The LEDMatrix project has been migrated from a git submodule implementation to a **multi-root workspace** implementation for managing plugins. This allows:
|
Official plugins live in a single repository,
|
||||||
|
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins), with one
|
||||||
|
directory per plugin under `plugins/`. There are no separate per-plugin
|
||||||
|
repositories. For development you clone that monorepo **next to** LEDMatrix
|
||||||
|
and symlink the plugin directories you are working on into LEDMatrix's
|
||||||
|
`plugins/` directory with `scripts/dev/dev_plugin_setup.sh`.
|
||||||
|
|
||||||
- ✅ Plugins to exist as independent Git repositories
|
- ✅ Plugin code stays in the monorepo checkout, with its own git history
|
||||||
- ✅ Updates to plugins without modifying the LEDMatrix project
|
- ✅ LEDMatrix discovers the plugins through symlinks in `plugins/`
|
||||||
- ✅ Easy development workflow with all repos in one workspace
|
(git-ignored), so the production `plugin-repos/` directory is untouched
|
||||||
- ✅ Plugin system discovers plugins via symlinks in `plugin-repos/`
|
- ✅ `LEDMatrix.code-workspace` opens both repositories in VS Code/Cursor
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/home/chuck/Github/
|
~/Github/
|
||||||
├── LEDMatrix/ # Main project
|
├── LEDMatrix/ # Main project
|
||||||
│ ├── plugin-repos/ # Symlinks to actual repos (managed automatically)
|
│ ├── plugins/ # Dev plugin directory (git-ignored)
|
||||||
│ │ ├── ledmatrix-clock-simple -> ../../ledmatrix-clock-simple
|
│ │ ├── clock-simple -> ~/Github/ledmatrix-plugins/plugins/clock-simple
|
||||||
│ │ ├── ledmatrix-weather -> ../../ledmatrix-weather
|
│ │ ├── ledmatrix-weather -> ~/Github/ledmatrix-plugins/plugins/ledmatrix-weather
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ ├── LEDMatrix.code-workspace # Multi-root workspace configuration
|
│ ├── plugin-repos/ # Default (Plugin Store) plugin directory
|
||||||
|
│ ├── LEDMatrix.code-workspace # Opens LEDMatrix and ../ledmatrix-plugins
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── ledmatrix-clock-simple/ # Plugin repository (actual git repo)
|
└── ledmatrix-plugins/ # Plugin monorepo (git repo)
|
||||||
├── ledmatrix-weather/ # Plugin repository (actual git repo)
|
├── plugins/
|
||||||
├── ledmatrix-football-scoreboard/ # Plugin repository (actual git repo)
|
│ ├── clock-simple/
|
||||||
└── ... # Other plugin repos
|
│ ├── ledmatrix-weather/
|
||||||
|
│ └── ...
|
||||||
|
├── plugins.json # Store registry
|
||||||
|
└── update_registry.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
### 1. Plugin Repositories
|
### 1. The plugin monorepo
|
||||||
|
|
||||||
All plugin repositories are cloned to `/home/chuck/Github/` (parent directory of LEDMatrix) as regular Git repositories:
|
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
|
||||||
|
workspace file and `scripts/update_plugin_repos.py` look for
|
||||||
- `ledmatrix-clock-simple/`
|
`../ledmatrix-plugins` relative to the LEDMatrix root):
|
||||||
- `ledmatrix-weather/`
|
|
||||||
- `ledmatrix-football-scoreboard/`
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
### 2. Symlinks in plugin-repos/
|
|
||||||
|
|
||||||
The `LEDMatrix/plugin-repos/` directory contains symlinks pointing to the actual repositories in the parent directory. This allows the plugin system to discover plugins without modifying the project structure.
|
|
||||||
|
|
||||||
### 3. Multi-Root Workspace
|
|
||||||
|
|
||||||
The `LEDMatrix.code-workspace` file configures VS Code/Cursor to open all plugin repositories as separate workspace roots, allowing easy development across all repos.
|
|
||||||
|
|
||||||
## Setup Scripts
|
|
||||||
|
|
||||||
### Initial Setup
|
|
||||||
|
|
||||||
If you already have plugin repositories cloned, use the setup script:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/chuck/Github/LEDMatrix
|
cd ~/Github
|
||||||
python3 scripts/setup_plugin_repos.py
|
git clone https://github.com/ChuckBuilds/ledmatrix-plugins.git
|
||||||
```
|
```
|
||||||
|
|
||||||
This script:
|
### 2. Symlinks in plugins/
|
||||||
- Reads the workspace configuration
|
|
||||||
- Creates symlinks in `plugin-repos/` pointing to actual repos
|
`scripts/dev/dev_plugin_setup.sh link <name> <path>` creates
|
||||||
- Verifies all links are created correctly
|
`LEDMatrix/plugins/<name>` as a symlink to a plugin directory. Use the
|
||||||
|
plugin's manifest `id` as the name: that is the name the loader and
|
||||||
|
`config.json` use, and the script warns when the two differ.
|
||||||
|
|
||||||
|
### 3. Multi-root workspace
|
||||||
|
|
||||||
|
`LEDMatrix.code-workspace` has two roots: LEDMatrix itself and
|
||||||
|
`../ledmatrix-plugins`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Link plugins
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Github/LEDMatrix
|
||||||
|
./scripts/dev/dev_plugin_setup.sh link clock-simple ../ledmatrix-plugins/plugins/clock-simple
|
||||||
|
./scripts/dev/dev_plugin_setup.sh list # show what is linked
|
||||||
|
```
|
||||||
|
|
||||||
|
If a real (non-symlink) directory of the same name already exists in
|
||||||
|
`plugins/`, the script offers to back it up and replace it.
|
||||||
|
|
||||||
|
Without a sibling checkout, `./scripts/dev/dev_plugin_setup.sh link-github
|
||||||
|
<name>` clones the monorepo into `~/.ledmatrix-dev-plugins/` instead and links
|
||||||
|
the plugin from there. See the
|
||||||
|
[Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md).
|
||||||
|
|
||||||
### Updating Plugins
|
### Updating Plugins
|
||||||
|
|
||||||
To update all plugin repositories:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/chuck/Github/LEDMatrix
|
cd ~/Github/LEDMatrix
|
||||||
python3 scripts/update_plugin_repos.py
|
python3 scripts/update_plugin_repos.py # git pull in ../ledmatrix-plugins
|
||||||
|
# or
|
||||||
|
./scripts/dev/dev_plugin_setup.sh update # git pull in every linked checkout
|
||||||
```
|
```
|
||||||
|
|
||||||
This script:
|
The symlinks pick up the new code; restart the display to load it.
|
||||||
- Finds all plugins in the workspace
|
|
||||||
- Runs `git pull` on each repository
|
|
||||||
- Reports which plugins were updated
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The plugin system is configured in `config/config.json`:
|
The loader scans only `plugin_system.plugins_directory` in
|
||||||
|
`config/config.json` (default `plugin-repos`). Point it at `plugins` so it
|
||||||
|
finds the links:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"plugin_system": {
|
"plugin_system": {
|
||||||
"plugins_directory": "plugin-repos",
|
"plugins_directory": "plugins"
|
||||||
"auto_discover": true,
|
|
||||||
"auto_load_enabled": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `plugins_directory` points to `plugin-repos/`, which contains symlinks to the actual repositories.
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### Daily Development
|
### Daily Development
|
||||||
|
|
||||||
1. **Open Workspace**: Open `LEDMatrix.code-workspace` in VS Code/Cursor
|
1. **Open Workspace**: Open `LEDMatrix.code-workspace` in VS Code/Cursor
|
||||||
2. **All Repos Available**: All plugin repos appear as separate folders in the workspace
|
2. **Edit Plugins**: Edit code under `ledmatrix-plugins/plugins/<plugin>/`
|
||||||
3. **Edit Plugins**: Edit plugin code directly in their repositories
|
3. **Test**: `python3 run.py -e` (emulator) or
|
||||||
4. **Update Plugins**: Run `update_plugin_repos.py` to pull latest changes
|
`python3 scripts/check_plugin.py --plugin <id>` from LEDMatrix
|
||||||
|
4. **Ship**: Bump `version` in the plugin's `manifest.json`, run
|
||||||
|
`python update_registry.py` in ledmatrix-plugins, commit there
|
||||||
|
|
||||||
### Adding New Plugins
|
### Adding New Plugins
|
||||||
|
|
||||||
1. **Clone Repository**: Clone the new plugin repo to `/home/chuck/Github/`
|
1. Create `plugins/<your-plugin-id>/` in the monorepo checkout
|
||||||
2. **Add to Workspace**: Add the plugin folder to `LEDMatrix.code-workspace`
|
2. Link it: `./scripts/dev/dev_plugin_setup.sh link <your-plugin-id> ../ledmatrix-plugins/plugins/<your-plugin-id>`
|
||||||
3. **Create Symlink**: Run `setup_plugin_repos.py` to create the symlink
|
|
||||||
|
|
||||||
### Updating Individual Plugins
|
|
||||||
|
|
||||||
Since plugins are regular Git repositories, you can update them individually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/chuck/Github/ledmatrix-weather
|
|
||||||
git pull origin master
|
|
||||||
```
|
|
||||||
|
|
||||||
Or update all at once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/chuck/Github/LEDMatrix
|
|
||||||
python3 scripts/update_plugin_repos.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **No Submodule Hassle**: No need to update `.gitmodules` or run `git submodule update`
|
|
||||||
2. **Independent Updates**: Update plugins independently without touching LEDMatrix
|
|
||||||
3. **Clean Separation**: Each plugin is a separate repository with its own history
|
|
||||||
4. **Easy Development**: Multi-root workspace makes it easy to work across repos
|
|
||||||
5. **Automatic Discovery**: Plugin system automatically discovers plugins via symlinks
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Symlinks Not Working
|
### Plugins not discovered
|
||||||
|
|
||||||
If plugins aren't being discovered:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/chuck/Github/LEDMatrix
|
cd ~/Github/LEDMatrix
|
||||||
python3 scripts/setup_plugin_repos.py
|
ls -la plugins/ # links present and not broken?
|
||||||
|
./scripts/dev/dev_plugin_setup.sh status # link targets and git state
|
||||||
```
|
```
|
||||||
|
|
||||||
This will recreate all symlinks.
|
Also check that `plugin_system.plugins_directory` is `plugins`.
|
||||||
|
|
||||||
### Missing Plugins
|
### Plugin updates not showing
|
||||||
|
|
||||||
If a plugin is in the workspace but not found:
|
1. Verify the link target: `ls -la plugins/<id>`
|
||||||
|
2. Check that you're editing the monorepo checkout, not a store-installed copy
|
||||||
1. Check if the repo exists in `/home/chuck/Github/`
|
3. Restart the LEDMatrix service (or `run.py`)
|
||||||
2. Check if the symlink exists in `plugin-repos/`
|
|
||||||
3. Run `setup_plugin_repos.py` to recreate symlinks
|
|
||||||
|
|
||||||
### Plugin Updates Not Showing
|
|
||||||
|
|
||||||
If changes to plugins aren't appearing:
|
|
||||||
|
|
||||||
1. Verify the symlink points to the correct directory: `ls -la plugin-repos/ledmatrix-weather`
|
|
||||||
2. Check that you're editing in the actual repo, not a copy
|
|
||||||
3. Restart the LEDMatrix service if running
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The `plugin-repos/` directory is tracked in git, but only contains symlinks
|
- `plugins/` is git-ignored (except `plugins/.gitkeep`); the symlinks are
|
||||||
- Actual plugin code lives in `/home/chuck/Github/ledmatrix-*/`
|
never committed.
|
||||||
- Each plugin repo can be updated independently via `git pull`
|
- When changing a plugin in the monorepo, bump its manifest `version` and run
|
||||||
- The LEDMatrix project doesn't need to be updated when plugins change
|
`python update_registry.py`, or users won't receive the update.
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# Offscreen Rendering
|
||||||
|
|
||||||
|
**Status (2026-09-24):** step 1, offscreen rendering, is implemented
|
||||||
|
(`DisplayManager.offscreen()`, the adapter on the prefetch thread, the plugin
|
||||||
|
lock). Steps 2 and 3 are proposed. When all three land, this file becomes the
|
||||||
|
reference for how plugin content is rendered off the render thread.
|
||||||
|
|
||||||
|
First soak of step 1 on hdpi (50 px/s, `pwm_bits` 7, preview open, 8-minute
|
||||||
|
runs, A/B/B/A):
|
||||||
|
|
||||||
|
| build | late | by 1 | 2 | 3–5 | 6+ | freezes | render-thread fetches |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| #628 | 0.53% | 82 | 2 | 3 | 2 | 3 | 6 |
|
||||||
|
| step 1 | 0.63% | 78 | 63 | 17 | 2 | 1 | 0 |
|
||||||
|
| step 1 | 0.42% | 77 | 23 | 10 | 0 | 0 | 0 |
|
||||||
|
| #628 | 0.37% | 84 | 5 | 3 | 3 | 2 | 14 |
|
||||||
|
|
||||||
|
It does what it was built to: no plugin is fetched on the render thread, and
|
||||||
|
freezes fell from 5 to 1. But frames 2–5 refreshes late rose. The rendering
|
||||||
|
moved to the prefetch thread still needs the GIL, and the render thread waits
|
||||||
|
for it (risk 5 below). The late rate did not improve overall. The 1–2 s
|
||||||
|
freezes appear in both builds and have a separate, not yet identified cause.
|
||||||
|
|
||||||
|
The GIL fix, measured on hdpi (90 px/s, `pwm_bits` 8, preview open, 8-minute
|
||||||
|
runs after a 2-minute warm-up, order A B C C B A, 2026-09-24). Each arm pools
|
||||||
|
two runs, about 81,000 frames:
|
||||||
|
|
||||||
|
| arm | late | by 1 | 2 | 3–5 | 6+ | 2+ late per 10k frames | freezes |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| A: step 1 as is | 0.90% | 575 | 64 | 91 | 9 | 20.1 | 0 |
|
||||||
|
| B: `switch_interval_ms` 1 | 0.78% | 510 | 105 | 23 | 2 | 15.8 | 0 |
|
||||||
|
| C: `prefetch_gate` | **0.60%** | 471 | 11 | 7 | 2 | **2.5** | 0 |
|
||||||
|
|
||||||
|
The gate removes the frames the render thread spent waiting for the GIL, and
|
||||||
|
it costs the prefetch nothing that shows: it parked the thread for 3–6 s per
|
||||||
|
run, and the next group was ready at every strip extension in every arm.
|
||||||
|
`prefetch_gate` is therefore on by default; `switch_interval_ms` stays an
|
||||||
|
off-by-default experiment. What is left is almost all one refresh late, which
|
||||||
|
is the per-frame budget (a 6.75 ms p50 blit in a refresh the panel holds at
|
||||||
|
83–85 Hz while rendering), not contention.
|
||||||
|
|
||||||
|
The runs restart the service, so the hourly sports refresh never fell inside
|
||||||
|
one. That refresh is its own case: about twenty ESPN chunk-fetch threads at
|
||||||
|
once, which the gate does not cover (it gates only the prefetch thread).
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
Vegas mode builds its ticker from every plugin's content. Most of that work
|
||||||
|
already happens on a background prefetch thread
|
||||||
|
(`RenderPipeline.start_prefetch`). But any plugin whose content needs the
|
||||||
|
**shared display canvas** is deferred to the render thread
|
||||||
|
(`RenderPipeline.drain_deferred`), one plugin every two seconds. The code's
|
||||||
|
own comments put each of those at 40–600 ms, and the render thread presents no
|
||||||
|
frames while one runs.
|
||||||
|
|
||||||
|
On hdpi (Pi 4, 512×64) most plugins take that path: geochron, tide-display,
|
||||||
|
news, hockey-scoreboard, ledmatrix-stocks, incoming-packages, clock-simple,
|
||||||
|
countdown, birdnet-go, ledmatrix-music and odds-ticker. They arrive in bursts
|
||||||
|
("Whole group deferred; strip will extend as it drains") every minute or so,
|
||||||
|
12 fetches in five minutes. That is the "occasional pause" a viewer sees.
|
||||||
|
|
||||||
|
An 8-minute soak (`scripts/frame_soak.py --preview`) of the #628 build on
|
||||||
|
hdpi:
|
||||||
|
|
||||||
|
| late by | frames |
|
||||||
|
|---|---|
|
||||||
|
| 1 refresh | 238 |
|
||||||
|
| 2 | 32 |
|
||||||
|
| 3–5 | 30 |
|
||||||
|
| 6+ | 5 |
|
||||||
|
| freezes ≥ 250 ms | 2 (0.97 s total) |
|
||||||
|
|
||||||
|
The 3+ rows and the freezes are the pauses. The single-refresh row is a
|
||||||
|
separate problem: the blit is 6 ms of a 10 ms refresh, so there is little
|
||||||
|
slack. It is covered under *What this does not fix*.
|
||||||
|
|
||||||
|
## Why a plugin is canvas-bound
|
||||||
|
|
||||||
|
The plugin-facing canvas is a set of shared attributes on `DisplayManager`:
|
||||||
|
`image`, `draw`, `matrix`, and the `width`/`height` properties that read from
|
||||||
|
`matrix`. Three adapter paths (`src/vegas_mode/plugin_adapter.py`) need them,
|
||||||
|
and each returns `None` under `offscreen_only=True` so the plugin is queued for
|
||||||
|
the render thread:
|
||||||
|
|
||||||
|
1. **Display capture** (`_capture_display_content`): clear the canvas, call
|
||||||
|
`plugin.display()`, copy `display_manager.image`. Used by any plugin
|
||||||
|
without `get_vegas_content()` or a populated `scroll_helper`.
|
||||||
|
2. **Scroll-content generation** (`_trigger_scroll_content_generation`): a
|
||||||
|
ticker plugin whose `scroll_helper.cached_image` is empty is made to build
|
||||||
|
it by calling `display(force_clear=True)` or `_create_scrolling_display()`.
|
||||||
|
Both draw on the canvas.
|
||||||
|
3. **Narrowed rendering** (`DisplayManager.render_size`): swaps the shared
|
||||||
|
`matrix`, `image` and `draw` for a narrower set so the plugin lays out for
|
||||||
|
`render_width_pct`. The render thread would see the swap mid-frame.
|
||||||
|
|
||||||
|
The render thread keeps the canvas coherent only because nothing else touches
|
||||||
|
it at the same time. A background thread can't use it.
|
||||||
|
|
||||||
|
## The design: a per-thread render target
|
||||||
|
|
||||||
|
`capture_mode()` is already per-thread (#423 made its state a
|
||||||
|
`threading.local`, so a background capture no longer suppresses the render
|
||||||
|
loop's pushes). The same move applies to the canvas itself:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with display_manager.offscreen(width=None, height=None) as surface:
|
||||||
|
plugin.display(force_clear=True)
|
||||||
|
content = surface.image.copy()
|
||||||
|
```
|
||||||
|
|
||||||
|
For the **calling thread only**, inside the block:
|
||||||
|
|
||||||
|
| accessor | resolves to |
|
||||||
|
|---|---|
|
||||||
|
| `display_manager.image`, `.draw` | the surface's own image and draw: a fresh black canvas, `fontmode = "1"` |
|
||||||
|
| `display_manager.matrix` | a logical proxy reporting the surface size, so `width`/`height` and plugins that read `matrix.width` follow it. Hardware calls through it (`SetImage`, `SwapOnVSync`, `Clear`, brightness writes) are inert. |
|
||||||
|
| `update_display()`, `clear()` | canvas-only: the block implies capture mode, which is already per-thread |
|
||||||
|
| `set_scrolling_state()`, `set_frame_hold()` | no-ops, so a plugin's `display()` cannot re-pace the live scroll. Today it can, when it is captured on the render thread. |
|
||||||
|
|
||||||
|
Every other thread sees the real canvas, unchanged. The render loop in
|
||||||
|
particular keeps presenting while a plugin draws elsewhere.
|
||||||
|
|
||||||
|
### Implementation sketch
|
||||||
|
|
||||||
|
- `image`, `draw` and `matrix` become properties over `_image`, `_draw` and
|
||||||
|
`_matrix`, plus a thread-local current surface. The getter returns the
|
||||||
|
surface's value when the calling thread has one, else the shared one; setters
|
||||||
|
mirror that. That costs about 0.1 µs per access, and `update_display()` reads
|
||||||
|
each a handful of times per frame. Every existing `self.image = ...` in
|
||||||
|
`DisplayManager` (`clear()`, setup, fallback) keeps working and becomes
|
||||||
|
thread-correct for free.
|
||||||
|
- `render_size()` is rebuilt on `offscreen()`: it creates or narrows the
|
||||||
|
calling thread's surface instead of swapping shared state.
|
||||||
|
- `offscreen()` nests and always restores on exit, including when the plugin
|
||||||
|
raises.
|
||||||
|
- `VisualDisplayManager` (the plugin test harness) gets the same method, for
|
||||||
|
parity.
|
||||||
|
|
||||||
|
### Adapter changes
|
||||||
|
|
||||||
|
- `get_content(offscreen_only=True)` stops returning `None` for the three
|
||||||
|
paths above. Each runs inside `display_manager.offscreen(render_width)`.
|
||||||
|
- `_capture_display_content` and `_trigger_scroll_content_generation` drop
|
||||||
|
their "copy the shared image, restore it afterwards" bookkeeping, since the
|
||||||
|
shared image is never touched.
|
||||||
|
- **Take the plugin's lock.** `PluginManager.get_plugin_lock()` keeps
|
||||||
|
`update()` and `display()` mutually exclusive in normal rotation, but Vegas
|
||||||
|
never takes it, so today's render-thread captures already race
|
||||||
|
`update()`. Off the render thread the adapter can afford to wait: blocking
|
||||||
|
acquire with a timeout (proposed 2 s). On timeout it keeps the cached segment
|
||||||
|
and tries again next group.
|
||||||
|
- `drain_deferred()` and the deferred queue are deleted. The only render-thread
|
||||||
|
fetch left is the inline fallback when no prepared group is ready, which in
|
||||||
|
practice is the first extension. Prefetching at start removes that too.
|
||||||
|
|
||||||
|
## Keeping live content fresh
|
||||||
|
|
||||||
|
Offscreen rendering is also what makes fresh sports scores possible. Today a
|
||||||
|
plugin's segment is drawn when its group is prefetched, and the strip carries
|
||||||
|
7,000–10,000 px of content ahead of the viewport (hdpi logs: "7153px still
|
||||||
|
ahead", "9842px ahead"). At ~100 px/s, a score drawn now reaches the screen
|
||||||
|
70–100 seconds later. When a plugin reports new data, Vegas only drops its
|
||||||
|
cache (`invalidate_pending_updates`), so the change is drawn on the plugin's
|
||||||
|
*next* turn, several minutes later. A segment already in the strip scrolls by
|
||||||
|
with the data it was drawn with.
|
||||||
|
|
||||||
|
That was the right trade while every redraw of a canvas-bound plugin stalled
|
||||||
|
the scroll. Off the render thread a redraw costs the scroll nothing, so the
|
||||||
|
strip can afford three things.
|
||||||
|
|
||||||
|
### 1. Refresh at the gate
|
||||||
|
|
||||||
|
Before a segment enters the viewport, check whether its plugin has updated
|
||||||
|
since the segment was drawn. If it has, redraw it offscreen and replace it
|
||||||
|
while it is still out of sight. Width changes are fine here, because
|
||||||
|
everything from that segment onward is still invisible.
|
||||||
|
|
||||||
|
The gate sits `lead` pixels ahead of the viewport's right edge:
|
||||||
|
`lead = max(one screen, speed × (render time + margin))`. The render time is
|
||||||
|
the plugin's own, measured on each render (sports cards take the longest,
|
||||||
|
hundreds of ms up to seconds per the prefetch notes). A plugin whose render
|
||||||
|
does not finish before its segment reaches the viewport keeps the old segment.
|
||||||
|
The scroll never waits for it.
|
||||||
|
|
||||||
|
Content is then at most `lead / speed` seconds old when it appears, a few
|
||||||
|
seconds instead of minutes, without changing how far ahead the rotation
|
||||||
|
fetches.
|
||||||
|
|
||||||
|
### 2. Replace ahead of the screen
|
||||||
|
|
||||||
|
When a plugin reports new data (the Vegas update tick already names them), any
|
||||||
|
of its segments that are **anywhere ahead of the viewport** are redrawn and
|
||||||
|
replaced straight away, not only at the gate. That covers the long stretch of
|
||||||
|
strip between prefetch and the gate.
|
||||||
|
|
||||||
|
### 3. Update on screen
|
||||||
|
|
||||||
|
A segment that is already **visible** is patched in place when the redrawn
|
||||||
|
version has the same geometry: the same total width, and the same width for
|
||||||
|
each card (a sports plugin returns one image per game, joined with
|
||||||
|
`intra_plugin_gap`). Scoreboard cards keep a fixed layout, so a score change
|
||||||
|
patches in and the digits update as the card scrolls past. The patch is a
|
||||||
|
pixel copy of one card (a 150×64 card is ~29 KB) applied by the render thread
|
||||||
|
between frames, so a frame never shows half of a patch.
|
||||||
|
|
||||||
|
When the geometry differs (a game added or dropped, a card that grew), the
|
||||||
|
visible part cannot change without a jump. Only the cards not yet on screen
|
||||||
|
are replaced, and only if the geometry up to that point is unchanged. Otherwise
|
||||||
|
the segment keeps its snapshot until it has scrolled off.
|
||||||
|
|
||||||
|
### Avoiding wasted work
|
||||||
|
|
||||||
|
- **Change detection.** `run_scheduled_updates_with_changes()` names a plugin
|
||||||
|
whenever its `update()` ran, not when its data changed. On hdpi
|
||||||
|
`clock-simple` and `ledmatrix-music` are named on every 4-second tick. A
|
||||||
|
redraw whose pixels hash the same as the segment's is discarded without a
|
||||||
|
swap.
|
||||||
|
- **Redraw on real updates only.** Vegas makes no API calls. Each plugin
|
||||||
|
fetches on its own schedule, and a redraw is triggered only when the
|
||||||
|
plugin's `update()` has run since its segment was drawn. On hdpi live
|
||||||
|
football, baseball and hockey poll every 30 s (live odds every 60 s,
|
||||||
|
everything else hourly), so a live sports card is redrawn once per poll.
|
||||||
|
- **Floor.** A plugin is redrawn at most once per
|
||||||
|
`vegas_scroll.refresh_min_interval` (proposed 10 s), and never while its
|
||||||
|
previous redraw is still running. The floor never holds back a sports card
|
||||||
|
polling every 30 s. It exists for chatty plugins: `clock-simple` updates
|
||||||
|
every second and `ledmatrix-music` polls every 2 s.
|
||||||
|
- **One worker.** Redraws go through the same background worker as prefetch,
|
||||||
|
one plugin at a time at `nice 10`, under the plugin's lock.
|
||||||
|
|
||||||
|
Data freshness is still bounded by each plugin's own fetch interval (how often
|
||||||
|
it polls live scores). Drawing faster cannot beat the data source.
|
||||||
|
|
||||||
|
### The strip becomes a list of segments
|
||||||
|
|
||||||
|
All three need the strip to be replaceable by segment. Today it is one
|
||||||
|
image (`ScrollHelper.cached_array`, 8,000–20,000 px wide, 1.5–3.8 MB), and
|
||||||
|
`append_content()` rebuilds the whole thing on the render thread for every
|
||||||
|
appended block. That is also a pause source.
|
||||||
|
|
||||||
|
Proposed `SegmentStrip`, used by Vegas in place of the single image:
|
||||||
|
|
||||||
|
- an ordered list of segments: plugin id, card boundaries, a pixel array, the
|
||||||
|
render time, and the plugin data version it was drawn from, plus its
|
||||||
|
x-offset in the strip;
|
||||||
|
- `visible(x, width)` assembles the viewport by slicing across at most a few
|
||||||
|
segments: the same ~100 KB copy per frame that slicing the single image
|
||||||
|
costs today;
|
||||||
|
- append and trim become O(block) list operations, not a copy of the strip;
|
||||||
|
- replace swaps one list entry and shifts the offsets of the segments after it
|
||||||
|
(dozens at most). A same-geometry patch copies pixels into the existing array.
|
||||||
|
|
||||||
|
Every mutation is prepared off the render thread and applied by the render
|
||||||
|
thread at a frame boundary, so the strip the render loop reads is never
|
||||||
|
half-changed.
|
||||||
|
|
||||||
|
### Multi-display sync
|
||||||
|
|
||||||
|
The follower renders from its own copy of the strip, offset from the leader's
|
||||||
|
scroll position. Today the leader sends that copy whole, and only in
|
||||||
|
`start_new_cycle()` (`send_scroll_image`), plus the scroll position every
|
||||||
|
frame. Continuous scroll, the default, extends and trims the strip without
|
||||||
|
starting a new cycle, and nothing sends those changes. From reading the code,
|
||||||
|
the follower therefore probably falls out of step after the first extension
|
||||||
|
already, before any of this design. That is untested; it needs a two-Pi rig.
|
||||||
|
|
||||||
|
With a segment strip, keeping the follower identical becomes **replaying the
|
||||||
|
leader's operations**:
|
||||||
|
|
||||||
|
- Every strip mutation (append, trim, replace, patch) is one operation in
|
||||||
|
strip coordinates. The leader applies it and sends the same operation to the
|
||||||
|
follower over the existing TCP channel. Segments are small: a card is ~29 KB
|
||||||
|
raw and compresses well.
|
||||||
|
- Operations on off-screen segments apply on arrival. A patch to a segment
|
||||||
|
that is on either panel carries an *apply at scroll position X* stamp a
|
||||||
|
couple of hundred milliseconds ahead. Both sides apply it when their scroll
|
||||||
|
position passes X, so both panels change on the same frame, within the
|
||||||
|
existing position-sync jitter.
|
||||||
|
- Each operation carries a sequence number. A follower that sees a gap (a
|
||||||
|
reconnect, a dropped message) asks for a full snapshot, which is today's
|
||||||
|
`send_scroll_image` path.
|
||||||
|
|
||||||
|
That also fixes the probable continuous-mode gap as a side effect, since
|
||||||
|
appends and trims become operations too. Until it is in place, fresh-content
|
||||||
|
updates are disabled while sync is active.
|
||||||
|
|
||||||
|
## Risks, and what was checked
|
||||||
|
|
||||||
|
1. **Plugins holding their own reference to the shared `draw` or `image`.**
|
||||||
|
They would keep drawing into the shared canvas, and routing by thread can't
|
||||||
|
redirect them. A grep of the 49 plugins installed on hdpi found none storing
|
||||||
|
`display_manager.draw` or `.image` in an attribute (a pattern search, so
|
||||||
|
indirect aliasing would slip past it). A plugin that did would
|
||||||
|
draw into an image nobody displays, which trims to a blank segment. That is
|
||||||
|
not corruption, and it is no worse than today.
|
||||||
|
2. **Plugins calling the matrix directly.** None in the audit. Inside
|
||||||
|
`offscreen()` the proxy makes it inert anyway.
|
||||||
|
3. **Font thread-safety.** `FontManager` shares font objects across plugins.
|
||||||
|
Measured on Pillow 12.3, two threads rendering text take 1.94× as long as
|
||||||
|
one, so text rendering holds the GIL and FreeType is never entered
|
||||||
|
concurrently. Re-check if Pillow changes that.
|
||||||
|
4. **Plugin thread-safety.** `display()` moves to the prefetch thread. The
|
||||||
|
plugin lock makes it exclusive with `update()`, which is more protection
|
||||||
|
than it has today. Threads a plugin starts itself are not covered, as today.
|
||||||
|
5. **The GIL.** Moving 40–600 ms of plugin rendering off the render thread
|
||||||
|
removes the pauses, but the work still needs the GIL. Pillow drawing holds
|
||||||
|
it, and a waiting thread only gets it back after the switch interval
|
||||||
|
(default 5 ms). Expect some single-refresh late frames while a prefetch
|
||||||
|
runs. Measure with the soak. A render process separate from plugin work
|
||||||
|
is the structural answer (the "native presenter" step). Two experiments
|
||||||
|
get most of the way first (results under Status, above):
|
||||||
|
- `vegas_scroll.switch_interval_ms` lowers the switch interval for a Vegas
|
||||||
|
run (1 ms is the obvious try), so the render thread waits at most that
|
||||||
|
long behind bytecode. It does nothing for a C call that keeps the GIL.
|
||||||
|
- `vegas_scroll.prefetch_gate` (`src/common/render_gate.py`) lets the
|
||||||
|
prefetch thread run Python only while the render thread is blocked in
|
||||||
|
`SwapOnVSync`, up to just before the refresh the swap returns on, and
|
||||||
|
parks it the rest of the time. That covers C calls too, since the gate is
|
||||||
|
checked before each one starts. It never parks the thread while it holds
|
||||||
|
a lock the render thread takes, and never for more than 50 ms. It needs
|
||||||
|
the rebuilt binding, which releases the GIL during the swap. On by
|
||||||
|
default.
|
||||||
|
|
||||||
|
## What this does not fix
|
||||||
|
|
||||||
|
- **The blit.** Copying a 512×64 frame into the matrix (`SetImage`) is ~6 ms at
|
||||||
|
8 PWM bits on a Pi 4, leaving ~4 ms of slack per refresh. That is the main
|
||||||
|
source of the single-refresh late frames. Holding frames for two refreshes
|
||||||
|
(≈50 px/s) doubles the budget. Cutting the blit itself is the native-presenter
|
||||||
|
step.
|
||||||
|
- **Live refreshes pushed from `update()`.** Some sports plugins call
|
||||||
|
`display()` and `update_display()` from inside `update()`, which runs on the
|
||||||
|
update worker and can push to the panel mid-Vegas. That is a separate
|
||||||
|
hazard. `offscreen()` gives a tool for it (run the update worker offscreen
|
||||||
|
while Vegas owns the panel), but it is out of scope here.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- **Unit, `DisplayManager`:** one thread inside `offscreen()` draws while
|
||||||
|
another reads `image`/`draw`/`matrix`/`width`/`height` and sees the real
|
||||||
|
canvas. Also: `update_display()` and `set_scrolling_state()` are inert inside;
|
||||||
|
`render_size()` narrows only the calling thread; nesting and exceptions
|
||||||
|
restore state.
|
||||||
|
- **Unit, adapter:** a stub display-capture plugin and a stub scroll-helper
|
||||||
|
plugin both return content with `offscreen_only=True`, and nothing is queued
|
||||||
|
for the render thread. The plugin lock is taken, and a timeout keeps the cached
|
||||||
|
segment.
|
||||||
|
- **Emulator integration:** a stub canvas-bound plugin whose `display()` sleeps
|
||||||
|
300 ms. The Vegas render loop never goes a frame without presenting (frame
|
||||||
|
timing recorder: zero freezes).
|
||||||
|
- **Unit, `SegmentStrip`:** the viewport assembled across segment boundaries
|
||||||
|
matches slicing one concatenated image, pixel for pixel. Append, trim,
|
||||||
|
replace-ahead and same-geometry patch each leave every other column
|
||||||
|
unchanged. A geometry-changing patch of a visible segment is refused.
|
||||||
|
- **Freshness:** a stub sports plugin whose score changes every second. The
|
||||||
|
score on screen is never older than `lead / speed` plus the plugin's fetch
|
||||||
|
interval. A visible card's digits change without the frame-timing recorder
|
||||||
|
seeing a late frame. An unchanged redraw is discarded.
|
||||||
|
- **Hardware:** an hdpi soak, A/B against the #628 build, alternating order.
|
||||||
|
Targets: no freezes, an empty 6+ bucket, the 3–5 bucket near zero, and the late
|
||||||
|
rate below 0.66%. Plus, for freshness: log each segment's age when it enters
|
||||||
|
the viewport, and compare the median and max before and after.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Three changes, each soaked on hdpi before the next:
|
||||||
|
|
||||||
|
1. **Offscreen rendering:** `offscreen()`, the adapter on the prefetch thread,
|
||||||
|
and the plugin lock. Removes the render-thread pauses.
|
||||||
|
2. **`SegmentStrip`:** Vegas's strip becomes a list of segments. Removes the
|
||||||
|
whole-strip copy on append. No visible behaviour change.
|
||||||
|
3. **Fresh content:** refresh at the gate, replace ahead, patch on screen,
|
||||||
|
with change detection and the rate limit.
|
||||||
|
|
||||||
|
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores today's
|
||||||
|
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
|
||||||
|
`true`) turns off step 3. Keep both for one release, then delete the old paths.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. Keep the kill switch, or ship without one?
|
||||||
|
2. Plugin lock timeout: skip the plugin and keep its cached segment (proposed),
|
||||||
|
or wait longer?
|
||||||
|
3. `refresh_min_interval`: 10 s proposed. It only limits chatty plugins;
|
||||||
|
live sports are redrawn once per 30 s poll regardless.
|
||||||
|
4. Multi-display sync: is there a two-Pi rig to test on? Operation replay is
|
||||||
|
proposed as part of the segment strip (step 2), with fresh content
|
||||||
|
disabled under sync until it has been verified on real hardware.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Permissions
|
||||||
|
|
||||||
|
Who owns what on an installed system, which privileged commands the web
|
||||||
|
interface may run, and how to repair ownership when it goes wrong. The
|
||||||
|
installer, [`first_time_install.sh`](../first_time_install.sh), sets all of
|
||||||
|
this up; this page describes the result.
|
||||||
|
|
||||||
|
## Users and groups
|
||||||
|
|
||||||
|
| Account | Used by | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `root` | `ledmatrix.service` (the display) | The LED matrix library needs direct GPIO access |
|
||||||
|
| The installing user (e.g. `ledpi`) | `ledmatrix-web.service`, `ledmatrix-update-verify.service` | A web server should not run as root |
|
||||||
|
| `ledmatrix` group | shared files | Members: the installing user, `root`, and `daemon` if it exists. Created by [`setup_cache.sh`](../scripts/install/setup_cache.sh) and the installer |
|
||||||
|
|
||||||
|
The installer also adds the web user to `systemd-journal` and `adm` so the
|
||||||
|
**Logs** tab can read the journal. Group changes apply after the user logs
|
||||||
|
in again (services pick them up on restart).
|
||||||
|
|
||||||
|
## Files and directories
|
||||||
|
|
||||||
|
| Path | Owner | Mode | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Project directory | web user | dirs `755`, files `644`, `*.sh` `755` | Set in the installer's "Normalize project file permissions" step |
|
||||||
|
| `config/` | web user | `2775` | |
|
||||||
|
| `config/config.json` | web user | `644` | Written by the web interface |
|
||||||
|
| `config/config_secrets.json` | web user : `ledmatrix` | `640` | Owned by the web user because the web interface writes it; root reads it regardless of mode |
|
||||||
|
| `plugin-repos/`, `plugins/` | web user | dirs `2775`, files `664` | The web interface installs and removes plugins |
|
||||||
|
| `assets/` | web user | dirs `755`, files `644` | Root writes downloaded logos regardless |
|
||||||
|
| `/var/cache/ledmatrix/` | `root:ledmatrix` | `2775` (setgid) | Shared cache: see below |
|
||||||
|
| Cache files | creator : `ledmatrix` | `660` | |
|
||||||
|
| `scripts/fix_perms/safe_plugin_rm.sh`, `safe_pip_install.sh` | `root:root` | `755` | Run as root through sudo, so the web user must not be able to edit them |
|
||||||
|
| `/etc/sudoers.d/ledmatrix_web`, `ledmatrix_wifi` | `root` | `440` | |
|
||||||
|
|
||||||
|
What keeps it that way at runtime:
|
||||||
|
|
||||||
|
- **Config files.** Saves go through
|
||||||
|
[`src/config_manager_atomic.py`](../src/config_manager_atomic.py), which
|
||||||
|
applies `get_config_file_mode()` (`640` for secrets, `644` otherwise) and,
|
||||||
|
when running as root, moves the file's group to the project directory's
|
||||||
|
group (`ensure_shared_group_ownership()` in
|
||||||
|
[`src/common/permission_utils.py`](../src/common/permission_utils.py)).
|
||||||
|
- **Cache files.** [`src/cache/disk_cache.py`](../src/cache/disk_cache.py)
|
||||||
|
sets every file it writes to `0660` and gives it the cache directory's
|
||||||
|
group, without relying on the setgid bit. So a file root writes stays
|
||||||
|
readable by the web user.
|
||||||
|
- **Plugin directories.** [`run.py`](../run.py) sets
|
||||||
|
`sys.dont_write_bytecode`, because root-owned `__pycache__` directories
|
||||||
|
inside a plugin stop the web user updating or removing it.
|
||||||
|
|
||||||
|
`ledmatrix-web.service` deliberately has no `CacheDirectory=`: systemd would
|
||||||
|
re-own `/var/cache/ledmatrix` to the web user and its primary group, and the
|
||||||
|
web interface could no longer read what the display writes (see the comment
|
||||||
|
in [`systemd/ledmatrix-web.service`](../systemd/ledmatrix-web.service)).
|
||||||
|
|
||||||
|
If `/var/cache/ledmatrix` is not usable, `CacheManager` falls back to
|
||||||
|
`~/.ledmatrix_cache`, `/opt/ledmatrix/cache` or a temp directory
|
||||||
|
([`src/cache_manager.py`](../src/cache_manager.py)). The two services then
|
||||||
|
may not share a cache, and the web UI shows stale or empty display status,
|
||||||
|
on-demand state and plugin health. Fix the directory rather than living
|
||||||
|
with the fallback.
|
||||||
|
|
||||||
|
## sudo rules
|
||||||
|
|
||||||
|
### `/etc/sudoers.d/ledmatrix_web`
|
||||||
|
|
||||||
|
Generated by `web_sudoers_rules()` in
|
||||||
|
[`scripts/install/lib_sudoers.sh`](../scripts/install/lib_sudoers.sh), the
|
||||||
|
only place these rules are defined. Installed by the installer and by
|
||||||
|
[`configure_web_sudo.sh`](../scripts/install/configure_web_sudo.sh), both of
|
||||||
|
which check them with `visudo -c` first. The web user may run, without a
|
||||||
|
password:
|
||||||
|
|
||||||
|
- `reboot`, `poweroff`
|
||||||
|
- `systemctl start|stop|restart|enable|disable|status ledmatrix.service`,
|
||||||
|
`systemctl is-active ledmatrix[.service]`
|
||||||
|
- `systemctl start|stop|restart ledmatrix-web.service`
|
||||||
|
- `bash <project>/scripts/fix_perms/safe_plugin_rm.sh *` — removes a
|
||||||
|
directory only if it resolves to a child of `plugin-repos/` or `plugins/`
|
||||||
|
- `bash <project>/scripts/fix_perms/safe_pip_install.sh *` — installs a
|
||||||
|
`requirements.txt` only if it is the project's own or one under
|
||||||
|
`plugin-repos/` or `plugins/`, so the root display service can import the
|
||||||
|
packages
|
||||||
|
- `journalctl -u ledmatrix.service *`, `-u ledmatrix *`, `-t ledmatrix *`,
|
||||||
|
tagged `NOEXEC`: journalctl opens a pager on a terminal, and a shell
|
||||||
|
escape from that pager would be a root shell
|
||||||
|
|
||||||
|
### `/etc/sudoers.d/ledmatrix_wifi`
|
||||||
|
|
||||||
|
Written by
|
||||||
|
[`scripts/install/configure_wifi_permissions.sh`](../scripts/install/configure_wifi_permissions.sh)
|
||||||
|
(run as the web user; the installer calls it). It refuses to grant a binary
|
||||||
|
that is not root-owned or is group/world-writable. The rules cover:
|
||||||
|
|
||||||
|
- `nmcli device wifi connect|disconnect *`, `nmcli device connect|disconnect *`,
|
||||||
|
`nmcli radio wifi on|off`
|
||||||
|
- `systemctl start|stop|restart hostapd`, `... dnsmasq`,
|
||||||
|
`systemctl restart NetworkManager`
|
||||||
|
- `sysctl -w net.ipv4.ip_forward=0|1`
|
||||||
|
- `nft add|delete table ip ledmatrix`
|
||||||
|
- `rfkill unblock wifi`
|
||||||
|
- `mkdir -p /etc/NetworkManager/dnsmasq-shared.d`
|
||||||
|
- `cp` of `/tmp/hostapd.conf` and `/tmp/dnsmasq.conf` to their fixed
|
||||||
|
destinations, and `rm -f /etc/dnsmasq.d/ledmatrix-captive.conf`
|
||||||
|
- `cp /tmp/ledmatrix-nm-dnsmasq.conf` to
|
||||||
|
`/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf`, and
|
||||||
|
`rm -f` of that file
|
||||||
|
|
||||||
|
**`iptables` is deliberately not granted.** The captive portal's rules are
|
||||||
|
built from the interface name and port, so a rule covering them would need a
|
||||||
|
trailing wildcard, and `iptables --modprobe=<path>` runs `<path>` as root: a
|
||||||
|
wildcard grant is a root shell for the web user. Doing it safely needs a
|
||||||
|
wrapper script that builds the rules itself, like `safe_plugin_rm.sh`. On a
|
||||||
|
stock Raspberry Pi OS image the default user's blanket `NOPASSWD` rule
|
||||||
|
(`/etc/sudoers.d/010_pi-nopasswd`) hides this gap.
|
||||||
|
|
||||||
|
### polkit
|
||||||
|
|
||||||
|
The same script installs `/etc/polkit-1/rules.d/10-ledmatrix-wifi.rules`,
|
||||||
|
which lets the web user perform any `org.freedesktop.NetworkManager.*`
|
||||||
|
action without authentication.
|
||||||
|
|
||||||
|
## Repair scripts
|
||||||
|
|
||||||
|
In [`scripts/fix_perms/`](../scripts/fix_perms/). Run them from the project
|
||||||
|
directory.
|
||||||
|
|
||||||
|
| Script | Run as | What it does | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `fix_plugin_permissions.sh` | `sudo` | `plugins/` and `plugin-repos/` to `root:<user>`, dirs `2775`, files `664`; makes a `700` home directory `755` so root can traverse it | Safe. Group-writable, so the web user keeps write access |
|
||||||
|
| `fix_assets_permissions.sh` | `sudo` | `assets/` to `<user>:<group>`, mode `777` recursively | Works, but looser than the installer's `755`/`644` |
|
||||||
|
| `fix_cache_permissions.sh` | `sudo` | Runs [`setup_cache.sh`](../scripts/install/setup_cache.sh) for `/var/cache/ledmatrix` (`root:ledmatrix`, `2775`, files `660`), then makes `~/.ledmatrix_cache` (the fallback cache) `<user>:<group>` mode `777` | Safe. The `~/.ledmatrix_cache` mode is still `777` |
|
||||||
|
| `fix_web_permissions.sh` | the web user, **without** `sudo` | Resets project file ownership for the web user (it calls `sudo` itself), then makes `safe_plugin_rm.sh` and `safe_pip_install.sh` `root:root` `755` again and restores `config_secrets.json` to its owner, group `ledmatrix`, mode `640` | Refuses to run as root. It does not write sudoers rules |
|
||||||
|
| `safe_plugin_rm.sh`, `safe_pip_install.sh` | — | Called by the web interface through sudo | Not for manual use |
|
||||||
|
|
||||||
|
To reinstall the sudoers rules, run
|
||||||
|
`./scripts/install/configure_web_sudo.sh` (web rules) or
|
||||||
|
`./scripts/install/configure_wifi_permissions.sh` (WiFi rules and polkit) as
|
||||||
|
the web user, not with `sudo`.
|
||||||
|
|
||||||
|
After any of these, restart both services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart ledmatrix.service ledmatrix-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checking
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -ld /var/cache/ledmatrix # drwxrwsr-x root ledmatrix
|
||||||
|
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
|
||||||
|
id # web user should list ledmatrix
|
||||||
|
sudo -l # lists the NOPASSWD rules
|
||||||
|
```
|
||||||