mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
Compare commits
6
Commits
768517a658
...
203d7b0940
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203d7b0940 | ||
|
|
9616a5a054 | ||
|
|
5a3bcb34a1 | ||
|
|
2d7a687a02 | ||
|
|
c200b5837d | ||
|
|
9f2743471c |
+35
-2
@@ -67,8 +67,41 @@ Web interface:
|
|||||||
- Row Address Type offers 5, the SM5368 / B707 row shift register. The
|
- Row Address Type offers 5, the SM5368 / B707 row shift register. The
|
||||||
Waveshare 96x48 V2 panel (back silkscreen `24S-A1`) needs it with RGB
|
Waveshare 96x48 V2 panel (back silkscreen `24S-A1`) needs it with RGB
|
||||||
sequence BGR and, on a Pi 4, a GPIO slowdown of 6–8. Panels with FM6124
|
sequence BGR and, on a Pi 4, a GPIO slowdown of 6–8. Panels with FM6124
|
||||||
column drivers need no Panel Type. On a Pi 5 the library supports only row
|
column drivers need no Panel Type.
|
||||||
address types 0 and 2.
|
- On a Raspberry Pi 5 the pinned rgbmatrix library can drive only row address
|
||||||
|
types 0 and 2, parallel 1–3 and the standard mappings. For anything else it
|
||||||
|
returns no matrix, which the Python binding doesn't catch, so the display
|
||||||
|
service crashed and restarted every 10 seconds. `DisplayManager` now refuses
|
||||||
|
those settings before creating the matrix (logged, reported by
|
||||||
|
`/api/v3/hardware/status`, fallback mode), the config API rejects them, and
|
||||||
|
the Display form offers only row address types 0 and 2 on a Pi 5. The rule
|
||||||
|
lives in `src/pi5_matrix_support.py` and must be re-checked when the
|
||||||
|
submodule is bumped.
|
||||||
|
- The Plugin Config Warning no longer lists core settings as plugins that are
|
||||||
|
"in config but not installed" (seen as `auto_update` on 3.4.0, where the
|
||||||
|
advice would have deleted the weekly-update setting). Core top-level config
|
||||||
|
keys now live in one list, `src/core_config_keys.py`, which reconciliation
|
||||||
|
uses and tests pin to `config.template.json` and the settings save endpoint.
|
||||||
|
A stored warning is also dropped once its entry is no longer a plugin in
|
||||||
|
config, so an old verdict clears without a restart.
|
||||||
|
- **Check & Update All** no longer sends installed Starlark apps
|
||||||
|
(`starlark:<app_id>` entries in `/plugins/installed`) to the plugin updater,
|
||||||
|
which answered each with a 500 "plugin not found". `POST /plugins/update`
|
||||||
|
now answers a `starlark:` id with a 400 saying it is a Starlark app. A
|
||||||
|
request that gets no HTTP answer (e.g. the web service restarting mid-run) is
|
||||||
|
re-sent with backoff instead of being counted as failed and skipped — that is
|
||||||
|
how a disabled plugin with an update waiting was silently left out.
|
||||||
|
|
||||||
|
Plugin system:
|
||||||
|
|
||||||
|
- A plugin no longer starts with a schema warning and a degraded flag because
|
||||||
|
config.json still holds a boolean where its schema now has an object with an
|
||||||
|
`enabled` property (news' `global.dynamic_duration: true`). The loader reads
|
||||||
|
the boolean as `{"enabled": <bool>}` before merging schema defaults and
|
||||||
|
validating, the same rule the settings form already applies
|
||||||
|
(`legacy_bool_as_object` in `src/plugin_system/schema_manager.py`). Nothing
|
||||||
|
is written at load; the next save of that plugin's settings stores the object.
|
||||||
|
Other type mismatches still warn.
|
||||||
|
|
||||||
## 3.4.0
|
## 3.4.0
|
||||||
|
|
||||||
|
|||||||
@@ -63,3 +63,4 @@
|
|||||||
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
||||||
(use a mask for transparency: `image.paste(rgba, (x, y), rgba)`)
|
(use a mask for transparency: `image.paste(rgba, (x, y), rgba)`)
|
||||||
- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
|
- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
|
||||||
|
- `src/pi5_matrix_support.py` hardcodes what the pinned `rpi-rgb-led-matrix-master` can drive on a Raspberry Pi 5 (`Rp1PioConfigSupported()` in `lib/rp1/rp1_pio_backend.cc`). Re-check it whenever the submodule is bumped: a stale rule blocks Pi 5 settings the new library supports, and a missing one lets the display service crash-loop
|
||||||
|
|||||||
@@ -631,6 +631,10 @@ These settings are typically only needed for non-standard panels or custom confi
|
|||||||
with an Adafruit Triple LED Matrix Bonnet, 4 left rows jumping; 6–8 gave a
|
with an Adafruit Triple LED Matrix Bonnet, 4 left rows jumping; 6–8 gave a
|
||||||
stable image.
|
stable image.
|
||||||
- On a Raspberry Pi 5 the rgbmatrix library currently supports only 0 and 2
|
- On a Raspberry Pi 5 the rgbmatrix library currently supports only 0 and 2
|
||||||
|
(and `parallel` 1-3). Anything else would crash the display service, so on
|
||||||
|
a Pi 5 the web UI offers only 0 and 2, the config API refuses the others,
|
||||||
|
and if one is set in `config.json` anyway the display logs why and runs in
|
||||||
|
fallback mode
|
||||||
- Check your panel datasheet if display appears corrupted
|
- Check your panel datasheet if display appears corrupted
|
||||||
|
|
||||||
- **`multiplexing`** (integer, 0-22, default: 0)
|
- **`multiplexing`** (integer, 0-22, default: 0)
|
||||||
@@ -748,7 +752,7 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
|||||||
- Verify `hardware_mapping` matches your HAT/connection type
|
- Verify `hardware_mapping` matches your HAT/connection type
|
||||||
- Try adjusting `gpio_slowdown`
|
- Try adjusting `gpio_slowdown`
|
||||||
- Ensure your display doesn't need the E-Addressable line
|
- Ensure your display doesn't need the E-Addressable line
|
||||||
- If it went blank right after a settings change, check `sudo journalctl -u ledmatrix` for `Failed to initialize RGB Matrix`: the rgbmatrix library refused a value (for example more than 64 `rows`, or `pwm_dither_bits` above 2) and the display fell back to no output. The library's own message nearby names the setting
|
- If it went blank right after a settings change, check `sudo journalctl -u ledmatrix` for `Failed to initialize RGB Matrix`: the rgbmatrix library refused a value (for example more than 64 `rows`, or `pwm_dither_bits` above 2) and the display fell back to no output. The library's own message nearby names the setting; on a Raspberry Pi 5, LEDMatrix's message names any unsupported `row_address_type`, `parallel` or `hardware_mapping`
|
||||||
- A repeating scramble points at `row_address_type` or `multiplexing`; a panel that stays dark, at `panel_type`
|
- A repeating scramble points at `row_address_type` or `multiplexing`; a panel that stays dark, at `panel_type`
|
||||||
|
|
||||||
**Rows jump up and down, or the bottom row repeats other rows:**
|
**Rows jump up and down, or the bottom row repeats other rows:**
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
|||||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) — `0` = no cap; scroll timing assumes 100 Hz when `0` |
|
| `limit_refresh_rate_hz` | int, `100` (code default 90) — `0` = no cap; scroll timing assumes 100 Hz when `0` |
|
||||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
|
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
|
||||||
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||||
| `row_address_type` | int, `0` — non-standard panel row addressing: `1` AB, `2` direct row select, `3` ABC, `4` ABC shift + DE direct, `5` SM5368 / B707 row shift register (e.g. Waveshare 96x48 V2, with `led_rgb_sequence` `"BGR"`). On a Pi 5 the library supports only `0` and `2` |
|
| `row_address_type` | int, `0` — non-standard panel row addressing: `1` AB, `2` direct row select, `3` ABC, `4` ABC shift + DE direct, `5` SM5368 / B707 row shift register (e.g. Waveshare 96x48 V2, with `led_rgb_sequence` `"BGR"`). On a Pi 5 the library supports only `0` and `2`, and LEDMatrix enforces that (`src/pi5_matrix_support.py`) |
|
||||||
| `multiplexing` | int, `0` — 0–22, pixel wiring scheme for outdoor/specialty panels (names listed in the README) |
|
| `multiplexing` | int, `0` — 0–22, pixel wiring scheme for outdoor/specialty panels (names listed in the README) |
|
||||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init; FM6124 / FM6124D / FM6124DJ panels need none, so leave it `""` |
|
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init; FM6124 / FM6124D / FM6124DJ panels need none, so leave it `""` |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Top-level config.json keys that belong to the LEDMatrix core, not to plugins.
|
||||||
|
|
||||||
|
config.json mixes two kinds of top-level section: core settings (``display``,
|
||||||
|
``schedule``, ``auto_update``, ...) and one section per plugin, keyed by plugin
|
||||||
|
id. Anything that needs to tell them apart -- plugin-state reconciliation above
|
||||||
|
all -- must use this list, not a private copy of it.
|
||||||
|
|
||||||
|
A private copy is what went wrong: #581 added a top-level ``auto_update``
|
||||||
|
section, reconciliation's own list did not know about it, and every device with
|
||||||
|
the new setting was told "In config but not installed: auto_update. Reinstall
|
||||||
|
via the Plugin Store, or remove these entries from config.json" -- advice that
|
||||||
|
deletes a real core setting.
|
||||||
|
|
||||||
|
When you add a top-level core setting, add its key here. The tests in
|
||||||
|
``test/test_core_config_keys.py`` fail if a top-level key in
|
||||||
|
``config/config.template.json``, or one written by the general-settings save
|
||||||
|
endpoint, is missing from this list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CORE_CONFIG_KEYS = frozenset({
|
||||||
|
# config/config.template.json
|
||||||
|
'web_display_autostart',
|
||||||
|
'auto_update',
|
||||||
|
'schedule',
|
||||||
|
'dim_schedule',
|
||||||
|
'timezone',
|
||||||
|
'target_fps',
|
||||||
|
'location',
|
||||||
|
'display',
|
||||||
|
'sync',
|
||||||
|
'plugin_system',
|
||||||
|
# Older or optional core sections still found in existing config files.
|
||||||
|
'logging',
|
||||||
|
'network',
|
||||||
|
'system',
|
||||||
|
'vegas_scroll_speed',
|
||||||
|
'vegas_separator_width',
|
||||||
|
'vegas_target_fps',
|
||||||
|
'vegas_buffer_ahead',
|
||||||
|
'vegas_plugin_order',
|
||||||
|
'vegas_excluded_plugins',
|
||||||
|
'vegas_scroll_enabled',
|
||||||
|
})
|
||||||
@@ -39,6 +39,7 @@ from src.display_geometry import (
|
|||||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||||
physical_size, resolve_double_sided,
|
physical_size, resolve_double_sided,
|
||||||
)
|
)
|
||||||
|
from src.pi5_matrix_support import is_raspberry_pi_5, pi5_unsupported_settings
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@@ -325,6 +326,15 @@ class DisplayManager:
|
|||||||
|
|
||||||
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
||||||
|
|
||||||
|
# On a Pi 5 the library hands back no matrix for settings its RP1
|
||||||
|
# path can't drive, and the binding doesn't check -- the process
|
||||||
|
# would crash on its next call instead of reaching the fallback
|
||||||
|
# below. Raise first so it is a logged, reported init failure.
|
||||||
|
if os.getenv("EMULATOR", "false") != "true" and is_raspberry_pi_5():
|
||||||
|
unsupported = pi5_unsupported_settings(hardware_config)
|
||||||
|
if unsupported:
|
||||||
|
raise RuntimeError(unsupported)
|
||||||
|
|
||||||
# Initialize the matrix
|
# Initialize the matrix
|
||||||
self.matrix = RGBMatrix(options=options)
|
self.matrix = RGBMatrix(options=options)
|
||||||
logger.info("RGB Matrix initialized successfully")
|
logger.info("RGB Matrix initialized successfully")
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Which matrix settings the pinned rgbmatrix library can drive on a Raspberry Pi 5.
|
||||||
|
|
||||||
|
On a Pi 5 the library drives the panel through the RP1 chip, and at the pinned
|
||||||
|
rpi-rgb-led-matrix-master commit (1ee4f76) that path supports only some
|
||||||
|
settings -- ``Rp1PioConfigSupported()`` in ``lib/rp1/rp1_pio_backend.cc``. For
|
||||||
|
anything else ``RGBMatrix::CreateFromOptions()`` returns NULL. The Python
|
||||||
|
binding does not check for that, so instead of raising, the display process
|
||||||
|
crashes on its first call into the matrix, and systemd restarts it into the
|
||||||
|
same crash every 10 seconds.
|
||||||
|
|
||||||
|
``DisplayManager`` checks this rule before creating the matrix, turning that
|
||||||
|
crash into a logged error and the usual fallback mode. The config API and the
|
||||||
|
Display form use the same rule to refuse the settings up front.
|
||||||
|
|
||||||
|
**Re-check this when the submodule is bumped.** Upstream is extending Pi 5
|
||||||
|
support (e.g. 4e326c1b, "Pi5 - Improvements, additional led-row-addr-type
|
||||||
|
support"); a stale rule here would block settings the new library drives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Mapping, Optional
|
||||||
|
|
||||||
|
#: Read the way the library's ``Rp1PioPlatformDetected()`` reads it.
|
||||||
|
MODEL_PATH = "/proc/device-tree/model"
|
||||||
|
PI5_MODEL_MARKERS = ("Raspberry Pi 5", "Compute Module 5")
|
||||||
|
|
||||||
|
PI5_ROW_ADDRESS_TYPES = (0, 2)
|
||||||
|
PI5_MAX_PARALLEL = 3
|
||||||
|
PI5_HARDWARE_MAPPINGS = ("regular", "regular-pi1", "classic", "adafruit-hat", "adafruit-hat-pwm")
|
||||||
|
|
||||||
|
|
||||||
|
def is_raspberry_pi_5() -> bool:
|
||||||
|
"""True on a Pi 5-family board: Pi 5, Pi 500 or Compute Module 5."""
|
||||||
|
try:
|
||||||
|
with open(MODEL_PATH, "rb") as f:
|
||||||
|
model = f.read(256).decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return any(marker in model for marker in PI5_MODEL_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: Any, default: int) -> Optional[int]:
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pi5_unsupported_settings(hardware: Mapping[str, Any]) -> Optional[str]:
|
||||||
|
"""Why a Pi 5 can't drive this ``display.hardware`` config, or None if it can.
|
||||||
|
|
||||||
|
Missing keys take DisplayManager's defaults. A value that isn't a number is
|
||||||
|
left to the caller's own validation rather than reported here.
|
||||||
|
"""
|
||||||
|
problems = []
|
||||||
|
row_address_type = _as_int(hardware.get("row_address_type"), 0)
|
||||||
|
if row_address_type is not None and row_address_type not in PI5_ROW_ADDRESS_TYPES:
|
||||||
|
problems.append(f"row address type {row_address_type} (only 0 and 2 are supported)")
|
||||||
|
parallel = _as_int(hardware.get("parallel"), 1)
|
||||||
|
if parallel is not None and not 1 <= parallel <= PI5_MAX_PARALLEL:
|
||||||
|
problems.append(f"parallel {parallel} (1 to {PI5_MAX_PARALLEL} are supported)")
|
||||||
|
mapping = hardware.get("hardware_mapping", "adafruit-hat-pwm")
|
||||||
|
# The library treats an empty mapping as "regular".
|
||||||
|
if isinstance(mapping, str) and (mapping or "regular") not in PI5_HARDWARE_MAPPINGS:
|
||||||
|
problems.append(f'hardware mapping "{mapping}"')
|
||||||
|
if not problems:
|
||||||
|
return None
|
||||||
|
return ("Not supported on a Raspberry Pi 5 by the installed rgbmatrix library: "
|
||||||
|
+ "; ".join(problems) + ".")
|
||||||
@@ -22,7 +22,7 @@ from src.logging_config import get_logger
|
|||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
from src.plugin_system.plugin_executor import PluginExecutor
|
from src.plugin_system.plugin_executor import PluginExecutor
|
||||||
from src.plugin_system.plugin_state import PluginStateManager, PluginState
|
from src.plugin_system.plugin_state import PluginStateManager, PluginState
|
||||||
from src.plugin_system.schema_manager import SchemaManager
|
from src.plugin_system.schema_manager import SchemaManager, normalize_legacy_booleans
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
get_plugin_dir_mode
|
get_plugin_dir_mode
|
||||||
@@ -351,6 +351,7 @@ class PluginManager:
|
|||||||
config = {}
|
config = {}
|
||||||
|
|
||||||
# Check if plugin has a config schema
|
# Check if plugin has a config schema
|
||||||
|
schema = None
|
||||||
schema_path = self.schema_manager.get_schema_path(plugin_id)
|
schema_path = self.schema_manager.get_schema_path(plugin_id)
|
||||||
if schema_path is None:
|
if schema_path is None:
|
||||||
# Schema file doesn't exist
|
# Schema file doesn't exist
|
||||||
@@ -368,6 +369,24 @@ class PluginManager:
|
|||||||
f"The schema may be invalid. Please verify the schema file at: {schema_path}"
|
f"The schema may be invalid. Please verify the schema file at: {schema_path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A plugin that turned an on/off boolean into an {enabled, ...}
|
||||||
|
# object still finds the boolean in config.json until the user saves
|
||||||
|
# its settings form, which carries it over (plugin_config.html).
|
||||||
|
# Read it the same way here, before the defaults fill in the rest
|
||||||
|
# of the object and before schema validation, so the plugin doesn't
|
||||||
|
# start with a schema warning and a degraded flag. In memory only:
|
||||||
|
# config.json is written by saves, never by loading a plugin.
|
||||||
|
if schema:
|
||||||
|
upgraded: List[str] = []
|
||||||
|
config = normalize_legacy_booleans(config, schema, upgraded)
|
||||||
|
if upgraded:
|
||||||
|
self.logger.info(
|
||||||
|
"Plugin %s: reading legacy boolean setting %s as "
|
||||||
|
"{\"enabled\": ...}; saving the plugin's settings "
|
||||||
|
"stores the new shape",
|
||||||
|
plugin_id, ", ".join(upgraded),
|
||||||
|
)
|
||||||
|
|
||||||
# Merge config with schema defaults to ensure all defaults are applied
|
# Merge config with schema defaults to ensure all defaults are applied
|
||||||
try:
|
try:
|
||||||
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
|
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
|
||||||
|
|||||||
@@ -14,6 +14,85 @@ import jsonschema
|
|||||||
from jsonschema import Draft7Validator, ValidationError
|
from jsonschema import Draft7Validator, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def _renders_as_object(prop: Dict[str, Any]) -> bool:
|
||||||
|
"""``field_type == 'object'`` as ``plugin_config.html`` computes it.
|
||||||
|
|
||||||
|
The template takes a type list's *first* entry, so ``["object", "null"]``
|
||||||
|
is an object and ``["boolean", "object"]`` is a checkbox.
|
||||||
|
"""
|
||||||
|
field_type = prop.get('type')
|
||||||
|
if isinstance(field_type, list):
|
||||||
|
field_type = field_type[0] if field_type else None
|
||||||
|
return field_type == 'object'
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_bool_as_object(value: Any, prop: Any) -> Any:
|
||||||
|
"""Read a boolean stored where the schema now has an ``{enabled, ...}`` object.
|
||||||
|
|
||||||
|
Plugins turn an on/off switch into a settings object (news'
|
||||||
|
``global.dynamic_duration: true`` became ``{enabled, min_duration_seconds,
|
||||||
|
...}``), but config.json keeps the boolean until the user next saves that
|
||||||
|
plugin's form. The boolean was the switch the object's ``enabled`` now
|
||||||
|
holds, so it becomes ``{"enabled": value}`` and schema defaults fill the rest.
|
||||||
|
|
||||||
|
Returns ``value`` itself when the rule does not apply: anything that is not
|
||||||
|
a real ``bool`` (``1``, ``"true"``, ``None``) or a schema property that is not
|
||||||
|
an object with an ``enabled`` child. Those stay as they are, so validation
|
||||||
|
still reports a genuine mismatch.
|
||||||
|
|
||||||
|
This is the rule ``render_nested_section`` in
|
||||||
|
``web_interface/templates/v3/partials/plugin_config.html`` applies when it
|
||||||
|
draws the form. ``test/test_legacy_boolean_config.py`` renders that macro
|
||||||
|
against this function, so change both together.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, bool) or not isinstance(prop, dict):
|
||||||
|
return value
|
||||||
|
properties = prop.get('properties')
|
||||||
|
if (_renders_as_object(prop) and isinstance(properties, dict)
|
||||||
|
and 'enabled' in properties):
|
||||||
|
return {'enabled': value}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_legacy_booleans(config: Any, schema: Any,
|
||||||
|
changed_paths: Optional[List[str]] = None,
|
||||||
|
_prefix: str = '') -> Any:
|
||||||
|
"""Apply :func:`legacy_bool_as_object` at every depth of a plugin config.
|
||||||
|
|
||||||
|
Walks the config along the schema's ``properties`` the way the settings
|
||||||
|
form does: into nested objects, not into array items (the form hands arrays
|
||||||
|
to widgets and never applies the rule there).
|
||||||
|
|
||||||
|
Never mutates ``config``. Returns the same object when nothing changed, and
|
||||||
|
otherwise copies only the dicts on the path to each upgraded value. When
|
||||||
|
``changed_paths`` is given, the dotted path of each upgraded value is
|
||||||
|
appended to it.
|
||||||
|
"""
|
||||||
|
if not isinstance(config, dict) or not isinstance(schema, dict):
|
||||||
|
return config
|
||||||
|
properties = schema.get('properties')
|
||||||
|
if not isinstance(properties, dict):
|
||||||
|
return config
|
||||||
|
|
||||||
|
result = config
|
||||||
|
for key, value in config.items():
|
||||||
|
prop = properties.get(key)
|
||||||
|
if not isinstance(prop, dict) or not _renders_as_object(prop):
|
||||||
|
continue
|
||||||
|
path = f"{_prefix}.{key}" if _prefix else key
|
||||||
|
new_value = legacy_bool_as_object(value, prop)
|
||||||
|
if new_value is not value:
|
||||||
|
if changed_paths is not None:
|
||||||
|
changed_paths.append(path)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
new_value = normalize_legacy_booleans(value, prop, changed_paths, path)
|
||||||
|
if new_value is not value:
|
||||||
|
if result is config:
|
||||||
|
result = dict(config)
|
||||||
|
result[key] = new_value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class SchemaManager:
|
class SchemaManager:
|
||||||
"""
|
"""
|
||||||
Manages plugin configuration schemas with caching and validation.
|
Manages plugin configuration schemas with caching and validation.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from dataclasses import dataclass
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||||
from src.plugin_system.state_manager import PluginStateManager
|
from src.plugin_system.state_manager import PluginStateManager
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
@@ -72,9 +73,16 @@ def secrets_top_level_keys(config_manager) -> Set[str]:
|
|||||||
return set(secrets) if isinstance(secrets, dict) else set()
|
return set(secrets) if isinstance(secrets, dict) else set()
|
||||||
|
|
||||||
|
|
||||||
def ignored_config_keys(config_manager) -> Set[str]:
|
def ignored_config_keys(config_manager, installed_ids=frozenset()) -> Set[str]:
|
||||||
"""Config keys that are not plugin ids: system keys plus secrets keys."""
|
"""Config keys that are not plugin ids: core keys plus secrets keys.
|
||||||
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_top_level_keys(config_manager)
|
|
||||||
|
A secrets key that is also an installed plugin's id is not ignored: plugin
|
||||||
|
secrets are namespaced by plugin id, so that key is the plugin's own entry.
|
||||||
|
Ignoring it made every installed plugin with secrets read as "installed but
|
||||||
|
missing from config" on each run.
|
||||||
|
"""
|
||||||
|
secrets_only = secrets_top_level_keys(config_manager) - set(installed_ids)
|
||||||
|
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_only
|
||||||
|
|
||||||
|
|
||||||
def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str]:
|
def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str]:
|
||||||
@@ -132,6 +140,13 @@ def still_unresolved(entries: List[Dict[str, Any]],
|
|||||||
plugins were all present in config kept being told, for hours, that four of
|
plugins were all present in config kept being told, for hours, that four of
|
||||||
them were missing and should be removed from config.json.
|
them were missing and should be removed from config.json.
|
||||||
|
|
||||||
|
An "in config but not on disk" finding is stale once the plugin is
|
||||||
|
installed, and equally once the id is no longer a plugin entry in config --
|
||||||
|
whether the user removed it or it was never a plugin (a core key such as
|
||||||
|
``auto_update`` that an older build misread). Without the second check a
|
||||||
|
verdict written by such a build kept telling users to delete a real core
|
||||||
|
setting until the next full reconciliation run.
|
||||||
|
|
||||||
Entry kinds this cannot re-check are kept, so filtering only ever removes
|
Entry kinds this cannot re-check are kept, so filtering only ever removes
|
||||||
findings that are provably stale.
|
findings that are provably stale.
|
||||||
"""
|
"""
|
||||||
@@ -143,7 +158,7 @@ def still_unresolved(entries: List[Dict[str, Any]],
|
|||||||
if plugin_id not in config_keys:
|
if plugin_id not in config_keys:
|
||||||
live.append(entry)
|
live.append(entry)
|
||||||
elif kind == InconsistencyType.PLUGIN_MISSING_ON_DISK.value:
|
elif kind == InconsistencyType.PLUGIN_MISSING_ON_DISK.value:
|
||||||
if plugin_id not in installed_ids:
|
if plugin_id in config_keys and plugin_id not in installed_ids:
|
||||||
live.append(entry)
|
live.append(entry)
|
||||||
else:
|
else:
|
||||||
live.append(entry)
|
live.append(entry)
|
||||||
@@ -280,39 +295,22 @@ class StateReconciliation:
|
|||||||
message=f"Reconciliation failed: {str(e)}"
|
message=f"Reconciliation failed: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Top-level config keys that are NOT plugins.
|
# Top-level config keys that are NOT plugins. The core keys come from the
|
||||||
# Includes both config.json structural keys and config_secrets.json top-level
|
# shared list in src/core_config_keys.py -- a private copy here missed
|
||||||
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
|
# #581's 'auto_update' and reported it as a plugin missing from disk.
|
||||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
# 'github'/'youtube' are the historical secrets-file keys. The secrets file
|
||||||
'web_display_autostart', 'timezone', 'location', 'display',
|
# itself is read at run time too (ignored_config_keys): load_config() merges
|
||||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
# it in, and naming its keys one by one let a 'data' key become a phantom
|
||||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
# plugin permanently reported as "in config but not on disk".
|
||||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'})
|
||||||
'dim_schedule', 'network', 'system', 'schedule',
|
|
||||||
# Multi-display sync config (config.json structural key)
|
|
||||||
'sync',
|
|
||||||
# Secrets file top-level keys (merged in by load_config)
|
|
||||||
'github', 'youtube',
|
|
||||||
})
|
|
||||||
|
|
||||||
def _secrets_top_level_keys(self) -> Set[str]:
|
|
||||||
"""Top-level keys that load_config() merges in from the secrets file.
|
|
||||||
|
|
||||||
load_config() merges config_secrets.json into the config it returns, so
|
|
||||||
those keys sit alongside plugin ids. _SYSTEM_CONFIG_KEYS named them
|
|
||||||
individually ('github', 'youtube'), which broke the moment anything else
|
|
||||||
was written there: a 'data' key became a phantom plugin, permanently
|
|
||||||
reported as "in config but not on disk". Reading the file keeps this
|
|
||||||
correct no matter what it holds.
|
|
||||||
"""
|
|
||||||
return secrets_top_level_keys(self.config_manager)
|
|
||||||
|
|
||||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
"""Get plugin state from config file."""
|
"""Get plugin state from config file."""
|
||||||
state = {}
|
state = {}
|
||||||
try:
|
try:
|
||||||
config = self.config_manager.load_config()
|
config = self.config_manager.load_config()
|
||||||
ignored = self._SYSTEM_CONFIG_KEYS | self._secrets_top_level_keys()
|
ignored = ignored_config_keys(self.config_manager,
|
||||||
|
disk_plugin_ids(self.plugins_dir))
|
||||||
for plugin_id in config_plugin_ids(config, ignored):
|
for plugin_id in config_plugin_ids(config, ignored):
|
||||||
plugin_config = config[plugin_id]
|
plugin_config = config[plugin_id]
|
||||||
state[plugin_id] = {
|
state[plugin_id] = {
|
||||||
@@ -390,6 +388,19 @@ class StateReconciliation:
|
|||||||
"""Check consistency for a single plugin."""
|
"""Check consistency for a single plugin."""
|
||||||
inconsistencies = []
|
inconsistencies = []
|
||||||
|
|
||||||
|
if plugin_id in CORE_CONFIG_KEYS:
|
||||||
|
# A plugin whose id is a core setting's key ('display', 'sync',
|
||||||
|
# ...) can never have a config section of its own: that section
|
||||||
|
# is the core setting. Every check below would misfire -- "not in
|
||||||
|
# config" forever, and an auto-fix that writes {'enabled': False}
|
||||||
|
# where a core setting belongs -- so report it in the log instead.
|
||||||
|
if disk_state.get(plugin_id, {}).get('exists_on_disk'):
|
||||||
|
self.logger.warning(
|
||||||
|
"Plugin id %r is reserved for a core config setting; "
|
||||||
|
"the plugin cannot be configured and is skipped by "
|
||||||
|
"reconciliation. Rename the plugin.", plugin_id)
|
||||||
|
return inconsistencies
|
||||||
|
|
||||||
config = config_state.get(plugin_id, {})
|
config = config_state.get(plugin_id, {})
|
||||||
disk = disk_state.get(plugin_id, {})
|
disk = disk_state.get(plugin_id, {})
|
||||||
state_mgr = state_manager_state.get(plugin_id, {})
|
state_mgr = state_manager_state.get(plugin_id, {})
|
||||||
@@ -468,6 +479,12 @@ class StateReconciliation:
|
|||||||
"""Attempt to fix an inconsistency."""
|
"""Attempt to fix an inconsistency."""
|
||||||
try:
|
try:
|
||||||
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
|
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
|
||||||
|
if inconsistency.plugin_id in CORE_CONFIG_KEYS:
|
||||||
|
# Never create or replace a core setting with a plugin stub.
|
||||||
|
self.logger.warning(
|
||||||
|
"Refusing to add plugin entry %r: that key is a core "
|
||||||
|
"config setting", inconsistency.plugin_id)
|
||||||
|
return False
|
||||||
config = self.config_manager.load_config()
|
config = self.config_manager.load_config()
|
||||||
if inconsistency.plugin_id in config:
|
if inconsistency.plugin_id in config:
|
||||||
# Detection said "not in config" but it is there -- the
|
# Detection said "not in config" but it is there -- the
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
|||||||
| Suite | Needs a server | Covers |
|
| Suite | Needs a server | Covers |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
||||||
|
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), and re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
|
||||||
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
||||||
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
||||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||||
|
|||||||
+2
-1
@@ -17,7 +17,8 @@ const BASE = process.env.BASE || 'http://localhost:5000';
|
|||||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||||
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
||||||
'unit/test_style_editor_layout_leaf_columns.js',
|
'unit/test_style_editor_layout_leaf_columns.js',
|
||||||
'unit/test_style_editor_layout_leaf_collision.js'];
|
'unit/test_style_editor_layout_leaf_collision.js',
|
||||||
|
'unit/test_update_all.js'];
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||||
'dom/test_tools_sections.js'];
|
'dom/test_tools_sections.js'];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// "Check & Update All" -- which ids it sends to POST /api/v3/plugins/update.
|
||||||
|
//
|
||||||
|
// Pins two bugs seen on a real device (core 3.4.0):
|
||||||
|
//
|
||||||
|
// 1. /plugins/installed lists installed Starlark apps as virtual
|
||||||
|
// `starlark:<app_id>` entries. Update-all sent those to the plugin
|
||||||
|
// updater, which answered 500 "plugin not found" for each one.
|
||||||
|
//
|
||||||
|
// 2. A web-service restart landed mid-run. The request in flight died with
|
||||||
|
// the old process and the next one (stock-news) was refused while the
|
||||||
|
// service was coming back; both were recorded as failures and never sent
|
||||||
|
// again, so stock-news -- installed, disabled, with an update waiting --
|
||||||
|
// was silently not updated.
|
||||||
|
//
|
||||||
|
// Runs the shipped install_manager.js (it exports itself under node) against
|
||||||
|
// a fake PluginAPI. The installed list is the device's, trimmed.
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||||
|
|
||||||
|
global.window = {};
|
||||||
|
const Manager = require(path.join(V3, 'js/plugins/install_manager.js'));
|
||||||
|
|
||||||
|
const INSTALLED = [
|
||||||
|
{ id: 'ledmatrix-flights', enabled: true, version: '1.14.0' },
|
||||||
|
{ id: 'stock-news', enabled: false, version: '2.6.2', latest_version: '2.8.0', update_available: true },
|
||||||
|
{ id: 'static-image', enabled: false, version: '1.1.3' },
|
||||||
|
{ id: 'starlark-apps', enabled: false, version: '1.0.0' }, // a real plugin -- keep it
|
||||||
|
{ id: 'pomodoro-timer', enabled: false, version: '1.3.6' },
|
||||||
|
{ id: 'starlark:analogtime', enabled: false, version: 'starlark', is_starlark_app: true },
|
||||||
|
{ id: 'starlark:analogclock', enabled: true, version: 'starlark', is_starlark_app: true },
|
||||||
|
];
|
||||||
|
const EXPECTED = ['ledmatrix-flights', 'stock-news', 'static-image', 'starlark-apps', 'pomodoro-timer'];
|
||||||
|
|
||||||
|
const netErr = () => ({ error_code: 'NETWORK_ERROR', message: 'Failed to fetch' });
|
||||||
|
|
||||||
|
function fakeApi(behaviour = {}) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
updatePlugin: async (id) => {
|
||||||
|
calls.push(id);
|
||||||
|
const b = behaviour[id];
|
||||||
|
if (typeof b === 'function') return b(calls.filter(c => c === id).length);
|
||||||
|
return { status: 'success', message: `Plugin ${id} updated successfully` };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(api, { stateList, windowList } = {}) {
|
||||||
|
global.window = {
|
||||||
|
PluginAPI: api,
|
||||||
|
installedPlugins: windowList,
|
||||||
|
PluginStateManager: stateList === undefined ? undefined : {
|
||||||
|
installedPlugins: stateList,
|
||||||
|
loadInstalledPlugins: async () => stateList,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const noSleep = { sleep: async () => {} };
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\nselection');
|
||||||
|
ok('starlark app entries are not updatable',
|
||||||
|
!Manager.isUpdatablePlugin(INSTALLED[5]) && !Manager.isUpdatablePlugin(INSTALLED[6]));
|
||||||
|
ok('a starlark: id without the flag is still excluded',
|
||||||
|
!Manager.isUpdatablePlugin({ id: 'starlark:foo' }));
|
||||||
|
ok('the starlark-apps plugin itself is updatable', Manager.isUpdatablePlugin(INSTALLED[3]));
|
||||||
|
ok('a disabled plugin with an update is updatable', Manager.isUpdatablePlugin(INSTALLED[1]));
|
||||||
|
ok('entries without a usable id are skipped',
|
||||||
|
!Manager.isUpdatablePlugin({}) && !Manager.isUpdatablePlugin(null) && !Manager.isUpdatablePlugin({ id: '' }));
|
||||||
|
const sel = Manager.updatablePlugins(INSTALLED).map(p => p.id);
|
||||||
|
ok('updatablePlugins keeps list order and drops only starlark apps',
|
||||||
|
JSON.stringify(sel) === JSON.stringify(EXPECTED), sel);
|
||||||
|
|
||||||
|
console.log('\nupdateAll sends only plugin ids');
|
||||||
|
{
|
||||||
|
const api = fakeApi();
|
||||||
|
setup(api, { windowList: INSTALLED });
|
||||||
|
const progress = [];
|
||||||
|
const results = await Manager.updateAll((i, n, id) => progress.push([i, n, id]), noSleep);
|
||||||
|
ok('POSTs exactly the non-starlark ids, in order',
|
||||||
|
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
|
||||||
|
ok('no starlark: id reached the plugin updater', !api.calls.some(id => id.startsWith('starlark:')));
|
||||||
|
ok('one result per plugin sent', results.length === EXPECTED.length, results.length);
|
||||||
|
ok('progress total counts only what is sent',
|
||||||
|
progress.length === EXPECTED.length && progress.every(([, n]) => n === EXPECTED.length), progress);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const api = fakeApi();
|
||||||
|
setup(api, { stateList: INSTALLED, windowList: [] });
|
||||||
|
await Manager.updateAll(null, noSleep);
|
||||||
|
ok('the PluginStateManager list is filtered the same way',
|
||||||
|
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const api = fakeApi();
|
||||||
|
setup(api, { windowList: INSTALLED.filter(p => p.is_starlark_app) });
|
||||||
|
const results = await Manager.updateAll(null, noSleep);
|
||||||
|
ok('a list of only starlark apps sends nothing', api.calls.length === 0 && results.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nweb service restarting mid-run (the stock-news case)');
|
||||||
|
{
|
||||||
|
// ledmatrix-flights is in flight when the service stops (reset); stock-news
|
||||||
|
// is refused twice while it comes back; then everything answers.
|
||||||
|
const api = fakeApi({
|
||||||
|
'ledmatrix-flights': (n) => { if (n === 1) throw netErr(); return { status: 'success', message: 'ok' }; },
|
||||||
|
'stock-news': (n) => { if (n <= 2) throw netErr(); return { status: 'success', message: 'Plugin stock-news updated successfully' }; },
|
||||||
|
});
|
||||||
|
setup(api, { windowList: INSTALLED });
|
||||||
|
const slept = [];
|
||||||
|
const results = await Manager.updateAll(null, { sleep: async ms => { slept.push(ms); }, retryDelaysMs: [5, 10, 20] });
|
||||||
|
const byId = Object.fromEntries(results.map(r => [r.pluginId, r]));
|
||||||
|
ok('stock-news is sent again until the server answers',
|
||||||
|
api.calls.filter(id => id === 'stock-news').length === 3, api.calls);
|
||||||
|
ok('stock-news ends up updated, not skipped', byId['stock-news'] && byId['stock-news'].success === true, byId['stock-news']);
|
||||||
|
ok('the request lost with the old process is re-sent too',
|
||||||
|
byId['ledmatrix-flights'] && byId['ledmatrix-flights'].success === true);
|
||||||
|
ok('it backs off between attempts', JSON.stringify(slept) === JSON.stringify([5, 5, 10]), slept);
|
||||||
|
ok('every plugin still gets exactly one result',
|
||||||
|
JSON.stringify(results.map(r => r.pluginId)) === JSON.stringify(EXPECTED), results.map(r => r.pluginId));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const api = fakeApi({ 'stock-news': () => { throw netErr(); } });
|
||||||
|
setup(api, { windowList: INSTALLED });
|
||||||
|
const results = await Manager.updateAll(null, { sleep: async () => {}, retryDelaysMs: [1, 1] });
|
||||||
|
const r = results.find(x => x.pluginId === 'stock-news');
|
||||||
|
ok('a server that never comes back is retried a bounded number of times',
|
||||||
|
api.calls.filter(id => id === 'stock-news').length === 3, api.calls);
|
||||||
|
ok('...then reported as a failure, and the run continues',
|
||||||
|
r && r.success === false && api.calls[api.calls.length - 1] === 'pomodoro-timer');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const api = fakeApi({ 'static-image': () => { throw { error_code: 'PLUGIN_UPDATE_FAILED', message: 'check logs' }; } });
|
||||||
|
setup(api, { windowList: INSTALLED });
|
||||||
|
const results = await Manager.updateAll(null, { sleep: async () => { throw new Error('must not sleep'); } });
|
||||||
|
ok('an HTTP error answer is not retried',
|
||||||
|
api.calls.filter(id => id === 'static-image').length === 1, api.calls);
|
||||||
|
ok('...and is reported as a failure', results.find(x => x.pluginId === 'static-image').success === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -31,6 +31,7 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
|
|
||||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
from test.test_web_settings_ui import REALISTIC_CONFIG # noqa: E402
|
from test.test_web_settings_ui import REALISTIC_CONFIG # noqa: E402
|
||||||
|
from src import pi5_matrix_support # noqa: E402
|
||||||
|
|
||||||
#: What a Waveshare RGB-Matrix-P2.5-96x48 V2 (back silkscreen 24S-A1) needed on
|
#: What a Waveshare RGB-Matrix-P2.5-96x48 V2 (back silkscreen 24S-A1) needed on
|
||||||
#: a Pi 4 with an Adafruit Triple LED Matrix Bonnet, checked on the panel.
|
#: a Pi 4 with an Adafruit Triple LED Matrix Bonnet, checked on the panel.
|
||||||
@@ -43,6 +44,24 @@ WAVESHARE_96X48_V2 = {
|
|||||||
#: Fields stored under display.runtime; the rest go under display.hardware.
|
#: Fields stored under display.runtime; the rest go under display.hardware.
|
||||||
RUNTIME_FIELDS = {'gpio_slowdown'}
|
RUNTIME_FIELDS = {'gpio_slowdown'}
|
||||||
|
|
||||||
|
PI5_MODEL = 'Raspberry Pi 5 Model B Rev 1.0'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def board(tmp_path, monkeypatch):
|
||||||
|
"""Not a Pi 5 unless a test says so, whatever machine runs the suite.
|
||||||
|
|
||||||
|
Returns a setter: board(PI5_MODEL) makes the API and the form see a Pi 5.
|
||||||
|
"""
|
||||||
|
path = tmp_path / 'device-tree-model'
|
||||||
|
|
||||||
|
def set_model(model):
|
||||||
|
path.write_bytes(model.encode() + b'\x00')
|
||||||
|
|
||||||
|
set_model('Raspberry Pi 4 Model B Rev 1.5')
|
||||||
|
monkeypatch.setattr(pi5_matrix_support, 'MODEL_PATH', str(path))
|
||||||
|
return set_model
|
||||||
|
|
||||||
|
|
||||||
def _stored(config, field):
|
def _stored(config, field):
|
||||||
section = 'runtime' if field in RUNTIME_FIELDS else 'hardware'
|
section = 'runtime' if field in RUNTIME_FIELDS else 'hardware'
|
||||||
@@ -220,3 +239,70 @@ def test_a_stored_zero_renders_as_zero(display_page, field, section):
|
|||||||
"""`value or default` showed a stored 0 as the default, and the next save wrote it back."""
|
"""`value or default` showed a stored 0 as the default, and the next save wrote it back."""
|
||||||
body = display_page(_config_with(**{section: {field: 0}}))
|
body = display_page(_config_with(**{section: {field: 0}}))
|
||||||
assert _attr(_input_tag(body, field), 'value') == '0'
|
assert _attr(_input_tag(body, field), 'value') == '0'
|
||||||
|
|
||||||
|
|
||||||
|
# --- Raspberry Pi 5 -------------------------------------------------------
|
||||||
|
# The pinned library's Pi 5 path drives only row address types 0 and 2,
|
||||||
|
# parallel 1-3 and the standard mappings; anything else crashes the display
|
||||||
|
# service (src/pi5_matrix_support.py), so the API and the form refuse it.
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('body', [
|
||||||
|
{'row_address_type': 5}, {'row_address_type': '1'},
|
||||||
|
{'hardware_mapping': 'compute-module'},
|
||||||
|
])
|
||||||
|
def test_pi5_refuses_what_its_library_cannot_drive(api_v3_client, saved, board, body):
|
||||||
|
board(PI5_MODEL)
|
||||||
|
response = _post(api_v3_client, body)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'Raspberry Pi 5' in response.get_json()['message']
|
||||||
|
assert 'config' not in saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_pi5_saves_what_it_can_drive(api_v3_client, saved, board):
|
||||||
|
board(PI5_MODEL)
|
||||||
|
response = _post(api_v3_client, dict(WAVESHARE_96X48_V2, row_address_type=2))
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||||
|
assert saved['config']['display']['hardware']['row_address_type'] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_pi5_check_uses_the_stored_value_for_fields_not_sent(api_v3_client, api_v3_module, saved, board):
|
||||||
|
"""Changing only the mapping is still checked against the stored row address type."""
|
||||||
|
board(PI5_MODEL)
|
||||||
|
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||||
|
'display': {'hardware': {'row_address_type': 5}}}
|
||||||
|
response = _post(api_v3_client, {'hardware_mapping': 'regular'})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'config' not in saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_pi5_stored_combination_does_not_block_unrelated_saves(api_v3_client, api_v3_module, saved, board):
|
||||||
|
board(PI5_MODEL)
|
||||||
|
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||||
|
'display': {'hardware': {'row_address_type': 5}}}
|
||||||
|
response = _post(api_v3_client, {'brightness': 70})
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def _option_values(body, select_id):
|
||||||
|
select = re.search(r'<select id="%s".*?</select>' % select_id, body, re.S)
|
||||||
|
assert select, f'no <select id="{select_id}">'
|
||||||
|
return re.findall(r'<option value="([^"]*)"', select.group(0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pi5_form_offers_only_supported_row_address_types(display_page, board):
|
||||||
|
board(PI5_MODEL)
|
||||||
|
body = display_page(_config_with())
|
||||||
|
assert _option_values(body, 'row_address_type') == ['0', '2']
|
||||||
|
assert "can't be used on this Raspberry Pi 5" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_boards_offer_every_row_address_type(display_page):
|
||||||
|
body = display_page(_config_with())
|
||||||
|
assert _option_values(body, 'row_address_type') == ['0', '1', '2', '3', '4', '5']
|
||||||
|
|
||||||
|
|
||||||
|
def test_pi5_form_warns_about_a_stored_unsupported_row_address_type(display_page, board):
|
||||||
|
"""The unsupported option isn't offered, so the browser posts 0 -- say so."""
|
||||||
|
board(PI5_MODEL)
|
||||||
|
body = display_page(_config_with(hardware={'row_address_type': 5}))
|
||||||
|
assert "Your saved row address type (5) can't be used on this Raspberry Pi 5" in body
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""Core settings in config.json must never be reported as orphaned plugins.
|
||||||
|
|
||||||
|
Observed on a v3.4.0 device: the web UI showed "Plugin Config Warning -- In
|
||||||
|
config but not installed: auto_update. Reinstall via the Plugin Store, or
|
||||||
|
remove these entries from config.json." ``auto_update`` is the top-level core
|
||||||
|
section #581 added for weekly automatic updates. Reconciliation kept its own
|
||||||
|
private list of non-plugin keys, which #581 did not know to extend, so any
|
||||||
|
top-level dict it did not list was treated as a plugin id. Following the
|
||||||
|
warning's advice deletes a real core setting.
|
||||||
|
|
||||||
|
These tests pin the shared list in src/core_config_keys.py to the sources that
|
||||||
|
actually define top-level keys, so the next new core setting cannot regress it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO))
|
||||||
|
|
||||||
|
from src.core_config_keys import CORE_CONFIG_KEYS # noqa: E402
|
||||||
|
from src.plugin_system.state_reconciliation import ( # noqa: E402
|
||||||
|
InconsistencyType,
|
||||||
|
StateReconciliation,
|
||||||
|
)
|
||||||
|
|
||||||
|
ON_DISK = InconsistencyType.PLUGIN_MISSING_ON_DISK
|
||||||
|
IN_CONFIG = InconsistencyType.PLUGIN_MISSING_IN_CONFIG
|
||||||
|
|
||||||
|
TEMPLATE = json.loads((REPO / "config" / "config.template.json").read_text(encoding="utf-8"))
|
||||||
|
BUNDLED_PLUGINS = {p.parent.name for p in (REPO / "plugin-repos").glob("*/manifest.json")}
|
||||||
|
|
||||||
|
# Top-level keys of the hdpi device's config.json (v3.4.0 fddb0e06), keys only.
|
||||||
|
DEVICE_CONFIG_KEYS = [
|
||||||
|
'web_display_autostart', 'schedule', 'dim_schedule', 'timezone', 'target_fps',
|
||||||
|
'location', 'display', 'sync', 'plugin_system', 'web-ui-info', 'starlark-apps',
|
||||||
|
'football-scoreboard', 'hockey-scoreboard', 'lacrosse-scoreboard', 'afl-scoreboard',
|
||||||
|
'nrl-scoreboard', 'soccer-scoreboard', 'baseball-scoreboard', 'ledmatrix-flights',
|
||||||
|
'birdnet-go', 'geochron', 'incoming-packages', 'jellyfin-now-playing', 'tide-display',
|
||||||
|
'ledmatrix-leaderboard', 'ledmatrix-music', 'odds-ticker', 'stock-news',
|
||||||
|
'basketball-scoreboard', 'ledmatrix-stocks', 'ledmatrix-weather', 'news',
|
||||||
|
'clock-simple', 'christmas-countdown', '7-segment-clock', 'calendar', 'on-air',
|
||||||
|
'static-image', 'ledmatrix-dresden-departures', 'olympics', 'ledmatrix-elections',
|
||||||
|
'f1-scoreboard', 'hello-world', 'tidbyt-baseball-scoreboard', 'youtube-stats',
|
||||||
|
'mqtt-notifications', 'march-madness', 'pga-tour-leaderboard', 'nfl-draft',
|
||||||
|
'pomodoro-timer', 'countdown', 'gif-player', 'of-the-day', 'text-display',
|
||||||
|
'masters-tournament', 'cricket-scoreboard', 'ufc-scoreboard', 'auto_update',
|
||||||
|
]
|
||||||
|
DEVICE_SECRETS_KEYS = ['github', 'incoming-packages', 'jellyfin-now-playing']
|
||||||
|
DEVICE_INSTALLED = [k for k in DEVICE_CONFIG_KEYS if k not in CORE_CONFIG_KEYS] + [
|
||||||
|
'ledmatrix-flights.standalone-backup-migrating']
|
||||||
|
|
||||||
|
|
||||||
|
def _device_config():
|
||||||
|
"""Device-shaped config: real key set, placeholder values."""
|
||||||
|
cfg = {}
|
||||||
|
for key in DEVICE_CONFIG_KEYS:
|
||||||
|
template_value = TEMPLATE.get(key)
|
||||||
|
cfg[key] = template_value if template_value is not None else {"enabled": True}
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
class _ConfigManager:
|
||||||
|
def __init__(self, config, secrets_path=None):
|
||||||
|
self._config = config
|
||||||
|
self._secrets_path = secrets_path
|
||||||
|
self.saved = []
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
return dict(self._config)
|
||||||
|
|
||||||
|
def save_config(self, config):
|
||||||
|
self.saved.append(dict(config))
|
||||||
|
self._config = dict(config)
|
||||||
|
|
||||||
|
def get_secrets_path(self):
|
||||||
|
return self._secrets_path
|
||||||
|
|
||||||
|
|
||||||
|
def _install(plugins_dir, plugin_id):
|
||||||
|
d = plugins_dir / plugin_id
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
(d / "manifest.json").write_text(json.dumps({"id": plugin_id, "version": "1.0.0"}),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile(tmp_path, config, installed=(), secrets=None):
|
||||||
|
plugins_dir = tmp_path / "plugin-repos"
|
||||||
|
plugins_dir.mkdir(exist_ok=True)
|
||||||
|
for pid in installed:
|
||||||
|
_install(plugins_dir, pid)
|
||||||
|
secrets_path = tmp_path / "config_secrets.json"
|
||||||
|
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
|
||||||
|
state_manager = Mock()
|
||||||
|
state_manager.get_all_states.return_value = {}
|
||||||
|
plugin_manager = Mock()
|
||||||
|
plugin_manager.plugin_manifests = {}
|
||||||
|
reconciler = StateReconciliation(
|
||||||
|
state_manager=state_manager,
|
||||||
|
config_manager=_ConfigManager(config, str(secrets_path)),
|
||||||
|
plugin_manager=plugin_manager,
|
||||||
|
plugins_dir=plugins_dir,
|
||||||
|
)
|
||||||
|
return reconciler, reconciler.reconcile_state()
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(result, kind):
|
||||||
|
return sorted(i.plugin_id for i in result.inconsistencies_found
|
||||||
|
if i.inconsistency_type == kind)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheListCoversEveryCoreKey:
|
||||||
|
def test_every_template_top_level_key_is_core_or_a_bundled_plugin(self):
|
||||||
|
unaccounted = [k for k in TEMPLATE
|
||||||
|
if k not in CORE_CONFIG_KEYS and k not in BUNDLED_PLUGINS]
|
||||||
|
assert unaccounted == [], (
|
||||||
|
"config.template.json has top-level keys that are neither in "
|
||||||
|
"src/core_config_keys.py nor a plugin under plugin-repos/; "
|
||||||
|
"reconciliation will report them as plugins missing from disk")
|
||||||
|
|
||||||
|
def test_the_template_keys_this_bug_was_about_are_covered(self):
|
||||||
|
for key in ("auto_update", "sync", "schedule", "dim_schedule", "location",
|
||||||
|
"display", "plugin_system", "timezone", "target_fps",
|
||||||
|
"web_display_autostart"):
|
||||||
|
assert key in CORE_CONFIG_KEYS, key
|
||||||
|
|
||||||
|
def test_keys_written_by_the_general_settings_save_are_core(self):
|
||||||
|
"""A new setting can be added to the save endpoint without touching
|
||||||
|
the template; catch that too."""
|
||||||
|
source = (REPO / "web_interface" / "blueprints" / "api_v3" / "config.py").read_text(
|
||||||
|
encoding="utf-8")
|
||||||
|
written = set(re.findall(r"current_config\[['\"]([A-Za-z0-9_-]+)['\"]\]\s*=", source))
|
||||||
|
assert "auto_update" in written, "pattern no longer finds the writes; update the test"
|
||||||
|
assert written - CORE_CONFIG_KEYS == set()
|
||||||
|
|
||||||
|
def test_no_core_key_is_also_a_bundled_plugin(self):
|
||||||
|
assert CORE_CONFIG_KEYS & BUNDLED_PLUGINS == set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconciliationIgnoresCoreKeys:
|
||||||
|
def test_auto_update_is_not_reported(self, tmp_path):
|
||||||
|
_, result = _reconcile(tmp_path, {"auto_update": {"enabled": False}})
|
||||||
|
assert "auto_update" not in [i.plugin_id for i in result.inconsistencies_found]
|
||||||
|
|
||||||
|
def test_the_whole_template_yields_only_its_bundled_plugins(self, tmp_path):
|
||||||
|
template_plugins = {k for k in TEMPLATE if k in BUNDLED_PLUGINS}
|
||||||
|
reconciler, result = _reconcile(tmp_path, dict(TEMPLATE), installed=template_plugins)
|
||||||
|
assert result.inconsistencies_found == []
|
||||||
|
assert set(reconciler._get_config_state()) == template_plugins
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", sorted(CORE_CONFIG_KEYS))
|
||||||
|
def test_no_core_key_is_ever_a_plugin_missing_from_disk(self, tmp_path, key):
|
||||||
|
_, result = _reconcile(tmp_path, {key: {"enabled": True}})
|
||||||
|
assert _ids(result, ON_DISK) == []
|
||||||
|
|
||||||
|
def test_a_genuinely_orphaned_plugin_is_still_reported(self, tmp_path):
|
||||||
|
config = {"auto_update": {"enabled": False}, "display": {"hardware": {}},
|
||||||
|
"ghost-plugin": {"enabled": True}, "odds-ticker": {"enabled": True}}
|
||||||
|
_, result = _reconcile(tmp_path, config, installed=["odds-ticker"])
|
||||||
|
assert _ids(result, ON_DISK) == ["ghost-plugin"]
|
||||||
|
assert [i.plugin_id for i in result.inconsistencies_manual] == ["ghost-plugin"]
|
||||||
|
|
||||||
|
def test_the_device_config_reports_nothing(self, tmp_path):
|
||||||
|
_, result = _reconcile(
|
||||||
|
tmp_path, _device_config(), installed=DEVICE_INSTALLED,
|
||||||
|
secrets={k: {"api_key": "x"} for k in DEVICE_SECRETS_KEYS})
|
||||||
|
assert result.inconsistencies_found == []
|
||||||
|
assert result.reconciliation_successful
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginSecretsDoNotHideThePlugin:
|
||||||
|
"""Plugin secrets are namespaced by plugin id in config_secrets.json. The
|
||||||
|
device had 'incoming-packages' and 'jellyfin-now-playing' there; ignoring
|
||||||
|
every secrets key reported both installed plugins as missing from config on
|
||||||
|
every run (auto-"fixed" as a no-op, so it never reached the banner)."""
|
||||||
|
|
||||||
|
def test_installed_plugin_with_secrets_is_a_plugin(self, tmp_path):
|
||||||
|
config = {"incoming-packages": {"enabled": True, "api_key": "x"}}
|
||||||
|
reconciler, result = _reconcile(tmp_path, config, installed=["incoming-packages"],
|
||||||
|
secrets={"incoming-packages": {"api_key": "x"}})
|
||||||
|
assert result.inconsistencies_found == []
|
||||||
|
assert "incoming-packages" in reconciler._get_config_state()
|
||||||
|
|
||||||
|
def test_a_secrets_only_key_is_still_not_a_plugin(self, tmp_path):
|
||||||
|
# The #557 phantom: a non-plugin 'data' key in the secrets file.
|
||||||
|
_, result = _reconcile(tmp_path, {"data": {"mode": "nfl_recent"}},
|
||||||
|
secrets={"data": {"mode": "nfl_recent"}})
|
||||||
|
assert result.inconsistencies_found == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPluginNamedLikeACoreKey:
|
||||||
|
"""A plugin whose id collides with a core key has no config section of its
|
||||||
|
own. Reconciliation must neither nag about it nor write a stub over (or
|
||||||
|
in place of) the core setting."""
|
||||||
|
|
||||||
|
def test_installed_plugin_named_like_a_core_key_is_not_reported(self, tmp_path):
|
||||||
|
config = {"sync": {"role": "standalone"}}
|
||||||
|
reconciler, result = _reconcile(tmp_path, config, installed=["sync"])
|
||||||
|
assert result.inconsistencies_found == []
|
||||||
|
assert reconciler.config_manager.saved == []
|
||||||
|
assert reconciler.config_manager.load_config() == config
|
||||||
|
|
||||||
|
def test_core_key_absent_from_config_is_not_stubbed(self, tmp_path):
|
||||||
|
reconciler, result = _reconcile(tmp_path, {}, installed=["display"])
|
||||||
|
assert result.inconsistencies_found == []
|
||||||
|
assert reconciler.config_manager.saved == []
|
||||||
|
|
||||||
|
def test_fix_refuses_to_write_a_core_key(self, tmp_path):
|
||||||
|
reconciler, _ = _reconcile(tmp_path, {})
|
||||||
|
inc = Mock(inconsistency_type=IN_CONFIG, plugin_id="auto_update")
|
||||||
|
assert reconciler._fix_inconsistency(inc) is False
|
||||||
|
assert reconciler.config_manager.saved == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheStatusEndpoint:
|
||||||
|
@pytest.fixture
|
||||||
|
def get_status(self, tmp_path, monkeypatch):
|
||||||
|
import tempfile
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
|
||||||
|
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
||||||
|
|
||||||
|
def _get(verdict, config, installed=(), secrets=None):
|
||||||
|
(tmp_path / "ledmatrix_reconciliation.json").write_text(
|
||||||
|
json.dumps(verdict), encoding="utf-8")
|
||||||
|
plugins_dir = tmp_path / "plugin-repos"
|
||||||
|
plugins_dir.mkdir(exist_ok=True)
|
||||||
|
for pid in installed:
|
||||||
|
_install(plugins_dir, pid)
|
||||||
|
secrets_path = tmp_path / "config_secrets.json"
|
||||||
|
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
|
||||||
|
cm = MagicMock()
|
||||||
|
cm.load_config.return_value = config
|
||||||
|
cm.get_secrets_path.return_value = str(secrets_path)
|
||||||
|
pm = MagicMock()
|
||||||
|
pm.plugins_dir = str(plugins_dir)
|
||||||
|
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
|
||||||
|
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||||
|
resp = app.test_client().get("/api/v3/plugins/reconciliation-status")
|
||||||
|
return resp.get_json()["data"]
|
||||||
|
|
||||||
|
return _get
|
||||||
|
|
||||||
|
def test_the_verdict_the_device_served_now_clears(self, get_status):
|
||||||
|
"""The exact stored verdict from the device, written by the old build,
|
||||||
|
is dropped against the device's config -- no restart needed."""
|
||||||
|
verdict = {"done": True, "fixed_count": 3, "successful": False, "unresolved": [
|
||||||
|
{"description": "Plugin auto_update in config but not on disk",
|
||||||
|
"plugin_id": "auto_update", "type": ON_DISK.value}]}
|
||||||
|
data = get_status(verdict, _device_config(), installed=DEVICE_INSTALLED,
|
||||||
|
secrets={k: {} for k in DEVICE_SECRETS_KEYS})
|
||||||
|
assert data["unresolved"] == []
|
||||||
|
|
||||||
|
def test_a_real_orphan_still_reaches_the_banner(self, get_status):
|
||||||
|
verdict = {"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "auto_update", "type": ON_DISK.value},
|
||||||
|
{"plugin_id": "ghost-plugin", "type": ON_DISK.value}]}
|
||||||
|
config = _device_config()
|
||||||
|
config["ghost-plugin"] = {"enabled": True}
|
||||||
|
data = get_status(verdict, config, installed=DEVICE_INSTALLED)
|
||||||
|
assert [e["plugin_id"] for e in data["unresolved"]] == ["ghost-plugin"]
|
||||||
@@ -279,3 +279,58 @@ class TestDisplayManagerOrientation:
|
|||||||
suppress_test_pattern=True)
|
suppress_test_pattern=True)
|
||||||
options = mock_rgb_matrix['options_class'].return_value
|
options = mock_rgb_matrix['options_class'].return_value
|
||||||
assert options.pixel_mapper_config == 'U-mapper;Rotate:180'
|
assert options.pixel_mapper_config == 'U-mapper;Rotate:180'
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisplayManagerPi5Guard:
|
||||||
|
"""On a Pi 5 the library returns no matrix for settings its RP1 path can't
|
||||||
|
drive, and the binding doesn't check, so the process would crash on its next
|
||||||
|
call. DisplayManager has to refuse before creating the matrix and fall back."""
|
||||||
|
|
||||||
|
def _config(self, **hardware_overrides):
|
||||||
|
config = {
|
||||||
|
'display': {
|
||||||
|
'hardware': {
|
||||||
|
'rows': 48, 'cols': 96, 'chain_length': 1, 'parallel': 1,
|
||||||
|
'hardware_mapping': 'regular', 'brightness': 90,
|
||||||
|
},
|
||||||
|
'runtime': {'gpio_slowdown': 2},
|
||||||
|
},
|
||||||
|
'timezone': 'UTC',
|
||||||
|
'plugin_system': {'plugins_directory': 'plugins'},
|
||||||
|
}
|
||||||
|
config['display']['hardware'].update(hardware_overrides)
|
||||||
|
return config
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def board(self, tmp_path, monkeypatch):
|
||||||
|
from src import pi5_matrix_support
|
||||||
|
|
||||||
|
def set_model(model):
|
||||||
|
path = tmp_path / 'model'
|
||||||
|
path.write_bytes(model.encode() + b'\x00')
|
||||||
|
monkeypatch.setattr(pi5_matrix_support, 'MODEL_PATH', str(path))
|
||||||
|
return set_model
|
||||||
|
|
||||||
|
def test_unsupported_setting_on_pi5_never_creates_the_matrix(self, mock_rgb_matrix, board):
|
||||||
|
board('Raspberry Pi 5 Model B Rev 1.0')
|
||||||
|
DisplayManager._instance = None
|
||||||
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
|
dm = DisplayManager(self._config(row_address_type=5), suppress_test_pattern=True)
|
||||||
|
mock_rgb_matrix['matrix_class'].assert_not_called()
|
||||||
|
assert dm.matrix is None
|
||||||
|
|
||||||
|
def test_supported_setting_on_pi5_creates_the_matrix(self, mock_rgb_matrix, board):
|
||||||
|
board('Raspberry Pi 5 Model B Rev 1.0')
|
||||||
|
DisplayManager._instance = None
|
||||||
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
|
dm = DisplayManager(self._config(row_address_type=2), suppress_test_pattern=True)
|
||||||
|
mock_rgb_matrix['matrix_class'].assert_called_once()
|
||||||
|
assert dm.matrix is not None
|
||||||
|
|
||||||
|
def test_other_boards_are_left_to_the_library(self, mock_rgb_matrix, board):
|
||||||
|
board('Raspberry Pi 4 Model B Rev 1.5')
|
||||||
|
DisplayManager._instance = None
|
||||||
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||||
|
dm = DisplayManager(self._config(row_address_type=5), suppress_test_pattern=True)
|
||||||
|
mock_rgb_matrix['matrix_class'].assert_called_once()
|
||||||
|
assert dm.matrix is not None
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"""A boolean the schema has since turned into an ``{enabled, ...}`` object.
|
||||||
|
|
||||||
|
The news plugin's ``global.dynamic_duration`` went from ``true`` to an object
|
||||||
|
with ``enabled``, ``min_duration_seconds``, ... An install that has not saved
|
||||||
|
the news settings since still holds ``true``, and on every start the display
|
||||||
|
service logged::
|
||||||
|
|
||||||
|
Plugin news config does not match its schema (loading anyway):
|
||||||
|
Field 'global.dynamic_duration': Expected type object, got bool
|
||||||
|
|
||||||
|
and flagged news degraded. The settings form already reads such a boolean as
|
||||||
|
``{"enabled": <bool>}`` (``render_nested_section`` in plugin_config.html); the
|
||||||
|
loader did not. Both now follow ``legacy_bool_as_object``, and the parity tests
|
||||||
|
below render the real macro against it so the two cannot drift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
from src.plugin_system.schema_manager import (
|
||||||
|
legacy_bool_as_object,
|
||||||
|
normalize_legacy_booleans,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DYNAMIC_DURATION = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean", "default": True},
|
||||||
|
"min_duration_seconds": {"type": "integer", "default": 30,
|
||||||
|
"minimum": 10, "maximum": 300},
|
||||||
|
"max_duration_seconds": {"type": "integer", "default": 300,
|
||||||
|
"minimum": 30, "maximum": 600},
|
||||||
|
"buffer_ratio": {"type": "number", "default": 0.1,
|
||||||
|
"minimum": 0.01, "maximum": 1.0},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The shape of news' schema, cut down to what the tests need.
|
||||||
|
NEWS_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean", "default": True},
|
||||||
|
"global": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"font_size": {"type": "integer", "default": 12},
|
||||||
|
"dynamic_duration": DYNAMIC_DURATION,
|
||||||
|
"display": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"scroll_speed": {"type": "number", "default": 1.0},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
"headline_paging": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean", "default": False},
|
||||||
|
"page_hold_seconds": {"type": "number", "default": 2.0},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
"feeds": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"custom_feeds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"enabled": {"type": "boolean", "default": True},
|
||||||
|
"schedule": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"enabled": {"type": "boolean"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# The helper
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLegacyBoolAsObject:
|
||||||
|
def test_true_under_an_enabled_object_becomes_its_switch(self):
|
||||||
|
assert legacy_bool_as_object(True, DYNAMIC_DURATION) == {"enabled": True}
|
||||||
|
|
||||||
|
def test_false_stays_off(self):
|
||||||
|
"""Upgrading must never turn a feature the user switched off back on."""
|
||||||
|
assert legacy_bool_as_object(False, DYNAMIC_DURATION) == {"enabled": False}
|
||||||
|
|
||||||
|
def test_nullable_object_type_list_counts_as_object(self):
|
||||||
|
prop = dict(DYNAMIC_DURATION, type=["object", "null"])
|
||||||
|
assert legacy_bool_as_object(True, prop) == {"enabled": True}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [1, 0, "true", "false", None, [], 2.5])
|
||||||
|
def test_a_value_that_is_not_a_real_bool_is_untouched(self, value):
|
||||||
|
assert legacy_bool_as_object(value, DYNAMIC_DURATION) is value
|
||||||
|
|
||||||
|
def test_an_object_without_enabled_is_untouched(self):
|
||||||
|
prop = {"type": "object", "properties": {"speed": {"type": "number"}}}
|
||||||
|
assert legacy_bool_as_object(True, prop) is True
|
||||||
|
|
||||||
|
def test_a_boolean_or_object_union_is_untouched(self):
|
||||||
|
"""The form draws ``["boolean", "object"]`` as a checkbox, and the
|
||||||
|
schema accepts the boolean, so there is nothing to upgrade."""
|
||||||
|
prop = dict(DYNAMIC_DURATION, type=["boolean", "object"])
|
||||||
|
assert legacy_bool_as_object(True, prop) is True
|
||||||
|
|
||||||
|
def test_a_non_object_schema_is_untouched(self):
|
||||||
|
assert legacy_bool_as_object(True, {"type": "boolean"}) is True
|
||||||
|
assert legacy_bool_as_object(True, None) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeLegacyBooleans:
|
||||||
|
def test_top_level(self):
|
||||||
|
schema = {"type": "object",
|
||||||
|
"properties": {"dynamic_duration": DYNAMIC_DURATION}}
|
||||||
|
out = normalize_legacy_booleans({"dynamic_duration": True}, schema)
|
||||||
|
assert out == {"dynamic_duration": {"enabled": True}}
|
||||||
|
|
||||||
|
def test_nested(self):
|
||||||
|
cfg = {"global": {"font_size": 12, "dynamic_duration": True}}
|
||||||
|
out = normalize_legacy_booleans(cfg, NEWS_SCHEMA)
|
||||||
|
assert out == {"global": {"font_size": 12,
|
||||||
|
"dynamic_duration": {"enabled": True}}}
|
||||||
|
|
||||||
|
def test_several_depths_report_their_paths(self):
|
||||||
|
schema = {"type": "object", "properties": {
|
||||||
|
"dynamic_duration": DYNAMIC_DURATION,
|
||||||
|
"outer": {"type": "object", "properties": {
|
||||||
|
"inner": {"type": "object", "properties": {
|
||||||
|
"mode": DYNAMIC_DURATION}}}},
|
||||||
|
}}
|
||||||
|
cfg = {"dynamic_duration": False, "outer": {"inner": {"mode": True}}}
|
||||||
|
changed = []
|
||||||
|
out = normalize_legacy_booleans(cfg, schema, changed)
|
||||||
|
assert out == {"dynamic_duration": {"enabled": False},
|
||||||
|
"outer": {"inner": {"mode": {"enabled": True}}}}
|
||||||
|
assert changed == ["dynamic_duration", "outer.inner.mode"]
|
||||||
|
|
||||||
|
def test_array_items_are_left_alone_as_the_form_does(self):
|
||||||
|
"""The form hands arrays to widgets and never upgrades inside them."""
|
||||||
|
cfg = {"feeds": {"custom_feeds": [{"name": "a", "schedule": True}]}}
|
||||||
|
out = normalize_legacy_booleans(cfg, NEWS_SCHEMA)
|
||||||
|
assert out is cfg
|
||||||
|
|
||||||
|
def test_already_an_object_is_untouched(self):
|
||||||
|
cfg = {"global": {"dynamic_duration": {"enabled": False,
|
||||||
|
"min_duration_seconds": 45}}}
|
||||||
|
assert normalize_legacy_booleans(cfg, NEWS_SCHEMA) is cfg
|
||||||
|
|
||||||
|
def test_other_mismatches_are_untouched(self):
|
||||||
|
cfg = {"global": {"font_size": True, "display": True,
|
||||||
|
"dynamic_duration": "yes"}}
|
||||||
|
changed = []
|
||||||
|
assert normalize_legacy_booleans(cfg, NEWS_SCHEMA, changed) is cfg
|
||||||
|
assert changed == []
|
||||||
|
|
||||||
|
def test_keys_the_schema_does_not_declare_are_untouched(self):
|
||||||
|
cfg = {"global": {"mystery": True}, "vegas_width_pct": 50}
|
||||||
|
assert normalize_legacy_booleans(cfg, NEWS_SCHEMA) is cfg
|
||||||
|
|
||||||
|
def test_the_stored_config_is_not_mutated(self):
|
||||||
|
"""load_config() may hand back a cached dict; it must stay as stored."""
|
||||||
|
cfg = {"enabled": True, "global": {"dynamic_duration": True}}
|
||||||
|
before = json.loads(json.dumps(cfg))
|
||||||
|
out = normalize_legacy_booleans(cfg, NEWS_SCHEMA)
|
||||||
|
assert cfg == before
|
||||||
|
assert out is not cfg and out["global"] is not cfg["global"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("config,schema", [
|
||||||
|
(None, NEWS_SCHEMA), ([], NEWS_SCHEMA), ({"a": True}, None),
|
||||||
|
({"a": True}, {"type": "object"}),
|
||||||
|
])
|
||||||
|
def test_tolerates_odd_inputs(self, config, schema):
|
||||||
|
assert normalize_legacy_booleans(config, schema) is config
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# The load path
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PLUGIN_ID = "news-shaped"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def load(tmp_path):
|
||||||
|
"""Run the real ``PluginManager.load_plugin`` for a plugin with
|
||||||
|
``NEWS_SCHEMA`` and the given stored section; the plugin class is stubbed.
|
||||||
|
|
||||||
|
Returns ``(loaded, config the plugin received, logger, health_tracker)``.
|
||||||
|
"""
|
||||||
|
plugins_dir = tmp_path / "plugins"
|
||||||
|
plugin_dir = plugins_dir / PLUGIN_ID
|
||||||
|
plugin_dir.mkdir(parents=True)
|
||||||
|
manifest = {"id": PLUGIN_ID, "name": "News-shaped", "version": "1.0.0",
|
||||||
|
"entry_point": "manager.py", "class_name": "Plugin"}
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
(plugin_dir / "config_schema.json").write_text(json.dumps(NEWS_SCHEMA),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
def _load(stored_section):
|
||||||
|
config_manager = MagicMock()
|
||||||
|
config_manager.load_config.return_value = {
|
||||||
|
PLUGIN_ID: json.loads(json.dumps(stored_section))}
|
||||||
|
with patch('src.common.permission_utils.ensure_directory_permissions'):
|
||||||
|
manager = PluginManager(plugins_dir=str(plugins_dir),
|
||||||
|
config_manager=config_manager,
|
||||||
|
display_manager=MagicMock(),
|
||||||
|
cache_manager=MagicMock())
|
||||||
|
manager.logger = MagicMock()
|
||||||
|
manager.health_tracker = MagicMock()
|
||||||
|
manager.plugin_manifests[PLUGIN_ID] = manifest
|
||||||
|
manager.plugin_loader.find_plugin_directory = MagicMock(return_value=plugin_dir)
|
||||||
|
|
||||||
|
received = {}
|
||||||
|
|
||||||
|
def fake_load_plugin(**kwargs):
|
||||||
|
received.update(kwargs["config"])
|
||||||
|
instance = MagicMock()
|
||||||
|
instance.validate_config.return_value = True
|
||||||
|
return instance, MagicMock()
|
||||||
|
|
||||||
|
manager.plugin_loader.load_plugin = MagicMock(side_effect=fake_load_plugin)
|
||||||
|
loaded = manager.load_plugin(PLUGIN_ID)
|
||||||
|
return loaded, received, manager.logger, manager.health_tracker
|
||||||
|
|
||||||
|
return _load
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_warnings(logger):
|
||||||
|
return [c for c in logger.warning.call_args_list
|
||||||
|
if "does not match its schema" in str(c.args[0])]
|
||||||
|
|
||||||
|
|
||||||
|
def _degraded_reason(health_tracker):
|
||||||
|
calls = [c.args for c in health_tracker.set_degraded.call_args_list
|
||||||
|
if c.args[0] == PLUGIN_ID]
|
||||||
|
assert calls, "schema validation never ran"
|
||||||
|
return calls[-1][1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadPath:
|
||||||
|
def test_news_shaped_legacy_config_loads_without_warning_or_degraded(self, load):
|
||||||
|
stored = {"enabled": True,
|
||||||
|
"global": {"font_size": 12, "dynamic_duration": True,
|
||||||
|
"headline_paging": {"enabled": False}}}
|
||||||
|
loaded, received, logger, tracker = load(stored)
|
||||||
|
|
||||||
|
assert loaded is True
|
||||||
|
assert _schema_warnings(logger) == []
|
||||||
|
assert _degraded_reason(tracker) is None
|
||||||
|
# The plugin gets the object, with the schema defaults filled in.
|
||||||
|
assert received["global"]["dynamic_duration"] == {
|
||||||
|
"enabled": True, "min_duration_seconds": 30,
|
||||||
|
"max_duration_seconds": 300, "buffer_ratio": 0.1}
|
||||||
|
# And the upgrade is logged once, below warning level.
|
||||||
|
assert any("global.dynamic_duration" in str(c.args)
|
||||||
|
for c in logger.info.call_args_list)
|
||||||
|
|
||||||
|
def test_a_legacy_false_loads_switched_off(self, load):
|
||||||
|
loaded, received, logger, tracker = load(
|
||||||
|
{"enabled": True, "global": {"dynamic_duration": False}})
|
||||||
|
assert loaded is True
|
||||||
|
assert received["global"]["dynamic_duration"]["enabled"] is False
|
||||||
|
assert _schema_warnings(logger) == []
|
||||||
|
assert _degraded_reason(tracker) is None
|
||||||
|
|
||||||
|
def test_a_real_mismatch_still_warns_and_degrades(self, load):
|
||||||
|
loaded, received, logger, tracker = load(
|
||||||
|
{"enabled": True, "global": {"dynamic_duration": True,
|
||||||
|
"font_size": "big"}})
|
||||||
|
assert loaded is True # warn-only, as before
|
||||||
|
warnings = _schema_warnings(logger)
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "font_size" in str(warnings[0].args)
|
||||||
|
assert "dynamic_duration" not in str(warnings[0].args)
|
||||||
|
reason = _degraded_reason(tracker)
|
||||||
|
assert reason and "font_size" in reason
|
||||||
|
|
||||||
|
def test_a_boolean_under_an_object_without_enabled_still_warns(self, load):
|
||||||
|
loaded, received, logger, tracker = load(
|
||||||
|
{"enabled": True, "global": {"display": True}})
|
||||||
|
assert loaded is True
|
||||||
|
assert received["global"]["display"] is True
|
||||||
|
warnings = _schema_warnings(logger)
|
||||||
|
assert len(warnings) == 1 and "global.display" in str(warnings[0].args)
|
||||||
|
assert _degraded_reason(tracker)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Parity with the settings form
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PARITY_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean", "default": True},
|
||||||
|
"global": {"type": "object", "properties": {
|
||||||
|
"dynamic_duration": DYNAMIC_DURATION,
|
||||||
|
"outer": {"type": "object", "properties": {
|
||||||
|
"inner": {"type": "object", "properties": {
|
||||||
|
"enabled": {"type": "boolean", "default": True},
|
||||||
|
"level": {"type": "integer", "default": 1},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
"union": {"type": ["boolean", "object"], "properties": {
|
||||||
|
"enabled": {"type": "boolean"}}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _Checkboxes(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.checked = {}
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
a = dict(attrs)
|
||||||
|
if tag == "input" and a.get("type") == "checkbox" and a.get("name"):
|
||||||
|
self.checked[a["name"]] = "checked" in a
|
||||||
|
|
||||||
|
|
||||||
|
def _form_checkboxes(stored):
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
|
||||||
|
autoescape=select_autoescape(["html"]),
|
||||||
|
)
|
||||||
|
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
|
||||||
|
"author": "me", "version": "1.0.0"}
|
||||||
|
html = env.get_template("v3/partials/plugin_config.html").render(
|
||||||
|
plugin=plugin, schema=PARITY_SCHEMA, config=json.loads(json.dumps(stored)))
|
||||||
|
parser = _Checkboxes()
|
||||||
|
parser.feed(html)
|
||||||
|
return parser.checked
|
||||||
|
|
||||||
|
|
||||||
|
def _loader_enabled(normalized, *path):
|
||||||
|
node = normalized
|
||||||
|
for key in path:
|
||||||
|
node = node.get(key) if isinstance(node, dict) else None
|
||||||
|
return isinstance(node, dict) and node.get("enabled") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [True, False, 1, "true", None])
|
||||||
|
def test_form_and_loader_agree_on_what_a_stored_value_means(value):
|
||||||
|
"""Where the loader reads a stored value as ``{"enabled": true}``, the form
|
||||||
|
draws the object's ``enabled`` checkbox ticked, and nowhere else."""
|
||||||
|
stored = {"enabled": True,
|
||||||
|
"global": {"dynamic_duration": value, "outer": {"inner": value}}}
|
||||||
|
normalized = normalize_legacy_booleans(stored, PARITY_SCHEMA)
|
||||||
|
boxes = _form_checkboxes(stored)
|
||||||
|
|
||||||
|
for path in (("global", "dynamic_duration"), ("global", "outer", "inner")):
|
||||||
|
name = ".".join(path) + ".enabled"
|
||||||
|
assert name in boxes, f"form drew no {name} checkbox"
|
||||||
|
assert boxes[name] is _loader_enabled(normalized, *path), name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [True, False])
|
||||||
|
def test_form_and_loader_agree_a_boolean_union_stays_a_boolean(value):
|
||||||
|
stored = {"enabled": True, "global": {"union": value}}
|
||||||
|
normalized = normalize_legacy_booleans(stored, PARITY_SCHEMA)
|
||||||
|
boxes = _form_checkboxes(stored)
|
||||||
|
|
||||||
|
assert normalized is stored
|
||||||
|
assert boxes.get("global.union") is value
|
||||||
|
assert "global.union.enabled" not in boxes
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""The Pi 5 rule in src/pi5_matrix_support.py mirrors the pinned library.
|
||||||
|
|
||||||
|
Rp1PioPlatformDetected() and Rp1PioConfigSupported() in
|
||||||
|
rpi-rgb-led-matrix-master/lib/rp1/rp1_pio_backend.cc (commit 1ee4f76). When the
|
||||||
|
submodule is bumped and those change, these are the cases to revisit.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src import pi5_matrix_support as pi5 # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def model_file(tmp_path, monkeypatch):
|
||||||
|
def write(model):
|
||||||
|
path = tmp_path / 'model'
|
||||||
|
# The device tree string is NUL-terminated.
|
||||||
|
path.write_bytes(model.encode() + b'\x00')
|
||||||
|
monkeypatch.setattr(pi5, 'MODEL_PATH', str(path))
|
||||||
|
return write
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('model', [
|
||||||
|
'Raspberry Pi 5 Model B Rev 1.0',
|
||||||
|
'Raspberry Pi 500 Rev 1.0',
|
||||||
|
'Raspberry Pi Compute Module 5 Rev 1.0',
|
||||||
|
])
|
||||||
|
def test_pi5_family_is_detected(model_file, model):
|
||||||
|
model_file(model)
|
||||||
|
assert pi5.is_raspberry_pi_5()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('model', [
|
||||||
|
'Raspberry Pi 4 Model B Rev 1.5',
|
||||||
|
'Raspberry Pi Zero 2 W Rev 1.0',
|
||||||
|
'Raspberry Pi Compute Module 4 Rev 1.0',
|
||||||
|
])
|
||||||
|
def test_other_boards_are_not(model_file, model):
|
||||||
|
model_file(model)
|
||||||
|
assert not pi5.is_raspberry_pi_5()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_device_tree_is_not_a_pi5(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(pi5, 'MODEL_PATH', str(tmp_path / 'missing'))
|
||||||
|
assert not pi5.is_raspberry_pi_5()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('hardware', [
|
||||||
|
{},
|
||||||
|
{'row_address_type': 0}, {'row_address_type': 2}, {'row_address_type': '2'},
|
||||||
|
{'parallel': 1}, {'parallel': 3},
|
||||||
|
{'hardware_mapping': 'regular'}, {'hardware_mapping': 'regular-pi1'},
|
||||||
|
{'hardware_mapping': 'classic'}, {'hardware_mapping': 'adafruit-hat'},
|
||||||
|
{'hardware_mapping': 'adafruit-hat-pwm'}, {'hardware_mapping': ''},
|
||||||
|
])
|
||||||
|
def test_what_the_pi5_path_supports(hardware):
|
||||||
|
assert pi5.pi5_unsupported_settings(hardware) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('hardware,named', [
|
||||||
|
({'row_address_type': 1}, 'row address type 1'),
|
||||||
|
({'row_address_type': 3}, 'row address type 3'),
|
||||||
|
({'row_address_type': 4}, 'row address type 4'),
|
||||||
|
({'row_address_type': '5'}, 'row address type 5'),
|
||||||
|
({'parallel': 4}, 'parallel 4'),
|
||||||
|
({'hardware_mapping': 'compute-module'}, 'hardware mapping "compute-module"'),
|
||||||
|
])
|
||||||
|
def test_what_it_does_not(hardware, named):
|
||||||
|
message = pi5.pi5_unsupported_settings(hardware)
|
||||||
|
assert message is not None and named in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_problem_is_named():
|
||||||
|
message = pi5.pi5_unsupported_settings({'row_address_type': 5, 'parallel': 4})
|
||||||
|
assert 'row address type 5' in message and 'parallel 4' in message
|
||||||
@@ -142,8 +142,14 @@ class TestStaleFindingsAreDropped:
|
|||||||
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
|
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
|
||||||
|
|
||||||
def test_genuinely_missing_on_disk_is_kept(self):
|
def test_genuinely_missing_on_disk_is_kept(self):
|
||||||
entries = [{"plugin_id": "data", "type": self.ON_DISK}]
|
entries = [{"plugin_id": "ghost-plugin", "type": self.ON_DISK}]
|
||||||
assert still_unresolved(entries, set(), {"odds-ticker"}) == entries
|
assert still_unresolved(entries, {"ghost-plugin"}, {"odds-ticker"}) == entries
|
||||||
|
|
||||||
|
def test_missing_on_disk_no_longer_in_config_is_dropped(self):
|
||||||
|
# Removed from config.json (or never a plugin entry at all, like a
|
||||||
|
# core key an older build misread): nothing left to reinstall.
|
||||||
|
entries = [{"plugin_id": "ghost-plugin", "type": self.ON_DISK}]
|
||||||
|
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
|
||||||
|
|
||||||
def test_unrecheckable_kinds_are_kept(self):
|
def test_unrecheckable_kinds_are_kept(self):
|
||||||
# Filtering must only ever remove what it can prove stale.
|
# Filtering must only ever remove what it can prove stale.
|
||||||
@@ -164,7 +170,6 @@ class TestStaleFindingsAreDropped:
|
|||||||
live = still_unresolved(entries, installed, installed)
|
live = still_unresolved(entries, installed, installed)
|
||||||
|
|
||||||
# All four "installed but not in config" findings were false and clear.
|
# All four "installed but not in config" findings were false and clear.
|
||||||
# 'data' legitimately is not installed, so this filter keeps it; it stops
|
# 'data' is not a plugin entry in config (it came from the secrets
|
||||||
# being reported because _get_config_state() no longer invents it from the
|
# file), so its "not on disk" finding is provably stale and clears too.
|
||||||
# secrets file, which takes effect on the next reconciliation run.
|
assert live == []
|
||||||
assert [e["plugin_id"] for e in live] == ["data"]
|
|
||||||
|
|||||||
@@ -94,9 +94,16 @@ class TestStaleFindingsAreDroppedByTheEndpoint:
|
|||||||
|
|
||||||
def test_a_finding_that_still_holds_is_kept(self, client):
|
def test_a_finding_that_still_holds_is_kept(self, client):
|
||||||
c = client({"done": True, "unresolved": [
|
c = client({"done": True, "unresolved": [
|
||||||
{"plugin_id": "ghost-plugin", "type": ON_DISK}]})
|
{"plugin_id": "ghost-plugin", "type": ON_DISK}]},
|
||||||
|
config={"ghost-plugin": {"enabled": True}})
|
||||||
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
|
||||||
|
|
||||||
|
def test_a_plugin_removed_from_config_is_no_longer_reported(self, client):
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "ghost-plugin", "type": ON_DISK}]},
|
||||||
|
config={"other-plugin": {"enabled": True}})
|
||||||
|
assert _get(c)["unresolved"] == []
|
||||||
|
|
||||||
def test_the_reported_device_verdict_clears(self, client):
|
def test_the_reported_device_verdict_clears(self, client):
|
||||||
"""The five findings the live device served, against its real state."""
|
"""The five findings the live device served, against its real state."""
|
||||||
installed = ["football-scoreboard", "ledmatrix-weather",
|
installed = ["football-scoreboard", "ledmatrix-weather",
|
||||||
@@ -157,11 +164,13 @@ class TestTheFilterUsesTheReconcilersOwnRules:
|
|||||||
# finding on the strength of something nothing can read.
|
# finding on the strength of something nothing can read.
|
||||||
c = client({"done": True, "unresolved": [
|
c = client({"done": True, "unresolved": [
|
||||||
{"plugin_id": "broken-plugin", "type": ON_DISK}]},
|
{"plugin_id": "broken-plugin", "type": ON_DISK}]},
|
||||||
|
config={"broken-plugin": {"enabled": True}},
|
||||||
corrupt=["broken-plugin"])
|
corrupt=["broken-plugin"])
|
||||||
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"]
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"]
|
||||||
|
|
||||||
def test_a_standalone_backup_dir_does_not_count_as_installed(self, client):
|
def test_a_standalone_backup_dir_does_not_count_as_installed(self, client):
|
||||||
c = client({"done": True, "unresolved": [
|
c = client({"done": True, "unresolved": [
|
||||||
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
|
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
|
||||||
|
config={"weather.standalone-backup-20260101": {"enabled": True}},
|
||||||
installed=["weather.standalone-backup-20260101"])
|
installed=["weather.standalone-backup-20260101"])
|
||||||
assert len(_get(c)["unresolved"]) == 1
|
assert len(_get(c)["unresolved"]) == 1
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""'Check & Update All' must only send ids POST /plugins/update handles.
|
||||||
|
|
||||||
|
Seen on a device running core 3.4.0: update-all posted every entry from
|
||||||
|
/plugins/installed, including the virtual `starlark:<app_id>` entries that
|
||||||
|
list installed Starlark apps, and the route answered each with a 500
|
||||||
|
"Plugin update failed: plugin not found". A web-service restart during the
|
||||||
|
same run also cost stock-news its update: its request was refused while the
|
||||||
|
service came back, and update-all never sent it again.
|
||||||
|
|
||||||
|
The id selection and retry live in install_manager.js and are covered by
|
||||||
|
test/js/unit/test_update_all.js, which this module runs so CI sees it. The
|
||||||
|
route contract is tested here directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
JS_SUITE = REPO / 'test' / 'js' / 'unit' / 'test_update_all.js'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
from web_interface.app import app
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path):
|
||||||
|
"""A store manager that records calls; no git, no network."""
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
sm = MagicMock()
|
||||||
|
sm.plugins_dir = str(tmp_path)
|
||||||
|
sm._get_local_git_info.return_value = None
|
||||||
|
sm.get_plugin_info.return_value = None
|
||||||
|
sm.update_plugin.return_value = True
|
||||||
|
with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \
|
||||||
|
patch.object(api_v3, 'plugin_manager', None, create=True), \
|
||||||
|
patch.object(api_v3, 'schema_manager', None, create=True), \
|
||||||
|
patch.object(api_v3, 'plugin_state_manager', None, create=True), \
|
||||||
|
patch.object(api_v3, 'operation_history', None, create=True):
|
||||||
|
yield sm
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateRouteRejectsStarlarkIds:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('app_id', ['starlark:analogtime', 'starlark:analogclock'])
|
||||||
|
def test_a_starlark_id_is_a_400_not_a_500(self, client, store, app_id):
|
||||||
|
resp = client.post('/api/v3/plugins/update', json={'plugin_id': app_id})
|
||||||
|
assert resp.status_code == 400, resp.get_json()
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body['status'] == 'error'
|
||||||
|
assert body['error_code'] == 'INVALID_INPUT'
|
||||||
|
assert 'Starlark app' in body['message'], body
|
||||||
|
assert 'not found' not in body['message'], \
|
||||||
|
"the app is installed; 'not found' is the misleading message this replaces"
|
||||||
|
|
||||||
|
def test_the_store_manager_is_never_asked(self, client, store):
|
||||||
|
client.post('/api/v3/plugins/update', json={'plugin_id': 'starlark:analogtime'})
|
||||||
|
store.update_plugin.assert_not_called()
|
||||||
|
|
||||||
|
def test_form_encoded_starlark_id_is_rejected_the_same_way(self, client, store):
|
||||||
|
resp = client.post('/api/v3/plugins/update', data={'plugin_id': 'starlark:analogtime'})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_a_plugin_id_still_reaches_the_updater(self, client, store, tmp_path):
|
||||||
|
# The guard is on the 'starlark:' prefix only: the starlark-apps plugin
|
||||||
|
# and a disabled plugin with an update are still updated.
|
||||||
|
for pid in ('stock-news', 'starlark-apps'):
|
||||||
|
(tmp_path / pid).mkdir()
|
||||||
|
(tmp_path / pid / 'manifest.json').write_text('{"id": "%s"}' % pid, encoding='utf-8')
|
||||||
|
resp = client.post('/api/v3/plugins/update', json={'plugin_id': pid})
|
||||||
|
assert resp.status_code == 200, (pid, resp.get_json())
|
||||||
|
assert [c.args[0] for c in store.update_plugin.call_args_list] == ['stock-news', 'starlark-apps']
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstalledListContract:
|
||||||
|
"""What update-all filters on is what /plugins/installed publishes."""
|
||||||
|
|
||||||
|
def test_starlark_entries_are_flagged_and_prefixed(self, client):
|
||||||
|
apps = {'apps': {'analogtime': {'name': 'Analog Time', 'enabled': False}}}
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=apps):
|
||||||
|
resp = client.get('/api/v3/plugins/installed')
|
||||||
|
plugins = resp.get_json()['data']['plugins']
|
||||||
|
entry = next(p for p in plugins if p['id'] == 'starlark:analogtime')
|
||||||
|
assert entry['is_starlark_app'] is True
|
||||||
|
assert not any(p.get('is_starlark_app') for p in plugins
|
||||||
|
if not p['id'].startswith('starlark:')), \
|
||||||
|
"a real plugin carries the Starlark flag and would be dropped from update-all"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(shutil.which('node') is None, reason='node is not installed')
|
||||||
|
def test_update_all_js_selection_and_retry():
|
||||||
|
node = shutil.which('node')
|
||||||
|
result = subprocess.run([node, str(JS_SUITE)], capture_output=True, text=True,
|
||||||
|
timeout=120, cwd=str(JS_SUITE.parent))
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
@@ -12,6 +12,7 @@ from web_interface.blueprints.api_v3 import (
|
|||||||
success_response,
|
success_response,
|
||||||
)
|
)
|
||||||
from src.common.path_safety import resolve_under
|
from src.common.path_safety import resolve_under
|
||||||
|
from src.pi5_matrix_support import is_raspberry_pi_5, pi5_unsupported_settings
|
||||||
import web_interface.blueprints.api_v3 as _pkg
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
# Read through the module rather than bound by value: tests patch these
|
# Read through the module rather than bound by value: tests patch these
|
||||||
# as module attributes, and a value binding would not see the patch.
|
# as module attributes, and a value binding would not see the patch.
|
||||||
@@ -606,6 +607,18 @@ def save_main_config():
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
|
# A Pi 5 can't drive every combination (src/pi5_matrix_support.py),
|
||||||
|
# and one it can't crashes the display service instead of falling
|
||||||
|
# back. Checked only when this request sets one of those fields, so
|
||||||
|
# a combination already stored doesn't block unrelated saves.
|
||||||
|
pi5_fields = ('row_address_type', 'parallel', 'hardware_mapping')
|
||||||
|
if any(k in data for k in pi5_fields) and is_raspberry_pi_5():
|
||||||
|
effective = dict(current_config['display']['hardware'])
|
||||||
|
effective.update({k: data[k] for k in pi5_fields if k in data})
|
||||||
|
unsupported = pi5_unsupported_settings(effective)
|
||||||
|
if unsupported:
|
||||||
|
return jsonify({'status': 'error', 'message': unsupported}), 400
|
||||||
|
|
||||||
# Handle hardware settings
|
# Handle hardware settings
|
||||||
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
||||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||||
|
|||||||
@@ -735,10 +735,10 @@ def _drop_stale_reconciliation_findings(unresolved):
|
|||||||
)
|
)
|
||||||
|
|
||||||
cm = api_v3.config_manager
|
cm = api_v3.config_manager
|
||||||
config_keys = config_plugin_ids(cm.load_config() or {},
|
|
||||||
ignored_config_keys(cm))
|
|
||||||
plugins_dir = getattr(api_v3.plugin_manager, 'plugins_dir', None)
|
plugins_dir = getattr(api_v3.plugin_manager, 'plugins_dir', None)
|
||||||
installed = disk_plugin_ids(plugins_dir) if plugins_dir else set()
|
installed = disk_plugin_ids(plugins_dir) if plugins_dir else set()
|
||||||
|
config_keys = config_plugin_ids(cm.load_config() or {},
|
||||||
|
ignored_config_keys(cm, installed))
|
||||||
return still_unresolved(unresolved, config_keys, installed)
|
return still_unresolved(unresolved, config_keys, installed)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("[Reconciliation] Could not re-check stored findings", exc_info=True)
|
logger.debug("[Reconciliation] Could not re-check stored findings", exc_info=True)
|
||||||
@@ -943,6 +943,19 @@ def update_plugin():
|
|||||||
)
|
)
|
||||||
data = {'plugin_id': plugin_id}
|
data = {'plugin_id': plugin_id}
|
||||||
|
|
||||||
|
# /plugins/installed lists installed Starlark apps as virtual
|
||||||
|
# 'starlark:<app_id>' entries. They are not plugin directories, so the
|
||||||
|
# store manager can only fail to find them -- which used to surface as
|
||||||
|
# a 500 "plugin not found" for an app that is installed and working.
|
||||||
|
raw_id = data.get('plugin_id')
|
||||||
|
if isinstance(raw_id, str) and raw_id.startswith('starlark:'):
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.INVALID_INPUT,
|
||||||
|
f'{raw_id} is a Starlark app, not a plugin; Starlark apps are '
|
||||||
|
'not updated through the plugin updater',
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
if not api_v3.plugin_store_manager:
|
if not api_v3.plugin_store_manager:
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCode.SYSTEM_ERROR,
|
ErrorCode.SYSTEM_ERROR,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ _SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
|||||||
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
from src.common.path_safety import resolve_under, safe_path_component
|
from src.common.path_safety import resolve_under, safe_path_component
|
||||||
|
from src.pi5_matrix_support import is_raspberry_pi_5
|
||||||
from web_interface import widget_bundle
|
from web_interface import widget_bundle
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -544,7 +545,8 @@ def _load_display_partial():
|
|||||||
if pages_v3.config_manager:
|
if pages_v3.config_manager:
|
||||||
main_config = pages_v3.config_manager.load_config()
|
main_config = pages_v3.config_manager.load_config()
|
||||||
return render_template('v3/partials/display.html',
|
return render_template('v3/partials/display.html',
|
||||||
main_config=main_config)
|
main_config=main_config,
|
||||||
|
is_pi5=is_raspberry_pi_5())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
logger.error("Error loading partial", exc_info=True)
|
||||||
return "Error loading partial", 500
|
return "Error loading partial", 500
|
||||||
|
|||||||
@@ -2421,7 +2421,10 @@
|
|||||||
|
|
||||||
async function updateAllPlugins() {
|
async function updateAllPlugins() {
|
||||||
try {
|
try {
|
||||||
const plugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
|
// Starlark apps are listed as virtual 'starlark:<id>' entries;
|
||||||
|
// /plugins/update does not handle them (see install_manager.js).
|
||||||
|
const plugins = (Array.isArray(window.installedPlugins) ? window.installedPlugins : [])
|
||||||
|
.filter(p => p && typeof p.id === 'string' && !p.is_starlark_app && !p.id.startsWith('starlark:'));
|
||||||
|
|
||||||
if (!plugins.length) {
|
if (!plugins.length) {
|
||||||
showNotification('No installed plugins to update.', 'warning');
|
showNotification('No installed plugins to update.', 'warning');
|
||||||
|
|||||||
@@ -78,33 +78,87 @@ const PluginInstallManager = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether POST /plugins/update can update this installed-list entry.
|
||||||
|
*
|
||||||
|
* /plugins/installed also lists installed Starlark apps as virtual
|
||||||
|
* `starlark:<app_id>` entries (flagged `is_starlark_app`) so they can be
|
||||||
|
* seen and toggled with everything else. They are not plugin directories:
|
||||||
|
* the plugin updater has nothing to update for them, and there is no
|
||||||
|
* Starlark update route (reinstalling from the repository would reset the
|
||||||
|
* app's saved settings), so they are left out of update-all.
|
||||||
|
*
|
||||||
|
* @param {Object} plugin - Entry from /plugins/installed
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isUpdatablePlugin(plugin) {
|
||||||
|
if (!plugin || typeof plugin.id !== 'string' || !plugin.id) return false;
|
||||||
|
return !plugin.is_starlark_app && !plugin.id.startsWith('starlark:');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entries update-all sends to POST /plugins/update, in list order.
|
||||||
|
*
|
||||||
|
* @param {Array} plugins - Entries from /plugins/installed
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
updatablePlugins(plugins) {
|
||||||
|
return (Array.isArray(plugins) ? plugins : []).filter(p => this.isUpdatablePlugin(p));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoff (ms) before re-sending an update whose request never got an
|
||||||
|
* answer. Covers a web-service restart (~3s on a Pi) with room to spare.
|
||||||
|
*/
|
||||||
|
NETWORK_RETRY_DELAYS_MS: [1000, 2000, 4000, 8000, 15000],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update all plugins.
|
* Update all plugins.
|
||||||
*
|
*
|
||||||
* @param {Function} onProgress - Optional callback(index, total, pluginId) for progress updates
|
* @param {Function} onProgress - Optional callback(index, total, pluginId) for progress updates
|
||||||
* @returns {Promise<Array>} Update results
|
* @param {Object} options - Optional { sleep(ms), retryDelaysMs } (tests inject these)
|
||||||
|
* @returns {Promise<Array>} Update results, one per plugin sent
|
||||||
*/
|
*/
|
||||||
async updateAll(onProgress) {
|
async updateAll(onProgress, options = {}) {
|
||||||
// Prefer PluginStateManager if populated, fall back to window.installedPlugins
|
// Prefer PluginStateManager if populated, fall back to window.installedPlugins
|
||||||
// (plugins_manager.js populates window.installedPlugins independently)
|
// (plugins_manager.js populates window.installedPlugins independently)
|
||||||
const stateManagerPlugins = window.PluginStateManager && window.PluginStateManager.installedPlugins;
|
const stateManagerPlugins = window.PluginStateManager && window.PluginStateManager.installedPlugins;
|
||||||
const plugins = (stateManagerPlugins && stateManagerPlugins.length > 0)
|
const listed = (stateManagerPlugins && stateManagerPlugins.length > 0)
|
||||||
? stateManagerPlugins
|
? stateManagerPlugins
|
||||||
: (window.installedPlugins || []);
|
: (window.installedPlugins || []);
|
||||||
|
// Snapshot: the list can be replaced while this loop is awaiting.
|
||||||
|
const plugins = this.updatablePlugins(listed);
|
||||||
|
|
||||||
if (!plugins.length) {
|
if (!plugins.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
||||||
|
const retryDelays = options.retryDelaysMs || this.NETWORK_RETRY_DELAYS_MS;
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (let i = 0; i < plugins.length; i++) {
|
for (let i = 0; i < plugins.length; i++) {
|
||||||
const plugin = plugins[i];
|
const plugin = plugins[i];
|
||||||
if (onProgress) onProgress(i + 1, plugins.length, plugin.id);
|
if (onProgress) onProgress(i + 1, plugins.length, plugin.id);
|
||||||
try {
|
// Each plugin gets its own pass over the backoff schedule.
|
||||||
const result = await window.PluginAPI.updatePlugin(plugin.id);
|
const pendingDelays = retryDelays.slice();
|
||||||
results.push({ pluginId: plugin.id, success: true, result });
|
for (;;) {
|
||||||
} catch (error) {
|
try {
|
||||||
results.push({ pluginId: plugin.id, success: false, error });
|
const result = await window.PluginAPI.updatePlugin(plugin.id);
|
||||||
|
results.push({ pluginId: plugin.id, success: true, result });
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
// No HTTP answer at all (connection refused/reset, e.g. the
|
||||||
|
// web service restarting mid-run): the server never saw or
|
||||||
|
// never finished this plugin, so send it again once it is
|
||||||
|
// back rather than skipping it. An HTTP error response is
|
||||||
|
// the server's answer and is not retried.
|
||||||
|
if (error && error.error_code === 'NETWORK_ERROR' && pendingDelays.length > 0) {
|
||||||
|
await sleep(pendingDelays.shift());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push({ pluginId: plugin.id, success: false, error });
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,15 +196,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-row_address_type" data-setting-key="display.hardware.row_address_type">
|
<div class="form-group" id="setting-display-row_address_type" data-setting-key="display.hardware.row_address_type">
|
||||||
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type{{ ui.help_tip('Row addressing scheme used by the panel.\nLeave at 0 (Default) unless your panel needs AB/ABC addressing — a wrong value shows a garbled or shifted image.\nABC panels (no E line, common on 128x64 FM6124 boards): try 3. Panels with SM5368 row drivers, such as the Waveshare 96x48 V2 (back silkscreen 24S-A1), need 5 with RGB sequence BGR. If rows then jump up and down, or the bottom row shows a copy of other rows, raise GPIO Slowdown (6–8 on a Pi 4). On a Raspberry Pi 5 the rgbmatrix library supports only 0 and 2.', 'Row Address Type') }}</label>
|
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type{{ ui.help_tip('Row addressing scheme used by the panel.\nLeave at 0 (Default) unless your panel needs AB/ABC addressing — a wrong value shows a garbled or shifted image.\nABC panels (no E line, common on 128x64 FM6124 boards): try 3. Panels with SM5368 row drivers, such as the Waveshare 96x48 V2 (back silkscreen 24S-A1), need 5 with RGB sequence BGR. If rows then jump up and down, or the bottom row shows a copy of other rows, raise GPIO Slowdown (6–8 on a Pi 4). On a Raspberry Pi 5 the rgbmatrix library supports only 0 and 2, so only those are offered there.', 'Row Address Type') }}</label>
|
||||||
|
{% set stored_row_address_type = main_config.display.hardware.get('row_address_type', 0)|int %}
|
||||||
<select id="row_address_type" name="row_address_type" class="form-control">
|
<select id="row_address_type" name="row_address_type" class="form-control">
|
||||||
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option>
|
<option value="0" {% if stored_row_address_type == 0 %}selected{% endif %}>0 - Default</option>
|
||||||
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
|
{% if not is_pi5 %}
|
||||||
<option value="2" {% if main_config.display.hardware.get('row_address_type', 0)|int == 2 %}selected{% endif %}>2 - Row direct</option>
|
<option value="1" {% if stored_row_address_type == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
|
||||||
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
|
{% endif %}
|
||||||
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
|
<option value="2" {% if stored_row_address_type == 2 %}selected{% endif %}>2 - Row direct</option>
|
||||||
<option value="5" {% if main_config.display.hardware.get('row_address_type', 0)|int == 5 %}selected{% endif %}>5 - SM5368 / B707 row shift register</option>
|
{% if not is_pi5 %}
|
||||||
|
<option value="3" {% if stored_row_address_type == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
|
||||||
|
<option value="4" {% if stored_row_address_type == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
|
||||||
|
<option value="5" {% if stored_row_address_type == 5 %}selected{% endif %}>5 - SM5368 / B707 row shift register</option>
|
||||||
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
|
{% if is_pi5 and stored_row_address_type not in (0, 2) %}
|
||||||
|
<p class="text-sm text-red-600 mt-1">Your saved row address type ({{ stored_row_address_type }}) can't be used on this Raspberry Pi 5, so the display won't start with it. Saving this form sets it to 0.</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1166,7 +1166,11 @@
|
|||||||
TypeError that takes down the whole config page. A legacy boolean was
|
TypeError that takes down the whole config page. A legacy boolean was
|
||||||
the on/off switch the object's `enabled` now holds, so carry it over --
|
the on/off switch the object's `enabled` now holds, so carry it over --
|
||||||
rendering {} would draw that checkbox unchecked and the next save would
|
rendering {} would draw that checkbox unchecked and the next save would
|
||||||
quietly turn the feature off. The save then writes the object. #}
|
quietly turn the feature off. The save then writes the object.
|
||||||
|
The plugin loader applies the same rule before validating
|
||||||
|
(legacy_bool_as_object in src/plugin_system/schema_manager.py), and
|
||||||
|
test/test_legacy_boolean_config.py renders this macro against it:
|
||||||
|
change the two together. #}
|
||||||
{% if value is mapping %}
|
{% if value is mapping %}
|
||||||
{% set nested_value = value %}
|
{% set nested_value = value %}
|
||||||
{% elif value is boolean and 'enabled' in (prop.properties or {}) %}
|
{% elif value is boolean and 'enabled' in (prop.properties or {}) %}
|
||||||
|
|||||||
Reference in New Issue
Block a user