Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853b5aa618 | ||
|
|
8ad9d191a7 | ||
|
|
7f9c73e9aa | ||
|
|
86c27970ef | ||
|
|
ab39552cdb | ||
|
|
83fd3491c7 | ||
|
|
37203e635c | ||
|
|
695be34a1b | ||
|
|
ffdd0efd43 | ||
|
|
cfcdbfb18a | ||
|
|
4c17e18786 | ||
|
|
b9416ef803 | ||
|
|
764fc6fcdb | ||
|
|
f6afbdbb15 | ||
|
|
99a3608516 | ||
|
|
b1510c209d | ||
|
|
48a433328a | ||
|
|
edfcd9e2a1 | ||
|
|
3a81f38f09 | ||
|
|
7b90759252 | ||
|
|
b11bcfa204 | ||
|
|
3967a6cffc | ||
|
|
74ba36a059 | ||
|
|
d37a3a712a | ||
|
|
dc26baa134 | ||
|
|
6aef54598b | ||
|
|
7b16e953d2 | ||
|
|
52bc520335 | ||
|
|
4e61d7248a | ||
|
|
ece416c4e5 | ||
|
|
13bbb537f3 | ||
|
|
afe9001aed | ||
|
|
abedc46104 | ||
|
|
1fe7237799 | ||
|
|
ddf5f085a5 | ||
|
|
5baf983fe0 | ||
|
|
82f3a3a3e4 | ||
|
|
c1ce0b7b04 | ||
|
|
14c38a3189 | ||
|
|
b67818d5c5 | ||
|
|
c5281d9a45 | ||
|
|
f79618d4f7 | ||
|
|
d56ec2ab3a | ||
|
|
c883a2fd1e | ||
|
|
ac841f4583 | ||
|
|
98728d3b81 | ||
|
|
3eb7a2e349 | ||
|
|
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 |
@@ -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`
|
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
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@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@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,75 @@
|
|||||||
|
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:
|
||||||
|
|
||||||
|
# Both jobs only check out the repo and run pytest.
|
||||||
|
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 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 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
|
||||||
@@ -5,9 +5,13 @@ __pycache__/
|
|||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
config/config_secrets.json
|
config/config_secrets.json
|
||||||
|
# Atomic writes leave these behind when a save or a test is interrupted;
|
||||||
|
# the suite drops several per run.
|
||||||
|
config/.config_secrets.json.tmp.*
|
||||||
config/config.json
|
config/config.json
|
||||||
config/config.json.backup
|
config/config.json.backup
|
||||||
config/wifi_config.json
|
config/wifi_config.json
|
||||||
|
config/uninstalled_plugins.json
|
||||||
credentials.json
|
credentials.json
|
||||||
token.pickle
|
token.pickle
|
||||||
|
|
||||||
@@ -35,11 +39,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 +52,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
|
||||||
|
|||||||
@@ -6,32 +6,54 @@
|
|||||||
- `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)
|
||||||
- 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
|
||||||
|
|||||||
@@ -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,13 @@ 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), `mypy` on
|
||||||
patterns already in `templates/v3/` and `static/v3/`.
|
`src/`, `bandit`, and `gitleaks` — install the CLI with
|
||||||
|
`python -m pip install pre-commit`, then run
|
||||||
|
`pre-commit install` so they run on every commit; HTML/JS in
|
||||||
|
`web_interface/` follows the patterns already in `templates/v3/`
|
||||||
|
and `static/v3/`.
|
||||||
5. **Update documentation** alongside code changes. If you add a
|
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.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 @@
|
|||||||
{
|
{
|
||||||
"web_display_autostart": true,
|
"web_display_autostart": true,
|
||||||
|
"auto_update": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"mode": "per-day",
|
"mode": "per-day",
|
||||||
@@ -88,6 +91,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timezone": "America/New_York",
|
"timezone": "America/New_York",
|
||||||
|
"target_fps": 100,
|
||||||
"location": {
|
"location": {
|
||||||
"city": "Tampa",
|
"city": "Tampa",
|
||||||
"state": "Florida",
|
"state": "Florida",
|
||||||
@@ -109,22 +113,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 +171,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"
|
||||||
|
}
|
||||||
@@ -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"`.
|
||||||
@@ -47,6 +47,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 +62,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
|
||||||
|
|
||||||
@@ -79,68 +176,62 @@ Override Vegas behavior for specific plugins:
|
|||||||
| Setting | Values | Description |
|
| Setting | Values | Description |
|
||||||
|---------|--------|-------------|
|
|---------|--------|-------------|
|
||||||
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin |
|
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin |
|
||||||
| `vegas_panel_count` | `1-10` | Width in panels (1 panel = display width) |
|
| `vegas_panel_count` | any positive integer | Width in panels (1 panel = display width) |
|
||||||
| `display_duration` | seconds | Pause duration for STATIC mode |
|
| `display_duration` | seconds | Pause duration for STATIC mode |
|
||||||
|
|
||||||
|
Plugins may also set `vegas_overflow` and `vegas_max_width_screens` in
|
||||||
|
their config section to control how oversized content is handled (see
|
||||||
|
`PluginManager` in `src/plugin_system/plugin_manager.py`).
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
**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]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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. Specify Content Type:**
|
**2. Specify Content Type:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_vegas_content_type(self):
|
def get_vegas_content_type(self):
|
||||||
"""
|
# 'multi' | 'static' | 'none' -- default is 'static'
|
||||||
Specify how content should be handled.
|
return 'multi'
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 'multi' | 'static' | 'none'
|
|
||||||
"""
|
|
||||||
return 'multi' # Default for most plugins
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`'none'` excludes the plugin from Vegas mode.
|
||||||
|
|
||||||
**3. Optionally Specify Display Mode:**
|
**3. Optionally Specify Display Mode:**
|
||||||
|
|
||||||
```python
|
These return `VegasDisplayMode` members, not strings:
|
||||||
def get_vegas_display_mode(self):
|
|
||||||
"""
|
|
||||||
Preferred display mode for this plugin.
|
|
||||||
|
|
||||||
Returns:
|
```python
|
||||||
str: 'scroll' | 'fixed' | 'static'
|
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||||
"""
|
|
||||||
return 'scroll'
|
def get_vegas_display_mode(self):
|
||||||
|
return VegasDisplayMode.SCROLL
|
||||||
|
|
||||||
def get_supported_vegas_modes(self):
|
def get_supported_vegas_modes(self):
|
||||||
"""
|
return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC]
|
||||||
List of supported modes.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: ['scroll', 'fixed', 'static']
|
|
||||||
"""
|
|
||||||
return ['scroll', 'static']
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`VegasDisplayMode` has `SCROLL` (`"scroll"`), `FIXED_SEGMENT` (`"fixed"`) and
|
||||||
|
`STATIC` (`"static"`). The default `get_vegas_display_mode()` uses the
|
||||||
|
plugin's `vegas_mode` config value if set, otherwise maps the content type
|
||||||
|
(`multi` to `SCROLL`, anything else to `FIXED_SEGMENT`).
|
||||||
|
|
||||||
### Content Rendering Guidelines
|
### Content Rendering Guidelines
|
||||||
|
|
||||||
**Image Dimensions:**
|
**Image Dimensions:**
|
||||||
@@ -276,9 +367,16 @@ 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
|
||||||
|
|
||||||
@@ -332,10 +430,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
|
||||||
@@ -451,7 +553,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 +610,36 @@ curl http://localhost:5000/api/v3/display/on-demand/status
|
|||||||
|
|
||||||
# Response:
|
# Response:
|
||||||
{
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"state": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"plugin_id": "weather",
|
"plugin_id": "weather",
|
||||||
"mode": "weather",
|
"mode": "weather",
|
||||||
"remaining": 25.5,
|
"duration": 30,
|
||||||
"pinned": false,
|
"pinned": false,
|
||||||
"status": "active"
|
"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 +761,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 +800,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 +826,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 +846,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 +876,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 +903,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 +924,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 +943,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 +951,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 +976,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 +1004,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 +1013,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 +1043,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 +1071,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 +1114,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.7.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)
|
||||||
try:
|
self.scroll_helper.update_scroll_position()
|
||||||
# Scroll content
|
self.display_manager.image = self.scroll_helper.get_visible_portion()
|
||||||
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()
|
self.display_manager.update_display()
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
# Update scroll activity timestamp
|
if self.scroll_helper.is_scroll_complete():
|
||||||
self.display_manager.set_scrolling_state(True)
|
# Mark as not scrolling when done
|
||||||
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.7.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,12 +596,10 @@ 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.7.0 — check the
|
||||||
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
# instance's `enabled` flag instead
|
||||||
if "weather" in enabled_plugins:
|
|
||||||
# Weather plugin is available
|
|
||||||
weather_plugin = self.plugin_manager.get_plugin("weather")
|
weather_plugin = self.plugin_manager.get_plugin("weather")
|
||||||
if weather_plugin:
|
if weather_plugin is not None and weather_plugin.enabled:
|
||||||
# Use weather data
|
# Use weather data
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# 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` |
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
The on-demand start route also restarts `ledmatrix.service` by default so the
|
||||||
|
request takes effect straight away.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **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. 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).
|
||||||
|
|
||||||
|
## 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 | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
|
||||||
|
| 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`) |
|
||||||
|
| 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, 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),
|
||||||
|
`plugins.py`, `starlark.py`, `system.py` (service actions, updates, git),
|
||||||
|
`wifi.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):
|
||||||
|
`git pull --rebase`, reinstall changed requirement files, report whether a
|
||||||
|
restart is needed.
|
||||||
|
- **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 on failure 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.
|
||||||
|
|
||||||
|
## 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,181 @@
|
|||||||
|
# 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()`) |
|
||||||
|
| `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` |
|
||||||
|
| `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_manager.py`) |
|
||||||
|
| `<plugin-id>.*` | Secrets a plugin declares with `"x-secret": true` in its config schema; merged into that plugin's config at load time |
|
||||||
@@ -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.7.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.7.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.7.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
|
||||||
|
|||||||
@@ -69,23 +69,24 @@ default configuration as it ships in the repo:
|
|||||||
```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.7.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)},
|
|
||||||
"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,
|
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):
|
|
||||||
self.font_manager = display_manager.font_manager
|
|
||||||
self.plugin_id = plugin_id
|
|
||||||
|
|
||||||
def display(self):
|
|
||||||
# Use plugin font (automatically namespaced)
|
|
||||||
font = self.font_manager.resolve_font(
|
|
||||||
element_key=f"{self.plugin_id}.text",
|
element_key=f"{self.plugin_id}.text",
|
||||||
family="custom_font", # Will be resolved as "my-plugin::custom_font"
|
family="custom_font", # resolved as "my-plugin::custom_font"
|
||||||
size_px=10,
|
size_px=10,
|
||||||
plugin_id=self.plugin_id
|
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.7.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,12 +226,14 @@ 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.
|
||||||
@@ -280,10 +300,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 +327,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,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
|
||||||
|
```
|
||||||
@@ -2,12 +2,63 @@
|
|||||||
|
|
||||||
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
||||||
|
|
||||||
|
> **Adaptive layout:** every `BasePlugin` also exposes `self.layout`,
|
||||||
|
> `self.draw_fit(text, region)` and `self.draw_image(img, region, ...)` —
|
||||||
|
> the recommended way to render text and images that scale to any panel
|
||||||
|
> size. See [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Manifest Required Fields](#manifest-required-fields)
|
||||||
- [BasePlugin](#baseplugin)
|
- [BasePlugin](#baseplugin)
|
||||||
- [Display Manager](#display-manager)
|
- [Display Manager](#display-manager)
|
||||||
- [Cache Manager](#cache-manager)
|
- [Cache Manager](#cache-manager)
|
||||||
- [Plugin Manager](#plugin-manager)
|
- [Plugin Manager](#plugin-manager)
|
||||||
|
- [Deprecated APIs](#deprecated-apis)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manifest Required Fields
|
||||||
|
|
||||||
|
Three parts of core check `manifest.json`, each for a different set of
|
||||||
|
fields:
|
||||||
|
|
||||||
|
| Check | Fields | What happens when one is missing |
|
||||||
|
|---|---|---|
|
||||||
|
| JSON schema, [`schema/manifest_schema.json`](../schema/manifest_schema.json) | `id`, `name`, `version`, `author`, `entry_point`, `class_name`, `compatible_versions` | Install from URL logs a warning (`PluginStoreManager._validate_manifest_schema()`); nothing is refused |
|
||||||
|
| Plugin Store install, [`src/plugin_system/store_manager.py`](../src/plugin_system/store_manager.py) | `id`, `name`, `class_name`, `display_modes` | Install is refused. A registry install first tries to detect a missing `class_name` from the entry-point file |
|
||||||
|
| Plugin loader, [`src/plugin_system/plugin_loader.py`](../src/plugin_system/plugin_loader.py) | `class_name` | The plugin fails to load |
|
||||||
|
|
||||||
|
Defaults and other uses:
|
||||||
|
|
||||||
|
- `entry_point` defaults to `manager.py`; the store writes the default back
|
||||||
|
into the manifest on install.
|
||||||
|
- `compatible_versions` (a list of semver ranges such as `">=2.0.0"`) is how
|
||||||
|
the store decides whether a plugin can run on this core. An install is
|
||||||
|
refused only when the field excludes the running version
|
||||||
|
(`compatibility.check()` in
|
||||||
|
[`src/plugin_system/compatibility.py`](../src/plugin_system/compatibility.py)).
|
||||||
|
- `version` is compared with the registry's `latest_version` to decide
|
||||||
|
whether an update is available.
|
||||||
|
- If `display_modes` is empty at load time, the display controller uses the
|
||||||
|
plugin id as the only mode.
|
||||||
|
|
||||||
|
**Set all eight:** `id`, `name`, `version`, `author`, `entry_point`,
|
||||||
|
`class_name`, `display_modes`, `compatible_versions`. That satisfies every
|
||||||
|
check. The schema lists the optional fields.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-plugin",
|
||||||
|
"name": "My Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "YourName",
|
||||||
|
"entry_point": "manager.py",
|
||||||
|
"class_name": "MyPlugin",
|
||||||
|
"display_modes": ["my-plugin"],
|
||||||
|
"compatible_versions": [">=2.0.0"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,7 +82,11 @@ self.enabled # Boolean enabled status
|
|||||||
|
|
||||||
#### `update() -> None`
|
#### `update() -> None`
|
||||||
|
|
||||||
Fetch/update data for this plugin. Called based on `update_interval` specified in the plugin's manifest.
|
Fetch/update data for this plugin. Called on the plugin's update interval:
|
||||||
|
the value `get_update_interval()` returns when it returns a number, otherwise
|
||||||
|
the static interval: the `update_interval` in the plugin's manifest, else
|
||||||
|
`update_interval` in the plugin's section of `config.json`, else 60 seconds
|
||||||
|
(see [`get_update_interval()`](#get_update_interval---optionalfloat) below).
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
```python
|
```python
|
||||||
@@ -104,6 +159,46 @@ Called when plugin is enabled.
|
|||||||
|
|
||||||
Called when plugin is disabled.
|
Called when plugin is disabled.
|
||||||
|
|
||||||
|
#### `get_update_interval() -> Optional[float]`
|
||||||
|
|
||||||
|
How often this plugin wants `update()` called right now, in seconds. The
|
||||||
|
manifest's `update_interval` is one static number; override this when the
|
||||||
|
right cadence depends on state only the plugin knows, e.g. poll every 15s
|
||||||
|
while a game is live and fall back to the manifest value otherwise.
|
||||||
|
|
||||||
|
**Returns**: seconds as a number, or `None` (the default) for no opinion.
|
||||||
|
|
||||||
|
How `PluginManager` (`_get_plugin_update_interval` in
|
||||||
|
`src/plugin_system/plugin_manager.py`) resolves the interval on each
|
||||||
|
scheduling tick:
|
||||||
|
|
||||||
|
1. It calls `get_update_interval()`. A number wins over everything below.
|
||||||
|
Values under `PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL` (5 seconds) are
|
||||||
|
raised to it.
|
||||||
|
2. If the hook returns `None`, raises, or returns something that isn't a
|
||||||
|
finite number (a `bool`, a string, NaN, infinity), it is ignored and the
|
||||||
|
static interval applies: the manifest's `update_interval`, else
|
||||||
|
`update_interval` in the plugin's section of `config.json`, else 60
|
||||||
|
seconds.
|
||||||
|
|
||||||
|
The static value is cached per plugin until the plugin is loaded or
|
||||||
|
unloaded again, so editing `update_interval` in config takes effect on the
|
||||||
|
next reload. The hook's return value is never cached: it is called on every
|
||||||
|
tick of the display loop, so keep it to attribute reads (no config lookups,
|
||||||
|
no I/O, no locks a fetch might hold) and don't let it raise.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```python
|
||||||
|
def get_update_interval(self):
|
||||||
|
# Fast while something is live, manifest default otherwise.
|
||||||
|
if any(m.live_games for m in self._live_managers):
|
||||||
|
return self.config.get("live_update_interval", 15)
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
Added in core 3.4.0; older cores never call it, so a plugin that relies on
|
||||||
|
it should floor `ledmatrix_min_version` at `3.4.0`.
|
||||||
|
|
||||||
#### `get_display_duration() -> float`
|
#### `get_display_duration() -> float`
|
||||||
|
|
||||||
Get display duration for this plugin. Can be overridden for dynamic durations.
|
Get display duration for this plugin. Can be overridden for dynamic durations.
|
||||||
@@ -165,6 +260,47 @@ Default returns `False`.
|
|||||||
List of display modes to show during a live takeover. Default returns the
|
List of display modes to show during a live takeover. Default returns the
|
||||||
plugin's `display_modes` from its manifest.
|
plugin's `display_modes` from its manifest.
|
||||||
|
|
||||||
|
#### `get_vegas_priority_weight() -> Optional[int]`
|
||||||
|
|
||||||
|
How many slots per Vegas cycle this plugin should get. Default returns
|
||||||
|
`None`, which defers to the core.
|
||||||
|
|
||||||
|
The Vegas ticker is otherwise a strict round robin — every plugin appears
|
||||||
|
exactly once per cycle — so with a dozen plugins enabled a live score can be
|
||||||
|
minutes stale by the time it comes round. A weight of *N* gives the plugin
|
||||||
|
*N* slots per cycle, spread evenly through it rather than clumped.
|
||||||
|
|
||||||
|
**You usually do not need this.** When the hook returns `None`, the core
|
||||||
|
already gives a plugin `vegas_scroll.live_weight` whenever
|
||||||
|
`has_live_priority()` and `has_live_content()` are both true. Live sports get
|
||||||
|
extra turns with no code at all.
|
||||||
|
|
||||||
|
Implement it only when the plugin knows something the core cannot. The
|
||||||
|
motivating case is favorite teams — the core can see *that* a game is live,
|
||||||
|
but not *whose*:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_vegas_priority_weight(self):
|
||||||
|
if not (self.has_live_priority() and self.has_live_content()):
|
||||||
|
return None # let the core decide
|
||||||
|
vegas = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||||
|
if self._favorite_is_live():
|
||||||
|
return vegas.get('favorite_live_weight', 5)
|
||||||
|
return vegas.get('live_weight', 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
The weight is per *plugin*, not per game: a scoreboard showing four live games
|
||||||
|
still occupies one slot at a time and rotates its own games within it. Values
|
||||||
|
are clamped to 1–10 by the caller. An exception here is caught and logged, and
|
||||||
|
the core then falls back to its own live-content check — so a plugin whose
|
||||||
|
weight calculation is broken still gets `live_weight` for a game that really
|
||||||
|
is live, rather than being demoted to 1.
|
||||||
|
|
||||||
|
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
|
||||||
|
default (`false`) live content preempts Vegas entirely and there is no ticker
|
||||||
|
to be weighted within. See
|
||||||
|
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||||
|
|
||||||
### Vegas scroll hooks
|
### Vegas scroll hooks
|
||||||
|
|
||||||
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
||||||
@@ -196,8 +332,9 @@ the mode selector for this plugin.
|
|||||||
|
|
||||||
#### `get_vegas_segment_width() -> Optional[int]`
|
#### `get_vegas_segment_width() -> Optional[int]`
|
||||||
|
|
||||||
For `FIXED_SEGMENT` plugins, the width in pixels of the segment they
|
For `FIXED_SEGMENT` plugins, the number of *panels* the segment
|
||||||
occupy in the scroll. `None` lets the controller pick a default.
|
occupies in the scroll (pixel width = panels × `single_panel_width`,
|
||||||
|
from `display.hardware.cols`). `None` uses the default of 1 panel.
|
||||||
|
|
||||||
> The full source for `BasePlugin` lives in
|
> The full source for `BasePlugin` lives in
|
||||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||||
@@ -338,106 +475,77 @@ self.display_manager.image.paste(icon, (5, 5), icon)
|
|||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the same pattern the bundled scoreboard base classes
|
This is the canonical way to render arbitrary images.
|
||||||
(`src/base_classes/baseball.py`, `basketball.py`, `football.py`,
|
|
||||||
`hockey.py`) use, so it's the canonical way to render arbitrary images.
|
|
||||||
|
|
||||||
### Weather Icons
|
### Weather Icons (deprecated)
|
||||||
|
|
||||||
#### `draw_weather_icon(condition: str, x: int, y: int, size: int = 16) -> None`
|
> Deprecated, removed in 3.7.0 — draw your own icons (the weather plugin
|
||||||
|
> ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Draw a weather icon based on the condition string.
|
- `draw_weather_icon(condition, x, y, size=16)` — icon for a condition
|
||||||
|
string such as `"clear"`, `"clouds"`, `"rain"`, `"snow"`, `"storm"`
|
||||||
**Parameters**:
|
- `draw_sun(x, y, size=16)`, `draw_cloud(x, y, size=16, color=(200, 200, 200))`,
|
||||||
- `condition` (str): Weather condition (e.g., "clear", "cloudy", "rain", "snow", "storm")
|
`draw_rain(x, y, size=16)`, `draw_snow(x, y, size=16)`
|
||||||
- `x` (int): X position
|
- `draw_text_with_icons(text, icons=None, x=None, y=None, color=(255, 255, 255))`
|
||||||
- `y` (int): Y position
|
— text plus a list of `(icon_type, x, y)` icons; calls `update_display()`
|
||||||
- `size` (int): Icon size in pixels (default: 16)
|
|
||||||
|
|
||||||
**Supported Conditions**:
|
|
||||||
- `"clear"`, `"sunny"` → Sun icon
|
|
||||||
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
|
|
||||||
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
|
|
||||||
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
|
|
||||||
- `"thunderstorm"`, `"storm"` → Storm icon
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```python
|
|
||||||
self.display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `draw_sun(x: int, y: int, size: int = 16) -> None`
|
|
||||||
|
|
||||||
Draw a sun icon with rays.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `x` (int): X position
|
|
||||||
- `y` (int): Y position
|
|
||||||
- `size` (int): Icon size (default: 16)
|
|
||||||
|
|
||||||
#### `draw_cloud(x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)) -> None`
|
|
||||||
|
|
||||||
Draw a cloud icon.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `x` (int): X position
|
|
||||||
- `y` (int): Y position
|
|
||||||
- `size` (int): Icon size (default: 16)
|
|
||||||
- `color` (tuple): RGB color (default: light gray)
|
|
||||||
|
|
||||||
#### `draw_rain(x: int, y: int, size: int = 16) -> None`
|
|
||||||
|
|
||||||
Draw rain icon with cloud and droplets.
|
|
||||||
|
|
||||||
#### `draw_snow(x: int, y: int, size: int = 16) -> None`
|
|
||||||
|
|
||||||
Draw snow icon with cloud and snowflakes.
|
|
||||||
|
|
||||||
#### `draw_text_with_icons(text: str, icons: List[tuple] = None, x: int = None, y: int = None, color: tuple = (255, 255, 255)) -> None`
|
|
||||||
|
|
||||||
Draw text with weather icons at specified positions.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `text` (str): Text to display
|
|
||||||
- `icons` (List[tuple], optional): List of (icon_type, x, y) tuples
|
|
||||||
- `x` (int, optional): X position for text
|
|
||||||
- `y` (int, optional): Y position for text
|
|
||||||
- `color` (tuple): Text color
|
|
||||||
|
|
||||||
**Note**: Automatically calls `update_display()` after drawing.
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```python
|
|
||||||
icons = [
|
|
||||||
("sun", 5, 5),
|
|
||||||
("cloud", 100, 5)
|
|
||||||
]
|
|
||||||
self.display_manager.draw_text_with_icons(
|
|
||||||
"Weather: Sunny, Cloudy",
|
|
||||||
icons=icons,
|
|
||||||
x=10, y=20
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scrolling State Management
|
### Scrolling State Management
|
||||||
|
|
||||||
For plugins that implement scrolling content, use these methods to coordinate with the display system.
|
For plugins that implement scrolling content, use these methods to coordinate with the display system.
|
||||||
|
|
||||||
#### `set_scrolling_state(is_scrolling: bool) -> None`
|
#### `set_scrolling_state(is_scrolling: bool, frame_hold: int = 1) -> None`
|
||||||
|
|
||||||
Mark the display as scrolling or not scrolling. Call when scrolling starts/stops.
|
Mark the display as scrolling or not scrolling, and set this scroll's frame
|
||||||
|
pacing. Call it when a scroll starts (calling it on every scroll frame is fine)
|
||||||
|
and with `False` when it stops.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
- `is_scrolling` (bool): True if currently scrolling, False otherwise
|
- `is_scrolling` (bool): True if currently scrolling, False otherwise
|
||||||
|
- `frame_hold` (int, default 1): how many panel refreshes each pushed frame is
|
||||||
|
held for (clamped to 1-255; ignored when `is_scrolling` is False, which
|
||||||
|
resets it to 1). Pass the `frame_hold` of the settings
|
||||||
|
`src.common.scroll_config.configure()` returned. Added in core 3.4.0.
|
||||||
|
|
||||||
|
**Why `frame_hold` matters**: `scroll_config.configure()` snaps the speed to
|
||||||
|
one the panel can show in whole pixels and sets the `ScrollHelper` to advance a
|
||||||
|
fixed number of pixels on every presented frame -- no clock is consulted. The
|
||||||
|
panel presents frames at its refresh rate divided by the hold, so the hold is
|
||||||
|
part of the speed. Omit it and a 50 px/s scroll (1px every 2nd refresh on a
|
||||||
|
100 Hz panel) runs at 100 px/s. The hold is not applied by `configure()`
|
||||||
|
because it must not outlive the scroll: plugins share one display manager.
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
```python
|
```python
|
||||||
|
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)
|
||||||
|
# ...later, hand it content with self.scroll_helper.set_scrolling_image(img)
|
||||||
|
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 display(self, force_clear=False):
|
def display(self, force_clear=False):
|
||||||
self.display_manager.set_scrolling_state(True)
|
self.display_manager.set_scrolling_state(
|
||||||
# Scroll content...
|
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()
|
||||||
|
if self.scroll_helper.is_scroll_complete():
|
||||||
self.display_manager.set_scrolling_state(False)
|
self.display_manager.set_scrolling_state(False)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Don't pace the loop with `time.sleep()`: `update_display()` blocks on the
|
||||||
|
panel's vsync, which is what paces a scroll. See `docs/SCROLL_PERFORMANCE.md`
|
||||||
|
for choosing a speed.
|
||||||
|
|
||||||
#### `is_currently_scrolling() -> bool`
|
#### `is_currently_scrolling() -> bool`
|
||||||
|
|
||||||
Check if the display is currently in a scrolling state.
|
Check if the display is currently in a scrolling state.
|
||||||
@@ -473,6 +581,8 @@ Process any deferred updates if not currently scrolling. Called automatically by
|
|||||||
|
|
||||||
#### `get_scrolling_stats() -> dict`
|
#### `get_scrolling_stats() -> dict`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get current scrolling statistics for debugging.
|
Get current scrolling statistics for debugging.
|
||||||
|
|
||||||
**Returns**: Dictionary with scrolling state information
|
**Returns**: Dictionary with scrolling state information
|
||||||
@@ -491,7 +601,7 @@ The Display Manager provides several pre-loaded fonts:
|
|||||||
display_manager.regular_font # Press Start 2P, size 8
|
display_manager.regular_font # Press Start 2P, size 8
|
||||||
display_manager.small_font # Press Start 2P, size 8
|
display_manager.small_font # Press Start 2P, size 8
|
||||||
display_manager.calendar_font # 5x7 BDF font
|
display_manager.calendar_font # 5x7 BDF font
|
||||||
display_manager.extra_small_font # 4x6 TTF font, size 6
|
display_manager.extra_small_font # 4x6 TTF font, size 7 (6 snapped to its pixel grid)
|
||||||
display_manager.bdf_5x7_font # Alias for calendar_font
|
display_manager.bdf_5x7_font # Alias for calendar_font
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -614,6 +724,8 @@ data = self.cache_manager.get_with_auto_strategy("nhl_live_scores")
|
|||||||
|
|
||||||
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
|
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0 — use `get()`. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get background service cached data with sport-specific intervals.
|
Get background service cached data with sport-specific intervals.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -651,6 +763,8 @@ max_age = strategy['max_age'] # Get configured max age
|
|||||||
|
|
||||||
#### `get_sport_live_interval(sport_key: str) -> int`
|
#### `get_sport_live_interval(sport_key: str) -> int`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get the live_update_interval for a specific sport from config.
|
Get the live_update_interval for a specific sport from config.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -675,6 +789,8 @@ Extract data type from cache key to determine appropriate cache strategy.
|
|||||||
|
|
||||||
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
|
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Extract sport key from cache key for sport-specific strategies.
|
Extract sport key from cache key for sport-specific strategies.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -719,22 +835,26 @@ for file_info in files:
|
|||||||
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Metrics Methods
|
### Metrics Methods (deprecated)
|
||||||
|
|
||||||
#### `get_cache_metrics() -> Dict[str, Any]`
|
#### `get_cache_metrics() -> Dict[str, Any]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get cache performance metrics.
|
Get cache performance metrics.
|
||||||
|
|
||||||
**Returns**: Dictionary with cache statistics (hits, misses, hit rate, etc.)
|
**Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
```python
|
```python
|
||||||
metrics = self.cache_manager.get_cache_metrics()
|
metrics = self.cache_manager.get_cache_metrics()
|
||||||
self.logger.info(f"Cache hit rate: {metrics['hit_rate']:.2%}")
|
self.logger.info(f"Cache hit rate: {metrics['cache_hit_rate']:.2%}")
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get memory cache statistics.
|
Get memory cache statistics.
|
||||||
|
|
||||||
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
|
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
|
||||||
@@ -779,6 +899,8 @@ for plugin_id, plugin in all_plugins.items():
|
|||||||
|
|
||||||
#### `get_enabled_plugins() -> List[str]`
|
#### `get_enabled_plugins() -> List[str]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get list of enabled plugin IDs.
|
Get list of enabled plugin IDs.
|
||||||
|
|
||||||
**Returns**: List of plugin identifier strings
|
**Returns**: List of plugin identifier strings
|
||||||
@@ -857,9 +979,8 @@ def update(self):
|
|||||||
|
|
||||||
**Example - Checking if another plugin is enabled**:
|
**Example - Checking if another plugin is enabled**:
|
||||||
```python
|
```python
|
||||||
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
weather = self.plugin_manager.plugins.get("weather")
|
||||||
if "weather" in enabled_plugins:
|
if weather is not None and weather.enabled:
|
||||||
# Weather plugin is enabled
|
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -907,9 +1028,10 @@ if "weather" in enabled_plugins:
|
|||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods
|
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods,
|
||||||
|
passing the frame hold `scroll_config.configure()` returned
|
||||||
```python
|
```python
|
||||||
self.display_manager.set_scrolling_state(True)
|
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||||||
# Scroll content...
|
# Scroll content...
|
||||||
self.display_manager.set_scrolling_state(False)
|
self.display_manager.set_scrolling_state(False)
|
||||||
```
|
```
|
||||||
@@ -944,3 +1066,23 @@ if "weather" in enabled_plugins:
|
|||||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete development guide
|
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete development guide
|
||||||
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
|
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deprecated APIs
|
||||||
|
|
||||||
|
These still work in 3.6 but log a warning the first time they are called
|
||||||
|
(`journalctl -u ledmatrix` shows which one), and are **removed in 3.7.0**.
|
||||||
|
Nothing in core, the official plugins or the third-party plugins in the
|
||||||
|
registry calls them.
|
||||||
|
|
||||||
|
| Object | Methods | Instead |
|
||||||
|
|---|---|---|
|
||||||
|
| `cache_manager` | `update_cache` | `set()` |
|
||||||
|
| `cache_manager` | `get_background_cached_data`, `is_background_data_available` | `get()` |
|
||||||
|
| `cache_manager` | `has_data_changed`, `setup_persistent_cache`, `get_sport_live_interval`, `get_sport_key_from_cache_key`, `record_cache_hit`, `record_cache_miss`, `record_fetch_time`, `get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats` | no replacement |
|
||||||
|
| `display_manager` | `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`, `draw_snow`, `draw_text_with_icons` | draw your own icons (the weather plugin ships `WeatherIcons`) |
|
||||||
|
| `display_manager` | `get_scrolling_stats` | no replacement |
|
||||||
|
| `font_manager` | `get_font_catalog`, `get_available_fonts` | read `font_catalog` |
|
||||||
|
| `font_manager` | `set_override`, `remove_override`, `get_overrides`, `add_font`, `remove_font`, `validate_font`, `get_size_tokens`, `get_performance_stats`, `get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`, `unregister_plugin_fonts` | no replacement |
|
||||||
|
| `plugin_manager` | `get_enabled_plugins` | check `enabled` on the entries in `plugin_manager.plugins` |
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,13 @@
|
|||||||
> - Code paths reference `web_interface_v2.py`; the current web UI is
|
> - Code paths reference `web_interface_v2.py`; the current web UI is
|
||||||
> `web_interface/app.py` with v3 Blueprint-based templates.
|
> `web_interface/app.py` with v3 Blueprint-based templates.
|
||||||
> - The example Flask routes use `/api/plugins/*`; the real API
|
> - The example Flask routes use `/api/plugins/*`; the real API
|
||||||
> blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
|
> blueprint (`web_interface/blueprints/api_v3/`) is mounted at `/api/v3`
|
||||||
|
> in `web_interface/app.py`.
|
||||||
> - The default plugin location is `plugin-repos/` (configurable via
|
> - The default plugin location is `plugin-repos/` (configurable via
|
||||||
> `plugin_system.plugins_directory`), not `./plugins/`.
|
> `plugin_system.plugins_directory`), not `./plugins/`.
|
||||||
|
> - Example imports use `src/plugin_system/base_classes/*_plugin.py`,
|
||||||
|
> which do not exist. The old `src/base_classes/` package has been
|
||||||
|
> removed; shared sports code lives in `src/common/`.
|
||||||
> - The "Migration Strategy" and "Implementation Roadmap" sections
|
> - The "Migration Strategy" and "Implementation Roadmap" sections
|
||||||
> describe work that has now shipped.
|
> describe work that has now shipped.
|
||||||
>
|
>
|
||||||
@@ -186,7 +190,9 @@ class BasePlugin(ABC):
|
|||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""
|
"""
|
||||||
Fetch/update data for this plugin.
|
Fetch/update data for this plugin.
|
||||||
Called based on update_interval in manifest.
|
Called every get_update_interval() seconds when that returns a
|
||||||
|
number, otherwise at the static interval: the manifest's
|
||||||
|
update_interval, else the plugin config's update_interval, else 60s.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -201,6 +207,21 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def get_update_interval(self) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Seconds until update() should run again, decided at runtime.
|
||||||
|
Return None (the default) to use the static interval.
|
||||||
|
|
||||||
|
PluginManager._get_plugin_update_interval calls this on every
|
||||||
|
scheduling tick. A number overrides the manifest and is clamped up
|
||||||
|
to PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL (5s); None, a raise,
|
||||||
|
or a non-finite/non-numeric value falls back to the manifest's
|
||||||
|
update_interval, then the plugin config's update_interval, then
|
||||||
|
60s. The static value is cached until the plugin reloads; the hook
|
||||||
|
is not cached, so it must be cheap and must not raise.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
def get_display_duration(self) -> float:
|
def get_display_duration(self) -> float:
|
||||||
"""
|
"""
|
||||||
Get the display duration for this plugin instance.
|
Get the display duration for this plugin instance.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ The LEDMatrix system uses a plugin-based architecture where each plugin manages
|
|||||||
1. **Install a plugin** from the Plugin Store in the web interface
|
1. **Install a plugin** from the Plugin Store in the web interface
|
||||||
2. **Navigate to the plugin's configuration tab** (automatically created when installed)
|
2. **Navigate to the plugin's configuration tab** (automatically created when installed)
|
||||||
3. **Configure settings** using the auto-generated form
|
3. **Configure settings** using the auto-generated form
|
||||||
4. **Save configuration** and restart the display service
|
4. **Save configuration**; the running display applies it without a restart
|
||||||
|
|
||||||
For detailed information, see the sections below.
|
For detailed information, see the sections below.
|
||||||
|
|
||||||
@@ -67,9 +67,7 @@ The main configuration file (`config/config.json`) now contains only essential s
|
|||||||
"time_format": "%I:%M %p"
|
"time_format": "%I:%M %p"
|
||||||
},
|
},
|
||||||
"plugin_system": {
|
"plugin_system": {
|
||||||
"plugins_directory": "plugin-repos",
|
"plugins_directory": "plugin-repos"
|
||||||
"auto_discover": true,
|
|
||||||
"auto_load_enabled": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -93,9 +91,9 @@ The main configuration file (`config/config.json`) now contains only essential s
|
|||||||
|
|
||||||
#### 4. Plugin System
|
#### 4. Plugin System
|
||||||
- **plugin_system**: Plugin system configuration
|
- **plugin_system**: Plugin system configuration
|
||||||
- **plugins_directory**: Directory where plugins are stored
|
- **plugins_directory**: Directory where plugins are stored (the only one the loader scans)
|
||||||
- **auto_discover**: Automatically discover plugins
|
- `auto_discover`, `auto_load_enabled`, `development_mode` may still appear in
|
||||||
- **auto_load_enabled**: Automatically load enabled plugins
|
older configs; nothing reads them (see [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#plugin_system))
|
||||||
|
|
||||||
## Plugin Configuration
|
## Plugin Configuration
|
||||||
|
|
||||||
@@ -191,19 +189,20 @@ plugin-repos/
|
|||||||
"author": "Your Name",
|
"author": "Your Name",
|
||||||
"entry_point": "manager.py",
|
"entry_point": "manager.py",
|
||||||
"class_name": "MyPlugin",
|
"class_name": "MyPlugin",
|
||||||
"display_modes": ["my_plugin"],
|
"display_modes": ["my_plugin"]
|
||||||
"config_schema": "config_schema.json"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The required fields the plugin loader will check for are `id`,
|
The Plugin Store refuses a manifest that lacks any of `id`, `name`,
|
||||||
`name`, `version`, `class_name`, and `display_modes`. `entry_point`
|
`class_name` or `display_modes` (`store_manager.py`); the loader itself
|
||||||
defaults to `manager.py` if omitted. `config_schema` must be a
|
needs `class_name`. `version` is not required, but the store compares it
|
||||||
**file path** (relative to the plugin directory) — the schema itself
|
with the registry's `latest_version` to offer updates, so set it.
|
||||||
lives in a separate JSON file, not inline in the manifest. The
|
`entry_point` defaults to `manager.py` if omitted. The config schema is not
|
||||||
`class_name` value must match the actual class defined in the entry
|
named in the manifest: it is always the file `config_schema.json` in the
|
||||||
point file **exactly** (case-sensitive, no spaces); otherwise the
|
plugin directory. The `class_name` value must match the actual class
|
||||||
loader fails with `AttributeError` at load time.
|
defined in the entry point file **exactly** (case-sensitive, no spaces);
|
||||||
|
otherwise the loader fails with a `PluginError` ("Class ... not found in
|
||||||
|
module") at load time.
|
||||||
|
|
||||||
### Plugin Manager Class
|
### Plugin Manager Class
|
||||||
|
|
||||||
@@ -225,9 +224,11 @@ class MyPlugin(BasePlugin):
|
|||||||
"""Render plugin content to the LED matrix."""
|
"""Render plugin content to the LED matrix."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_duration(self):
|
# BasePlugin.get_display_duration() already returns
|
||||||
"""Get display duration for this plugin"""
|
# self.config['display_duration'] (default 15s); override it only to
|
||||||
return self.config.get('duration', 30)
|
# vary the duration with the content.
|
||||||
|
def get_display_duration(self):
|
||||||
|
return self.config.get('display_duration', 30)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dynamic Duration Configuration
|
### Dynamic Duration Configuration
|
||||||
@@ -261,7 +262,7 @@ Each installed plugin automatically gets its own dedicated configuration tab in
|
|||||||
|
|
||||||
### Accessing Plugin Configuration
|
### Accessing Plugin Configuration
|
||||||
|
|
||||||
1. Navigate to the **Plugins** tab to see all installed plugins
|
1. Navigate to the **Plugin Manager** tab to see all installed plugins
|
||||||
2. Click the **Configure** button on any plugin card, or
|
2. Click the **Configure** button on any plugin card, or
|
||||||
3. Click directly on the plugin's tab button in the navigation bar
|
3. Click directly on the plugin's tab button in the navigation bar
|
||||||
|
|
||||||
@@ -280,7 +281,6 @@ Configuration forms are automatically generated from each plugin's `config_schem
|
|||||||
- **Type-safe inputs**: Form inputs match JSON Schema types
|
- **Type-safe inputs**: Form inputs match JSON Schema types
|
||||||
- **Default values**: Fields show current values or schema defaults
|
- **Default values**: Fields show current values or schema defaults
|
||||||
- **Real-time validation**: Input constraints enforced (min, max, maxLength, etc.)
|
- **Real-time validation**: Input constraints enforced (min, max, maxLength, etc.)
|
||||||
- **Reset to defaults**: One-click reset to restore original settings
|
|
||||||
- **Help text**: Each field shows description from schema
|
- **Help text**: Each field shows description from schema
|
||||||
|
|
||||||
For more details, see [Plugin Configuration Tabs](PLUGIN_CONFIGURATION_TABS.md).
|
For more details, see [Plugin Configuration Tabs](PLUGIN_CONFIGURATION_TABS.md).
|
||||||
@@ -339,20 +339,16 @@ The configuration system uses JSON Schema Draft-07 for validation:
|
|||||||
2. **Configuration errors**: Validate plugin configuration against schema
|
2. **Configuration errors**: Validate plugin configuration against schema
|
||||||
3. **Display issues**: Check display durations and plugin display methods
|
3. **Display issues**: Check display durations and plugin display methods
|
||||||
4. **Performance**: Monitor plugin update intervals and resource usage
|
4. **Performance**: Monitor plugin update intervals and resource usage
|
||||||
5. **Tab not showing**: Verify `config_schema.json` exists and is referenced in manifest
|
5. **Form missing or wrong**: Verify `config_schema.json` exists in the plugin directory and is valid JSON Schema
|
||||||
6. **Settings not saving**: Check validation errors and ensure all required fields are filled
|
6. **Settings not saving**: Check validation errors and ensure all required fields are filled
|
||||||
|
|
||||||
### Debug Mode
|
### Debug Mode
|
||||||
|
|
||||||
Enable debug logging to troubleshoot plugin issues:
|
There is no config key for debug logging. Run the display with debug
|
||||||
|
logging instead:
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
{
|
python3 run.py -d # or: LEDMATRIX_DEBUG=true python3 run.py
|
||||||
"plugin_system": {
|
|
||||||
"debug": true,
|
|
||||||
"log_level": "debug"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
# Plugin Configuration Tabs
|
# Plugin Configuration Tabs
|
||||||
|
|
||||||
> **Status note:** this doc was written during the rollout of the
|
|
||||||
> per-plugin configuration tab feature. The feature itself is shipped
|
|
||||||
> and working in the current v3 web interface, but a few file paths
|
|
||||||
> in the "Implementation Details" section below still reference the
|
|
||||||
> pre-v3 file layout (`web_interface_v2.py`, `templates/index_v2.html`).
|
|
||||||
> The current implementation lives in `web_interface/app.py`,
|
|
||||||
> `web_interface/blueprints/api_v3.py`, and `web_interface/templates/v3/`.
|
|
||||||
> The user-facing description (Overview, Features, Form Generation
|
|
||||||
> Process) is still accurate.
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Each installed plugin now gets its own dedicated configuration tab in the web interface. This provides a clean, organized way to configure plugins without cluttering the main Plugins management tab.
|
Each installed plugin now gets its own dedicated configuration tab in the web interface. This provides a clean, organized way to configure plugins without cluttering the **Plugin Manager** tab.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -20,24 +10,27 @@ Each installed plugin now gets its own dedicated configuration tab in the web in
|
|||||||
- **JSON Schema-Based Forms**: Configuration forms are automatically generated based on each plugin's `config_schema.json`
|
- **JSON Schema-Based Forms**: Configuration forms are automatically generated based on each plugin's `config_schema.json`
|
||||||
- **Type-Safe Inputs**: Form inputs are created based on the JSON Schema type (boolean, number, string, array, enum)
|
- **Type-Safe Inputs**: Form inputs are created based on the JSON Schema type (boolean, number, string, array, enum)
|
||||||
- **Default Values**: All fields show current values or fallback to schema defaults
|
- **Default Values**: All fields show current values or fallback to schema defaults
|
||||||
- **Reset Functionality**: Users can reset all settings to defaults with one click
|
|
||||||
- **Real-Time Validation**: Input constraints from JSON Schema are enforced (min, max, maxLength, etc.)
|
- **Real-Time Validation**: Input constraints from JSON Schema are enforced (min, max, maxLength, etc.)
|
||||||
|
|
||||||
## User Experience
|
## User Experience
|
||||||
|
|
||||||
### Accessing Plugin Configuration
|
### Accessing Plugin Configuration
|
||||||
|
|
||||||
1. Navigate to the **Plugins** tab to see all installed plugins
|
1. Navigate to the **Plugin Manager** tab to see all installed plugins
|
||||||
2. Click the **Configure** button on any plugin card
|
2. Click the **Configure** button on any plugin card
|
||||||
3. You'll be automatically taken to that plugin's configuration tab
|
3. You'll be automatically taken to that plugin's configuration tab
|
||||||
4. Alternatively, click directly on the plugin's tab button (marked with a puzzle piece icon)
|
4. Alternatively, click directly on the plugin's tab button in the second nav row
|
||||||
|
|
||||||
### Configuring a Plugin
|
### Configuring a Plugin
|
||||||
|
|
||||||
1. Open the plugin's configuration tab
|
1. Open the plugin's configuration tab
|
||||||
2. Modify settings using the generated form
|
2. Modify settings using the generated form
|
||||||
3. Click **Save Configuration**
|
3. Click **Save Configuration**. The settings apply to the running display
|
||||||
4. Restart the display service to apply changes
|
without a restart: the display service reloads `config.json` when it
|
||||||
|
changes and calls the plugin's `on_config_change()`
|
||||||
|
|
||||||
|
The tab also has **Refresh** (reload the form), **Update** (update the
|
||||||
|
plugin) and **Uninstall** buttons.
|
||||||
|
|
||||||
### Plugin Manager vs Per-Plugin Configuration
|
### Plugin Manager vs Per-Plugin Configuration
|
||||||
|
|
||||||
@@ -52,22 +45,13 @@ Each installed plugin now gets its own dedicated configuration tab in the web in
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
To enable automatic configuration tab generation, your plugin must:
|
Every installed plugin gets a tab. To get a generated form in it, include a
|
||||||
|
`config_schema.json` file in the plugin's directory. The name is fixed: the
|
||||||
|
web interface finds the schema by that file name (`SchemaManager` in
|
||||||
|
`src/plugin_system/schema_manager.py`), and no manifest field points to it.
|
||||||
|
|
||||||
1. Include a `config_schema.json` file
|
**Note:** You can optionally specify a Font Awesome `icon` class for your
|
||||||
2. Reference it in your `manifest.json`:
|
plugin tab in `manifest.json`. See [Plugin Custom Icons Guide](PLUGIN_CUSTOM_ICONS.md) for details.
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "your-plugin",
|
|
||||||
"name": "Your Plugin",
|
|
||||||
"icon": "fas fa-star", // Optional: Custom tab icon
|
|
||||||
...
|
|
||||||
"config_schema": "config_schema.json"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** You can optionally specify a custom `icon` for your plugin tab. See [Plugin Custom Icons Guide](PLUGIN_CUSTOM_ICONS.md) for details.
|
|
||||||
|
|
||||||
### Supported JSON Schema Types
|
### Supported JSON Schema Types
|
||||||
|
|
||||||
@@ -208,69 +192,32 @@ Renders as: Dropdown select
|
|||||||
|
|
||||||
### Form Generation Process
|
### Form Generation Process
|
||||||
|
|
||||||
1. Web UI loads installed plugins via `/api/v3/plugins/installed`
|
Forms are rendered on the server, not generated in the browser:
|
||||||
2. For each plugin, the backend loads its `config_schema.json`
|
|
||||||
3. Frontend generates a tab button with plugin name
|
|
||||||
4. Frontend generates a form based on the JSON Schema
|
|
||||||
5. Current config values from `config.json` are populated
|
|
||||||
6. When saved, each field is sent to `/api/v3/plugins/config` endpoint
|
|
||||||
|
|
||||||
## Implementation Details
|
1. The web UI loads installed plugins via `/api/v3/plugins/installed` and adds
|
||||||
|
a tab button for each one
|
||||||
### Backend Changes
|
2. Opening a tab loads `/v3/partials/plugin-config/<plugin_id>`
|
||||||
|
(`web_interface/blueprints/pages_v3.py`), which loads the plugin's schema
|
||||||
**File**: `web_interface_v2.py`
|
through `SchemaManager` and its current values from `config.json`
|
||||||
|
3. `web_interface/templates/v3/partials/plugin_config.html` renders the form
|
||||||
- Modified `/api/v3/plugins/installed` endpoint to include `config_schema_data`
|
from the schema (widgets named by `x-widget` are rendered by the scripts in
|
||||||
- Loads each plugin's `config_schema.json` if it exists
|
`web_interface/static/v3/js/widgets/`)
|
||||||
- Returns schema data along with plugin info
|
4. **Save Configuration** posts the form to `/api/v3/plugins/config`
|
||||||
|
(`web_interface/blueprints/api_v3/plugins.py`), which validates it against
|
||||||
### Frontend Changes
|
the schema, writes `config.json` (secret fields go to
|
||||||
|
`config_secrets.json`) and shows a notification
|
||||||
**File**: `templates/index_v2.html`
|
|
||||||
|
|
||||||
New Functions:
|
|
||||||
- `generatePluginTabs(plugins)` - Creates tab buttons and content for each plugin
|
|
||||||
- `generatePluginConfigForm(plugin)` - Generates HTML form from JSON Schema
|
|
||||||
- `savePluginConfiguration(pluginId)` - Saves form data to backend
|
|
||||||
- `resetPluginConfig(pluginId)` - Resets all settings to defaults
|
|
||||||
- `configurePlugin(pluginId)` - Navigates to plugin's tab
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Page Load
|
|
||||||
→ refreshPlugins()
|
|
||||||
→ /api/v3/plugins/installed
|
|
||||||
→ Returns plugins with config_schema_data
|
|
||||||
→ generatePluginTabs()
|
|
||||||
→ Creates tab buttons
|
|
||||||
→ Creates tab content
|
|
||||||
→ generatePluginConfigForm()
|
|
||||||
→ Reads JSON Schema
|
|
||||||
→ Creates form inputs
|
|
||||||
→ Populates current values
|
|
||||||
|
|
||||||
User Saves
|
|
||||||
→ savePluginConfiguration()
|
|
||||||
→ Reads form data
|
|
||||||
→ Converts types per schema
|
|
||||||
→ Sends to /api/v3/plugins/config
|
|
||||||
→ Updates config.json
|
|
||||||
→ Shows success notification
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Plugin Tab Not Appearing
|
### Plugin Tab Not Appearing
|
||||||
|
|
||||||
- Ensure `config_schema.json` exists in plugin directory
|
- Check that the plugin is installed and appears in the **Plugin Manager** tab
|
||||||
- Verify `config_schema` field in `manifest.json`
|
|
||||||
- Check browser console for errors
|
- Check browser console for errors
|
||||||
- Try refreshing plugins (Plugins tab → Refresh button)
|
- Reload the page
|
||||||
|
|
||||||
### Form Not Generating Correctly
|
### Form Not Generating Correctly
|
||||||
|
|
||||||
|
- Ensure `config_schema.json` exists in the plugin directory
|
||||||
- Validate your `config_schema.json` against JSON Schema Draft 07
|
- Validate your `config_schema.json` against JSON Schema Draft 07
|
||||||
- Check that all properties have a `type` field
|
- Check that all properties have a `type` field
|
||||||
- Ensure `default` values match the specified type
|
- Ensure `default` values match the specified type
|
||||||
@@ -282,7 +229,6 @@ User Saves
|
|||||||
- Check that config keys match schema properties
|
- Check that config keys match schema properties
|
||||||
- Verify backend API is accessible
|
- Verify backend API is accessible
|
||||||
- Check browser network tab for API errors
|
- Check browser network tab for API errors
|
||||||
- Ensure display service is restarted after config changes
|
|
||||||
|
|
||||||
## Migration Guide
|
## Migration Guide
|
||||||
|
|
||||||
@@ -300,26 +246,21 @@ If your plugin doesn't have a config schema:
|
|||||||
2. Add descriptions for each property
|
2. Add descriptions for each property
|
||||||
3. Set appropriate defaults
|
3. Set appropriate defaults
|
||||||
4. Add validation constraints (min, max, etc.)
|
4. Add validation constraints (min, max, etc.)
|
||||||
5. Reference the schema in your `manifest.json`
|
|
||||||
|
|
||||||
### Backward Compatibility
|
### Backward Compatibility
|
||||||
|
|
||||||
- Plugins without `config_schema.json` still work normally
|
- Plugins without `config_schema.json` still work normally
|
||||||
- They simply won't have a configuration tab
|
- Their tab shows plain text, number and checkbox inputs for the keys already
|
||||||
|
in their `config.json` section, or "No configuration options available for
|
||||||
|
this plugin." when there are none
|
||||||
- Users can still edit config via the Raw JSON editor
|
- Users can still edit config via the Raw JSON editor
|
||||||
- The Configure button will navigate to a tab with a friendly message
|
|
||||||
|
|
||||||
## Future Enhancements
|
## Beyond the Basic Types
|
||||||
|
|
||||||
Potential improvements for future versions:
|
Nested objects (rendered as collapsible sections), `x-widget` widgets such as
|
||||||
|
`color-picker` and `file-upload`, and more are supported; see
|
||||||
- **Advanced Schema Features**: Support for nested objects, conditional fields
|
[PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) and
|
||||||
- **Visual Validation**: Real-time validation feedback as user types
|
`web_interface/static/v3/js/widgets/README.md`.
|
||||||
- **Color Pickers**: Special input for RGB/color array types
|
|
||||||
- **File Uploads**: Support for image/asset uploads
|
|
||||||
- **Import/Export**: Save and share plugin configurations
|
|
||||||
- **Presets**: Quick-switch between saved configurations
|
|
||||||
- **Documentation Links**: Link schema fields to plugin documentation
|
|
||||||
|
|
||||||
## Example Plugins
|
## Example Plugins
|
||||||
|
|
||||||
|
|||||||
@@ -1,431 +1,189 @@
|
|||||||
# Plugin Configuration Tabs - Architecture
|
# Plugin Configuration Tabs - Architecture
|
||||||
|
|
||||||
|
> This page covers internals (how the config system works under the
|
||||||
|
> hood). For designing a plugin's config schema, the canonical guide is
|
||||||
|
> [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md); for
|
||||||
|
> the user-facing tabs feature, see
|
||||||
|
> [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md).
|
||||||
|
|
||||||
## System Architecture
|
## System Architecture
|
||||||
|
|
||||||
### Component Overview
|
### Component Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ Web Browser │
|
│ Web browser (templates/v3/base.html, Alpine.js + HTMX) │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Tab Navigation Bar │ │
|
|
||||||
│ │ [Overview] [General] ... [Plugins] [Plugin X] [Plugin Y]│ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────┐ ┌──────────────────────────────────┐ │
|
│ Second nav row: one tab per installed plugin │
|
||||||
│ │ Plugins Tab │ │ Plugin X Configuration Tab │ │
|
│ Clicking a tab: GET /v3/partials/plugin-config/<plugin_id> │
|
||||||
│ │ │ │ │ │
|
│ → server-rendered form swapped into the tab │
|
||||||
│ │ • Install │ │ Form Generated from Schema: │ │
|
│ │
|
||||||
│ │ • Update │ │ • Boolean → Toggle │ │
|
│ Save: hx-post="/api/v3/plugins/config?plugin_id=<id>" (form data) │
|
||||||
│ │ • Uninstall │ │ • Number → Number Input │ │
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
│ │ • Enable │ │ • String → Text Input │ │
|
|
||||||
│ │ • [Configure]──────→ • Array → Comma Input │ │
|
|
||||||
│ │ │ │ • Enum → Dropdown │ │
|
|
||||||
│ └─────────────────┘ │ │ │
|
|
||||||
│ │ [Save] [Back] [Reset] │ │
|
|
||||||
│ └──────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
│
|
||||||
│ HTTP API
|
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ Flask Backend │
|
│ Flask (web_interface/app.py) │
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ /api/v3/plugins/installed │ │
|
|
||||||
│ │ • Discover plugins in plugins/ directory │ │
|
|
||||||
│ │ • Load manifest.json for each plugin │ │
|
|
||||||
│ │ • Load config_schema.json if exists │ │
|
|
||||||
│ │ • Load current config from config.json │ │
|
|
||||||
│ │ • Return combined data to frontend │ │
|
|
||||||
│ └───────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
│ │
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
│ pages_v3 blueprint (blueprints/pages_v3.py) │
|
||||||
│ │ /api/v3/plugins/config │ │
|
│ _load_plugin_config_partial(plugin_id) │
|
||||||
│ │ • Receive key-value pair │ │
|
│ • SchemaManager.load_schema() → config_schema.json │
|
||||||
│ │ • Update config.json │ │
|
│ • config.json section for the plugin │
|
||||||
│ │ • Return success/error │ │
|
│ • masks x-secret fields │
|
||||||
│ └───────────────────────────────────────────────────────┘ │
|
│ • renders partials/plugin_config.html (render_field macros) │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
│ │
|
||||||
|
│ api_v3 blueprint (blueprints/api_v3/plugins.py) │
|
||||||
|
│ save_plugin_config() POST /api/v3/plugins/config │
|
||||||
|
│ get_plugin_config() GET /api/v3/plugins/config │
|
||||||
|
│ get_plugin_schema() GET /api/v3/plugins/schema │
|
||||||
|
│ reset_plugin_config() POST /api/v3/plugins/config/reset │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
│ File System
|
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ File System │
|
│ Files │
|
||||||
│ │
|
│ plugin-repos/<id>/config_schema.json JSON Schema (Draft-7) │
|
||||||
│ plugins/ │
|
│ config/config.json { "<id>": { ... } } │
|
||||||
│ ├── hello-world/ │
|
│ config/config_secrets.json { "<id>": { secrets } } │
|
||||||
│ │ ├── manifest.json ───┐ │
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
│ │ ├── config_schema.json ─┼─→ Defines UI structure │
|
|
||||||
│ │ ├── manager.py │ │
|
|
||||||
│ │ └── requirements.txt │ │
|
|
||||||
│ └── clock-simple/ │ │
|
|
||||||
│ ├── manifest.json │ │
|
|
||||||
│ └── config_schema.json ──┘ │
|
|
||||||
│ │
|
|
||||||
│ config/ │
|
|
||||||
│ └── config.json ────────────→ Stores configuration values │
|
|
||||||
│ { │
|
|
||||||
│ "hello-world": { │
|
|
||||||
│ "enabled": true, │
|
|
||||||
│ "message": "Hello!", │
|
|
||||||
│ ... │
|
|
||||||
│ } │
|
|
||||||
│ } │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The plugins directory is `plugin_system.plugins_directory` in
|
||||||
|
`config/config.json` (default `plugin-repos/`). Plugin configuration lives in
|
||||||
|
`config/config.json`, not in the plugin directory, so it survives reinstalls.
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
### 1. Page Load Sequence
|
### 1. Rendering a plugin's tab
|
||||||
|
|
||||||
```
|
```
|
||||||
User Opens Web Interface
|
User opens the plugin's tab
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
DOMContentLoaded Event
|
GET /v3/partials/plugin-config/<plugin_id> (pages_v3)
|
||||||
│
|
│
|
||||||
▼
|
├─→ Load schema (SchemaManager, no cache)
|
||||||
refreshPlugins()
|
├─→ Load config.json[<plugin_id>]
|
||||||
|
├─→ Mask "x-secret" values (fails closed if the schema is unusable)
|
||||||
|
└─→ render partials/plugin_config.html
|
||||||
│
|
│
|
||||||
▼
|
└─→ render_field() per property, recursively:
|
||||||
GET /api/v3/plugins/installed
|
boolean → toggle, number/integer → input or slider,
|
||||||
│
|
string → input / textarea / select (enum),
|
||||||
├─→ For each plugin directory:
|
array → list or table widget,
|
||||||
│ ├─→ Read manifest.json
|
object → collapsible nested section,
|
||||||
│ ├─→ Read config_schema.json (if exists)
|
"x-widget" → a registered widget
|
||||||
│ └─→ Read config from config.json
|
(static/v3/js/widgets/, or one the plugin ships)
|
||||||
│
|
|
||||||
▼
|
|
||||||
Return JSON Array:
|
|
||||||
[{
|
|
||||||
id: "hello-world",
|
|
||||||
name: "Hello World",
|
|
||||||
config: { enabled: true, message: "Hello!" },
|
|
||||||
config_schema_data: {
|
|
||||||
properties: {
|
|
||||||
enabled: { type: "boolean", ... },
|
|
||||||
message: { type: "string", ... }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, ...]
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
generatePluginTabs(plugins)
|
|
||||||
│
|
|
||||||
├─→ For each plugin:
|
|
||||||
│ ├─→ Create tab button
|
|
||||||
│ ├─→ Create tab content div
|
|
||||||
│ └─→ generatePluginConfigForm(plugin)
|
|
||||||
│ │
|
|
||||||
│ ├─→ Read schema properties
|
|
||||||
│ ├─→ Get current config values
|
|
||||||
│ └─→ Generate HTML form inputs
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Tabs Rendered in UI
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configuration Save Sequence
|
Nested objects are supported: a nested field is posted with a dotted name
|
||||||
|
(e.g. `transition.type`).
|
||||||
|
|
||||||
|
### 2. Saving
|
||||||
|
|
||||||
```
|
```
|
||||||
User Modifies Form
|
User clicks Save
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
User Clicks "Save"
|
validatePluginConfigForm() (client-side checks)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
savePluginConfiguration(pluginId)
|
POST /api/v3/plugins/config?plugin_id=<id> (form data, all fields of the form)
|
||||||
│
|
|
||||||
├─→ Get form data
|
|
||||||
├─→ For each field:
|
|
||||||
│ ├─→ Get schema type
|
|
||||||
│ ├─→ Convert value to correct type
|
|
||||||
│ │ • boolean: checkbox.checked
|
|
||||||
│ │ • integer: parseInt()
|
|
||||||
│ │ • number: parseFloat()
|
|
||||||
│ │ • array: split(',')
|
|
||||||
│ │ • string: as-is
|
|
||||||
│ │
|
|
||||||
│ └─→ POST /api/v3/plugins/config
|
|
||||||
│ {
|
|
||||||
│ plugin_id: "hello-world",
|
|
||||||
│ key: "message",
|
|
||||||
│ value: "Hello, World!"
|
|
||||||
│ }
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Backend Updates config.json
|
save_plugin_config() (api_v3/plugins.py)
|
||||||
|
├─→ Start from the stored config.json[<id>]
|
||||||
|
├─→ Apply form fields: dotted names → nested keys, "[]" checkbox
|
||||||
|
│ groups → lists, values coerced to the schema's types
|
||||||
|
├─→ Merge schema defaults for keys that are still missing
|
||||||
|
├─→ Validate against the schema (plus core per-plugin properties);
|
||||||
|
│ invalid → 400 with the validation errors, nothing saved
|
||||||
|
├─→ Split "x-secret" fields out; masked/blank secrets are dropped so
|
||||||
|
│ an untouched secret keeps its stored value
|
||||||
|
├─→ Deep-merge regular fields into config.json[<id>] (atomic save)
|
||||||
|
├─→ Merge secrets into config_secrets.json[<id>]
|
||||||
|
└─→ Call the loaded plugin's on_config_change() (and
|
||||||
|
on_enable/on_disable if "enabled" changed)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Return Success
|
One response for the whole form → notification in the UI
|
||||||
│
|
|
||||||
▼
|
|
||||||
Show Notification
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Refresh Plugins
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Class and Function Hierarchy
|
The display service picks up the new config through its config hot reload
|
||||||
|
(ConfigService) without a restart.
|
||||||
|
|
||||||
### Frontend (JavaScript)
|
JSON clients can post `{"plugin_id": ..., "config": {...}}` instead; the keys
|
||||||
|
sent are merged onto the stored config the same way. See
|
||||||
|
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#save-plugin-configuration).
|
||||||
|
|
||||||
```
|
### 3. Reset
|
||||||
Window Load
|
|
||||||
└── DOMContentLoaded
|
|
||||||
└── refreshPlugins()
|
|
||||||
├── fetch('/api/v3/plugins/installed')
|
|
||||||
├── renderInstalledPlugins(plugins)
|
|
||||||
└── generatePluginTabs(plugins)
|
|
||||||
└── For each plugin:
|
|
||||||
├── Create tab button
|
|
||||||
├── Create tab content
|
|
||||||
└── generatePluginConfigForm(plugin)
|
|
||||||
├── Read config_schema_data
|
|
||||||
├── Read current config
|
|
||||||
└── Generate form HTML
|
|
||||||
├── Boolean → Toggle switch
|
|
||||||
├── Number → Number input
|
|
||||||
├── String → Text input
|
|
||||||
├── Array → Comma-separated input
|
|
||||||
└── Enum → Select dropdown
|
|
||||||
|
|
||||||
User Interactions
|
`POST /api/v3/plugins/config/reset` replaces the plugin's section with the
|
||||||
├── configurePlugin(pluginId)
|
schema defaults (keeping secrets unless `preserve_secrets` is false).
|
||||||
│ └── showTab(`plugin-${pluginId}`)
|
|
||||||
│
|
|
||||||
├── savePluginConfiguration(pluginId)
|
|
||||||
│ ├── Process form data
|
|
||||||
│ ├── Convert types per schema
|
|
||||||
│ └── For each field:
|
|
||||||
│ └── POST /api/v3/plugins/config
|
|
||||||
│
|
|
||||||
└── resetPluginConfig(pluginId)
|
|
||||||
├── Get schema defaults
|
|
||||||
└── For each field:
|
|
||||||
└── POST /api/v3/plugins/config
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend (Python)
|
|
||||||
|
|
||||||
```
|
|
||||||
Flask Routes
|
|
||||||
├── /api/v3/plugins/installed (GET)
|
|
||||||
│ └── api_plugins_installed()
|
|
||||||
│ ├── PluginManager.discover_plugins()
|
|
||||||
│ ├── For each plugin:
|
|
||||||
│ │ ├── PluginManager.get_plugin_info()
|
|
||||||
│ │ ├── Load config_schema.json
|
|
||||||
│ │ └── Load config from config.json
|
|
||||||
│ └── Return JSON response
|
|
||||||
│
|
|
||||||
└── /api/v3/plugins/config (POST)
|
|
||||||
└── api_plugin_config()
|
|
||||||
├── Parse request JSON
|
|
||||||
├── Load current config
|
|
||||||
├── Update config[plugin_id][key] = value
|
|
||||||
└── Save config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
LEDMatrix/
|
|
||||||
│
|
|
||||||
├── web_interface_v2.py
|
|
||||||
│ └── Flask backend with plugin API endpoints
|
|
||||||
│
|
|
||||||
├── templates/
|
|
||||||
│ └── index_v2.html
|
|
||||||
│ └── Frontend with dynamic tab generation
|
|
||||||
│
|
|
||||||
├── config/
|
|
||||||
│ └── config.json
|
|
||||||
│ └── Stores all plugin configurations
|
|
||||||
│
|
|
||||||
├── plugins/
|
|
||||||
│ ├── hello-world/
|
|
||||||
│ │ ├── manifest.json ← Plugin metadata
|
|
||||||
│ │ ├── config_schema.json ← UI schema definition
|
|
||||||
│ │ ├── manager.py ← Plugin logic
|
|
||||||
│ │ └── requirements.txt
|
|
||||||
│ │
|
|
||||||
│ └── clock-simple/
|
|
||||||
│ ├── manifest.json
|
|
||||||
│ ├── config_schema.json
|
|
||||||
│ └── manager.py
|
|
||||||
│
|
|
||||||
└── docs/
|
|
||||||
├── PLUGIN_CONFIGURATION_TABS.md ← Full documentation
|
|
||||||
├── PLUGIN_CONFIG_TABS_SUMMARY.md ← Implementation summary
|
|
||||||
├── PLUGIN_CONFIG_QUICK_START.md ← Quick start guide
|
|
||||||
└── PLUGIN_CONFIG_ARCHITECTURE.md ← This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Design Decisions
|
## Key Design Decisions
|
||||||
|
|
||||||
### 1. Dynamic Tab Generation
|
### 1. Server-side rendered forms
|
||||||
|
|
||||||
**Why**: Plugins are installed/uninstalled dynamically
|
**Why**: One renderer for every plugin, no per-plugin frontend code
|
||||||
**How**: JavaScript creates/removes tab elements on plugin list refresh
|
**How**: Jinja macros in `partials/plugin_config.html` walk the schema
|
||||||
**Benefit**: No server-side template rendering needed
|
**Benefit**: The settings search index is built from the same rendered HTML
|
||||||
|
(`/v3/settings/search-index`)
|
||||||
|
|
||||||
### 2. JSON Schema as Source of Truth
|
### 2. JSON Schema as source of truth
|
||||||
|
|
||||||
**Why**: Standard, well-documented, validation-ready
|
**Why**: Standard, well-documented, validation-ready
|
||||||
**How**: Frontend interprets schema to generate forms
|
**How**: The same schema drives the form, the defaults and server-side validation
|
||||||
**Benefit**: Plugin developers use familiar format
|
**Benefit**: Plugin developers use a familiar format
|
||||||
|
|
||||||
### 3. Individual Config Updates
|
### 3. Whole-form saves that merge
|
||||||
|
|
||||||
**Why**: Simplifies backend API
|
**Why**: A partial form (or a field the form doesn't show) must not wipe
|
||||||
**How**: Each field saved separately via `/api/v3/plugins/config`
|
stored values
|
||||||
**Benefit**: Atomic updates, easier error handling
|
**How**: The handler starts from the stored section and merges what was posted
|
||||||
|
**Benefit**: One request per save, atomic write
|
||||||
|
|
||||||
### 4. Type Conversion in Frontend
|
### 4. Secrets kept out of config.json
|
||||||
|
|
||||||
**Why**: HTML forms only return strings
|
**Why**: `config.json` is shown in the raw editor and returned by the API
|
||||||
**How**: JavaScript converts based on schema type before sending
|
**How**: `"x-secret": true` fields go to `config_secrets.json`, which is
|
||||||
**Benefit**: Backend receives correctly-typed values
|
deep-merged back into the plugin's config at load time
|
||||||
|
**Benefit**: Plugins read secrets with plain `config.get(...)`
|
||||||
### 5. No Nested Objects
|
|
||||||
|
|
||||||
**Why**: Keeps UI simple
|
|
||||||
**How**: Only flat property structures supported
|
|
||||||
**Benefit**: Easy form generation, clear to users
|
|
||||||
|
|
||||||
## Extension Points
|
## Extension Points
|
||||||
|
|
||||||
### Adding New Input Types
|
### Custom input widgets
|
||||||
|
|
||||||
Location: `generatePluginConfigForm()` in `index_v2.html`
|
Set `"x-widget": "<name>"` on a property. Core widgets are in
|
||||||
|
`web_interface/static/v3/js/widgets/`; a plugin can ship its own widget
|
||||||
|
script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`. See the
|
||||||
|
[widget guide](../web_interface/static/v3/js/widgets/README.md).
|
||||||
|
|
||||||
```javascript
|
### Custom actions
|
||||||
if (type === 'your-new-type') {
|
|
||||||
formHTML += `
|
|
||||||
<!-- Your custom input HTML -->
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Validation
|
Buttons that run plugin scripts are declared in the manifest's
|
||||||
|
`web_ui_actions`. See [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md).
|
||||||
|
|
||||||
Location: `savePluginConfiguration()` in `index_v2.html`
|
### Reacting to changes
|
||||||
|
|
||||||
```javascript
|
Implement `on_config_change(new_config)` in the plugin (see
|
||||||
// Add validation before sending
|
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)).
|
||||||
if (!validateCustomConstraint(value, propSchema)) {
|
|
||||||
throw new Error('Validation failed');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend Hook
|
## Where to Look
|
||||||
|
|
||||||
Location: `api_plugin_config()` in `web_interface_v2.py`
|
| Concern | File |
|
||||||
|
|---------|------|
|
||||||
```python
|
| Tab partial loader | `web_interface/blueprints/pages_v3.py` (`_load_plugin_config_partial`) |
|
||||||
# Add custom logic before saving
|
| Form template and field macros | `web_interface/templates/v3/partials/plugin_config.html` |
|
||||||
if plugin_id == 'special-plugin':
|
| Save / get / schema / reset handlers | `web_interface/blueprints/api_v3/plugins.py` |
|
||||||
value = transform_value(value)
|
| Schema loading, defaults, validation | `src/plugin_system/schema_manager.py` |
|
||||||
```
|
| Secret masking and splitting | `src/web_interface/secret_helpers.py` |
|
||||||
|
| Widgets | `web_interface/static/v3/js/widgets/` |
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- **Tab Generation**: O(n) where n = number of plugins (typically < 20)
|
|
||||||
- **Form Generation**: O(m) where m = number of config properties (typically < 10)
|
|
||||||
- **Memory**: Each plugin tab ~5KB HTML
|
|
||||||
- **Total Impact**: Negligible for typical use cases
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- **Schema Loading**: Cached after first load
|
|
||||||
- **Config Updates**: Single file write (atomic)
|
|
||||||
- **API Calls**: One per config field on save (sequential)
|
|
||||||
- **Optimization**: Could batch updates in single API call
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
1. **Input Validation**: Schema constraints enforced client-side (UX) and should be enforced server-side
|
|
||||||
2. **Path Traversal**: Plugin paths validated against known plugin directory
|
|
||||||
3. **XSS**: All user inputs escaped before rendering in HTML
|
|
||||||
4. **CSRF**: Flask CSRF tokens should be used in production
|
|
||||||
5. **File Permissions**: config.json requires write access
|
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
### Frontend
|
- Unknown plugin or unreadable schema: the partial renders an error message
|
||||||
|
- Validation failure: `400` with `details` and `context.validation_errors`;
|
||||||
- Network errors: Show notification, don't crash
|
the form shows them and nothing is saved
|
||||||
- Schema errors: Graceful fallback to no config tab
|
- Save failure: `500` with an error message; config.json is written
|
||||||
- Type errors: Log to console, continue processing other fields
|
atomically, so a failed save leaves the previous file intact
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- Invalid plugin_id: 400 Bad Request
|
|
||||||
- Schema not found: Return null, frontend handles gracefully
|
|
||||||
- Config save error: 500 Internal Server Error with message
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
- `generatePluginConfigForm()` for each schema type
|
|
||||||
- Type conversion logic in `savePluginConfiguration()`
|
|
||||||
- Backend schema loading logic
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
- Full save flow: form → API → config.json
|
|
||||||
- Tab generation from API response
|
|
||||||
- Reset to defaults
|
|
||||||
|
|
||||||
### E2E Tests
|
|
||||||
|
|
||||||
- Install plugin → verify tab appears
|
|
||||||
- Configure plugin → verify config saved
|
|
||||||
- Uninstall plugin → verify tab removed
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Frontend Metrics
|
|
||||||
|
|
||||||
- Time to generate tabs
|
|
||||||
- Form submission success rate
|
|
||||||
- User interactions (configure, save, reset)
|
|
||||||
|
|
||||||
### Backend Metrics
|
|
||||||
|
|
||||||
- API response times
|
|
||||||
- Config update success rate
|
|
||||||
- Schema loading errors
|
|
||||||
|
|
||||||
### User Feedback
|
|
||||||
|
|
||||||
- Are users finding the configuration interface?
|
|
||||||
- Are validation errors clear?
|
|
||||||
- Are default values sensible?
|
|
||||||
|
|
||||||
## Future Roadmap
|
|
||||||
|
|
||||||
### Phase 2: Enhanced Validation
|
|
||||||
- Real-time validation feedback
|
|
||||||
- Custom error messages
|
|
||||||
- Dependent field validation
|
|
||||||
|
|
||||||
### Phase 3: Advanced Inputs
|
|
||||||
- Color pickers for RGB arrays
|
|
||||||
- File upload for assets
|
|
||||||
- Rich text editor for descriptions
|
|
||||||
|
|
||||||
### Phase 4: Configuration Management
|
|
||||||
- Export/import configurations
|
|
||||||
- Configuration presets
|
|
||||||
- Version history/rollback
|
|
||||||
|
|
||||||
### Phase 5: Developer Tools
|
|
||||||
- Schema editor in web UI
|
|
||||||
- Live preview while editing schema
|
|
||||||
- Validation tester
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ The LEDMatrix plugin system automatically manages certain core properties that a
|
|||||||
|
|
||||||
## Core Properties
|
## Core Properties
|
||||||
|
|
||||||
The following properties are automatically managed by the system:
|
The following properties are automatically managed by the system (the list
|
||||||
|
is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`):
|
||||||
|
|
||||||
1. **`enabled`** (boolean)
|
1. **`enabled`** (boolean)
|
||||||
- Default: `true`
|
- Default: `true`
|
||||||
@@ -24,6 +25,18 @@ The following properties are automatically managed by the system:
|
|||||||
- Description: Enable live priority takeover when plugin has live content
|
- Description: Enable live priority takeover when plugin has live content
|
||||||
- Used by DisplayController for priority scheduling
|
- Used by DisplayController for priority scheduling
|
||||||
|
|
||||||
|
4. **`vegas_width_pct`**, **`vegas_overflow`**, **`vegas_max_width_screens`**
|
||||||
|
(untyped; no default)
|
||||||
|
- Description: Vegas mode tuning for this plugin — card width as a
|
||||||
|
percentage of the panel, `"rotate"` or `"truncate"` on overflow, and the
|
||||||
|
widest the card may be in screens
|
||||||
|
- Read by `src/vegas_mode/plugin_adapter.py` and `BasePlugin`, which
|
||||||
|
validate the values themselves and ignore a bad one with a log line
|
||||||
|
|
||||||
|
`skin` and `skin_options` were core properties until the skin system was
|
||||||
|
removed. A plugin config saved with them still loads and saves; the keys are
|
||||||
|
dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`).
|
||||||
|
|
||||||
## How Core Properties Work
|
## How Core Properties Work
|
||||||
|
|
||||||
### Schema Validation
|
### Schema Validation
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
and click **Install**
|
and click **Install**
|
||||||
4. Notice a new tab appears in the second nav row with the plugin's name
|
4. Notice a new tab appears in the second nav row with the plugin's name
|
||||||
5. Click that tab to configure the plugin
|
5. Click that tab to configure the plugin
|
||||||
6. Modify settings and click **Save**
|
6. Modify settings and click **Save Configuration**. The running display
|
||||||
7. From **Overview**, click **Restart Display Service** to see changes
|
picks the change up by itself; no restart is needed
|
||||||
|
|
||||||
That's it! Each installed plugin automatically gets its own configuration tab.
|
That's it! Each installed plugin automatically gets its own configuration tab.
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ That's it! Each installed plugin automatically gets its own configuration tab.
|
|||||||
- ✅ Proper input types (toggles, numbers, dropdowns)
|
- ✅ Proper input types (toggles, numbers, dropdowns)
|
||||||
- ✅ Help text explaining each setting
|
- ✅ Help text explaining each setting
|
||||||
- ✅ Input validation (min/max, length, etc.)
|
- ✅ Input validation (min/max, length, etc.)
|
||||||
- ✅ One-click reset to defaults
|
|
||||||
|
|
||||||
## 📋 Example Walkthrough
|
## 📋 Example Walkthrough
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ Let's configure the "Hello World" plugin:
|
|||||||
After installing the plugin, you'll see a new tab:
|
After installing the plugin, you'll see a new tab:
|
||||||
|
|
||||||
```
|
```
|
||||||
[Overview] [General] [...] [Plugins] [Hello World] ← New tab!
|
[Plugin Manager] [Hello World] ← New tab! (second nav row)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Configure Settings
|
### Step 2: Configure Settings
|
||||||
@@ -70,15 +69,16 @@ Display Duration
|
|||||||
How long to display in seconds
|
How long to display in seconds
|
||||||
[10 ]
|
[10 ]
|
||||||
|
|
||||||
[Save Configuration] [Back] [Reset to Defaults]
|
[Refresh] [Update] [Uninstall] [Save Configuration]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 3: Save and Apply
|
### Step 3: Save and Apply
|
||||||
|
|
||||||
1. Modify any settings
|
1. Modify any settings
|
||||||
2. Click **Save Configuration**
|
2. Click **Save Configuration**
|
||||||
3. See confirmation: "Configuration saved for hello-world. Restart display to apply changes."
|
3. See the confirmation notification. Plugin settings apply live: the
|
||||||
4. Restart the display service
|
display service reloads `config.json` when it changes and passes the new
|
||||||
|
settings to the plugin's `on_config_change()`
|
||||||
|
|
||||||
## 🛠️ For Plugin Developers
|
## 🛠️ For Plugin Developers
|
||||||
|
|
||||||
@@ -105,19 +105,14 @@ Create `config_schema.json` in your plugin directory:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Reference it in `manifest.json`:
|
**Done!** The file name is fixed: the web interface looks for
|
||||||
|
`config_schema.json` in the plugin's directory; there is no manifest field
|
||||||
|
for it. Every installed plugin gets a tab; the schema is what turns it into a
|
||||||
|
form.
|
||||||
|
|
||||||
```json
|
**Bonus:** an `icon` field in `manifest.json` names a Font Awesome class for
|
||||||
{
|
the tab (`"icon": "fas fa-star"`). See
|
||||||
"id": "my-plugin",
|
[PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md).
|
||||||
"icon": "fas fa-star", // Optional: add a custom icon!
|
|
||||||
"config_schema": "config_schema.json"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Done!** Your plugin now has a configuration tab.
|
|
||||||
|
|
||||||
**Bonus:** Add an `icon` field for a custom tab icon! Use Font Awesome icons (`fas fa-star`), emoji (⭐), or custom images. See [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) for the full guide.
|
|
||||||
|
|
||||||
## 🎨 Supported Input Types
|
## 🎨 Supported Input Types
|
||||||
|
|
||||||
@@ -171,12 +166,10 @@ User enters: `255, 0, 0`
|
|||||||
|
|
||||||
### For Users
|
### For Users
|
||||||
|
|
||||||
1. **Reset Anytime**: Use "Reset to Defaults" to restore original settings
|
1. **Navigate Back**: Switch to the **Plugin Manager** tab to see the
|
||||||
2. **Navigate Back**: Switch to the **Plugin Manager** tab to see the
|
|
||||||
full list of installed plugins
|
full list of installed plugins
|
||||||
3. **Check Help Text**: Each field has a description explaining what it does
|
2. **Check Help Text**: Each field has a description explaining what it does
|
||||||
4. **Restart Required**: Remember to restart the display service from
|
3. **No Restart Needed**: Saved plugin settings apply to the running display
|
||||||
**Overview** after saving
|
|
||||||
|
|
||||||
### For Developers
|
### For Developers
|
||||||
|
|
||||||
@@ -189,18 +182,17 @@ User enters: `255, 0, 0`
|
|||||||
## 🔧 Troubleshooting
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
### Tab Not Showing
|
### Tab Not Showing
|
||||||
- Check that `config_schema.json` exists
|
- Check that the plugin is installed and listed under **Plugin Manager**
|
||||||
- Verify `config_schema` is in `manifest.json`
|
|
||||||
- Refresh the page
|
- Refresh the page
|
||||||
- Check browser console for errors
|
- Check browser console for errors
|
||||||
|
|
||||||
### Settings Not Saving
|
### Settings Not Saving
|
||||||
- Ensure plugin is properly installed
|
- Ensure plugin is properly installed
|
||||||
- Restart the display service after saving
|
|
||||||
- Check that all required fields are filled
|
- Check that all required fields are filled
|
||||||
- Look for validation errors in browser console
|
- Look for validation errors in browser console
|
||||||
|
|
||||||
### Form Looks Wrong
|
### Form Looks Wrong
|
||||||
|
- Check that `config_schema.json` is in the plugin's directory
|
||||||
- Validate your JSON Schema
|
- Validate your JSON Schema
|
||||||
- Check that types match your defaults
|
- Check that types match your defaults
|
||||||
- Ensure descriptions are strings
|
- Ensure descriptions are strings
|
||||||
|
|||||||
@@ -2,17 +2,28 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Plugins can specify custom icons that appear next to their name in the web interface tabs. This makes your plugin instantly recognizable and adds visual polish to the UI.
|
A plugin can name an icon for its tab in the web interface's second nav row
|
||||||
|
(next to **Plugin Manager**) with the `icon` field in `manifest.json`.
|
||||||
|
|
||||||
## Icon Types Supported
|
> **Status:** the tab code honors `icon`, but `GET /api/v3/plugins/installed`
|
||||||
|
> (`web_interface/blueprints/api_v3/plugins.py`) does not currently include
|
||||||
|
> the manifest's `icon` in its response, so every tab shows the default
|
||||||
|
> puzzle piece. Setting `icon` is harmless and will take effect once the API
|
||||||
|
> passes it through again.
|
||||||
|
|
||||||
The system supports three types of icons:
|
## Font Awesome classes only
|
||||||
|
|
||||||
### 1. Font Awesome Icons (Recommended)
|
`icon` is used verbatim as the CSS class of an `<i>` element
|
||||||
|
(`iconEl.className = plugin.icon || 'fas fa-puzzle-piece'` in
|
||||||
|
`web_interface/static/v3/js/app-shell.js` and the same fallback in
|
||||||
|
`app-early.js`). So it must be a Font Awesome class string. Emoji, image
|
||||||
|
paths and URLs are not supported: they would end up as a meaningless class
|
||||||
|
name and render nothing.
|
||||||
|
|
||||||
The web interface uses Font Awesome 6, giving you access to thousands of icons.
|
The web interface bundles Font Awesome Free 6
|
||||||
|
(`web_interface/static/v3/vendor/fontawesome/`), so any free `fas`, `far` or
|
||||||
|
`fab` icon works.
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "my-plugin",
|
"id": "my-plugin",
|
||||||
@@ -21,292 +32,33 @@ The web interface uses Font Awesome 6, giving you access to thousands of icons.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Common Font Awesome Icons:**
|
Some common choices:
|
||||||
- Clock: `fas fa-clock`
|
|
||||||
|
- Clock / calendar: `fas fa-clock`, `fas fa-calendar-alt`
|
||||||
- Weather: `fas fa-cloud-sun`, `fas fa-cloud-rain`
|
- Weather: `fas fa-cloud-sun`, `fas fa-cloud-rain`
|
||||||
- Calendar: `fas fa-calendar`, `fas fa-calendar-alt`
|
- Sports: `fas fa-football-ball`, `fas fa-basketball-ball`, `fas fa-trophy`
|
||||||
- Sports: `fas fa-football-ball`, `fas fa-basketball-ball`
|
|
||||||
- Music: `fas fa-music`, `fas fa-headphones`
|
- Music: `fas fa-music`, `fas fa-headphones`
|
||||||
- Finance: `fas fa-chart-line`, `fas fa-dollar-sign`
|
- Finance: `fas fa-chart-line`, `fas fa-dollar-sign`
|
||||||
- News: `fas fa-newspaper`, `fas fa-rss`
|
- News: `fas fa-newspaper`, `fas fa-rss`
|
||||||
- Settings: `fas fa-cog`, `fas fa-sliders-h`
|
- Games: `fas fa-gamepad`, `fas fa-dice`
|
||||||
- Timer: `fas fa-stopwatch`, `fas fa-hourglass`
|
|
||||||
- Alert: `fas fa-bell`, `fas fa-exclamation-triangle`
|
|
||||||
- Heart: `fas fa-heart`, `far fa-heart` (outline)
|
|
||||||
- Star: `fas fa-star`, `far fa-star` (outline)
|
|
||||||
- Image: `fas fa-image`, `fas fa-camera`
|
|
||||||
- Video: `fas fa-video`, `fas fa-film`
|
|
||||||
- Game: `fas fa-gamepad`, `fas fa-dice`
|
|
||||||
|
|
||||||
**Browse all icons:** [Font Awesome Icon Gallery](https://fontawesome.com/icons)
|
Browse the rest in the [Font Awesome gallery](https://fontawesome.com/icons)
|
||||||
|
(filter to Free, version 6).
|
||||||
|
|
||||||
### 2. Emoji Icons (Fun & Simple)
|
## Default
|
||||||
|
|
||||||
Use any emoji character for a colorful, fun icon.
|
With no `icon` (or an empty one) the tab shows `fas fa-puzzle-piece`.
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "hello-world",
|
|
||||||
"name": "Hello World",
|
|
||||||
"icon": "👋"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Popular Emojis:**
|
|
||||||
- Time: ⏰ 🕐 ⏱️ ⏲️
|
|
||||||
- Weather: ☀️ ⛅ 🌤️ 🌧️ ⛈️ 🌩️ ❄️
|
|
||||||
- Sports: ⚽ 🏀 🏈 ⚾ 🎾 🏐
|
|
||||||
- Music: 🎵 🎶 🎸 🎹 🎤
|
|
||||||
- Money: 💰 💵 💴 💶 💷
|
|
||||||
- Calendar: 📅 📆
|
|
||||||
- News: 📰 📻 📡
|
|
||||||
- Fun: 🎮 🎲 🎯 🎨 🎭
|
|
||||||
- Nature: 🌍 🌎 🌏 🌳 🌺 🌸
|
|
||||||
- Food: 🍕 🍔 🍟 🍦 ☕ 🍰
|
|
||||||
|
|
||||||
### 3. Custom Image URLs (Advanced)
|
|
||||||
|
|
||||||
Use a custom image file for ultimate branding.
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "my-plugin",
|
|
||||||
"name": "My Plugin",
|
|
||||||
"icon": "/plugins/my-plugin/icon.png"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- Image should be 16x16 to 32x32 pixels
|
|
||||||
- Supported formats: PNG, SVG, JPG, GIF
|
|
||||||
- Can be a relative path, absolute path, or external URL
|
|
||||||
- SVG recommended for best quality at any size
|
|
||||||
|
|
||||||
## How to Add an Icon
|
|
||||||
|
|
||||||
### Step 1: Choose Your Icon
|
|
||||||
|
|
||||||
Decide which type suits your plugin:
|
|
||||||
- **Font Awesome**: Professional, consistent with UI
|
|
||||||
- **Emoji**: Fun, colorful, no setup needed
|
|
||||||
- **Custom Image**: Unique branding, requires image file
|
|
||||||
|
|
||||||
### Step 2: Add to manifest.json
|
|
||||||
|
|
||||||
Add the `icon` field to your plugin's `manifest.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "my-weather-plugin",
|
|
||||||
"name": "Weather Display",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "Your Name",
|
|
||||||
"description": "Shows weather information",
|
|
||||||
"icon": "fas fa-cloud-sun", // ← Add this line
|
|
||||||
"entry_point": "manager.py",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Test Your Plugin
|
|
||||||
|
|
||||||
1. Install or update your plugin
|
|
||||||
2. Open the web interface
|
|
||||||
3. Look for your plugin's tab
|
|
||||||
4. The icon should appear next to the plugin name
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Weather Plugin
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "weather-advanced",
|
|
||||||
"name": "Weather Advanced",
|
|
||||||
"icon": "fas fa-cloud-sun",
|
|
||||||
"description": "Advanced weather display with forecasts"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Result:** Tab shows: `☁️ Weather Advanced`
|
|
||||||
|
|
||||||
### Clock Plugin
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "digital-clock",
|
|
||||||
"name": "Digital Clock",
|
|
||||||
"icon": "⏰",
|
|
||||||
"description": "A beautiful digital clock"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Result:** Tab shows: `⏰ Digital Clock`
|
|
||||||
|
|
||||||
### Sports Scores Plugin
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "sports-scores",
|
|
||||||
"name": "Sports Scores",
|
|
||||||
"icon": "fas fa-trophy",
|
|
||||||
"description": "Live sports scores"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Result:** Tab shows: `🏆 Sports Scores`
|
|
||||||
|
|
||||||
### Custom Branding
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "company-dashboard",
|
|
||||||
"name": "Company Dashboard",
|
|
||||||
"icon": "/plugins/company-dashboard/logo.svg",
|
|
||||||
"description": "Company metrics display"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Result:** Tab shows: `[logo] Company Dashboard`
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### 1. Choose Meaningful Icons
|
|
||||||
- Icon should relate to plugin functionality
|
|
||||||
- Users should understand what the plugin does at a glance
|
|
||||||
- Avoid generic icons for specific functionality
|
|
||||||
|
|
||||||
### 2. Keep It Simple
|
|
||||||
- Simpler icons work better at small sizes
|
|
||||||
- Avoid icons with too much detail
|
|
||||||
- Test how your icon looks at 16x16 pixels
|
|
||||||
|
|
||||||
### 3. Match the UI Style
|
|
||||||
- Font Awesome icons match the interface best
|
|
||||||
- If using emoji, consider contrast with background
|
|
||||||
- Custom images should use similar color schemes
|
|
||||||
|
|
||||||
### 4. Consider Accessibility
|
|
||||||
- Icons should be recognizable without color
|
|
||||||
- Don't rely solely on color to convey meaning
|
|
||||||
- The plugin name should be descriptive
|
|
||||||
|
|
||||||
### 5. Test on Different Displays
|
|
||||||
- Check icon clarity on various screen sizes
|
|
||||||
- Ensure emoji render correctly on target devices
|
|
||||||
- Custom images should have good contrast
|
|
||||||
|
|
||||||
## Icon Categories
|
|
||||||
|
|
||||||
Here are recommended icons by plugin category:
|
|
||||||
|
|
||||||
### Time & Calendar
|
|
||||||
- `fas fa-clock`, `fas fa-calendar`, `fas fa-hourglass`
|
|
||||||
- Emoji: ⏰ 📅 ⏱️
|
|
||||||
|
|
||||||
### Weather
|
|
||||||
- `fas fa-cloud-sun`, `fas fa-temperature-high`, `fas fa-wind`
|
|
||||||
- Emoji: ☀️ 🌧️ ⛈️
|
|
||||||
|
|
||||||
### Finance & Stocks
|
|
||||||
- `fas fa-chart-line`, `fas fa-dollar-sign`, `fas fa-coins`
|
|
||||||
- Emoji: 💰 📈 💵
|
|
||||||
|
|
||||||
### Sports & Games
|
|
||||||
- `fas fa-football-ball`, `fas fa-trophy`, `fas fa-gamepad`
|
|
||||||
- Emoji: ⚽ 🏀 🎮
|
|
||||||
|
|
||||||
### Entertainment
|
|
||||||
- `fas fa-music`, `fas fa-film`, `fas fa-tv`
|
|
||||||
- Emoji: 🎵 🎬 📺
|
|
||||||
|
|
||||||
### News & Information
|
|
||||||
- `fas fa-newspaper`, `fas fa-rss`, `fas fa-info-circle`
|
|
||||||
- Emoji: 📰 📡 ℹ️
|
|
||||||
|
|
||||||
### Utilities
|
|
||||||
- `fas fa-tools`, `fas fa-cog`, `fas fa-wrench`
|
|
||||||
- Emoji: 🔧 ⚙️ 🛠️
|
|
||||||
|
|
||||||
### Social Media
|
|
||||||
- `fab fa-twitter`, `fab fa-facebook`, `fab fa-instagram`
|
|
||||||
- Emoji: 📱 💬 📧
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Icon Not Showing
|
1. Check the class name against the Font Awesome 6 Free gallery; a Pro-only
|
||||||
1. Check that the `icon` field is correctly spelled in `manifest.json`
|
or misspelled class renders as a blank space.
|
||||||
2. For Font Awesome icons, verify the class name is correct
|
2. Include the style prefix (`fas`, `far` or `fab`) as well as the icon
|
||||||
3. For custom images, check that the file path is accessible
|
class.
|
||||||
4. Refresh the plugins in the web interface
|
3. See the status note above: the icon is currently not passed through by
|
||||||
5. Check browser console for errors
|
the API.
|
||||||
|
|
||||||
### Emoji Looks Wrong
|
|
||||||
- Some emojis render differently on different platforms
|
|
||||||
- Try a different emoji if one doesn't work well
|
|
||||||
- Consider using Font Awesome instead for consistency
|
|
||||||
|
|
||||||
### Custom Image Not Loading
|
|
||||||
- Verify the image file exists in the specified path
|
|
||||||
- Check file permissions (should be readable)
|
|
||||||
- Try using an absolute path or URL
|
|
||||||
- Ensure image format is supported (PNG, SVG, JPG, GIF)
|
|
||||||
- Check image dimensions (16x16 to 32x32 recommended)
|
|
||||||
|
|
||||||
### Icon Too Large/Small
|
|
||||||
- Font Awesome and emoji icons automatically size correctly
|
|
||||||
- For custom images, adjust the image file dimensions
|
|
||||||
- SVG images scale best
|
|
||||||
|
|
||||||
## Default Behavior
|
|
||||||
|
|
||||||
If you don't specify an `icon` field in your manifest:
|
|
||||||
- The plugin tab will show a default puzzle piece icon: 🧩
|
|
||||||
- This is the fallback for all plugins without custom icons
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
The icon system works as follows:
|
|
||||||
|
|
||||||
1. **Frontend reads manifest**: When plugins load, the web interface reads each plugin's `manifest.json`
|
|
||||||
2. **Icon detection**: The `getPluginIcon()` function determines icon type:
|
|
||||||
- Contains `fa-` → Font Awesome icon
|
|
||||||
- 1-4 characters → Emoji
|
|
||||||
- Starts with `http://`, `https://`, or `/` → Custom image
|
|
||||||
- Otherwise → Default puzzle piece
|
|
||||||
3. **Rendering**: Icon HTML is generated and inserted into:
|
|
||||||
- Tab button in navigation bar
|
|
||||||
- Configuration page header
|
|
||||||
|
|
||||||
## Advanced: Dynamic Icons
|
|
||||||
|
|
||||||
Want to change icons programmatically? While not officially supported, you could:
|
|
||||||
|
|
||||||
1. Store multiple icon options in your manifest
|
|
||||||
2. Use JavaScript to swap icons based on plugin state
|
|
||||||
3. Update the manifest dynamically and refresh plugins
|
|
||||||
|
|
||||||
**Example (advanced):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "status-display",
|
|
||||||
"icon": "fas fa-circle",
|
|
||||||
"icon_states": {
|
|
||||||
"active": "fas fa-check-circle",
|
|
||||||
"error": "fas fa-exclamation-circle",
|
|
||||||
"warning": "fas fa-exclamation-triangle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Plugin Configuration Tabs](PLUGIN_CONFIGURATION_TABS.md) - Main plugin tabs documentation
|
- [Plugin Configuration Tabs](PLUGIN_CONFIGURATION_TABS.md)
|
||||||
- [Plugin Development Guide](plugin_docs/) - How to create plugins
|
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||||
- [Font Awesome Icons](https://fontawesome.com/icons) - Browse all available icons
|
|
||||||
- [Emoji Reference](https://unicode.org/emoji/charts/full-emoji-list.html) - All emoji options
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Adding a custom icon to your plugin:
|
|
||||||
|
|
||||||
1. **Choose** your icon (Font Awesome, emoji, or custom image)
|
|
||||||
2. **Add** the `icon` field to `manifest.json`
|
|
||||||
3. **Test** in the web interface
|
|
||||||
|
|
||||||
That's it! Your plugin now has a professional, recognizable icon in the UI. 🎨
|
|
||||||
|
|
||||||
|
|||||||