Merge audit16-core/configsave into the September 16 core audit fixes

# Conflicts:
#	src/plugin_system/plugin_manager.py
This commit is contained in:
Chuck
2026-09-17 09:18:17 -04:00
22 changed files with 1953 additions and 834 deletions
+38
View File
@@ -19,6 +19,44 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
Config saves and plugin config preparation:
- A JSON `POST /api/v3/config/main` changes only the keys it sends. The MQTT
bridge's brightness slider used to turn off `disable_hardware_pulsing`,
`inverse_colors`, `show_refresh_rate` and `use_short_date_format`, and a
timezone- or location-only save turned off web-UI autostart and weekly
automatic updates. Missing checkboxes still save as unchecked for the
settings forms (they now send a hidden `__form_section` field) and for
form-encoded posts.
- A partial JSON `POST /api/v3/plugins/config` merges onto the plugin's stored
settings instead of resetting everything it didn't send to the schema
defaults, and keeps a submitted `skin`, `skin_options`, `vegas_width_pct`,
`vegas_overflow` or `vegas_max_width_screens` (they were silently dropped).
- Plugin sections posted to `/config/main` are validated and prepared exactly
like `/plugins/config`; a value that endpoint rejects is rejected here too,
and nothing is saved.
- Legacy boolean settings (#588) are read as `{"enabled": ...}` objects
everywhere, not just when the plugin loads: `GET /plugins/config` returns
the object, posting it back saves, and hot reload hands plugins the same
shape (schema defaults included) they were constructed with.
`schema_manager.prepare_plugin_config` is the one implementation.
- `scripts/dev_server.py`, `check_plugin.py`, `render_plugin.py` and the plugin
harness build configs the way a device does: nested defaults are included,
a schema `enabled: false` no longer beats the forced `enabled: true` in the
dev server, and nested overrides such as `{"nhl": {"enabled": true}}` keep
the other defaults of that section.
- Clearing Vegas "Min/Max Cycle Time" no longer rejects the whole Display save,
and those fields no longer add junk entries to `display.display_durations`.
- Turning automatic updates on from the Raw JSON editor finishes their setup
like the General tab does, instead of waiting for the next display restart.
- `POST /config/schedule` and `/config/dim-schedule` accept the per-day
`days.<day>.{enabled,start_time,end_time}` shape their GETs return, as well
as the flat form keys.
- The startup check no longer warns that `auto_update` or `dim_schedule` is
"enabled but not found in plugins directory", and plugin ids that collide
with any core config section are flagged: the last private copies of the
core-key list now use `src/core_config_keys.py`.
New module a plugin may import via `src.*` (floor on the release that ships New module a plugin may import via `src.*` (floor on the release that ships
this): this):
+4 -2
View File
@@ -114,5 +114,7 @@ python3 integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py --config integrations/
discovery itself. Discovery is lazy and normally happens because somebody discovery itself. Discovery is lazy and normally happens because somebody
opened the dashboard; without that endpoint a bridge that never does would opened the dashboard; without that endpoint a bridge that never does would
see an empty list. see an empty list.
- Brightness writes `display.hardware.brightness` through `/api/v3/config/main`. - Brightness writes `display.hardware.brightness` through `/api/v3/config/main`,
The display service picks it up on its next restart, not instantly. and changes nothing else. The running display service notices the saved
config within a few seconds and applies the new brightness without a
restart (unless `LEDMATRIX_HOT_RELOAD=false`).
+16 -12
View File
@@ -142,17 +142,18 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]: def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
"""Extract default values from config_schema.json.""" """Extract default values from config_schema.json.
The same extraction a device and the plugin harness use
(src/plugin_system/testing/loading.py), nested defaults included.
"""
from src.plugin_system.testing.loading import (
load_config_defaults as _load_config_defaults,
)
schema_path = resolve_under(plugin_dir, 'config_schema.json') schema_path = resolve_under(plugin_dir, 'config_schema.json')
if schema_path is None or not schema_path.exists(): if schema_path is None or not schema_path.exists():
return {} return {}
with open(schema_path, 'r') as f: return _load_config_defaults(schema_path.parent)
schema = json.load(f)
defaults: Dict[str, Any] = {}
for key, prop in schema.get('properties', {}).items():
if 'default' in prop:
defaults[key] = prop['default']
return defaults
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -303,10 +304,13 @@ def _parse_render_request(data):
with open(manifest_path, 'r') as f: with open(manifest_path, 'r') as f:
manifest = json.load(f) manifest = json.load(f)
# Build config: schema defaults + user overrides # Build config the way a device would: schema defaults under a forced
config = {'enabled': True} # enabled, with the user's overrides deep-merged on top
config.update(load_config_defaults(trusted_dir)) from src.plugin_system.testing.loading import build_config
config.update(data.get('config', {})) overrides = data.get('config') or {}
if not isinstance(overrides, dict):
raise ValueError('config must be a JSON object')
config = build_config(trusted_dir, overrides)
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False) return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
+7 -5
View File
@@ -29,6 +29,7 @@ import os
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
from src.exceptions import ConfigError from src.exceptions import ConfigError
from src.logging_config import get_logger from src.logging_config import get_logger
from src.config_manager_atomic import ( from src.config_manager_atomic import (
@@ -756,12 +757,13 @@ class ConfigManager:
valid_set = set(valid_plugin_ids) valid_set = set(valid_plugin_ids)
# Find orphaned plugins in main config # Find orphaned plugins in main config. Core sections (display,
main_plugins = set(main_config.keys()) # schedule, auto_update, ...) are not plugins and never orphans.
main_plugins = set(main_config.keys()) - CORE_CONFIG_KEYS
orphaned_main = main_plugins - valid_set orphaned_main = main_plugins - valid_set
# Find orphaned plugins in secrets config # Find orphaned plugins in secrets config
secrets_plugins = set(secrets_config.keys()) secrets_plugins = set(secrets_config.keys()) - CORE_CONFIG_KEYS - CORE_SECRETS_KEYS
orphaned_secrets = secrets_plugins - valid_set orphaned_secrets = secrets_plugins - valid_set
all_orphaned = orphaned_main | orphaned_secrets all_orphaned = orphaned_main | orphaned_secrets
@@ -815,8 +817,8 @@ class ConfigManager:
if not isinstance(plugin_config, dict): if not isinstance(plugin_config, dict):
continue continue
# Skip non-plugin config sections # Skip core config sections
if plugin_id in ['display', 'schedule', 'timezone', 'plugin_system']: if plugin_id in CORE_CONFIG_KEYS:
continue continue
schema = plugin_schema_manager.load_schema(plugin_id, use_cache=True) schema = plugin_schema_manager.load_schema(plugin_id, use_cache=True)
+10
View File
@@ -41,3 +41,13 @@ CORE_CONFIG_KEYS = frozenset({
'vegas_excluded_plugins', 'vegas_excluded_plugins',
'vegas_scroll_enabled', 'vegas_scroll_enabled',
}) })
#: Top-level keys of ``config_secrets.json`` that belong to the core rather than
#: to a plugin: the GitHub token the Plugin Store reads, and the historical
#: ``youtube`` section. Plugin secrets are namespaced by plugin id, so anything
#: deciding whether a secrets section is a plugin's needs this as well as
#: ``CORE_CONFIG_KEYS``.
CORE_SECRETS_KEYS = frozenset({
'github',
'youtube',
})
+8
View File
@@ -2976,6 +2976,14 @@ class DisplayController:
_pid: str = plugin_id, _plugin: Any = plugin_instance) -> None: _pid: str = plugin_id, _plugin: Any = plugin_instance) -> None:
"""Callback for plugin config changes.""" """Callback for plugin config changes."""
try: try:
# ConfigService hands over the raw config.json section.
# Prepare it as loading did (legacy booleans read as
# objects, schema defaults filled in), so the plugin gets
# the same shape it was constructed with.
prepare = getattr(self.plugin_manager, 'prepare_plugin_config', None)
prepared = prepare(_pid, new_config) if callable(prepare) else None
if isinstance(prepared, dict):
new_config = prepared
_plugin.on_config_change(new_config) _plugin.on_config_change(new_config)
logger.debug("Plugin %s notified of config change", _pid) logger.debug("Plugin %s notified of config change", _pid)
except Exception as e: except Exception as e:
+57 -32
View File
@@ -22,7 +22,9 @@ 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, normalize_legacy_booleans from src.plugin_system.schema_manager import (
CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans,
)
from src.common.path_safety import safe_path_component from src.common.path_safety import safe_path_component
from src.common.permission_utils import ( from src.common.permission_utils import (
ensure_directory_permissions, ensure_directory_permissions,
@@ -370,32 +372,11 @@ 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, ...} # Legacy booleans read as objects, then schema defaults: the same
# object still finds the boolean in config.json until the user saves # preparation saves, GET /plugins/config and hot reload apply
# its settings form, which carries it over (plugin_config.html). # (prepare_plugin_config). In memory only: config.json is written
# Read it the same way here, before the defaults fill in the rest # by saves, never by loading a plugin.
# of the object and before schema validation, so the plugin doesn't config = self.prepare_plugin_config(plugin_id, config, schema=schema)
# 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
try:
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
config = self.schema_manager.merge_with_defaults(config, defaults)
self.logger.debug(f"Merged config with schema defaults for {plugin_id}")
except Exception as e:
self.logger.warning(f"Could not apply schema defaults for {plugin_id}: {e}")
# Continue with original config if defaults can't be applied
# Use PluginLoader to load plugin # Use PluginLoader to load plugin
plugin_instance, module = self.plugin_loader.load_plugin( plugin_instance, module = self.plugin_loader.load_plugin(
@@ -490,11 +471,55 @@ class PluginManager:
#: #:
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``, #: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``). #: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``).
CORE_OWNED_CONFIG_KEYS = frozenset({ #:
'vegas_width_pct', #: The list itself lives with the other core-owned per-plugin properties in
'vegas_overflow', #: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also
'vegas_max_width_screens', #: uses to keep these keys.
}) CORE_OWNED_CONFIG_KEYS = CORE_VEGAS_TUNING_KEYS
def prepare_plugin_config(self, plugin_id: str, config: Any,
schema: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""The config a plugin runs with, built from its raw config.json section.
A plugin that turned an on/off boolean into an ``{enabled, ...}``
object still finds the boolean in config.json until its settings are
next saved; it is read as the object, and schema defaults fill in the
rest (``SchemaManager.prepare_plugin_config``). Used when loading a
plugin and on hot reload (DisplayController), so ``on_config_change``
receives the same shape the plugin was constructed with.
Never raises: on failure the legacy-boolean pass alone is applied, or
failing that the section is returned as it was.
"""
if schema is None:
try:
schema = self.schema_manager.load_schema(plugin_id)
except Exception as e:
self.logger.debug("Could not load schema for %s: %s", plugin_id, e)
schema = None
upgraded: List[str] = []
try:
prepared = self.schema_manager.prepare_plugin_config(
plugin_id, config, schema=schema, changed_paths=upgraded)
self.logger.debug("Merged config with schema defaults for %s", plugin_id)
except Exception as e:
self.logger.warning("Could not apply schema defaults for %s: %s", plugin_id, e)
# Continue without defaults if they can't be applied
upgraded = []
prepared = config if isinstance(config, dict) else {}
if schema:
try:
prepared = normalize_legacy_booleans(prepared, schema, upgraded)
except Exception:
pass
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),
)
return prepared
def _strip_core_owned_keys(self, config: Dict[str, Any]) -> Dict[str, Any]: def _strip_core_owned_keys(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""A shallow copy of ``config`` without the core's own tuning keys. """A shallow copy of ``config`` without the core's own tuning keys.
+268 -188
View File
@@ -13,6 +13,8 @@ from typing import Any, Dict, List, Optional, Tuple
import jsonschema import jsonschema
from jsonschema import Draft7Validator, ValidationError from jsonschema import Draft7Validator, ValidationError
from src.core_config_keys import CORE_CONFIG_KEYS
def _renders_as_object(prop: Dict[str, Any]) -> bool: def _renders_as_object(prop: Dict[str, Any]) -> bool:
"""``field_type == 'object'`` as ``plugin_config.html`` computes it. """``field_type == 'object'`` as ``plugin_config.html`` computes it.
@@ -93,6 +95,222 @@ def normalize_legacy_booleans(config: Any, schema: Any,
return result return result
#: Per-plugin settings the **core** owns: it reads them out of each plugin's
#: config section, so they are allowed in every plugin's config whether or not
#: the plugin's schema declares them. The one list for validation, for the web
#: save filter and for the load-time checks -- a private copy is how JSON saves
#: came to drop ``skin`` and the ``vegas_*`` keys while the validator accepted
#: them.
#:
#: Values are the schema used when the plugin does not declare the property.
CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
# Defaults match BasePlugin behavior: enabled=True, display_duration=15,
# live_priority=False.
"enabled": {
"type": "boolean",
"default": True,
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "How long to display this plugin in seconds"
},
"live_priority": {
"type": "boolean",
"default": False,
"description": "Enable live priority takeover when plugin has live content"
},
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an enum here:
# validation must keep passing when a configured skin gets uninstalled
# (rendering falls back to built-in). The install-dependent enum is
# injected only at serve time (inject_skin_selector) for the web UI
# dropdown.
"skin": {
"type": ["string", "object", "null"],
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
},
"skin_options": {
"type": "object",
"description": "Options passed through to the selected skin"
},
# Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py.
# Left untyped: the adapter validates them itself and ignores a bad
# value with a log line, so a stored one must never block a save.
"vegas_width_pct": {
"description": "Vegas mode: width of this plugin's card, as a percentage of the panel"
},
"vegas_overflow": {
"description": "Vegas mode: 'rotate' or 'truncate' when this plugin's content overflows"
},
"vegas_max_width_screens": {
"description": "Vegas mode: widest this plugin's card may be, in screens"
},
}
#: The keys of CORE_PLUGIN_PROPERTIES that are Vegas tuning rather than plugin
#: state. PluginManager strips these before its soft validation (see
#: PluginManager.CORE_OWNED_CONFIG_KEYS).
CORE_VEGAS_TUNING_KEYS = frozenset({
'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens',
})
def with_core_plugin_properties(schema: Dict[str, Any]) -> Dict[str, Any]:
"""A deep copy of a plugin schema with CORE_PLUGIN_PROPERTIES allowed.
Properties the plugin declares itself are left as declared. Core
properties are removed from ``required``: they are system-managed.
"""
enhanced = copy.deepcopy(schema) if isinstance(schema, dict) else {}
properties = enhanced.setdefault("properties", {})
for name, definition in CORE_PLUGIN_PROPERTIES.items():
if name not in properties:
properties[name] = copy.deepcopy(definition)
if "required" in enhanced:
enhanced["required"] = [field for field in enhanced["required"]
if field not in CORE_PLUGIN_PROPERTIES]
return enhanced
def extract_schema_defaults(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Default values of a JSON Schema's properties, recursively.
A property's own ``default`` wins; otherwise a nested object contributes
its children's defaults, and an array contributes ``[]`` (or a one-item
list of its ``items`` default). This is what a device runs with, so the
dev tools use it too (src/plugin_system/testing/loading.py).
"""
defaults: Dict[str, Any] = {}
properties = schema.get('properties', {}) if isinstance(schema, dict) else {}
if not isinstance(properties, dict):
return defaults
for key, prop_schema in properties.items():
if not isinstance(prop_schema, dict):
continue
# If property has a default, use it
if 'default' in prop_schema:
defaults[key] = prop_schema['default']
continue
# Handle nested objects
if prop_schema.get('type') == 'object' and 'properties' in prop_schema:
nested_defaults = extract_schema_defaults(prop_schema)
if nested_defaults:
defaults[key] = nested_defaults
# Handle arrays with object items
elif prop_schema.get('type') == 'array' and 'items' in prop_schema:
items_schema = prop_schema['items']
if items_schema.get('type') == 'object' and 'properties' in items_schema:
# For arrays of objects, use empty array as default
# Individual objects will use their defaults when created
defaults[key] = []
elif 'default' in items_schema:
# Array with default item value
defaults[key] = [items_schema['default']]
else:
# Empty array as default
defaults[key] = []
# For other types without defaults, don't add to defaults dict
# This allows plugins to handle missing values as needed
return defaults
def plugin_config_defaults(schema: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Every default a plugin's config gets: the schema's plus the core ones.
A plugin with no schema gets the minimal ``enabled: False,
display_duration: 15``. Device location is not applied here; that needs a
config manager (SchemaManager.generate_default_config).
"""
if not schema:
return {
'enabled': False,
'display_duration': 15
}
defaults = extract_schema_defaults(schema)
# Ensure core properties have defaults (they may not be in the schema)
# These match BasePlugin behavior
for name in ('enabled', 'display_duration', 'live_priority'):
if name not in defaults:
defaults[name] = CORE_PLUGIN_PROPERTIES[name]['default']
return defaults
def merge_config_defaults(config: Dict[str, Any], defaults: Dict[str, Any]) -> Dict[str, Any]:
"""Merge configuration with defaults, preserving user values.
Also replaces None values with defaults so a config never starts with
None where a default exists. Neither argument is mutated.
"""
merged = copy.deepcopy(defaults)
def deep_merge(target: Dict[str, Any], source: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively merge source into target, replacing None with defaults."""
for key, value in source.items():
default_value = default_dict.get(key)
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
# Both are dicts, recursively merge
if isinstance(default_value, dict):
deep_merge(target[key], value, default_value)
else:
deep_merge(target[key], value, {})
elif value is None and default_value is not None:
# Value is None and we have a default, use the default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
else:
# Normal merge: user value takes precedence (copy if dict/list)
if isinstance(value, (dict, list)):
target[key] = copy.deepcopy(value)
else:
target[key] = value
deep_merge(merged, config, defaults)
# Final pass: replace any remaining None values at any level with defaults
def replace_none_with_defaults(target: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively replace None values with defaults."""
for key in list(target.keys()):
value = target[key]
default_value = default_dict.get(key)
if value is None and default_value is not None:
# Replace None with default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
elif isinstance(value, dict) and isinstance(default_value, dict):
# Recursively process nested dicts
replace_none_with_defaults(value, default_value)
replace_none_with_defaults(merged, defaults)
return merged
def prepare_plugin_config(config: Any, schema: Optional[Dict[str, Any]],
defaults: Dict[str, Any],
changed_paths: Optional[List[str]] = None) -> Dict[str, Any]:
"""The config a plugin runs with, from its stored (or submitted) section.
Legacy booleans are read as ``{"enabled": ...}`` objects
(normalize_legacy_booleans), then schema defaults fill in whatever is
missing. Loading a plugin, both config saves, GET /plugins/config, hot
reload and the dev tools all go through this, so a plugin sees the same
shape however its config reached it.
"""
config = config if isinstance(config, dict) else {}
if schema:
config = normalize_legacy_booleans(config, schema, changed_paths)
return merge_config_defaults(config, defaults)
class SchemaManager: class SchemaManager:
""" """
Manages plugin configuration schemas with caching and validation. Manages plugin configuration schemas with caching and validation.
@@ -262,57 +480,12 @@ class SchemaManager:
def extract_defaults_from_schema(self, schema: Dict[str, Any], prefix: str = '') -> Dict[str, Any]: def extract_defaults_from_schema(self, schema: Dict[str, Any], prefix: str = '') -> Dict[str, Any]:
""" """
Recursively extract default values from a JSON Schema. Recursively extract default values from a JSON Schema.
Handles nested objects, arrays, and all schema types. See :func:`extract_schema_defaults`; ``prefix`` is accepted for
compatibility and unused.
Args:
schema: JSON Schema dictionary
prefix: Optional prefix for logging/debugging
Returns:
Dictionary of default values
""" """
defaults = {} return extract_schema_defaults(schema)
# Handle schema with properties
properties = schema.get('properties', {})
if not properties:
return defaults
for key, prop_schema in properties.items():
field_path = f"{prefix}.{key}" if prefix else key
# If property has a default, use it
if 'default' in prop_schema:
defaults[key] = prop_schema['default']
self.logger.debug(f"Found default for {field_path}: {prop_schema['default']}")
continue
# Handle nested objects
if prop_schema.get('type') == 'object' and 'properties' in prop_schema:
nested_defaults = self.extract_defaults_from_schema(prop_schema, field_path)
if nested_defaults:
defaults[key] = nested_defaults
# Handle arrays with object items
elif prop_schema.get('type') == 'array' and 'items' in prop_schema:
items_schema = prop_schema['items']
if items_schema.get('type') == 'object' and 'properties' in items_schema:
# For arrays of objects, use empty array as default
# Individual objects will use their defaults when created
defaults[key] = []
elif 'default' in items_schema:
# Array with default item value
defaults[key] = [items_schema['default']]
else:
# Empty array as default
defaults[key] = []
# For other types without defaults, don't add to defaults dict
# This allows plugins to handle missing values as needed
return defaults
def get_device_location(self) -> Optional[Dict[str, Any]]: def get_device_location(self) -> Optional[Dict[str, Any]]:
""" """
Return the device-wide ``location`` block from config.json, or None. Return the device-wide ``location`` block from config.json, or None.
@@ -391,31 +564,39 @@ class SchemaManager:
schema = self.load_schema(plugin_id, use_cache=use_cache) schema = self.load_schema(plugin_id, use_cache=use_cache)
if not schema: if not schema:
# Return minimal defaults if no schema # Return minimal defaults if no schema
return { return plugin_config_defaults(None)
'enabled': False,
'display_duration': 15 # Schema defaults plus the core properties' (they may not be in the
} # schema)
defaults = plugin_config_defaults(schema)
# Extract defaults from schema
defaults = self.extract_defaults_from_schema(schema)
# Ensure core properties have defaults (they may not be in the schema)
# These match BasePlugin behavior
if 'enabled' not in defaults:
defaults['enabled'] = schema.get('properties', {}).get('enabled', {}).get('default', True)
if 'display_duration' not in defaults:
defaults['display_duration'] = schema.get('properties', {}).get('display_duration', {}).get('default', 15)
if 'live_priority' not in defaults:
defaults['live_priority'] = schema.get('properties', {}).get('live_priority', {}).get('default', False)
# Cache the defaults *before* the device location is layered on, so a # Cache the defaults *before* the device location is layered on, so a
# later change to the device location is picked up by the next call. # later change to the device location is picked up by the next call.
self._defaults_cache[plugin_id] = defaults.copy() self._defaults_cache[plugin_id] = defaults.copy()
return self.apply_device_location(defaults) return self.apply_device_location(defaults)
def prepare_plugin_config(self, plugin_id: str, config: Any,
schema: Optional[Dict[str, Any]] = None,
changed_paths: Optional[List[str]] = None) -> Dict[str, Any]:
"""
The config a plugin runs with: see :func:`prepare_plugin_config`.
Args:
plugin_id: Plugin identifier
config: The plugin's stored or submitted config section
schema: The plugin's schema, when the caller already has it
changed_paths: Receives the dotted path of each legacy boolean
read as an object
Returns:
A new dict; ``config`` is not mutated
"""
if schema is None:
schema = self.load_schema(plugin_id, use_cache=True)
defaults = self.generate_default_config(plugin_id, use_cache=True)
return prepare_plugin_config(config, schema, defaults, changed_paths)
def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any], def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any],
plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]: plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]:
""" """
@@ -436,80 +617,18 @@ class SchemaManager:
errors = [] errors = []
try: try:
# Core plugin properties that should always be allowed # Core plugin properties (CORE_PLUGIN_PROPERTIES) are handled by
# These are handled by the base plugin system and should not cause validation failures # the base plugin system and should not cause validation failures:
# Defaults match BasePlugin behavior: enabled=True, display_duration=15, live_priority=False # they are allowed even when the plugin's schema doesn't declare
core_properties = { # them, and never required.
"enabled": { enhanced_schema = with_core_plugin_properties(schema)
"type": "boolean", if plugin_id:
"default": True, declared = schema.get("properties", {}) if isinstance(schema, dict) else {}
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "How long to display this plugin in seconds"
},
"live_priority": {
"type": "boolean",
"default": False,
"description": "Enable live priority takeover when plugin has live content"
},
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an
# enum here: validation must keep passing when a configured
# skin gets uninstalled (rendering falls back to built-in).
# The install-dependent enum is injected only at serve time
# (inject_skin_selector) for the web UI dropdown.
"skin": {
"type": ["string", "object", "null"],
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
},
"skin_options": {
"type": "object",
"description": "Options passed through to the selected skin"
}
}
# Create a deep copy of the schema to modify (to avoid mutating the original)
enhanced_schema = copy.deepcopy(schema)
if "properties" not in enhanced_schema:
enhanced_schema["properties"] = {}
# Inject core properties if they're not already defined in the schema
# This ensures core properties are always allowed even if not in the plugin's schema
properties_added = []
for prop_name, prop_def in core_properties.items():
if prop_name not in enhanced_schema["properties"]:
enhanced_schema["properties"][prop_name] = copy.deepcopy(prop_def)
properties_added.append(prop_name)
# Log if we added any core properties (for debugging)
if properties_added and plugin_id:
self.logger.debug( self.logger.debug(
f"Injected core properties into schema for {plugin_id}: {properties_added}" "Injected core properties into schema for %s: %s", plugin_id,
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
) )
# Remove core properties from required array (they're system-managed)
# Core properties should be allowed but not required for validation
if "required" in enhanced_schema:
core_prop_names = list(core_properties.keys())
removed_from_required = [
field for field in enhanced_schema["required"]
if field in core_prop_names
]
enhanced_schema["required"] = [
field for field in enhanced_schema["required"]
if field not in core_prop_names
]
# Log if we removed any core properties from required (for debugging)
if removed_from_required and plugin_id:
self.logger.debug(
f"Removed core properties from required array for {plugin_id}: {removed_from_required}"
)
# Create validator with enhanced schema # Create validator with enhanced schema
validator = Draft7Validator(enhanced_schema) validator = Draft7Validator(enhanced_schema)
@@ -626,55 +745,15 @@ class SchemaManager:
""" """
Merge configuration with defaults, preserving user values. Merge configuration with defaults, preserving user values.
Also replaces None values with defaults to ensure config never has None from the start. Also replaces None values with defaults to ensure config never has None from the start.
Args: Args:
config: User configuration config: User configuration
defaults: Default values from schema defaults: Default values from schema
Returns: Returns:
Merged configuration with defaults applied where missing or None Merged configuration with defaults applied where missing or None
""" """
merged = copy.deepcopy(defaults) return merge_config_defaults(config, defaults)
def deep_merge(target: Dict[str, Any], source: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively merge source into target, replacing None with defaults."""
for key, value in source.items():
default_value = default_dict.get(key)
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
# Both are dicts, recursively merge
if isinstance(default_value, dict):
deep_merge(target[key], value, default_value)
else:
deep_merge(target[key], value, {})
elif value is None and default_value is not None:
# Value is None and we have a default, use the default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
else:
# Normal merge: user value takes precedence (copy if dict/list)
if isinstance(value, (dict, list)):
target[key] = copy.deepcopy(value)
else:
target[key] = value
deep_merge(merged, config, defaults)
# Final pass: replace any remaining None values at any level with defaults
def replace_none_with_defaults(target: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively replace None values with defaults."""
for key in list(target.keys()):
value = target[key]
default_value = default_dict.get(key)
if value is None and default_value is not None:
# Replace None with default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
elif isinstance(value, dict) and isinstance(default_value, dict):
# Recursively process nested dicts
replace_none_with_defaults(value, default_value)
replace_none_with_defaults(merged, defaults)
return merged
def detect_config_key_collisions( def detect_config_key_collisions(
self, self,
@@ -698,10 +777,11 @@ class SchemaManager:
""" """
collisions = [] collisions = []
# Reserved top-level config keys that plugins should not use as IDs # Reserved top-level config keys that plugins should not use as IDs:
reserved_keys = { # every core section (src/core_config_keys.py), plus a few names that
'display', 'schedule', 'timezone', 'plugin_system', # read as core even though no current section uses them.
'display_modes', 'system', 'hardware', 'debug', reserved_keys = set(CORE_CONFIG_KEYS) | {
'display_modes', 'hardware', 'debug',
'log_level', 'emulator', 'web_interface' 'log_level', 'emulator', 'web_interface'
} }
+2 -4
View File
@@ -27,7 +27,7 @@ from PIL import Image, ImageChops
from src.logging_config import get_logger from src.logging_config import get_logger
from .bounds_display_manager import BoundsCheckingDisplayManager from .bounds_display_manager import BoundsCheckingDisplayManager
from .loading import load_config_defaults, load_manifest, merge_config from .loading import build_config, load_manifest
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
logger = get_logger("[Plugin Harness]") logger = get_logger("[Plugin Harness]")
@@ -305,9 +305,7 @@ def render_plugin_matrix(
manifest = load_manifest(plugin_dir) manifest = load_manifest(plugin_dir)
# Start from config_schema.json defaults so the plugin behaves like a real # Start from config_schema.json defaults so the plugin behaves like a real
# install; explicit caller config still wins over a schema default. # install; explicit caller config still wins over a schema default.
config = merge_config( config = build_config(plugin_dir, config)
merge_config({"enabled": True}, load_config_defaults(plugin_dir)),
config or {})
sizes = sizes or DEFAULT_TEST_SIZES sizes = sizes or DEFAULT_TEST_SIZES
results: List[RenderResult] = [] results: List[RenderResult] = []
+45 -39
View File
@@ -42,29 +42,6 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
return json.load(f) return json.load(f)
def _defaults_from_properties(properties: Dict[str, Any]) -> Dict[str, Any]:
"""Defaults for one `properties` block, recursing into nested objects.
An object property carries its defaults on its children, not on itself, so
reading only the top level dropped everything nested. That is most of the
fleet: config organised by league, or under customization/display_options,
lost 2,386 defaults across 37 of 44 plugins -- soccer-scoreboard alone lost
539 of 565 -- and the harness rendered them with a config no install would
ever have.
"""
defaults: Dict[str, Any] = {}
for key, prop in (properties or {}).items():
if not isinstance(prop, dict):
continue
if prop.get('type') == 'object' and isinstance(prop.get('properties'), dict):
nested = _defaults_from_properties(prop['properties'])
if nested:
defaults[key] = nested
elif 'default' in prop:
defaults[key] = prop['default']
return defaults
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Deep-merge override onto base, without dropping sibling defaults. """Deep-merge override onto base, without dropping sibling defaults.
@@ -81,14 +58,45 @@ def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, An
return merged return merged
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]: def load_schema(plugin_dir: Union[str, Path]) -> Optional[Dict[str, Any]]:
"""Extract default values from a plugin's config_schema.json (empty if none).""" """A plugin's config_schema.json, or None when it has none."""
schema_path = Path(plugin_dir) / 'config_schema.json' schema_path = Path(plugin_dir) / 'config_schema.json'
if not schema_path.exists(): if not schema_path.exists():
return {} return None
with open(schema_path, 'r', encoding='utf-8') as f: with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f) return json.load(f)
return _defaults_from_properties(schema.get('properties', {}))
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Default values from a plugin's config_schema.json (empty if none).
The device's own extraction (schema_manager.extract_schema_defaults), so a
harness run starts from the config an install would have: nested objects
contribute their children's defaults (organised-by-league configs lost
thousands of them when only the top level was read), and arrays without a
default start as [].
"""
from src.plugin_system.schema_manager import extract_schema_defaults
schema = load_schema(plugin_dir)
return extract_schema_defaults(schema) if schema else {}
def build_config(plugin_dir: Union[str, Path],
overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""The config a device would give this plugin, with ``overrides`` applied.
Starts from a forced ``enabled: True``, deep-merges ``overrides`` onto it
(merge_config), then prepares the result exactly as the device does when it
loads a plugin: legacy booleans read as objects, and schema plus core
defaults filled in (schema_manager.prepare_plugin_config). Used by the
harness, check_plugin, render_plugin and the dev preview server.
"""
from src.plugin_system.schema_manager import (
plugin_config_defaults, prepare_plugin_config,
)
schema = load_schema(plugin_dir)
requested = merge_config({"enabled": True}, overrides or {})
return prepare_plugin_config(requested, schema, plugin_config_defaults(schema))
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]: def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
@@ -143,16 +151,14 @@ def build_full_config(
Merge order: config_schema.json defaults, then a forced ``enabled: True``, Merge order: config_schema.json defaults, then a forced ``enabled: True``,
then harness.json's config overlay, then the caller's explicit config -- then harness.json's config overlay, then the caller's explicit config --
most specific wins. `enabled` is re-asserted *after* the schema defaults most specific wins, and each layer deep-merges (a nested override such as
so a plugin that reasonably ships `enabled: false` (e.g. a seasonal or ``{"nhl": {"enabled": true}}`` keeps the other nhl defaults). `enabled` is
opt-in plugin) can't silently make every harness run test "disabled, do asserted over the schema defaults so a plugin that reasonably ships
nothing" by accident -- callers that genuinely want to test the disabled `enabled: false` (e.g. a seasonal or opt-in plugin) can't silently make
path can still do so via `cli_config={"enabled": False}`. every harness run test "disabled, do nothing" by accident -- callers that
genuinely want to test the disabled path can still do so via
`cli_config={"enabled": False}`. See build_config.
""" """
spec = spec or {} spec = spec or {}
config: Dict[str, Any] = {} overrides = merge_config(spec.get("config", {}) or {}, cli_config or {})
config.update(load_config_defaults(plugin_dir)) return build_config(plugin_dir, overrides)
config["enabled"] = True
config.update(spec.get("config", {}))
config.update(cli_config or {})
return config
+4 -2
View File
@@ -8,6 +8,7 @@ Fails fast with clear error messages to prevent runtime issues.
import os import os
from typing import Any, List, Optional, Tuple from typing import Any, List, Optional, Tuple
from pathlib import Path from pathlib import Path
from src.core_config_keys import CORE_CONFIG_KEYS
from src.exceptions import ConfigError, PluginError, CacheError from src.exceptions import ConfigError, PluginError, CacheError
from src.logging_config import get_logger from src.logging_config import get_logger
@@ -275,8 +276,9 @@ class StartupValidator:
# Check for enabled plugins that don't exist # Check for enabled plugins that don't exist
for plugin_id, plugin_config in config.items(): for plugin_id, plugin_config in config.items():
# Skip non-plugin config sections # Skip core sections: auto_update and dim_schedule have an
if plugin_id in ['display', 'schedule', 'timezone', 'plugin_system']: # 'enabled' key too, and are not plugins that went missing.
if plugin_id in CORE_CONFIG_KEYS:
continue continue
if not isinstance(plugin_config, dict): if not isinstance(plugin_config, dict):
+288
View File
@@ -0,0 +1,288 @@
"""POST /config/main: a partial JSON body changes only what it sends.
The settings forms post every field, and a browser leaves an unchecked box out,
so for a form a missing checkbox means False. JSON API clients send only what
they change. Treating their missing keys as unchecked meant:
- the MQTT bridge's Home Assistant brightness slider (``{"brightness": N}``)
turned off disable_hardware_pulsing, inverse_colors, show_refresh_rate and
use_short_date_format on every change;
- the documented timezone/location update turned off web_display_autostart and
weekly automatic updates.
The v3 forms post JSON as well (htmx json-enc), so they mark themselves with a
hidden ``__form_section`` input; these tests pin both halves of that contract.
Also here: the Vegas cycle-time fields no longer land in display_durations
(and a blank one no longer 400s the Display save), the Raw JSON editor starts
auto-update setup like the General form does, and both schedule POSTs accept
the per-day shape their GETs return.
"""
import copy
import json
import re
from pathlib import Path
import pytest
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
REPO = Path(__file__).resolve().parent.parent
PARTIALS = REPO / 'web_interface' / 'templates' / 'v3' / 'partials'
STORED = {
'web_display_autostart': True,
'auto_update': {'enabled': True},
'timezone': 'America/Chicago',
'plugin_system': {'auto_discover': True, 'auto_load_enabled': True,
'development_mode': True},
'display': {
'hardware': {'rows': 32, 'cols': 64, 'chain_length': 2, 'brightness': 90,
'disable_hardware_pulsing': True, 'inverse_colors': True,
'show_refresh_rate': True},
'runtime': {'gpio_slowdown': 4},
'use_short_date_format': True,
'double_sided': {'enabled': True, 'copies': 2, 'axis': 'horizontal'},
'vegas_scroll': {'enabled': True, 'auto_trim': True,
'dynamic_duration_enabled': True,
'continuous_scroll': True, 'smooth_scroll': True},
},
}
@pytest.fixture
def saved(api_v3_module, monkeypatch):
captured = {}
api_v3_module.api_v3.config_manager.load_config.side_effect = \
lambda *a, **k: copy.deepcopy(STORED)
def fake_save(_manager, config, **_kwargs):
captured['config'] = config
return True, ''
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
from web_interface import auto_update
monkeypatch.setattr(auto_update, 'start_setup_if_needed', lambda *a, **k: None)
return captured
def _post_json(client, body):
return client.post('/api/v3/config/main', data=json.dumps(body),
content_type='application/json')
class TestJsonPartialSaves:
def test_mqtt_bridge_brightness_changes_only_brightness(self, api_v3_client, saved):
# integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py set_brightness
resp = _post_json(api_v3_client, {'brightness': 40})
assert resp.status_code == 200, resp.get_json()
display = saved['config']['display']
assert display['hardware']['brightness'] == 40
assert display['hardware']['disable_hardware_pulsing'] is True
assert display['hardware']['inverse_colors'] is True
assert display['hardware']['show_refresh_rate'] is True
assert display['use_short_date_format'] is True
def test_timezone_only_keeps_autostart_and_auto_update(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'timezone': 'UTC'})
assert resp.status_code == 200, resp.get_json()
config = saved['config']
assert config['timezone'] == 'UTC'
assert config['web_display_autostart'] is True
assert config['auto_update'] == {'enabled': True}
def test_location_only_keeps_autostart_and_auto_update(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'city': 'Paris', 'country': 'FR'})
assert resp.status_code == 200, resp.get_json()
config = saved['config']
assert config['location'] == {'city': 'Paris', 'country': 'FR'}
assert config['web_display_autostart'] is True
assert config['auto_update'] == {'enabled': True}
@pytest.mark.parametrize('key', ['auto_discover', 'auto_load_enabled', 'development_mode'])
def test_a_legacy_plugin_system_toggle_is_not_a_general_save(self, api_v3_client, saved, key):
# These left the General form; a client still sending one must not
# have the general-settings checkboxes treated as unchecked.
resp = api_v3_client.post('/api/v3/config/main', data={key: 'on'},
content_type='application/x-www-form-urlencoded')
assert resp.status_code == 200, resp.get_json()
assert saved['config']['web_display_autostart'] is True
assert saved['config']['auto_update'] == {'enabled': True}
def test_vegas_speed_only_keeps_vegas_toggles(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'vegas_scroll_speed': 80})
assert resp.status_code == 200, resp.get_json()
vegas = saved['config']['display']['vegas_scroll']
assert vegas['scroll_speed'] == 80
for key in ('enabled', 'auto_trim', 'dynamic_duration_enabled',
'continuous_scroll', 'smooth_scroll'):
assert vegas[key] is True, key
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['double_sided']['enabled'] is True
def test_json_can_still_turn_a_checkbox_off(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'inverse_colors': False, 'auto_update_enabled': False})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['hardware']['inverse_colors'] is False
assert saved['config']['display']['hardware']['disable_hardware_pulsing'] is True
assert saved['config']['auto_update'] == {'enabled': False}
def test_json_with_charset_is_still_json(self, api_v3_client, saved):
resp = api_v3_client.post('/api/v3/config/main', data=json.dumps({'brightness': 41}),
content_type='application/json; charset=utf-8')
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['hardware']['brightness'] == 41
assert saved['config']['display']['hardware']['inverse_colors'] is True
def test_a_non_object_body_is_refused(self, api_v3_client, saved):
assert _post_json(api_v3_client, [1, 2]).status_code == 400
class TestFormSavesStillUncheck:
"""Unchecking a box in the UI must still save false."""
def test_marked_json_form_post_unchecks_missing_display_boxes(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40',
'vegas_scroll_speed': '50'})
assert resp.status_code == 200, resp.get_json()
display = saved['config']['display']
for key in ('disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate'):
assert display['hardware'][key] is False, key
assert display['use_short_date_format'] is False
assert display['vegas_scroll']['enabled'] is False
assert '__form_section' not in saved['config']
def test_marked_json_form_post_keeps_checked_boxes(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40',
'inverse_colors': 'on'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['hardware']['inverse_colors'] is True
assert saved['config']['display']['hardware']['show_refresh_rate'] is False
def test_marked_general_form_unchecks_autostart_and_auto_update(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'__form_section': 'general', 'timezone': 'UTC'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['web_display_autostart'] is False
assert saved['config']['auto_update'] == {'enabled': False}
def test_form_encoded_post_unchecks_missing_boxes(self, api_v3_client, saved):
resp = api_v3_client.post('/api/v3/config/main', data={'brightness': '40'},
content_type='application/x-www-form-urlencoded')
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['hardware']['inverse_colors'] is False
@pytest.mark.parametrize('partial', ['general.html', 'display.html', 'durations.html'])
def test_every_config_main_form_carries_the_marker(self, partial):
html = (PARTIALS / partial).read_text(encoding='utf-8')
form = re.search(r'<form hx-post="/api/v3/config/main".*?</form>', html, re.S)
assert form, f'{partial} no longer posts to /config/main'
assert re.search(r'<input type="hidden" name="__form_section" value="\w+">',
form.group(0)), f'{partial} form lost its __form_section marker'
class TestVegasCycleDurations:
def test_cycle_durations_are_not_display_durations(self, api_v3_client, saved):
resp = api_v3_client.post('/api/v3/config/main', data={
'vegas_scroll_enabled': 'on', 'vegas_min_cycle_duration': '90',
'vegas_max_cycle_duration': '300'})
assert resp.status_code == 200, resp.get_json()
display = saved['config']['display']
assert display['vegas_scroll']['min_cycle_duration'] == 90
assert display['vegas_scroll']['max_cycle_duration'] == 300
durations = display.get('display_durations', {})
assert 'vegas_min_cycle_duration' not in durations
assert 'vegas_max_cycle_duration' not in durations
assert 'vegas_min_cycle_duration' not in saved['config']
def test_blank_cycle_duration_does_not_reject_the_save(self, api_v3_client, saved):
resp = api_v3_client.post('/api/v3/config/main', data={
'vegas_scroll_enabled': 'on', 'vegas_scroll_speed': '60',
'vegas_min_cycle_duration': ''})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['scroll_speed'] == 60
def test_real_display_durations_still_save(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'clock_duration': '45'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['display_durations']['clock_duration'] == 45
class TestRawSaveStartsAutoUpdateSetup:
@pytest.fixture
def raw_env(self, api_v3_module, monkeypatch):
cm = api_v3_module.api_v3.config_manager
cm.get_raw_file_content.return_value = {'timezone': 'UTC', 'auto_update': {'enabled': False}}
calls = []
from web_interface import auto_update
monkeypatch.setattr(auto_update, 'start_setup_if_needed',
lambda was, config: calls.append((was, config)) or 'Setup note.')
return cm, calls
def test_enabling_from_raw_json_calls_setup(self, api_v3_client, raw_env):
cm, calls = raw_env
body = {'timezone': 'UTC', 'auto_update': {'enabled': True}}
resp = api_v3_client.post('/api/v3/config/raw/main', json=body)
assert resp.status_code == 200, resp.get_json()
cm.save_raw_file_content.assert_called_once_with('main', body)
assert calls == [(False, body)]
assert 'Setup note.' in resp.get_json()['message']
def test_previously_enabled_is_passed_through(self, api_v3_client, raw_env):
cm, calls = raw_env
cm.get_raw_file_content.return_value = {'auto_update': {'enabled': True}}
body = {'auto_update': {'enabled': True}}
assert api_v3_client.post('/api/v3/config/raw/main', json=body).status_code == 200
assert calls == [(True, body)]
class TestSchedulesAcceptTheirGetShape:
PER_DAY = {
'enabled': True, 'mode': 'per-day', 'dim_brightness': 20,
'days': {
'monday': {'enabled': True, 'start_time': '21:15', 'end_time': '06:45'},
'tuesday': {'enabled': False},
'wednesday': {'enabled': True, 'start_time': '22:00', 'end_time': '05:30'},
'thursday': {'enabled': True, 'start_time': '20:00', 'end_time': '07:00'},
'friday': {'enabled': True, 'start_time': '23:00', 'end_time': '08:00'},
'saturday': {'enabled': True, 'start_time': '23:30', 'end_time': '09:00'},
'sunday': {'enabled': True, 'start_time': '21:00', 'end_time': '07:00'},
},
}
@pytest.fixture
def store(self, api_v3_module, monkeypatch):
state = {'config': {}}
api_v3_module.api_v3.config_manager.load_config.side_effect = \
lambda *a, **k: copy.deepcopy(state['config'])
def fake_save(_manager, config, **_kwargs):
state['config'] = copy.deepcopy(config)
return True, ''
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
return state
@pytest.mark.parametrize('route,section,extra', [
('/api/v3/config/dim-schedule', 'dim_schedule', {'dim_brightness': 20}),
('/api/v3/config/schedule', 'schedule', {}),
])
def test_get_output_posts_back_unchanged(self, api_v3_client, store, route, section, extra):
body = {k: v for k, v in self.PER_DAY.items() if k != 'dim_brightness'}
body.update(extra)
store['config'] = {section: copy.deepcopy(body)}
read = api_v3_client.get(route).get_json()['data']
resp = api_v3_client.post(route, json=read)
assert resp.status_code == 200, resp.get_json()
assert store['config'][section]['days'] == body['days']
def test_flat_form_keys_still_work(self, api_v3_client, store):
body = {'enabled': True, 'mode': 'per-day', 'dim_brightness': 25,
'monday_enabled': 'on', 'monday_start': '19:00', 'monday_end': '06:00'}
resp = api_v3_client.post('/api/v3/config/dim-schedule', json=body)
assert resp.status_code == 200, resp.get_json()
assert store['config']['dim_schedule']['days']['monday'] == {
'enabled': True, 'start_time': '19:00', 'end_time': '06:00'}
+3 -1
View File
@@ -832,7 +832,9 @@ class TestSettingsSave:
def test_unchecked_toggle_on_general_save_disables(self, api_client): def test_unchecked_toggle_on_general_save_disables(self, api_client):
client, cm, setup_calls = api_client client, cm, setup_calls = api_client
resp = client.post('/api/v3/config/main', json={'timezone': 'UTC'}) # The General form marks its posts; an unchecked box is then absent.
resp = client.post('/api/v3/config/main',
json={'__form_section': 'general', 'timezone': 'UTC'})
assert resp.status_code == 200 assert resp.status_code == 200
assert self._saved(cm)['auto_update'] == {'enabled': False} assert self._saved(cm)['auto_update'] == {'enabled': False}
assert setup_calls == [True] assert setup_calls == [True]
+108
View File
@@ -0,0 +1,108 @@
"""Every "is this top-level key a plugin?" decision uses src/core_config_keys.py.
#589 moved plugin-state reconciliation onto the shared list, but three private
copies stayed behind and did not know about auto_update, dim_schedule, sync,
location, target_fps, ...:
- StartupValidator warned "Plugin 'auto_update' is enabled but not found in
plugins directory" on every display start with auto-update or a dim
schedule switched on;
- SchemaManager.detect_config_key_collisions let a plugin id collide with
those sections silently;
- ConfigManager.cleanup_orphaned_plugin_configs deleted display, schedule,
auto_update, ... as orphans, and validate_all_plugin_configs validated core
sections as plugins.
"""
import json
import re
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.config_manager import ConfigManager
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
from src.plugin_system.schema_manager import SchemaManager
from src.startup_validator import StartupValidator
REPO = Path(__file__).resolve().parent.parent
class TestStartupValidator:
def test_enabled_core_sections_are_not_missing_plugins(self):
config = {
'display': {'hardware': {}},
'auto_update': {'enabled': True},
'dim_schedule': {'enabled': True},
'schedule': {'enabled': True},
'gone-plugin': {'enabled': True},
}
cm = MagicMock()
cm.get_config.return_value = config
pm = MagicMock()
pm.discover_plugins.return_value = []
validator = StartupValidator(cm, plugin_manager=pm, cache_manager=MagicMock())
validator.warnings = []
validator.errors = []
validator._validate_plugins()
missing = [w for w in validator.warnings if 'not found in plugins directory' in w]
assert missing == ["Plugin 'gone-plugin' is enabled but not found in plugins directory"]
class TestReservedPluginIds:
@pytest.mark.parametrize('key', sorted(CORE_CONFIG_KEYS))
def test_every_core_key_is_reserved(self, key):
collisions = SchemaManager().detect_config_key_collisions([key])
assert [c['type'] for c in collisions] == ['reserved_key_collision']
def test_an_ordinary_plugin_id_is_not(self):
assert SchemaManager().detect_config_key_collisions(['clock-simple']) == []
class TestConfigManagerHelpers:
@pytest.fixture
def manager(self, tmp_path):
config = {key: {'enabled': True} for key in ('display', 'schedule', 'auto_update',
'dim_schedule', 'sync', 'location')}
config.update({'timezone': 'UTC', 'installed': {'enabled': True},
'orphan': {'enabled': True}})
secrets = {'github': {'token': 't'}, 'youtube': {'api_key': 'k'},
'installed': {'api_key': 'a'}, 'orphan': {'api_key': 'o'}}
(tmp_path / 'config.json').write_text(json.dumps(config))
(tmp_path / 'secrets.json').write_text(json.dumps(secrets))
manager = ConfigManager(config_path=str(tmp_path / 'config.json'),
secrets_path=str(tmp_path / 'secrets.json'))
manager.template_path = str(tmp_path / 'no-template.json')
return manager
def test_cleanup_removes_only_real_orphans(self, manager, tmp_path):
removed = manager.cleanup_orphaned_plugin_configs(['installed'])
assert removed == ['orphan']
config = json.loads((tmp_path / 'config.json').read_text())
assert {'display', 'schedule', 'auto_update', 'dim_schedule', 'sync',
'location', 'timezone', 'installed'} == set(config)
secrets = json.loads((tmp_path / 'secrets.json').read_text())
assert set(secrets) == {'github', 'youtube', 'installed'}
def test_validate_all_skips_core_sections(self, manager):
schema_manager = MagicMock()
schema_manager.load_schema.return_value = None
results = manager.validate_all_plugin_configs(schema_manager)
assert set(results) == {'installed', 'orphan'}
def test_core_secrets_keys_are_not_core_config_keys():
assert not (CORE_SECRETS_KEYS & CORE_CONFIG_KEYS)
@pytest.mark.parametrize('relpath', [
'src/startup_validator.py',
'src/config_manager.py',
'src/plugin_system/schema_manager.py',
])
def test_no_private_copy_of_the_list_remains(relpath):
"""The old copies all started with this literal; a new copy likely would too."""
source = (REPO / relpath).read_text(encoding='utf-8')
assert not re.search(r"""\[\s*['"]display['"]\s*,\s*['"]schedule['"]""", source), \
f'{relpath} has a private core-key list again; use src/core_config_keys.py'
+205
View File
@@ -0,0 +1,205 @@
"""One preparation for a plugin's config, wherever the config comes from.
A plugin's stored section becomes the config it runs with through
schema_manager.prepare_plugin_config: legacy booleans (#588) read as
``{"enabled": ...}`` objects, then schema and core defaults filled in. Loading
did that; hot reload handed plugins the raw section instead (a legacy
``dynamic_duration: true`` came back as a boolean, turning dynamic duration
off), and the dev tools each built configs their own way:
- dev_server read only top-level defaults and let a schema ``enabled: false``
override its forced ``enabled: True``;
- check_plugin/render_plugin (build_full_config) merged overrides with
dict.update, so ``{"nhl": {"enabled": true}}`` dropped every other nhl
default;
- the harness and the device disagreed on object and array defaults.
The web saves and GET are covered in
test/web_interface/test_plugin_config_json_saves.py.
"""
import importlib.util
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.schema_manager import (
CORE_PLUGIN_PROPERTIES, SchemaManager, extract_schema_defaults,
)
from src.plugin_system.testing import loading
REPO = Path(__file__).resolve().parent.parent
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"global": {
"type": "object",
"properties": {
"dynamic_duration": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"max_duration_seconds": {"type": "integer", "default": 300},
},
},
},
},
"nhl": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"favorite_teams": {"type": "array", "default": ["TB"]},
"show_records": {"type": "boolean", "default": True},
},
},
"colors": {
"type": "object",
"default": {"text": [255, 255, 255]},
"properties": {"text": {"type": "array", "default": [1, 2, 3]}},
},
"feeds": {"type": "array", "items": {"type": "string"}},
},
}
@pytest.fixture
def plugin_dir(tmp_path):
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA))
(pdir / "manifest.json").write_text(json.dumps({"id": "demo"}))
return pdir
@pytest.fixture
def plugin_manager(plugin_dir, tmp_path):
manager = PluginManager.__new__(PluginManager) # skip the heavy constructor
manager.logger = MagicMock()
manager.schema_manager = SchemaManager(plugins_dir=plugin_dir.parent, project_root=tmp_path)
return manager
class TestPluginManagerPreparation:
def test_legacy_boolean_and_defaults(self, plugin_manager):
prepared = plugin_manager.prepare_plugin_config(
"demo", {"enabled": True, "global": {"dynamic_duration": True}})
assert prepared["global"]["dynamic_duration"] == {
"enabled": True, "max_duration_seconds": 300}
assert prepared["nhl"]["favorite_teams"] == ["TB"]
assert prepared["display_duration"] == 15
def test_does_not_mutate_the_raw_section(self, plugin_manager):
raw = {"global": {"dynamic_duration": True}}
plugin_manager.prepare_plugin_config("demo", raw)
assert raw == {"global": {"dynamic_duration": True}}
def test_never_raises(self, plugin_manager):
plugin_manager.schema_manager = MagicMock()
plugin_manager.schema_manager.load_schema.return_value = SCHEMA
plugin_manager.schema_manager.prepare_plugin_config.side_effect = RuntimeError("boom")
prepared = plugin_manager.prepare_plugin_config("demo", {"global": {"dynamic_duration": False}})
assert prepared["global"]["dynamic_duration"] == {"enabled": False}
class TestHotReload:
def test_on_config_change_gets_the_prepared_config(self, test_display_controller, plugin_manager):
controller = test_display_controller
plugin = MagicMock()
plugin.modes = ["demo"]
pm = controller.plugin_manager
pm.discover_plugins.return_value = ["demo"]
pm.load_plugin.return_value = True
pm.plugin_manifests = {}
pm.get_plugin.side_effect = lambda pid: plugin if pid == "demo" else None
pm.prepare_plugin_config.side_effect = plugin_manager.prepare_plugin_config
controller.config_service.get_config = lambda: {"demo": {"enabled": True}}
controller._reconcile_enabled_plugins()
callback = controller._plugin_config_callbacks["demo"]
callback({"enabled": True}, {"enabled": True, "global": {"dynamic_duration": True}})
new_config = plugin.on_config_change.call_args[0][0]
assert new_config["global"]["dynamic_duration"] == {
"enabled": True, "max_duration_seconds": 300}
assert new_config["nhl"]["show_records"] is True
def test_raw_section_still_delivered_without_a_preparer(self, test_display_controller):
controller = test_display_controller
plugin = MagicMock()
plugin.modes = ["demo"]
pm = controller.plugin_manager
pm.discover_plugins.return_value = ["demo"]
pm.load_plugin.return_value = True
pm.plugin_manifests = {}
pm.get_plugin.side_effect = lambda pid: plugin if pid == "demo" else None
pm.prepare_plugin_config.return_value = None
controller.config_service.get_config = lambda: {"demo": {"enabled": True}}
controller._reconcile_enabled_plugins()
raw = {"enabled": False}
controller._plugin_config_callbacks["demo"]({}, raw)
plugin.on_config_change.assert_called_once_with(raw)
class TestDevToolsMatchTheDevice:
def test_harness_defaults_are_the_device_defaults(self, plugin_dir):
assert loading.load_config_defaults(plugin_dir) == extract_schema_defaults(SCHEMA)
defaults = loading.load_config_defaults(plugin_dir)
# An object's own default wins, as on a device; arrays start empty
assert defaults["colors"] == {"text": [255, 255, 255]}
assert defaults["feeds"] == []
def test_nested_override_keeps_sibling_defaults(self, plugin_dir):
config = loading.build_full_config(plugin_dir, cli_config={"nhl": {"enabled": True}})
assert config["nhl"] == {"enabled": True, "favorite_teams": ["TB"], "show_records": True}
def test_spec_and_cli_overrides_both_deep_merge(self, plugin_dir):
config = loading.build_full_config(
plugin_dir, spec={"config": {"nhl": {"show_records": False}}},
cli_config={"nhl": {"enabled": True}})
assert config["nhl"] == {"enabled": True, "favorite_teams": ["TB"], "show_records": False}
def test_build_config_matches_the_device_load(self, plugin_dir, plugin_manager):
overrides = {"enabled": True, "global": {"dynamic_duration": False}}
assert loading.build_config(plugin_dir, overrides) == \
plugin_manager.prepare_plugin_config("demo", overrides)
def test_core_defaults_are_present(self, plugin_dir):
config = loading.build_full_config(plugin_dir)
assert config["enabled"] is True
assert config["display_duration"] == CORE_PLUGIN_PROPERTIES["display_duration"]["default"]
assert config["live_priority"] is False
def test_render_plugin_matrix_config(self, plugin_dir, monkeypatch):
from src.plugin_system.testing import harness
seen = {}
def fake_render_size(plugin_id, manifest, pdir, config, *args, **kwargs):
seen["config"] = config
return []
monkeypatch.setattr(harness, "_render_size", fake_render_size)
harness.render_plugin_matrix("demo", plugin_dir, config={"nhl": {"enabled": True}},
sizes=[(64, 32)], run_update=False)
assert seen["config"]["enabled"] is True, "a schema enabled:false must not win"
assert seen["config"]["nhl"]["favorite_teams"] == ["TB"]
def test_dev_server_render_config(self, plugin_dir, monkeypatch):
spec = importlib.util.spec_from_file_location("dev_server_under_test",
REPO / "scripts" / "dev_server.py")
dev_server = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dev_server)
monkeypatch.setattr(dev_server, "find_plugin_dir", lambda pid: plugin_dir)
monkeypatch.setattr(dev_server, "_trusted_plugin_dir", lambda d: plugin_dir)
_, _, config, _, _ = dev_server._parse_render_request(
{"plugin_id": "demo", "config": {"nhl": {"enabled": True}}})
assert config["enabled"] is True, "a schema enabled:false must not win"
assert config["nhl"] == {"enabled": True, "favorite_teams": ["TB"], "show_records": True}
assert config["global"]["dynamic_duration"]["max_duration_seconds"] == 300
assert dev_server.load_config_defaults(plugin_dir) == extract_schema_defaults(SCHEMA)
@@ -0,0 +1,258 @@
"""JSON plugin-config saves store what the device would load, and only what was sent.
Runs a real ConfigManager and SchemaManager over tmp_path, like
test_api_v3_secret_roundtrip.py, so assertions are on config.json itself.
- POST /plugins/config with a partial ``config`` reset every unsent setting to
its schema default: the JSON path built on defaults, not the stored section.
- Legacy booleans (#588) were normalized only at load, so posting back what
GET /plugins/config returned failed validation.
- The JSON save's filter kept only enabled/display_duration/live_priority, so
a submitted skin, skin_options or vegas_* tuning key was silently dropped.
- Plugin sections posted to /config/main skipped all of that and were stored
verbatim, including values /plugins/config rejects.
"""
import json
from unittest.mock import MagicMock
import pytest
from flask import Flask
from src.config_manager import ConfigManager
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints.api_v3 import api_v3
PLUGIN_ID = "demo"
SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"enabled": {"type": "boolean", "default": True},
"city": {"type": "string", "default": "Dallas"},
"update_interval": {"type": "integer", "default": 300, "minimum": 30},
"api_key": {"type": "string", "x-secret": True, "default": ""},
"display": {
"type": "object",
"additionalProperties": False,
"properties": {
"brightness": {"type": "integer", "default": 50},
"show_icons": {"type": "boolean", "default": True},
},
},
"global": {
"type": "object",
"additionalProperties": False,
"properties": {
"dynamic_duration": {
"type": "object",
"additionalProperties": False,
"properties": {
"enabled": {"type": "boolean", "default": True},
"min_duration_seconds": {"type": "integer", "default": 30},
},
},
},
},
},
}
STORED = {
"enabled": True,
"city": "Paris",
"update_interval": 600,
"display": {"brightness": 80, "show_icons": False},
"global": {"dynamic_duration": {"enabled": False, "min_duration_seconds": 45}},
"vegas_width_pct": 60,
"skin": "neon",
}
_ATTRS = ('config_manager', 'plugin_manager', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager')
@pytest.fixture
def env(tmp_path):
config_file = tmp_path / "config.json"
secrets_file = tmp_path / "config_secrets.json"
plugins_dir = tmp_path / "plugins"
plugin_dir = plugins_dir / PLUGIN_ID
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(json.dumps(SCHEMA))
(plugin_dir / "manifest.json").write_text(json.dumps({"id": PLUGIN_ID}))
sentinel = object()
originals = {name: getattr(api_v3, name, sentinel) for name in _ATTRS}
config_manager = ConfigManager(config_path=str(config_file),
secrets_path=str(secrets_file))
config_manager.template_path = str(tmp_path / "no-template.json")
plugin_manager = MagicMock()
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin.return_value = None
for name in _ATTRS:
setattr(api_v3, name, MagicMock())
api_v3.config_manager = config_manager
api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
api_v3.plugin_manager = plugin_manager
api_v3.operation_queue = None
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
class Env:
client = app.test_client()
@staticmethod
def store(section):
config_file.write_text(json.dumps({"timezone": "UTC", PLUGIN_ID: section}))
@staticmethod
def main():
return json.loads(config_file.read_text())
@staticmethod
def stored():
return json.loads(config_file.read_text())[PLUGIN_ID]
@staticmethod
def secrets():
return json.loads(secrets_file.read_text()) if secrets_file.exists() else {}
@staticmethod
def save(config):
return app.test_client().post("/api/v3/plugins/config",
json={"plugin_id": PLUGIN_ID, "config": config})
Env.store(json.loads(json.dumps(STORED)))
yield Env
for name, original in originals.items():
if original is sentinel:
if hasattr(api_v3, name):
delattr(api_v3, name)
else:
setattr(api_v3, name, original)
class TestPartialJsonSave:
def test_unsent_settings_keep_their_values(self, env):
resp = env.save({"enabled": True})
assert resp.status_code == 200, resp.get_json()
stored = env.stored()
assert stored["city"] == "Paris"
assert stored["update_interval"] == 600
assert stored["display"] == {"brightness": 80, "show_icons": False}
assert stored["global"]["dynamic_duration"] == {"enabled": False, "min_duration_seconds": 45}
def test_a_nested_partial_keeps_its_siblings(self, env):
resp = env.save({"display": {"brightness": 20}})
assert resp.status_code == 200, resp.get_json()
assert env.stored()["display"] == {"brightness": 20, "show_icons": False}
def test_a_sent_setting_still_changes(self, env):
assert env.save({"city": "Lyon", "enabled": False}).status_code == 200
stored = env.stored()
assert stored["city"] == "Lyon" and stored["enabled"] is False
def test_an_invalid_sent_value_is_still_rejected(self, env):
resp = env.save({"update_interval": 5})
assert resp.status_code == 400
assert env.stored()["update_interval"] == 600
def test_config_must_be_an_object(self, env):
assert env.save(["city"]).status_code == 400
class TestCoreOwnedKeysSurviveTheFilter:
def test_submitted_core_keys_are_stored(self, env):
env.store({"enabled": True, "city": "Paris"})
resp = env.save({
"vegas_width_pct": 50, "vegas_overflow": "truncate",
"vegas_max_width_screens": 2, "skin": "retro",
"skin_options": {"accent": "#ff0000"}, "live_priority": True,
})
assert resp.status_code == 200, resp.get_json()
stored = env.stored()
assert stored["vegas_width_pct"] == 50
assert stored["vegas_overflow"] == "truncate"
assert stored["vegas_max_width_screens"] == 2
assert stored["skin"] == "retro"
assert stored["skin_options"] == {"accent": "#ff0000"}
assert stored["live_priority"] is True
def test_stored_core_keys_survive_an_unrelated_save(self, env):
assert env.save({"city": "Nice"}).status_code == 200
stored = env.stored()
assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon"
def test_a_non_core_unknown_key_is_still_filtered(self, env):
assert env.save({"not_in_schema": 1}).status_code == 200
assert "not_in_schema" not in env.stored()
class TestLegacyBooleans:
LEGACY = {"enabled": True, "city": "Paris", "global": {"dynamic_duration": True}}
def test_get_returns_the_object_shape(self, env):
env.store(dict(self.LEGACY))
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
assert data["global"]["dynamic_duration"] == {"enabled": True, "min_duration_seconds": 30}
def test_get_output_posts_back(self, env):
env.store(dict(self.LEGACY))
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
resp = env.save(data)
assert resp.status_code == 200, resp.get_json()
assert env.stored()["global"]["dynamic_duration"] == {"enabled": True, "min_duration_seconds": 30}
def test_an_unrelated_json_save_upgrades_the_stored_boolean(self, env):
env.store(dict(self.LEGACY))
resp = env.save({"city": "Lyon"})
assert resp.status_code == 200, resp.get_json()
assert env.stored()["global"]["dynamic_duration"] == {"enabled": True, "min_duration_seconds": 30}
def test_a_posted_legacy_boolean_is_read_as_the_object(self, env):
resp = env.save({"global": {"dynamic_duration": False}})
assert resp.status_code == 200, resp.get_json()
assert env.stored()["global"]["dynamic_duration"]["enabled"] is False
class TestPluginSectionsInConfigMain:
def _post(self, env, body):
return env.client.post("/api/v3/config/main", json=body)
def test_a_value_plugins_config_rejects_is_rejected_here_too(self, env):
resp = self._post(env, {PLUGIN_ID: {"update_interval": "abc"}})
assert resp.status_code == 400
assert env.stored()["update_interval"] == 600
assert env.save({"update_interval": "abc"}).status_code == 400
def test_nothing_is_saved_when_a_plugin_section_fails(self, env):
resp = self._post(env, {"timezone": "Europe/Paris", PLUGIN_ID: {"update_interval": 1}})
assert resp.status_code == 400
assert env.main()["timezone"] == "UTC"
assert env.stored()["update_interval"] == 600
def test_partial_section_merges_and_keeps_core_keys(self, env):
resp = self._post(env, {PLUGIN_ID: {"city": "Lyon", "vegas_overflow": "rotate"}})
assert resp.status_code == 200, resp.get_json()
stored = env.stored()
assert stored["city"] == "Lyon"
assert stored["display"] == {"brightness": 80, "show_icons": False}
assert stored["vegas_overflow"] == "rotate"
assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon"
def test_secrets_still_go_to_the_secrets_file(self, env):
resp = self._post(env, {PLUGIN_ID: {"api_key": "s3cret"}})
assert resp.status_code == 200, resp.get_json()
assert "api_key" not in env.stored() or env.stored()["api_key"] in ("", None)
assert env.secrets()[PLUGIN_ID]["api_key"] == "s3cret"
def test_a_non_object_section_is_rejected(self, env):
assert self._post(env, {PLUGIN_ID: "on"}).status_code == 400
+25 -33
View File
@@ -1254,8 +1254,13 @@ def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefi
) )
def _enhance_schema_with_core_properties(schema): def _enhance_schema_with_core_properties(schema):
""" """
Enhance schema with core plugin properties (enabled, display_duration, live_priority). Enhance schema with the core-owned per-plugin properties.
These properties are system-managed and should always be allowed even if not in the plugin's schema.
``enabled``, ``display_duration``, ``live_priority``, ``skin``,
``skin_options`` and the ``vegas_*`` tuning keys are system-managed and
always allowed, even when the plugin's schema doesn't declare them. The
list is ``schema_manager.CORE_PLUGIN_PROPERTIES``, the one validation uses,
so the save filter keeps exactly what validation accepts.
Args: Args:
schema: The original JSON schema dict schema: The original JSON schema dict
@@ -1263,44 +1268,31 @@ def _enhance_schema_with_core_properties(schema):
Returns: Returns:
Enhanced schema dict with core properties injected Enhanced schema dict with core properties injected
""" """
import copy from src.plugin_system.schema_manager import with_core_plugin_properties
if not schema: if not schema:
return schema return schema
return with_core_plugin_properties(schema)
# Core plugin properties that should always be allowed
# These match the definitions in SchemaManager.validate_config_against_schema()
core_properties = {
"enabled": {
"type": "boolean",
"default": True,
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "How long to display this plugin in seconds"
},
"live_priority": {
"type": "boolean",
"default": False,
"description": "Enable live priority takeover when plugin has live content"
}
}
# Create a deep copy of the schema to modify (to avoid mutating the original) def _prepared_plugin_config(plugin_id, raw_config):
enhanced_schema = copy.deepcopy(schema) """A plugin's config section as the plugin runs with it, for on_config_change.
if "properties" not in enhanced_schema:
enhanced_schema["properties"] = {} Loading a plugin reads legacy booleans as objects and fills in schema
defaults (PluginManager.prepare_plugin_config); a save's notification must
hand over the same shape. Falls back to the raw section.
"""
prepare = getattr(api_v3.plugin_manager, 'prepare_plugin_config', None)
if callable(prepare):
try:
prepared = prepare(plugin_id, raw_config)
if isinstance(prepared, dict):
return prepared
except Exception:
logger.debug("Could not prepare config for %s", plugin_id, exc_info=True)
return raw_config
# Inject core properties if they're not already defined in the schema
for prop_name, prop_def in core_properties.items():
if prop_name not in enhanced_schema["properties"]:
enhanced_schema["properties"][prop_name] = copy.deepcopy(prop_def)
return enhanced_schema
def _filter_config_by_schema(config, schema, prefix=''): def _filter_config_by_schema(config, schema, prefix=''):
""" """
Filter config to only include fields defined in the schema. Filter config to only include fields defined in the schema.
+163 -120
View File
@@ -16,11 +16,33 @@ from src.display_geometry import ORIENTATION_ROTATE_DEGREES
from src.matrix_support import INT_SETTING_LIMITS, describe_range, library_refusals, refusal_message from src.matrix_support import INT_SETTING_LIMITS, describe_range, library_refusals, refusal_message
from src.pi5_matrix_support import is_raspberry_pi_5 from src.pi5_matrix_support import is_raspberry_pi_5
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.
# Several are also called from helpers that live in __init__, so the # Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller. # package is the only patch point that covers every caller.
#: Hidden input the v3 settings forms (general.html, display.html,
#: durations.html) post to /config/main. Its presence tells save_main_config
#: that a missing checkbox was unchecked, not merely left out of an API call.
FORM_SECTION_FIELD = '__form_section'
def _day_setting(data, day, flat_key, nested_key):
"""(present, value) of one per-day schedule setting in a POST body.
The schedule forms post flat keys (``monday_start``), while GET returns
the stored shape, ``days.monday.start_time``. Accept both, so a client can
post back what it read; a flat key wins when a body carries both.
"""
if flat_key in data:
return True, data[flat_key]
days = data.get('days')
day_config = days.get(day) if isinstance(days, dict) else None
if isinstance(day_config, dict) and nested_key in day_config:
return True, day_config[nested_key]
return False, None
@api_v3.route('/config/main', methods=['GET']) @api_v3.route('/config/main', methods=['GET'])
def get_main_config(): def get_main_config():
@@ -125,8 +147,8 @@ def save_schedule_config():
end_key = f'{day}_end' end_key = f'{day}_end'
# Check if day is enabled # Check if day is enabled
if enabled_key in data: has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
enabled_val = data[enabled_key] if has_enabled:
# Handle checkbox values that may come as 'on', True, or False # Handle checkbox values that may come as 'on', True, or False
if isinstance(enabled_val, str): if isinstance(enabled_val, str):
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1') day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
@@ -142,15 +164,8 @@ def save_schedule_config():
start_time = None start_time = None
end_time = None end_time = None
if start_key in data and data[start_key]: start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
start_time = data[start_key] end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23:00'
else:
start_time = '07:00'
if end_key in data and data[end_key]:
end_time = data[end_key]
else:
end_time = '23:00'
# Validate time formats # Validate time formats
is_valid, error_msg = _validate_time_format(start_time) is_valid, error_msg = _validate_time_format(start_time)
@@ -354,8 +369,8 @@ def save_dim_schedule_config():
end_key = f'{day}_end' end_key = f'{day}_end'
# Check if day is enabled # Check if day is enabled
if enabled_key in data: has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
enabled_val = data[enabled_key] if has_enabled:
if isinstance(enabled_val, str): if isinstance(enabled_val, str):
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1') day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
else: else:
@@ -366,8 +381,8 @@ def save_dim_schedule_config():
# Only add times if day is enabled # Only add times if day is enabled
if day_config.get('enabled', True): if day_config.get('enabled', True):
enabled_days_count += 1 enabled_days_count += 1
start_time = data.get(start_key) or '20:00' start_time = _day_setting(data, day, start_key, 'start_time')[1] or '20:00'
end_time = data.get(end_key) or '07:00' end_time = _day_setting(data, day, end_key, 'end_time')[1] or '07:00'
# Validate time formats # Validate time formats
is_valid, error_msg = _validate_time_format(start_time) is_valid, error_msg = _validate_time_format(start_time)
@@ -435,8 +450,10 @@ def save_main_config():
# Try to get JSON data first, fallback to form data # Try to get JSON data first, fallback to form data
data = None data = None
if request.content_type == 'application/json': if request.is_json:
data = request.get_json() data = request.get_json()
if data is not None and not isinstance(data, dict):
return jsonify({'status': 'error', 'message': 'Request body must be a JSON object'}), 400
else: else:
# Handle form data # Handle form data
data = request.form.to_dict() data = request.form.to_dict()
@@ -448,6 +465,24 @@ def save_main_config():
if not data: if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400 return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# A missing checkbox means different things to the two kinds of caller.
# The settings forms post every field, and a browser leaves an
# unchecked box out entirely, so for them absent means False. A JSON
# API client (the MQTT bridge's brightness slider, a curl call from the
# REST docs) sends only what it is changing, and there absent means
# "leave it alone": treating it as unchecked turned off
# disable_hardware_pulsing and three other settings on every
# brightness change, and weekly auto-updates on every timezone change.
# The v3 forms post JSON too (htmx json-enc), so they identify
# themselves with a hidden FORM_SECTION_FIELD input. A form-encoded
# post is a form by definition.
is_form_submission = bool(data.pop(FORM_SECTION_FIELD, None)) or not request.is_json
def _set_checkbox(section, key, field):
"""Store checkbox ``field`` as ``section[key]``, if this request sets it."""
if is_form_submission or field in data:
section[key] = _coerce_to_bool(data.get(field))
# What arrives here is the config itself, and the headers carry the # What arrives here is the config itself, and the headers carry the
# session cookie -- neither belongs in the journal, least of all at # session cookie -- neither belongs in the journal, least of all at
# ERROR on every save. The shape of the request is the part with # ERROR on every save. The shape of the request is the part with
@@ -463,15 +498,16 @@ def save_main_config():
# Note: Checkboxes don't send data when unchecked, so we need to check if we're updating general settings # Note: Checkboxes don't send data when unchecked, so we need to check if we're updating general settings
# If any general setting is present, we're updating the general tab # If any general setting is present, we're updating the general tab
is_general_update = any(k in data for k in ['timezone', 'city', 'state', 'country', 'web_display_autostart', is_general_update = any(k in data for k in ['timezone', 'city', 'state', 'country', 'web_display_autostart',
'auto_discover', 'auto_load_enabled', 'development_mode', 'plugins_directory', 'plugins_directory', 'auto_update_enabled'])
'auto_update_enabled'])
if is_general_update: if is_general_update:
# For checkbox: if not present in data during general update, it means unchecked # For checkbox: if not present in data during a general *form*
current_config['web_display_autostart'] = _coerce_to_bool(data.get('web_display_autostart')) # update, it means unchecked (see _set_checkbox)
if not isinstance(current_config.get('auto_update'), dict): _set_checkbox(current_config, 'web_display_autostart', 'web_display_autostart')
current_config['auto_update'] = {} if is_form_submission or 'auto_update_enabled' in data:
current_config['auto_update']['enabled'] = _coerce_to_bool(data.get('auto_update_enabled')) if not isinstance(current_config.get('auto_update'), dict):
current_config['auto_update'] = {}
_set_checkbox(current_config['auto_update'], 'enabled', 'auto_update_enabled')
if 'timezone' in data: if 'timezone' in data:
current_config['timezone'] = data['timezone'] current_config['timezone'] = data['timezone']
@@ -641,10 +677,10 @@ def save_main_config():
# Handle checkboxes - coerce to bool to ensure proper JSON types # Handle checkboxes - coerce to bool to ensure proper JSON types
for checkbox in ['disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate']: for checkbox in ['disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate']:
current_config['display']['hardware'][checkbox] = _coerce_to_bool(data.get(checkbox)) _set_checkbox(current_config['display']['hardware'], checkbox, checkbox)
# Handle display-level checkboxes (always set to handle unchecked state) # Handle display-level checkboxes (unchecked state on form saves)
current_config['display']['use_short_date_format'] = _coerce_to_bool(data.get('use_short_date_format')) _set_checkbox(current_config['display'], 'use_short_date_format', 'use_short_date_format')
# Handle dynamic duration settings # Handle dynamic duration settings
if 'max_dynamic_duration_seconds' in data: if 'max_dynamic_duration_seconds' in data:
@@ -666,8 +702,11 @@ def save_main_config():
# checkbox, so when the feature is off we accept the values without # checkbox, so when the feature is off we accept the values without
# rejecting the whole save — otherwise a stale copies/chain_length # rejecting the whole save — otherwise a stale copies/chain_length
# mismatch locks the user out of every other display setting. # mismatch locks the user out of every other display setting.
enabled = _coerce_to_bool(data.get('double_sided_enabled')) if is_form_submission or 'double_sided_enabled' in data:
ds_config['enabled'] = enabled enabled = _coerce_to_bool(data.get('double_sided_enabled'))
ds_config['enabled'] = enabled
else:
enabled = _coerce_to_bool(ds_config.get('enabled'))
def _copies_fits_hardware(copies: int) -> Optional[str]: def _copies_fits_hardware(copies: int) -> Optional[str]:
"""Error message if copies doesn't divide the panel evenly, else None.""" """Error message if copies doesn't divide the panel evenly, else None."""
@@ -737,15 +776,13 @@ def save_main_config():
# Handle enabled checkbox # Handle enabled checkbox
# HTML checkboxes omit the key entirely when unchecked, so if the form # HTML checkboxes omit the key entirely when unchecked, so if the form
# was submitted (any vegas field present) but enabled key is missing, # was submitted (any vegas field present) but enabled key is missing,
# the checkbox was unchecked and we should set enabled=False # the checkbox was unchecked and we should set enabled=False.
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled')) # A JSON API call only changes the checkboxes it sends.
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim')) _set_checkbox(vegas_config, 'enabled', 'vegas_scroll_enabled')
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool( _set_checkbox(vegas_config, 'auto_trim', 'vegas_auto_trim')
data.get('vegas_dynamic_duration_enabled')) _set_checkbox(vegas_config, 'dynamic_duration_enabled', 'vegas_dynamic_duration_enabled')
vegas_config['continuous_scroll'] = _coerce_to_bool( _set_checkbox(vegas_config, 'continuous_scroll', 'vegas_continuous_scroll')
data.get('vegas_continuous_scroll')) _set_checkbox(vegas_config, 'smooth_scroll', 'vegas_smooth_scroll')
vegas_config['smooth_scroll'] = _coerce_to_bool(
data.get('vegas_smooth_scroll'))
# max_plugin_width_ratio is the one fractional setting, so it is # max_plugin_width_ratio is the one fractional setting, so it is
# handled outside the integer loop below. # handled outside the integer loop below.
@@ -911,8 +948,12 @@ def save_main_config():
# them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30 # them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30
# sitting at config root alongside the correct # sitting at config root alongside the correct
# display.display_durations.clock_duration). # display.display_durations.clock_duration).
# The Vegas cycle-time fields (vegas_min_cycle_duration, ...) share the
# suffix but are Vegas settings, already handled above: counting them
# here wrote junk mode durations, and a blank one 400'd the save.
duration_fields = [k for k in list(data.keys()) duration_fields = [k for k in list(data.keys())
if k.endswith('_duration') or k in ('default_duration', 'transition_duration')] if (k.endswith('_duration') and k not in vegas_fields)
or k in ('default_duration', 'transition_duration')]
if duration_fields: if duration_fields:
if 'display' not in current_config: if 'display' not in current_config:
current_config['display'] = {} current_config['display'] = {}
@@ -952,33 +993,34 @@ def save_main_config():
current_config['display']['display_durations'][mode_key] = int_value current_config['display']['display_durations'][mode_key] = int_value
# Handle plugin configurations dynamically # Handle plugin configurations dynamically
# Any key that matches a plugin ID should be saved as plugin config # Any key that matches a plugin ID is that plugin's settings. They go
# This includes proper secret field handling from schema # through the same preparation as POST /plugins/config -- merged onto
# the stored section, legacy booleans and schema defaults applied,
# filtered, validated, secrets split out -- so this route can't store
# a config that one rejects (stored verbatim, it left the plugin
# flagged degraded at its next load).
plugin_keys_to_remove = [] plugin_keys_to_remove = []
plugin_secrets_updates = {}
for key in data: for key in data:
# Check if this key is a plugin ID # Check if this key is a plugin ID
if api_v3.plugin_manager and key in api_v3.plugin_manager.plugin_manifests: if api_v3.plugin_manager and key in api_v3.plugin_manager.plugin_manifests:
plugin_id = key plugin_id = key
plugin_config = data[key] submitted_config = data[key]
if not isinstance(submitted_config, dict):
return error_response(
ErrorCode.VALIDATION_ERROR,
f"Settings for plugin '{plugin_id}' must be a JSON object",
status_code=400
)
# Load plugin schema to identify secret fields (same logic as save_plugin_config)
secret_fields = set()
if api_v3.plugin_manager:
plugins_dir = api_v3.plugin_manager.plugins_dir
else:
plugin_system_config = current_config.get('plugin_system', {})
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
if os.path.isabs(plugins_dir_name):
plugins_dir = Path(plugins_dir_name)
else:
plugins_dir = PROJECT_ROOT / plugins_dir_name
# plugin_id is already known to be a loaded plugin (the # plugin_id is already known to be a loaded plugin (the
# membership test above), so this cannot currently traverse -- # membership test above), so this cannot currently traverse --
# but the path is built from a request key, and the guard and # but the path is built from a request key, and the guard and
# the join are far enough apart that a later edit could # the schema load are far enough apart that a later edit could
# separate them. Build it through the shared helper instead. # separate them. Refuse rather than save without knowing which
schema_path = resolve_under(plugins_dir, plugin_id, 'config_schema.json') # fields are secrets.
schema_path = resolve_under(api_v3.plugin_manager.plugins_dir,
plugin_id, 'config_schema.json')
if schema_path is None: if schema_path is None:
return error_response( return error_response(
ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR,
@@ -986,77 +1028,48 @@ def save_main_config():
status_code=400 status_code=400
) )
if schema_path.exists(): schema_mgr = api_v3.schema_manager
try: if not schema_mgr:
with open(schema_path, 'r', encoding='utf-8') as f: return error_response(
schema = json.load(f) ErrorCode.SYSTEM_ERROR,
if 'properties' in schema: 'Schema manager not initialized',
secret_fields = find_secret_fields(schema['properties']) status_code=500
except Exception as e: )
logger.debug("Error reading schema for secret detection: %s", e) schema = schema_mgr.load_schema(plugin_id, use_cache=False)
# Separate secrets from regular config (same logic as save_plugin_config) from web_interface.blueprints.api_v3.plugins import (
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields) _merge_onto_stored_plugin_config, _prepare_plugin_config_for_save,
# The config form renders secrets masked, so every save posts )
# them back blank. Without this the blank is merged over the plugin_config = _merge_onto_stored_plugin_config(
# stored value and the credential is destroyed by the act of plugin_id, submitted_config, current_config)
# changing an unrelated setting. A blank means "unchanged". regular_config, secrets_config, error = _prepare_plugin_config_for_save(
secrets_config = remove_empty_secrets(secrets_config) plugin_id, plugin_config, schema, schema_mgr, is_json=True)
if error:
# PRE-PROCESSING: Preserve 'enabled' state if not in regular_config return error
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
if 'enabled' not in regular_config:
try:
if plugin_id in current_config and 'enabled' in current_config[plugin_id]:
regular_config['enabled'] = current_config[plugin_id]['enabled']
elif api_v3.plugin_manager:
# Fallback to plugin instance if config doesn't have it
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
regular_config['enabled'] = plugin_instance.enabled
# Final fallback: default to True if plugin is loaded (matches BasePlugin default)
if 'enabled' not in regular_config:
regular_config['enabled'] = True
except Exception as e:
logger.debug("Error preserving enabled state: %s", e)
# Default to True on error to avoid disabling plugins
regular_config['enabled'] = True
# Get current secrets config
current_secrets = api_v3.config_manager.get_raw_file_content('secrets')
# Deep merge regular config into main config # Deep merge regular config into main config
if plugin_id not in current_config: stored_section = current_config.get(plugin_id)
current_config[plugin_id] = {} current_config[plugin_id] = deep_merge(
current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config) stored_section if isinstance(stored_section, dict) else {}, regular_config)
# Deep merge secrets into secrets config
if secrets_config: if secrets_config:
if plugin_id not in current_secrets: plugin_secrets_updates[plugin_id] = secrets_config
current_secrets[plugin_id] = {}
# Lists merge by replacement, so deep_merge here wrote a
# blanked array straight over the stored credentials.
current_secrets[plugin_id] = merge_secrets(
current_secrets[plugin_id], secrets_config)
# Save secrets file
api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
# Mark for removal from data dict (already processed) # Mark for removal from data dict (already processed)
plugin_keys_to_remove.append(key) plugin_keys_to_remove.append(key)
# Notify plugin of config change if loaded (with merged config including secrets) # Deep merge secrets into secrets config, once every plugin section
try: # has validated
if api_v3.plugin_manager: if plugin_secrets_updates:
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) current_secrets = api_v3.config_manager.get_raw_file_content('secrets')
if plugin_instance: for plugin_id, secrets_config in plugin_secrets_updates.items():
# Reload merged config (includes secrets) and pass the plugin-specific section if plugin_id not in current_secrets:
merged_config = api_v3.config_manager.load_config() current_secrets[plugin_id] = {}
plugin_full_config = merged_config.get(plugin_id, {}) # Lists merge by replacement, so deep_merge here wrote a
if hasattr(plugin_instance, 'on_config_change'): # blanked array straight over the stored credentials.
plugin_instance.on_config_change(plugin_full_config) current_secrets[plugin_id] = merge_secrets(
except Exception as hook_err: current_secrets[plugin_id], secrets_config)
# Don't fail the save if hook fails # Save secrets file
logger.warning("on_config_change failed: %s", hook_err) api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
# Remove processed plugin keys from data (they're already in current_config) # Remove processed plugin keys from data (they're already in current_config)
for key in plugin_keys_to_remove: for key in plugin_keys_to_remove:
@@ -1105,6 +1118,19 @@ def save_main_config():
except ImportError: except ImportError:
pass pass
# Notify saved plugins of their new config (with secrets merged), now
# that it is on disk.
for plugin_id in plugin_keys_to_remove:
try:
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance and hasattr(plugin_instance, 'on_config_change'):
merged_config = api_v3.config_manager.load_config()
plugin_instance.on_config_change(_pkg._prepared_plugin_config(
plugin_id, merged_config.get(plugin_id, {})))
except Exception as hook_err:
# Don't fail the save if hook fails
logger.warning("on_config_change failed: %s", hook_err)
message = 'Configuration saved successfully' message = 'Configuration saved successfully'
# Switching automatic updates on finishes their setup, which needs # Switching automatic updates on finishes their setup, which needs
# the display service to restart (web_interface/auto_update.py). # the display service to restart (web_interface/auto_update.py).
@@ -1157,10 +1183,27 @@ def save_raw_main_config():
if not data: if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400 return jsonify({'status': 'error', 'message': 'No data provided'}), 400
was_auto_update_enabled = False
try:
previous = api_v3.config_manager.get_raw_file_content('main') or {}
was_auto_update_enabled = bool((previous.get('auto_update') or {}).get('enabled'))
except Exception:
logger.debug("Could not read the previous auto_update setting", exc_info=True)
# Save the raw config file # Save the raw config file
api_v3.config_manager.save_raw_file_content('main', data) api_v3.config_manager.save_raw_file_content('main', data)
return jsonify({'status': 'success', 'message': 'Main configuration saved successfully'}) message = 'Main configuration saved successfully'
# Same hook as save_main_config: switching automatic updates on here
# must finish their setup too, not wait for the next service restart.
try:
from web_interface import auto_update
note = auto_update.start_setup_if_needed(was_auto_update_enabled, data)
if note:
message = f'{message}. {note}'
except Exception:
logger.warning("Automatic update setup could not be started", exc_info=True)
return jsonify({'status': 'success', 'message': message})
except Exception as e: except Exception as e:
from src.exceptions import ConfigError from src.exceptions import ConfigError
logger.error("Error saving raw main config", exc_info=True) logger.error("Error saving raw main config", exc_info=True)
+434 -396
View File
@@ -792,12 +792,16 @@ def get_plugin_config():
main_config = api_v3.config_manager.load_config() main_config = api_v3.config_manager.load_config()
plugin_config = main_config.get(plugin_id, {}) plugin_config = main_config.get(plugin_id, {})
# Merge with defaults from schema so form shows default values for missing fields # Merge with defaults from schema so form shows default values for
# missing fields, reading legacy booleans as objects first: what the
# plugin runs with, and what posts back through the JSON save
schema_mgr = api_v3.schema_manager schema_mgr = api_v3.schema_manager
if schema_mgr: if schema_mgr:
try: try:
from src.plugin_system.schema_manager import prepare_plugin_config
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True) defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults) plugin_config = prepare_plugin_config(
plugin_config, schema_mgr.load_schema(plugin_id, use_cache=True), defaults)
except Exception as e: except Exception as e:
# Log but don't fail - defaults merge is best effort # Log but don't fail - defaults merge is best effort
import logging import logging
@@ -1746,7 +1750,14 @@ def save_plugin_config():
if error: if error:
return error return error
plugin_id = data['plugin_id'] plugin_id = data['plugin_id']
plugin_config = data.get('config', {}) submitted_config = data.get('config', {})
if not isinstance(submitted_config, dict):
return error_response(
ErrorCode.INVALID_INPUT,
'config must be a JSON object',
status_code=400
)
plugin_config = _merge_onto_stored_plugin_config(plugin_id, submitted_config)
else: else:
# Form data (HTMX submission) # Form data (HTMX submission)
# plugin_id comes from query string, config from form fields # plugin_id comes from query string, config from form fields
@@ -2183,398 +2194,11 @@ def save_plugin_config():
if 'application/json' in content_type: if 'application/json' in content_type:
schema = schema_mgr.load_schema(plugin_id, use_cache=False) schema = schema_mgr.load_schema(plugin_id, use_cache=False)
# JSON path: fix numeric-keyed dicts that should be arrays. regular_config, secrets_config, error = _prepare_plugin_config_for_save(
# JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}} plugin_id, plugin_config, schema, schema_mgr,
# instead of [{name:...}]. The form-data path has fix_array_structures for this; is_json='application/json' in content_type)
# mirror that logic here for JSON submissions. if error:
if 'application/json' in content_type and schema and 'properties' in schema: return error
def _fix_json_arrays(cfg, props):
for k, ps in props.items():
if not isinstance(cfg, dict) or k not in cfg:
continue
pt = ps.get('type')
val = cfg[k]
if pt == 'array':
items_schema = ps.get('items', {})
item_type = items_schema.get('type')
if isinstance(val, dict):
keys = list(val.keys())
if keys and all(str(x).isdigit() for x in keys):
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
arr = [val[sk] for sk in sorted_keys]
if item_type in ('integer', 'number'):
converted = []
for v in arr:
if isinstance(v, str):
try:
converted.append(int(v) if item_type == 'integer' else float(v))
except (ValueError, TypeError, OverflowError):
converted.append(v)
else:
converted.append(v)
arr = converted
cfg[k] = arr
elif not keys:
cfg[k] = []
# Recurse into each element when items are objects with properties,
# covering both freshly-converted and already-list values.
if item_type == 'object' and 'properties' in items_schema:
for elem in (cfg[k] if isinstance(cfg[k], list) else []):
if isinstance(elem, dict):
_fix_json_arrays(elem, items_schema['properties'])
elif pt == 'object' and 'properties' in ps and isinstance(val, dict):
_fix_json_arrays(val, ps['properties'])
_fix_json_arrays(plugin_config, schema['properties'])
# PRE-PROCESSING: Preserve 'enabled' state if not in request
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
if 'enabled' not in plugin_config:
try:
current_config = api_v3.config_manager.load_config()
if plugin_id in current_config and 'enabled' in current_config[plugin_id]:
plugin_config['enabled'] = current_config[plugin_id]['enabled']
# logger.debug(f"Preserving enabled state for {plugin_id}: {plugin_config['enabled']}")
elif api_v3.plugin_manager:
# Fallback to plugin instance if config doesn't have it
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
plugin_config['enabled'] = plugin_instance.enabled
# Final fallback: default to True if plugin is loaded (matches BasePlugin default)
if 'enabled' not in plugin_config:
plugin_config['enabled'] = True
except Exception as e:
logger.debug("Error preserving enabled state: %s", e)
# Default to True on error to avoid disabling plugins
plugin_config['enabled'] = True
# Find secret fields (supports nested schemas and array-item secrets)
secret_fields = set()
if schema and 'properties' in schema:
secret_fields = find_secret_fields(schema['properties'])
# Apply defaults from schema to config BEFORE validation
# This ensures required fields with defaults are present before validation
# Store preserved enabled value before merge to protect it from defaults
preserved_enabled = None
if 'enabled' in plugin_config:
preserved_enabled = plugin_config['enabled']
if schema:
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
# After merging defaults, replace any None array values with their schema defaults.
# merge_with_defaults gives user config higher priority, so a None submitted by
# the client can survive the merge — this pass cleans those up.
def _fix_none_arrays(cfg, props):
for k, pschema in props.items():
if pschema.get('type') == 'array':
if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None):
cfg[k] = pschema.get('default', [])
elif pschema.get('type') == 'object' and 'properties' in pschema:
if isinstance(cfg, dict) and isinstance(cfg.get(k), dict):
_fix_none_arrays(cfg[k], pschema['properties'])
if schema and 'properties' in schema and isinstance(plugin_config, dict):
_fix_none_arrays(plugin_config, schema['properties'])
# Ensure enabled state is preserved after defaults merge
# Defaults should not overwrite an explicitly preserved enabled value
if preserved_enabled is not None:
# Restore preserved value if it was changed by defaults merge
if plugin_config.get('enabled') != preserved_enabled:
plugin_config['enabled'] = preserved_enabled
# Normalize config data: convert string numbers to integers/floats where schema expects numbers
# This handles form data which sends everything as strings
def normalize_config_values(config, schema_props, prefix=''):
"""Recursively normalize config values based on schema types"""
if not isinstance(config, dict) or not isinstance(schema_props, dict):
return config
normalized = {}
for key, value in config.items():
field_path = f"{prefix}.{key}" if prefix else key
if key not in schema_props:
# Field not in schema, keep as-is (will be caught by additionalProperties check if needed)
normalized[key] = value
continue
prop_schema = schema_props[key]
prop_type = prop_schema.get('type')
# Handle union types (e.g., ["integer", "null"])
if isinstance(prop_type, list):
# Check if null is allowed and value is empty/null
if 'null' in prop_type:
# Handle various representations of null/empty
if value is None:
normalized[key] = None
continue
elif isinstance(value, str):
# Strip whitespace and check for null representations
value_stripped = value.strip()
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
normalized[key] = None
continue
# Try to normalize based on non-null types in the union
# Check integer first (more specific than number)
if 'integer' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '':
# Empty string with null allowed - already handled above, but double-check
if 'null' in prop_type:
normalized[key] = None
continue
try:
normalized[key] = int(value_stripped)
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = int(value)
continue
# Check number (less specific, but handles floats)
if 'number' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '':
# Empty string with null allowed - already handled above, but double-check
if 'null' in prop_type:
normalized[key] = None
continue
try:
normalized[key] = float(value_stripped)
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = float(value)
continue
# Check boolean
if 'boolean' in prop_type:
if isinstance(value, str):
normalized[key] = value.strip().lower() in ('true', '1', 'on', 'yes')
continue
# If no conversion worked and null is allowed, try to set to None
# This handles cases where the value is an empty string or can't be converted
if 'null' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
normalized[key] = None
continue
# If it's already None, keep it
if value is None:
normalized[key] = None
continue
# If no conversion worked, keep original value (will fail validation, but that's expected)
# Log a warning for debugging
logger.warning(f"Could not normalize field {field_path}: value={repr(value)}, type={type(value)}, schema_type={prop_type}")
normalized[key] = value
continue
if isinstance(value, dict) and prop_type == 'object' and 'properties' in prop_schema:
# Recursively normalize nested objects
normalized[key] = normalize_config_values(value, prop_schema['properties'], field_path)
elif isinstance(value, list) and prop_type == 'array' and 'items' in prop_schema:
# Normalize array items
items_schema = prop_schema['items']
item_type = items_schema.get('type')
# Handle union types in array items
if isinstance(item_type, list):
normalized_array = []
for v in value:
# Check if null is allowed
if 'null' in item_type:
if v is None or v == '' or (isinstance(v, str) and v.lower() in ('null', 'none')):
normalized_array.append(None)
continue
# Try to normalize based on non-null types
if 'integer' in item_type:
if isinstance(v, str):
try:
normalized_array.append(int(v))
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
continue
elif 'number' in item_type:
if isinstance(v, str):
try:
normalized_array.append(float(v))
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(float(v))
continue
# If no conversion worked, keep original value
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'integer':
# Convert string numbers to integers
normalized_array = []
for v in value:
if isinstance(v, str):
try:
normalized_array.append(int(v))
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
else:
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'number':
# Convert string numbers to floats
normalized_array = []
for v in value:
if isinstance(v, str):
try:
normalized_array.append(float(v))
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
else:
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'object' and 'properties' in items_schema:
# Recursively normalize array of objects
normalized_array = []
for v in value:
if isinstance(v, dict):
normalized_array.append(
normalize_config_values(v, items_schema['properties'], f"{field_path}[]")
)
else:
normalized_array.append(v)
normalized[key] = normalized_array
else:
normalized[key] = value
elif prop_type == 'integer':
# Convert string to integer
if isinstance(value, str):
try:
normalized[key] = int(value)
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
elif prop_type == 'number':
# Convert string to float
if isinstance(value, str):
try:
normalized[key] = float(value)
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
elif prop_type == 'boolean':
# Convert string booleans
if isinstance(value, str):
normalized[key] = value.lower() in ('true', '1', 'on', 'yes')
else:
normalized[key] = value
else:
normalized[key] = value
return normalized
# Normalize config before validation
if schema and 'properties' in schema:
plugin_config = normalize_config_values(plugin_config, schema['properties'])
# Filter config to only include schema-defined fields (important when additionalProperties is false)
# Use enhanced schema with core properties to ensure core properties are preserved during filtering
if schema and 'properties' in schema:
enhanced_schema_for_filtering = _enhance_schema_with_core_properties(schema)
plugin_config = _filter_config_by_schema(plugin_config, enhanced_schema_for_filtering)
# Debug logging for union type fields (temporary)
if 'rotation_settings' in plugin_config and 'random_seed' in plugin_config.get('rotation_settings', {}):
seed_value = plugin_config['rotation_settings']['random_seed']
logger.debug(f"After normalization, random_seed value: {repr(seed_value)}, type: {type(seed_value)}")
# Validate configuration against schema before saving
if schema:
# Log what we're validating for debugging
logger.info(f"Validating config for {plugin_id}")
# Only the shape. plugin_config still holds the submitted secret
# values at this point -- separate_secrets does not run until
# below -- so logging it wrote live credentials to the journal.
logger.info(f"Config keys being validated: {list(plugin_config.keys())}")
# Get enhanced schema keys (including injected core properties)
# We need to create an enhanced schema to get the actual allowed keys
import copy
enhanced_schema = copy.deepcopy(schema)
if "properties" not in enhanced_schema:
enhanced_schema["properties"] = {}
# Core properties that are always injected during validation
core_properties = ["enabled", "display_duration", "live_priority"]
for prop_name in core_properties:
if prop_name not in enhanced_schema["properties"]:
# Add placeholder to get the full list of allowed keys
enhanced_schema["properties"][prop_name] = {"type": "any"}
is_valid, validation_errors = schema_mgr.validate_config_against_schema(
plugin_config, schema, plugin_id
)
if not is_valid:
# Log validation errors for debugging
logger.error(f"Config validation failed for {plugin_id}")
logger.error(f"Validation errors: {validation_errors}")
# Keys only, for the same reason as above.
logger.error(f"Config keys that failed: {list(plugin_config.keys())}")
logger.error(f"Schema properties: {list(enhanced_schema.get('properties', {}).keys())}")
# Also print to console for immediate visibility
import json
logger.warning("Config validation failed for plugin (see debug logs)")
# Log raw form data if this was a form submission
if 'application/json' not in (request.content_type or ''):
form_data = request.form.to_dict()
return error_response(
ErrorCode.CONFIG_VALIDATION_FAILED,
'Configuration validation failed',
details='; '.join(validation_errors) if validation_errors else 'Unknown validation error',
context={
'plugin_id': plugin_id,
'validation_errors': validation_errors,
'config_keys': list(plugin_config.keys()),
'schema_keys': list(enhanced_schema.get('properties', {}).keys())
},
suggested_fixes=[
'Review validation errors above',
'Check config against schema',
'Verify all required fields are present'
],
status_code=400
)
# Separate secrets from regular config (handles nested configs and
# array-item secrets — see src/web_interface/secret_helpers.py)
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
# The config form renders secrets masked, so every save posts
# them back blank. Without this the blank is merged over the
# stored value and the credential is destroyed by the act of
# changing an unrelated setting. A blank means "unchanged".
secrets_config = remove_empty_secrets(secrets_config)
# Get current configs # Get current configs
current_config = api_v3.config_manager.load_config() current_config = api_v3.config_manager.load_config()
@@ -2647,7 +2271,8 @@ def save_plugin_config():
if plugin_instance: if plugin_instance:
# Reload merged config (includes secrets) and pass the plugin-specific section # Reload merged config (includes secrets) and pass the plugin-specific section
merged_config = api_v3.config_manager.load_config() merged_config = api_v3.config_manager.load_config()
plugin_full_config = merged_config.get(plugin_id, {}) plugin_full_config = _pkg._prepared_plugin_config(
plugin_id, merged_config.get(plugin_id, {}))
if hasattr(plugin_instance, 'on_config_change'): if hasattr(plugin_instance, 'on_config_change'):
plugin_instance.on_config_change(plugin_full_config) plugin_instance.on_config_change(plugin_full_config)
@@ -2698,6 +2323,419 @@ def save_plugin_config():
context=error.context, context=error.context,
status_code=500 status_code=500
) )
def _merge_onto_stored_plugin_config(plugin_id, submitted_config, current_config=None):
"""A JSON plugin-config body merged onto the plugin's stored section.
A JSON body carries the settings being changed, as a form post does: the
rest keep their stored values. Built from defaults alone, a body such as
``{"enabled": true}`` reset every unsent setting of the plugin.
``current_config`` is the loaded main config when the caller has one.
"""
import copy
if current_config is None:
current_config = api_v3.config_manager.load_config()
stored = current_config.get(plugin_id)
base = copy.deepcopy(stored) if isinstance(stored, dict) else {}
return deep_merge(base, submitted_config)
def _prepare_plugin_config_for_save(plugin_id, plugin_config, schema, schema_mgr, is_json):
"""Turn a submitted plugin config into what gets stored.
Fixes array shapes, keeps the enabled state, reads legacy booleans and
applies schema defaults, normalizes value types, filters to the schema plus
the core-owned per-plugin properties, validates, and splits out secrets.
Shared by POST /plugins/config and plugin sections posted to
POST /config/main, so both store the same thing.
Returns ``(regular_config, secrets_config, None)``, or
``(None, None, error_response)`` when validation fails.
"""
# JSON path: fix numeric-keyed dicts that should be arrays.
# JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}}
# instead of [{name:...}]. The form-data path has fix_array_structures for this;
# mirror that logic here for JSON submissions.
if is_json and schema and 'properties' in schema:
def _fix_json_arrays(cfg, props):
for k, ps in props.items():
if not isinstance(cfg, dict) or k not in cfg:
continue
pt = ps.get('type')
val = cfg[k]
if pt == 'array':
items_schema = ps.get('items', {})
item_type = items_schema.get('type')
if isinstance(val, dict):
keys = list(val.keys())
if keys and all(str(x).isdigit() for x in keys):
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
arr = [val[sk] for sk in sorted_keys]
if item_type in ('integer', 'number'):
converted = []
for v in arr:
if isinstance(v, str):
try:
converted.append(int(v) if item_type == 'integer' else float(v))
except (ValueError, TypeError, OverflowError):
converted.append(v)
else:
converted.append(v)
arr = converted
cfg[k] = arr
elif not keys:
cfg[k] = []
# Recurse into each element when items are objects with properties,
# covering both freshly-converted and already-list values.
if item_type == 'object' and 'properties' in items_schema:
for elem in (cfg[k] if isinstance(cfg[k], list) else []):
if isinstance(elem, dict):
_fix_json_arrays(elem, items_schema['properties'])
elif pt == 'object' and 'properties' in ps and isinstance(val, dict):
_fix_json_arrays(val, ps['properties'])
_fix_json_arrays(plugin_config, schema['properties'])
# PRE-PROCESSING: Preserve 'enabled' state if not in request
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
if 'enabled' not in plugin_config:
try:
current_config = api_v3.config_manager.load_config()
if plugin_id in current_config and 'enabled' in current_config[plugin_id]:
plugin_config['enabled'] = current_config[plugin_id]['enabled']
# logger.debug(f"Preserving enabled state for {plugin_id}: {plugin_config['enabled']}")
elif api_v3.plugin_manager:
# Fallback to plugin instance if config doesn't have it
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
plugin_config['enabled'] = plugin_instance.enabled
# Final fallback: default to True if plugin is loaded (matches BasePlugin default)
if 'enabled' not in plugin_config:
plugin_config['enabled'] = True
except Exception as e:
logger.debug("Error preserving enabled state: %s", e)
# Default to True on error to avoid disabling plugins
plugin_config['enabled'] = True
# Find secret fields (supports nested schemas and array-item secrets)
secret_fields = set()
if schema and 'properties' in schema:
secret_fields = find_secret_fields(schema['properties'])
# Apply defaults from schema to config BEFORE validation
# This ensures required fields with defaults are present before validation
# Store preserved enabled value before merge to protect it from defaults
preserved_enabled = None
if 'enabled' in plugin_config:
preserved_enabled = plugin_config['enabled']
if schema:
# Legacy booleans read as {"enabled": ...} objects first (#588), the
# same preparation loading applies, so a config the device reads
# without complaint also saves.
from src.plugin_system.schema_manager import prepare_plugin_config
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
plugin_config = prepare_plugin_config(plugin_config, schema, defaults)
# After merging defaults, replace any None array values with their schema defaults.
# merge_with_defaults gives user config higher priority, so a None submitted by
# the client can survive the merge — this pass cleans those up.
def _fix_none_arrays(cfg, props):
for k, pschema in props.items():
if pschema.get('type') == 'array':
if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None):
cfg[k] = pschema.get('default', [])
elif pschema.get('type') == 'object' and 'properties' in pschema:
if isinstance(cfg, dict) and isinstance(cfg.get(k), dict):
_fix_none_arrays(cfg[k], pschema['properties'])
if schema and 'properties' in schema and isinstance(plugin_config, dict):
_fix_none_arrays(plugin_config, schema['properties'])
# Ensure enabled state is preserved after defaults merge
# Defaults should not overwrite an explicitly preserved enabled value
if preserved_enabled is not None:
# Restore preserved value if it was changed by defaults merge
if plugin_config.get('enabled') != preserved_enabled:
plugin_config['enabled'] = preserved_enabled
# Normalize config data: convert string numbers to integers/floats where schema expects numbers
# This handles form data which sends everything as strings
def normalize_config_values(config, schema_props, prefix=''):
"""Recursively normalize config values based on schema types"""
if not isinstance(config, dict) or not isinstance(schema_props, dict):
return config
normalized = {}
for key, value in config.items():
field_path = f"{prefix}.{key}" if prefix else key
if key not in schema_props:
# Field not in schema, keep as-is (will be caught by additionalProperties check if needed)
normalized[key] = value
continue
prop_schema = schema_props[key]
prop_type = prop_schema.get('type')
# Handle union types (e.g., ["integer", "null"])
if isinstance(prop_type, list):
# Check if null is allowed and value is empty/null
if 'null' in prop_type:
# Handle various representations of null/empty
if value is None:
normalized[key] = None
continue
elif isinstance(value, str):
# Strip whitespace and check for null representations
value_stripped = value.strip()
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
normalized[key] = None
continue
# Try to normalize based on non-null types in the union
# Check integer first (more specific than number)
if 'integer' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '':
# Empty string with null allowed - already handled above, but double-check
if 'null' in prop_type:
normalized[key] = None
continue
try:
normalized[key] = int(value_stripped)
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = int(value)
continue
# Check number (less specific, but handles floats)
if 'number' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '':
# Empty string with null allowed - already handled above, but double-check
if 'null' in prop_type:
normalized[key] = None
continue
try:
normalized[key] = float(value_stripped)
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = float(value)
continue
# Check boolean
if 'boolean' in prop_type:
if isinstance(value, str):
normalized[key] = value.strip().lower() in ('true', '1', 'on', 'yes')
continue
# If no conversion worked and null is allowed, try to set to None
# This handles cases where the value is an empty string or can't be converted
if 'null' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
normalized[key] = None
continue
# If it's already None, keep it
if value is None:
normalized[key] = None
continue
# If no conversion worked, keep original value (will fail validation, but that's expected)
# Log a warning for debugging
logger.warning(f"Could not normalize field {field_path}: value={repr(value)}, type={type(value)}, schema_type={prop_type}")
normalized[key] = value
continue
if isinstance(value, dict) and prop_type == 'object' and 'properties' in prop_schema:
# Recursively normalize nested objects
normalized[key] = normalize_config_values(value, prop_schema['properties'], field_path)
elif isinstance(value, list) and prop_type == 'array' and 'items' in prop_schema:
# Normalize array items
items_schema = prop_schema['items']
item_type = items_schema.get('type')
# Handle union types in array items
if isinstance(item_type, list):
normalized_array = []
for v in value:
# Check if null is allowed
if 'null' in item_type:
if v is None or v == '' or (isinstance(v, str) and v.lower() in ('null', 'none')):
normalized_array.append(None)
continue
# Try to normalize based on non-null types
if 'integer' in item_type:
if isinstance(v, str):
try:
normalized_array.append(int(v))
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
continue
elif 'number' in item_type:
if isinstance(v, str):
try:
normalized_array.append(float(v))
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(float(v))
continue
# If no conversion worked, keep original value
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'integer':
# Convert string numbers to integers
normalized_array = []
for v in value:
if isinstance(v, str):
try:
normalized_array.append(int(v))
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
else:
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'number':
# Convert string numbers to floats
normalized_array = []
for v in value:
if isinstance(v, str):
try:
normalized_array.append(float(v))
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
else:
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'object' and 'properties' in items_schema:
# Recursively normalize array of objects
normalized_array = []
for v in value:
if isinstance(v, dict):
normalized_array.append(
normalize_config_values(v, items_schema['properties'], f"{field_path}[]")
)
else:
normalized_array.append(v)
normalized[key] = normalized_array
else:
normalized[key] = value
elif prop_type == 'integer':
# Convert string to integer
if isinstance(value, str):
try:
normalized[key] = int(value)
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
elif prop_type == 'number':
# Convert string to float
if isinstance(value, str):
try:
normalized[key] = float(value)
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
elif prop_type == 'boolean':
# Convert string booleans
if isinstance(value, str):
normalized[key] = value.lower() in ('true', '1', 'on', 'yes')
else:
normalized[key] = value
else:
normalized[key] = value
return normalized
# Normalize config before validation
if schema and 'properties' in schema:
plugin_config = normalize_config_values(plugin_config, schema['properties'])
# Filter config to only include schema-defined fields (important when additionalProperties is false)
# Use enhanced schema with core properties to ensure core properties are preserved during filtering
if schema and 'properties' in schema:
enhanced_schema_for_filtering = _enhance_schema_with_core_properties(schema)
plugin_config = _filter_config_by_schema(plugin_config, enhanced_schema_for_filtering)
# Debug logging for union type fields (temporary)
if 'rotation_settings' in plugin_config and 'random_seed' in plugin_config.get('rotation_settings', {}):
seed_value = plugin_config['rotation_settings']['random_seed']
logger.debug(f"After normalization, random_seed value: {repr(seed_value)}, type: {type(seed_value)}")
# Validate configuration against schema before saving
if schema:
# Log what we're validating for debugging
logger.info(f"Validating config for {plugin_id}")
# Only the shape. plugin_config still holds the submitted secret
# values at this point -- separate_secrets does not run until
# below -- so logging it wrote live credentials to the journal.
logger.info(f"Config keys being validated: {list(plugin_config.keys())}")
# Schema keys including the injected core properties, for the error
enhanced_schema = _enhance_schema_with_core_properties(schema)
is_valid, validation_errors = schema_mgr.validate_config_against_schema(
plugin_config, schema, plugin_id
)
if not is_valid:
# Log validation errors for debugging
logger.error(f"Config validation failed for {plugin_id}")
logger.error(f"Validation errors: {validation_errors}")
# Keys only, for the same reason as above.
logger.error(f"Config keys that failed: {list(plugin_config.keys())}")
logger.error(f"Schema properties: {list(enhanced_schema.get('properties', {}).keys())}")
# Also print to console for immediate visibility
logger.warning("Config validation failed for plugin (see debug logs)")
return None, None, error_response(
ErrorCode.CONFIG_VALIDATION_FAILED,
'Configuration validation failed',
details='; '.join(validation_errors) if validation_errors else 'Unknown validation error',
context={
'plugin_id': plugin_id,
'validation_errors': validation_errors,
'config_keys': list(plugin_config.keys()),
'schema_keys': list(enhanced_schema.get('properties', {}).keys())
},
suggested_fixes=[
'Review validation errors above',
'Check config against schema',
'Verify all required fields are present'
],
status_code=400
)
# Separate secrets from regular config (handles nested configs and
# array-item secrets — see src/web_interface/secret_helpers.py)
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
# The config form renders secrets masked, so every save posts
# them back blank. Without this the blank is merged over the
# stored value and the credential is destroyed by the act of
# changing an unrelated setting. A blank means "unchanged".
secrets_config = remove_empty_secrets(secrets_config)
return regular_config, secrets_config, None
@api_v3.route('/plugins/schema', methods=['GET']) @api_v3.route('/plugins/schema', methods=['GET'])
def get_plugin_schema(): def get_plugin_schema():
"""Get plugin configuration schema""" """Get plugin configuration schema"""
@@ -39,6 +39,10 @@
class="space-y-6" class="space-y-6"
novalidate novalidate
onsubmit="fixInvalidNumberInputs(this); return true;"> onsubmit="fixInvalidNumberInputs(this); return true;">
{# Marks this post as the whole form, so an unchecked box saves as
false. Without it the save endpoint treats absent keys as unchanged
(FORM_SECTION_FIELD in api_v3/config.py). #}
<input type="hidden" name="__form_section" value="display">
<!-- Hardware Settings --> <!-- Hardware Settings -->
<div class="bg-gray-50 rounded-lg p-4"> <div class="bg-gray-50 rounded-lg p-4">
@@ -15,6 +15,8 @@
class="space-y-6" class="space-y-6"
novalidate novalidate
onsubmit="fixInvalidNumberInputs(this); return true;"> onsubmit="fixInvalidNumberInputs(this); return true;">
{# See general.html: identifies a whole-form post to /config/main. #}
<input type="hidden" name="__form_section" value="durations">
<!-- Primary rotation order: drag to reorder which plugin shows first, <!-- Primary rotation order: drag to reorder which plugin shows first,
second, ... in the normal display rotation. Saved as second, ... in the normal display rotation. Saved as
@@ -30,6 +30,10 @@
showNotification(message, status); showNotification(message, status);
" "
class="space-y-6"> class="space-y-6">
{# Marks this post as the whole form, so an unchecked box saves as
false. Without it the save endpoint treats absent keys as unchanged
(FORM_SECTION_FIELD in api_v3/config.py). #}
<input type="hidden" name="__form_section" value="general">
<!-- Web Display Autostart --> <!-- Web Display Autostart -->
<div class="form-group" id="setting-general-web_display_autostart" data-setting-key="web_display_autostart"> <div class="form-group" id="setting-general-web_display_autostart" data-setting-key="web_display_autostart">