mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
feat(ipc): control socket stage 2 - wake the render thread, brightness.set, plugin.reload (#720)
Control socket stage 2: the render thread wakes for queued commands (static screens ~1 ms, Vegas within one frame), brightness.set, and plugin.reload after a store update, with mailbox/restart fallbacks. Rig checks listed in the PR body are still to run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,7 @@ from src.common.path_safety import resolve_under, safe_path_component
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
|
||||
from src.backup_manager import BUNDLED_FONTS as _BUNDLED_FONTS
|
||||
from src.device_location import DeviceLocationResolver, apply_device_location
|
||||
from src.ipc import client as control_client
|
||||
_SUDO = shutil.which('sudo')
|
||||
_JOURNALCTL = shutil.which('journalctl')
|
||||
_GIT = shutil.which('git')
|
||||
@@ -776,6 +777,83 @@ def _store_restart_fields(action: str, plugin_enabled: bool, **kwargs) -> Dict[s
|
||||
return fields
|
||||
|
||||
|
||||
# -- the control socket (src/ipc) ---------------------------------------------------
|
||||
|
||||
#: Socket failures that only mean "this display has no socket": stopped,
|
||||
#: older than the socket, Windows, or switched off. Not worth a log line.
|
||||
_QUIET_SOCKET_REASONS = frozenset({'no_socket', 'disabled', 'unsupported'})
|
||||
|
||||
#: Every reason code a response may echo as a socket error: the client's
|
||||
#: transport reasons plus the display's ErrorCode values. Anything else is
|
||||
#: reported as ``other``, so no text taken from an exception reaches a reply.
|
||||
_REPORTABLE_SOCKET_REASONS = (
|
||||
'disabled', 'unsupported', 'no_socket', 'refused', 'timeout', 'closed',
|
||||
'bad_response', 'invalid_request',
|
||||
'bad_json', 'bad_request', 'message_too_large', 'unsupported_version',
|
||||
'unknown_command', 'invalid_args', 'busy', 'forbidden', 'internal',
|
||||
'pending', 'not_loaded', 'failed',
|
||||
)
|
||||
|
||||
|
||||
def _socket_reason_code(reason):
|
||||
"""``reason`` as one of _REPORTABLE_SOCKET_REASONS, else ``'other'``."""
|
||||
return next((code for code in _REPORTABLE_SOCKET_REASONS if code == reason), 'other')
|
||||
|
||||
|
||||
def _log_socket_failure(what: str, error: Exception, reason: str) -> None:
|
||||
if reason in _QUIET_SOCKET_REASONS:
|
||||
logger.debug("%s not sent over the control socket: %s", what, error)
|
||||
else:
|
||||
logger.warning("Control socket did not take %s (%s)", what, error)
|
||||
|
||||
|
||||
def _reload_after_store_update(plugin_id: str, fields: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Reload an updated, enabled plugin on the running display.
|
||||
|
||||
``fields`` are ``_store_restart_fields('update', ...)``. When they ask
|
||||
for a restart, the display is asked over the control socket to reload
|
||||
the plugin instead (``plugin.reload``), and the answer becomes
|
||||
``restart_required: false`` with ``reloaded: true`` once the new code is
|
||||
running. Any failure -- no socket, a display that predates the command,
|
||||
a plugin it is not running, a load that failed, no answer in time --
|
||||
keeps ``fields`` as they were, adding ``reload_error`` with the reason.
|
||||
"""
|
||||
if not fields.get('restart_required'):
|
||||
return fields
|
||||
try:
|
||||
result = control_client.plugin_reload(plugin_id)
|
||||
except control_client.ControlError as e:
|
||||
reason = _socket_reason_code(e.reason)
|
||||
_log_socket_failure(f'plugin.reload {plugin_id}', e, reason)
|
||||
return {**fields, 'reload_error': reason}
|
||||
except Exception: # never let the socket path break the route
|
||||
logger.exception("Control socket client failed reloading %s", plugin_id)
|
||||
return {**fields, 'reload_error': 'internal'}
|
||||
version = result.get('version')
|
||||
return {'restart_required': False, 'reloaded': True,
|
||||
'reloaded_version': version if isinstance(version, str) else None}
|
||||
|
||||
|
||||
def _apply_brightness_on_display(brightness: int) -> Dict[str, Any]:
|
||||
"""Put a just-saved brightness on the panel now, over the control socket.
|
||||
|
||||
Without the socket the display's config watcher applies the saved value
|
||||
within a few seconds, as it always has; the answer says which happened:
|
||||
``brightness_transport`` is ``socket`` or ``config``, and in the second
|
||||
case ``brightness_socket_error`` gives the reason.
|
||||
"""
|
||||
try:
|
||||
control_client.brightness_set(int(brightness))
|
||||
return {'brightness_transport': 'socket'}
|
||||
except control_client.ControlError as e:
|
||||
reason = _socket_reason_code(e.reason)
|
||||
_log_socket_failure('brightness.set', e, reason)
|
||||
except Exception: # never let the socket path break the route
|
||||
logger.exception("Control socket client failed setting brightness")
|
||||
reason = 'internal'
|
||||
return {'brightness_transport': 'config', 'brightness_socket_error': reason}
|
||||
|
||||
|
||||
def deep_merge(base_dict, update_dict):
|
||||
"""
|
||||
Deep merge update_dict into base_dict.
|
||||
|
||||
@@ -1193,7 +1193,15 @@ def save_main_config():
|
||||
# Display hardware, rotation/durations and general settings take
|
||||
# effect after a display restart; the UI shows its restart banner on
|
||||
# this flag.
|
||||
return success_response(message=message, extra={'restart_required': True})
|
||||
extra = {'restart_required': True}
|
||||
# Brightness is the exception: the display applies a saved one
|
||||
# without a restart. Over the control socket it lands at once,
|
||||
# instead of when the config watcher next looks (up to ~2 s).
|
||||
if 'brightness' in data:
|
||||
saved = (current_config.get('display', {}).get('hardware', {}) or {}).get('brightness')
|
||||
if isinstance(saved, int) and not isinstance(saved, bool):
|
||||
extra.update(_pkg._apply_brightness_on_display(saved))
|
||||
return success_response(message=message, extra=extra)
|
||||
except Exception as e:
|
||||
logger.error("Error saving config", exc_info=True)
|
||||
return error_response(
|
||||
|
||||
@@ -4,8 +4,9 @@ Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
||||
so their endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
_QUIET_SOCKET_REASONS, _REPORTABLE_SOCKET_REASONS, # noqa: F401 - tests read them here
|
||||
_coerce_to_bool, _ensure_display_service_running,
|
||||
_get_display_service_status, _stop_display_service, api_v3,
|
||||
_get_display_service_status, _socket_reason_code, _stop_display_service, api_v3,
|
||||
jsonify, logger, request, uuid,
|
||||
)
|
||||
from web_interface import display_preview
|
||||
@@ -30,24 +31,6 @@ def _cache_manager():
|
||||
return cache
|
||||
|
||||
|
||||
#: Socket failures that only mean "this display has no socket": stopped,
|
||||
#: older than the socket, Windows, or switched off. Not worth a log line.
|
||||
_QUIET_SOCKET_REASONS = frozenset({'no_socket', 'disabled', 'unsupported'})
|
||||
|
||||
#: Every reason code a response may echo as ``socket_error``: the client's
|
||||
#: transport reasons plus the display's ErrorCode values. Anything else is
|
||||
#: reported as ``other``, so no text taken from an exception reaches a reply.
|
||||
_REPORTABLE_SOCKET_REASONS = (
|
||||
'disabled', 'unsupported', 'no_socket', 'refused', 'timeout', 'closed',
|
||||
'bad_response', 'invalid_request',
|
||||
'bad_json', 'bad_request', 'message_too_large', 'unsupported_version',
|
||||
'unknown_command', 'invalid_args', 'busy', 'forbidden', 'internal',
|
||||
)
|
||||
|
||||
|
||||
def _socket_reason_code(reason):
|
||||
"""``reason`` as one of _REPORTABLE_SOCKET_REASONS, else ``'other'``."""
|
||||
return next((code for code in _REPORTABLE_SOCKET_REASONS if code == reason), 'other')
|
||||
|
||||
|
||||
def _deliver_on_demand(payload):
|
||||
|
||||
@@ -7,7 +7,7 @@ so their endpoint names do not depend on which module they live in.
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
ErrorCode, OperationType, Path, _do_transactional_uninstall,
|
||||
_non_plugin_id_error, _get_plugin_version, _plugin_directory,
|
||||
_plugin_enabled_in_config, _store_restart_fields, api_v3,
|
||||
_plugin_enabled_in_config, _reload_after_store_update, _store_restart_fields, api_v3,
|
||||
error_response, exception_error_response, json, jsonify, logger,
|
||||
request, success_response, validate_request_json,
|
||||
)
|
||||
@@ -181,12 +181,20 @@ def update_plugin():
|
||||
if success:
|
||||
updated_last_updated = current_last_updated
|
||||
updated_version = current_version
|
||||
# The id the display knows the plugin by (and keys its config
|
||||
# section on) is the manifest's, which can differ from the
|
||||
# store id this route was given (an alias such as weather /
|
||||
# ledmatrix-weather).
|
||||
display_plugin_id = plugin_id
|
||||
try:
|
||||
if manifest_path is not None and manifest_path.exists():
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
updated_last_updated = manifest.get('last_updated', current_last_updated)
|
||||
updated_version = manifest.get('version', current_version)
|
||||
manifest_id = manifest.get('id')
|
||||
if isinstance(manifest_id, str) and safe_path_component(manifest_id):
|
||||
display_plugin_id = manifest_id
|
||||
except Exception as e:
|
||||
logger.debug("Could not read updated manifest after update: %s", e)
|
||||
|
||||
@@ -230,12 +238,19 @@ def update_plugin():
|
||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||
|
||||
# Rediscover plugins. The web process runs no plugin code, so
|
||||
# there is nothing here to reload: the display keeps running the
|
||||
# version it loaded until it restarts, which restart_required
|
||||
# below asks for.
|
||||
# there is nothing to reload here: the display reloads it, asked
|
||||
# over the control socket below, or keeps running the version it
|
||||
# loaded until a restart, which restart_required then asks for.
|
||||
if api_v3.plugin_catalog:
|
||||
api_v3.plugin_catalog.discover_plugins()
|
||||
|
||||
restart_fields = _reload_after_store_update(
|
||||
display_plugin_id,
|
||||
_store_restart_fields('update', _plugin_enabled_in_config(display_plugin_id),
|
||||
changed=update_status == 'updated'))
|
||||
if restart_fields.get('reloaded'):
|
||||
message += '; the display is running the new version'
|
||||
|
||||
# Record in history (the only record of when it was updated;
|
||||
# the version is the manifest on disk).
|
||||
if api_v3.operation_history:
|
||||
@@ -260,9 +275,7 @@ def update_plugin():
|
||||
'update_status': update_status
|
||||
},
|
||||
message=message,
|
||||
extra=_store_restart_fields(
|
||||
'update', _plugin_enabled_in_config(plugin_id),
|
||||
changed=update_status == 'updated'),
|
||||
extra=restart_fields,
|
||||
)
|
||||
else:
|
||||
refusal = _compatibility_refusal(plugin_id)
|
||||
|
||||
Reference in New Issue
Block a user