mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-19 02:58:37 +00:00
Compare commits
33 Commits
v3.0.0
...
963c4d3b91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
963c4d3b91 | ||
|
|
22c495ea7c | ||
|
|
5b0ad5ab71 | ||
|
|
bc8568604a | ||
|
|
878f339fb3 | ||
|
|
51616f1bc4 | ||
|
|
82370a0253 | ||
|
|
3975940cff | ||
|
|
158e07c82b | ||
|
|
9a72adbde1 | ||
|
|
9d3bc55c18 | ||
|
|
df3cf9bb56 | ||
|
|
448a15c1e6 | ||
|
|
b99be88cec | ||
|
|
4a9fc2df3a | ||
|
|
d207e7c6dd | ||
|
|
7e98fa9bd8 | ||
|
|
0d5510d8f7 | ||
|
|
18fecd3cda | ||
|
|
1c3269c0f3 | ||
|
|
ea61331d46 | ||
|
|
8fb2800495 | ||
|
|
8912501604 | ||
|
|
68c4259370 | ||
|
|
7f5c7399fb | ||
|
|
14c50f316e | ||
|
|
ddd300a117 | ||
|
|
7524747e44 | ||
|
|
10d70d911a | ||
|
|
a8c85dd015 | ||
|
|
0203c5c1b5 | ||
|
|
384ed096ff | ||
|
|
f9de9fa29e |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -40,3 +40,7 @@ htmlcov/
|
||||
# See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details
|
||||
plugins/*
|
||||
!plugins/.gitkeep
|
||||
|
||||
# Binary files and backups
|
||||
bin/pixlet/
|
||||
config/backups/
|
||||
|
||||
63
.gitmodules
vendored
63
.gitmodules
vendored
@@ -1,66 +1,3 @@
|
||||
[submodule "plugins/odds-ticker"]
|
||||
path = plugins/odds-ticker
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-odds-ticker.git
|
||||
[submodule "plugins/clock-simple"]
|
||||
path = plugins/clock-simple
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-clock-simple.git
|
||||
[submodule "plugins/text-display"]
|
||||
path = plugins/text-display
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-text-display.git
|
||||
[submodule "rpi-rgb-led-matrix-master"]
|
||||
path = rpi-rgb-led-matrix-master
|
||||
url = https://github.com/hzeller/rpi-rgb-led-matrix.git
|
||||
[submodule "plugins/basketball-scoreboard"]
|
||||
path = plugins/basketball-scoreboard
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-basketball-scoreboard.git
|
||||
[submodule "plugins/soccer-scoreboard"]
|
||||
path = plugins/soccer-scoreboard
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-soccer-scoreboard.git
|
||||
[submodule "plugins/calendar"]
|
||||
path = plugins/calendar
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-calendar.git
|
||||
[submodule "plugins/mqtt-notifications"]
|
||||
path = plugins/mqtt-notifications
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-mqtt-notifications.git
|
||||
[submodule "plugins/olympics-countdown"]
|
||||
path = plugins/olympics-countdown
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-olympics-countdown.git
|
||||
[submodule "plugins/ledmatrix-stocks"]
|
||||
path = plugins/ledmatrix-stocks
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-stocks.git
|
||||
[submodule "plugins/ledmatrix-music"]
|
||||
path = plugins/ledmatrix-music
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-music.git
|
||||
[submodule "plugins/static-image"]
|
||||
path = plugins/static-image
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-static-image.git
|
||||
[submodule "plugins/football-scoreboard"]
|
||||
path = plugins/football-scoreboard
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-football-scoreboard.git
|
||||
[submodule "plugins/hockey-scoreboard"]
|
||||
path = plugins/hockey-scoreboard
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-hockey-scoreboard.git
|
||||
[submodule "plugins/baseball-scoreboard"]
|
||||
path = plugins/baseball-scoreboard
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-baseball-scoreboard.git
|
||||
[submodule "plugins/christmas-countdown"]
|
||||
path = plugins/christmas-countdown
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-christmas-countdown.git
|
||||
[submodule "plugins/ledmatrix-flights"]
|
||||
path = plugins/ledmatrix-flights
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-flights.git
|
||||
[submodule "plugins/ledmatrix-leaderboard"]
|
||||
path = plugins/ledmatrix-leaderboard
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-leaderboard.git
|
||||
[submodule "plugins/ledmatrix-weather"]
|
||||
path = plugins/ledmatrix-weather
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-weather.git
|
||||
[submodule "plugins/ledmatrix-news"]
|
||||
path = plugins/ledmatrix-news
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-news.git
|
||||
[submodule "plugins/ledmatrix-of-the-day"]
|
||||
path = plugins/ledmatrix-of-the-day
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-of-the-day.git
|
||||
[submodule "plugins/youtube-stats"]
|
||||
path = plugins/youtube-stats
|
||||
url = https://github.com/ChuckBuilds/ledmatrix-youtube-stats.git
|
||||
|
||||
47
.pre-commit-config.yaml
Normal file
47
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# Pre-commit hooks for LEDMatrix
|
||||
# Install: pip install pre-commit && pre-commit install
|
||||
# Run manually: pre-commit run --all-files
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1000']
|
||||
- id: check-merge-conflict
|
||||
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ['--select=E9,F63,F7,F82,B', '--ignore=E501']
|
||||
additional_dependencies: [flake8-bugbear]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: no-bare-except
|
||||
name: Check for bare except clauses
|
||||
entry: bash -c 'if grep -rn "except:\s*pass" src/; then echo "Found bare except:pass - please handle exceptions properly"; exit 1; fi'
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
- id: no-hardcoded-paths
|
||||
name: Check for hardcoded user paths
|
||||
entry: bash -c 'if grep -rn "/home/chuck/" src/; then echo "Found hardcoded user paths - please use relative paths or config"; exit 1; fi'
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.8.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-requests, types-pytz]
|
||||
args: [--ignore-missing-imports, --no-error-summary]
|
||||
pass_filenames: false
|
||||
files: ^src/
|
||||
31
CLAUDE.md
Normal file
31
CLAUDE.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# LEDMatrix
|
||||
|
||||
## Project Structure
|
||||
- `src/plugin_system/` — Plugin loader, manager, store manager, base plugin class
|
||||
- `web_interface/` — Flask web UI (blueprints, templates, static JS)
|
||||
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
||||
- `plugins/` — Installed plugins directory (gitignored)
|
||||
- `plugin-repos/` — Development symlinks to monorepo plugin dirs
|
||||
|
||||
## Plugin System
|
||||
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
||||
- Required abstract methods: `update()`, `display(force_clear=False)`
|
||||
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
|
||||
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
||||
- Config schemas use JSON Schema Draft-7
|
||||
- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height`
|
||||
|
||||
## Plugin Store Architecture
|
||||
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
||||
- 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`
|
||||
- Store manager (`src/plugin_system/store_manager.py`) handles install/update/uninstall
|
||||
- Monorepo plugins are installed via ZIP extraction (no `.git` directory)
|
||||
- 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
|
||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||
|
||||
## Common Pitfalls
|
||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
||||
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||
- 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
|
||||
@@ -4,89 +4,9 @@
|
||||
"path": ".",
|
||||
"name": "LEDMatrix (Main)"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-odds-ticker",
|
||||
"name": "Odds Ticker"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-clock-simple",
|
||||
"name": "Clock Simple"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-text-display",
|
||||
"name": "Text Display"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-basketball-scoreboard",
|
||||
"name": "Basketball Scoreboard"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-soccer-scoreboard",
|
||||
"name": "Soccer Scoreboard"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-calendar",
|
||||
"name": "Calendar"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-olympics-countdown",
|
||||
"name": "Olympics Countdown"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-stocks",
|
||||
"name": "Stocks"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-music",
|
||||
"name": "Music"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-static-image",
|
||||
"name": "Static Image"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-football-scoreboard",
|
||||
"name": "Football Scoreboard"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-hockey-scoreboard",
|
||||
"name": "Hockey Scoreboard"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-baseball-scoreboard",
|
||||
"name": "Baseball Scoreboard"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-christmas-countdown",
|
||||
"name": "Christmas Countdown"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-flights",
|
||||
"name": "Flights"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-leaderboard",
|
||||
"name": "Leaderboard"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-weather",
|
||||
"name": "Weather"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-news",
|
||||
"name": "News"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-of-the-day",
|
||||
"name": "Of The Day"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-youtube-stats",
|
||||
"name": "YouTube Stats"
|
||||
},
|
||||
{
|
||||
"path": "../ledmatrix-plugins",
|
||||
"name": "Plugin Registry"
|
||||
"name": "Plugins (Monorepo)"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
@@ -14,8 +14,12 @@ I'm very new to all of this and am *heavily* relying on AI development tools to
|
||||
I'm trying to be open to constructive criticism and support, as long as it's a realistic ask and aligns with my priorities on this project. If you have ideas for improvements, find bugs, or want to add features to the base project, please don't hesitate to reach out on Discord or submit a pull request. Similarly, if you want to develop a plugin of your own, please do so! I'd love to see what you create.
|
||||
|
||||
|
||||
### Installing the LEDMatrix project on a pi video:
|
||||
[](https://www.youtube.com/watch?v=bkT0f1tZI0Y)
|
||||
|
||||
### Setup video and feature walkthrough on Youtube (Outdated but still useful) :
|
||||
[](https://www.youtube.com/watch?v=_HaqfJy1Y54)
|
||||
[](https://www.youtube.com/watch?v=_HaqfJy1Y54)
|
||||
|
||||
|
||||
-----------------------------------------------------------------------------------
|
||||
### Connect with ChuckBuilds
|
||||
@@ -23,7 +27,7 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
|
||||
- Show support on Youtube: https://www.youtube.com/@ChuckBuilds
|
||||
- Check out the write-up on my website: https://www.chuck-builds.com/led-matrix/
|
||||
- Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/
|
||||
- Want to chat? Reach out on the ChuckBuilds Discord: https://discord.com/invite/uW36dVAtcT
|
||||
- Want to chat? Reach out on the LEDMatrix Discord: [https://discord.com/invite/uW36dVAtcT](https://discord.gg/dfFwsasa6W)
|
||||
- Feeling Generous? Consider sponsoring this project or sending a donation (these AI credits aren't cheap!)
|
||||
|
||||
-----------------------------------------------------------------------------------
|
||||
|
||||
140567
assets/fonts/10x20.bdf
Normal file
140567
assets/fonts/10x20.bdf
Normal file
File diff suppressed because it is too large
Load Diff
31042
assets/fonts/6x10.bdf
Normal file
31042
assets/fonts/6x10.bdf
Normal file
File diff suppressed because it is too large
Load Diff
86121
assets/fonts/6x12.bdf
Normal file
86121
assets/fonts/6x12.bdf
Normal file
File diff suppressed because it is too large
Load Diff
82452
assets/fonts/6x13.bdf
Normal file
82452
assets/fonts/6x13.bdf
Normal file
File diff suppressed because it is too large
Load Diff
25672
assets/fonts/6x13B.bdf
Normal file
25672
assets/fonts/6x13B.bdf
Normal file
File diff suppressed because it is too large
Load Diff
15432
assets/fonts/6x13O.bdf
Normal file
15432
assets/fonts/6x13O.bdf
Normal file
File diff suppressed because it is too large
Load Diff
64553
assets/fonts/7x13.bdf
Normal file
64553
assets/fonts/7x13.bdf
Normal file
File diff suppressed because it is too large
Load Diff
20093
assets/fonts/7x13B.bdf
Normal file
20093
assets/fonts/7x13B.bdf
Normal file
File diff suppressed because it is too large
Load Diff
16653
assets/fonts/7x13O.bdf
Normal file
16653
assets/fonts/7x13O.bdf
Normal file
File diff suppressed because it is too large
Load Diff
54128
assets/fonts/7x14.bdf
Normal file
54128
assets/fonts/7x14.bdf
Normal file
File diff suppressed because it is too large
Load Diff
21221
assets/fonts/7x14B.bdf
Normal file
21221
assets/fonts/7x14B.bdf
Normal file
File diff suppressed because it is too large
Load Diff
74092
assets/fonts/8x13.bdf
Normal file
74092
assets/fonts/8x13.bdf
Normal file
File diff suppressed because it is too large
Load Diff
22852
assets/fonts/8x13B.bdf
Normal file
22852
assets/fonts/8x13B.bdf
Normal file
File diff suppressed because it is too large
Load Diff
25932
assets/fonts/8x13O.bdf
Normal file
25932
assets/fonts/8x13O.bdf
Normal file
File diff suppressed because it is too large
Load Diff
105126
assets/fonts/9x15.bdf
Normal file
105126
assets/fonts/9x15.bdf
Normal file
File diff suppressed because it is too large
Load Diff
37168
assets/fonts/9x15B.bdf
Normal file
37168
assets/fonts/9x15B.bdf
Normal file
File diff suppressed because it is too large
Load Diff
119182
assets/fonts/9x18.bdf
Normal file
119182
assets/fonts/9x18.bdf
Normal file
File diff suppressed because it is too large
Load Diff
19082
assets/fonts/9x18B.bdf
Normal file
19082
assets/fonts/9x18B.bdf
Normal file
File diff suppressed because it is too large
Load Diff
42
assets/fonts/AUTHORS
Normal file
42
assets/fonts/AUTHORS
Normal file
@@ -0,0 +1,42 @@
|
||||
The identity of the designer(s) of the original ASCII repertoire and
|
||||
the later Latin-1 extension of the misc-fixed BDF fonts appears to
|
||||
have been lost in history. (It is likely that many of these 7-bit
|
||||
ASCII fonts were created in the early or mid 1980s as part of MIT's
|
||||
Project Athena, or at its industrial partner, DEC.)
|
||||
|
||||
In 1997, Markus Kuhn at the University of Cambridge Computer
|
||||
Laboratory initiated and headed a project to extend the misc-fixed BDF
|
||||
fonts to as large a subset of Unicode/ISO 10646 as is feasible for
|
||||
each of the available font sizes, as part of a wider effort to
|
||||
encourage users of POSIX systems to migrate from ISO 8859 to UTF-8.
|
||||
|
||||
Robert Brady <rwb197@ecs.soton.ac.uk> and Birger Langkjer
|
||||
<birger.langkjer@image.dk> contributed thousands of glyphs and made
|
||||
very substantial contributions and improvements on almost all fonts.
|
||||
Constantine Stathopoulos <cstath@irismedia.gr> contributed all the
|
||||
Greek characters. Markus Kuhn <http://www.cl.cam.ac.uk/~mgk25/> did
|
||||
most 6x13 glyphs and the italic fonts and provided many more glyphs,
|
||||
coordination, and quality assurance for the other fonts. Mark Leisher
|
||||
<mleisher@crl.nmsu.edu> contributed to 6x13 Armenian, Georgian, the
|
||||
first version of Latin Extended Block A and some Cyrillic. Serge V.
|
||||
Vakulenko <vak@crox.net.kiae.su> donated the original Cyrillic glyphs
|
||||
from his 6x13 ISO 8859-5 font. Nozomi Ytow <nozomi@biol.tsukuba.ac.jp>
|
||||
contributed 6x13 halfwidth Katakana. Henning Brunzel
|
||||
<hbrunzel@meta-systems.de> contributed glyphs to 10x20.bdf. Theppitak
|
||||
Karoonboonyanan <thep@linux.thai.net> contributed Thai for 7x13,
|
||||
7x13B, 7x13O, 7x14, 7x14B, 8x13, 8x13B, 8x13O, 9x15, 9x15B, and 10x20.
|
||||
Karl Koehler <koehler@or.uni-bonn.de> contributed Arabic to 9x15,
|
||||
9x15B, and 10x20 and Roozbeh Pournader <roozbeh@sharif.ac.ir> and
|
||||
Behdad Esfahbod revised and extended Arabic in 10x20. Raphael Finkel
|
||||
<raphael@cs.uky.edu> revised Hebrew/Yiddish in 10x20. Jungshik Shin
|
||||
<jshin@pantheon.yale.edu> prepared 18x18ko.bdf. Won-kyu Park
|
||||
<wkpark@chem.skku.ac.kr> prepared the Hangul glyphs used in 12x13ja.
|
||||
Janne V. Kujala <jvk@iki.fi> contributed 4x6. Daniel Yacob
|
||||
<perl@geez.org> revised some Ethiopic glyphs. Ted Zlatanov
|
||||
<tzz@lifelogs.com> did some 7x14. Mikael Öhman <micketeer@gmail.com>
|
||||
worked on 6x12.
|
||||
|
||||
The fonts are still maintained by Markus Kuhn and the original
|
||||
distribution can be found at:
|
||||
|
||||
http://www.cl.cam.ac.uk/~mgk25/ucs-fonts.html
|
||||
369
assets/fonts/README
Normal file
369
assets/fonts/README
Normal file
@@ -0,0 +1,369 @@
|
||||
|
||||
Unicode versions of the X11 "misc-fixed-*" fonts
|
||||
------------------------------------------------
|
||||
|
||||
Markus Kuhn <http://www.cl.cam.ac.uk/~mgk25/> -- 2008-04-21
|
||||
|
||||
|
||||
This package contains the X Window System bitmap fonts
|
||||
|
||||
-Misc-Fixed-*-*-*--*-*-*-*-C-*-ISO10646-1
|
||||
|
||||
These are Unicode (ISO 10646-1) extensions of the classic ISO 8859-1
|
||||
X11 terminal fonts that are widely used with many X11 applications
|
||||
such as xterm, emacs, etc.
|
||||
|
||||
COVERAGE
|
||||
--------
|
||||
|
||||
None of these fonts covers Unicode completely. Complete coverage
|
||||
simply would not make much sense here. Unicode 5.1 contains over
|
||||
100000 characters, and the large majority of them are
|
||||
Chinese/Japanese/Korean Han ideographs (~70000) and Korean Hangul
|
||||
Syllables (~11000) that cannot adequately be displayed in the small
|
||||
pixel sizes of the fixed fonts. Similarly, Arabic characters are
|
||||
difficult to fit nicely together with European characters into the
|
||||
fixed character cells and X11 lacks the ligature substitution
|
||||
mechanisms required for using Indic scripts.
|
||||
|
||||
Therefore these fonts primarily attempt to cover Unicode subsets that
|
||||
fit together with European scripts. This includes the Latin, Greek,
|
||||
Cyrillic, Armenian, Georgian, and Hebrew scripts, plus a lot of
|
||||
linguistic, technical and mathematical symbols. Some of the fixed
|
||||
fonts now also cover Arabic, Thai, Ethiopian, halfwidth Katakana, and
|
||||
some other non-European scripts.
|
||||
|
||||
We have defined 3 different target character repertoires (ISO 10646-1
|
||||
subsets) that the various fonts were checked against for minimal
|
||||
guaranteed coverage:
|
||||
|
||||
TARGET1 617 characters
|
||||
Covers all characters of ISO 8859 part 1-5,7-10,13-16,
|
||||
CEN MES-1, ISO 6937, Microsoft CP1251/CP1252, DEC VT100
|
||||
graphics symbols, and the replacement and default
|
||||
character. It is intended for small bold, italic, and
|
||||
proportional fonts, for which adding block graphics
|
||||
characters would make little sense. This repertoire
|
||||
covers the following ISO 10646-1:2000 collections
|
||||
completely: 1-3, 8, 12.
|
||||
|
||||
TARGET2 886 characters
|
||||
Adds to TARGET1 the characters of the Adobe/Microsoft
|
||||
Windows Glyph List 4 (WGL4), plus a selected set of
|
||||
mathematical characters (covering most of ISO 31-11
|
||||
high-school level math symbols) and some combining
|
||||
characters. It is intended to be covered by all normal
|
||||
"fixed" fonts and covers all European IBM, Microsoft, and
|
||||
Macintosh character sets. This repertoire covers the
|
||||
following ISO 10646-1:2000 (including Amd 1:2002)
|
||||
collections completely: 1-3, 8, 12, 33, 45.
|
||||
|
||||
TARGET3 3282 characters
|
||||
|
||||
Adds to TARGET2 all characters of all European scripts
|
||||
(Latin, Greek, Cyrillic, Armenian, Georgian), all
|
||||
phonetic alphabet symbols, many mathematical symbols
|
||||
(including all those available in LaTeX), all typographic
|
||||
punctuation, all box-drawing characters, control code
|
||||
pictures, graphical shapes and some more that you would
|
||||
expect in a very comprehensive Unicode 4.0 font for
|
||||
European users. It is intended for some of the more
|
||||
useful and more widely used normal "fixed" fonts. This
|
||||
repertoire is, with two exceptions, a superset of all
|
||||
graphical characters in CEN MES-3A and covers the
|
||||
following ISO 10646-1:2000 (including Amd 1:2002)
|
||||
collections completely: 1-12, 27, 30-31, 32 (only
|
||||
graphical characters), 33-42, 44-47, 63, 65, 70 (only
|
||||
graphical characters).
|
||||
|
||||
[The two MES-3A characters deliberately omitted are the
|
||||
angle bracket characters U+2329 and U+232A. ISO and CEN
|
||||
appears to have included these into collection 40 and
|
||||
MES-3A by accident, because there they are the only
|
||||
characters in the Unicode EastAsianWidth "wide" class.]
|
||||
|
||||
CURRENT STATUS:
|
||||
|
||||
6x13.bdf 8x13.bdf 9x15.bdf 9x18.bdf 10x20.bdf:
|
||||
|
||||
Complete (TARGET3 reached and checked)
|
||||
|
||||
5x7.bdf 5x8.bdf 6x9.bdf 6x10.bdf 6x12.bdf 7x13.bdf 7x14.bdf clR6x12.bdf:
|
||||
|
||||
Complete (TARGET2 reached and checked)
|
||||
|
||||
6x13B.bdf 7x13B.bdf 7x14B.bdf 8x13B.bdf 9x15B.bdf 9x18B.bdf:
|
||||
|
||||
Complete (TARGET1 reached and checked)
|
||||
|
||||
6x13O.bdf 7x13O.bdf 8x13O.bdf
|
||||
|
||||
Complete (TARGET1 minus Hebrew and block graphics)
|
||||
|
||||
[None of the above fonts contains any character that has in Unicode
|
||||
the East Asian Width Property "W" or "F" assigned. This way, the
|
||||
desired combination of "half-width" and "full-width" glyphs can be
|
||||
achieved easily. Most font mechanisms display a character that is not
|
||||
covered in a font by using a glyph from another font that appears
|
||||
later in a priority list, which can be arranged to be a "full-width"
|
||||
font.]
|
||||
|
||||
The supplement package
|
||||
|
||||
http://www.cl.cam.ac.uk/~mgk25/download/ucs-fonts-asian.tar.gz
|
||||
|
||||
contains the following additional square fonts with Han characters for
|
||||
East Asian users:
|
||||
|
||||
12x13ja.bdf:
|
||||
|
||||
Covers TARGET2, JIS X 0208, Hangul, and a few more. This font is
|
||||
primarily intended to provide Japanese full-width Hiragana,
|
||||
Katakana, and Kanji for applications that take the remaining
|
||||
("halfwidth") characters from 6x13.bdf. The Greek lowercase
|
||||
characters in it are still a bit ugly and will need some work.
|
||||
|
||||
18x18ja.bdf:
|
||||
|
||||
Covers all JIS X 0208, JIS X 0212, GB 2312-80, KS X 1001:1992,
|
||||
ISO 8859-1,2,3,4,5,7,9,10,15, CP437, CP850 and CP1252 characters,
|
||||
plus a few more, where priority was given to Japanese han style
|
||||
variants. This font should have everything needed to cover the
|
||||
full ISO-2022-JP-2 (RFC 1554) repertoire. This font is primarily
|
||||
intended to provide Japanese full-width Hiragana, Katakana, and
|
||||
Kanji for applications that take the remaining ("halfwidth")
|
||||
characters from 9x18.bdf.
|
||||
|
||||
18x18ko.bdf:
|
||||
|
||||
Covers the same repertoire as 18x18ja plus full coverage of all
|
||||
Hangul syllables and priority was given to Hanja glyphs in the
|
||||
unified CJK area as they are used for writing Korean.
|
||||
|
||||
The 9x18 and 6x12 fonts are recommended for use with overstriking
|
||||
combining characters.
|
||||
|
||||
Bug reports, suggestions for improvement, and especially contributed
|
||||
extensions are very welcome!
|
||||
|
||||
INSTALLATION
|
||||
------------
|
||||
|
||||
You install the fonts under Unix roughly like this (details depending
|
||||
on your system of course):
|
||||
|
||||
System-wide installation (root access required):
|
||||
|
||||
cd submission/
|
||||
make
|
||||
su
|
||||
mv -b *.pcf.gz /usr/lib/X11/fonts/misc/
|
||||
cd /usr/lib/X11/fonts/misc/
|
||||
mkfontdir
|
||||
xset fp rehash
|
||||
|
||||
Alternative: Installation in your private user directory:
|
||||
|
||||
cd submission/
|
||||
make
|
||||
mkdir -p ~/local/lib/X11/fonts/
|
||||
mv *.pcf.gz ~/local/lib/X11/fonts/
|
||||
cd ~/local/lib/X11/fonts/
|
||||
mkfontdir
|
||||
xset +fp ~/local/lib/X11/fonts (put this last line also in ~/.xinitrc)
|
||||
|
||||
Now you can have a look at say the 6x13 font with the command
|
||||
|
||||
xfd -fn '-misc-fixed-medium-r-semicondensed--13-120-75-75-c-60-iso10646-1'
|
||||
|
||||
If you want to have short names for the Unicode fonts, you can also
|
||||
append the fonts.alias file to that in the directory where you install
|
||||
the fonts, call "mkfontdir" and "xset fp rehash" again, and then you
|
||||
can also write
|
||||
|
||||
xfd -fn 6x13U
|
||||
|
||||
Note: If you use an old version of xfontsel, you might notice that it
|
||||
treats every font that contains characters >0x00ff as a Japanese JIS
|
||||
font and therefore selects inappropriate sample characters for display
|
||||
of ISO 10646-1 fonts. An updated xfontsel version with this bug fixed
|
||||
comes with XFree86 4.0 / X11R6.8 or newer.
|
||||
|
||||
If you use the Exceed X server on Microsoft Windows, then you will
|
||||
have to convert the BDF files into Microsoft FON files using the
|
||||
"Compile Fonts" function of Exceed xconfig. See the file exceed.txt
|
||||
for more information.
|
||||
|
||||
There is one significant efficiency problem that X11R6 has with the
|
||||
sparsely populated ISO10646-1 fonts. X11 transmits and allocates 12
|
||||
bytes with the XFontStruct data structure for the difference between
|
||||
the lowest and the highest code value found in a font, no matter
|
||||
whether the code positions in between are used for characters or not.
|
||||
Even a tiny font that contains only two glyphs at positions 0x0000 and
|
||||
0xfffd causes 12 bytes * 65534 codes = 786 kbytes to be requested and
|
||||
stored by the client. Since all the ISO10646-1 BDF files provided in
|
||||
this package contain characters in the U+00xx (ASCII) and U+ffxx
|
||||
(ligatures, etc.) range, all of them would result in 786 kbyte large
|
||||
XCharStruct arrays in the per_char array of the corresponding
|
||||
XFontStruct (even for CharCell fonts!) when loaded by an X client.
|
||||
Until this problem is fixed by extending the X11 font protocol and
|
||||
implementation, non-CJK ISO10646-1 fonts that lack the (anyway not
|
||||
very interesting) characters above U+31FF seem to be the best
|
||||
compromise. The bdftruncate.pl program in this package can be used to
|
||||
deactivate any glyphs above a threshold code value in BDF files. This
|
||||
way, we get relatively memory-economic ISO10646-1 fonts that cause
|
||||
"only" 150 kbyte large XCharStruct arrays to be allocated. The
|
||||
deactivated glyphs are still present in the BDF files, but with an
|
||||
encoding value of -1 that causes them to be ignored.
|
||||
|
||||
The ISO10646-1 fonts can not only be used directly by Unicode aware
|
||||
software, they can also be used to create any 8-bit font. The
|
||||
ucs2any.pl Perl script converts a ISO10646-1 BDF font into a BDF font
|
||||
file with some different encoding. For instance the command
|
||||
|
||||
perl ucs2any.pl 6x13.bdf MAPPINGS/8859-7.TXT ISO8859-7
|
||||
|
||||
will generate the file 6x13-ISO8859-7.bdf according to the 8859-7.TXT
|
||||
Latin/Greek mapping table, which available from
|
||||
<ftp://ftp.unicode.org/Public/MAPPINGS/>. [The shell script
|
||||
./map_fonts automatically generates a subdirectory derived-fonts/ with
|
||||
many *.bdf and *.pcf.gz 8-bit versions of all the
|
||||
-misc-fixed-*-iso10646-1 fonts.]
|
||||
|
||||
When you do a "make" in the submission/ subdirectory as suggested in
|
||||
the installation instructions above, this will generate exactly the
|
||||
set of fonts that have been submitted to the XFree86 project for
|
||||
inclusion into XFree86 4.0. These consists of all the ISO10646-1 fonts
|
||||
processed with "bdftruncate.pl U+3200" plus a selected set of derived
|
||||
8-bit fonts generated with ucs2any.pl.
|
||||
|
||||
Every font comes with a *.repertoire-utf8 file that lists all the
|
||||
characters in this font.
|
||||
|
||||
|
||||
CONTRIBUTING
|
||||
------------
|
||||
|
||||
If you want to help me in extending or improving the fonts, or if you
|
||||
want to start your own ISO 10646-1 font project, you will have to edit
|
||||
BDF font files. This is most comfortably done with the gbdfed font
|
||||
editor (version 1.3 or higher), which is available from
|
||||
|
||||
http://crl.nmsu.edu/~mleisher/gbdfed.html
|
||||
|
||||
Once you are familiar with gbdfed, you will notice that it is no
|
||||
problem to design up to 100 nice characters per hour (even more if
|
||||
only placing accents is involved).
|
||||
|
||||
Information about other X11 font tools and Unicode fonts for X11 in
|
||||
general can be found on
|
||||
|
||||
http://www.cl.cam.ac.uk/~mgk25/ucs-fonts.html
|
||||
|
||||
The latest version of this package is available from
|
||||
|
||||
http://www.cl.cam.ac.uk/~mgk25/download/ucs-fonts.tar.gz
|
||||
|
||||
If you want to contribute, then get the very latest version of this
|
||||
package, check which glyphs are still missing or inappropriate for
|
||||
your needs, and send me whatever you had the time to add and fix. Just
|
||||
email me the extended BDF-files back, or even better, send me a patch
|
||||
file of what you changed. The best way of preparing a patch file is
|
||||
|
||||
./touch_id newfile.bdf
|
||||
diff -d -u -F STARTCHAR oldfile.bdf newfile.bdf >file.diff
|
||||
|
||||
which ensures that the patch file preserves information about which
|
||||
exact version you worked on and what character each "hunk" changes.
|
||||
|
||||
I will try to update this packet on a daily basis. By sending me
|
||||
extensions to these fonts, you agree that the resulting improved font
|
||||
files will remain in the public domain for everyone's free use. Always
|
||||
make sure to load the very latest version of the package immediately
|
||||
before your start, and send me your results as soon as you are done,
|
||||
in order to avoid revision overlaps with other contributors.
|
||||
|
||||
Please try to be careful with the glyphs you generate:
|
||||
|
||||
- Always look first at existing similar characters in order to
|
||||
preserve a consistent look and feel for the entire font and
|
||||
within the font family. For block graphics characters and geometric
|
||||
symbols, take care of correct alignment.
|
||||
|
||||
- Read issues.txt, which contains some design hints for certain
|
||||
characters.
|
||||
|
||||
- All characters of CharCell (C) fonts must strictly fit into
|
||||
the pixel matrix and absolutely no out-of-box ink is allowed.
|
||||
|
||||
- The character cells will be displayed directly next to each other,
|
||||
without any additional pixels in between. Therefore, always make
|
||||
sure that at least the rightmost pixel column remains white, as
|
||||
otherwise letters will stick together, except of course for
|
||||
characters -- like Arabic or block graphics -- that are supposed to
|
||||
stick together.
|
||||
|
||||
- Place accents as low as possible on the Latin characters.
|
||||
|
||||
- Try to keep the shape of accents consistent among each other and
|
||||
with the combining characters in the U+03xx range.
|
||||
|
||||
- Use gbdfed only to edit the BDF file directly and do not import
|
||||
the font that you want to edit from the X server. Use gbdfed 1.3
|
||||
or higher.
|
||||
|
||||
- The glyph names should be the Adobe names for Unicode characters
|
||||
defined at
|
||||
|
||||
http://www.adobe.com/devnet/opentype/archives/glyph.html
|
||||
|
||||
which gbdfed can set automatically. To make the Edit/Rename Glyphs/
|
||||
Adobe Names function work, you have to download the file
|
||||
|
||||
http://www.adobe.com/devnet/opentype/archives/glyphlist.txt
|
||||
|
||||
and configure its location either in Edit/Preferences/Editing Options/
|
||||
Adobe Glyph List, or as "adobe_name_file" in "~/.gbdfed".
|
||||
|
||||
- Be careful to not change the FONTBOUNDINGBOX box accidentally in
|
||||
a patch.
|
||||
|
||||
You should have a copy of the ISO 10646 standard
|
||||
|
||||
ISO/IEC 10646:2003, Information technology -- Universal
|
||||
Multiple-Octet Coded Character Set (UCS),
|
||||
International Organization for Standardization, Geneva, 2003.
|
||||
http://standards.iso.org/ittf/PubliclyAvailableStandards/
|
||||
|
||||
and/or the Unicode 5.0 book:
|
||||
|
||||
The Unicode Consortium: The Unicode Standard, Version 5.0,
|
||||
Reading, MA, Addison-Wesley, 2006,
|
||||
ISBN 9780321480910.
|
||||
http://www.amazon.com/exec/obidos/ASIN/0321480910/mgk25
|
||||
|
||||
All these fonts are from time to time resubmitted to the X.Org
|
||||
project, XFree86 (they have been in there since XFree86 4.0), and to
|
||||
other X server developers for inclusion into their normal X11
|
||||
distributions.
|
||||
|
||||
Starting with XFree86 4.0, xterm has included UTF-8 support. This
|
||||
version is also available from
|
||||
|
||||
http://dickey.his.com/xterm/xterm.html
|
||||
|
||||
Please make the developer of your favourite software aware of the
|
||||
UTF-8 definition in RFC 2279 and of the existence of this font
|
||||
collection. For more information on how to use UTF-8, please check out
|
||||
|
||||
http://www.cl.cam.ac.uk/~mgk25/unicode.html
|
||||
ftp://ftp.ilog.fr/pub/Users/haible/utf8/Unicode-HOWTO.html
|
||||
|
||||
where you will also find information on joining the
|
||||
linux-utf8@nl.linux.org mailing list.
|
||||
|
||||
A number of UTF-8 example text files can be found in the examples/
|
||||
subdirectory or on
|
||||
|
||||
http://www.cl.cam.ac.uk/~mgk25/ucs/examples/
|
||||
|
||||
72
assets/fonts/README.md
Normal file
72
assets/fonts/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
## Provided fonts
|
||||
These are BDF fonts, a simple bitmap font-format that can be created
|
||||
by many font tools. Given that these are bitmap fonts, they will look good on
|
||||
very low resolution screens such as the LED displays.
|
||||
|
||||
Fonts in this directory (except tom-thumb.bdf) are public domain (see the [README](./README)) and
|
||||
help you to get started with the font support in the API or the `text-util`
|
||||
from the utils/ directory.
|
||||
|
||||
Tom-Thumb.bdf is included in this directory under [MIT license](http://vt100.tarunz.org/LICENSE). Tom-thumb.bdf was created by [@robey](http://twitter.com/robey) and originally published at https://robey.lag.net/2010/01/23/tiny-monospace-font.html
|
||||
|
||||
The texgyre-27.bdf font was created using the [otf2bdf] tool from the TeX Gyre font.
|
||||
```bash
|
||||
otf2bdf -v -o texgyre-27.bdf -r 72 -p 27 texgyreadventor-regular.otf
|
||||
```
|
||||
|
||||
## Create your own
|
||||
|
||||
Fonts are in a human-readable and editable `*.bdf` format, but unless you
|
||||
like reading and writing pixels in hex, generating them is probably easier :)
|
||||
|
||||
You can use any font-editor to generate a BDF font or use the conversion
|
||||
tool [otf2bdf] to create one from some other font format.
|
||||
|
||||
Here is an example how you could create a 30-pixel high BDF font from some
|
||||
TrueType font:
|
||||
|
||||
```bash
|
||||
otf2bdf -v -o myfont.bdf -r 72 -p 30 /path/to/font-Bold.ttf
|
||||
```
|
||||
|
||||
## Getting otf2bdf
|
||||
|
||||
Installing the tool should be fairly straightforward.
|
||||
|
||||
```bash
|
||||
sudo apt-get install otf2bdf
|
||||
```
|
||||
|
||||
## Compiling otf2bdf
|
||||
|
||||
If you like to compile otf2bdf, you might notice that the configure script
|
||||
uses some old way of getting the freetype configuration. There does not seem
|
||||
to be much activity on the mature code, so let's patch that first:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y libfreetype6-dev pkg-config autoconf
|
||||
git clone https://github.com/jirutka/otf2bdf.git # check it out
|
||||
cd otf2bdf
|
||||
patch -p1 <<"EOF"
|
||||
--- a/configure.in
|
||||
+++ b/configure.in
|
||||
@@ -5,8 +5,8 @@ AC_INIT(otf2bdf.c)
|
||||
AC_PROG_CC
|
||||
|
||||
OLDLIBS=$LIBS
|
||||
-LIBS="$LIBS `freetype-config --libs`"
|
||||
-CPPFLAGS="$CPPFLAGS `freetype-config --cflags`"
|
||||
+LIBS="$LIBS `pkg-config freetype2 --libs`"
|
||||
+CPPFLAGS="$CPPFLAGS `pkg-config freetype2 --cflags`"
|
||||
AC_CHECK_LIB(freetype, FT_Init_FreeType, LIBS="$LIBS -lfreetype",[
|
||||
AC_MSG_ERROR([Can't find Freetype library! Compile FreeType first.])])
|
||||
AC_SUBST(LIBS)
|
||||
EOF
|
||||
|
||||
autoconf # rebuild configure script
|
||||
./configure # run configure
|
||||
make # build the software
|
||||
sudo make install # install it
|
||||
```
|
||||
|
||||
[otf2bdf]: https://github.com/jirutka/otf2bdf
|
||||
22736
assets/fonts/clR6x12.bdf
Normal file
22736
assets/fonts/clR6x12.bdf
Normal file
File diff suppressed because it is too large
Load Diff
32869
assets/fonts/helvR12.bdf
Normal file
32869
assets/fonts/helvR12.bdf
Normal file
File diff suppressed because it is too large
Load Diff
30577
assets/fonts/texgyre-27.bdf
Normal file
30577
assets/fonts/texgyre-27.bdf
Normal file
File diff suppressed because it is too large
Load Diff
2365
assets/fonts/tom-thumb.bdf
Normal file
2365
assets/fonts/tom-thumb.bdf
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dim_schedule": {
|
||||
"enabled": false,
|
||||
"dim_brightness": 30,
|
||||
"mode": "global",
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00",
|
||||
"days": {
|
||||
"monday": {
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"tuesday": {
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"wednesday": {
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"thursday": {
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"friday": {
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"saturday": {
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"sunday": {
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timezone": "America/Chicago",
|
||||
"location": {
|
||||
"city": "Dallas",
|
||||
@@ -64,15 +108,23 @@
|
||||
"disable_hardware_pulsing": false,
|
||||
"inverse_colors": false,
|
||||
"show_refresh_rate": false,
|
||||
"led_rgb_sequence": "RGB",
|
||||
"limit_refresh_rate_hz": 100
|
||||
},
|
||||
"runtime": {
|
||||
"gpio_slowdown": 3
|
||||
},
|
||||
"display_durations": {
|
||||
"calendar": 30
|
||||
},
|
||||
"use_short_date_format": true
|
||||
"display_durations": {},
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"enabled": false,
|
||||
"scroll_speed": 50,
|
||||
"separator_width": 32,
|
||||
"plugin_order": [],
|
||||
"excluded_plugins": [],
|
||||
"target_fps": 125,
|
||||
"buffer_ahead": 2
|
||||
}
|
||||
},
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos",
|
||||
|
||||
1032
docs/ADVANCED_FEATURES.md
Normal file
1032
docs/ADVANCED_FEATURES.md
Normal file
File diff suppressed because it is too large
Load Diff
306
docs/CONFIG_DEBUGGING.md
Normal file
306
docs/CONFIG_DEBUGGING.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Configuration Debugging Guide
|
||||
|
||||
This guide helps troubleshoot configuration issues in LEDMatrix.
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Main Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `config/config.json` | Main configuration |
|
||||
| `config/config_secrets.json` | API keys and sensitive data |
|
||||
| `config/config.template.json` | Template for new installations |
|
||||
|
||||
### Plugin Configuration
|
||||
|
||||
Each plugin's configuration is a top-level key in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"football-scoreboard": {
|
||||
"enabled": true,
|
||||
"display_duration": 30,
|
||||
"nfl": {
|
||||
"enabled": true,
|
||||
"live_priority": false
|
||||
}
|
||||
},
|
||||
"odds-ticker": {
|
||||
"enabled": true,
|
||||
"display_duration": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Validation
|
||||
|
||||
Plugins define their configuration schema in `config_schema.json`. This enables:
|
||||
- Automatic default value population
|
||||
- Configuration validation
|
||||
- Web UI form generation
|
||||
|
||||
### Missing Schema Warning
|
||||
|
||||
If a plugin doesn't have `config_schema.json`, you'll see:
|
||||
|
||||
```
|
||||
WARNING - Plugin 'my-plugin' has no config_schema.json - configuration will not be validated.
|
||||
```
|
||||
|
||||
**Fix**: Add a `config_schema.json` to your plugin directory.
|
||||
|
||||
### Schema Example
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"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 in seconds"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "API key for data access"
|
||||
}
|
||||
},
|
||||
"required": ["api_key"]
|
||||
}
|
||||
```
|
||||
|
||||
## Common Configuration Issues
|
||||
|
||||
### 1. Type Mismatches
|
||||
|
||||
**Problem**: String value where number expected
|
||||
|
||||
```json
|
||||
{
|
||||
"display_duration": "30" // Wrong: string
|
||||
}
|
||||
```
|
||||
|
||||
**Fix**: Use correct types
|
||||
|
||||
```json
|
||||
{
|
||||
"display_duration": 30 // Correct: number
|
||||
}
|
||||
```
|
||||
|
||||
**Logged Warning**:
|
||||
```
|
||||
WARNING - Config display_duration has invalid string value '30', using default 15.0
|
||||
```
|
||||
|
||||
### 2. Missing Required Fields
|
||||
|
||||
**Problem**: Required field not in config
|
||||
|
||||
```json
|
||||
{
|
||||
"football-scoreboard": {
|
||||
"enabled": true
|
||||
// Missing api_key which is required
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Logged Error**:
|
||||
```
|
||||
ERROR - Plugin football-scoreboard configuration validation failed: 'api_key' is a required property
|
||||
```
|
||||
|
||||
### 3. Invalid Nested Objects
|
||||
|
||||
**Problem**: Wrong structure for nested config
|
||||
|
||||
```json
|
||||
{
|
||||
"football-scoreboard": {
|
||||
"nfl": "enabled" // Wrong: should be object
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fix**: Use correct structure
|
||||
|
||||
```json
|
||||
{
|
||||
"football-scoreboard": {
|
||||
"nfl": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Invalid JSON Syntax
|
||||
|
||||
**Problem**: Malformed JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": {
|
||||
"enabled": true, // Trailing comma
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fix**: Remove trailing commas, ensure valid JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tip**: Validate JSON at https://jsonlint.com/
|
||||
|
||||
## Debugging Configuration Loading
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
Set environment variable:
|
||||
```bash
|
||||
export LEDMATRIX_DEBUG=1
|
||||
python run.py
|
||||
```
|
||||
|
||||
### Check Merged Configuration
|
||||
|
||||
The configuration is merged with schema defaults. To see the final merged config:
|
||||
|
||||
1. Enable debug logging
|
||||
2. Look for log entries like:
|
||||
```
|
||||
DEBUG - Merged config with schema defaults for football-scoreboard
|
||||
```
|
||||
|
||||
### Configuration Load Order
|
||||
|
||||
1. Load `config.json`
|
||||
2. Load `config_secrets.json`
|
||||
3. Merge secrets into main config
|
||||
4. For each plugin:
|
||||
- Load plugin's `config_schema.json`
|
||||
- Extract default values from schema
|
||||
- Merge user config with defaults
|
||||
- Validate merged config against schema
|
||||
|
||||
## Web Interface Issues
|
||||
|
||||
### Changes Not Saving
|
||||
|
||||
1. Check file permissions on `config/` directory
|
||||
2. Check disk space
|
||||
3. Look for errors in browser console
|
||||
4. Check server logs for save errors
|
||||
|
||||
### Form Fields Not Appearing
|
||||
|
||||
1. Plugin may not have `config_schema.json`
|
||||
2. Schema may have syntax errors
|
||||
3. Check browser console for JavaScript errors
|
||||
|
||||
### Checkboxes Not Working
|
||||
|
||||
Boolean values from checkboxes should be actual booleans, not strings:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true, // Correct
|
||||
"enabled": "true" // Wrong
|
||||
}
|
||||
```
|
||||
|
||||
## Config Key Collision Detection
|
||||
|
||||
LEDMatrix detects potential config key conflicts:
|
||||
|
||||
### Reserved Keys
|
||||
|
||||
These plugin IDs will trigger a warning:
|
||||
- `display`, `schedule`, `timezone`, `plugin_system`
|
||||
- `display_modes`, `system`, `hardware`, `debug`
|
||||
- `log_level`, `emulator`, `web_interface`
|
||||
|
||||
**Warning**:
|
||||
```
|
||||
WARNING - Plugin ID 'display' conflicts with reserved config key.
|
||||
```
|
||||
|
||||
### Case Collisions
|
||||
|
||||
Plugin IDs that differ only in case:
|
||||
```
|
||||
WARNING - Plugin ID 'Football-Scoreboard' may conflict with 'football-scoreboard' on case-insensitive file systems.
|
||||
```
|
||||
|
||||
## Checking Configuration via API
|
||||
|
||||
```bash
|
||||
# Get current config
|
||||
curl http://localhost:5000/api/v3/config
|
||||
|
||||
# Get specific plugin config
|
||||
curl http://localhost:5000/api/v3/config/plugin/football-scoreboard
|
||||
|
||||
# Validate config without saving
|
||||
curl -X POST http://localhost:5000/api/v3/config/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"football-scoreboard": {"enabled": true}}'
|
||||
```
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Manual Backup
|
||||
|
||||
```bash
|
||||
cp config/config.json config/config.backup.json
|
||||
```
|
||||
|
||||
### Automatic Backups
|
||||
|
||||
LEDMatrix creates backups before saves:
|
||||
- Location: `config/backups/`
|
||||
- Format: `config_YYYYMMDD_HHMMSS.json`
|
||||
|
||||
### Recovery
|
||||
|
||||
```bash
|
||||
# List backups
|
||||
ls -la config/backups/
|
||||
|
||||
# Restore from backup
|
||||
cp config/backups/config_20240115_120000.json config/config.json
|
||||
```
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
- [ ] JSON syntax is valid (no trailing commas, quotes correct)
|
||||
- [ ] Data types match schema (numbers are numbers, not strings)
|
||||
- [ ] Required fields are present
|
||||
- [ ] Nested objects have correct structure
|
||||
- [ ] File permissions allow read/write
|
||||
- [ ] No reserved config key collisions
|
||||
- [ ] Plugin has `config_schema.json` for validation
|
||||
|
||||
## Getting Help
|
||||
|
||||
1. Check logs: `tail -f logs/ledmatrix.log`
|
||||
2. Enable debug: `LEDMATRIX_DEBUG=1`
|
||||
3. Check error dashboard: `/api/v3/errors/summary`
|
||||
4. Validate JSON: https://jsonlint.com/
|
||||
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
|
||||
324
docs/GETTING_STARTED.md
Normal file
324
docs/GETTING_STARTED.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Getting Started with LEDMatrix
|
||||
|
||||
## Welcome
|
||||
|
||||
This guide will help you set up your LEDMatrix display for the first time and get it running in under 30 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Hardware:**
|
||||
- Raspberry Pi (3, 4, or 5 recommended)
|
||||
- RGB LED Matrix panel (32x64 or 64x64)
|
||||
- Adafruit RGB Matrix HAT or similar
|
||||
- Power supply (5V, 4A minimum recommended)
|
||||
- MicroSD card (16GB minimum)
|
||||
|
||||
**Network:**
|
||||
- WiFi network (or Ethernet cable)
|
||||
- Computer with web browser on same network
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (5 Minutes)
|
||||
|
||||
### 1. First Boot
|
||||
|
||||
1. Insert the MicroSD card with LEDMatrix installed
|
||||
2. Connect the LED matrix to your Raspberry Pi
|
||||
3. Plug in the power supply
|
||||
4. Wait for the Pi to boot (about 60 seconds)
|
||||
|
||||
**Expected Behavior:**
|
||||
- LED matrix will light up
|
||||
- Display will show default plugins (clock, weather, etc.)
|
||||
- Pi creates WiFi network "LEDMatrix-Setup" if not connected
|
||||
|
||||
### 2. Connect to WiFi
|
||||
|
||||
**If you see "LEDMatrix-Setup" WiFi network:**
|
||||
1. Connect your device to "LEDMatrix-Setup" (open network, no password)
|
||||
2. Open browser to: `http://192.168.4.1:5050`
|
||||
3. Navigate to the WiFi tab
|
||||
4. Click "Scan" to find your WiFi network
|
||||
5. Select your network, enter password
|
||||
6. Click "Connect"
|
||||
7. Wait for connection (LED matrix will show confirmation)
|
||||
|
||||
**If already connected to WiFi:**
|
||||
1. Find your Pi's IP address (check your router, or run `hostname -I` on the Pi)
|
||||
2. Open browser to: `http://your-pi-ip:5050`
|
||||
|
||||
### 3. Access the Web Interface
|
||||
|
||||
Once connected, access the web interface:
|
||||
|
||||
```
|
||||
http://your-pi-ip:5050
|
||||
```
|
||||
|
||||
You should see:
|
||||
- Overview tab with system stats
|
||||
- Live display preview
|
||||
- Quick action buttons
|
||||
|
||||
---
|
||||
|
||||
## Initial Configuration (15 Minutes)
|
||||
|
||||
### Step 1: Configure Display Hardware
|
||||
|
||||
1. Navigate to Settings → **Display Settings**
|
||||
2. Set your matrix configuration:
|
||||
- **Rows**: 32 or 64 (match your hardware)
|
||||
- **Columns**: 64, 128, or 256 (match your hardware)
|
||||
- **Chain Length**: Number of panels chained together
|
||||
- **Brightness**: 50-75% recommended for indoor use
|
||||
3. Click **Save Configuration**
|
||||
4. Click **Restart Display** to apply changes
|
||||
|
||||
**Tip:** If the display doesn't look right, try different hardware mapping options.
|
||||
|
||||
### Step 2: Set Timezone and Location
|
||||
|
||||
1. Navigate to Settings → **General Settings**
|
||||
2. Set your timezone (e.g., "America/New_York")
|
||||
3. Set your location (city, state, country)
|
||||
4. Click **Save Configuration**
|
||||
|
||||
**Why it matters:** Correct timezone ensures accurate time display. Location enables weather and location-based features.
|
||||
|
||||
### Step 3: Install Plugins
|
||||
|
||||
1. Navigate to **Plugin Store** tab
|
||||
2. Browse available plugins:
|
||||
- **Time & Date**: Clock, calendar
|
||||
- **Weather**: Weather forecasts
|
||||
- **Sports**: NHL, NBA, NFL, MLB scores
|
||||
- **Finance**: Stocks, crypto
|
||||
- **Custom**: Community plugins
|
||||
3. Click **Install** on desired plugins
|
||||
4. Wait for installation to complete
|
||||
5. Navigate to **Plugin Management** tab
|
||||
6. Enable installed plugins (toggle switch)
|
||||
7. Click **Restart Display**
|
||||
|
||||
**Popular First Plugins:**
|
||||
- `clock-simple` - Simple digital clock
|
||||
- `weather` - Weather forecast
|
||||
- `nhl-scores` - NHL scores (if you're a hockey fan)
|
||||
|
||||
### Step 4: Configure Plugins
|
||||
|
||||
1. Navigate to **Plugin Management** tab
|
||||
2. Find a plugin you installed
|
||||
3. Click the ⚙️ **Configure** button
|
||||
4. Edit settings (e.g., favorite teams, update intervals)
|
||||
5. Click **Save**
|
||||
6. Click **Restart Display**
|
||||
|
||||
**Example: Weather Plugin**
|
||||
- Set your location (city, state, country)
|
||||
- Add API key from OpenWeatherMap (free signup)
|
||||
- Set update interval (300 seconds recommended)
|
||||
|
||||
---
|
||||
|
||||
## Testing Your Display
|
||||
|
||||
### Quick Test
|
||||
|
||||
1. Navigate to **Overview** tab
|
||||
2. Click **Test Display** button
|
||||
3. You should see a test pattern on your LED matrix
|
||||
|
||||
### Manual Plugin Trigger
|
||||
|
||||
1. Navigate to **Plugin Management** tab
|
||||
2. Find a plugin
|
||||
3. Click **Show Now** button
|
||||
4. The plugin should display immediately
|
||||
5. Click **Stop** to return to rotation
|
||||
|
||||
### Check Logs
|
||||
|
||||
1. Navigate to **Logs** tab
|
||||
2. Watch real-time logs
|
||||
3. Look for any ERROR messages
|
||||
4. Normal operation shows INFO messages about plugin rotation
|
||||
|
||||
---
|
||||
|
||||
## Common First-Time Issues
|
||||
|
||||
### Display Not Showing Anything
|
||||
|
||||
**Check:**
|
||||
1. Power supply connected and adequate (5V, 4A minimum)
|
||||
2. LED matrix connected to GPIO pins correctly
|
||||
3. Display service running: `sudo systemctl status ledmatrix`
|
||||
4. Hardware configuration matches your matrix (rows/columns)
|
||||
|
||||
**Fix:**
|
||||
1. Restart display: Settings → Overview → Restart Display
|
||||
2. Or via SSH: `sudo systemctl restart ledmatrix`
|
||||
|
||||
### Web Interface Won't Load
|
||||
|
||||
**Check:**
|
||||
1. Pi is connected to network: `ping your-pi-ip`
|
||||
2. Web service running: `sudo systemctl status ledmatrix-web`
|
||||
3. Correct port: Use `:5050` not `:5000`
|
||||
4. Firewall not blocking port 5050
|
||||
|
||||
**Fix:**
|
||||
1. Restart web service: `sudo systemctl restart ledmatrix-web`
|
||||
2. Check logs: `sudo journalctl -u ledmatrix-web -n 50`
|
||||
|
||||
### Plugins Not Showing
|
||||
|
||||
**Check:**
|
||||
1. Plugins are enabled (toggle switch in Plugin Management)
|
||||
2. Display has been restarted after enabling
|
||||
3. Plugin duration is reasonable (not too short)
|
||||
4. No errors in logs for the plugin
|
||||
|
||||
**Fix:**
|
||||
1. Enable plugin in Plugin Management
|
||||
2. Restart display
|
||||
3. Check logs for plugin-specific errors
|
||||
|
||||
### Weather Plugin Shows "No Data"
|
||||
|
||||
**Check:**
|
||||
1. API key configured (OpenWeatherMap)
|
||||
2. Location is correct (city, state, country)
|
||||
3. Internet connection working
|
||||
|
||||
**Fix:**
|
||||
1. Sign up at openweathermap.org (free)
|
||||
2. Add API key to config_secrets.json or plugin config
|
||||
3. Restart display
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Customize Your Display
|
||||
|
||||
**Adjust Display Durations:**
|
||||
- Navigate to Settings → Durations
|
||||
- Set how long each plugin displays
|
||||
- Save and restart
|
||||
|
||||
**Organize Plugin Order:**
|
||||
- Use Plugin Management to enable/disable plugins
|
||||
- Display cycles through enabled plugins in order
|
||||
|
||||
**Add More Plugins:**
|
||||
- Check Plugin Store regularly for new plugins
|
||||
- Install from GitHub URLs for custom/community plugins
|
||||
|
||||
### Enable Advanced Features
|
||||
|
||||
**Vegas Scroll Mode:**
|
||||
- Continuous scrolling ticker display
|
||||
- See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for details
|
||||
|
||||
**On-Demand Display:**
|
||||
- Manually trigger specific plugins
|
||||
- Pin important information
|
||||
- See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for details
|
||||
|
||||
**Background Services:**
|
||||
- Non-blocking data fetching
|
||||
- Faster plugin rotation
|
||||
- See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for details
|
||||
|
||||
### Explore Documentation
|
||||
|
||||
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Complete web interface guide
|
||||
- [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi configuration details
|
||||
- [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Installing and managing plugins
|
||||
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Solving common issues
|
||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) - Advanced functionality
|
||||
|
||||
### Join the Community
|
||||
|
||||
- Report issues on GitHub
|
||||
- Share your custom plugins
|
||||
- Help others in discussions
|
||||
- Contribute improvements
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Service Commands
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
sudo systemctl status ledmatrix
|
||||
sudo systemctl status ledmatrix-web
|
||||
|
||||
# Restart services
|
||||
sudo systemctl restart ledmatrix
|
||||
sudo systemctl restart ledmatrix-web
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u ledmatrix -f
|
||||
sudo journalctl -u ledmatrix-web -f
|
||||
```
|
||||
|
||||
### File Locations
|
||||
|
||||
```
|
||||
/home/ledpi/LEDMatrix/
|
||||
├── config/
|
||||
│ ├── config.json # Main configuration
|
||||
│ ├── config_secrets.json # API keys and secrets
|
||||
│ └── wifi_config.json # WiFi settings
|
||||
├── plugins/ # Installed plugins
|
||||
├── cache/ # Cached data
|
||||
└── web_interface/ # Web interface files
|
||||
```
|
||||
|
||||
### Web Interface
|
||||
|
||||
```
|
||||
Main Interface: http://your-pi-ip:5050
|
||||
|
||||
Tabs:
|
||||
- Overview: System stats and quick actions
|
||||
- General Settings: Timezone, location, autostart
|
||||
- Display Settings: Hardware configuration
|
||||
- Durations: Plugin display times
|
||||
- Sports Configuration: Per-league settings
|
||||
- Plugin Management: Enable/disable, configure
|
||||
- Plugin Store: Install new plugins
|
||||
- Font Management: Upload and manage fonts
|
||||
- Logs: Real-time log viewing
|
||||
```
|
||||
|
||||
### WiFi Access Point
|
||||
|
||||
```
|
||||
Network Name: LEDMatrix-Setup
|
||||
Password: (none - open network)
|
||||
URL when connected: http://192.168.4.1:5050
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Congratulations!
|
||||
|
||||
Your LEDMatrix display is now set up and running. Explore the web interface, try different plugins, and customize it to your liking.
|
||||
|
||||
**Need Help?**
|
||||
- Check [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
|
||||
- Review detailed guides for specific features
|
||||
- Report issues on GitHub
|
||||
- Ask questions in community discussions
|
||||
|
||||
Enjoy your LED matrix display!
|
||||
243
docs/PLUGIN_ERROR_HANDLING.md
Normal file
243
docs/PLUGIN_ERROR_HANDLING.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Plugin Error Handling Guide
|
||||
|
||||
This guide covers best practices for error handling in LEDMatrix plugins.
|
||||
|
||||
## Custom Exception Hierarchy
|
||||
|
||||
LEDMatrix provides typed exceptions for different error categories. Use these instead of generic `Exception`:
|
||||
|
||||
```python
|
||||
from src.exceptions import PluginError, ConfigError, CacheError, DisplayError
|
||||
|
||||
# Plugin-related errors
|
||||
raise PluginError("Failed to fetch data", plugin_id=self.plugin_id, context={"api": "ESPN"})
|
||||
|
||||
# Configuration errors
|
||||
raise ConfigError("Invalid API key format", field="api_key")
|
||||
|
||||
# Cache errors
|
||||
raise CacheError("Cache write failed", cache_key="game_data")
|
||||
|
||||
# Display errors
|
||||
raise DisplayError("Failed to render", display_mode="live")
|
||||
```
|
||||
|
||||
### Exception Context
|
||||
|
||||
All LEDMatrix exceptions support a `context` dict for additional debugging info:
|
||||
|
||||
```python
|
||||
raise PluginError(
|
||||
"API request failed",
|
||||
plugin_id=self.plugin_id,
|
||||
context={
|
||||
"url": api_url,
|
||||
"status_code": response.status_code,
|
||||
"retry_count": 3
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Logging Best Practices
|
||||
|
||||
### Use the Plugin Logger
|
||||
|
||||
Every plugin has access to `self.logger`:
|
||||
|
||||
```python
|
||||
class MyPlugin(BasePlugin):
|
||||
def update(self):
|
||||
self.logger.info("Starting data fetch")
|
||||
self.logger.debug("API URL: %s", api_url)
|
||||
self.logger.warning("Rate limit approaching")
|
||||
self.logger.error("API request failed", exc_info=True)
|
||||
```
|
||||
|
||||
### Log Levels
|
||||
|
||||
- **DEBUG**: Detailed info for troubleshooting (API URLs, parsed data)
|
||||
- **INFO**: Normal operation milestones (plugin loaded, data fetched)
|
||||
- **WARNING**: Recoverable issues (rate limits, cache miss, fallback used)
|
||||
- **ERROR**: Failures that need attention (API down, display error)
|
||||
|
||||
### Include exc_info for Exceptions
|
||||
|
||||
```python
|
||||
try:
|
||||
response = requests.get(url)
|
||||
except requests.RequestException as e:
|
||||
self.logger.error("API request failed: %s", e, exc_info=True)
|
||||
```
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### Never Use Bare except
|
||||
|
||||
```python
|
||||
# BAD - swallows all errors including KeyboardInterrupt
|
||||
try:
|
||||
self.fetch_data()
|
||||
except:
|
||||
pass
|
||||
|
||||
# GOOD - catch specific exceptions
|
||||
try:
|
||||
self.fetch_data()
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning("Network error, using cached data: %s", e)
|
||||
self.data = self.get_cached_data()
|
||||
```
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
```python
|
||||
def update(self):
|
||||
try:
|
||||
self.data = self.fetch_live_data()
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning("Live data unavailable: %s", e)
|
||||
# Fall back to cache
|
||||
cached = self.cache_manager.get(self.cache_key)
|
||||
if cached:
|
||||
self.logger.info("Using cached data")
|
||||
self.data = cached
|
||||
else:
|
||||
self.logger.error("No cached data available")
|
||||
self.data = None
|
||||
```
|
||||
|
||||
### Validate Configuration Early
|
||||
|
||||
```python
|
||||
def validate_config(self) -> bool:
|
||||
"""Validate configuration at load time."""
|
||||
api_key = self.config.get("api_key")
|
||||
if not api_key:
|
||||
self.logger.error("api_key is required but not configured")
|
||||
return False
|
||||
|
||||
if not isinstance(api_key, str) or len(api_key) < 10:
|
||||
self.logger.error("api_key appears to be invalid")
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
### Handle Display Errors
|
||||
|
||||
```python
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
if not self.data:
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
self.display_manager.update_display()
|
||||
return False
|
||||
|
||||
try:
|
||||
self._render_content()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error("Display error: %s", e, exc_info=True)
|
||||
# Clear display on error to prevent stale content
|
||||
self.display_manager.clear()
|
||||
self.display_manager.update_display()
|
||||
return False
|
||||
```
|
||||
|
||||
## Error Aggregation
|
||||
|
||||
LEDMatrix automatically tracks plugin errors. Access error data via the API:
|
||||
|
||||
```bash
|
||||
# Get error summary
|
||||
curl http://localhost:5000/api/v3/errors/summary
|
||||
|
||||
# Get plugin-specific health
|
||||
curl http://localhost:5000/api/v3/errors/plugin/my-plugin
|
||||
|
||||
# Clear old errors
|
||||
curl -X POST http://localhost:5000/api/v3/errors/clear
|
||||
```
|
||||
|
||||
### Error Patterns
|
||||
|
||||
When the same error occurs repeatedly (5+ times in 60 minutes), it's detected as a pattern and logged as a warning. This helps identify systemic issues.
|
||||
|
||||
## Common Error Scenarios
|
||||
|
||||
### API Rate Limiting
|
||||
|
||||
```python
|
||||
def fetch_data(self):
|
||||
try:
|
||||
response = requests.get(self.api_url)
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get("Retry-After", 60))
|
||||
self.logger.warning("Rate limited, retry after %ds", retry_after)
|
||||
self._rate_limited_until = time.time() + retry_after
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
self.logger.error("API error: %s", e)
|
||||
return None
|
||||
```
|
||||
|
||||
### Timeout Handling
|
||||
|
||||
```python
|
||||
def fetch_data(self):
|
||||
try:
|
||||
response = requests.get(self.api_url, timeout=10)
|
||||
return response.json()
|
||||
except requests.Timeout:
|
||||
self.logger.warning("Request timed out, will retry next update")
|
||||
return None
|
||||
except requests.RequestException as e:
|
||||
self.logger.error("Request failed: %s", e)
|
||||
return None
|
||||
```
|
||||
|
||||
### Missing Data Gracefully
|
||||
|
||||
```python
|
||||
def get_team_logo(self, team_id):
|
||||
logo_path = self.logos_dir / f"{team_id}.png"
|
||||
if not logo_path.exists():
|
||||
self.logger.debug("Logo not found for team %s, using default", team_id)
|
||||
return self.default_logo
|
||||
return Image.open(logo_path)
|
||||
```
|
||||
|
||||
## Testing Error Handling
|
||||
|
||||
```python
|
||||
def test_handles_api_error(mock_requests):
|
||||
"""Test plugin handles API errors gracefully."""
|
||||
mock_requests.get.side_effect = requests.RequestException("Network error")
|
||||
|
||||
plugin = MyPlugin(...)
|
||||
plugin.update()
|
||||
|
||||
# Should not raise, should log warning, should have no data
|
||||
assert plugin.data is None
|
||||
|
||||
def test_handles_invalid_json(mock_requests):
|
||||
"""Test plugin handles invalid JSON response."""
|
||||
mock_requests.get.return_value.json.side_effect = ValueError("Invalid JSON")
|
||||
|
||||
plugin = MyPlugin(...)
|
||||
plugin.update()
|
||||
|
||||
assert plugin.data is None
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] No bare `except:` clauses
|
||||
- [ ] All exceptions logged with appropriate level
|
||||
- [ ] `exc_info=True` for error-level logs
|
||||
- [ ] Graceful degradation with cache fallbacks
|
||||
- [ ] Configuration validated in `validate_config()`
|
||||
- [ ] Display clears on error to prevent stale content
|
||||
- [ ] Timeouts configured for all network requests
|
||||
493
docs/PLUGIN_STORE_GUIDE.md
Normal file
493
docs/PLUGIN_STORE_GUIDE.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# Plugin Store Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The LEDMatrix Plugin Store allows you to discover, install, and manage display plugins for your LED matrix. Install curated plugins from the official registry or add custom plugins directly from any GitHub repository.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Install from Store
|
||||
```bash
|
||||
# Web UI: Plugin Store → Search → Click Install
|
||||
# API:
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple"}'
|
||||
```
|
||||
|
||||
### Install from GitHub URL
|
||||
```bash
|
||||
# Web UI: Plugin Store → "Install from URL" → Paste URL
|
||||
# API:
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/install-from-url \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repo_url": "https://github.com/user/ledmatrix-plugin"}'
|
||||
```
|
||||
|
||||
### Manage Plugins
|
||||
```bash
|
||||
# List installed
|
||||
curl "http://your-pi-ip:5050/api/plugins/installed"
|
||||
|
||||
# Enable/disable
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/toggle \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple", "enabled": true}'
|
||||
|
||||
# Update
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/update \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple"}'
|
||||
|
||||
# Uninstall
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/uninstall \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation Methods
|
||||
|
||||
### Method 1: From Official Plugin Store (Recommended)
|
||||
|
||||
The official plugin store contains curated, verified plugins that have been reviewed by maintainers.
|
||||
|
||||
**Via Web Interface:**
|
||||
1. Open the web interface at http://your-pi-ip:5050
|
||||
2. Navigate to the "Plugin Store" tab
|
||||
3. Browse or search for plugins
|
||||
4. Click "Install" on the desired plugin
|
||||
5. Wait for installation to complete
|
||||
6. Restart the display to activate the plugin
|
||||
|
||||
**Via REST API:**
|
||||
```bash
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple"}'
|
||||
```
|
||||
|
||||
**Via Python:**
|
||||
```python
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
store = PluginStoreManager()
|
||||
success = store.install_plugin('clock-simple')
|
||||
if success:
|
||||
print("Plugin installed!")
|
||||
```
|
||||
|
||||
### Method 2: From Custom GitHub URL
|
||||
|
||||
Install any plugin directly from a GitHub repository, even if it's not in the official store. This method is useful for:
|
||||
- Testing your own plugins during development
|
||||
- Installing community plugins before they're in the official store
|
||||
- Using private plugins
|
||||
- Sharing plugins with specific users
|
||||
|
||||
**Via Web Interface:**
|
||||
1. Open the web interface
|
||||
2. Navigate to the "Plugin Store" tab
|
||||
3. Find the "Install from URL" section
|
||||
4. Paste the GitHub repository URL (e.g., `https://github.com/user/ledmatrix-my-plugin`)
|
||||
5. Click "Install from URL"
|
||||
6. Review the warning about unverified plugins
|
||||
7. Confirm installation
|
||||
8. Wait for installation to complete
|
||||
9. Restart the display
|
||||
|
||||
**Via REST API:**
|
||||
```bash
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/install-from-url \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repo_url": "https://github.com/user/ledmatrix-my-plugin"}'
|
||||
```
|
||||
|
||||
**Via Python:**
|
||||
```python
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
store = PluginStoreManager()
|
||||
result = store.install_from_url('https://github.com/user/ledmatrix-my-plugin')
|
||||
|
||||
if result['success']:
|
||||
print(f"Installed: {result['plugin_id']}")
|
||||
else:
|
||||
print(f"Error: {result['error']}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Searching for Plugins
|
||||
|
||||
**Via Web Interface:**
|
||||
- Use the search bar to search by name, description, or author
|
||||
- Filter by category (sports, weather, time, finance, etc.)
|
||||
- Click on tags to filter by specific tags
|
||||
|
||||
**Via REST API:**
|
||||
```bash
|
||||
# Search by query
|
||||
curl "http://your-pi-ip:5050/api/plugins/store/search?q=hockey"
|
||||
|
||||
# Filter by category
|
||||
curl "http://your-pi-ip:5050/api/plugins/store/search?category=sports"
|
||||
|
||||
# Filter by tags
|
||||
curl "http://your-pi-ip:5050/api/plugins/store/search?tags=nhl&tags=hockey"
|
||||
```
|
||||
|
||||
**Via Python:**
|
||||
```python
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
store = PluginStoreManager()
|
||||
|
||||
# Search by query
|
||||
results = store.search_plugins(query="hockey")
|
||||
|
||||
# Filter by category
|
||||
results = store.search_plugins(category="sports")
|
||||
|
||||
# Filter by tags
|
||||
results = store.search_plugins(tags=["nhl", "hockey"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Managing Installed Plugins
|
||||
|
||||
### List Installed Plugins
|
||||
|
||||
**Via Web Interface:**
|
||||
- Navigate to the "Plugin Manager" tab
|
||||
- View all installed plugins with their status
|
||||
|
||||
**Via REST API:**
|
||||
```bash
|
||||
curl "http://your-pi-ip:5050/api/plugins/installed"
|
||||
```
|
||||
|
||||
**Via Python:**
|
||||
```python
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
store = PluginStoreManager()
|
||||
installed = store.list_installed_plugins()
|
||||
|
||||
for plugin_id in installed:
|
||||
info = store.get_installed_plugin_info(plugin_id)
|
||||
print(f"{info['name']} (Last updated: {info.get('last_updated', 'unknown')})")
|
||||
```
|
||||
|
||||
### Enable/Disable Plugins
|
||||
|
||||
**Via Web Interface:**
|
||||
1. Navigate to the "Plugin Manager" tab
|
||||
2. Use the toggle switch next to each plugin
|
||||
3. Restart the display to apply changes
|
||||
|
||||
**Via REST API:**
|
||||
```bash
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/toggle \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple", "enabled": true}'
|
||||
```
|
||||
|
||||
### Update Plugins
|
||||
|
||||
**Via Web Interface:**
|
||||
1. Navigate to the "Plugin Manager" tab
|
||||
2. Click the "Update" button next to the plugin
|
||||
3. Wait for the update to complete
|
||||
4. Restart the display
|
||||
|
||||
**Via REST API:**
|
||||
```bash
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/update \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple"}'
|
||||
```
|
||||
|
||||
**Via Python:**
|
||||
```python
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
store = PluginStoreManager()
|
||||
success = store.update_plugin('clock-simple')
|
||||
```
|
||||
|
||||
### Uninstall Plugins
|
||||
|
||||
**Via Web Interface:**
|
||||
1. Navigate to the "Plugin Manager" tab
|
||||
2. Click the "Uninstall" button next to the plugin
|
||||
3. Confirm removal
|
||||
4. Restart the display
|
||||
|
||||
**Via REST API:**
|
||||
```bash
|
||||
curl -X POST http://your-pi-ip:5050/api/plugins/uninstall \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple"}'
|
||||
```
|
||||
|
||||
**Via Python:**
|
||||
```python
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
store = PluginStoreManager()
|
||||
success = store.uninstall_plugin('clock-simple')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuring Plugins
|
||||
|
||||
Each plugin can have its own configuration in `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clock-simple": {
|
||||
"enabled": true,
|
||||
"display_duration": 15,
|
||||
"color": [255, 255, 255],
|
||||
"time_format": "12h"
|
||||
},
|
||||
"nhl-scores": {
|
||||
"enabled": true,
|
||||
"favorite_teams": ["TBL", "FLA"],
|
||||
"show_favorite_teams_only": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Via Web Interface:**
|
||||
1. Navigate to the "Plugin Manager" tab
|
||||
2. Click the Configure (⚙️) button next to the plugin
|
||||
3. Edit the configuration in the form
|
||||
4. Save changes
|
||||
5. Restart the display to apply changes
|
||||
|
||||
---
|
||||
|
||||
## Safety and Security
|
||||
|
||||
### Verified vs Unverified Plugins
|
||||
|
||||
- **Verified Plugins**: Reviewed by maintainers, follow best practices, no known security issues
|
||||
- **Unverified Plugins**: User-contributed, not reviewed, install at your own risk
|
||||
|
||||
When installing from a custom GitHub URL, you'll see a warning about installing an unverified plugin. The plugin will have access to your display manager, cache manager, configuration files, and network access.
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. Only install plugins from trusted sources
|
||||
2. Review plugin code before installing (click "View on GitHub")
|
||||
3. Keep plugins updated for security patches
|
||||
4. Report suspicious plugins to maintainers
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Won't Install
|
||||
|
||||
**Problem:** Installation fails with "Failed to clone or download repository"
|
||||
|
||||
**Solutions:**
|
||||
- Check that git is installed: `which git`
|
||||
- Verify the GitHub URL is correct
|
||||
- Check your internet connection
|
||||
- The system will automatically try ZIP download as fallback
|
||||
|
||||
### Plugin Won't Load
|
||||
|
||||
**Problem:** Plugin installed but doesn't appear in rotation
|
||||
|
||||
**Solutions:**
|
||||
1. Check that the plugin is enabled in config: `"enabled": true`
|
||||
2. Verify manifest.json exists and is valid
|
||||
3. Check logs for errors: `sudo journalctl -u ledmatrix -f`
|
||||
4. Restart the display service: `sudo systemctl restart ledmatrix`
|
||||
|
||||
### Dependencies Failed
|
||||
|
||||
**Problem:** "Error installing dependencies" message
|
||||
|
||||
**Solutions:**
|
||||
- Check that pip3 is installed
|
||||
- Manually install: `pip3 install --break-system-packages -r plugins/plugin-id/requirements.txt`
|
||||
- Check for conflicting package versions
|
||||
|
||||
### Plugin Shows Errors
|
||||
|
||||
**Problem:** Plugin loads but shows error message on display
|
||||
|
||||
**Solutions:**
|
||||
1. Check that the plugin configuration is correct
|
||||
2. Verify API keys are set (if the plugin requires them)
|
||||
3. Check plugin logs: `sudo journalctl -u ledmatrix -f | grep plugin-id`
|
||||
4. Report the issue to the plugin developer on GitHub
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All API endpoints return JSON with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success" | "error",
|
||||
"message": "Human-readable message",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/plugins/store/list` | List all plugins in store |
|
||||
| GET | `/api/plugins/store/search` | Search for plugins |
|
||||
| GET | `/api/plugins/installed` | List installed plugins |
|
||||
| POST | `/api/plugins/install` | Install from registry |
|
||||
| POST | `/api/plugins/install-from-url` | Install from GitHub URL |
|
||||
| POST | `/api/plugins/uninstall` | Uninstall plugin |
|
||||
| POST | `/api/plugins/update` | Update plugin |
|
||||
| POST | `/api/plugins/toggle` | Enable/disable plugin |
|
||||
| POST | `/api/plugins/config` | Update plugin config |
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Install Clock Plugin
|
||||
|
||||
```bash
|
||||
# Install
|
||||
curl -X POST http://192.168.1.100:5050/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple"}'
|
||||
|
||||
# Configure in config/config.json
|
||||
{
|
||||
"clock-simple": {
|
||||
"enabled": true,
|
||||
"display_duration": 20,
|
||||
"time_format": "24h"
|
||||
}
|
||||
}
|
||||
|
||||
# Restart display
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
### Example 2: Install Custom Plugin from GitHub
|
||||
|
||||
```bash
|
||||
# Install your own plugin during development
|
||||
curl -X POST http://192.168.1.100:5050/api/plugins/install-from-url \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repo_url": "https://github.com/myusername/ledmatrix-my-custom-plugin"}'
|
||||
|
||||
# Enable it
|
||||
curl -X POST http://192.168.1.100:5050/api/plugins/toggle \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "my-custom-plugin", "enabled": true}'
|
||||
|
||||
# Restart
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
### Example 3: Share Plugin with Others
|
||||
|
||||
As a plugin developer, you can share your plugin with others even before it's in the official store:
|
||||
|
||||
1. Push your plugin to GitHub: `https://github.com/yourusername/ledmatrix-awesome-plugin`
|
||||
2. Share the URL with users
|
||||
3. Users install via:
|
||||
- Open the LEDMatrix web interface
|
||||
- Click "Plugin Store" tab
|
||||
- Scroll to "Install from URL"
|
||||
- Paste the URL
|
||||
- Click "Install from URL"
|
||||
|
||||
---
|
||||
|
||||
## Command-Line Usage
|
||||
|
||||
For advanced users, manage plugins via command line:
|
||||
|
||||
```bash
|
||||
# Install from registry
|
||||
python3 -c "
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
store = PluginStoreManager()
|
||||
store.install_plugin('clock-simple')
|
||||
"
|
||||
|
||||
# Install from URL
|
||||
python3 -c "
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
store = PluginStoreManager()
|
||||
result = store.install_from_url('https://github.com/user/plugin')
|
||||
print(result)
|
||||
"
|
||||
|
||||
# List installed
|
||||
python3 -c "
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
store = PluginStoreManager()
|
||||
for plugin_id in store.list_installed_plugins():
|
||||
info = store.get_installed_plugin_info(plugin_id)
|
||||
print(f'{plugin_id}: {info[\"name\"]} (Last updated: {info.get(\"last_updated\", \"unknown\")})')
|
||||
"
|
||||
|
||||
# Uninstall
|
||||
python3 -c "
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
store = PluginStoreManager()
|
||||
store.uninstall_plugin('clock-simple')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Do I need to restart the display after installing a plugin?**
|
||||
A: Yes, plugins are loaded when the display controller starts.
|
||||
|
||||
**Q: Can I install plugins while the display is running?**
|
||||
A: Yes, you can install anytime, but you must restart the display to load them.
|
||||
|
||||
**Q: What happens if I install a plugin with the same ID as an existing one?**
|
||||
A: The existing copy will be replaced with the latest code from the repository.
|
||||
|
||||
**Q: Can I install multiple versions of the same plugin?**
|
||||
A: No, each plugin ID maps to a single checkout of the repository's default branch.
|
||||
|
||||
**Q: How do I update all plugins at once?**
|
||||
A: Currently, you need to update each plugin individually. Bulk update is planned for a future release.
|
||||
|
||||
**Q: Can plugins access my API keys from config_secrets.json?**
|
||||
A: Yes, if a plugin needs API keys, it can access them like core managers do.
|
||||
|
||||
**Q: How much disk space do plugins use?**
|
||||
A: Most plugins are small (1-5MB). Check individual plugin documentation for specific requirements.
|
||||
|
||||
**Q: Can I create my own plugin?**
|
||||
A: Yes! See [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) for instructions.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Create your own plugins
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin API documentation
|
||||
- [PLUGIN_ARCHITECTURE.md](PLUGIN_ARCHITECTURE.md) - Plugin system architecture
|
||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API reference
|
||||
212
docs/README.md
212
docs/README.md
@@ -4,174 +4,196 @@ Welcome to the LEDMatrix documentation! This directory contains comprehensive gu
|
||||
|
||||
## 📚 Documentation Overview
|
||||
|
||||
This documentation has been consolidated and organized to reduce redundancy while maintaining comprehensive coverage. Recent improvements include complete API references, enhanced plugin development guides, and better organization for both end users and developers.
|
||||
This documentation has been recently consolidated (January 2026) to reduce redundancy while maintaining comprehensive coverage. We've reduced from 51 main documents to 16-17 well-organized files (~68% reduction) by merging duplicates, archiving ephemeral content, and unifying writing styles.
|
||||
|
||||
## 📖 Quick Start
|
||||
|
||||
### For New Users
|
||||
1. **Installation**: Follow the main [README.md](../README.md) in the project root
|
||||
2. **First Setup**: Run `first_time_install.sh` for initial configuration
|
||||
3. **Basic Usage**: See [TROUBLESHOOTING_QUICK_START.md](TROUBLESHOOTING_QUICK_START.md) for common issues
|
||||
2. **First Setup**: See [GETTING_STARTED.md](GETTING_STARTED.md) for first-time setup guide
|
||||
3. **Web Interface**: Use [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) to learn the control panel
|
||||
4. **Troubleshooting**: Check [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common issues
|
||||
|
||||
### For Developers
|
||||
1. **Plugin System**: Read [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) for an overview
|
||||
2. **Plugin Development**: See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for development workflow
|
||||
1. **Plugin Development**: See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for complete guide
|
||||
2. **Advanced Patterns**: Read [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) for advanced techniques
|
||||
3. **API Reference**: Check [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for available methods
|
||||
4. **Configuration**: Check [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md)
|
||||
4. **Configuration**: See [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) for config schemas
|
||||
|
||||
### For API Integration
|
||||
1. **REST API**: See [API_REFERENCE.md](API_REFERENCE.md) for all web interface endpoints
|
||||
1. **REST API**: See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for all web interface endpoints
|
||||
2. **Plugin API**: See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for plugin developer APIs
|
||||
3. **Quick Reference**: See [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) for common tasks
|
||||
3. **Developer Reference**: See [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) for common tasks
|
||||
|
||||
## 📋 Documentation Categories
|
||||
|
||||
### 🚀 Getting Started & Setup
|
||||
- [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) - Set up development environment with emulator
|
||||
- [TRIXIE_UPGRADE_GUIDE.md](TRIXIE_UPGRADE_GUIDE.md) - Upgrade to Raspbian OS 13 "Trixie"
|
||||
- [TROUBLESHOOTING_QUICK_START.md](TROUBLESHOOTING_QUICK_START.md) - Common issues and solutions
|
||||
### 🚀 Getting Started & User Guides
|
||||
- [GETTING_STARTED.md](GETTING_STARTED.md) - First-time setup and quick start guide
|
||||
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Complete web interface user guide
|
||||
- [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi configuration and AP mode setup
|
||||
- [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Installing and managing plugins
|
||||
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Common issues and solutions
|
||||
|
||||
### 🏗️ Architecture & Design
|
||||
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete plugin system specification
|
||||
- [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Plugin system implementation details
|
||||
- [FEATURE_IMPLEMENTATION_SUMMARY.md](FEATURE_IMPLEMENTATION_SUMMARY.md) - Major feature implementations
|
||||
- [NESTED_CONFIG_SCHEMAS.md](NESTED_CONFIG_SCHEMAS.md) - Configuration schema design
|
||||
- [NESTED_SCHEMA_IMPLEMENTATION.md](NESTED_SCHEMA_IMPLEMENTATION.md) - Schema implementation details
|
||||
- [NESTED_SCHEMA_VISUAL_COMPARISON.md](NESTED_SCHEMA_VISUAL_COMPARISON.md) - Schema comparison visuals
|
||||
|
||||
### ⚙️ Configuration & Management
|
||||
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) - Complete plugin configuration guide
|
||||
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) - Configuration tabs feature
|
||||
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) - Quick configuration guide
|
||||
### ⚡ Advanced Features
|
||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) - Vegas scroll mode, on-demand display, cache management, background services, permissions
|
||||
|
||||
### 🔌 Plugin Development
|
||||
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete plugin development guide
|
||||
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete plugin development workflow
|
||||
- [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) - Plugin development quick reference
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Complete API reference for plugin developers
|
||||
- [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
|
||||
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) - Setting up plugin registry
|
||||
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration schema design
|
||||
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) - Configuration tabs feature
|
||||
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) - Quick configuration guide
|
||||
- [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md) - Managing plugin dependencies
|
||||
- [PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) - Dependency troubleshooting
|
||||
|
||||
### 🎮 Plugin Features
|
||||
- [ON_DEMAND_DISPLAY_QUICK_START.md](ON_DEMAND_DISPLAY_QUICK_START.md) - Manual display triggering
|
||||
- [PLUGIN_LIVE_PRIORITY_QUICK_START.md](PLUGIN_LIVE_PRIORITY_QUICK_START.md) - Live content priority
|
||||
- [PLUGIN_LIVE_PRIORITY_API.md](PLUGIN_LIVE_PRIORITY_API.md) - Live priority API reference
|
||||
- [PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md) - Custom plugin icons
|
||||
- [PLUGIN_DISPATCH_IMPLEMENTATION.md](PLUGIN_DISPATCH_IMPLEMENTATION.md) - Plugin dispatch system
|
||||
- [PLUGIN_TABS_FEATURE_COMPLETE.md](PLUGIN_TABS_FEATURE_COMPLETE.md) - Plugin tabs feature
|
||||
### 🏗️ Plugin Features & Extensions
|
||||
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) - Custom plugin icons
|
||||
- [PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md) - Custom icons implementation
|
||||
- [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Plugin system implementation
|
||||
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) - Setting up plugin registry
|
||||
- [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md) - Web UI actions for plugins
|
||||
|
||||
### 📡 API Reference
|
||||
- [API_REFERENCE.md](API_REFERENCE.md) - Complete REST API documentation for web interface
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin developer API reference (Display Manager, Cache Manager, Plugin Manager)
|
||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API documentation (71+ endpoints)
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin developer API (Display Manager, Cache Manager, Plugin Manager)
|
||||
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) - Quick reference for common developer tasks
|
||||
- [ON_DEMAND_DISPLAY_API.md](ON_DEMAND_DISPLAY_API.md) - On-demand display API reference
|
||||
|
||||
### 🏛️ Architecture & Design
|
||||
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete plugin system specification
|
||||
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) - Configuration system architecture
|
||||
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) - Core configuration properties
|
||||
|
||||
### 🛠️ Development & Tools
|
||||
- [BACKGROUND_SERVICE_README.md](BACKGROUND_SERVICE_README.md) - Background service architecture
|
||||
- [FONT_MANAGER_USAGE.md](FONT_MANAGER_USAGE.md) - Font management system
|
||||
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment setup
|
||||
- [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) - Set up development environment with emulator
|
||||
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) - Testing documentation
|
||||
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) - Multi-workspace development
|
||||
- [FONT_MANAGER.md](FONT_MANAGER.md) - Font management system
|
||||
|
||||
### 🔍 Analysis & Compatibility
|
||||
- [RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md](RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md) - Detailed Trixie compatibility analysis
|
||||
- [CONFIGURATION_CLEANUP_SUMMARY.md](CONFIGURATION_CLEANUP_SUMMARY.md) - Configuration cleanup details
|
||||
- [football_plugin_comparison.md](football_plugin_comparison.md) - Football plugin analysis
|
||||
### 🔄 Migration & Updates
|
||||
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) - Breaking changes and migration instructions
|
||||
- [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) - SSH troubleshooting after install
|
||||
|
||||
### 📊 Utility & Scripts
|
||||
- [README_broadcast_logo_analyzer.md](README_broadcast_logo_analyzer.md) - Broadcast logo analysis tool
|
||||
- [README_soccer_logos.md](README_soccer_logos.md) - Soccer logo management
|
||||
- [WEB_INTERFACE_TROUBLESHOOTING.md](WEB_INTERFACE_TROUBLESHOOTING.md) - Web interface troubleshooting
|
||||
|
||||
## 🔄 Migration & Updates
|
||||
|
||||
### Recent Consolidations (October 2025)
|
||||
- **Implementation Summaries**: Consolidated 7 separate implementation summaries into 2 comprehensive guides:
|
||||
- `FEATURE_IMPLEMENTATION_SUMMARY.md` (AP Top 25, Plugin System, Configuration, Web Interface, Trixie Compatibility)
|
||||
- `PLUGIN_IMPLEMENTATION_SUMMARY.md` (Plugin system technical details)
|
||||
- **Trixie Documentation**: Merged 4 Trixie-related documents into `TRIXIE_UPGRADE_GUIDE.md`
|
||||
- **Removed Redundancy**: Eliminated duplicate documents and outdated debug guides
|
||||
- **Total Reduction**: 53 → 39 documents (26% reduction)
|
||||
|
||||
### Migration Notes
|
||||
- Old implementation summary documents have been consolidated
|
||||
- Trixie upgrade information is now centralized in one guide
|
||||
- Deprecated manager documentation has been removed (no longer applicable)
|
||||
- Very specific debug documents have been archived or removed
|
||||
### 📚 Miscellaneous
|
||||
- [widget-guide.md](widget-guide.md) - Widget development guide
|
||||
- Template files:
|
||||
- [plugin_registry_template.json](plugin_registry_template.json) - Plugin registry template
|
||||
- [PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json](PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json) - Web UI actions example
|
||||
|
||||
## 🎯 Key Resources by Use Case
|
||||
|
||||
### I'm new to LEDMatrix
|
||||
1. [Main README](../README.md) - Installation and setup
|
||||
2. [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) - Development environment
|
||||
3. [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) - Understanding the system
|
||||
1. [GETTING_STARTED.md](GETTING_STARTED.md) - Start here for first-time setup
|
||||
2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Learn the control panel
|
||||
3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Install plugins
|
||||
|
||||
### I want to create a plugin
|
||||
1. [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete development guide
|
||||
2. [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Available methods and APIs
|
||||
3. [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
|
||||
3. [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns
|
||||
4. [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
|
||||
5. [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete specification
|
||||
|
||||
### I want to upgrade to Trixie
|
||||
1. [TRIXIE_UPGRADE_GUIDE.md](TRIXIE_UPGRADE_GUIDE.md) - Complete upgrade guide
|
||||
2. [RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md](RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md) - Technical details
|
||||
|
||||
### I need to troubleshoot an issue
|
||||
1. [TROUBLESHOOTING_QUICK_START.md](TROUBLESHOOTING_QUICK_START.md) - Common issues
|
||||
2. [WEB_INTERFACE_TROUBLESHOOTING.md](WEB_INTERFACE_TROUBLESHOOTING.md) - Web interface problems
|
||||
1. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Comprehensive troubleshooting guide
|
||||
2. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi/network issues
|
||||
3. [PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) - Dependency issues
|
||||
|
||||
### I want to use advanced features
|
||||
1. [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) - Vegas scroll, on-demand display, background services
|
||||
2. [FONT_MANAGER.md](FONT_MANAGER.md) - Font management
|
||||
3. [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - API integration
|
||||
|
||||
### I want to understand the architecture
|
||||
1. [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - System architecture
|
||||
2. [FEATURE_IMPLEMENTATION_SUMMARY.md](FEATURE_IMPLEMENTATION_SUMMARY.md) - Feature overview
|
||||
2. [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) - Configuration architecture
|
||||
3. [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Implementation details
|
||||
|
||||
## 🔄 Recent Consolidations (January 2026)
|
||||
|
||||
### Major Consolidation Effort
|
||||
- **Before**: 51 main documentation files
|
||||
- **After**: 16-17 well-organized files
|
||||
- **Reduction**: ~68% fewer files
|
||||
- **Archived**: 33 files (consolidated sources + ephemeral docs)
|
||||
|
||||
### New Consolidated Guides
|
||||
- **GETTING_STARTED.md** - New first-time user guide
|
||||
- **WEB_INTERFACE_GUIDE.md** - Consolidated web interface documentation
|
||||
- **WIFI_NETWORK_SETUP.md** - Consolidated WiFi setup (5 files → 1)
|
||||
- **PLUGIN_STORE_GUIDE.md** - Consolidated plugin store guides (2 files → 1)
|
||||
- **TROUBLESHOOTING.md** - Consolidated troubleshooting (4 files → 1)
|
||||
- **ADVANCED_FEATURES.md** - Consolidated advanced features (6 files → 1)
|
||||
|
||||
### What Was Archived
|
||||
- Ephemeral debug documents (DEBUG_WEB_ISSUE.md, BROWSER_ERRORS_EXPLANATION.md, etc.)
|
||||
- Implementation summaries (PLUGIN_CONFIG_TABS_SUMMARY.md, STARTUP_OPTIMIZATION_SUMMARY.md, etc.)
|
||||
- Consolidated source files (WIFI_SETUP.md, V3_INTERFACE_README.md, etc.)
|
||||
- Testing documentation (CAPTIVE_PORTAL_TESTING.md, etc.)
|
||||
|
||||
All archived files are preserved in `docs/archive/` with full git history.
|
||||
|
||||
### Benefits
|
||||
- ✅ Easier to find information (fewer files to search)
|
||||
- ✅ No duplicate content
|
||||
- ✅ Consistent writing style (professional technical)
|
||||
- ✅ Updated outdated references
|
||||
- ✅ Fixed broken internal links
|
||||
- ✅ Better organization for users vs developers
|
||||
|
||||
## 📝 Contributing to Documentation
|
||||
|
||||
### Documentation Standards
|
||||
- Use Markdown format with consistent headers
|
||||
- Professional technical writing style
|
||||
- Minimal emojis (1-2 per major section for navigation)
|
||||
- Include code examples where helpful
|
||||
- Provide both quick start and detailed reference sections
|
||||
- Keep implementation summaries focused on what was built, not how to use
|
||||
- Cross-reference related documentation
|
||||
|
||||
### Adding New Documentation
|
||||
1. Place in appropriate category (see sections above)
|
||||
2. Update this README.md with the new document
|
||||
3. Follow naming conventions (FEATURE_NAME.md)
|
||||
4. Consider if content should be consolidated with existing docs
|
||||
1. Consider if content should be added to existing docs first
|
||||
2. Place in appropriate category (see sections above)
|
||||
3. Update this README.md with the new document
|
||||
4. Follow naming conventions (FEATURE_NAME.md)
|
||||
5. Use consistent formatting and voice
|
||||
|
||||
### Consolidation Guidelines
|
||||
- **Implementation Summaries**: Consolidate into feature-specific summaries
|
||||
- **Quick References**: Keep if they provide unique value, otherwise merge
|
||||
- **Debug Documents**: Remove after issues are resolved
|
||||
- **Migration Guides**: Consolidate when migrations are complete
|
||||
- **User Guides**: Consolidate by topic (WiFi, troubleshooting, etc.)
|
||||
- **Developer Guides**: Keep development vs reference vs architecture separate
|
||||
- **Debug Documents**: Archive after issues are resolved
|
||||
- **Implementation Summaries**: Archive completed implementation details
|
||||
- **Ephemeral Content**: Archive, don't keep in main docs
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [Main Project README](../README.md) - Installation and basic usage
|
||||
- [Web Interface README](../web_interface/README.md) - Web interface details
|
||||
- [LEDMatrix Wiki](../LEDMatrix.wiki/) - Extended documentation and guides
|
||||
- [GitHub Issues](https://github.com/ChuckBuilds/LEDMatrix/issues) - Bug reports and feature requests
|
||||
- [GitHub Discussions](https://github.com/ChuckBuilds/LEDMatrix/discussions) - Community support
|
||||
|
||||
## 📊 Documentation Statistics
|
||||
|
||||
- **Total Documents**: ~35 (after consolidation)
|
||||
- **Categories**: 8 major sections (including new API Reference section)
|
||||
- **Primary Languages**: English
|
||||
- **Main Documents**: 16-17 files (after consolidation)
|
||||
- **Archived Documents**: 33 files (in docs/archive/)
|
||||
- **Categories**: 9 major sections
|
||||
- **Primary Language**: English
|
||||
- **Format**: Markdown (.md)
|
||||
- **Last Update**: December 2025
|
||||
- **Coverage**: Installation, development, troubleshooting, architecture, API references
|
||||
- **Last Major Update**: January 2026
|
||||
- **Coverage**: Installation, user guides, development, troubleshooting, architecture, API references
|
||||
|
||||
### Recent Improvements (December 2025)
|
||||
- ✅ Complete REST API documentation (50+ endpoints)
|
||||
### Documentation Highlights
|
||||
- ✅ Comprehensive user guides for first-time setup
|
||||
- ✅ Complete REST API documentation (71+ endpoints)
|
||||
- ✅ Complete Plugin API reference (Display Manager, Cache Manager, Plugin Manager)
|
||||
- ✅ Advanced plugin development guide with examples
|
||||
- ✅ Consolidated plugin configuration documentation
|
||||
- ✅ Developer quick reference guide
|
||||
- ✅ Better organization for end users and developers
|
||||
- ✅ Consolidated configuration documentation
|
||||
- ✅ Professional technical writing throughout
|
||||
- ✅ ~68% reduction in file count while maintaining coverage
|
||||
|
||||
---
|
||||
|
||||
*This documentation index was last updated: December 2025*
|
||||
*This documentation index was last updated: January 2026*
|
||||
|
||||
*For questions or suggestions about the documentation, please open an issue or start a discussion on GitHub.*
|
||||
|
||||
915
docs/TROUBLESHOOTING.md
Normal file
915
docs/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,915 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
## Quick Diagnosis Steps
|
||||
|
||||
Run these checks first to quickly identify common issues:
|
||||
|
||||
### 1. Check Service Status
|
||||
|
||||
```bash
|
||||
# Check all LEDMatrix services
|
||||
sudo systemctl status ledmatrix
|
||||
sudo systemctl status ledmatrix-web
|
||||
sudo systemctl status ledmatrix-wifi-monitor
|
||||
|
||||
# Check AP mode services (if using WiFi)
|
||||
sudo systemctl status hostapd
|
||||
sudo systemctl status dnsmasq
|
||||
```
|
||||
|
||||
**Note:** Look for `active (running)` status and check for error messages in the output.
|
||||
|
||||
### 2. View Service Logs
|
||||
|
||||
**IMPORTANT:** The web service logs to **syslog**, NOT stdout. Use `journalctl` to view logs:
|
||||
|
||||
```bash
|
||||
# View all recent logs
|
||||
sudo journalctl -u ledmatrix -n 50
|
||||
sudo journalctl -u ledmatrix-web -n 50
|
||||
|
||||
# Follow logs in real-time
|
||||
sudo journalctl -u ledmatrix -f
|
||||
|
||||
# View logs from last hour
|
||||
sudo journalctl -u ledmatrix-web --since "1 hour ago"
|
||||
|
||||
# Filter for errors only
|
||||
sudo journalctl -u ledmatrix -p err
|
||||
```
|
||||
|
||||
### 3. Run Diagnostic Scripts
|
||||
|
||||
```bash
|
||||
# Web interface diagnostics
|
||||
bash scripts/diagnose_web_interface.sh
|
||||
|
||||
# WiFi setup verification
|
||||
./scripts/verify_wifi_setup.sh
|
||||
|
||||
# Weather plugin troubleshooting
|
||||
./troubleshoot_weather.sh
|
||||
|
||||
# Captive portal troubleshooting
|
||||
./scripts/troubleshoot_captive_portal.sh
|
||||
```
|
||||
|
||||
### 4. Check Configuration
|
||||
|
||||
```bash
|
||||
# Verify web interface autostart
|
||||
cat config/config.json | grep web_display_autostart
|
||||
|
||||
# Check plugin enabled status
|
||||
cat config/config.json | grep -A 2 "plugin-id"
|
||||
|
||||
# Verify API keys present
|
||||
ls -l config/config_secrets.json
|
||||
```
|
||||
|
||||
### 5. Test Manual Startup
|
||||
|
||||
```bash
|
||||
# Test web interface manually
|
||||
python3 web_interface/start.py
|
||||
|
||||
# If it works manually but not as a service, check systemd service file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues by Category
|
||||
|
||||
### Web Interface & Service Issues
|
||||
|
||||
#### Service Not Running/Starting
|
||||
|
||||
**Symptoms:**
|
||||
- Cannot access web interface at http://your-pi-ip:5050
|
||||
- `systemctl status ledmatrix-web` shows `inactive (dead)`
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Start the service:**
|
||||
```bash
|
||||
sudo systemctl start ledmatrix-web
|
||||
```
|
||||
|
||||
2. **Enable on boot:**
|
||||
```bash
|
||||
sudo systemctl enable ledmatrix-web
|
||||
```
|
||||
|
||||
3. **Check why it failed:**
|
||||
```bash
|
||||
sudo journalctl -u ledmatrix-web -n 50
|
||||
```
|
||||
|
||||
#### web_display_autostart is False
|
||||
|
||||
**Symptoms:**
|
||||
- Service exists but web interface doesn't start automatically
|
||||
- Logs show service starting but nothing happens
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Edit config.json
|
||||
nano config/config.json
|
||||
|
||||
# Set web_display_autostart to true
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
...
|
||||
}
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart ledmatrix-web
|
||||
```
|
||||
|
||||
#### Import or Dependency Errors
|
||||
|
||||
**Symptoms:**
|
||||
- Logs show `ModuleNotFoundError` or `ImportError`
|
||||
- Service fails to start with Python errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
pip3 install --break-system-packages -r requirements.txt
|
||||
pip3 install --break-system-packages -r web_interface/requirements.txt
|
||||
```
|
||||
|
||||
2. **Test imports step-by-step:**
|
||||
```bash
|
||||
python3 -c "from src.config_manager import ConfigManager; print('OK')"
|
||||
python3 -c "from src.plugin_system.plugin_manager import PluginManager; print('OK')"
|
||||
python3 -c "from web_interface.app import app; print('OK')"
|
||||
```
|
||||
|
||||
3. **Check Python path:**
|
||||
```bash
|
||||
python3 -c "import sys; print(sys.path)"
|
||||
```
|
||||
|
||||
#### Port Already in Use
|
||||
|
||||
**Symptoms:**
|
||||
- Error: `Address already in use`
|
||||
- Service fails to bind to port 5050
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check what's using the port:**
|
||||
```bash
|
||||
sudo lsof -i :5050
|
||||
```
|
||||
|
||||
2. **Kill the conflicting process:**
|
||||
```bash
|
||||
sudo kill -9 <PID>
|
||||
```
|
||||
|
||||
3. **Or change the port in start.py:**
|
||||
```python
|
||||
app.run(host='0.0.0.0', port=5051)
|
||||
```
|
||||
|
||||
#### Permission Issues
|
||||
|
||||
**Symptoms:**
|
||||
- `Permission denied` errors in logs
|
||||
- Cannot read/write configuration files
|
||||
|
||||
**Solutions:**
|
||||
|
||||
```bash
|
||||
# Fix ownership of LEDMatrix directory
|
||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix
|
||||
|
||||
# Fix config file permissions
|
||||
sudo chmod 644 config/config.json
|
||||
sudo chmod 640 config/config_secrets.json
|
||||
|
||||
# Verify service runs as correct user
|
||||
sudo systemctl cat ledmatrix-web | grep User
|
||||
```
|
||||
|
||||
#### Flask/Blueprint Import Errors
|
||||
|
||||
**Symptoms:**
|
||||
- `ImportError: cannot import name 'app'`
|
||||
- `ModuleNotFoundError: No module named 'blueprints'`
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Verify file structure:**
|
||||
```bash
|
||||
ls -l web_interface/app.py
|
||||
ls -l web_interface/blueprints/api_v3.py
|
||||
ls -l web_interface/blueprints/pages_v3.py
|
||||
```
|
||||
|
||||
2. **Check for __init__.py files:**
|
||||
```bash
|
||||
ls -l web_interface/__init__.py
|
||||
ls -l web_interface/blueprints/__init__.py
|
||||
```
|
||||
|
||||
3. **Test import manually:**
|
||||
```bash
|
||||
cd /home/ledpi/LEDMatrix
|
||||
python3 -c "from web_interface.app import app"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WiFi & AP Mode Issues
|
||||
|
||||
#### AP Mode Not Activating
|
||||
|
||||
**Symptoms:**
|
||||
- WiFi disconnected but AP mode doesn't start
|
||||
- Cannot find "LEDMatrix-Setup" network
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check auto-enable setting:**
|
||||
```bash
|
||||
cat config/wifi_config.json | grep auto_enable_ap_mode
|
||||
# Should show: "auto_enable_ap_mode": true
|
||||
```
|
||||
|
||||
2. **Verify WiFi monitor service is running:**
|
||||
```bash
|
||||
sudo systemctl status ledmatrix-wifi-monitor
|
||||
```
|
||||
|
||||
3. **Wait for grace period (90 seconds):**
|
||||
- AP mode requires 3 consecutive disconnected checks at 30-second intervals
|
||||
- Total wait time: 90 seconds after WiFi disconnects
|
||||
|
||||
4. **Check if Ethernet is connected:**
|
||||
```bash
|
||||
nmcli device status
|
||||
# If Ethernet is connected, AP mode won't activate
|
||||
```
|
||||
|
||||
5. **Check required services:**
|
||||
```bash
|
||||
sudo systemctl status hostapd
|
||||
sudo systemctl status dnsmasq
|
||||
```
|
||||
|
||||
6. **Manually enable AP mode:**
|
||||
```bash
|
||||
# Via API
|
||||
curl -X POST http://localhost:5050/api/wifi/ap/enable
|
||||
|
||||
# Via Python
|
||||
python3 -c "
|
||||
from src.wifi_manager import WiFiManager
|
||||
wm = WiFiManager()
|
||||
wm.enable_ap_mode()
|
||||
"
|
||||
```
|
||||
|
||||
#### Cannot Connect to AP Mode / Connection Refused
|
||||
|
||||
**Symptoms:**
|
||||
- Can see "LEDMatrix-Setup" network but can't connect to web interface
|
||||
- Browser shows "Connection Refused" or "Can't connect to server"
|
||||
- AP mode active but web interface not accessible
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Verify web server is running:**
|
||||
```bash
|
||||
sudo systemctl status ledmatrix-web
|
||||
# Should be active (running)
|
||||
```
|
||||
|
||||
2. **Use correct IP address and port:**
|
||||
- Correct: `http://192.168.4.1:5050`
|
||||
- NOT: `http://192.168.4.1` (port 80)
|
||||
- NOT: `http://192.168.4.1:5000`
|
||||
|
||||
3. **Check wlan0 has correct IP:**
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
# Should show: inet 192.168.4.1/24
|
||||
```
|
||||
|
||||
4. **Verify hostapd and dnsmasq are running:**
|
||||
```bash
|
||||
sudo systemctl status hostapd
|
||||
sudo systemctl status dnsmasq
|
||||
```
|
||||
|
||||
5. **Test from the Pi itself:**
|
||||
```bash
|
||||
curl http://192.168.4.1:5050
|
||||
# Should return HTML
|
||||
```
|
||||
|
||||
#### DNS Resolution Failures
|
||||
|
||||
**Symptoms:**
|
||||
- Captive portal doesn't redirect automatically
|
||||
- DNS lookups fail when connected to AP mode
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check dnsmasq status:**
|
||||
```bash
|
||||
sudo systemctl status dnsmasq
|
||||
sudo journalctl -u dnsmasq -n 20
|
||||
```
|
||||
|
||||
2. **Verify DNS configuration:**
|
||||
```bash
|
||||
cat /etc/dnsmasq.conf | grep -v "^#" | grep -v "^$"
|
||||
```
|
||||
|
||||
3. **Test DNS resolution:**
|
||||
```bash
|
||||
nslookup captive.apple.com
|
||||
# Should resolve to 192.168.4.1 when in AP mode
|
||||
```
|
||||
|
||||
4. **Manual captive portal testing:**
|
||||
- Try these URLs manually:
|
||||
- `http://192.168.4.1:5050`
|
||||
- `http://captive.apple.com`
|
||||
- `http://connectivitycheck.gstatic.com/generate_204`
|
||||
|
||||
#### Firewall Blocking Port 5050
|
||||
|
||||
**Symptoms:**
|
||||
- Services running but cannot connect
|
||||
- Works from Pi but not from other devices
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check UFW status:**
|
||||
```bash
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
2. **Allow port 5050:**
|
||||
```bash
|
||||
sudo ufw allow 5050/tcp
|
||||
```
|
||||
|
||||
3. **Check iptables:**
|
||||
```bash
|
||||
sudo iptables -L -n
|
||||
```
|
||||
|
||||
4. **Temporarily disable firewall to test:**
|
||||
```bash
|
||||
sudo ufw disable
|
||||
# Test if it works, then re-enable and add rule
|
||||
sudo ufw enable
|
||||
sudo ufw allow 5050/tcp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Plugin Issues
|
||||
|
||||
#### Plugin Not Enabled
|
||||
|
||||
**Symptoms:**
|
||||
- Plugin installed but doesn't appear in rotation
|
||||
- Plugin shows in web interface but is greyed out
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Enable in configuration:**
|
||||
```json
|
||||
{
|
||||
"plugin-id": {
|
||||
"enabled": true,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Restart display:**
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
3. **Verify in web interface:**
|
||||
- Navigate to Plugin Management tab
|
||||
- Toggle the switch to enable
|
||||
- Restart display
|
||||
|
||||
#### Plugin Not Loading
|
||||
|
||||
**Symptoms:**
|
||||
- Plugin enabled but not showing
|
||||
- Errors in logs about plugin
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check plugin directory exists:**
|
||||
```bash
|
||||
ls -ld plugins/plugin-id/
|
||||
```
|
||||
|
||||
2. **Verify manifest.json:**
|
||||
```bash
|
||||
cat plugins/plugin-id/manifest.json
|
||||
# Verify all required fields present
|
||||
```
|
||||
|
||||
3. **Check dependencies installed:**
|
||||
```bash
|
||||
if [ -f plugins/plugin-id/requirements.txt ]; then
|
||||
pip3 install --break-system-packages -r plugins/plugin-id/requirements.txt
|
||||
fi
|
||||
```
|
||||
|
||||
4. **Check logs for plugin errors:**
|
||||
```bash
|
||||
sudo journalctl -u ledmatrix -f | grep plugin-id
|
||||
```
|
||||
|
||||
5. **Test plugin import:**
|
||||
```bash
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'plugins/plugin-id')
|
||||
from manager import PluginClass
|
||||
print('Plugin imports successfully')
|
||||
"
|
||||
```
|
||||
|
||||
#### Stale Cache Data
|
||||
|
||||
**Symptoms:**
|
||||
- Plugin shows old data
|
||||
- Data doesn't update even after restarting
|
||||
- Clearing cache in web interface doesn't help
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Manual cache clearing:**
|
||||
```bash
|
||||
# Remove plugin-specific cache
|
||||
rm -rf cache/plugin-id*
|
||||
|
||||
# Or remove all cache
|
||||
rm -rf cache/*
|
||||
|
||||
# Restart display
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
2. **Check cache permissions:**
|
||||
```bash
|
||||
ls -ld cache/
|
||||
sudo chown -R ledpi:ledpi cache/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Weather Plugin Specific Issues
|
||||
|
||||
#### Missing or Invalid API Key
|
||||
|
||||
**Symptoms:**
|
||||
- "No Weather Data" message on display
|
||||
- Logs show API authentication errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Get OpenWeatherMap API key:**
|
||||
- Sign up at https://openweathermap.org/api
|
||||
- Free tier: 1,000 calls/day, 60 calls/minute
|
||||
- Copy your API key
|
||||
|
||||
2. **Add to config_secrets.json (recommended):**
|
||||
```json
|
||||
{
|
||||
"openweathermap_api_key": "your-api-key-here"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Or add to config.json:**
|
||||
```json
|
||||
{
|
||||
"ledmatrix-weather": {
|
||||
"enabled": true,
|
||||
"openweathermap_api_key": "your-api-key-here",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Secure the API key file:**
|
||||
```bash
|
||||
chmod 640 config/config_secrets.json
|
||||
```
|
||||
|
||||
5. **Restart display:**
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
#### API Rate Limits Exceeded
|
||||
|
||||
**Symptoms:**
|
||||
- Weather works initially then stops
|
||||
- Logs show HTTP 429 errors (Too Many Requests)
|
||||
- Error message: "Rate limit exceeded"
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Increase update interval:**
|
||||
```json
|
||||
{
|
||||
"ledmatrix-weather": {
|
||||
"update_interval": 300,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
**Note:** Minimum recommended: 300 seconds (5 minutes)
|
||||
|
||||
2. **Check current rate limit usage:**
|
||||
- OpenWeatherMap free tier: 1,000 calls/day, 60 calls/minute
|
||||
- With 300s interval: 288 calls/day (well within limits)
|
||||
|
||||
3. **Monitor API calls:**
|
||||
```bash
|
||||
sudo journalctl -u ledmatrix -f | grep "openweathermap"
|
||||
```
|
||||
|
||||
#### Invalid Location Configuration
|
||||
|
||||
**Symptoms:**
|
||||
- "No Weather Data" message
|
||||
- Logs show location not found errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Use correct location format:**
|
||||
```json
|
||||
{
|
||||
"ledmatrix-weather": {
|
||||
"city": "Tampa",
|
||||
"state": "FL",
|
||||
"country": "US"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use ISO country codes:**
|
||||
- US = United States
|
||||
- GB = United Kingdom
|
||||
- CA = Canada
|
||||
- etc.
|
||||
|
||||
3. **Test API call manually:**
|
||||
```bash
|
||||
API_KEY="your-key-here"
|
||||
curl "http://api.openweathermap.org/data/2.5/weather?q=Tampa,FL,US&appid=${API_KEY}"
|
||||
```
|
||||
|
||||
#### Network Connectivity to OpenWeatherMap
|
||||
|
||||
**Symptoms:**
|
||||
- Other internet features work
|
||||
- Weather specifically fails
|
||||
- Connection timeout errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Test connectivity:**
|
||||
```bash
|
||||
ping api.openweathermap.org
|
||||
```
|
||||
|
||||
2. **Test DNS resolution:**
|
||||
```bash
|
||||
nslookup api.openweathermap.org
|
||||
```
|
||||
|
||||
3. **Test API endpoint:**
|
||||
```bash
|
||||
curl -I https://api.openweathermap.org
|
||||
# Should return HTTP 200 or 301
|
||||
```
|
||||
|
||||
4. **Check firewall:**
|
||||
```bash
|
||||
# Ensure HTTPS (443) is allowed for outbound connections
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic Commands Reference
|
||||
|
||||
### Service Commands
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
sudo systemctl status ledmatrix
|
||||
sudo systemctl status ledmatrix-web
|
||||
sudo systemctl status ledmatrix-wifi-monitor
|
||||
|
||||
# Start service
|
||||
sudo systemctl start <service-name>
|
||||
|
||||
# Stop service
|
||||
sudo systemctl stop <service-name>
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart <service-name>
|
||||
|
||||
# Enable on boot
|
||||
sudo systemctl enable <service-name>
|
||||
|
||||
# Disable on boot
|
||||
sudo systemctl disable <service-name>
|
||||
|
||||
# View service file
|
||||
sudo systemctl cat <service-name>
|
||||
|
||||
# Reload systemd after editing service files
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
### Log Viewing Commands
|
||||
|
||||
```bash
|
||||
# View recent logs (last 50 lines)
|
||||
sudo journalctl -u ledmatrix -n 50
|
||||
|
||||
# Follow logs in real-time
|
||||
sudo journalctl -u ledmatrix -f
|
||||
|
||||
# View logs from specific time
|
||||
sudo journalctl -u ledmatrix --since "1 hour ago"
|
||||
sudo journalctl -u ledmatrix --since "2024-01-01 10:00:00"
|
||||
|
||||
# View logs until specific time
|
||||
sudo journalctl -u ledmatrix --until "2024-01-01 12:00:00"
|
||||
|
||||
# Filter by priority (errors only)
|
||||
sudo journalctl -u ledmatrix -p err
|
||||
|
||||
# Filter by priority (warnings and errors)
|
||||
sudo journalctl -u ledmatrix -p warning
|
||||
|
||||
# Search logs for specific text
|
||||
sudo journalctl -u ledmatrix | grep "error"
|
||||
sudo journalctl -u ledmatrix | grep -i "plugin"
|
||||
|
||||
# View logs for multiple services
|
||||
sudo journalctl -u ledmatrix -u ledmatrix-web -n 50
|
||||
|
||||
# Export logs to file
|
||||
sudo journalctl -u ledmatrix > ledmatrix.log
|
||||
```
|
||||
|
||||
### Network Testing Commands
|
||||
|
||||
```bash
|
||||
# Test connectivity
|
||||
ping -c 4 8.8.8.8
|
||||
ping -c 4 api.openweathermap.org
|
||||
|
||||
# Test DNS resolution
|
||||
nslookup api.openweathermap.org
|
||||
dig api.openweathermap.org
|
||||
|
||||
# Test HTTP endpoint
|
||||
curl -I http://your-pi-ip:5050
|
||||
curl http://192.168.4.1:5050
|
||||
|
||||
# Check listening ports
|
||||
sudo lsof -i :5050
|
||||
sudo netstat -tuln | grep 5050
|
||||
|
||||
# Check network interfaces
|
||||
ip addr show
|
||||
nmcli device status
|
||||
```
|
||||
|
||||
### File/Directory Verification
|
||||
|
||||
```bash
|
||||
# Check file exists
|
||||
ls -l config/config.json
|
||||
ls -l plugins/plugin-id/manifest.json
|
||||
|
||||
# Check directory structure
|
||||
ls -la web_interface/
|
||||
ls -la plugins/
|
||||
|
||||
# Check file permissions
|
||||
ls -l config/config_secrets.json
|
||||
|
||||
# Check file contents
|
||||
cat config/config.json | jq .
|
||||
cat config/wifi_config.json | grep auto_enable
|
||||
```
|
||||
|
||||
### Python Import Testing
|
||||
|
||||
```bash
|
||||
# Test core imports
|
||||
python3 -c "from src.config_manager import ConfigManager; print('OK')"
|
||||
python3 -c "from src.plugin_system.plugin_manager import PluginManager; print('OK')"
|
||||
python3 -c "from src.display_manager import DisplayManager; print('OK')"
|
||||
|
||||
# Test web interface imports
|
||||
python3 -c "from web_interface.app import app; print('OK')"
|
||||
python3 -c "from web_interface.blueprints.api_v3 import api_v3; print('OK')"
|
||||
|
||||
# Test WiFi manager
|
||||
python3 -c "from src.wifi_manager import WiFiManager; print('OK')"
|
||||
|
||||
# Test plugin import
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'plugins/plugin-id')
|
||||
from manager import PluginClass
|
||||
print('Plugin imports OK')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service File Template
|
||||
|
||||
If your systemd service file is corrupted or missing, use this template:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=LEDMatrix Web Interface
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ledpi
|
||||
Group=ledpi
|
||||
WorkingDirectory=/home/ledpi/LEDMatrix
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/web_interface/start.py
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=ledmatrix-web
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Save to `/etc/systemd/system/ledmatrix-web.service` and run:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ledmatrix-web
|
||||
sudo systemctl start ledmatrix-web
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Diagnostic Script
|
||||
|
||||
Run this script for comprehensive diagnostics:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
echo "=== LEDMatrix Diagnostic Report ==="
|
||||
echo ""
|
||||
|
||||
echo "1. Service Status:"
|
||||
systemctl status ledmatrix --no-pager -n 5
|
||||
systemctl status ledmatrix-web --no-pager -n 5
|
||||
echo ""
|
||||
|
||||
echo "2. Recent Logs:"
|
||||
journalctl -u ledmatrix -n 20 --no-pager
|
||||
echo ""
|
||||
|
||||
echo "3. Configuration:"
|
||||
cat config/config.json | grep -E "(web_display_autostart|enabled)"
|
||||
echo ""
|
||||
|
||||
echo "4. Network Status:"
|
||||
ip addr show | grep -E "(wlan|eth|inet )"
|
||||
curl -s http://localhost:5050 > /dev/null && echo "Web interface: OK" || echo "Web interface: FAILED"
|
||||
echo ""
|
||||
|
||||
echo "5. File Structure:"
|
||||
ls -la web_interface/ | head -10
|
||||
ls -la plugins/ | head -10
|
||||
echo ""
|
||||
|
||||
echo "6. Python Imports:"
|
||||
python3 -c "from src.config_manager import ConfigManager" && echo "ConfigManager: OK" || echo "ConfigManager: FAILED"
|
||||
python3 -c "from web_interface.app import app" && echo "Web app: OK" || echo "Web app: FAILED"
|
||||
echo ""
|
||||
|
||||
echo "=== End Diagnostic Report ==="
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Indicators
|
||||
|
||||
A properly functioning system should show:
|
||||
|
||||
1. **Services Running:**
|
||||
```
|
||||
● ledmatrix.service - active (running)
|
||||
● ledmatrix-web.service - active (running)
|
||||
```
|
||||
|
||||
2. **Web Interface Accessible:**
|
||||
- Navigate to http://your-pi-ip:5050
|
||||
- Page loads successfully
|
||||
- Display preview visible
|
||||
|
||||
3. **Logs Show Normal Operation:**
|
||||
```
|
||||
INFO: Web interface started on port 5050
|
||||
INFO: Loaded X plugins
|
||||
INFO: Display rotation active
|
||||
```
|
||||
|
||||
4. **Process Listening on Port:**
|
||||
```bash
|
||||
$ sudo lsof -i :5050
|
||||
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||
python3 1234 ledpi 3u IPv4 12345 0t0 TCP *:5050 (LISTEN)
|
||||
```
|
||||
|
||||
5. **Plugins Loading:**
|
||||
- Logs show plugin initialization
|
||||
- Plugins appear in web interface
|
||||
- Display cycles through enabled plugins
|
||||
|
||||
---
|
||||
|
||||
## Emergency Recovery
|
||||
|
||||
If the system is completely broken:
|
||||
|
||||
### 1. Git Rollback
|
||||
|
||||
```bash
|
||||
# View recent commits
|
||||
git log --oneline -10
|
||||
|
||||
# Rollback to previous commit
|
||||
git reset --hard HEAD~1
|
||||
|
||||
# Or rollback to specific commit
|
||||
git reset --hard <commit-hash>
|
||||
|
||||
# Restart all services
|
||||
sudo systemctl restart ledmatrix
|
||||
sudo systemctl restart ledmatrix-web
|
||||
```
|
||||
|
||||
### 2. Fresh Service Installation
|
||||
|
||||
```bash
|
||||
# Reinstall WiFi monitor
|
||||
sudo ./scripts/install/install_wifi_monitor.sh
|
||||
|
||||
# Recreate service files from templates
|
||||
sudo cp templates/ledmatrix.service /etc/systemd/system/
|
||||
sudo cp templates/ledmatrix-web.service /etc/systemd/system/
|
||||
|
||||
# Reload and restart
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ledmatrix ledmatrix-web
|
||||
```
|
||||
|
||||
### 3. Full System Reboot
|
||||
|
||||
```bash
|
||||
# As a last resort
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Web interface usage
|
||||
- [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi configuration
|
||||
- [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Plugin installation
|
||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - API documentation
|
||||
442
docs/WEB_INTERFACE_GUIDE.md
Normal file
442
docs/WEB_INTERFACE_GUIDE.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# Web Interface Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The LEDMatrix web interface provides a complete control panel for managing your LED matrix display. Access all features through a modern, responsive web interface that works on desktop, tablet, and mobile devices.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Accessing the Interface
|
||||
|
||||
1. Find your Raspberry Pi's IP address:
|
||||
```bash
|
||||
hostname -I
|
||||
```
|
||||
|
||||
2. Open a web browser and navigate to:
|
||||
```
|
||||
http://your-pi-ip:5050
|
||||
```
|
||||
|
||||
3. The interface will load with the Overview tab displaying system stats and a live display preview.
|
||||
|
||||
**Note:** If the interface doesn't load, verify the web service is running:
|
||||
```bash
|
||||
sudo systemctl status ledmatrix-web
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
The interface uses a tab-based layout for easy navigation between features:
|
||||
|
||||
- **Overview** - System stats, quick actions, and display preview
|
||||
- **General Settings** - Timezone, location, and autostart configuration
|
||||
- **Display Settings** - Hardware configuration, brightness, and display options
|
||||
- **Durations** - Display rotation timing configuration
|
||||
- **Sports Configuration** - Per-league settings and on-demand modes
|
||||
- **Plugin Management** - Install, configure, enable/disable plugins
|
||||
- **Plugin Store** - Discover and install plugins
|
||||
- **Font Management** - Upload fonts, manage overrides, and preview
|
||||
- **Logs** - Real-time log streaming with filtering and search
|
||||
|
||||
---
|
||||
|
||||
## Features and Usage
|
||||
|
||||
### Overview Tab
|
||||
|
||||
The Overview tab provides at-a-glance information and quick actions:
|
||||
|
||||
**System Stats:**
|
||||
- CPU usage and temperature
|
||||
- Memory usage
|
||||
- Disk usage
|
||||
- Network status
|
||||
|
||||
**Quick Actions:**
|
||||
- **Start/Stop Display** - Control the display service
|
||||
- **Restart Display** - Restart to apply configuration changes
|
||||
- **Test Display** - Run a quick test pattern
|
||||
|
||||
**Display Preview:**
|
||||
- Live preview of what's currently shown on the LED matrix
|
||||
- Updates in real-time
|
||||
- Useful for remote monitoring
|
||||
|
||||
### General Settings Tab
|
||||
|
||||
Configure basic system settings:
|
||||
|
||||
**Timezone:**
|
||||
- Set your local timezone for accurate time display
|
||||
- Auto-detects common timezones
|
||||
|
||||
**Location:**
|
||||
- Set latitude/longitude for location-based features
|
||||
- Used by weather plugins and sunrise/sunset calculations
|
||||
|
||||
**Autostart:**
|
||||
- Enable/disable display autostart on boot
|
||||
- Configure systemd service settings
|
||||
|
||||
**Save Changes:**
|
||||
- Click "Save Configuration" to apply changes
|
||||
- Restart the display for changes to take effect
|
||||
|
||||
### Display Settings Tab
|
||||
|
||||
Configure your LED matrix hardware:
|
||||
|
||||
**Matrix Configuration:**
|
||||
- Rows: Number of LED rows (typically 32 or 64)
|
||||
- Columns: Number of LED columns (typically 64, 128, or 256)
|
||||
- Chain Length: Number of chained panels
|
||||
- Parallel Chains: Number of parallel chains
|
||||
|
||||
**Display Options:**
|
||||
- Brightness: Adjust LED brightness (0-100%)
|
||||
- Hardware Mapping: GPIO pin mapping
|
||||
- Slowdown GPIO: Timing adjustment for compatibility
|
||||
|
||||
**Save and Apply:**
|
||||
- Changes require a display restart
|
||||
- Use "Test Display" to verify configuration
|
||||
|
||||
### Durations Tab
|
||||
|
||||
Control how long each plugin displays:
|
||||
|
||||
**Global Settings:**
|
||||
- Default Duration: Default time for plugins without specific durations
|
||||
- Transition Speed: Speed of transitions between plugins
|
||||
|
||||
**Per-Plugin Durations:**
|
||||
- Set custom display duration for each plugin
|
||||
- Override global default for specific plugins
|
||||
- Measured in seconds
|
||||
|
||||
### Sports Configuration Tab
|
||||
|
||||
Configure sports-specific settings:
|
||||
|
||||
**Per-League Settings:**
|
||||
- Favorite teams
|
||||
- Show favorite teams only
|
||||
- Include scores/standings
|
||||
- Refresh intervals
|
||||
|
||||
**On-Demand Modes:**
|
||||
- Live Priority: Show live games immediately
|
||||
- Game Day Mode: Enhanced display during game days
|
||||
- Score Alerts: Highlight score changes
|
||||
|
||||
### Plugin Management Tab
|
||||
|
||||
Manage installed plugins:
|
||||
|
||||
**Plugin List:**
|
||||
- View all installed plugins
|
||||
- See plugin status (enabled/disabled)
|
||||
- Check last update time
|
||||
|
||||
**Actions:**
|
||||
- **Enable/Disable**: Toggle plugin using the switch
|
||||
- **Configure**: Click ⚙️ to edit plugin settings
|
||||
- **Update**: Update plugin to latest version
|
||||
- **Uninstall**: Remove plugin completely
|
||||
|
||||
**Configuration:**
|
||||
- Edit plugin-specific settings
|
||||
- Changes are saved to `config/config.json`
|
||||
- Restart display to apply changes
|
||||
|
||||
**Note:** See [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) for information on installing plugins.
|
||||
|
||||
### Plugin Store Tab
|
||||
|
||||
Discover and install new plugins:
|
||||
|
||||
**Browse Plugins:**
|
||||
- View available plugins in the official store
|
||||
- Filter by category (sports, weather, time, finance, etc.)
|
||||
- Search by name, description, or author
|
||||
|
||||
**Install Plugins:**
|
||||
- Click "Install" next to any plugin
|
||||
- Wait for installation to complete
|
||||
- Restart the display to activate
|
||||
|
||||
**Install from URL:**
|
||||
- Install plugins from any GitHub repository
|
||||
- Paste the repository URL in the "Install from URL" section
|
||||
- Review the warning about unverified plugins
|
||||
- Click "Install from URL"
|
||||
|
||||
**Plugin Information:**
|
||||
- View plugin descriptions, ratings, and screenshots
|
||||
- Check compatibility and requirements
|
||||
- Read user reviews (when available)
|
||||
|
||||
### Font Management Tab
|
||||
|
||||
Manage fonts for your display:
|
||||
|
||||
**Upload Fonts:**
|
||||
- Drag and drop font files (.ttf, .otf, .bdf)
|
||||
- Upload multiple files at once
|
||||
- Progress indicator shows upload status
|
||||
|
||||
**Font Catalog:**
|
||||
- View all available fonts
|
||||
- See font previews
|
||||
- Check font sizes and styles
|
||||
|
||||
**Plugin Font Overrides:**
|
||||
- Set custom fonts for specific plugins
|
||||
- Override default font choices
|
||||
- Preview font changes
|
||||
|
||||
**Delete Fonts:**
|
||||
- Remove unused fonts
|
||||
- Free up disk space
|
||||
|
||||
### Logs Tab
|
||||
|
||||
View real-time system logs:
|
||||
|
||||
**Log Viewer:**
|
||||
- Streaming logs from the display service
|
||||
- Auto-scroll to latest entries
|
||||
- Timestamps for each log entry
|
||||
|
||||
**Filtering:**
|
||||
- Filter by log level (INFO, WARNING, ERROR)
|
||||
- Search for specific text
|
||||
- Filter by plugin or component
|
||||
|
||||
**Actions:**
|
||||
- **Clear**: Clear the current view
|
||||
- **Download**: Download logs for offline analysis
|
||||
- **Pause**: Pause auto-scrolling
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Changing Display Brightness
|
||||
|
||||
1. Navigate to the **Display Settings** tab
|
||||
2. Adjust the **Brightness** slider (0-100%)
|
||||
3. Click **Save Configuration**
|
||||
4. Restart the display for changes to take effect
|
||||
|
||||
### Installing a New Plugin
|
||||
|
||||
1. Navigate to the **Plugin Store** tab
|
||||
2. Browse or search for the desired plugin
|
||||
3. Click **Install** next to the plugin
|
||||
4. Wait for installation to complete
|
||||
5. Restart the display
|
||||
6. Enable the plugin in the **Plugin Management** tab
|
||||
|
||||
### Configuring a Plugin
|
||||
|
||||
1. Navigate to the **Plugin Management** tab
|
||||
2. Find the plugin you want to configure
|
||||
3. Click the ⚙️ **Configure** button
|
||||
4. Edit the settings in the form
|
||||
5. Click **Save**
|
||||
6. Restart the display to apply changes
|
||||
|
||||
### Setting Favorite Sports Teams
|
||||
|
||||
1. Navigate to the **Sports Configuration** tab
|
||||
2. Select the league (NHL, NBA, MLB, NFL)
|
||||
3. Choose your favorite teams from the dropdown
|
||||
4. Enable "Show favorite teams only" if desired
|
||||
5. Click **Save Configuration**
|
||||
6. Restart the display
|
||||
|
||||
### Troubleshooting Display Issues
|
||||
|
||||
1. Navigate to the **Logs** tab
|
||||
2. Look for ERROR or WARNING messages
|
||||
3. Filter by the problematic plugin or component
|
||||
4. Check the error message for clues
|
||||
5. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common solutions
|
||||
|
||||
---
|
||||
|
||||
## Real-Time Features
|
||||
|
||||
The web interface uses Server-Sent Events (SSE) for real-time updates:
|
||||
|
||||
**Live Updates:**
|
||||
- System stats refresh automatically every few seconds
|
||||
- Display preview updates in real-time
|
||||
- Logs stream continuously
|
||||
- No page refresh required
|
||||
|
||||
**Performance:**
|
||||
- Minimal bandwidth usage
|
||||
- Server-side rendering for fast load times
|
||||
- Progressive enhancement - works without JavaScript
|
||||
|
||||
---
|
||||
|
||||
## Mobile Access
|
||||
|
||||
The interface is fully responsive and works on mobile devices:
|
||||
|
||||
**Mobile Features:**
|
||||
- Touch-friendly interface
|
||||
- Responsive layout adapts to screen size
|
||||
- All features available on mobile
|
||||
- Swipe navigation between tabs
|
||||
|
||||
**Tips for Mobile:**
|
||||
- Use landscape mode for better visibility
|
||||
- Pinch to zoom on display preview
|
||||
- Long-press for context menus
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
Use keyboard shortcuts for faster navigation:
|
||||
|
||||
- **Tab**: Navigate between form fields
|
||||
- **Enter**: Submit forms
|
||||
- **Esc**: Close modals
|
||||
- **Ctrl+F**: Search in logs
|
||||
|
||||
---
|
||||
|
||||
## API Access
|
||||
|
||||
The web interface is built on a REST API that you can access programmatically:
|
||||
|
||||
**API Base URL:**
|
||||
```
|
||||
http://your-pi-ip:5050/api
|
||||
```
|
||||
|
||||
**Common Endpoints:**
|
||||
- `GET /api/config/main` - Get configuration
|
||||
- `POST /api/config/main` - Update configuration
|
||||
- `GET /api/system/status` - Get system status
|
||||
- `POST /api/system/action` - Control display (start/stop/restart)
|
||||
- `GET /api/plugins/installed` - List installed plugins
|
||||
|
||||
**Note:** See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete API documentation.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Interface Won't Load
|
||||
|
||||
**Problem:** Browser shows "Unable to connect" or "Connection refused"
|
||||
|
||||
**Solutions:**
|
||||
1. Verify the web service is running:
|
||||
```bash
|
||||
sudo systemctl status ledmatrix-web
|
||||
```
|
||||
|
||||
2. Start the service if stopped:
|
||||
```bash
|
||||
sudo systemctl start ledmatrix-web
|
||||
```
|
||||
|
||||
3. Check that port 5050 is not blocked by firewall
|
||||
4. Verify the Pi's IP address is correct
|
||||
|
||||
### Changes Not Applying
|
||||
|
||||
**Problem:** Configuration changes don't take effect
|
||||
|
||||
**Solutions:**
|
||||
1. Ensure you clicked "Save Configuration"
|
||||
2. Restart the display service for changes to apply:
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
3. Check logs for error messages
|
||||
|
||||
### Display Preview Not Updating
|
||||
|
||||
**Problem:** Display preview shows old content or doesn't update
|
||||
|
||||
**Solutions:**
|
||||
1. Refresh the browser page (F5)
|
||||
2. Check that the display service is running
|
||||
3. Verify SSE streams are working (check browser console)
|
||||
|
||||
### Plugin Configuration Not Saving
|
||||
|
||||
**Problem:** Plugin settings revert after restart
|
||||
|
||||
**Solutions:**
|
||||
1. Check file permissions on `config/config.json`:
|
||||
```bash
|
||||
ls -l config/config.json
|
||||
```
|
||||
2. Ensure the web service has write permissions
|
||||
3. Check logs for permission errors
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Network Access:**
|
||||
- The interface is accessible to anyone on your local network
|
||||
- No authentication is currently implemented
|
||||
- Recommended for trusted networks only
|
||||
|
||||
**Best Practices:**
|
||||
1. Run on a private network (not exposed to internet)
|
||||
2. Use a firewall to restrict access if needed
|
||||
3. Consider VPN access for remote control
|
||||
4. Keep the system updated
|
||||
|
||||
---
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Architecture
|
||||
|
||||
The web interface uses modern web technologies:
|
||||
|
||||
- **Backend:** Flask with Blueprint-based modular design
|
||||
- **Frontend:** HTMX for dynamic content, Alpine.js for reactive components
|
||||
- **Styling:** Tailwind CSS for responsive design
|
||||
- **Real-Time:** Server-Sent Events (SSE) for live updates
|
||||
|
||||
### File Locations
|
||||
|
||||
**Configuration:**
|
||||
- Main config: `/config/config.json`
|
||||
- Secrets: `/config/config_secrets.json`
|
||||
- WiFi config: `/config/wifi_config.json`
|
||||
|
||||
**Logs:**
|
||||
- Display service: `sudo journalctl -u ledmatrix -f`
|
||||
- Web service: `sudo journalctl -u ledmatrix-web -f`
|
||||
|
||||
**Plugins:**
|
||||
- Plugin directory: `/plugins/`
|
||||
- Plugin config: `/config/config.json` (per-plugin sections)
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Installing and managing plugins
|
||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API documentation
|
||||
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Troubleshooting common issues
|
||||
- [FONT_MANAGER.md](FONT_MANAGER.md) - Font management details
|
||||
631
docs/WIFI_NETWORK_SETUP.md
Normal file
631
docs/WIFI_NETWORK_SETUP.md
Normal file
@@ -0,0 +1,631 @@
|
||||
# WiFi Network Setup Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The LEDMatrix WiFi system provides automatic network configuration with intelligent failover to Access Point (AP) mode. When your Raspberry Pi loses network connectivity, it automatically creates a WiFi access point for easy configuration—ensuring you can always connect to your device.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Automatic AP Mode**: Creates a WiFi access point when network connection is lost
|
||||
- **Intelligent Failover**: Only activates after a grace period to prevent false positives
|
||||
- **Dual Connectivity**: Supports both WiFi and Ethernet with automatic priority management
|
||||
- **Web Interface**: Configure WiFi through an easy-to-use web interface
|
||||
- **Network Scanning**: Scan and connect to available WiFi networks
|
||||
- **Secure Storage**: WiFi credentials stored securely
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Accessing WiFi Setup
|
||||
|
||||
**If not connected to WiFi:**
|
||||
1. Wait 90 seconds after boot (AP mode activation grace period)
|
||||
2. Connect to WiFi network: **LEDMatrix-Setup** (open network)
|
||||
3. Open browser to: `http://192.168.4.1:5050`
|
||||
4. Navigate to the WiFi tab
|
||||
5. Scan, select your network, and connect
|
||||
|
||||
**If already connected:**
|
||||
1. Open browser to: `http://your-pi-ip:5050`
|
||||
2. Navigate to the WiFi tab
|
||||
3. Configure as needed
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The following packages are required:
|
||||
- **hostapd** - Access point software
|
||||
- **dnsmasq** - DHCP server for AP mode
|
||||
- **NetworkManager** - WiFi management
|
||||
|
||||
### Install WiFi Monitor Service
|
||||
|
||||
```bash
|
||||
cd /home/ledpi/LEDMatrix
|
||||
sudo ./scripts/install/install_wifi_monitor.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
- Check for required packages and offer to install them
|
||||
- Create the systemd service file
|
||||
- Enable and start the WiFi monitor service
|
||||
- Configure the service to start on boot
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
sudo systemctl status ledmatrix-wifi-monitor
|
||||
|
||||
# Run verification script
|
||||
./scripts/verify_wifi_setup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Configuration File
|
||||
|
||||
WiFi settings are stored in `config/wifi_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ap_ssid": "LEDMatrix-Setup",
|
||||
"ap_password": "",
|
||||
"ap_channel": 7,
|
||||
"auto_enable_ap_mode": true,
|
||||
"saved_networks": [
|
||||
{
|
||||
"ssid": "YourNetwork",
|
||||
"password": "your-password",
|
||||
"saved_at": 1234567890.0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `ap_ssid` | `LEDMatrix-Setup` | Network name for AP mode |
|
||||
| `ap_password` | `` (empty) | AP password (empty = open network) |
|
||||
| `ap_channel` | `7` | WiFi channel (use 1, 6, or 11 for non-overlapping) |
|
||||
| `auto_enable_ap_mode` | `true` | Automatically enable AP mode when disconnected |
|
||||
| `saved_networks` | `[]` | Array of saved WiFi credentials |
|
||||
|
||||
### Auto-Enable AP Mode Behavior
|
||||
|
||||
**When enabled (`true` - recommended):**
|
||||
- AP mode activates automatically after 90-second grace period
|
||||
- Only when both WiFi AND Ethernet are disconnected
|
||||
- Automatically disables when either WiFi or Ethernet connects
|
||||
- Best for portable devices or unreliable network environments
|
||||
|
||||
**When disabled (`false`):**
|
||||
- AP mode must be manually enabled through web interface
|
||||
- Prevents unnecessary AP activation
|
||||
- Best for devices with stable network connections
|
||||
|
||||
---
|
||||
|
||||
## Using WiFi Setup
|
||||
|
||||
### Connecting to a WiFi Network
|
||||
|
||||
**Via Web Interface:**
|
||||
1. Navigate to the **WiFi** tab
|
||||
2. Click **Scan** to search for networks
|
||||
3. Select a network from the dropdown (or enter SSID manually)
|
||||
4. Enter the WiFi password (leave empty for open networks)
|
||||
5. Click **Connect**
|
||||
6. System will attempt connection
|
||||
7. AP mode automatically disables once connected
|
||||
|
||||
**Via API:**
|
||||
```bash
|
||||
# Scan for networks
|
||||
curl "http://your-pi-ip:5050/api/wifi/scan"
|
||||
|
||||
# Connect to network
|
||||
curl -X POST http://your-pi-ip:5050/api/wifi/connect \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ssid": "YourNetwork", "password": "your-password"}'
|
||||
```
|
||||
|
||||
### Manual AP Mode Control
|
||||
|
||||
**Via Web Interface:**
|
||||
- **Enable AP Mode**: Click "Enable AP Mode" button (only when WiFi/Ethernet disconnected)
|
||||
- **Disable AP Mode**: Click "Disable AP Mode" button (when AP is active)
|
||||
|
||||
**Via API:**
|
||||
```bash
|
||||
# Enable AP mode
|
||||
curl -X POST http://your-pi-ip:5050/api/wifi/ap/enable
|
||||
|
||||
# Disable AP mode
|
||||
curl -X POST http://your-pi-ip:5050/api/wifi/ap/disable
|
||||
```
|
||||
|
||||
**Note:** Manual enable still requires both WiFi and Ethernet to be disconnected.
|
||||
|
||||
---
|
||||
|
||||
## Understanding AP Mode Failover
|
||||
|
||||
### How the Grace Period Works
|
||||
|
||||
The system uses a **grace period mechanism** to prevent false positives from temporary network hiccups:
|
||||
|
||||
```
|
||||
Check Interval: 30 seconds (default)
|
||||
Required Checks: 3 consecutive
|
||||
Grace Period: 90 seconds total
|
||||
```
|
||||
|
||||
**Timeline Example:**
|
||||
```
|
||||
Time 0s: WiFi disconnects
|
||||
Time 30s: Check 1 - Disconnected (counter = 1)
|
||||
Time 60s: Check 2 - Disconnected (counter = 2)
|
||||
Time 90s: Check 3 - Disconnected (counter = 3) → AP MODE ENABLED
|
||||
```
|
||||
|
||||
If WiFi or Ethernet reconnects at any point, the counter resets to 0.
|
||||
|
||||
### Why Grace Period is Important
|
||||
|
||||
Without a grace period, AP mode would activate during:
|
||||
- Brief network hiccups
|
||||
- Router reboots
|
||||
- Temporary signal interference
|
||||
- NetworkManager reconnection attempts
|
||||
|
||||
The 90-second grace period ensures AP mode only activates during **sustained disconnection**.
|
||||
|
||||
### Connection Priority
|
||||
|
||||
The system checks connections in this order:
|
||||
1. **WiFi Connection** (highest priority)
|
||||
2. **Ethernet Connection** (fallback)
|
||||
3. **AP Mode** (last resort - only when both WiFi and Ethernet disconnected)
|
||||
|
||||
### Behavior Summary
|
||||
|
||||
| WiFi Status | Ethernet Status | Auto-Enable | AP Mode Behavior |
|
||||
|-------------|-----------------|-------------|------------------|
|
||||
| Any | Any | `false` | Manual enable only |
|
||||
| Connected | Any | `true` | Disabled |
|
||||
| Disconnected | Connected | `true` | Disabled (Ethernet available) |
|
||||
| Disconnected | Disconnected | `true` | Auto-enabled after 90s |
|
||||
|
||||
---
|
||||
|
||||
## Access Point Configuration
|
||||
|
||||
### AP Mode Settings
|
||||
|
||||
- **SSID**: LEDMatrix-Setup (configurable)
|
||||
- **Network**: Open (no password by default)
|
||||
- **IP Address**: 192.168.4.1
|
||||
- **DHCP Range**: 192.168.4.2 - 192.168.4.20
|
||||
- **Channel**: 7 (configurable)
|
||||
|
||||
### Accessing Services in AP Mode
|
||||
|
||||
When AP mode is active:
|
||||
- Web Interface: `http://192.168.4.1:5050`
|
||||
- SSH: `ssh ledpi@192.168.4.1`
|
||||
- Captive portal may automatically redirect browsers
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security Recommendations
|
||||
|
||||
**1. Change AP Password (Optional):**
|
||||
```json
|
||||
{
|
||||
"ap_password": "your-strong-password"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The default is an open network for easy initial setup. For deployments in public areas, consider adding a password.
|
||||
|
||||
**2. Use Non-Overlapping WiFi Channels:**
|
||||
- Channels 1, 6, 11 are non-overlapping (2.4GHz)
|
||||
- Choose a channel that doesn't conflict with your primary network
|
||||
- Example: If primary uses channel 1, use channel 11 for AP mode
|
||||
|
||||
**3. Secure WiFi Credentials:**
|
||||
```bash
|
||||
sudo chmod 600 config/wifi_config.json
|
||||
```
|
||||
|
||||
### Network Configuration Tips
|
||||
|
||||
**Save Multiple Networks:**
|
||||
```json
|
||||
{
|
||||
"saved_networks": [
|
||||
{
|
||||
"ssid": "Home-Network",
|
||||
"password": "home-password"
|
||||
},
|
||||
{
|
||||
"ssid": "Office-Network",
|
||||
"password": "office-password"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Adjust Check Interval:**
|
||||
|
||||
Edit the systemd service file to change grace period:
|
||||
```bash
|
||||
sudo systemctl edit ledmatrix-wifi-monitor
|
||||
```
|
||||
|
||||
Add:
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/python3 /path/to/LEDMatrix/scripts/utils/wifi_monitor_daemon.py --interval 20
|
||||
```
|
||||
|
||||
**Note:** Interval affects grace period:
|
||||
- 20-second interval = 60-second grace period (3 × 20)
|
||||
- 30-second interval = 90-second grace period (3 × 30) ← Default
|
||||
- 60-second interval = 180-second grace period (3 × 60)
|
||||
|
||||
---
|
||||
|
||||
## Configuration Scenarios
|
||||
|
||||
### Scenario 1: Portable Device with Auto-Failover (Recommended)
|
||||
|
||||
**Use Case:** Device may lose WiFi connection
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"auto_enable_ap_mode": true
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- AP mode activates automatically after 90 seconds of disconnection
|
||||
- Always provides a way to connect
|
||||
- Best for devices that move or have unreliable WiFi
|
||||
|
||||
### Scenario 2: Stable Network Connection
|
||||
|
||||
**Use Case:** Ethernet or reliable WiFi connection
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"auto_enable_ap_mode": false
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- AP mode must be manually enabled
|
||||
- Prevents unnecessary activation
|
||||
- Best for stationary devices with stable connections
|
||||
|
||||
### Scenario 3: Ethernet Primary with WiFi Backup
|
||||
|
||||
**Use Case:** Primary Ethernet, WiFi as backup
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"auto_enable_ap_mode": true
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Ethernet connection prevents AP mode activation
|
||||
- If Ethernet disconnects, WiFi is attempted
|
||||
- If both disconnect, AP mode activates after grace period
|
||||
- Best for devices with both Ethernet and WiFi
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### AP Mode Not Activating
|
||||
|
||||
**Check 1: Auto-Enable Setting**
|
||||
```bash
|
||||
cat config/wifi_config.json | grep auto_enable_ap_mode
|
||||
```
|
||||
Should show `"auto_enable_ap_mode": true`
|
||||
|
||||
**Check 2: Service Status**
|
||||
```bash
|
||||
sudo systemctl status ledmatrix-wifi-monitor
|
||||
```
|
||||
Service should be `active (running)`
|
||||
|
||||
**Check 3: Grace Period**
|
||||
- Wait at least 90 seconds after disconnection
|
||||
- Check logs: `sudo journalctl -u ledmatrix-wifi-monitor -f`
|
||||
|
||||
**Check 4: Ethernet Connection**
|
||||
- If Ethernet is connected, AP mode won't activate
|
||||
- Verify: `nmcli device status`
|
||||
- Disconnect Ethernet to test AP mode
|
||||
|
||||
**Check 5: Required Packages**
|
||||
```bash
|
||||
# Verify hostapd is installed
|
||||
which hostapd
|
||||
|
||||
# Verify dnsmasq is installed
|
||||
which dnsmasq
|
||||
```
|
||||
|
||||
### Cannot Access AP Mode
|
||||
|
||||
**Check 1: AP Mode Active**
|
||||
```bash
|
||||
sudo systemctl status hostapd
|
||||
sudo systemctl status dnsmasq
|
||||
```
|
||||
Both should be running
|
||||
|
||||
**Check 2: Network Interface**
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
```
|
||||
Should show IP `192.168.4.1`
|
||||
|
||||
**Check 3: WiFi Interface Available**
|
||||
```bash
|
||||
ip link show wlan0
|
||||
```
|
||||
Interface should exist
|
||||
|
||||
**Check 4: Try Manual Enable**
|
||||
- Use web interface: WiFi tab → Enable AP Mode
|
||||
- Or via API: `curl -X POST http://localhost:5050/api/wifi/ap/enable`
|
||||
|
||||
### Cannot Connect to WiFi Network
|
||||
|
||||
**Check 1: Verify Credentials**
|
||||
- Ensure SSID and password are correct
|
||||
- Check for hidden networks (manual SSID entry required)
|
||||
|
||||
**Check 2: Check Logs**
|
||||
```bash
|
||||
# WiFi monitor logs
|
||||
sudo journalctl -u ledmatrix-wifi-monitor -f
|
||||
|
||||
# NetworkManager logs
|
||||
sudo journalctl -u NetworkManager -n 50
|
||||
```
|
||||
|
||||
**Check 3: Network Compatibility**
|
||||
- Verify network is 2.4GHz (5GHz may not be supported on all Pi models)
|
||||
- Check if network requires special authentication
|
||||
|
||||
### AP Mode Not Disabling After WiFi Connect
|
||||
|
||||
**Check 1: WiFi Connection Status**
|
||||
```bash
|
||||
nmcli device status
|
||||
```
|
||||
|
||||
**Check 2: Manually Disable**
|
||||
- Use web interface: WiFi tab → Disable AP Mode
|
||||
- Or restart service: `sudo systemctl restart ledmatrix-wifi-monitor`
|
||||
|
||||
**Check 3: Check Logs**
|
||||
```bash
|
||||
sudo journalctl -u ledmatrix-wifi-monitor -n 50
|
||||
```
|
||||
|
||||
### AP Mode Activating Unexpectedly
|
||||
|
||||
**Check 1: Network Stability**
|
||||
- Verify WiFi connection is stable
|
||||
- Check router status
|
||||
- Check signal strength
|
||||
|
||||
**Check 2: Disable Auto-Enable**
|
||||
```bash
|
||||
nano config/wifi_config.json
|
||||
# Change: "auto_enable_ap_mode": false
|
||||
sudo systemctl restart ledmatrix-wifi-monitor
|
||||
```
|
||||
|
||||
**Check 3: Increase Grace Period**
|
||||
- Edit service file to increase check interval
|
||||
- Longer interval = longer grace period
|
||||
- See "Best Practices" section above
|
||||
|
||||
---
|
||||
|
||||
## Monitoring and Diagnostics
|
||||
|
||||
### Check WiFi Status
|
||||
|
||||
**Via Python:**
|
||||
```python
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wm = WiFiManager()
|
||||
status = wm.get_wifi_status()
|
||||
|
||||
print(f'Connected: {status.connected}')
|
||||
print(f'SSID: {status.ssid}')
|
||||
print(f'IP Address: {status.ip_address}')
|
||||
print(f'AP Mode Active: {status.ap_mode_active}')
|
||||
print(f'Auto-Enable: {wm.config.get("auto_enable_ap_mode", False)}')
|
||||
```
|
||||
|
||||
**Via NetworkManager:**
|
||||
```bash
|
||||
# View device status
|
||||
nmcli device status
|
||||
|
||||
# View connections
|
||||
nmcli connection show
|
||||
|
||||
# View available WiFi networks
|
||||
nmcli device wifi list
|
||||
```
|
||||
|
||||
### View Service Logs
|
||||
|
||||
```bash
|
||||
# Real-time logs
|
||||
sudo journalctl -u ledmatrix-wifi-monitor -f
|
||||
|
||||
# Recent logs (last 50 lines)
|
||||
sudo journalctl -u ledmatrix-wifi-monitor -n 50
|
||||
|
||||
# Logs from specific time
|
||||
sudo journalctl -u ledmatrix-wifi-monitor --since "1 hour ago"
|
||||
```
|
||||
|
||||
### Run Verification Script
|
||||
|
||||
```bash
|
||||
cd /home/ledpi/LEDMatrix
|
||||
./scripts/verify_wifi_setup.sh
|
||||
```
|
||||
|
||||
Checks:
|
||||
- Required packages installed
|
||||
- WiFi monitor service running
|
||||
- Configuration files valid
|
||||
- WiFi interface available
|
||||
- Current connection status
|
||||
- AP mode status
|
||||
|
||||
---
|
||||
|
||||
## Service Management
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
sudo systemctl status ledmatrix-wifi-monitor
|
||||
|
||||
# Start the service
|
||||
sudo systemctl start ledmatrix-wifi-monitor
|
||||
|
||||
# Stop the service
|
||||
sudo systemctl stop ledmatrix-wifi-monitor
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart ledmatrix-wifi-monitor
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u ledmatrix-wifi-monitor -f
|
||||
|
||||
# Disable service from starting on boot
|
||||
sudo systemctl disable ledmatrix-wifi-monitor
|
||||
|
||||
# Enable service to start on boot
|
||||
sudo systemctl enable ledmatrix-wifi-monitor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
The WiFi setup feature exposes the following API endpoints:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/wifi/status` | Get current WiFi connection status |
|
||||
| GET | `/api/wifi/scan` | Scan for available WiFi networks |
|
||||
| POST | `/api/wifi/connect` | Connect to a WiFi network |
|
||||
| POST | `/api/wifi/ap/enable` | Enable access point mode |
|
||||
| POST | `/api/wifi/ap/disable` | Disable access point mode |
|
||||
| GET | `/api/wifi/ap/auto-enable` | Get auto-enable setting |
|
||||
| POST | `/api/wifi/ap/auto-enable` | Set auto-enable setting |
|
||||
|
||||
### Example Usage
|
||||
|
||||
```bash
|
||||
# Get WiFi status
|
||||
curl "http://your-pi-ip:5050/api/wifi/status"
|
||||
|
||||
# Scan for networks
|
||||
curl "http://your-pi-ip:5050/api/wifi/scan"
|
||||
|
||||
# Connect to network
|
||||
curl -X POST http://your-pi-ip:5050/api/wifi/connect \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ssid": "MyNetwork", "password": "mypassword"}'
|
||||
|
||||
# Enable AP mode
|
||||
curl -X POST http://your-pi-ip:5050/api/wifi/ap/enable
|
||||
|
||||
# Check auto-enable setting
|
||||
curl "http://your-pi-ip:5050/api/wifi/ap/auto-enable"
|
||||
|
||||
# Set auto-enable
|
||||
curl -X POST http://your-pi-ip:5050/api/wifi/ap/auto-enable \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"auto_enable_ap_mode": true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Details
|
||||
|
||||
### WiFi Monitor Daemon
|
||||
|
||||
The WiFi monitor daemon (`wifi_monitor_daemon.py`) runs as a background service that:
|
||||
|
||||
1. Checks WiFi and Ethernet connection status every 30 seconds (configurable)
|
||||
2. Maintains disconnected check counter for grace period
|
||||
3. Automatically enables AP mode when:
|
||||
- `auto_enable_ap_mode` is enabled AND
|
||||
- Both WiFi and Ethernet disconnected AND
|
||||
- Grace period elapsed (3 consecutive checks)
|
||||
4. Automatically disables AP mode when WiFi or Ethernet connects
|
||||
5. Logs all state changes
|
||||
|
||||
### WiFi Detection Methods
|
||||
|
||||
The WiFi manager tries multiple methods:
|
||||
|
||||
1. **NetworkManager (nmcli)** - Preferred method
|
||||
2. **iwconfig** - Fallback for systems without NetworkManager
|
||||
|
||||
### Network Scanning Methods
|
||||
|
||||
1. **nmcli** - Fast, preferred method
|
||||
2. **iwlist** - Fallback for older systems
|
||||
|
||||
### Access Point Implementation
|
||||
|
||||
- Uses `hostapd` for WiFi access point functionality
|
||||
- Uses `dnsmasq` for DHCP and DNS services
|
||||
- Configures wlan0 interface with IP 192.168.4.1
|
||||
- Provides DHCP range: 192.168.4.2-20
|
||||
- Captive portal with DNS redirection
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Using the web interface
|
||||
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - General troubleshooting
|
||||
- [GETTING_STARTED.md](GETTING_STARTED.md) - Initial setup guide
|
||||
388
docs/archive/VEGAS_SCROLL_MODE.md
Normal file
388
docs/archive/VEGAS_SCROLL_MODE.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# Vegas Scroll Mode - Plugin Developer Guide
|
||||
|
||||
Vegas scroll mode displays content from multiple plugins in a continuous horizontal scroll, similar to the news tickers seen in Las Vegas casinos. This guide explains how to integrate your plugin with Vegas mode.
|
||||
|
||||
## Overview
|
||||
|
||||
When Vegas mode is enabled, the display controller composes content from all enabled plugins into a single continuous scroll. Each plugin can control how its content appears in the scroll using one of three **display modes**:
|
||||
|
||||
| Mode | Behavior | Best For |
|
||||
|------|----------|----------|
|
||||
| **SCROLL** | Content scrolls continuously within the stream | Multi-item plugins (sports scores, odds, news) |
|
||||
| **FIXED_SEGMENT** | Fixed-width block that scrolls by | Static info (clock, weather, current temp) |
|
||||
| **STATIC** | Scroll pauses, plugin displays for duration, then resumes | Important alerts, detailed views |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Minimal Integration (Zero Code Changes)
|
||||
|
||||
If you do nothing, your plugin will work with Vegas mode using these defaults:
|
||||
|
||||
- Plugins with `get_vegas_content_type() == 'multi'` use **SCROLL** mode
|
||||
- Plugins with `get_vegas_content_type() == 'static'` use **FIXED_SEGMENT** mode
|
||||
- Content is captured by calling your plugin's `display()` method
|
||||
|
||||
### Basic Integration
|
||||
|
||||
To provide optimized Vegas content, implement `get_vegas_content()`:
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
|
||||
class MyPlugin(BasePlugin):
|
||||
def get_vegas_content(self):
|
||||
"""Return content for Vegas scroll mode."""
|
||||
# Return a single image for fixed content
|
||||
return self._render_current_view()
|
||||
|
||||
# OR return multiple images for multi-item content
|
||||
# return [self._render_item(item) for item in self.items]
|
||||
```
|
||||
|
||||
### Full Integration
|
||||
|
||||
For complete control over Vegas behavior, implement these methods:
|
||||
|
||||
```python
|
||||
from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode
|
||||
|
||||
class MyPlugin(BasePlugin):
|
||||
def get_vegas_content_type(self) -> str:
|
||||
"""Legacy method - determines default mode mapping."""
|
||||
return 'multi' # or 'static' or 'none'
|
||||
|
||||
def get_vegas_display_mode(self) -> VegasDisplayMode:
|
||||
"""Specify how this plugin behaves in Vegas scroll."""
|
||||
return VegasDisplayMode.SCROLL
|
||||
|
||||
def get_supported_vegas_modes(self) -> list:
|
||||
"""Return list of modes users can configure."""
|
||||
return [VegasDisplayMode.SCROLL, VegasDisplayMode.FIXED_SEGMENT]
|
||||
|
||||
def get_vegas_content(self):
|
||||
"""Return PIL Image(s) for the scroll."""
|
||||
return [self._render_game(g) for g in self.games]
|
||||
|
||||
def get_vegas_segment_width(self) -> int:
|
||||
"""For FIXED_SEGMENT: width in panels (optional)."""
|
||||
return 2 # Use 2 panels width
|
||||
```
|
||||
|
||||
## Display Modes Explained
|
||||
|
||||
### SCROLL Mode
|
||||
|
||||
Content scrolls continuously within the Vegas stream. Best for plugins with multiple items.
|
||||
|
||||
```python
|
||||
def get_vegas_display_mode(self):
|
||||
return VegasDisplayMode.SCROLL
|
||||
|
||||
def get_vegas_content(self):
|
||||
# Return list of images - each scrolls individually
|
||||
images = []
|
||||
for game in self.games:
|
||||
img = Image.new('RGB', (200, 32))
|
||||
# ... render game info ...
|
||||
images.append(img)
|
||||
return images
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Sports scores with multiple games
|
||||
- Stock/odds tickers with multiple items
|
||||
- News feeds with multiple headlines
|
||||
|
||||
### FIXED_SEGMENT Mode
|
||||
|
||||
Content is rendered as a fixed-width block that scrolls by with other content.
|
||||
|
||||
```python
|
||||
def get_vegas_display_mode(self):
|
||||
return VegasDisplayMode.FIXED_SEGMENT
|
||||
|
||||
def get_vegas_content(self):
|
||||
# Return single image at your preferred width
|
||||
img = Image.new('RGB', (128, 32)) # 2 panels wide
|
||||
# ... render clock/weather/etc ...
|
||||
return img
|
||||
|
||||
def get_vegas_segment_width(self):
|
||||
# Optional: specify width in panels
|
||||
return 2
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Clock display
|
||||
- Current weather/temperature
|
||||
- System status indicators
|
||||
- Any "at a glance" information
|
||||
|
||||
### STATIC Mode
|
||||
|
||||
Scroll pauses completely, your plugin displays using its normal `display()` method for its configured duration, then scroll resumes.
|
||||
|
||||
```python
|
||||
def get_vegas_display_mode(self):
|
||||
return VegasDisplayMode.STATIC
|
||||
|
||||
def get_display_duration(self):
|
||||
# How long to pause and show this plugin
|
||||
return 10.0 # 10 seconds
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Important alerts that need attention
|
||||
- Detailed information that's hard to read while scrolling
|
||||
- Interactive or animated content
|
||||
- Content that requires the full display
|
||||
|
||||
## User Configuration
|
||||
|
||||
Users can override the default display mode per-plugin in their config:
|
||||
|
||||
```json
|
||||
{
|
||||
"my_plugin": {
|
||||
"enabled": true,
|
||||
"vegas_mode": "static", // Override: "scroll", "fixed", or "static"
|
||||
"vegas_panel_count": 2, // Width in panels for fixed mode
|
||||
"display_duration": 10 // Duration for static mode
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `get_vegas_display_mode()` method checks config first, then falls back to your implementation.
|
||||
|
||||
## Content Rendering Guidelines
|
||||
|
||||
### Image Dimensions
|
||||
|
||||
- **Height**: Must match display height (typically 32 pixels)
|
||||
- **Width**:
|
||||
- SCROLL: Any width, content will scroll
|
||||
- FIXED_SEGMENT: `panels × single_panel_width` (e.g., 2 × 64 = 128px)
|
||||
|
||||
### Color Mode
|
||||
|
||||
Always use RGB mode for images:
|
||||
|
||||
```python
|
||||
img = Image.new('RGB', (width, 32), color=(0, 0, 0))
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. **Cache rendered images** - Don't re-render on every call
|
||||
2. **Pre-render on update()** - Render images when data changes, not when Vegas requests them
|
||||
3. **Keep images small** - Memory adds up with multiple plugins
|
||||
|
||||
```python
|
||||
class MyPlugin(BasePlugin):
|
||||
def __init__(self, ...):
|
||||
super().__init__(...)
|
||||
self._cached_vegas_images = None
|
||||
self._cache_valid = False
|
||||
|
||||
def update(self):
|
||||
# Fetch new data
|
||||
self.data = self._fetch_data()
|
||||
# Invalidate cache so next Vegas request re-renders
|
||||
self._cache_valid = False
|
||||
|
||||
def get_vegas_content(self):
|
||||
if not self._cache_valid:
|
||||
self._cached_vegas_images = self._render_all_items()
|
||||
self._cache_valid = True
|
||||
return self._cached_vegas_images
|
||||
```
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
If your plugin doesn't implement `get_vegas_content()`, Vegas mode will:
|
||||
|
||||
1. Create a temporary canvas matching display dimensions
|
||||
2. Call your `display()` method
|
||||
3. Capture the resulting image
|
||||
4. Use that image in the scroll
|
||||
|
||||
This works but is less efficient than providing native Vegas content.
|
||||
|
||||
## Excluding from Vegas Mode
|
||||
|
||||
To exclude your plugin from Vegas scroll entirely:
|
||||
|
||||
```python
|
||||
def get_vegas_content_type(self):
|
||||
return 'none'
|
||||
```
|
||||
|
||||
Or users can exclude via config:
|
||||
|
||||
```json
|
||||
{
|
||||
"display": {
|
||||
"vegas_scroll": {
|
||||
"excluded_plugins": ["my_plugin"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example of a weather plugin with full Vegas integration:
|
||||
|
||||
```python
|
||||
from PIL import Image, ImageDraw
|
||||
from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode
|
||||
|
||||
class WeatherPlugin(BasePlugin):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.temperature = None
|
||||
self.conditions = None
|
||||
self._vegas_image = None
|
||||
|
||||
def update(self):
|
||||
"""Fetch weather data."""
|
||||
data = self._fetch_weather_api()
|
||||
self.temperature = data['temp']
|
||||
self.conditions = data['conditions']
|
||||
self._vegas_image = None # Invalidate cache
|
||||
|
||||
def display(self, force_clear=False):
|
||||
"""Standard display for normal rotation."""
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
# Full weather display with details
|
||||
self.display_manager.draw_text(
|
||||
f"{self.temperature}°F",
|
||||
x=10, y=8, color=(255, 255, 255)
|
||||
)
|
||||
self.display_manager.draw_text(
|
||||
self.conditions,
|
||||
x=10, y=20, color=(200, 200, 200)
|
||||
)
|
||||
self.display_manager.update_display()
|
||||
|
||||
# --- Vegas Mode Integration ---
|
||||
|
||||
def get_vegas_content_type(self):
|
||||
"""Legacy compatibility."""
|
||||
return 'static'
|
||||
|
||||
def get_vegas_display_mode(self):
|
||||
"""Use FIXED_SEGMENT for compact weather display."""
|
||||
# Allow user override via config
|
||||
return super().get_vegas_display_mode()
|
||||
|
||||
def get_supported_vegas_modes(self):
|
||||
"""Weather can work as fixed or static."""
|
||||
return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.STATIC]
|
||||
|
||||
def get_vegas_segment_width(self):
|
||||
"""Weather needs 2 panels to show clearly."""
|
||||
return self.config.get('vegas_panel_count', 2)
|
||||
|
||||
def get_vegas_content(self):
|
||||
"""Render compact weather for Vegas scroll."""
|
||||
if self._vegas_image is not None:
|
||||
return self._vegas_image
|
||||
|
||||
# Create compact display (2 panels = 128px typical)
|
||||
panel_width = 64 # From display.hardware.cols
|
||||
panels = self.get_vegas_segment_width() or 2
|
||||
width = panel_width * panels
|
||||
height = 32
|
||||
|
||||
img = Image.new('RGB', (width, height), color=(0, 0, 40))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw compact weather
|
||||
temp_text = f"{self.temperature}°"
|
||||
draw.text((10, 8), temp_text, fill=(255, 255, 255))
|
||||
draw.text((60, 8), self.conditions[:10], fill=(200, 200, 200))
|
||||
|
||||
self._vegas_image = img
|
||||
return img
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### VegasDisplayMode Enum
|
||||
|
||||
```python
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
|
||||
VegasDisplayMode.SCROLL # "scroll" - continuous scrolling
|
||||
VegasDisplayMode.FIXED_SEGMENT # "fixed" - fixed block in scroll
|
||||
VegasDisplayMode.STATIC # "static" - pause scroll to display
|
||||
```
|
||||
|
||||
### BasePlugin Vegas Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `get_vegas_content()` | `Image` or `List[Image]` or `None` | Content for Vegas scroll |
|
||||
| `get_vegas_content_type()` | `str` | Legacy: 'multi', 'static', or 'none' |
|
||||
| `get_vegas_display_mode()` | `VegasDisplayMode` | How plugin behaves in Vegas |
|
||||
| `get_supported_vegas_modes()` | `List[VegasDisplayMode]` | Modes available for user config |
|
||||
| `get_vegas_segment_width()` | `int` or `None` | Width in panels for FIXED_SEGMENT |
|
||||
|
||||
### Configuration Options
|
||||
|
||||
**Per-plugin config:**
|
||||
```json
|
||||
{
|
||||
"plugin_id": {
|
||||
"vegas_mode": "scroll|fixed|static",
|
||||
"vegas_panel_count": 2,
|
||||
"display_duration": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Global Vegas config:**
|
||||
```json
|
||||
{
|
||||
"display": {
|
||||
"vegas_scroll": {
|
||||
"enabled": true,
|
||||
"scroll_speed": 50,
|
||||
"separator_width": 32,
|
||||
"plugin_order": ["clock", "weather", "sports"],
|
||||
"excluded_plugins": ["debug_plugin"],
|
||||
"target_fps": 125,
|
||||
"buffer_ahead": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin not appearing in Vegas scroll
|
||||
|
||||
1. Check `get_vegas_content_type()` doesn't return `'none'`
|
||||
2. Verify plugin is not in `excluded_plugins` list
|
||||
3. Ensure plugin is enabled
|
||||
|
||||
### Content looks wrong in scroll
|
||||
|
||||
1. Verify image height matches display height (32px typical)
|
||||
2. Check image mode is 'RGB'
|
||||
3. Test with `get_vegas_content()` returning a simple test image
|
||||
|
||||
### STATIC mode not pausing
|
||||
|
||||
1. Verify `get_vegas_display_mode()` returns `VegasDisplayMode.STATIC`
|
||||
2. Check user hasn't overridden with `vegas_mode` in config
|
||||
3. Ensure `display()` method works correctly
|
||||
|
||||
### Performance issues
|
||||
|
||||
1. Implement image caching in `get_vegas_content()`
|
||||
2. Pre-render images in `update()` instead of on-demand
|
||||
3. Reduce image dimensions if possible
|
||||
@@ -220,11 +220,13 @@ echo "1. Install system dependencies"
|
||||
echo "2. Fix cache permissions"
|
||||
echo "3. Fix assets directory permissions"
|
||||
echo "3.1. Fix plugin directory permissions"
|
||||
echo "4. Install main LED Matrix service"
|
||||
echo "4. Ensure configuration files exist"
|
||||
echo "5. Install Python project dependencies (requirements.txt)"
|
||||
echo "6. Build and install rpi-rgb-led-matrix and test import"
|
||||
echo "7. Install web interface dependencies"
|
||||
echo "7.5. Install main LED Matrix service"
|
||||
echo "8. Install web interface service"
|
||||
echo "8.1. Harden systemd unit file permissions"
|
||||
echo "8.5. Install WiFi monitor service"
|
||||
echo "9. Configure web interface permissions"
|
||||
echo "10. Configure passwordless sudo access"
|
||||
@@ -271,7 +273,7 @@ apt_update
|
||||
|
||||
# Install required system packages
|
||||
echo "Installing Python packages and dependencies..."
|
||||
apt_install python3-pip python3-venv python3-dev python3-pil python3-pil.imagetk python3-pillow build-essential python3-setuptools python3-wheel cython3 scons cmake ninja-build
|
||||
apt_install python3-pip python3-venv python3-dev python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cython3 scons cmake ninja-build
|
||||
|
||||
# Install additional system dependencies that might be needed
|
||||
echo "Installing additional system dependencies..."
|
||||
@@ -511,43 +513,9 @@ find "$PLUGIN_REPOS_DIR" -type f -exec chmod 664 {} \;
|
||||
echo "✓ Plugin-repos directory permissions fixed"
|
||||
echo ""
|
||||
|
||||
CURRENT_STEP="Install main LED Matrix service"
|
||||
echo "Step 4: Installing main LED Matrix service..."
|
||||
echo "---------------------------------------------"
|
||||
|
||||
# Run the main service installation (idempotent)
|
||||
# Note: install_service.sh always overwrites the service file, so it will update paths automatically
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ]; then
|
||||
echo "Running main service installation/update..."
|
||||
bash "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"
|
||||
echo "✓ Main LED Matrix service installed/updated"
|
||||
else
|
||||
echo "✗ Main service installation script not found at $PROJECT_ROOT_DIR/scripts/install/install_service.sh"
|
||||
echo "Please ensure you are running this script from the project root: $PROJECT_ROOT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configure Python capabilities for hardware timing
|
||||
echo "Configuring Python capabilities for hardware timing..."
|
||||
if [ -f "/usr/bin/python3.13" ]; then
|
||||
sudo setcap 'cap_sys_nice=eip' /usr/bin/python3.13 2>/dev/null || echo "⚠ Could not set cap_sys_nice on python3.13 (may need manual setup)"
|
||||
echo "✓ Python3.13 capabilities configured"
|
||||
elif [ -f "/usr/bin/python3" ]; then
|
||||
PYTHON_VER=$(python3 --version 2>&1 | grep -oP '(?<=Python )\d\.\d+' || echo "unknown")
|
||||
if command -v setcap >/dev/null 2>&1; then
|
||||
sudo setcap 'cap_sys_nice=eip' /usr/bin/python3 2>/dev/null || echo "⚠ Could not set cap_sys_nice on python3"
|
||||
echo "✓ Python3 capabilities configured (version: $PYTHON_VER)"
|
||||
else
|
||||
echo "⚠ setcap not found, skipping capability configuration"
|
||||
fi
|
||||
else
|
||||
echo "⚠ Python3 not found, skipping capability configuration"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
CURRENT_STEP="Ensure configuration files exist"
|
||||
echo "Step 4.1: Ensuring configuration files exist..."
|
||||
echo "------------------------------------------------"
|
||||
echo "Step 4: Ensuring configuration files exist..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
# Ensure config directory exists
|
||||
mkdir -p "$PROJECT_ROOT_DIR/config"
|
||||
@@ -661,32 +629,15 @@ CURRENT_STEP="Install project Python dependencies"
|
||||
echo "Step 5: Installing Python project dependencies..."
|
||||
echo "-----------------------------------------------"
|
||||
|
||||
# Install numpy via apt first (pre-built binary, much faster than building from source)
|
||||
echo "Installing numpy via apt (pre-built binary for faster installation)..."
|
||||
if ! python3 -c "import numpy" >/dev/null 2>&1; then
|
||||
apt_install python3-numpy
|
||||
echo "✓ numpy installed via apt"
|
||||
else
|
||||
NUMPY_VERSION=$(python3 -c "import numpy; print(numpy.__version__)" 2>/dev/null || echo "unknown")
|
||||
echo "✓ numpy already installed (version: $NUMPY_VERSION)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Install main project Python dependencies
|
||||
# Install main project Python dependencies (numpy will be installed via pip from requirements.txt)
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
||||
echo "Reading requirements from: $PROJECT_ROOT_DIR/requirements.txt"
|
||||
|
||||
# Check pip version and upgrade if needed
|
||||
# Check pip version (apt-installed pip is sufficient, no upgrade needed)
|
||||
echo "Checking pip version..."
|
||||
python3 -m pip --version
|
||||
|
||||
# Upgrade pip, setuptools, and wheel for better compatibility
|
||||
echo "Upgrading pip, setuptools, and wheel..."
|
||||
python3 -m pip install --break-system-packages --upgrade pip setuptools wheel || {
|
||||
echo "⚠ Warning: Failed to upgrade pip/setuptools/wheel, continuing anyway..."
|
||||
}
|
||||
|
||||
|
||||
# Count total packages for progress
|
||||
TOTAL_PACKAGES=$(grep -v '^#' "$PROJECT_ROOT_DIR/requirements.txt" | grep -v '^$' | wc -l)
|
||||
echo "Found $TOTAL_PACKAGES package(s) to install"
|
||||
@@ -812,6 +763,22 @@ else
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Install web interface dependencies
|
||||
echo "Installing web interface dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
||||
if python3 -m pip install --break-system-packages -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
||||
echo "✓ Web interface dependencies installed"
|
||||
# Create marker file to indicate dependencies are installed
|
||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||
else
|
||||
echo "⚠ Warning: Some web interface dependencies failed to install"
|
||||
echo " The web interface may not work correctly until dependencies are installed"
|
||||
fi
|
||||
else
|
||||
echo "⚠ web_interface/requirements.txt not found; skipping"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||
echo "-----------------------------------------------------"
|
||||
@@ -903,24 +870,82 @@ CURRENT_STEP="Install web interface dependencies"
|
||||
echo "Step 7: Installing web interface dependencies..."
|
||||
echo "------------------------------------------------"
|
||||
|
||||
# Install web interface dependencies
|
||||
echo "Installing Python dependencies for web interface..."
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
|
||||
# Try to install dependencies using the smart installer if available
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||
echo "Using smart dependency installer..."
|
||||
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
# Check if web dependencies were already installed (marker created in Step 5)
|
||||
if [ -f "$PROJECT_ROOT_DIR/.web_deps_installed" ]; then
|
||||
echo "✓ Web interface dependencies already installed (marker file found)"
|
||||
else
|
||||
echo "Using pip to install dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||
python3 -m pip install --break-system-packages -r requirements_web_v2.txt
|
||||
# Install web interface dependencies
|
||||
echo "Installing Python dependencies for web interface..."
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
|
||||
# Try to install dependencies using the smart installer if available
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||
echo "Using smart dependency installer..."
|
||||
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
else
|
||||
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
||||
echo "Using pip to install dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||
python3 -m pip install --break-system-packages -r requirements_web_v2.txt
|
||||
else
|
||||
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create marker file to indicate dependencies are installed
|
||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||
echo "✓ Web interface dependencies installed"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
CURRENT_STEP="Install main LED Matrix service"
|
||||
echo "Step 7.5: Installing main LED Matrix service..."
|
||||
echo "------------------------------------------------"
|
||||
|
||||
# Run the main service installation (idempotent)
|
||||
# Note: install_service.sh always overwrites the service file, so it will update paths automatically
|
||||
# This step runs AFTER all Python dependencies are installed (Steps 5-7)
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ]; then
|
||||
echo "Running main service installation/update..."
|
||||
bash "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"
|
||||
echo "✓ Main LED Matrix service installed/updated"
|
||||
else
|
||||
echo "✗ Main service installation script not found at $PROJECT_ROOT_DIR/scripts/install/install_service.sh"
|
||||
echo "Please ensure you are running this script from the project root: $PROJECT_ROOT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Web interface dependencies installed"
|
||||
# Configure Python capabilities for hardware timing
|
||||
echo "Configuring Python capabilities for hardware timing..."
|
||||
|
||||
# Check if setcap is available first
|
||||
if ! command -v setcap >/dev/null 2>&1; then
|
||||
echo "⚠ setcap not found, skipping capability configuration"
|
||||
echo " Install libcap2-bin if you need hardware timing capabilities"
|
||||
else
|
||||
# Find the Python binary and resolve symlinks to get the real binary
|
||||
PYTHON_BIN=""
|
||||
PYTHON_VER=""
|
||||
if [ -f "/usr/bin/python3.13" ]; then
|
||||
PYTHON_BIN=$(readlink -f /usr/bin/python3.13)
|
||||
PYTHON_VER="3.13"
|
||||
elif [ -f "/usr/bin/python3" ]; then
|
||||
PYTHON_BIN=$(readlink -f /usr/bin/python3)
|
||||
PYTHON_VER=$(python3 --version 2>&1 | grep -oP '(?<=Python )\d+\.\d+' || echo "unknown")
|
||||
fi
|
||||
|
||||
if [ -n "$PYTHON_BIN" ] && [ -f "$PYTHON_BIN" ]; then
|
||||
echo "Setting cap_sys_nice on $PYTHON_BIN (Python $PYTHON_VER)..."
|
||||
if sudo setcap 'cap_sys_nice=eip' "$PYTHON_BIN" 2>/dev/null; then
|
||||
echo "✓ Python $PYTHON_VER capabilities configured ($PYTHON_BIN)"
|
||||
else
|
||||
echo "⚠ Could not set cap_sys_nice on $PYTHON_BIN"
|
||||
echo " This may require manual setup or running as root"
|
||||
echo " The LED display may have timing issues without this capability"
|
||||
fi
|
||||
else
|
||||
echo "⚠ Python3 not found, skipping capability configuration"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
CURRENT_STEP="Install web interface service"
|
||||
@@ -1212,19 +1237,21 @@ CURRENT_STEP="Normalize project file permissions"
|
||||
echo "Step 11.1: Normalizing project file and directory permissions..."
|
||||
echo "--------------------------------------------------------------"
|
||||
|
||||
# Normalize directory permissions (exclude VCS metadata and plugin directories)
|
||||
# Normalize directory permissions (exclude VCS metadata, plugin directories, and compiled libraries)
|
||||
find "$PROJECT_ROOT_DIR" \
|
||||
-path "$PROJECT_ROOT_DIR/plugins" -prune -o \
|
||||
-path "$PROJECT_ROOT_DIR/plugin-repos" -prune -o \
|
||||
-path "$PROJECT_ROOT_DIR/scripts/dev/plugins" -prune -o \
|
||||
-path "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" -prune -o \
|
||||
-path "*/.git*" -prune -o \
|
||||
-type d -exec chmod 755 {} \; 2>/dev/null || true
|
||||
|
||||
# Set default file permissions (exclude plugin directories)
|
||||
# Set default file permissions (exclude plugin directories and compiled libraries)
|
||||
find "$PROJECT_ROOT_DIR" \
|
||||
-path "$PROJECT_ROOT_DIR/plugins" -prune -o \
|
||||
-path "$PROJECT_ROOT_DIR/plugin-repos" -prune -o \
|
||||
-path "$PROJECT_ROOT_DIR/scripts/dev/plugins" -prune -o \
|
||||
-path "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" -prune -o \
|
||||
-path "*/.git*" -prune -o \
|
||||
-type f -exec chmod 644 {} \; 2>/dev/null || true
|
||||
|
||||
@@ -1541,26 +1568,31 @@ echo "=========================================="
|
||||
echo "Important Notes"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "1. For group changes to take effect:"
|
||||
echo "1. PLEASE BE PATIENT after reboot!"
|
||||
echo " - The web interface may take up to 5 minutes to start on first boot"
|
||||
echo " - Services need time to initialize after installation"
|
||||
echo " - Wait at least 2-3 minutes before checking service status"
|
||||
echo ""
|
||||
echo "2. For group changes to take effect:"
|
||||
echo " - Log out and log back in to your SSH session, OR"
|
||||
echo " - Run: newgrp systemd-journal"
|
||||
echo ""
|
||||
echo "2. If you cannot access the web UI:"
|
||||
echo "3. If you cannot access the web UI:"
|
||||
echo " - Check that the web service is running: sudo systemctl status ledmatrix-web"
|
||||
echo " - Verify firewall allows port 5000: sudo ufw status (if using UFW)"
|
||||
echo " - Check network connectivity: ping -c 3 8.8.8.8"
|
||||
echo " - If WiFi is not connected, connect to LEDMatrix-Setup AP network"
|
||||
echo ""
|
||||
echo "3. SSH Access:"
|
||||
echo "4. SSH Access:"
|
||||
echo " - SSH must be configured during initial Pi setup (via Raspberry Pi Imager or raspi-config)"
|
||||
echo " - This installation script does not configure SSH credentials"
|
||||
echo ""
|
||||
echo "4. Useful Commands:"
|
||||
echo "5. Useful Commands:"
|
||||
echo " - Check service status: sudo systemctl status ledmatrix.service"
|
||||
echo " - View logs: journalctl -u ledmatrix-web.service -f"
|
||||
echo " - Start/stop display: sudo systemctl start/stop ledmatrix.service"
|
||||
echo ""
|
||||
echo "5. Configuration Files:"
|
||||
echo "6. Configuration Files:"
|
||||
echo " - Main config: $PROJECT_ROOT_DIR/config/config.json"
|
||||
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
|
||||
echo ""
|
||||
|
||||
5
run.py
5
run.py
@@ -4,6 +4,11 @@ import sys
|
||||
import os
|
||||
import argparse
|
||||
|
||||
# Prevent Python from creating __pycache__ directories in plugin dirs.
|
||||
# The root service loads plugins via importlib, and root-owned __pycache__
|
||||
# files block the web service (non-root) from updating/uninstalling plugins.
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
# Add project directory to Python path (needed before importing src modules)
|
||||
project_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if project_dir not in sys.path:
|
||||
|
||||
61
scripts/fix_perms/safe_plugin_rm.sh
Executable file
61
scripts/fix_perms/safe_plugin_rm.sh
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# safe_plugin_rm.sh — Safely remove a plugin directory after validating
|
||||
# that the resolved path is inside an allowed base directory.
|
||||
#
|
||||
# This script is intended to be called via sudo from the web interface.
|
||||
# It prevents path traversal attacks by resolving symlinks and verifying
|
||||
# the target is a child of plugin-repos/ or plugins/.
|
||||
#
|
||||
# Usage: safe_plugin_rm.sh <target_path>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: $0 <target_path>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET="$1"
|
||||
|
||||
# Determine the project root (parent of scripts/fix_perms/)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# Allowed base directories (resolved, no trailing slash)
|
||||
# Use --canonicalize-missing so this works even if the dirs don't exist yet
|
||||
ALLOWED_BASES=(
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
|
||||
)
|
||||
|
||||
# Resolve the target path (follow symlinks)
|
||||
# Use realpath --canonicalize-missing so it works even if the path
|
||||
# doesn't fully exist (e.g., partially deleted directory)
|
||||
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
|
||||
|
||||
# Validate: resolved target must be a strict child of an allowed base
|
||||
# (must not BE the base itself — only children are allowed)
|
||||
ALLOWED=false
|
||||
for BASE in "${ALLOWED_BASES[@]}"; do
|
||||
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
|
||||
ALLOWED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "DENIED: $RESOLVED_TARGET is not inside an allowed plugin directory" >&2
|
||||
echo "Allowed bases: ${ALLOWED_BASES[*]}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Safety check: refuse to delete the base directories themselves
|
||||
for BASE in "${ALLOWED_BASES[@]}"; do
|
||||
if [ "$RESOLVED_TARGET" = "$BASE" ]; then
|
||||
echo "DENIED: cannot remove plugin base directory itself: $BASE" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
# All checks passed — remove the target
|
||||
rm -rf -- "$RESOLVED_TARGET"
|
||||
@@ -10,9 +10,11 @@ echo "Configuring passwordless sudo access for LED Matrix Web Interface..."
|
||||
# Get the current user (should be the user running the web interface)
|
||||
WEB_USER=$(whoami)
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)"
|
||||
|
||||
echo "Detected web interface user: $WEB_USER"
|
||||
echo "Project directory: $PROJECT_DIR"
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
@@ -21,50 +23,92 @@ if [ "$EUID" -eq 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the full paths to commands
|
||||
PYTHON_PATH=$(which python3)
|
||||
SYSTEMCTL_PATH=$(which systemctl)
|
||||
REBOOT_PATH=$(which reboot)
|
||||
POWEROFF_PATH=$(which poweroff)
|
||||
BASH_PATH=$(which bash)
|
||||
JOURNALCTL_PATH=$(which journalctl)
|
||||
# Get the full paths to commands and validate each one
|
||||
MISSING_CMDS=()
|
||||
|
||||
PYTHON_PATH=$(command -v python3) || true
|
||||
SYSTEMCTL_PATH=$(command -v systemctl) || true
|
||||
REBOOT_PATH=$(command -v reboot) || true
|
||||
POWEROFF_PATH=$(command -v poweroff) || true
|
||||
BASH_PATH=$(command -v bash) || true
|
||||
JOURNALCTL_PATH=$(command -v journalctl) || true
|
||||
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
||||
|
||||
# Validate required commands (systemctl, bash, python3 are essential)
|
||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
||||
CMD_VAL="${!CMD_NAME}"
|
||||
if [ -z "$CMD_VAL" ]; then
|
||||
MISSING_CMDS+=("$CMD_NAME")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
|
||||
echo "Error: Required commands not found: ${MISSING_CMDS[*]}" >&2
|
||||
echo "Cannot generate valid sudoers configuration without these." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate helper script exists
|
||||
if [ ! -f "$SAFE_RM_PATH" ]; then
|
||||
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Command paths:"
|
||||
echo " Python: $PYTHON_PATH"
|
||||
echo " Systemctl: $SYSTEMCTL_PATH"
|
||||
echo " Reboot: $REBOOT_PATH"
|
||||
echo " Poweroff: $POWEROFF_PATH"
|
||||
echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
|
||||
echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
|
||||
echo " Bash: $BASH_PATH"
|
||||
echo " Journalctl: $JOURNALCTL_PATH"
|
||||
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
||||
echo " Safe plugin rm: $SAFE_RM_PATH"
|
||||
|
||||
# Create a temporary sudoers file
|
||||
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||
|
||||
cat > "$TEMP_SUDOERS" << EOF
|
||||
# LED Matrix Web Interface passwordless sudo configuration
|
||||
# This allows the web interface user to run specific commands without a password
|
||||
{
|
||||
echo "# LED Matrix Web Interface passwordless sudo configuration"
|
||||
echo "# This allows the web interface user to run specific commands without a password"
|
||||
echo ""
|
||||
echo "# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface"
|
||||
|
||||
# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh
|
||||
EOF
|
||||
# Optional: reboot/poweroff (non-critical — skip if not found)
|
||||
if [ -n "$REBOOT_PATH" ]; then
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
|
||||
fi
|
||||
if [ -n "$POWEROFF_PATH" ]; then
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
|
||||
fi
|
||||
|
||||
# Required: systemctl
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web"
|
||||
|
||||
# Optional: journalctl (non-critical — skip if not found)
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *"
|
||||
fi
|
||||
|
||||
# Required: python3, bash
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh"
|
||||
echo ""
|
||||
echo "# Allow web user to remove plugin directories via vetted helper script"
|
||||
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
||||
} > "$TEMP_SUDOERS"
|
||||
|
||||
echo ""
|
||||
echo "Generated sudoers configuration:"
|
||||
@@ -81,6 +125,7 @@ echo "- View system logs via journalctl"
|
||||
echo "- Run display_controller.py directly"
|
||||
echo "- Execute start_display.sh and stop_display.sh"
|
||||
echo "- Reboot and shutdown the system"
|
||||
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
||||
echo ""
|
||||
|
||||
# Ask for confirmation
|
||||
@@ -94,6 +139,15 @@ fi
|
||||
|
||||
# Apply the configuration using visudo
|
||||
echo "Applying sudoers configuration..."
|
||||
# Harden the helper script: root-owned, not writable by web user
|
||||
echo "Hardening safe_plugin_rm.sh ownership..."
|
||||
if ! sudo chown root:root "$SAFE_RM_PATH"; then
|
||||
echo "Warning: Could not set ownership on $SAFE_RM_PATH"
|
||||
fi
|
||||
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
||||
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
||||
fi
|
||||
|
||||
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
||||
echo "Configuration applied successfully!"
|
||||
echo ""
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to normalize all plugins as git submodules
|
||||
# This ensures uniform plugin management across the repository
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
PLUGINS_DIR="$PROJECT_ROOT/plugins"
|
||||
GITMODULES="$PROJECT_ROOT/.gitmodules"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if a plugin is in .gitmodules
|
||||
is_in_gitmodules() {
|
||||
local plugin_path="$1"
|
||||
git config -f "$GITMODULES" --get-regexp "^submodule\." | grep -q "path = $plugin_path$" || return 1
|
||||
}
|
||||
|
||||
# Get submodule URL from .gitmodules
|
||||
get_submodule_url() {
|
||||
local plugin_path="$1"
|
||||
git config -f "$GITMODULES" "submodule.$plugin_path.url" 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
# Check if directory is a git repo
|
||||
is_git_repo() {
|
||||
[[ -d "$1/.git" ]]
|
||||
}
|
||||
|
||||
# Get git remote URL
|
||||
get_git_remote() {
|
||||
local plugin_dir="$1"
|
||||
if is_git_repo "$plugin_dir"; then
|
||||
(cd "$plugin_dir" && git remote get-url origin 2>/dev/null || echo "")
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if directory is a symlink
|
||||
is_symlink() {
|
||||
[[ -L "$1" ]]
|
||||
}
|
||||
|
||||
# Check if plugin has GitHub repo
|
||||
has_github_repo() {
|
||||
local plugin_name="$1"
|
||||
local url="https://github.com/ChuckBuilds/ledmatrix-$plugin_name"
|
||||
local status=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "0")
|
||||
[[ "$status" == "200" ]]
|
||||
}
|
||||
|
||||
# Update .gitignore to allow a plugin submodule
|
||||
update_gitignore() {
|
||||
local plugin_name="$1"
|
||||
local plugin_path="plugins/$plugin_name"
|
||||
local gitignore="$PROJECT_ROOT/.gitignore"
|
||||
|
||||
# Check if already in .gitignore exceptions
|
||||
if grep -q "!plugins/$plugin_name$" "$gitignore" 2>/dev/null; then
|
||||
log_info "Plugin $plugin_name already in .gitignore exceptions"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find the line with the last plugin exception
|
||||
local last_line=$(grep -n "!plugins/" "$gitignore" | tail -1 | cut -d: -f1)
|
||||
|
||||
if [[ -z "$last_line" ]]; then
|
||||
log_warn "Could not find plugin exceptions in .gitignore"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Add exceptions after the last plugin exception
|
||||
log_info "Updating .gitignore to allow $plugin_name submodule"
|
||||
sed -i "${last_line}a!plugins/$plugin_name\n!plugins/$plugin_name/" "$gitignore"
|
||||
|
||||
log_success "Updated .gitignore for $plugin_name"
|
||||
}
|
||||
|
||||
# Re-initialize a submodule that appears as regular directory
|
||||
reinit_submodule() {
|
||||
local plugin_name="$1"
|
||||
local plugin_path="plugins/$plugin_name"
|
||||
local plugin_dir="$PLUGINS_DIR/$plugin_name"
|
||||
|
||||
log_info "Re-initializing submodule: $plugin_name"
|
||||
|
||||
if ! is_in_gitmodules "$plugin_path"; then
|
||||
log_error "Plugin $plugin_name is not in .gitmodules"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local submodule_url=$(get_submodule_url "$plugin_path")
|
||||
if [[ -z "$submodule_url" ]]; then
|
||||
log_error "Could not find URL for $plugin_name in .gitmodules"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# If it's a symlink, remove it first
|
||||
if is_symlink "$plugin_dir"; then
|
||||
log_warn "Removing symlink: $plugin_dir"
|
||||
rm "$plugin_dir"
|
||||
fi
|
||||
|
||||
# If it's a regular directory with .git, we need to handle it carefully
|
||||
if is_git_repo "$plugin_dir"; then
|
||||
local remote_url=$(get_git_remote "$plugin_dir")
|
||||
if [[ "$remote_url" == "$submodule_url" ]] || [[ "$remote_url" == "${submodule_url%.git}" ]] || [[ "${submodule_url%.git}" == "$remote_url" ]]; then
|
||||
log_info "Directory is already the correct git repo, re-initializing submodule..."
|
||||
# Remove from git index and re-add as submodule
|
||||
git rm --cached "$plugin_path" 2>/dev/null || true
|
||||
rm -rf "$plugin_dir"
|
||||
else
|
||||
log_warn "Directory has different remote ($remote_url vs $submodule_url)"
|
||||
log_warn "Backing up to ${plugin_dir}.backup"
|
||||
mv "$plugin_dir" "${plugin_dir}.backup"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Re-add as submodule (use -f to force if needed)
|
||||
if git submodule add -f "$submodule_url" "$plugin_path" 2>/dev/null; then
|
||||
log_info "Submodule added successfully"
|
||||
else
|
||||
log_info "Submodule already exists, updating..."
|
||||
git submodule update --init "$plugin_path"
|
||||
fi
|
||||
|
||||
log_success "Re-initialized submodule: $plugin_name"
|
||||
}
|
||||
|
||||
# Convert standalone git repo to submodule
|
||||
convert_to_submodule() {
|
||||
local plugin_name="$1"
|
||||
local plugin_path="plugins/$plugin_name"
|
||||
local plugin_dir="$PLUGINS_DIR/$plugin_name"
|
||||
|
||||
log_info "Converting to submodule: $plugin_name"
|
||||
|
||||
if is_in_gitmodules "$plugin_path"; then
|
||||
log_warn "Plugin $plugin_name is already in .gitmodules, re-initializing instead"
|
||||
reinit_submodule "$plugin_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! is_git_repo "$plugin_dir"; then
|
||||
log_error "Plugin $plugin_name is not a git repository"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local remote_url=$(get_git_remote "$plugin_dir")
|
||||
if [[ -z "$remote_url" ]]; then
|
||||
log_error "Plugin $plugin_name has no remote URL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# If it's a symlink, we need to handle it differently
|
||||
if is_symlink "$plugin_dir"; then
|
||||
local target=$(readlink -f "$plugin_dir")
|
||||
log_warn "Plugin is a symlink to $target"
|
||||
log_warn "Removing symlink and adding as submodule"
|
||||
rm "$plugin_dir"
|
||||
|
||||
# Update .gitignore first
|
||||
update_gitignore "$plugin_name"
|
||||
|
||||
# Add as submodule
|
||||
if git submodule add -f "$remote_url" "$plugin_path"; then
|
||||
log_success "Added submodule: $plugin_name"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to add submodule"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Backup the directory
|
||||
log_info "Backing up existing directory to ${plugin_dir}.backup"
|
||||
mv "$plugin_dir" "${plugin_dir}.backup"
|
||||
|
||||
# Remove from git index
|
||||
git rm --cached "$plugin_path" 2>/dev/null || true
|
||||
|
||||
# Update .gitignore first
|
||||
update_gitignore "$plugin_name"
|
||||
|
||||
# Add as submodule (use -f to force if .gitignore blocks it)
|
||||
if git submodule add -f "$remote_url" "$plugin_path"; then
|
||||
log_success "Converted to submodule: $plugin_name"
|
||||
log_warn "Backup saved at ${plugin_dir}.backup - you can remove it after verifying"
|
||||
else
|
||||
log_error "Failed to add submodule"
|
||||
log_warn "Restoring backup..."
|
||||
mv "${plugin_dir}.backup" "$plugin_dir"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Add new submodule for plugin with GitHub repo
|
||||
add_new_submodule() {
|
||||
local plugin_name="$1"
|
||||
local plugin_path="plugins/$plugin_name"
|
||||
local plugin_dir="$PLUGINS_DIR/$plugin_name"
|
||||
local repo_url="https://github.com/ChuckBuilds/ledmatrix-$plugin_name.git"
|
||||
|
||||
log_info "Adding new submodule: $plugin_name"
|
||||
|
||||
if is_in_gitmodules "$plugin_path"; then
|
||||
log_warn "Plugin $plugin_name is already in .gitmodules"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -e "$plugin_dir" ]]; then
|
||||
if is_symlink "$plugin_dir"; then
|
||||
log_warn "Removing symlink: $plugin_dir"
|
||||
rm "$plugin_dir"
|
||||
elif is_git_repo "$plugin_dir"; then
|
||||
log_warn "Directory exists as git repo, converting instead"
|
||||
convert_to_submodule "$plugin_name"
|
||||
return 0
|
||||
else
|
||||
log_warn "Backing up existing directory to ${plugin_dir}.backup"
|
||||
mv "$plugin_dir" "${plugin_dir}.backup"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Remove from git index if it exists
|
||||
git rm --cached "$plugin_path" 2>/dev/null || true
|
||||
|
||||
# Update .gitignore first
|
||||
update_gitignore "$plugin_name"
|
||||
|
||||
# Add as submodule (use -f to force if .gitignore blocks it)
|
||||
if git submodule add -f "$repo_url" "$plugin_path"; then
|
||||
log_success "Added new submodule: $plugin_name"
|
||||
else
|
||||
log_error "Failed to add submodule"
|
||||
if [[ -e "${plugin_dir}.backup" ]]; then
|
||||
log_warn "Restoring backup..."
|
||||
mv "${plugin_dir}.backup" "$plugin_dir"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main processing function
|
||||
main() {
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
log_info "Normalizing all plugins as git submodules..."
|
||||
echo
|
||||
|
||||
# Step 1: Re-initialize submodules that appear as regular directories
|
||||
log_info "Step 1: Re-initializing existing submodules..."
|
||||
for plugin in basketball-scoreboard calendar clock-simple odds-ticker olympics-countdown soccer-scoreboard text-display mqtt-notifications; do
|
||||
if [[ -d "$PLUGINS_DIR/$plugin" ]] && is_in_gitmodules "plugins/$plugin"; then
|
||||
if ! git submodule status "plugins/$plugin" >/dev/null 2>&1; then
|
||||
reinit_submodule "$plugin"
|
||||
else
|
||||
log_info "Submodule $plugin is already properly initialized"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
# Step 2: Convert standalone git repos to submodules
|
||||
log_info "Step 2: Converting standalone git repos to submodules..."
|
||||
for plugin in baseball-scoreboard ledmatrix-stocks; do
|
||||
if [[ -d "$PLUGINS_DIR/$plugin" ]] && is_git_repo "$PLUGINS_DIR/$plugin"; then
|
||||
if ! is_in_gitmodules "plugins/$plugin"; then
|
||||
convert_to_submodule "$plugin"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
# Step 2b: Convert symlinks to submodules
|
||||
log_info "Step 2b: Converting symlinks to submodules..."
|
||||
for plugin in christmas-countdown ledmatrix-music static-image; do
|
||||
if [[ -L "$PLUGINS_DIR/$plugin" ]]; then
|
||||
if ! is_in_gitmodules "plugins/$plugin"; then
|
||||
convert_to_submodule "$plugin"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
# Step 3: Add new submodules for plugins with GitHub repos
|
||||
log_info "Step 3: Adding new submodules for plugins with GitHub repos..."
|
||||
for plugin in football-scoreboard hockey-scoreboard; do
|
||||
if [[ -d "$PLUGINS_DIR/$plugin" ]] && has_github_repo "$plugin"; then
|
||||
if ! is_in_gitmodules "plugins/$plugin"; then
|
||||
add_new_submodule "$plugin"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
# Step 4: Report on plugins without GitHub repos
|
||||
log_info "Step 4: Checking plugins without GitHub repos..."
|
||||
for plugin in ledmatrix-flights ledmatrix-leaderboard ledmatrix-weather; do
|
||||
if [[ -d "$PLUGINS_DIR/$plugin" ]]; then
|
||||
if ! is_in_gitmodules "plugins/$plugin" && ! is_git_repo "$PLUGINS_DIR/$plugin"; then
|
||||
log_warn "Plugin $plugin has no GitHub repo and is not a git repo"
|
||||
log_warn " This plugin may be local-only or needs a repository created"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
# Final: Initialize all submodules
|
||||
log_info "Finalizing: Initializing all submodules..."
|
||||
git submodule update --init --recursive
|
||||
|
||||
log_success "Plugin normalization complete!"
|
||||
log_info "Run 'git status' to see changes"
|
||||
log_info "Run 'git submodule status' to verify all submodules"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -1,151 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Setup plugin repository references for multi-root workspace.
|
||||
Setup plugin repository symlinks for local development.
|
||||
|
||||
This script creates symlinks in plugin-repos/ pointing to the actual
|
||||
plugin repositories in the parent directory, allowing the system to
|
||||
find plugins without modifying the LEDMatrix project structure.
|
||||
Creates symlinks in plugin-repos/ pointing to plugin directories
|
||||
in the ledmatrix-plugins monorepo.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
PLUGIN_REPOS_DIR = PROJECT_ROOT / "plugin-repos"
|
||||
GITHUB_DIR = PROJECT_ROOT.parent
|
||||
CONFIG_FILE = PROJECT_ROOT / "config" / "config.json"
|
||||
MONOREPO_PLUGINS = PROJECT_ROOT.parent / "ledmatrix-plugins" / "plugins"
|
||||
|
||||
|
||||
def get_workspace_plugins():
|
||||
"""Get list of plugins from workspace file."""
|
||||
workspace_file = PROJECT_ROOT / "LEDMatrix.code-workspace"
|
||||
if not workspace_file.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(workspace_file, 'r') as f:
|
||||
workspace = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Failed to parse workspace file {workspace_file}: {e}")
|
||||
print("Please check that the workspace file contains valid JSON.")
|
||||
return []
|
||||
|
||||
plugins = []
|
||||
for folder in workspace.get('folders', []):
|
||||
path = folder.get('path', '')
|
||||
if path.startswith('../') and path != '../ledmatrix-plugins':
|
||||
plugin_name = path.replace('../', '')
|
||||
plugins.append({
|
||||
'name': plugin_name,
|
||||
'workspace_path': path,
|
||||
'actual_path': GITHUB_DIR / plugin_name,
|
||||
'link_path': PLUGIN_REPOS_DIR / plugin_name
|
||||
})
|
||||
|
||||
return plugins
|
||||
def parse_json_with_trailing_commas(text: str) -> dict:
|
||||
"""Parse JSON that may have trailing commas.
|
||||
|
||||
Note: The regex also matches commas inside string values (e.g., "hello, }").
|
||||
This is fine for manifest files but may corrupt complex JSON with such patterns.
|
||||
"""
|
||||
text = re.sub(r",\s*([}\]])", r"\1", text)
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def create_symlinks():
|
||||
"""Create symlinks in plugin-repos/ pointing to actual repos."""
|
||||
plugins = get_workspace_plugins()
|
||||
|
||||
if not plugins:
|
||||
print("No plugins found in workspace configuration")
|
||||
def create_symlinks() -> bool:
|
||||
"""Create symlinks in plugin-repos/ pointing to monorepo plugin dirs."""
|
||||
if not MONOREPO_PLUGINS.exists():
|
||||
print(f"Error: Monorepo plugins directory not found: {MONOREPO_PLUGINS}")
|
||||
return False
|
||||
|
||||
# Ensure plugin-repos directory exists
|
||||
|
||||
PLUGIN_REPOS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
print(f"Setting up plugin repository links...")
|
||||
print(f" Source: {GITHUB_DIR}")
|
||||
|
||||
print("Setting up plugin symlinks...")
|
||||
print(f" Source: {MONOREPO_PLUGINS}")
|
||||
print(f" Links: {PLUGIN_REPOS_DIR}")
|
||||
print()
|
||||
|
||||
for plugin in plugins:
|
||||
actual_path = plugin['actual_path']
|
||||
link_path = plugin['link_path']
|
||||
|
||||
if not actual_path.exists():
|
||||
print(f" ⚠️ {plugin['name']} - source not found: {actual_path}")
|
||||
errors += 1
|
||||
|
||||
for plugin_dir in sorted(MONOREPO_PLUGINS.iterdir()):
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Remove existing link/file if it exists
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = parse_json_with_trailing_commas(f.read())
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
print(f" {plugin_dir.name} - failed to read {manifest_path}: {e}")
|
||||
continue
|
||||
plugin_id = manifest.get("id", plugin_dir.name)
|
||||
link_path = PLUGIN_REPOS_DIR / plugin_id
|
||||
|
||||
if link_path.exists() or link_path.is_symlink():
|
||||
if link_path.is_symlink():
|
||||
# Check if it points to the right place
|
||||
try:
|
||||
if link_path.resolve() == actual_path.resolve():
|
||||
print(f" ✓ {plugin['name']} - link already exists")
|
||||
if link_path.resolve() == plugin_dir.resolve():
|
||||
skipped += 1
|
||||
continue
|
||||
else:
|
||||
# Remove old symlink pointing elsewhere
|
||||
link_path.unlink()
|
||||
except Exception as e:
|
||||
print(f" ⚠️ {plugin['name']} - error checking link: {e}")
|
||||
except OSError:
|
||||
link_path.unlink()
|
||||
else:
|
||||
# It's a directory/file, not a symlink
|
||||
print(f" ⚠️ {plugin['name']} - {link_path.name} exists but is not a symlink")
|
||||
print(f" Skipping (manual cleanup required)")
|
||||
print(f" {plugin_id} - exists but is not a symlink, skipping")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Create symlink
|
||||
try:
|
||||
# Use relative path for symlink portability
|
||||
relative_path = os.path.relpath(actual_path, link_path.parent)
|
||||
link_path.symlink_to(relative_path)
|
||||
print(f" ✓ {plugin['name']} - linked")
|
||||
created += 1
|
||||
except Exception as e:
|
||||
print(f" ✗ {plugin['name']} - failed to create link: {e}")
|
||||
errors += 1
|
||||
|
||||
print()
|
||||
print(f"✅ Created {created} links, skipped {skipped}, errors {errors}")
|
||||
|
||||
return errors == 0
|
||||
|
||||
relative_path = os.path.relpath(plugin_dir, link_path.parent)
|
||||
link_path.symlink_to(relative_path)
|
||||
print(f" {plugin_id} - linked")
|
||||
created += 1
|
||||
|
||||
def update_config_path():
|
||||
"""Update config to use absolute path to parent directory (alternative approach)."""
|
||||
# This is an alternative - set plugins_directory to absolute path
|
||||
# Currently not implemented as symlinks are preferred
|
||||
pass
|
||||
print(f"\nCreated {created} links, skipped {skipped}")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function."""
|
||||
print("🔗 Setting up plugin repository symlinks...")
|
||||
print()
|
||||
|
||||
if not GITHUB_DIR.exists():
|
||||
print(f"Error: GitHub directory not found: {GITHUB_DIR}")
|
||||
return 1
|
||||
|
||||
success = create_symlinks()
|
||||
|
||||
if success:
|
||||
print()
|
||||
print("✅ Plugin repository setup complete!")
|
||||
print()
|
||||
print("Plugins are now accessible via symlinks in plugin-repos/")
|
||||
print("You can update plugins independently in their git repos.")
|
||||
return 0
|
||||
else:
|
||||
print()
|
||||
print("⚠️ Setup completed with some errors. Check output above.")
|
||||
return 1
|
||||
print("Setting up plugin repository symlinks from monorepo...\n")
|
||||
if not create_symlinks():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,123 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Update all plugin repositories by pulling the latest changes.
|
||||
This script updates all plugin repos without needing to modify
|
||||
the LEDMatrix project itself.
|
||||
Update the ledmatrix-plugins monorepo by pulling latest changes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
WORKSPACE_FILE = Path(__file__).parent.parent / "LEDMatrix.code-workspace"
|
||||
GITHUB_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
def load_workspace_plugins():
|
||||
"""Load plugin paths from workspace file."""
|
||||
try:
|
||||
with open(WORKSPACE_FILE, 'r', encoding='utf-8') as f:
|
||||
workspace = json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Workspace file not found: {WORKSPACE_FILE}")
|
||||
return []
|
||||
except PermissionError as e:
|
||||
print(f"Error: Permission denied reading workspace file {WORKSPACE_FILE}: {e}")
|
||||
return []
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in workspace file {WORKSPACE_FILE}: {e}")
|
||||
return []
|
||||
|
||||
plugins = []
|
||||
for folder in workspace.get('folders', []):
|
||||
path = folder.get('path', '')
|
||||
name = folder.get('name', '')
|
||||
|
||||
# Only process plugin folders (those starting with ../)
|
||||
if path.startswith('../') and path != '../ledmatrix-plugins':
|
||||
plugin_name = path.replace('../', '')
|
||||
plugin_path = GITHUB_DIR / plugin_name
|
||||
if plugin_path.exists():
|
||||
plugins.append({
|
||||
'name': plugin_name,
|
||||
'display_name': name,
|
||||
'path': plugin_path
|
||||
})
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
def update_repo(repo_path):
|
||||
"""Update a git repository by pulling latest changes."""
|
||||
if not (repo_path / '.git').exists():
|
||||
print(f" ⚠️ {repo_path.name} is not a git repository, skipping")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Fetch latest changes
|
||||
fetch_result = subprocess.run(['git', 'fetch', 'origin'],
|
||||
cwd=repo_path, capture_output=True, text=True)
|
||||
|
||||
if fetch_result.returncode != 0:
|
||||
print(f" ✗ Failed to fetch {repo_path.name}: {fetch_result.stderr.strip()}")
|
||||
return False
|
||||
|
||||
# Get current branch
|
||||
branch_result = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
cwd=repo_path, capture_output=True, text=True)
|
||||
current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else 'main'
|
||||
|
||||
# Pull latest changes
|
||||
pull_result = subprocess.run(['git', 'pull', 'origin', current_branch],
|
||||
cwd=repo_path, capture_output=True, text=True)
|
||||
|
||||
if pull_result.returncode == 0:
|
||||
# Check if there were actual updates
|
||||
if 'Already up to date' in pull_result.stdout:
|
||||
print(f" ✓ {repo_path.name} is up to date")
|
||||
else:
|
||||
print(f" ✓ Updated {repo_path.name}")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Failed to update {repo_path.name}: {pull_result.stderr.strip()}")
|
||||
return False
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
print(f" ✗ Error updating {repo_path.name}: {e}")
|
||||
return False
|
||||
MONOREPO_DIR = Path(__file__).parent.parent.parent / "ledmatrix-plugins"
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function."""
|
||||
print("🔍 Finding plugin repositories...")
|
||||
|
||||
plugins = load_workspace_plugins()
|
||||
|
||||
if not plugins:
|
||||
print(" No plugin repositories found!")
|
||||
if not MONOREPO_DIR.exists():
|
||||
print(f"Error: Monorepo not found: {MONOREPO_DIR}")
|
||||
return 1
|
||||
|
||||
print(f" Found {len(plugins)} plugin repositories")
|
||||
print(f"\n🚀 Updating plugins in {GITHUB_DIR}...")
|
||||
print()
|
||||
|
||||
success_count = 0
|
||||
for plugin in plugins:
|
||||
print(f"Updating {plugin['name']}...")
|
||||
if update_repo(plugin['path']):
|
||||
success_count += 1
|
||||
print()
|
||||
|
||||
print(f"\n✅ Updated {success_count}/{len(plugins)} plugins successfully!")
|
||||
|
||||
if success_count < len(plugins):
|
||||
print("⚠️ Some plugins failed to update. Check the errors above.")
|
||||
|
||||
if not (MONOREPO_DIR / ".git").exists():
|
||||
print(f"Error: {MONOREPO_DIR} is not a git repository")
|
||||
return 1
|
||||
|
||||
print(f"Updating {MONOREPO_DIR}...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(MONOREPO_DIR), "pull"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Error: git pull timed out after 120 seconds for {MONOREPO_DIR}")
|
||||
return 1
|
||||
|
||||
if result.returncode == 0:
|
||||
print(result.stdout.strip())
|
||||
return 0
|
||||
else:
|
||||
print(f"Error: {result.stderr.strip()}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -8,6 +8,13 @@ from pathlib import Path
|
||||
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
CONFIG_FILE = os.path.join(PROJECT_DIR, 'config', 'config.json')
|
||||
WEB_INTERFACE_SCRIPT = os.path.join(PROJECT_DIR, 'web_interface', 'start.py')
|
||||
# Marker file created by first_time_install.sh to indicate dependencies are installed
|
||||
DEPS_MARKER = os.path.join(PROJECT_DIR, '.web_deps_installed')
|
||||
|
||||
|
||||
def dependencies_installed():
|
||||
"""Check if dependencies were installed during first-time setup."""
|
||||
return os.path.exists(DEPS_MARKER)
|
||||
|
||||
def install_dependencies():
|
||||
"""Install required dependencies using system Python."""
|
||||
@@ -85,11 +92,18 @@ def main():
|
||||
|
||||
if is_enabled:
|
||||
print("Configuration 'web_display_autostart' is enabled. Starting web interface...")
|
||||
|
||||
# Install dependencies
|
||||
if not install_dependencies():
|
||||
print("Failed to install dependencies. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
# Only install dependencies if not already done during first-time setup
|
||||
if not dependencies_installed():
|
||||
print("First run detected: Installing dependencies...")
|
||||
if not install_dependencies():
|
||||
print("Failed to install dependencies. Exiting.")
|
||||
sys.exit(1)
|
||||
# Create marker file after successful install
|
||||
Path(DEPS_MARKER).touch()
|
||||
print("Dependencies installed and marker file created.")
|
||||
else:
|
||||
print("Dependencies already installed (marker file found). Skipping installation.")
|
||||
|
||||
try:
|
||||
# Replace the current process with web_interface.py using system Python
|
||||
|
||||
@@ -387,43 +387,8 @@ class FootballLive(Football, SportsLive):
|
||||
main_img = main_img.convert('RGB') # Convert for display
|
||||
|
||||
# Display the final image
|
||||
# #region agent log
|
||||
import json
|
||||
import time
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "C",
|
||||
"location": "football.py:390",
|
||||
"message": "About to update display",
|
||||
"data": {
|
||||
"force_clear": force_clear,
|
||||
"game": game.get('away_abbr', '') + "@" + game.get('home_abbr', '')
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
self.display_manager.update_display() # Update display here for live
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "C",
|
||||
"location": "football.py:392",
|
||||
"message": "After update display",
|
||||
"data": {
|
||||
"force_clear": force_clear
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix
|
||||
|
||||
@@ -207,25 +207,6 @@ class SportsCore(ABC):
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
"""Common display method for all NCAA FB managers""" # Updated docstring
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "D",
|
||||
"location": "sports.py:208",
|
||||
"message": "Display called",
|
||||
"data": {
|
||||
"force_clear": force_clear,
|
||||
"has_current_game": self.current_game is not None,
|
||||
"current_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
if not self.is_enabled: # Check if module is enabled
|
||||
return False
|
||||
|
||||
@@ -248,40 +229,7 @@ class SportsCore(ABC):
|
||||
return False
|
||||
|
||||
try:
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "D",
|
||||
"location": "sports.py:232",
|
||||
"message": "About to draw scorebug",
|
||||
"data": {
|
||||
"force_clear": force_clear,
|
||||
"game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "D",
|
||||
"location": "sports.py:235",
|
||||
"message": "After draw scorebug",
|
||||
"data": {
|
||||
"force_clear": force_clear
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
# display_manager.update_display() should be called within subclass draw methods
|
||||
# or after calling display() in the main loop. Let's keep it out of the base display.
|
||||
return True
|
||||
@@ -942,7 +890,7 @@ class SportsUpcoming(SportsCore):
|
||||
away_text = ''
|
||||
elif self.show_ranking:
|
||||
# Show ranking only if available
|
||||
away_rank = rankself._team_rankings_cacheings.get(away_abbr, 0)
|
||||
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
||||
if away_rank > 0:
|
||||
away_text = f"#{away_rank}"
|
||||
else:
|
||||
@@ -1443,48 +1391,9 @@ class SportsLive(SportsCore):
|
||||
self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time
|
||||
# Reset index if current game is gone or list is new
|
||||
if not self.current_game or self.current_game['id'] not in new_game_ids:
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "B",
|
||||
"location": "sports.py:1393",
|
||||
"message": "Games loaded - resetting index and last_game_switch",
|
||||
"data": {
|
||||
"current_game_before": self.current_game['id'] if self.current_game else None,
|
||||
"live_games_count": len(self.live_games),
|
||||
"last_game_switch_before": self.last_game_switch,
|
||||
"current_time": current_time,
|
||||
"time_since_init": current_time - self.last_game_switch if self.last_game_switch > 0 else None
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self.current_game_index = 0
|
||||
self.current_game = self.live_games[0] if self.live_games else None
|
||||
self.last_game_switch = current_time
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "B",
|
||||
"location": "sports.py:1396",
|
||||
"message": "Games loaded - after setting last_game_switch",
|
||||
"data": {
|
||||
"current_game_after": self.current_game['id'] if self.current_game else None,
|
||||
"last_game_switch_after": self.last_game_switch,
|
||||
"first_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
else:
|
||||
# Find current game's new index if it still exists
|
||||
try:
|
||||
@@ -1530,70 +1439,9 @@ class SportsLive(SportsCore):
|
||||
# Handle game switching (outside test mode check)
|
||||
# Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet)
|
||||
# This prevents immediate switching when the system has been running for a while before games load
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "A",
|
||||
"location": "sports.py:1432",
|
||||
"message": "Game switch check - before condition",
|
||||
"data": {
|
||||
"test_mode": self.test_mode,
|
||||
"live_games_count": len(self.live_games),
|
||||
"current_time": current_time,
|
||||
"last_game_switch": self.last_game_switch,
|
||||
"time_since_switch": current_time - self.last_game_switch,
|
||||
"game_display_duration": self.game_display_duration,
|
||||
"current_game_index": self.current_game_index,
|
||||
"will_switch": not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
if not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration:
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "A",
|
||||
"location": "sports.py:1433",
|
||||
"message": "Game switch triggered",
|
||||
"data": {
|
||||
"old_index": self.current_game_index,
|
||||
"old_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None,
|
||||
"time_since_switch": current_time - self.last_game_switch,
|
||||
"last_game_switch_before": self.last_game_switch
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
|
||||
self.current_game = self.live_games[self.current_game_index]
|
||||
self.last_game_switch = current_time
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "A",
|
||||
"location": "sports.py:1436",
|
||||
"message": "Game switch completed",
|
||||
"data": {
|
||||
"new_index": self.current_game_index,
|
||||
"new_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None,
|
||||
"last_game_switch_after": self.last_game_switch
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix
|
||||
# Force display update via flag or direct call if needed, but usually let main loop handle
|
||||
|
||||
@@ -8,32 +8,70 @@ files that need to be accessible by both root service and web user.
|
||||
|
||||
import os
|
||||
import logging
|
||||
import shutil as _shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# System directories that should never have their permissions modified
|
||||
# These directories have special system-level permissions that must be preserved
|
||||
PROTECTED_SYSTEM_DIRECTORIES = {
|
||||
'/tmp',
|
||||
'/var/tmp',
|
||||
'/dev',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/run',
|
||||
'/var/run',
|
||||
'/etc',
|
||||
'/boot',
|
||||
'/var',
|
||||
'/usr',
|
||||
'/lib',
|
||||
'/lib64',
|
||||
'/bin',
|
||||
'/sbin',
|
||||
}
|
||||
|
||||
|
||||
def ensure_directory_permissions(path: Path, mode: int = 0o775) -> None:
|
||||
"""
|
||||
Create directory and set permissions.
|
||||
|
||||
|
||||
If the directory already exists and we cannot change its permissions,
|
||||
we check if it's usable (readable/writable). If so, we continue without
|
||||
raising an exception. This allows the system to work even when running
|
||||
as a non-root user who cannot change permissions on existing directories.
|
||||
|
||||
|
||||
Protected system directories (like /tmp, /etc, /var) are never modified
|
||||
to prevent breaking system functionality.
|
||||
|
||||
Args:
|
||||
path: Directory path to create/ensure
|
||||
mode: Permission mode (default: 0o775 for group-writable directories)
|
||||
|
||||
|
||||
Raises:
|
||||
OSError: If directory creation fails or directory exists but is not usable
|
||||
"""
|
||||
try:
|
||||
# Never modify permissions on system directories
|
||||
path_str = str(path.resolve() if path.is_absolute() else path)
|
||||
if path_str in PROTECTED_SYSTEM_DIRECTORIES:
|
||||
logger.debug(f"Skipping permission modification on protected system directory: {path_str}")
|
||||
# Verify the directory is usable
|
||||
if path.exists() and os.access(path, os.R_OK | os.W_OK):
|
||||
return
|
||||
elif path.exists():
|
||||
logger.warning(f"Protected system directory {path_str} exists but is not writable")
|
||||
return
|
||||
else:
|
||||
raise OSError(f"Protected system directory {path_str} does not exist")
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Try to set permissions
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
@@ -156,9 +194,96 @@ def get_plugin_dir_mode() -> int:
|
||||
def get_cache_dir_mode() -> int:
|
||||
"""
|
||||
Return permission mode for cache directories.
|
||||
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable cache directories
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
|
||||
def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> bool:
|
||||
"""
|
||||
Remove a directory using sudo as a last resort.
|
||||
|
||||
Used when normal removal fails due to root-owned files (e.g., __pycache__
|
||||
directories created by the root ledmatrix service). Delegates to the
|
||||
safe_plugin_rm.sh helper which validates the path is inside allowed
|
||||
plugin directories.
|
||||
|
||||
Before invoking sudo, this function also validates that the resolved
|
||||
path is a descendant of at least one allowed base directory.
|
||||
|
||||
Args:
|
||||
path: Directory path to remove
|
||||
allowed_bases: List of allowed parent directories. If None, defaults
|
||||
to plugin-repos/ and plugins/ under the project root.
|
||||
|
||||
Returns:
|
||||
True if removal succeeded, False otherwise
|
||||
"""
|
||||
# Determine project root (permission_utils.py is at src/common/)
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
if allowed_bases is None:
|
||||
allowed_bases = [
|
||||
project_root / "plugin-repos",
|
||||
project_root / "plugins",
|
||||
]
|
||||
|
||||
# Resolve the target path to prevent symlink/traversal tricks
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except (OSError, ValueError) as e:
|
||||
logger.error(f"Cannot resolve path {path}: {e}")
|
||||
return False
|
||||
|
||||
# Validate the resolved path is a strict child of an allowed base
|
||||
is_allowed = False
|
||||
for base in allowed_bases:
|
||||
try:
|
||||
base_resolved = base.resolve()
|
||||
if resolved != base_resolved and resolved.is_relative_to(base_resolved):
|
||||
is_allowed = True
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
if not is_allowed:
|
||||
logger.error(
|
||||
f"sudo_remove_directory DENIED: {resolved} is not inside "
|
||||
f"allowed bases {[str(b) for b in allowed_bases]}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Use the safe_plugin_rm.sh helper which does its own validation
|
||||
helper_script = project_root / "scripts" / "fix_perms" / "safe_plugin_rm.sh"
|
||||
if not helper_script.exists():
|
||||
logger.error(f"Safe removal helper not found: {helper_script}")
|
||||
return False
|
||||
|
||||
bash_path = _shutil.which('bash') or '/bin/bash'
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
if result.returncode == 0 and not resolved.exists():
|
||||
logger.info(f"Successfully removed {path} via sudo helper")
|
||||
return True
|
||||
else:
|
||||
stderr = result.stderr.strip()
|
||||
logger.error(f"sudo helper failed for {path}: {stderr}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"sudo helper timed out for {path}")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.error("sudo command not found on system")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ class ScrollHelper:
|
||||
# Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps)
|
||||
steps = int(time_since_last_step / self.scroll_delay)
|
||||
# Cap at reasonable number to prevent huge jumps from lag
|
||||
max_steps = max(1, int(0.1 / self.scroll_delay)) # Allow up to 0.1s of catch-up
|
||||
max_steps = max(1, int(0.04 / self.scroll_delay)) # Limit to 0.04s (2 steps at 50 FPS) for smoother scrolling
|
||||
steps = min(steps, max_steps)
|
||||
pixels_to_move = self.scroll_speed * steps
|
||||
# Update last_step_time, preserving fractional delay for smooth timing
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
||||
import pytz
|
||||
|
||||
# Core system imports only - all functionality now handled via plugins
|
||||
from src.display_manager import DisplayManager
|
||||
@@ -18,6 +19,10 @@ from src.logging_config import get_logger
|
||||
|
||||
# Get logger with consistent configuration
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Vegas mode import (lazy loaded to avoid circular imports)
|
||||
_vegas_mode_imported = False
|
||||
VegasModeCoordinator = None
|
||||
DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
||||
|
||||
# WiFi status message file path (same as used in wifi_manager.py)
|
||||
@@ -330,7 +335,12 @@ class DisplayController:
|
||||
# Schedule management
|
||||
self.is_display_active = True
|
||||
self._was_display_active = True # Track previous state for schedule change detection
|
||||
|
||||
|
||||
# Brightness state tracking for dim schedule
|
||||
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
self.is_dimmed = False
|
||||
self._was_dimmed = False
|
||||
|
||||
# Publish initial on-demand state
|
||||
try:
|
||||
self._publish_on_demand_state()
|
||||
@@ -343,8 +353,87 @@ class DisplayController:
|
||||
self._update_modules()
|
||||
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)
|
||||
|
||||
# Initialize Vegas mode coordinator
|
||||
self.vegas_coordinator = None
|
||||
self._initialize_vegas_mode()
|
||||
|
||||
logger.info("DisplayController initialization completed in %.3f seconds", time.time() - start_time)
|
||||
|
||||
def _initialize_vegas_mode(self):
|
||||
"""Initialize Vegas mode coordinator if enabled."""
|
||||
global _vegas_mode_imported, VegasModeCoordinator
|
||||
|
||||
vegas_config = self.config.get('display', {}).get('vegas_scroll', {})
|
||||
if not vegas_config.get('enabled', False):
|
||||
logger.debug("Vegas mode disabled in config")
|
||||
return
|
||||
|
||||
if self.plugin_manager is None:
|
||||
logger.warning("Vegas mode skipped: plugin_manager is None")
|
||||
return
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular imports
|
||||
if not _vegas_mode_imported:
|
||||
try:
|
||||
from src.vegas_mode import VegasModeCoordinator as VMC
|
||||
VegasModeCoordinator = VMC
|
||||
_vegas_mode_imported = True
|
||||
except ImportError:
|
||||
logger.exception("Failed to import Vegas mode module")
|
||||
return
|
||||
|
||||
self.vegas_coordinator = VegasModeCoordinator(
|
||||
config=self.config,
|
||||
display_manager=self.display_manager,
|
||||
plugin_manager=self.plugin_manager
|
||||
)
|
||||
|
||||
# Set up live priority checker
|
||||
self.vegas_coordinator.set_live_priority_checker(self._check_live_priority)
|
||||
|
||||
# Set up interrupt checker for on-demand/wifi status
|
||||
self.vegas_coordinator.set_interrupt_checker(
|
||||
self._check_vegas_interrupt,
|
||||
check_interval=10 # Check every 10 frames (~80ms at 125 FPS)
|
||||
)
|
||||
|
||||
logger.info("Vegas mode coordinator initialized")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize Vegas mode: %s", e, exc_info=True)
|
||||
self.vegas_coordinator = None
|
||||
|
||||
def _is_vegas_mode_active(self) -> bool:
|
||||
"""Check if Vegas mode should be running."""
|
||||
if not self.vegas_coordinator:
|
||||
return False
|
||||
if not self.vegas_coordinator.is_enabled:
|
||||
return False
|
||||
if self.on_demand_active:
|
||||
return False # On-demand takes priority
|
||||
return True
|
||||
|
||||
def _check_vegas_interrupt(self) -> bool:
|
||||
"""
|
||||
Check if Vegas should yield control for higher priority events.
|
||||
|
||||
Called periodically by Vegas coordinator to allow responsive
|
||||
handling of on-demand requests, wifi status, etc.
|
||||
|
||||
Returns:
|
||||
True if Vegas should yield control, False to continue
|
||||
"""
|
||||
# Check for pending on-demand request
|
||||
if self.on_demand_active:
|
||||
return True
|
||||
|
||||
# Check for wifi status that needs display
|
||||
if self._check_wifi_status_message():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _check_schedule(self):
|
||||
"""Check if display should be active based on schedule."""
|
||||
schedule_config = self.config.get('schedule', {})
|
||||
@@ -362,8 +451,17 @@ class DisplayController:
|
||||
self._was_display_active = True # Track previous state for schedule change detection
|
||||
logger.debug("Schedule is disabled - display always active")
|
||||
return
|
||||
|
||||
current_time = datetime.now()
|
||||
|
||||
# Get configured timezone, default to UTC
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
|
||||
tz = pytz.UTC
|
||||
|
||||
# Use timezone-aware current time
|
||||
current_time = datetime.now(tz)
|
||||
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
|
||||
current_time_only = current_time.time()
|
||||
|
||||
@@ -440,11 +538,96 @@ class DisplayController:
|
||||
self._was_display_active = self.is_display_active
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.",
|
||||
logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.",
|
||||
schedule_type, e, start_time_str, end_time_str)
|
||||
self.is_display_active = True
|
||||
self._was_display_active = True # Track previous state for schedule change detection
|
||||
|
||||
def _check_dim_schedule(self) -> int:
|
||||
"""
|
||||
Check if display should be dimmed based on dim schedule.
|
||||
|
||||
Returns:
|
||||
Target brightness level (dim_brightness if in dim period,
|
||||
normal brightness otherwise)
|
||||
"""
|
||||
# Get normal brightness from config
|
||||
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
|
||||
# If display is OFF via schedule, don't process dim schedule
|
||||
if not self.is_display_active:
|
||||
self.is_dimmed = False
|
||||
return normal_brightness
|
||||
|
||||
dim_config = self.config.get('dim_schedule', {})
|
||||
|
||||
# If dim schedule doesn't exist or is disabled, use normal brightness
|
||||
if not dim_config or not dim_config.get('enabled', False):
|
||||
self.is_dimmed = False
|
||||
return normal_brightness
|
||||
|
||||
# Get configured timezone
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
|
||||
tz = pytz.UTC
|
||||
|
||||
current_time = datetime.now(tz)
|
||||
current_day = current_time.strftime('%A').lower()
|
||||
current_time_only = current_time.time()
|
||||
|
||||
# Determine if using per-day or global dim schedule
|
||||
# Normalize mode to handle both "per-day" and "per_day" variants
|
||||
mode = dim_config.get('mode', 'global')
|
||||
mode_normalized = mode.replace('_', '-') if mode else 'global'
|
||||
days_config = dim_config.get('days')
|
||||
use_per_day = mode_normalized == 'per-day' and days_config and current_day in days_config
|
||||
|
||||
if use_per_day:
|
||||
day_config = days_config[current_day]
|
||||
if not day_config.get('enabled', True):
|
||||
self.is_dimmed = False
|
||||
return normal_brightness
|
||||
start_time_str = day_config.get('start_time', '20:00')
|
||||
end_time_str = day_config.get('end_time', '07:00')
|
||||
else:
|
||||
start_time_str = dim_config.get('start_time', '20:00')
|
||||
end_time_str = dim_config.get('end_time', '07:00')
|
||||
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
|
||||
# Determine if currently in dim period
|
||||
if start_time <= end_time:
|
||||
# Same-day schedule (e.g., 10:00 to 18:00)
|
||||
in_dim_period = start_time <= current_time_only <= end_time
|
||||
else:
|
||||
# Overnight schedule (e.g., 20:00 to 07:00)
|
||||
in_dim_period = current_time_only >= start_time or current_time_only <= end_time
|
||||
|
||||
if in_dim_period:
|
||||
self.is_dimmed = True
|
||||
target_brightness = dim_config.get('dim_brightness', 30)
|
||||
else:
|
||||
self.is_dimmed = False
|
||||
target_brightness = normal_brightness
|
||||
|
||||
# Log state changes
|
||||
if self.is_dimmed and not self._was_dimmed:
|
||||
logger.info(f"Dim schedule activated: brightness set to {target_brightness}%")
|
||||
elif not self.is_dimmed and self._was_dimmed:
|
||||
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
||||
|
||||
self._was_dimmed = self.is_dimmed
|
||||
return target_brightness
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid dim schedule time format: {e}")
|
||||
return normal_brightness
|
||||
|
||||
def _update_modules(self):
|
||||
"""Update all plugin modules."""
|
||||
if not self.plugin_manager:
|
||||
@@ -1101,6 +1284,13 @@ class DisplayController:
|
||||
elif not self.on_demand_active and self.on_demand_schedule_override:
|
||||
self.on_demand_schedule_override = False
|
||||
|
||||
# Check dim schedule and apply brightness (only when display is active)
|
||||
if self.is_display_active:
|
||||
target_brightness = self._check_dim_schedule()
|
||||
if target_brightness != self.current_brightness:
|
||||
if self.display_manager.set_brightness(target_brightness):
|
||||
self.current_brightness = target_brightness
|
||||
|
||||
if not self.is_display_active:
|
||||
# Clear display when schedule makes it inactive to ensure blank screen
|
||||
# (not showing initialization screen)
|
||||
@@ -1152,6 +1342,23 @@ class DisplayController:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||
if self._is_vegas_mode_active() and not wifi_status_data:
|
||||
live_mode = self._check_live_priority()
|
||||
if not live_mode:
|
||||
try:
|
||||
# Run Vegas mode iteration
|
||||
if self.vegas_coordinator.run_iteration():
|
||||
# Vegas completed an iteration, continue to next loop
|
||||
continue
|
||||
else:
|
||||
# Vegas was interrupted (live priority), fall through to normal handling
|
||||
logger.debug("Vegas mode interrupted, falling back to normal rotation")
|
||||
except Exception:
|
||||
logger.exception("Vegas mode error")
|
||||
# Fall through to normal rotation on error
|
||||
|
||||
if self.on_demand_active:
|
||||
# Guard against empty on_demand_modes
|
||||
if not self.on_demand_modes:
|
||||
|
||||
@@ -82,6 +82,7 @@ class DisplayManager:
|
||||
options.pixel_mapper_config = hardware_config.get('pixel_mapper_config', '')
|
||||
options.row_address_type = hardware_config.get('row_address_type', 0)
|
||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||
options.panel_type = hardware_config.get('panel_type', '')
|
||||
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
|
||||
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
|
||||
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
|
||||
@@ -174,6 +175,57 @@ class DisplayManager:
|
||||
else:
|
||||
return 32 # Default fallback height
|
||||
|
||||
def set_brightness(self, brightness: int) -> bool:
|
||||
"""
|
||||
Set display brightness at runtime.
|
||||
|
||||
Args:
|
||||
brightness: Brightness level (0-100)
|
||||
|
||||
Returns:
|
||||
True if brightness was set successfully, False otherwise
|
||||
"""
|
||||
# Fail fast: validate input type
|
||||
if not isinstance(brightness, (int, float)):
|
||||
logger.error(f"[BRIGHTNESS] Invalid brightness type: {type(brightness).__name__}, expected int")
|
||||
return False
|
||||
|
||||
if self.matrix is None:
|
||||
logger.warning("[BRIGHTNESS] Cannot set brightness in fallback mode")
|
||||
return False
|
||||
|
||||
# Clamp to valid range
|
||||
brightness = max(0, min(100, int(brightness)))
|
||||
|
||||
try:
|
||||
# RGBMatrix accepts brightness as a property
|
||||
self.matrix.brightness = brightness
|
||||
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
|
||||
return True
|
||||
except AttributeError as e:
|
||||
logger.error(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
|
||||
return False
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.error(f"[BRIGHTNESS] Invalid brightness value rejected by hardware: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def get_brightness(self) -> int:
|
||||
"""
|
||||
Get current display brightness.
|
||||
|
||||
Returns:
|
||||
Current brightness level (0-100), or -1 if unavailable
|
||||
"""
|
||||
if self.matrix is None:
|
||||
logger.debug("[BRIGHTNESS] Cannot get brightness in fallback mode")
|
||||
return -1
|
||||
|
||||
try:
|
||||
return self.matrix.brightness
|
||||
except AttributeError as e:
|
||||
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
|
||||
return -1
|
||||
|
||||
def _draw_test_pattern(self):
|
||||
"""Draw a test pattern to verify the display is working."""
|
||||
try:
|
||||
@@ -816,8 +868,12 @@ class DisplayManager:
|
||||
get_assets_file_mode
|
||||
)
|
||||
snapshot_path_obj = Path(self._snapshot_path)
|
||||
if snapshot_path_obj.parent:
|
||||
ensure_directory_permissions(snapshot_path_obj.parent, get_assets_dir_mode())
|
||||
# Only ensure permissions on non-system directories
|
||||
# Never modify /tmp permissions - it has special system permissions (1777)
|
||||
# that must not be changed or it breaks apt and other system tools
|
||||
parent_dir = snapshot_path_obj.parent
|
||||
if parent_dir and str(parent_dir) != '/tmp':
|
||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||
# Write atomically: temp then replace
|
||||
tmp_path = f"{self._snapshot_path}.tmp"
|
||||
self.image.save(tmp_path, format='PNG')
|
||||
|
||||
418
src/error_aggregator.py
Normal file
418
src/error_aggregator.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
Error Aggregation Service
|
||||
|
||||
Provides centralized error tracking, pattern detection, and reporting
|
||||
for the LEDMatrix system. Enables automatic bug detection by tracking
|
||||
error frequency, patterns, and context.
|
||||
|
||||
This is a local-only implementation with no external dependencies.
|
||||
Errors are stored in memory with optional JSON export.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import traceback
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
import logging
|
||||
|
||||
from src.exceptions import LEDMatrixError
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorRecord:
|
||||
"""Record of a single error occurrence."""
|
||||
error_type: str
|
||||
message: str
|
||||
timestamp: datetime
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
plugin_id: Optional[str] = None
|
||||
operation: Optional[str] = None
|
||||
stack_trace: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"error_type": self.error_type,
|
||||
"message": self.message,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"context": self.context,
|
||||
"plugin_id": self.plugin_id,
|
||||
"operation": self.operation,
|
||||
"stack_trace": self.stack_trace
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorPattern:
|
||||
"""Detected error pattern for automatic detection."""
|
||||
error_type: str
|
||||
count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
affected_plugins: List[str] = field(default_factory=list)
|
||||
sample_messages: List[str] = field(default_factory=list)
|
||||
severity: str = "warning" # warning, error, critical
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"error_type": self.error_type,
|
||||
"count": self.count,
|
||||
"first_seen": self.first_seen.isoformat(),
|
||||
"last_seen": self.last_seen.isoformat(),
|
||||
"affected_plugins": list(set(self.affected_plugins)),
|
||||
"sample_messages": self.sample_messages[:3], # Keep only 3 samples
|
||||
"severity": self.severity
|
||||
}
|
||||
|
||||
|
||||
class ErrorAggregator:
|
||||
"""
|
||||
Aggregates and analyzes errors across the system.
|
||||
|
||||
Features:
|
||||
- Error counting by type, plugin, and time window
|
||||
- Pattern detection (recurring errors)
|
||||
- Error rate alerting via callbacks
|
||||
- Export for analytics/reporting
|
||||
|
||||
Thread-safe for concurrent access.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_records: int = 1000,
|
||||
pattern_threshold: int = 5,
|
||||
pattern_window_minutes: int = 60,
|
||||
export_path: Optional[Path] = None
|
||||
):
|
||||
"""
|
||||
Initialize the error aggregator.
|
||||
|
||||
Args:
|
||||
max_records: Maximum number of error records to keep in memory
|
||||
pattern_threshold: Number of occurrences to detect a pattern
|
||||
pattern_window_minutes: Time window for pattern detection
|
||||
export_path: Optional path for JSON export (auto-export on pattern detection)
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.max_records = max_records
|
||||
self.pattern_threshold = pattern_threshold
|
||||
self.pattern_window = timedelta(minutes=pattern_window_minutes)
|
||||
self.export_path = export_path
|
||||
|
||||
self._records: List[ErrorRecord] = []
|
||||
self._error_counts: Dict[str, int] = defaultdict(int)
|
||||
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
self._patterns: Dict[str, ErrorPattern] = {}
|
||||
self._pattern_callbacks: List[Callable[[ErrorPattern], None]] = []
|
||||
self._lock = threading.RLock() # RLock allows nested acquisition for export_to_file
|
||||
|
||||
# Track session start for relative timing
|
||||
self._session_start = datetime.now()
|
||||
|
||||
def record_error(
|
||||
self,
|
||||
error: Exception,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
plugin_id: Optional[str] = None,
|
||||
operation: Optional[str] = None
|
||||
) -> ErrorRecord:
|
||||
"""
|
||||
Record an error occurrence.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
context: Optional context dictionary with additional details
|
||||
plugin_id: Optional plugin ID that caused the error
|
||||
operation: Optional operation name (e.g., "update", "display")
|
||||
|
||||
Returns:
|
||||
The created ErrorRecord
|
||||
"""
|
||||
with self._lock:
|
||||
error_type = type(error).__name__
|
||||
|
||||
# Extract additional context from LEDMatrixError subclasses
|
||||
error_context = context or {}
|
||||
if isinstance(error, LEDMatrixError) and error.context:
|
||||
error_context.update(error.context)
|
||||
|
||||
record = ErrorRecord(
|
||||
error_type=error_type,
|
||||
message=str(error),
|
||||
timestamp=datetime.now(),
|
||||
context=error_context,
|
||||
plugin_id=plugin_id,
|
||||
operation=operation,
|
||||
stack_trace=traceback.format_exc()
|
||||
)
|
||||
|
||||
# Add record (with size limit)
|
||||
self._records.append(record)
|
||||
if len(self._records) > self.max_records:
|
||||
self._records.pop(0)
|
||||
|
||||
# Update counts
|
||||
self._error_counts[error_type] += 1
|
||||
if plugin_id:
|
||||
self._plugin_error_counts[plugin_id][error_type] += 1
|
||||
|
||||
# Check for patterns
|
||||
self._detect_pattern(record)
|
||||
|
||||
# Log the error
|
||||
self.logger.debug(
|
||||
f"Error recorded: {error_type} - {str(error)[:100]}",
|
||||
extra={"plugin_id": plugin_id, "operation": operation}
|
||||
)
|
||||
|
||||
return record
|
||||
|
||||
def _detect_pattern(self, record: ErrorRecord) -> None:
|
||||
"""Detect recurring error patterns."""
|
||||
cutoff = datetime.now() - self.pattern_window
|
||||
recent_same_type = [
|
||||
r for r in self._records
|
||||
if r.error_type == record.error_type and r.timestamp > cutoff
|
||||
]
|
||||
|
||||
if len(recent_same_type) >= self.pattern_threshold:
|
||||
pattern_key = record.error_type
|
||||
is_new_pattern = pattern_key not in self._patterns
|
||||
|
||||
# Determine severity based on count
|
||||
count = len(recent_same_type)
|
||||
if count > self.pattern_threshold * 3:
|
||||
severity = "critical"
|
||||
elif count > self.pattern_threshold * 2:
|
||||
severity = "error"
|
||||
else:
|
||||
severity = "warning"
|
||||
|
||||
# Collect affected plugins
|
||||
affected_plugins = [r.plugin_id for r in recent_same_type if r.plugin_id]
|
||||
|
||||
# Collect sample messages
|
||||
sample_messages = list(set(r.message for r in recent_same_type[:5]))
|
||||
|
||||
if is_new_pattern:
|
||||
pattern = ErrorPattern(
|
||||
error_type=record.error_type,
|
||||
count=count,
|
||||
first_seen=recent_same_type[0].timestamp,
|
||||
last_seen=record.timestamp,
|
||||
affected_plugins=affected_plugins,
|
||||
sample_messages=sample_messages,
|
||||
severity=severity
|
||||
)
|
||||
self._patterns[pattern_key] = pattern
|
||||
|
||||
self.logger.warning(
|
||||
f"Error pattern detected: {record.error_type} occurred "
|
||||
f"{count} times in last {self.pattern_window}. "
|
||||
f"Affected plugins: {set(affected_plugins) or 'unknown'}"
|
||||
)
|
||||
|
||||
# Notify callbacks
|
||||
for callback in self._pattern_callbacks:
|
||||
try:
|
||||
callback(pattern)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Pattern callback failed: {e}")
|
||||
|
||||
# Auto-export if path configured
|
||||
if self.export_path:
|
||||
self._auto_export()
|
||||
else:
|
||||
# Update existing pattern
|
||||
self._patterns[pattern_key].count = count
|
||||
self._patterns[pattern_key].last_seen = record.timestamp
|
||||
self._patterns[pattern_key].severity = severity
|
||||
self._patterns[pattern_key].affected_plugins.extend(affected_plugins)
|
||||
|
||||
def on_pattern_detected(self, callback: Callable[[ErrorPattern], None]) -> None:
|
||||
"""
|
||||
Register a callback to be called when a new error pattern is detected.
|
||||
|
||||
Args:
|
||||
callback: Function that takes an ErrorPattern as argument
|
||||
"""
|
||||
self._pattern_callbacks.append(callback)
|
||||
|
||||
def get_error_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get summary of all errors for reporting.
|
||||
|
||||
Returns:
|
||||
Dictionary with error statistics and recent errors
|
||||
"""
|
||||
with self._lock:
|
||||
# Calculate error rate (errors per hour)
|
||||
session_duration = (datetime.now() - self._session_start).total_seconds() / 3600
|
||||
error_rate = len(self._records) / max(session_duration, 0.01)
|
||||
|
||||
return {
|
||||
"session_start": self._session_start.isoformat(),
|
||||
"total_errors": len(self._records),
|
||||
"error_rate_per_hour": round(error_rate, 2),
|
||||
"error_counts_by_type": dict(self._error_counts),
|
||||
"plugin_error_counts": {
|
||||
k: dict(v) for k, v in self._plugin_error_counts.items()
|
||||
},
|
||||
"active_patterns": {
|
||||
k: v.to_dict() for k, v in self._patterns.items()
|
||||
},
|
||||
"recent_errors": [
|
||||
r.to_dict() for r in self._records[-20:]
|
||||
]
|
||||
}
|
||||
|
||||
def get_plugin_health(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get health status for a specific plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin ID to check
|
||||
|
||||
Returns:
|
||||
Dictionary with plugin error statistics
|
||||
"""
|
||||
with self._lock:
|
||||
plugin_errors = self._plugin_error_counts.get(plugin_id, {})
|
||||
recent_plugin_errors = [
|
||||
r for r in self._records[-100:]
|
||||
if r.plugin_id == plugin_id
|
||||
]
|
||||
|
||||
# Determine health status
|
||||
recent_count = len(recent_plugin_errors)
|
||||
if recent_count == 0:
|
||||
status = "healthy"
|
||||
elif recent_count < 5:
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "unhealthy"
|
||||
|
||||
return {
|
||||
"plugin_id": plugin_id,
|
||||
"status": status,
|
||||
"total_errors": sum(plugin_errors.values()),
|
||||
"error_types": dict(plugin_errors),
|
||||
"recent_error_count": recent_count,
|
||||
"last_error": recent_plugin_errors[-1].to_dict() if recent_plugin_errors else None
|
||||
}
|
||||
|
||||
def clear_old_records(self, max_age_hours: int = 24) -> int:
|
||||
"""
|
||||
Clear records older than specified age.
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age in hours
|
||||
|
||||
Returns:
|
||||
Number of records cleared
|
||||
"""
|
||||
with self._lock:
|
||||
cutoff = datetime.now() - timedelta(hours=max_age_hours)
|
||||
original_count = len(self._records)
|
||||
self._records = [r for r in self._records if r.timestamp > cutoff]
|
||||
cleared = original_count - len(self._records)
|
||||
|
||||
if cleared > 0:
|
||||
self.logger.info(f"Cleared {cleared} old error records")
|
||||
|
||||
return cleared
|
||||
|
||||
def export_to_file(self, filepath: Path) -> None:
|
||||
"""
|
||||
Export error data to JSON file.
|
||||
|
||||
Args:
|
||||
filepath: Path to export file
|
||||
"""
|
||||
with self._lock:
|
||||
data = {
|
||||
"exported_at": datetime.now().isoformat(),
|
||||
"summary": self.get_error_summary(),
|
||||
"all_records": [r.to_dict() for r in self._records]
|
||||
}
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
filepath.write_text(json.dumps(data, indent=2))
|
||||
self.logger.info(f"Exported error data to {filepath}")
|
||||
|
||||
def _auto_export(self) -> None:
|
||||
"""Auto-export on pattern detection (if export_path configured)."""
|
||||
if self.export_path:
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filepath = self.export_path / f"errors_{timestamp}.json"
|
||||
self.export_to_file(filepath)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Auto-export failed: {e}")
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_error_aggregator: Optional[ErrorAggregator] = None
|
||||
_aggregator_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_error_aggregator(
|
||||
max_records: int = 1000,
|
||||
pattern_threshold: int = 5,
|
||||
pattern_window_minutes: int = 60,
|
||||
export_path: Optional[Path] = None
|
||||
) -> ErrorAggregator:
|
||||
"""
|
||||
Get or create the global error aggregator instance.
|
||||
|
||||
Args:
|
||||
max_records: Maximum records to keep (only used on first call)
|
||||
pattern_threshold: Pattern detection threshold (only used on first call)
|
||||
pattern_window_minutes: Pattern detection window (only used on first call)
|
||||
export_path: Export path for auto-export (only used on first call)
|
||||
|
||||
Returns:
|
||||
The global ErrorAggregator instance
|
||||
"""
|
||||
global _error_aggregator
|
||||
|
||||
with _aggregator_lock:
|
||||
if _error_aggregator is None:
|
||||
_error_aggregator = ErrorAggregator(
|
||||
max_records=max_records,
|
||||
pattern_threshold=pattern_threshold,
|
||||
pattern_window_minutes=pattern_window_minutes,
|
||||
export_path=export_path
|
||||
)
|
||||
return _error_aggregator
|
||||
|
||||
|
||||
def record_error(
|
||||
error: Exception,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
plugin_id: Optional[str] = None,
|
||||
operation: Optional[str] = None
|
||||
) -> ErrorRecord:
|
||||
"""
|
||||
Convenience function to record an error to the global aggregator.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
context: Optional context dictionary
|
||||
plugin_id: Optional plugin ID
|
||||
operation: Optional operation name
|
||||
|
||||
Returns:
|
||||
The created ErrorRecord
|
||||
"""
|
||||
return get_error_aggregator().record_error(
|
||||
error=error,
|
||||
context=context,
|
||||
plugin_id=plugin_id,
|
||||
operation=operation
|
||||
)
|
||||
@@ -148,7 +148,13 @@ class LogoDownloader:
|
||||
|
||||
def get_logo_directory(self, league: str) -> str:
|
||||
"""Get the logo directory for a given league."""
|
||||
directory = LogoDownloader.LOGO_DIRECTORIES.get(league, f'assets/sports/{league}_logos')
|
||||
directory = LogoDownloader.LOGO_DIRECTORIES.get(league)
|
||||
if not directory:
|
||||
# Custom soccer leagues share the same logo directory as predefined ones
|
||||
if league.startswith('soccer_'):
|
||||
directory = 'assets/sports/soccer_logos'
|
||||
else:
|
||||
directory = f'assets/sports/{league}_logos'
|
||||
path = Path(directory)
|
||||
if not path.is_absolute():
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
@@ -238,9 +244,18 @@ class LogoDownloader:
|
||||
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
|
||||
return False
|
||||
|
||||
def _resolve_api_url(self, league: str) -> Optional[str]:
|
||||
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
|
||||
api_url = self.API_ENDPOINTS.get(league)
|
||||
if not api_url and league.startswith('soccer_'):
|
||||
league_code = league[len('soccer_'):]
|
||||
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
|
||||
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
|
||||
return api_url
|
||||
|
||||
def fetch_teams_data(self, league: str) -> Optional[Dict]:
|
||||
"""Fetch team data from ESPN API for a specific league."""
|
||||
api_url = self.API_ENDPOINTS.get(league)
|
||||
api_url = self._resolve_api_url(league)
|
||||
if not api_url:
|
||||
logger.error(f"No API endpoint configured for league: {league}")
|
||||
return None
|
||||
@@ -263,7 +278,7 @@ class LogoDownloader:
|
||||
|
||||
def fetch_single_team(self, league: str, team_id: str) -> Optional[Dict]:
|
||||
"""Fetch team data from ESPN API for a specific league."""
|
||||
api_url = self.API_ENDPOINTS.get(league)
|
||||
api_url = self._resolve_api_url(league)
|
||||
if not api_url:
|
||||
logger.error(f"No API endpoint configured for league: {league}")
|
||||
return None
|
||||
@@ -570,7 +585,7 @@ class LogoDownloader:
|
||||
total_failed = 0
|
||||
|
||||
for league in leagues:
|
||||
if league not in self.API_ENDPOINTS:
|
||||
if not self._resolve_api_url(league):
|
||||
logger.warning(f"Skipping unknown league: {league}")
|
||||
continue
|
||||
|
||||
|
||||
@@ -9,11 +9,35 @@ Stability: Stable - maintains backward compatibility
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional, List
|
||||
import logging
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class VegasDisplayMode(Enum):
|
||||
"""
|
||||
Display mode for Vegas scroll integration.
|
||||
|
||||
Determines how a plugin's content behaves within the continuous scroll:
|
||||
|
||||
- SCROLL: Content scrolls continuously within the stream.
|
||||
Best for multi-item plugins like sports scores, odds tickers, news feeds.
|
||||
Plugin provides multiple frames via get_vegas_content().
|
||||
|
||||
- FIXED_SEGMENT: Content is a fixed-width block that scrolls BY with
|
||||
the rest of the content. Best for static info like clock, weather.
|
||||
Plugin provides a single image sized to vegas_panel_count panels.
|
||||
|
||||
- STATIC: Scroll pauses, plugin displays for its duration, then scroll
|
||||
resumes. Best for important alerts or detailed views that need attention.
|
||||
Plugin uses standard display() method during the pause.
|
||||
"""
|
||||
SCROLL = "scroll"
|
||||
FIXED_SEGMENT = "fixed"
|
||||
STATIC = "static"
|
||||
|
||||
|
||||
class BasePlugin(ABC):
|
||||
"""
|
||||
Base class that all plugins must inherit from.
|
||||
@@ -109,11 +133,11 @@ class BasePlugin(ABC):
|
||||
def get_display_duration(self) -> float:
|
||||
"""
|
||||
Get the display duration for this plugin instance.
|
||||
|
||||
|
||||
Automatically detects duration from:
|
||||
1. self.display_duration instance variable (if exists)
|
||||
2. self.config.get("display_duration", 15.0) (fallback)
|
||||
|
||||
|
||||
Can be overridden by plugins to provide dynamic durations based
|
||||
on content (e.g., longer duration for more complex displays).
|
||||
|
||||
@@ -131,28 +155,79 @@ class BasePlugin(ABC):
|
||||
elif isinstance(duration, (int, float)):
|
||||
if duration > 0:
|
||||
return float(duration)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"display_duration instance variable is non-positive (%s), using config fallback",
|
||||
duration
|
||||
)
|
||||
# Try converting string representations of numbers
|
||||
elif isinstance(duration, str):
|
||||
try:
|
||||
duration_float = float(duration)
|
||||
if duration_float > 0:
|
||||
return duration_float
|
||||
else:
|
||||
self.logger.debug(
|
||||
"display_duration string value is non-positive (%s), using config fallback",
|
||||
duration
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass # Fall through to config
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
pass # Fall through to config
|
||||
|
||||
self.logger.warning(
|
||||
"display_duration instance variable has invalid string value '%s', using config fallback",
|
||||
duration
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"display_duration instance variable has unexpected type %s (value: %s), using config fallback",
|
||||
type(duration).__name__, duration
|
||||
)
|
||||
except (TypeError, ValueError, AttributeError) as e:
|
||||
self.logger.warning(
|
||||
"Error reading display_duration instance variable: %s, using config fallback",
|
||||
e
|
||||
)
|
||||
|
||||
# Fall back to config
|
||||
config_duration = self.config.get("display_duration", 15.0)
|
||||
try:
|
||||
# Ensure config value is also a valid float
|
||||
if isinstance(config_duration, (int, float)):
|
||||
return float(config_duration) if config_duration > 0 else 15.0
|
||||
if config_duration > 0:
|
||||
return float(config_duration)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"Config display_duration is non-positive (%s), using default 15.0",
|
||||
config_duration
|
||||
)
|
||||
return 15.0
|
||||
elif isinstance(config_duration, str):
|
||||
return float(config_duration) if float(config_duration) > 0 else 15.0
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
duration_float = float(config_duration)
|
||||
if duration_float > 0:
|
||||
return duration_float
|
||||
else:
|
||||
self.logger.debug(
|
||||
"Config display_duration string is non-positive (%s), using default 15.0",
|
||||
config_duration
|
||||
)
|
||||
return 15.0
|
||||
except ValueError:
|
||||
self.logger.warning(
|
||||
"Config display_duration has invalid string value '%s', using default 15.0",
|
||||
config_duration
|
||||
)
|
||||
return 15.0
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Config display_duration has unexpected type %s (value: %s), using default 15.0",
|
||||
type(config_duration).__name__, config_duration
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.logger.warning(
|
||||
"Error processing config display_duration: %s, using default 15.0",
|
||||
e
|
||||
)
|
||||
|
||||
return 15.0
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@@ -285,6 +360,168 @@ class BasePlugin(ABC):
|
||||
return manifest.get("display_modes", [self.plugin_id])
|
||||
return [self.plugin_id]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Vegas scroll mode support
|
||||
# -------------------------------------------------------------------------
|
||||
def get_vegas_content(self) -> Optional[Any]:
|
||||
"""
|
||||
Get content for Vegas-style continuous scroll mode.
|
||||
|
||||
Override this method to provide optimized content for continuous scrolling.
|
||||
Plugins can return:
|
||||
- A single PIL Image: Displayed as a static block in the scroll
|
||||
- A list of PIL Images: Each image becomes a separate item in the scroll
|
||||
- None: Vegas mode will fall back to capturing display() output
|
||||
|
||||
Multi-item plugins (sports scores, odds) should return individual game/item
|
||||
images so they scroll smoothly with other plugins.
|
||||
|
||||
Returns:
|
||||
PIL Image, list of PIL Images, or None
|
||||
|
||||
Example (sports plugin):
|
||||
def get_vegas_content(self):
|
||||
# Return individual game cards for smooth scrolling
|
||||
return [self._render_game(game) for game in self.games]
|
||||
|
||||
Example (static plugin):
|
||||
def get_vegas_content(self):
|
||||
# Return current display as single block
|
||||
return self._render_current_view()
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_vegas_content_type(self) -> str:
|
||||
"""
|
||||
Indicate the type of content this plugin provides for Vegas scroll.
|
||||
|
||||
Override this to specify how Vegas mode should treat this plugin's content.
|
||||
|
||||
Returns:
|
||||
'multi' - Plugin has multiple scrollable items (sports, odds, news)
|
||||
'static' - Plugin is a static block (clock, weather, music)
|
||||
'none' - Plugin should not appear in Vegas scroll mode
|
||||
|
||||
Example:
|
||||
def get_vegas_content_type(self):
|
||||
return 'multi' # We have multiple games to scroll
|
||||
"""
|
||||
return 'static'
|
||||
|
||||
def get_vegas_display_mode(self) -> VegasDisplayMode:
|
||||
"""
|
||||
Get the display mode for Vegas scroll integration.
|
||||
|
||||
This method determines how the plugin's content behaves within Vegas mode:
|
||||
- SCROLL: Content scrolls continuously (multi-item plugins)
|
||||
- FIXED_SEGMENT: Fixed block that scrolls by (clock, weather)
|
||||
- STATIC: Pause scroll to display (alerts, detailed views)
|
||||
|
||||
Override to change default behavior. By default, reads from config
|
||||
or maps legacy get_vegas_content_type() for backward compatibility.
|
||||
|
||||
Returns:
|
||||
VegasDisplayMode enum value
|
||||
|
||||
Example:
|
||||
def get_vegas_display_mode(self):
|
||||
return VegasDisplayMode.SCROLL
|
||||
"""
|
||||
# Check for explicit config setting first
|
||||
config_mode = self.config.get("vegas_mode")
|
||||
if config_mode:
|
||||
try:
|
||||
return VegasDisplayMode(config_mode)
|
||||
except ValueError:
|
||||
self.logger.warning(
|
||||
"Invalid vegas_mode '%s' for %s, using default",
|
||||
config_mode, self.plugin_id
|
||||
)
|
||||
|
||||
# Fall back to mapping legacy content_type
|
||||
content_type = self.get_vegas_content_type()
|
||||
if content_type == 'multi':
|
||||
return VegasDisplayMode.SCROLL
|
||||
elif content_type == 'static':
|
||||
return VegasDisplayMode.FIXED_SEGMENT
|
||||
elif content_type == 'none':
|
||||
# 'none' means excluded - return FIXED_SEGMENT as default
|
||||
# The exclusion is handled by checking get_vegas_content_type() separately
|
||||
return VegasDisplayMode.FIXED_SEGMENT
|
||||
|
||||
return VegasDisplayMode.FIXED_SEGMENT
|
||||
|
||||
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
||||
"""
|
||||
Return list of Vegas display modes this plugin supports.
|
||||
|
||||
Used by the web UI to show available mode options for user configuration.
|
||||
Override to customize which modes are available for this plugin.
|
||||
|
||||
By default:
|
||||
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
||||
- 'static' content type plugins support FIXED_SEGMENT and STATIC
|
||||
- 'none' content type plugins return empty list (excluded from Vegas)
|
||||
|
||||
Returns:
|
||||
List of VegasDisplayMode values this plugin can use
|
||||
|
||||
Example:
|
||||
def get_supported_vegas_modes(self):
|
||||
# This plugin only makes sense as a scrolling ticker
|
||||
return [VegasDisplayMode.SCROLL]
|
||||
"""
|
||||
content_type = self.get_vegas_content_type()
|
||||
|
||||
if content_type == 'none':
|
||||
return []
|
||||
elif content_type == 'multi':
|
||||
return [VegasDisplayMode.SCROLL, VegasDisplayMode.FIXED_SEGMENT]
|
||||
else: # 'static'
|
||||
return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.STATIC]
|
||||
|
||||
def get_vegas_segment_width(self) -> Optional[int]:
|
||||
"""
|
||||
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
|
||||
|
||||
Returns the number of panels this plugin should occupy when displayed
|
||||
as a fixed segment. The actual pixel width is calculated as:
|
||||
width = panels * single_panel_width
|
||||
|
||||
Where single_panel_width comes from display.hardware.cols in config.
|
||||
|
||||
Override to provide dynamic sizing based on content.
|
||||
Returns None to use the default (1 panel).
|
||||
|
||||
Returns:
|
||||
Number of panels, or None for default (1 panel)
|
||||
|
||||
Example:
|
||||
def get_vegas_segment_width(self):
|
||||
# Clock needs 2 panels to show time clearly
|
||||
return 2
|
||||
"""
|
||||
raw_value = self.config.get("vegas_panel_count", None)
|
||||
if raw_value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
panel_count = int(raw_value)
|
||||
if panel_count > 0:
|
||||
return panel_count
|
||||
else:
|
||||
self.logger.warning(
|
||||
"vegas_panel_count must be positive, got %s; using default",
|
||||
raw_value
|
||||
)
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
self.logger.warning(
|
||||
"Invalid vegas_panel_count value '%s'; using default",
|
||||
raw_value
|
||||
)
|
||||
return None
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""
|
||||
Validate plugin configuration against schema.
|
||||
|
||||
@@ -167,6 +167,13 @@ class OperationHistory:
|
||||
|
||||
return history[:limit]
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear all operation history records."""
|
||||
with self._lock:
|
||||
self._history.clear()
|
||||
self._save_history()
|
||||
self.logger.info("Operation history cleared")
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save history to file."""
|
||||
if not self.history_file:
|
||||
|
||||
@@ -13,6 +13,7 @@ import logging
|
||||
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.error_aggregator import record_error
|
||||
|
||||
|
||||
class TimeoutError(Exception):
|
||||
@@ -80,12 +81,15 @@ class PluginExecutor:
|
||||
if not result_container['completed']:
|
||||
error_msg = f"{plugin_context} operation timed out after {timeout}s"
|
||||
self.logger.error(error_msg)
|
||||
raise TimeoutError(error_msg)
|
||||
|
||||
timeout_error = TimeoutError(error_msg)
|
||||
record_error(timeout_error, plugin_id=plugin_id, operation="timeout")
|
||||
raise timeout_error
|
||||
|
||||
if result_container['exception']:
|
||||
error = result_container['exception']
|
||||
error_msg = f"{plugin_context} operation failed: {error}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
record_error(error, plugin_id=plugin_id, operation="execute")
|
||||
raise PluginError(error_msg, plugin_id=plugin_id) from error
|
||||
|
||||
return result_container['value']
|
||||
@@ -128,7 +132,7 @@ class PluginExecutor:
|
||||
self.logger.error("Plugin %s update() timed out", plugin_id)
|
||||
return False
|
||||
except PluginError:
|
||||
# Already logged in execute_with_timeout
|
||||
# Already logged and recorded in execute_with_timeout
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
@@ -137,6 +141,7 @@ class PluginExecutor:
|
||||
e,
|
||||
exc_info=True
|
||||
)
|
||||
record_error(e, plugin_id=plugin_id, operation="update")
|
||||
return False
|
||||
|
||||
def execute_display(
|
||||
@@ -203,7 +208,7 @@ class PluginExecutor:
|
||||
self.logger.error("Plugin %s display() timed out", plugin_id)
|
||||
return False
|
||||
except PluginError:
|
||||
# Already logged in execute_with_timeout
|
||||
# Already logged and recorded in execute_with_timeout
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
@@ -212,6 +217,7 @@ class PluginExecutor:
|
||||
e,
|
||||
exc_info=True
|
||||
)
|
||||
record_error(e, plugin_id=plugin_id, operation="display")
|
||||
return False
|
||||
|
||||
def execute_safe(
|
||||
|
||||
@@ -10,6 +10,7 @@ import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple, Type
|
||||
import logging
|
||||
@@ -24,16 +25,25 @@ from src.common.permission_utils import (
|
||||
|
||||
class PluginLoader:
|
||||
"""Handles plugin module loading and class instantiation."""
|
||||
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Initialize the plugin loader.
|
||||
|
||||
|
||||
Args:
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or get_logger(__name__)
|
||||
self._loaded_modules: Dict[str, Any] = {}
|
||||
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
|
||||
# Lock to serialize module loading when plugins share module names
|
||||
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
|
||||
# During exec_module, bare-name sub-modules temporarily appear in
|
||||
# sys.modules; the lock prevents concurrent plugins from seeing each
|
||||
# other's entries. After exec_module, _namespace_plugin_modules
|
||||
# moves those bare names to namespaced keys (e.g.
|
||||
# _plg_basketball_scoreboard_scroll_display) so they never collide.
|
||||
self._module_load_lock = threading.Lock()
|
||||
|
||||
def find_plugin_directory(
|
||||
self,
|
||||
@@ -189,6 +199,92 @@ class PluginLoader:
|
||||
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _iter_plugin_bare_modules(
|
||||
plugin_dir: Path, before_keys: set
|
||||
) -> list:
|
||||
"""Return bare-name modules from plugin_dir added after before_keys.
|
||||
|
||||
Returns a list of (mod_name, module) tuples for modules that:
|
||||
- Were added to sys.modules after before_keys snapshot
|
||||
- Have bare names (no dots)
|
||||
- Have a ``__file__`` inside plugin_dir
|
||||
"""
|
||||
resolved_dir = plugin_dir.resolve()
|
||||
result = []
|
||||
for key in set(sys.modules.keys()) - before_keys:
|
||||
if "." in key:
|
||||
continue
|
||||
mod = sys.modules.get(key)
|
||||
if mod is None:
|
||||
continue
|
||||
mod_file = getattr(mod, "__file__", None)
|
||||
if not mod_file:
|
||||
continue
|
||||
try:
|
||||
if Path(mod_file).resolve().is_relative_to(resolved_dir):
|
||||
result.append((key, mod))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return result
|
||||
|
||||
def _namespace_plugin_modules(
|
||||
self, plugin_id: str, plugin_dir: Path, before_keys: set
|
||||
) -> None:
|
||||
"""
|
||||
Move bare-name plugin modules to namespaced keys in sys.modules.
|
||||
|
||||
After exec_module loads a plugin's entry point, Python will have added
|
||||
the plugin's local modules (scroll_display, game_renderer, …) to
|
||||
sys.modules under their bare names. This method renames them to
|
||||
``_plg_<plugin_id>_<module>`` so they cannot collide with identically-
|
||||
named modules from other plugins.
|
||||
|
||||
The plugin code keeps working because ``from scroll_display import X``
|
||||
binds ``X`` to the class *object*, not to the sys.modules entry.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugin_dir: Plugin directory path
|
||||
before_keys: Snapshot of sys.modules keys taken *before* exec_module
|
||||
"""
|
||||
safe_id = plugin_id.replace("-", "_")
|
||||
namespaced_names: set = set()
|
||||
|
||||
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
||||
namespaced = f"_plg_{safe_id}_{mod_name}"
|
||||
sys.modules[namespaced] = mod
|
||||
# Keep sys.modules[mod_name] as an alias to the same object.
|
||||
# Removing it would cause lazy intra-plugin imports (e.g. a
|
||||
# deferred ``import scroll_display`` inside a method) to
|
||||
# re-import from disk and create a second, inconsistent copy
|
||||
# of the module. The next plugin's exec_module will naturally
|
||||
# overwrite the bare entry with its own version.
|
||||
namespaced_names.add(namespaced)
|
||||
self.logger.debug(
|
||||
"Namespace-isolated module '%s' -> '%s' for plugin %s",
|
||||
mod_name, namespaced, plugin_id,
|
||||
)
|
||||
|
||||
# Track for cleanup during unload
|
||||
self._plugin_module_registry[plugin_id] = namespaced_names
|
||||
|
||||
if namespaced_names:
|
||||
self.logger.info(
|
||||
"Namespace-isolated %d module(s) for plugin %s",
|
||||
len(namespaced_names), plugin_id,
|
||||
)
|
||||
|
||||
def unregister_plugin_modules(self, plugin_id: str) -> None:
|
||||
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
|
||||
|
||||
Called by PluginManager during unload to clean up all module entries
|
||||
that were created when the plugin was loaded.
|
||||
"""
|
||||
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
|
||||
sys.modules.pop(ns_name, None)
|
||||
self._loaded_modules.pop(plugin_id, None)
|
||||
|
||||
def load_module(
|
||||
self,
|
||||
plugin_id: str,
|
||||
@@ -197,12 +293,21 @@ class PluginLoader:
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Load a plugin module from file.
|
||||
|
||||
|
||||
Module loading is serialized via _module_load_lock because plugins are
|
||||
loaded in parallel (ThreadPoolExecutor) and multiple sport plugins
|
||||
share identically-named local modules (scroll_display.py,
|
||||
game_renderer.py, sports.py, etc.).
|
||||
|
||||
After loading, bare-name modules from the plugin directory are moved
|
||||
to namespaced keys in sys.modules (e.g. ``_plg_basketball_scoreboard_scroll_display``)
|
||||
so they cannot collide with other plugins.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugin_dir: Plugin directory path
|
||||
entry_point: Entry point filename (e.g., 'manager.py')
|
||||
|
||||
|
||||
Returns:
|
||||
Loaded module or None on error
|
||||
"""
|
||||
@@ -211,34 +316,53 @@ class PluginLoader:
|
||||
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
# Add plugin directory to sys.path if not already there
|
||||
plugin_dir_str = str(plugin_dir)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str)
|
||||
|
||||
# Import the plugin module
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
|
||||
# Check if already loaded
|
||||
if module_name in sys.modules:
|
||||
self.logger.debug("Module %s already loaded, reusing", module_name)
|
||||
return sys.modules[module_name]
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, entry_file)
|
||||
if spec is None or spec.loader is None:
|
||||
error_msg = f"Could not create module spec for {entry_file}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
self._loaded_modules[plugin_id] = module
|
||||
self.logger.debug("Loaded module %s for plugin %s", module_name, plugin_id)
|
||||
|
||||
|
||||
with self._module_load_lock:
|
||||
# Add plugin directory to sys.path if not already there
|
||||
plugin_dir_str = str(plugin_dir)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str)
|
||||
|
||||
# Import the plugin module
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
|
||||
# Check if already loaded
|
||||
if module_name in sys.modules:
|
||||
self.logger.debug("Module %s already loaded, reusing", module_name)
|
||||
return sys.modules[module_name]
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, entry_file)
|
||||
if spec is None or spec.loader is None:
|
||||
error_msg = f"Could not create module spec for {entry_file}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
|
||||
# Snapshot AFTER inserting the main module so that
|
||||
# _namespace_plugin_modules and error cleanup only target
|
||||
# sub-modules, not the main module entry itself.
|
||||
before_keys = set(sys.modules.keys())
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Move bare-name plugin modules to namespaced keys so they
|
||||
# cannot collide with identically-named modules from other plugins
|
||||
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
|
||||
except Exception:
|
||||
# Clean up the partially-initialized main module and any
|
||||
# bare-name sub-modules that were added during exec_module
|
||||
# so they don't leak into subsequent plugin loads.
|
||||
sys.modules.pop(module_name, None)
|
||||
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
||||
sys.modules.pop(key, None)
|
||||
raise
|
||||
|
||||
self._loaded_modules[plugin_id] = module
|
||||
self.logger.debug("Loaded module %s for plugin %s", module_name, plugin_id)
|
||||
|
||||
return module
|
||||
|
||||
def get_plugin_class(
|
||||
|
||||
@@ -136,13 +136,24 @@ class PluginManager:
|
||||
def discover_plugins(self) -> List[str]:
|
||||
"""
|
||||
Discover all plugins in the plugins directory.
|
||||
|
||||
|
||||
Also checks for potential config key collisions and logs warnings.
|
||||
|
||||
Returns:
|
||||
List of plugin IDs
|
||||
"""
|
||||
self.logger.info("Discovering plugins in %s", self.plugins_dir)
|
||||
plugin_ids = self._scan_directory_for_plugins(self.plugins_dir)
|
||||
self.logger.info("Discovered %d plugin(s)", len(plugin_ids))
|
||||
|
||||
# Check for config key collisions
|
||||
collisions = self.schema_manager.detect_config_key_collisions(plugin_ids)
|
||||
for collision in collisions:
|
||||
self.logger.warning(
|
||||
"Config collision detected: %s",
|
||||
collision.get('message', str(collision))
|
||||
)
|
||||
|
||||
return plugin_ids
|
||||
|
||||
def _get_dependency_marker_path(self, plugin_id: str) -> Path:
|
||||
@@ -288,6 +299,24 @@ class PluginManager:
|
||||
else:
|
||||
config = {}
|
||||
|
||||
# Check if plugin has a config schema
|
||||
schema_path = self.schema_manager.get_schema_path(plugin_id)
|
||||
if schema_path is None:
|
||||
# Schema file doesn't exist
|
||||
self.logger.warning(
|
||||
f"Plugin '{plugin_id}' has no config_schema.json - configuration will not be validated. "
|
||||
f"Consider adding a schema file for better error detection and user experience."
|
||||
)
|
||||
else:
|
||||
# Schema file exists, try to load it
|
||||
schema = self.schema_manager.load_schema(plugin_id)
|
||||
if schema is None:
|
||||
# Schema exists but couldn't be loaded (likely invalid JSON or schema)
|
||||
self.logger.warning(
|
||||
f"Plugin '{plugin_id}' has a config_schema.json but it could not be loaded. "
|
||||
f"The schema may be invalid. Please verify the schema file at: {schema_path}"
|
||||
)
|
||||
|
||||
# Merge config with schema defaults to ensure all defaults are applied
|
||||
try:
|
||||
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
|
||||
@@ -386,11 +415,13 @@ class PluginManager:
|
||||
if plugin_id in self.plugin_last_update:
|
||||
del self.plugin_last_update[plugin_id]
|
||||
|
||||
# Remove module from sys.modules if present
|
||||
# Remove main module from sys.modules if present
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
# Delegate sub-module and cached-module cleanup to the loader
|
||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||
|
||||
# Remove from plugin_modules
|
||||
self.plugin_modules.pop(plugin_id, None)
|
||||
|
||||
|
||||
@@ -445,3 +445,62 @@ class SchemaManager:
|
||||
replace_none_with_defaults(merged, defaults)
|
||||
return merged
|
||||
|
||||
def detect_config_key_collisions(
|
||||
self,
|
||||
plugin_ids: List[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Detect config key collisions between plugins.
|
||||
|
||||
Checks for:
|
||||
1. Plugin IDs that collide with reserved system config keys
|
||||
2. Plugin IDs that might cause confusion or conflicts
|
||||
|
||||
Args:
|
||||
plugin_ids: List of plugin identifiers to check
|
||||
|
||||
Returns:
|
||||
List of collision warnings, each containing:
|
||||
- type: 'reserved_key_collision' or 'case_collision'
|
||||
- plugin_id: The plugin ID involved
|
||||
- message: Human-readable warning message
|
||||
"""
|
||||
collisions = []
|
||||
|
||||
# Reserved top-level config keys that plugins should not use as IDs
|
||||
reserved_keys = {
|
||||
'display', 'schedule', 'timezone', 'plugin_system',
|
||||
'display_modes', 'system', 'hardware', 'debug',
|
||||
'log_level', 'emulator', 'web_interface'
|
||||
}
|
||||
|
||||
# Track plugin IDs for case collision detection
|
||||
lowercase_ids: Dict[str, str] = {}
|
||||
|
||||
for plugin_id in plugin_ids:
|
||||
# Check reserved key collision
|
||||
if plugin_id.lower() in {k.lower() for k in reserved_keys}:
|
||||
collisions.append({
|
||||
"type": "reserved_key_collision",
|
||||
"plugin_id": plugin_id,
|
||||
"message": f"Plugin ID '{plugin_id}' conflicts with reserved config key. "
|
||||
f"This may cause configuration issues."
|
||||
})
|
||||
|
||||
# Check for case-insensitive collisions between plugins
|
||||
lower_id = plugin_id.lower()
|
||||
if lower_id in lowercase_ids:
|
||||
existing_id = lowercase_ids[lower_id]
|
||||
if existing_id != plugin_id:
|
||||
collisions.append({
|
||||
"type": "case_collision",
|
||||
"plugin_id": plugin_id,
|
||||
"conflicting_id": existing_id,
|
||||
"message": f"Plugin ID '{plugin_id}' may conflict with '{existing_id}' "
|
||||
f"on case-insensitive file systems."
|
||||
})
|
||||
else:
|
||||
lowercase_ids[lower_id] = plugin_id
|
||||
|
||||
return collisions
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from both the official registry and custom GitHub repositories.
|
||||
|
||||
import os
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import shutil
|
||||
import zipfile
|
||||
@@ -18,6 +19,8 @@ from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
import logging
|
||||
|
||||
from src.common.permission_utils import sudo_remove_directory
|
||||
|
||||
try:
|
||||
import jsonschema
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
@@ -51,6 +54,10 @@ class PluginStoreManager:
|
||||
self.github_cache = {} # Cache for GitHub API responses
|
||||
self.cache_timeout = 3600 # 1 hour cache timeout
|
||||
self.registry_cache_timeout = 300 # 5 minutes for registry cache
|
||||
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
|
||||
self.commit_cache_timeout = 300 # 5 minutes (same as registry)
|
||||
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
|
||||
self.manifest_cache_timeout = 300 # 5 minutes
|
||||
self.github_token = self._load_github_token()
|
||||
self._token_validation_cache = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)}
|
||||
self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation
|
||||
@@ -560,7 +567,9 @@ class PluginStoreManager:
|
||||
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
|
||||
|
||||
# Fetch manifest from GitHub for additional metadata (description, etc.)
|
||||
github_manifest = self._fetch_manifest_from_github(repo_url, branch)
|
||||
plugin_subpath = plugin.get('plugin_path', '')
|
||||
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
|
||||
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel)
|
||||
if github_manifest:
|
||||
if 'last_updated' in github_manifest and not enhanced_plugin.get('last_updated'):
|
||||
enhanced_plugin['last_updated'] = github_manifest['last_updated']
|
||||
@@ -571,14 +580,17 @@ class PluginStoreManager:
|
||||
|
||||
return results
|
||||
|
||||
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master") -> Optional[Dict]:
|
||||
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch manifest.json directly from a GitHub repository.
|
||||
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL
|
||||
branch: Branch name (default: master)
|
||||
|
||||
manifest_path: Path to manifest within the repo (default: manifest.json).
|
||||
For monorepo plugins this will be e.g. "plugins/football-scoreboard/manifest.json".
|
||||
force_refresh: If True, bypass the cache.
|
||||
|
||||
Returns:
|
||||
Manifest data or None if not found
|
||||
"""
|
||||
@@ -590,30 +602,44 @@ class PluginStoreManager:
|
||||
repo_url = repo_url.rstrip('/')
|
||||
if repo_url.endswith('.git'):
|
||||
repo_url = repo_url[:-4]
|
||||
|
||||
|
||||
parts = repo_url.split('/')
|
||||
if len(parts) >= 2:
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/manifest.json"
|
||||
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
|
||||
if not force_refresh and cache_key in self.manifest_cache:
|
||||
cached_time, cached_data = self.manifest_cache[cache_key]
|
||||
if time.time() - cached_time < self.manifest_cache_timeout:
|
||||
return cached_data
|
||||
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
|
||||
|
||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
result = response.json()
|
||||
self.manifest_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
elif response.status_code == 404:
|
||||
# Try main branch instead
|
||||
if branch != "main":
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/manifest.json"
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
|
||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
result = response.json()
|
||||
self.manifest_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
|
||||
# Cache negative result
|
||||
self.manifest_cache[cache_key] = (time.time(), None)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def _get_latest_commit_info(self, repo_url: str, branch: str = "main") -> Optional[Dict[str, Any]]:
|
||||
def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Return metadata about the latest commit on the given branch."""
|
||||
try:
|
||||
if 'github.com' not in repo_url:
|
||||
@@ -630,6 +656,13 @@ class PluginStoreManager:
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"{owner}/{repo}:{branch}"
|
||||
if not force_refresh and cache_key in self.commit_info_cache:
|
||||
cached_time, cached_data = self.commit_info_cache[cache_key]
|
||||
if time.time() - cached_time < self.commit_cache_timeout:
|
||||
return cached_data
|
||||
|
||||
branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
|
||||
|
||||
headers = {
|
||||
@@ -652,7 +685,7 @@ class PluginStoreManager:
|
||||
commit_author = commit_meta.get('author', {})
|
||||
commit_date_iso = commit_author.get('date', '')
|
||||
|
||||
return {
|
||||
result = {
|
||||
'branch': branch_name,
|
||||
'sha': commit_sha_full,
|
||||
'short_sha': commit_sha_short,
|
||||
@@ -661,6 +694,8 @@ class PluginStoreManager:
|
||||
'author': commit_author.get('name', ''),
|
||||
'message': commit_meta.get('message', ''),
|
||||
}
|
||||
self.commit_info_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
|
||||
if response.status_code == 403 and not self.github_token:
|
||||
self.logger.debug("GitHub commit API rate limited (403). Consider adding a token.")
|
||||
@@ -671,33 +706,37 @@ class PluginStoreManager:
|
||||
if last_error:
|
||||
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
|
||||
|
||||
# Cache negative result to avoid repeated failing calls
|
||||
self.commit_info_cache[cache_key] = (time.time(), None)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error fetching latest commit metadata for {repo_url}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True) -> Optional[Dict]:
|
||||
def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:
|
||||
"""
|
||||
Get detailed information about a plugin from the registry.
|
||||
|
||||
GitHub provides authoritative metadata such as stars and the latest
|
||||
commit. The registry supplies descriptive information (name, id, repo URL).
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
fetch_latest_from_github: If True (default), augment with GitHub commit metadata.
|
||||
|
||||
force_refresh: If True, bypass caches for commit/manifest data.
|
||||
|
||||
Returns:
|
||||
Plugin metadata or None if not found
|
||||
"""
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
plugin_info = next((p for p in plugins if p['id'] == plugin_id), None)
|
||||
|
||||
|
||||
if not plugin_info:
|
||||
return None
|
||||
|
||||
|
||||
if fetch_latest_from_github:
|
||||
repo_url = plugin_info.get('repo')
|
||||
if repo_url:
|
||||
@@ -711,7 +750,7 @@ class PluginStoreManager:
|
||||
plugin_info['last_updated'] = github_info.get('last_commit_date', plugin_info.get('last_updated'))
|
||||
plugin_info['last_updated_iso'] = github_info.get('last_commit_iso', plugin_info.get('last_updated_iso'))
|
||||
|
||||
commit_info = self._get_latest_commit_info(repo_url, branch)
|
||||
commit_info = self._get_latest_commit_info(repo_url, branch, force_refresh=force_refresh)
|
||||
if commit_info:
|
||||
plugin_info['last_commit'] = commit_info.get('short_sha')
|
||||
plugin_info['last_commit_sha'] = commit_info.get('sha')
|
||||
@@ -722,14 +761,33 @@ class PluginStoreManager:
|
||||
plugin_info['branch'] = commit_info.get('branch', branch)
|
||||
plugin_info['last_commit_branch'] = commit_info.get('branch')
|
||||
|
||||
github_manifest = self._fetch_manifest_from_github(repo_url, branch)
|
||||
plugin_subpath = plugin_info.get('plugin_path', '')
|
||||
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
|
||||
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel, force_refresh=force_refresh)
|
||||
if github_manifest:
|
||||
if 'last_updated' in github_manifest and not plugin_info.get('last_updated'):
|
||||
plugin_info['last_updated'] = github_manifest['last_updated']
|
||||
if 'description' in github_manifest:
|
||||
plugin_info['description'] = github_manifest['description']
|
||||
|
||||
|
||||
return plugin_info
|
||||
|
||||
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get plugin information from the registry cache only (no GitHub API calls).
|
||||
|
||||
Use this for lightweight lookups where only registry fields are needed
|
||||
(e.g., verified status, latest_version).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Plugin metadata from registry or None if not found
|
||||
"""
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
return next((p for p in plugins if p.get('id') == plugin_id), None)
|
||||
|
||||
def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool:
|
||||
"""
|
||||
@@ -745,7 +803,7 @@ class PluginStoreManager:
|
||||
branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
|
||||
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
|
||||
|
||||
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True)
|
||||
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
if not plugin_info:
|
||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||
return False
|
||||
@@ -808,7 +866,7 @@ class PluginStoreManager:
|
||||
manifest_path = plugin_path / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
self.logger.error(f"No manifest.json found in plugin: {plugin_id}")
|
||||
shutil.rmtree(plugin_path, ignore_errors=True)
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -819,7 +877,7 @@ class PluginStoreManager:
|
||||
manifest_plugin_id = manifest.get('id')
|
||||
if not manifest_plugin_id:
|
||||
self.logger.error(f"Plugin manifest missing 'id' field")
|
||||
shutil.rmtree(plugin_path, ignore_errors=True)
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
# If manifest ID doesn't match directory name, rename directory to match manifest
|
||||
@@ -831,7 +889,9 @@ class PluginStoreManager:
|
||||
correct_path = self.plugins_dir / manifest_plugin_id
|
||||
if correct_path.exists():
|
||||
self.logger.warning(f"Target directory {manifest_plugin_id} already exists, removing it")
|
||||
shutil.rmtree(correct_path)
|
||||
if not self._safe_remove_directory(correct_path):
|
||||
self.logger.error(f"Failed to remove existing directory {correct_path}, cannot rename plugin")
|
||||
return False
|
||||
shutil.move(str(plugin_path), str(correct_path))
|
||||
plugin_path = correct_path
|
||||
manifest_path = plugin_path / "manifest.json"
|
||||
@@ -859,7 +919,7 @@ class PluginStoreManager:
|
||||
|
||||
if missing:
|
||||
self.logger.error(f"Plugin manifest missing required fields for {plugin_id}: {', '.join(missing)}")
|
||||
shutil.rmtree(plugin_path, ignore_errors=True)
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
if 'entry_point' not in manifest:
|
||||
@@ -873,7 +933,7 @@ class PluginStoreManager:
|
||||
|
||||
except Exception as manifest_error:
|
||||
self.logger.error(f"Failed to read/validate manifest for {plugin_id}: {manifest_error}")
|
||||
shutil.rmtree(plugin_path, ignore_errors=True)
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
if not self._install_dependencies(plugin_path):
|
||||
@@ -886,7 +946,7 @@ class PluginStoreManager:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error installing plugin {plugin_id}: {e}", exc_info=True)
|
||||
if plugin_path.exists():
|
||||
shutil.rmtree(plugin_path, ignore_errors=True)
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
def install_from_url(self, repo_url: str, plugin_id: str = None, plugin_path: str = None, branch: Optional[str] = None) -> Dict[str, Any]:
|
||||
@@ -1047,8 +1107,8 @@ class PluginStoreManager:
|
||||
finally:
|
||||
# Cleanup temp directory if it still exists
|
||||
if temp_dir and temp_dir.exists():
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
def _detect_class_name(self, manager_file: Path) -> Optional[str]:
|
||||
"""
|
||||
Attempt to auto-detect the plugin class name from the manager file.
|
||||
@@ -1104,7 +1164,7 @@ class PluginStoreManager:
|
||||
last_error = e
|
||||
self.logger.debug(f"Git clone failed for branch {try_branch}: {e}")
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path)
|
||||
self._safe_remove_directory(target_path)
|
||||
|
||||
# Try default branch (Git's configured default) as last resort
|
||||
try:
|
||||
@@ -1121,83 +1181,257 @@ class PluginStoreManager:
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
last_error = e
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path)
|
||||
self._safe_remove_directory(target_path)
|
||||
|
||||
self.logger.error(f"Git clone failed for all attempted branches: {last_error}")
|
||||
return None
|
||||
|
||||
def _install_from_monorepo(self, download_url: str, plugin_subpath: str, target_path: Path) -> bool:
|
||||
"""
|
||||
Install a plugin from a monorepo by downloading and extracting a subdirectory.
|
||||
|
||||
Install a plugin from a monorepo by downloading only the target subdirectory.
|
||||
|
||||
Uses the GitHub Git Trees API to list files, then downloads each file
|
||||
individually from raw.githubusercontent.com. Falls back to downloading
|
||||
the full ZIP archive if the API approach fails.
|
||||
|
||||
Args:
|
||||
download_url: URL to download zip from
|
||||
download_url: URL to download zip from (used as fallback and to extract repo info)
|
||||
plugin_subpath: Path within repo (e.g., "plugins/hello-world")
|
||||
target_path: Target directory for plugin
|
||||
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
# Try the API-based approach first (downloads only the target directory)
|
||||
repo_url, branch = self._parse_monorepo_download_url(download_url)
|
||||
if repo_url and branch:
|
||||
result = self._install_from_monorepo_api(repo_url, branch, plugin_subpath, target_path)
|
||||
if result:
|
||||
return True
|
||||
self.logger.info(f"API-based install failed for {plugin_subpath}, falling back to ZIP download")
|
||||
# Ensure no partial files remain before ZIP fallback
|
||||
if target_path.exists():
|
||||
self._safe_remove_directory(target_path)
|
||||
|
||||
# Fallback: download full ZIP and extract subdirectory
|
||||
return self._install_from_monorepo_zip(download_url, plugin_subpath, target_path)
|
||||
|
||||
@staticmethod
|
||||
def _parse_monorepo_download_url(download_url: str):
|
||||
"""Extract repo URL and branch from a GitHub archive download URL.
|
||||
|
||||
Example: "https://github.com/ChuckBuilds/ledmatrix-plugins/archive/refs/heads/main.zip"
|
||||
Returns: ("https://github.com/ChuckBuilds/ledmatrix-plugins", "main")
|
||||
"""
|
||||
try:
|
||||
self.logger.info(f"Downloading monorepo from: {download_url}")
|
||||
# Pattern: {repo_url}/archive/refs/heads/{branch}.zip
|
||||
if '/archive/refs/heads/' in download_url:
|
||||
parts = download_url.split('/archive/refs/heads/')
|
||||
repo_url = parts[0]
|
||||
branch = parts[1].removesuffix('.zip')
|
||||
return repo_url, branch
|
||||
except (IndexError, AttributeError):
|
||||
pass
|
||||
return None, None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_repo_url(url: str) -> str:
|
||||
"""Normalize a GitHub repo URL for comparison (strip trailing / and .git)."""
|
||||
url = url.rstrip('/')
|
||||
if url.endswith('.git'):
|
||||
url = url[:-4]
|
||||
return url.lower()
|
||||
|
||||
def _install_from_monorepo_api(self, repo_url: str, branch: str, plugin_subpath: str, target_path: Path) -> bool:
|
||||
"""
|
||||
Install a plugin subdirectory using the GitHub Git Trees API.
|
||||
|
||||
Downloads only the files in the target subdirectory (~200KB) instead
|
||||
of the entire repository ZIP (~5MB+). Uses one API call for the tree
|
||||
listing, then downloads individual files from raw.githubusercontent.com.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL (e.g., "https://github.com/owner/repo")
|
||||
branch: Branch name (e.g., "main")
|
||||
plugin_subpath: Path within repo (e.g., "plugins/hello-world")
|
||||
target_path: Target directory for plugin
|
||||
|
||||
Returns:
|
||||
True if successful, False to trigger ZIP fallback
|
||||
"""
|
||||
try:
|
||||
# Parse owner/repo from URL
|
||||
clean_url = repo_url.rstrip('/')
|
||||
if clean_url.endswith('.git'):
|
||||
clean_url = clean_url[:-4]
|
||||
parts = clean_url.split('/')
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
owner, repo = parts[-2], parts[-1]
|
||||
|
||||
# Step 1: Get the recursive tree listing (1 API call)
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"
|
||||
headers = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'
|
||||
}
|
||||
if self.github_token:
|
||||
headers['Authorization'] = f'token {self.github_token}'
|
||||
|
||||
tree_response = self._http_get_with_retries(api_url, timeout=15, headers=headers)
|
||||
if tree_response.status_code != 200:
|
||||
self.logger.debug(f"Trees API returned {tree_response.status_code} for {owner}/{repo}")
|
||||
return False
|
||||
|
||||
tree_data = tree_response.json()
|
||||
if tree_data.get('truncated'):
|
||||
self.logger.debug(f"Tree response truncated for {owner}/{repo}, falling back to ZIP")
|
||||
return False
|
||||
|
||||
# Step 2: Filter for files in the target subdirectory
|
||||
prefix = f"{plugin_subpath.strip('/')}/"
|
||||
file_entries = [
|
||||
entry for entry in tree_data.get('tree', [])
|
||||
if entry['path'].startswith(prefix) and entry['type'] == 'blob'
|
||||
]
|
||||
|
||||
if not file_entries:
|
||||
self.logger.error(f"No files found under '{plugin_subpath}' in tree for {owner}/{repo}")
|
||||
return False
|
||||
|
||||
# Sanity check: refuse unreasonably large plugin directories
|
||||
max_files = 500
|
||||
if len(file_entries) > max_files:
|
||||
self.logger.error(
|
||||
f"Plugin {plugin_subpath} has {len(file_entries)} files (limit {max_files}), "
|
||||
f"falling back to ZIP"
|
||||
)
|
||||
return False
|
||||
|
||||
self.logger.info(f"Downloading {len(file_entries)} files for {plugin_subpath} via API")
|
||||
|
||||
# Step 3: Create target directory and download each file
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prefix_len = len(prefix)
|
||||
target_root = target_path.resolve()
|
||||
for entry in file_entries:
|
||||
# Relative path within the plugin directory
|
||||
rel_path = entry['path'][prefix_len:]
|
||||
dest_file = target_path / rel_path
|
||||
|
||||
# Guard against path traversal
|
||||
if not dest_file.resolve().is_relative_to(target_root):
|
||||
self.logger.error(
|
||||
f"Path traversal detected: {entry['path']!r} resolves outside target directory"
|
||||
)
|
||||
if target_path.exists():
|
||||
self._safe_remove_directory(target_path)
|
||||
return False
|
||||
|
||||
# Create parent directories
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download from raw.githubusercontent.com (no API rate limit cost)
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{entry['path']}"
|
||||
file_response = self._http_get_with_retries(raw_url, timeout=30)
|
||||
if file_response.status_code != 200:
|
||||
self.logger.error(f"Failed to download {entry['path']}: HTTP {file_response.status_code}")
|
||||
# Clean up partial download
|
||||
if target_path.exists():
|
||||
self._safe_remove_directory(target_path)
|
||||
return False
|
||||
|
||||
dest_file.write_bytes(file_response.content)
|
||||
|
||||
self.logger.info(f"Successfully installed {plugin_subpath} via API ({len(file_entries)} files)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"API-based monorepo install failed: {e}")
|
||||
# Clean up partial download
|
||||
if target_path.exists():
|
||||
self._safe_remove_directory(target_path)
|
||||
return False
|
||||
|
||||
def _install_from_monorepo_zip(self, download_url: str, plugin_subpath: str, target_path: Path) -> bool:
|
||||
"""
|
||||
Fallback: install a plugin from a monorepo by downloading the full ZIP.
|
||||
|
||||
Used when the API-based approach fails (rate limited, auth issues, etc.).
|
||||
"""
|
||||
tmp_zip_path = None
|
||||
temp_extract = None
|
||||
try:
|
||||
self.logger.info(f"Downloading monorepo ZIP from: {download_url}")
|
||||
response = self._http_get_with_retries(download_url, timeout=60, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
# Download to temporary file
|
||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp_file:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
tmp_file.write(chunk)
|
||||
tmp_zip_path = tmp_file.name
|
||||
|
||||
try:
|
||||
# Extract zip
|
||||
with zipfile.ZipFile(tmp_zip_path, 'r') as zip_ref:
|
||||
zip_contents = zip_ref.namelist()
|
||||
if not zip_contents:
|
||||
return False
|
||||
|
||||
# GitHub zips have a root directory like "repo-main/"
|
||||
root_dir = zip_contents[0].split('/')[0]
|
||||
|
||||
# Build path to plugin within extracted archive
|
||||
# e.g., "ledmatrix-plugins-main/plugins/hello-world/"
|
||||
plugin_path_in_zip = f"{root_dir}/{plugin_subpath}/"
|
||||
|
||||
# Extract to temp location
|
||||
temp_extract = Path(tempfile.mkdtemp())
|
||||
zip_ref.extractall(temp_extract)
|
||||
|
||||
# Find the plugin directory
|
||||
source_plugin_dir = temp_extract / root_dir / plugin_subpath
|
||||
|
||||
if not source_plugin_dir.exists():
|
||||
self.logger.error(f"Plugin path not found in archive: {plugin_subpath}")
|
||||
self.logger.error(f"Expected at: {source_plugin_dir}")
|
||||
|
||||
with zipfile.ZipFile(tmp_zip_path, 'r') as zip_ref:
|
||||
zip_contents = zip_ref.namelist()
|
||||
if not zip_contents:
|
||||
return False
|
||||
|
||||
root_dir = zip_contents[0].split('/')[0]
|
||||
plugin_prefix = f"{root_dir}/{plugin_subpath}/"
|
||||
|
||||
# Extract ONLY files under the plugin subdirectory
|
||||
plugin_members = [m for m in zip_contents if m.startswith(plugin_prefix)]
|
||||
|
||||
if not plugin_members:
|
||||
self.logger.error(f"Plugin path not found in archive: {plugin_subpath}")
|
||||
return False
|
||||
|
||||
temp_extract = Path(tempfile.mkdtemp())
|
||||
temp_extract_resolved = temp_extract.resolve()
|
||||
|
||||
for member in plugin_members:
|
||||
# Guard against zip-slip (directory traversal)
|
||||
member_dest = (temp_extract / member).resolve()
|
||||
if not member_dest.is_relative_to(temp_extract_resolved):
|
||||
self.logger.error(
|
||||
f"Zip-slip detected: member {member!r} resolves outside "
|
||||
f"temp directory, aborting"
|
||||
)
|
||||
shutil.rmtree(temp_extract, ignore_errors=True)
|
||||
return False
|
||||
|
||||
# Move plugin contents to target
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
||||
shutil.move(str(source_plugin_dir), str(target_path))
|
||||
|
||||
# Cleanup temp extract dir
|
||||
if temp_extract.exists():
|
||||
shutil.rmtree(temp_extract, ignore_errors=True)
|
||||
|
||||
return True
|
||||
|
||||
finally:
|
||||
# Remove temporary zip file
|
||||
if os.path.exists(tmp_zip_path):
|
||||
os.remove(tmp_zip_path)
|
||||
|
||||
zip_ref.extract(member, temp_extract)
|
||||
|
||||
source_plugin_dir = temp_extract / root_dir / plugin_subpath
|
||||
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
||||
# Ensure target doesn't exist to prevent shutil.move nesting
|
||||
if target_path.exists():
|
||||
if not self._safe_remove_directory(target_path):
|
||||
self.logger.error(f"Cannot remove existing target {target_path} for monorepo install")
|
||||
return False
|
||||
shutil.move(str(source_plugin_dir), str(target_path))
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Monorepo download failed: {e}", exc_info=True)
|
||||
self.logger.error(f"Monorepo ZIP download failed: {e}", exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
if tmp_zip_path and os.path.exists(tmp_zip_path):
|
||||
os.remove(tmp_zip_path)
|
||||
if temp_extract and temp_extract.exists():
|
||||
shutil.rmtree(temp_extract, ignore_errors=True)
|
||||
|
||||
def _install_via_download(self, download_url: str, target_path: Path) -> bool:
|
||||
"""
|
||||
@@ -1233,8 +1467,18 @@ class PluginStoreManager:
|
||||
# Find the root directory in the zip
|
||||
root_dir = zip_contents[0].split('/')[0]
|
||||
|
||||
# Extract to temp location
|
||||
# Extract to temp location with zip-slip protection
|
||||
temp_extract = Path(tempfile.mkdtemp())
|
||||
temp_extract_resolved = temp_extract.resolve()
|
||||
for member in zip_ref.namelist():
|
||||
member_dest = (temp_extract / member).resolve()
|
||||
if not member_dest.is_relative_to(temp_extract_resolved):
|
||||
self.logger.error(
|
||||
f"Zip-slip detected: member {member!r} resolves outside "
|
||||
f"temp directory, aborting"
|
||||
)
|
||||
shutil.rmtree(temp_extract, ignore_errors=True)
|
||||
return False
|
||||
zip_ref.extractall(temp_extract)
|
||||
|
||||
# Move contents from root_dir to target
|
||||
@@ -1389,70 +1633,58 @@ class PluginStoreManager:
|
||||
|
||||
def _safe_remove_directory(self, path: Path) -> bool:
|
||||
"""
|
||||
Safely remove a directory, handling permission errors for __pycache__ directories.
|
||||
|
||||
This function attempts to remove a directory and handles permission errors
|
||||
gracefully, especially for __pycache__ directories that may have been created
|
||||
by Python with different permissions.
|
||||
|
||||
Safely remove a directory, handling permission errors for root-owned files.
|
||||
|
||||
Attempts removal in three stages:
|
||||
1. Normal shutil.rmtree()
|
||||
2. Fix permissions via os.chmod() then retry (works for same-owner files)
|
||||
3. Use sudo rm -rf as last resort (works for root-owned __pycache__, etc.)
|
||||
|
||||
Args:
|
||||
path: Path to directory to remove
|
||||
|
||||
|
||||
Returns:
|
||||
True if directory was removed successfully, False otherwise
|
||||
"""
|
||||
if not path.exists():
|
||||
return True # Already removed
|
||||
|
||||
|
||||
# Stage 1: Try normal removal
|
||||
try:
|
||||
# First, try normal removal
|
||||
shutil.rmtree(path)
|
||||
return True
|
||||
except PermissionError as e:
|
||||
# Handle permission errors, especially for __pycache__ directories
|
||||
self.logger.warning(f"Permission error removing {path}: {e}. Attempting to fix permissions...")
|
||||
|
||||
try:
|
||||
# Try to fix permissions on __pycache__ directories recursively
|
||||
import stat
|
||||
for root, dirs, files in os.walk(path):
|
||||
root_path = Path(root)
|
||||
except OSError:
|
||||
self.logger.warning(f"Permission error removing {path}, attempting chmod fix...")
|
||||
|
||||
# Stage 2: Try chmod + retry (works when we own the files)
|
||||
try:
|
||||
for root, _dirs, files in os.walk(path):
|
||||
root_path = Path(root)
|
||||
try:
|
||||
os.chmod(root_path, stat.S_IRWXU)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
for file in files:
|
||||
try:
|
||||
# Make directory writable
|
||||
os.chmod(root_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
|
||||
os.chmod(root_path / file, stat.S_IRWXU)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
# Fix file permissions
|
||||
for file in files:
|
||||
file_path = root_path / file
|
||||
try:
|
||||
os.chmod(file_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
# Try removal again after fixing permissions
|
||||
shutil.rmtree(path)
|
||||
self.logger.info(f"Successfully removed {path} after fixing permissions")
|
||||
return True
|
||||
except Exception as e2:
|
||||
self.logger.error(f"Failed to remove {path} even after fixing permissions: {e2}")
|
||||
# Last resort: try with ignore_errors
|
||||
try:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
# Check if it actually got removed
|
||||
if not path.exists():
|
||||
self.logger.warning(f"Removed {path} with ignore_errors=True (some files may remain)")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Could not remove {path} even with ignore_errors")
|
||||
return False
|
||||
except Exception as e3:
|
||||
self.logger.error(f"Final removal attempt failed for {path}: {e3}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error removing {path}: {e}")
|
||||
return False
|
||||
shutil.rmtree(path)
|
||||
self.logger.info(f"Removed {path} after fixing permissions")
|
||||
return True
|
||||
except (PermissionError, OSError):
|
||||
self.logger.warning(f"chmod fix failed for {path}, attempting sudo removal...")
|
||||
|
||||
# Stage 3: Use sudo rm -rf (for root-owned __pycache__, data/.cache, etc.)
|
||||
if sudo_remove_directory(path):
|
||||
return True
|
||||
|
||||
# Final check — maybe partial removal got everything
|
||||
if not path.exists():
|
||||
return True
|
||||
|
||||
self.logger.error(f"All removal strategies failed for {path}")
|
||||
return False
|
||||
|
||||
def _find_plugin_path(self, plugin_id: str) -> Optional[Path]:
|
||||
"""
|
||||
@@ -1535,22 +1767,37 @@ class PluginStoreManager:
|
||||
# Plugin is a git repository - try to update via git
|
||||
local_branch = git_info.get('branch') or 'main'
|
||||
local_sha = git_info.get('sha')
|
||||
|
||||
|
||||
# Try to get remote info from registry (optional)
|
||||
self.fetch_registry(force_refresh=True)
|
||||
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True)
|
||||
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
remote_branch = None
|
||||
remote_sha = None
|
||||
|
||||
|
||||
if plugin_info_remote:
|
||||
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
|
||||
remote_sha = plugin_info_remote.get('last_commit_sha')
|
||||
|
||||
|
||||
# Check if the local git remote still matches the registry repo URL.
|
||||
# After monorepo migration, old clones point to archived individual repos
|
||||
# while the registry now points to the monorepo. Detect this and reinstall.
|
||||
registry_repo = plugin_info_remote.get('repo', '')
|
||||
local_remote = git_info.get('remote_url', '')
|
||||
if local_remote and registry_repo and self._normalize_repo_url(local_remote) != self._normalize_repo_url(registry_repo):
|
||||
self.logger.info(
|
||||
f"Plugin {plugin_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||
f"Reinstalling from registry to migrate to new source."
|
||||
)
|
||||
if not self._safe_remove_directory(plugin_path):
|
||||
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
|
||||
return False
|
||||
return self.install_plugin(plugin_id)
|
||||
|
||||
# Check if already up to date
|
||||
if remote_sha and local_sha and remote_sha.startswith(local_sha):
|
||||
self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}")
|
||||
return True
|
||||
|
||||
|
||||
# Update via git pull
|
||||
self.logger.info(f"Updating {plugin_id} via git pull (local branch: {local_branch})...")
|
||||
try:
|
||||
@@ -1795,7 +2042,7 @@ class PluginStoreManager:
|
||||
# Try registry-based update
|
||||
self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...")
|
||||
self.fetch_registry(force_refresh=True)
|
||||
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True)
|
||||
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
|
||||
# If not in registry but we have a repo URL, try reinstalling from that URL
|
||||
if not plugin_info_remote and repo_url:
|
||||
@@ -1833,11 +2080,28 @@ class PluginStoreManager:
|
||||
remote_sha = plugin_info_remote.get('last_commit_sha')
|
||||
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
|
||||
|
||||
# If we get here, plugin is not a git repo but is in registry - reinstall
|
||||
# Compare local manifest version against registry latest_version
|
||||
# to avoid unnecessary reinstalls for monorepo plugins
|
||||
try:
|
||||
local_manifest_path = plugin_path / "manifest.json"
|
||||
if local_manifest_path.exists():
|
||||
with open(local_manifest_path, 'r', encoding='utf-8') as f:
|
||||
local_manifest = json.load(f)
|
||||
local_version = local_manifest.get('version', '')
|
||||
remote_version = plugin_info_remote.get('latest_version', '')
|
||||
if local_version and remote_version and local_version == remote_version:
|
||||
self.logger.info(f"Plugin {plugin_id} already at latest version {local_version}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
|
||||
|
||||
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive")
|
||||
|
||||
# Remove directory and reinstall fresh
|
||||
shutil.rmtree(plugin_path, ignore_errors=True)
|
||||
if not self._safe_remove_directory(plugin_path):
|
||||
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
|
||||
return False
|
||||
return self.install_plugin(plugin_id)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
21
src/vegas_mode/__init__.py
Normal file
21
src/vegas_mode/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Vegas Mode - Continuous Scrolling Ticker
|
||||
|
||||
This package implements a Vegas-style continuous scroll mode where all enabled
|
||||
plugins' content is composed into a single horizontally scrolling display.
|
||||
|
||||
Components:
|
||||
- VegasModeCoordinator: Main orchestrator for Vegas mode
|
||||
- StreamManager: Manages plugin content streaming with 1-2 ahead buffering
|
||||
- RenderPipeline: Handles 125 FPS rendering with double-buffering
|
||||
- PluginAdapter: Converts plugin content to scrollable images
|
||||
- VegasModeConfig: Configuration management
|
||||
"""
|
||||
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
__all__ = [
|
||||
'VegasModeConfig',
|
||||
'VegasModeCoordinator',
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user