Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 ecf9195c11 fix(odds): stop a stalled ESPN taking the whole plugin update with it
Odds are fetched per live game from inside SportsLive.update(), with
show_odds defaulting on, and the plugin executor kills an operation at
30s. The odds request timeout was also 30s, so a single stalled request
consumed the entire budget and the update carrying every game's score
was killed.

Out of season that is invisible: preseason week 1 returns one game. A
Sunday slate is around sixteen, so the odds of at least one slow request
rise sharply just as the cost of losing the update does.

Shorten the request timeout to 5s, and after a network failure skip the
network for 60s. The timeout alone is not enough -- sixteen consecutive
5s timeouts still blow through -- and when ESPN is unreachable it is
unreachable for the whole slate, so the first failure already answers
the question for the rest of the pass.

    before: one stalled request = 30s = the entire budget
    after : 5s, the rest of the slate skipped, retry after 60s

The stale-cache fallback is unchanged: the cache is consulted before any
of this, and the failing request still falls back to it.

An earlier version of this branch also jittered the cache TTL to stagger
expiry across a slate. That has been dropped: CacheManager.set() stores
ttl for compatibility but the read path expires entries by a per-type
max_age (1800s for odds), so the jitter was inert. Making the read path
honour a per-entry ttl is a real fix but changes a contract 48 plugin
call sites already rely on, which is not a change to make two weeks
before the season.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-11 11:32:56 -04:00
21 changed files with 140 additions and 1274 deletions
-8
View File
@@ -600,14 +600,6 @@ These settings are typically only needed for non-standard panels or custom confi
- Leave empty unless you need custom mapping
- See rpi-rgb-led-matrix documentation for full options
- **`orientation`** (string, default: "normal")
- Rotates the rendered image to match how the panel is physically mounted
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
settings) if the panel is mounted upside down — useful for optimizing
where the Raspberry Pi and wiring sit relative to the mounting location
- Applied independently of `pixel_mapper_config` (appended as a trailing
`Rotate:180` mapper), so custom mapper configs keep working alongside it
- **`row_address_type`** (integer, default: 0)
- How rows are addressed on the panel
- Most panels use 0 (direct addressing)
-1
View File
@@ -112,7 +112,6 @@
"led_rgb_sequence": "RGB",
"limit_refresh_rate_hz": 100,
"pixel_mapper_config": "",
"orientation": "normal",
"row_address_type": 0,
"multiplexing": 0,
"panel_type": ""
-1
View File
@@ -66,7 +66,6 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270295).
| `led_rgb_sequence` | string, `"RGB"` |
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
| `orientation` | string, `"normal"``"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
| `row_address_type` | int, `0` — non-standard panel row addressing |
| `multiplexing` | int, `0` — panel multiplexing scheme |
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
+1 -19
View File
@@ -45,24 +45,6 @@ class BaseOddsManager:
self.logger = logging.getLogger(__name__)
self.base_url = "https://sports.core.api.espn.com/v2/sports"
# This path used a bare requests.get, so it identified itself as
# python-requests/x.y -- the one thing ESPN is known to reject. Around
# 2026-08-04 it began 403ing browser strings and bare custom tokens
# alike; what it accepts is a token with a URL that says who is
# calling. Every other ESPN caller in the tree already sends this
# (src/common/api_helper.py, src/base_classes/data_sources.py); the
# odds path was simply missed, and it is the one whose failures cost
# the caller its whole update budget.
#
# Deliberately no retry adapter, unlike api_helper: retries multiply
# request_timeout, which is set to 5s precisely to stay inside that
# budget. One try, then the cooldown below.
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
'Accept': 'application/json',
})
# Configuration with defaults
self.update_interval = 3600 # 1 hour default
# Well under the plugin executor's 30s operation budget. At 30s a
@@ -162,7 +144,7 @@ class BaseOddsManager:
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
self.logger.info(f"Requesting odds from URL: {url}")
response = self.session.get(url, timeout=self.request_timeout)
response = requests.get(url, timeout=self.request_timeout)
response.raise_for_status()
raw_data = response.json()
+1 -80
View File
@@ -14,13 +14,6 @@ import zlib
from typing import Dict, Any, Optional, Protocol
from datetime import datetime
# How old an abandoned write's temp file must be before the sweep removes it.
# A real write holds its temp file for milliseconds, so an hour is far beyond
# any in-flight write while still clearing the same day's debris. Deliberately
# not tied to the retention policies: those describe how long data stays
# useful, and a half-written file was never useful.
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
class CacheStrategyProtocol(Protocol):
@@ -119,22 +112,6 @@ class DiskCache:
record_ts = None
now = time.time()
# An explicit per-entry ttl wins over the caller's max_age. The
# caller that wrote the record knows what its data is; max_age is
# inferred from substrings in the key ("live", "odds", "stock") and
# is only a fallback for records that never said. Until now the ttl
# was stored and ignored, so `set(key, data, ttl=...)` did nothing
# at all -- 48 plugin call sites and 4 in the core were writing a
# number no read path consulted.
effective_max_age = max_age
if isinstance(record, dict):
stored_ttl = record.get('ttl')
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
and stored_ttl >= 0:
effective_max_age = stored_ttl
max_age = effective_max_age
# max_age=None means "never expires" (mirrors MemoryCache and the
# cache_manager docstring). Guard it explicitly — otherwise the
# comparison below raises TypeError and the record is treated as a
@@ -354,23 +331,6 @@ class DiskCache:
"""Get the cache directory path."""
return self.cache_dir
@staticmethod
def _is_orphaned_temp(filename: str) -> bool:
"""Whether a name is one of set()'s temp files rather than real data.
Matches only what this class creates: mkstemp with a prefix of
".<cache filename>." , so ".weather.json.a1b2c3d4". The shape is
checked rather than just the leading dot, because this predicate
deletes things -- a stray dotfile someone left in the cache directory
is not ours to remove, and a completed ".json" never is either.
"""
if not filename.startswith('.') or filename.endswith('.json'):
return False
head, sep, suffix = filename.rpartition('.json.')
# head is the key (non-empty after the leading dot), suffix is
# mkstemp's random component.
return bool(sep) and len(head) > 1 and bool(suffix)
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
"""
Clean up expired cache files based on retention policies.
@@ -405,51 +365,12 @@ class DiskCache:
try:
with self._lock:
# Get snapshot of files while holding lock briefly
entries = os.listdir(self.cache_dir)
filenames = [f for f in os.listdir(self.cache_dir) if f.endswith('.json')]
except OSError as list_error:
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
stats['errors'] += 1
return stats
filenames = [f for f in entries if f.endswith('.json')]
# Sweep temp files abandoned by a write that never finished. set()
# removes its own in a finally, so these are the ones where the
# process died between mkstemp and os.replace -- a SIGKILL, a lost
# restart race, a power cut. Nothing ever collected them: they are
# named ".<key>.json.<random>", and the scan above only matches
# names ending in .json, so they accumulated indefinitely. Measured
# on a live rig: 76 files, 1,050 MB, 81% of the whole cache
# directory, the oldest six months old.
stats['orphan_temp_files_deleted'] = 0
for filename in (f for f in entries if self._is_orphaned_temp(f)):
# Counted as scanned like any other candidate, so files_deleted
# can never exceed files_scanned and the summary line reads
# honestly ("77/8864", not "77/0").
stats['files_scanned'] += 1
path = os.path.join(self.cache_dir, filename)
try:
# An in-flight write lives for milliseconds, so anything
# this old is certainly abandoned rather than in progress.
if (current_time - os.path.getmtime(path)) <= _ORPHAN_TEMP_MAX_AGE_SECONDS:
continue
with self._lock:
size = os.path.getsize(path)
os.remove(path)
stats['files_deleted'] += 1
stats['orphan_temp_files_deleted'] += 1
stats['space_freed_bytes'] += size
except FileNotFoundError:
continue # another sweep got there first
except OSError as e:
stats['errors'] += 1
self.logger.warning("Error deleting orphaned temp file %s: %s", filename, e)
if stats['orphan_temp_files_deleted']:
self.logger.info(
"Removed %d abandoned cache temp file(s)",
stats['orphan_temp_files_deleted'])
# Process files outside the lock to avoid blocking get/set operations
for filename in filenames:
stats['files_scanned'] += 1
-10
View File
@@ -57,16 +57,6 @@ class MemoryCache:
if timestamp is None:
return None
# An explicit per-entry ttl wins over the caller's max_age, matching
# DiskCache. max_age is inferred from substrings in the key and is
# only a fallback for records that did not say what they wanted.
record = self._cache[key]
if isinstance(record, dict):
stored_ttl = record.get('ttl')
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
and stored_ttl >= 0:
max_age = stored_ttl
# Check expiration
if max_age is not None and (now - timestamp) > max_age:
# Expired - remove it
+3 -44
View File
@@ -47,20 +47,6 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
class CacheManager:
"""Manages caching of API responses to reduce API calls."""
# Which cache directories already have a cleanup thread in this process.
#
# The sweep is directory-scoped work -- it lists a directory and deletes
# from it -- so one per directory is the right number no matter how many
# managers exist. Nothing enforced that before: every instance started its
# own, and because the loop closes over `self`, a discarded manager could
# never be collected and its thread woke to re-scan the same directory
# every 24 hours for the life of the process. Startup validation runs
# twice and built a throwaway manager each time, so a display process
# carried three threads for one cache.
_cleanup_owners: Dict[str, 'CacheManager'] = {}
_cleanup_owners_lock = threading.Lock()
def __init__(self) -> None:
# Initialize logger first
self.logger: logging.Logger = get_logger(__name__)
@@ -608,10 +594,8 @@ class CacheManager:
Args:
key: Cache key
data: Data to cache
ttl: Time-to-live in seconds for this entry. Takes precedence over
the max_age a reader would otherwise apply, which is inferred
from the key and is only a fallback for entries that did not
say. Omit it to keep that inferred behaviour.
ttl: Optional time-to-live in seconds (stored for compatibility but
expiration is still controlled via max_age when reading)
"""
cache_data = {
'data': data,
@@ -732,29 +716,11 @@ class CacheManager:
}
def start_cleanup_thread(self) -> None:
"""Start background thread for periodic disk cache cleanup.
At most one thread per cache directory per process: the sweep is
directory-scoped, so a second one only duplicates the scan.
"""
"""Start background thread for periodic disk cache cleanup."""
if self._cleanup_thread and self._cleanup_thread.is_alive():
self.logger.debug("Cleanup thread already running")
return
with CacheManager._cleanup_owners_lock:
owner = CacheManager._cleanup_owners.get(self.cache_dir)
if owner is not None and owner is not self:
thread = owner._cleanup_thread
if thread is not None and thread.is_alive():
self.logger.debug(
"Cleanup thread for %s already owned by another cache "
"manager in this process; not starting a second",
self.cache_dir)
return
# The owner's thread died or was stopped -- take over.
CacheManager._cleanup_owners[self.cache_dir] = self
def cleanup_loop():
"""Background loop that runs cleanup periodically."""
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
@@ -802,13 +768,6 @@ class CacheManager:
Signals the thread to stop and waits for it to finish (with timeout).
This allows for clean shutdown during testing or application termination.
"""
# Release ownership first and unconditionally, so a manager that never
# started a thread (or whose thread already exited) cannot keep the
# directory claimed and block a live manager from sweeping it.
with CacheManager._cleanup_owners_lock:
if CacheManager._cleanup_owners.get(self.cache_dir) is self:
del CacheManager._cleanup_owners[self.cache_dir]
if not self._cleanup_thread or not self._cleanup_thread.is_alive():
self.logger.debug("Cleanup thread not running")
return
+2 -4
View File
@@ -90,8 +90,7 @@ class DisplayController:
# Validate startup configuration
try:
from src.startup_validator import StartupValidator
validator = StartupValidator(self.config_manager,
cache_manager=self.cache_manager)
validator = StartupValidator(self.config_manager)
is_valid, errors, warnings = validator.validate_all()
if warnings:
@@ -259,8 +258,7 @@ class DisplayController:
# Validate plugins after plugin manager is created
try:
from src.startup_validator import StartupValidator
validator = StartupValidator(self.config_manager, self.plugin_manager,
cache_manager=self.cache_manager)
validator = StartupValidator(self.config_manager, self.plugin_manager)
is_valid, errors, warnings = validator.validate_all()
if warnings:
+1 -21
View File
@@ -258,26 +258,6 @@ class DisplayManager:
# Initialize managers
# Calendar manager is now initialized by DisplayController
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
# "normal" needs no suffix since 0 degrees is the identity transform.
_ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
"""Compose the raw pixel_mapper_config string with the orientation setting.
`pixel_mapper_config` stays available as a free-form advanced field (e.g.
for "U-mapper" chain layouts); `orientation` is the user-facing dropdown
for physical mounting (e.g. panels mounted upside down) and is appended as
a "Rotate:<deg>" mapper rather than overwriting any existing config.
"""
base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip()
orientation = hardware_config.get('orientation', 'normal')
degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation)
if degrees is None:
return base_mapper
rotate_mapper = f'Rotate:{degrees}'
return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper
def _setup_matrix(self):
"""Initialize the RGB matrix with configuration settings."""
_init_error_str = None
@@ -303,7 +283,7 @@ class DisplayManager:
options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
options.pixel_mapper_config = self._build_pixel_mapper_config(hardware_config)
options.pixel_mapper_config = hardware_config.get('pixel_mapper_config', '')
options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0)
options.panel_type = hardware_config.get('panel_type', '')
+4 -23
View File
@@ -15,23 +15,16 @@ from src.logging_config import get_logger
class StartupValidator:
"""Validates system state on startup."""
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None,
cache_manager: Optional[Any] = None) -> None:
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None) -> None:
"""
Initialize the startup validator.
Args:
config_manager: ConfigManager instance
plugin_manager: Optional PluginManager instance
cache_manager: The CacheManager the application will actually use.
Pass it. Without one this validator builds its own just to read
a directory path, which reports on a cache the app does not
use and leaves behind a cleanup thread that nothing stops --
validation runs twice per startup, so that was two of them.
"""
self.config_manager = config_manager
self.plugin_manager = plugin_manager
self.cache_manager = cache_manager
self.logger = get_logger(__name__)
self.errors: List[str] = []
self.warnings: List[str] = []
@@ -98,21 +91,9 @@ class StartupValidator:
def _validate_cache_directory(self) -> None:
"""Validate cache directory permissions."""
try:
cache_manager = self.cache_manager
if cache_manager is None:
# No caller supplied one (older embedders, direct use in a
# script). Build one, but do not leave its cleanup thread
# running behind us -- this instance is discarded on the next
# line but the thread is a closure over it, so it would never
# be collected.
from src.cache_manager import CacheManager
cache_manager = CacheManager()
try:
cache_dir = cache_manager.get_cache_dir()
finally:
cache_manager.stop_cleanup_thread()
else:
cache_dir = cache_manager.get_cache_dir()
from src.cache_manager import CacheManager
cache_manager = CacheManager()
cache_dir = cache_manager.get_cache_dir()
if not cache_dir:
self.warnings.append("Cache directory not available - caching will be disabled")
-73
View File
@@ -4,7 +4,6 @@ Centralized error handling for web interface.
Provides helpers for consistent error responses across API endpoints.
"""
import re
from typing import Any, Optional
from flask import jsonify
@@ -17,78 +16,6 @@ from src.logging_config import get_logger
logger = get_logger(__name__)
# Credentials that turn up inside exception text. A requests error quotes the
# URL it failed on, and plugins that authenticate by query string put their key
# there, so echoing an exception verbatim can hand out an API key. Redact the
# value, keep the parameter name -- knowing *which* credential was involved is
# part of the diagnosis.
_REDACT_CREDENTIAL = re.compile(
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
re.IGNORECASE,
)
# `Authorization: <scheme> <credential>`. The scheme name is kept because it
# says which kind of credential failed; the credential goes. Any scheme
# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and
# whatever a plugin's API invents next are all credentials, and a list would
# silently leak the ones nobody thought of. Not covered by the generic pattern
# above, whose value part stops at whitespace and so would keep the credential
# once a space follows the scheme.
_REDACT_AUTH_HEADER = re.compile(
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
r'([^\s,"\'<>}]+)', # the credential, redacted
re.IGNORECASE,
)
# Credentials embedded in a URL: https://user:password@host. requests quotes
# the full URL in its exceptions, so this is a realistic leak. The username is
# kept -- it identifies which account failed without being the secret.
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
re.IGNORECASE)
# Long enough for an errno string with a path, short enough not to dump a
# parser's worth of context into a JSON field.
_MAX_DETAIL_LENGTH = 400
def describe_exception(exc: BaseException,
max_length: int = _MAX_DETAIL_LENGTH) -> str:
"""
One-line, safe-to-return description of an exception.
The generic "an error occurred; see logs for details" tells a user nothing
and, when the failure is bad enough, the logs are unreachable too: a device
whose storage was failing returned that message from every endpoint
*including* the log viewer, because journalctl could not be executed. The
underlying `[Errno 5] Input/output error` named the fault immediately.
Returns "TypeName: message", credentials redacted and length capped. The
type alone is worth carrying -- a bare PermissionError says more than any
generic sentence.
Args:
exc: The exception to describe
max_length: Truncate beyond this many characters
Returns:
A single-line description, never empty
"""
message = str(exc).strip()
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
# Order matters: the URL and header forms are more specific than the
# generic key=value pattern, which would otherwise chew the scheme.
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
text = _REDACT_CREDENTIAL.sub(r'\1<redacted>', text)
# Collapse newlines/tabs so the detail stays one line in a JSON field.
text = ' '.join(text.split())
if len(text) > max_length:
text = text[:max_length - 1].rstrip() + ''
return text
def create_error_response(
error_code: ErrorCode,
message: str,
+2 -4
View File
@@ -8,9 +8,7 @@ is_odds_available's ML-blind truth table, the fixed format_odds_summary
gate (money-line-only odds now format), get_odds_for_games, and
configuration loading.
No real network: requests.Session.get is always patched. The odds path sends
its requests through a session so it can identify itself to ESPN, so patching
the module-level requests.get would no longer intercept anything.
No real network: src.base_odds_manager.requests.get is always patched.
"""
from unittest.mock import MagicMock, patch
@@ -61,7 +59,7 @@ def manager(cache_manager):
@pytest.fixture
def mock_get():
with patch('src.base_odds_manager.requests.Session.get') as m:
with patch('src.base_odds_manager.requests.get') as m:
m.return_value = _make_response({'items': [dict(FULL_ITEM)]})
yield m
-146
View File
@@ -1,146 +0,0 @@
"""Tests that one cache directory gets one cleanup thread per process.
The sweep lists a directory and deletes from it, so a second thread over the
same directory only duplicates the scan. Nothing enforced that: every
CacheManager started its own, and since the loop closes over `self`, a
discarded manager could never be collected -- its thread stayed alive and
re-scanned the same directory every 24 hours for the life of the process.
On the dev rig a display process carried three, for one cache directory:
14:22:59.954 display_controller (the real one)
14:22:59.973 startup validation, run 1 (discarded)
14:23:01.055 startup validation, run 2 (discarded)
Startup validation runs twice and built a throwaway manager each time, purely
to read a directory path.
"""
import threading
import pytest
from src.cache_manager import CacheManager
@pytest.fixture(autouse=True)
def _clean_registry():
CacheManager._cleanup_owners.clear()
yield
for owner in list(CacheManager._cleanup_owners.values()):
owner.stop_cleanup_thread()
CacheManager._cleanup_owners.clear()
def _live_cleanup_threads():
return [t for t in threading.enumerate()
if t.name == 'DiskCacheCleanup' and t.is_alive()]
@pytest.fixture
def manager(tmp_path, monkeypatch):
"""A CacheManager pinned to a temp dir, so tests never touch the real one."""
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
lambda self: str(tmp_path))
return CacheManager
class TestOneThreadPerDirectory:
def test_a_single_manager_starts_one(self, manager):
before = len(_live_cleanup_threads())
m = manager()
try:
assert len(_live_cleanup_threads()) == before + 1
finally:
m.stop_cleanup_thread()
def test_three_managers_still_start_one(self, manager):
# Exactly the rig's shape: the real manager plus two throwaways.
before = len(_live_cleanup_threads())
managers = [manager() for _ in range(3)]
try:
assert len(_live_cleanup_threads()) == before + 1
finally:
for m in managers:
m.stop_cleanup_thread()
def test_the_first_one_owns_it(self, manager):
first, second = manager(), manager()
try:
assert CacheManager._cleanup_owners[first.cache_dir] is first
assert second._cleanup_thread is None
finally:
first.stop_cleanup_thread()
second.stop_cleanup_thread()
def test_the_survivor_can_take_over(self, manager):
first = manager()
first.stop_cleanup_thread()
assert not _live_cleanup_threads()
second = manager()
try:
# Ownership was released, so the directory is swept again rather
# than being left permanently unclaimed by a dead owner.
assert len(_live_cleanup_threads()) == 1
assert CacheManager._cleanup_owners[second.cache_dir] is second
finally:
second.stop_cleanup_thread()
def test_stopping_a_non_owner_does_not_unclaim_the_directory(self, manager):
first, second = manager(), manager()
try:
second.stop_cleanup_thread() # never owned it
assert CacheManager._cleanup_owners[first.cache_dir] is first
assert len(_live_cleanup_threads()) == 1
finally:
first.stop_cleanup_thread()
def test_separate_directories_get_separate_threads(self, tmp_path, monkeypatch):
a, b = tmp_path / 'a', tmp_path / 'b'
a.mkdir()
b.mkdir()
dirs = iter([str(a), str(b)])
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
lambda self: next(dirs))
first, second = CacheManager(), CacheManager()
try:
assert first.cache_dir != second.cache_dir
assert len(_live_cleanup_threads()) == 2
finally:
first.stop_cleanup_thread()
second.stop_cleanup_thread()
def test_no_thread_leaks_across_many_constructions(self, manager):
before = len(_live_cleanup_threads())
made = [manager() for _ in range(12)]
try:
assert len(_live_cleanup_threads()) == before + 1
finally:
for m in made:
m.stop_cleanup_thread()
assert len(_live_cleanup_threads()) == before
class TestValidatorDoesNotBuildItsOwn:
def test_it_uses_the_cache_manager_it_is_given(self, manager):
from src.startup_validator import StartupValidator
shared = manager()
try:
before = len(_live_cleanup_threads())
v = StartupValidator(config_manager=object(), cache_manager=shared)
v._validate_cache_directory()
assert len(_live_cleanup_threads()) == before, (
"validation started another cleanup thread")
finally:
shared.stop_cleanup_thread()
def test_without_one_it_cleans_up_after_itself(self, manager):
from src.startup_validator import StartupValidator
before = len(_live_cleanup_threads())
v = StartupValidator(config_manager=object())
v._validate_cache_directory()
assert len(_live_cleanup_threads()) == before, (
"the fallback manager left its cleanup thread running")
-188
View File
@@ -1,188 +0,0 @@
"""Tests that abandoned cache temp files get collected.
DiskCache.set() writes through tempfile.mkstemp and os.replace, removing its
own temp file in a finally. That covers a failed write, but not a process that
dies between the two -- a SIGKILL, a lost restart race, a power cut, all
ordinary on a Pi. Nothing collected what was left behind: the temp names are
".<key>.json.<random>", and the expiry sweep only listed names ending in
.json, so they accumulated for as long as the card had been in service.
Measured on a live rig before this fix: 76 orphans totalling 1,050 MB -- 81%
of the entire cache directory -- the oldest six months old.
The predicate that decides what to delete is tested harder than the sweep
itself, because a false positive here destroys real data.
"""
import os
import time
import pytest
from src.cache.disk_cache import DiskCache, _ORPHAN_TEMP_MAX_AGE_SECONDS
class FakeStrategy:
@staticmethod
def get_data_type_from_key(key):
return 'default'
POLICIES = {'default': 30}
@pytest.fixture
def cache(tmp_path):
return DiskCache(str(tmp_path))
def _age(path, seconds):
old = time.time() - seconds
os.utime(path, (old, old))
def _write(tmp_path, name, body='{}'):
p = tmp_path / name
p.write_text(body, encoding='utf-8')
return p
class TestWhatCountsAsAnOrphan:
@pytest.mark.parametrize('name', [
'.weather.json.a1b2c3d4',
'.odds_espn_football_nfl_401.json.xyz00000',
'.a.json.b',
])
def test_our_temp_files_are_orphans(self, name):
assert DiskCache._is_orphaned_temp(name)
@pytest.mark.parametrize('name', [
'weather.json', # real data
'.weather.json', # a dotted key that completed
'.gitignore', # not ours
'.hidden', # not ours
'weather.json.bak', # no leading dot: someone else's
'.json.abc', # no key between the dot and .json.
'.weather.json.', # no random component
'notes.txt',
])
def test_everything_else_is_left_alone(self, name):
assert not DiskCache._is_orphaned_temp(name)
def test_the_names_set_actually_creates_are_matched(self, cache, tmp_path):
"""Guard against the predicate and the writer drifting apart."""
created = []
real = os.replace
def capture(src, dst):
created.append(os.path.basename(src))
return real(src, dst)
import src.cache.disk_cache as mod
mod.os.replace = capture
try:
cache.set('weather', {'v': 1})
finally:
mod.os.replace = real
assert created, "set() did not go through the temp-file path"
assert all(DiskCache._is_orphaned_temp(n) for n in created), created
class TestTheSweep:
def test_an_old_orphan_is_removed(self, cache, tmp_path):
p = _write(tmp_path, '.weather.json.a1b2c3d4', 'x' * 5000)
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert not p.exists()
assert stats['orphan_temp_files_deleted'] == 1
assert stats['space_freed_bytes'] >= 5000
def test_an_in_flight_write_is_not_snatched_away(self, cache, tmp_path):
# The whole risk of this sweep: deleting a temp file another thread is
# about to os.replace into place.
p = _write(tmp_path, '.weather.json.inflight')
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert p.exists()
def test_real_cache_files_survive(self, cache, tmp_path):
fresh = _write(tmp_path, 'weather.json')
dotted = _write(tmp_path, '.weather.json')
_age(dotted, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert fresh.exists()
assert dotted.exists(), "a completed .json was treated as a temp file"
def test_unrelated_dotfiles_survive(self, cache, tmp_path):
keep = _write(tmp_path, '.gitignore')
_age(keep, 400 * 86400)
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert keep.exists()
def test_expiry_still_works_alongside_it(self, cache, tmp_path):
stale = _write(tmp_path, 'old.json')
_age(stale, 40 * 86400) # past the 30-day default
orphan = _write(tmp_path, '.old.json.zz999999')
_age(orphan, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert not stale.exists()
assert not orphan.exists()
assert stats['files_deleted'] == 2
assert stats['orphan_temp_files_deleted'] == 1
def test_the_rig_scenario(self, cache, tmp_path):
"""76 orphans of assorted ages, none of them reachable before."""
for i in range(76):
p = _write(tmp_path, '.sched_%d.json.r%06d' % (i, i), 'x' * 1000)
_age(p, (i + 2) * 86400)
keep = _write(tmp_path, 'sched.json')
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert stats['orphan_temp_files_deleted'] == 76
assert keep.exists()
assert not list(tmp_path.glob('.sched_*'))
# The summary line is "<deleted>/<scanned>", so an orphan that is
# deleted but never counted as scanned renders as "76/1".
assert stats['files_scanned'] == 77
assert stats['files_deleted'] <= stats['files_scanned']
def test_deleted_never_exceeds_scanned(self, cache, tmp_path):
p = _write(tmp_path, '.only.json.a1b2c3d4')
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert stats['files_deleted'] == 1
assert stats['files_scanned'] == 1
def test_a_missing_file_mid_sweep_is_not_an_error(self, cache, tmp_path):
p = _write(tmp_path, '.weather.json.a1b2c3d4')
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
import src.cache.disk_cache as mod
real = mod.os.path.getsize
def vanish(path):
if path.endswith('.a1b2c3d4'):
os.remove(path)
raise FileNotFoundError(path)
return real(path)
mod.os.path.getsize = vanish
try:
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
finally:
mod.os.path.getsize = real
assert stats['errors'] == 0
-130
View File
@@ -1,130 +0,0 @@
"""Tests that a per-entry ttl actually controls expiry.
Regression under test: `CacheManager.set(key, data, ttl=...)` stored the value
and no read path ever consulted it. Expiry came from a `max_age` inferred from
substrings in the key ("live", "odds", "stock"), so every caller passing `ttl=`
-- 48 sites across the plugins and 4 in the core -- was writing a number that
did nothing. The old docstring admitted as much: "stored for compatibility but
expiration is still controlled via max_age when reading".
Measured against a real device's cache (8,873 entries carrying a ttl), the
inferred value and the intended one disagreed almost everywhere:
stocks max_age 600 vs ttl 1800 4903 entries
news max_age 3600 vs ttl 600 1770 entries
odds max_age 1800 vs ttl 3600 1301 entries
images max_age 300 vs ttl 2592000 20 entries
No `sports_live` entry carries a ttl, so live scores keep their inferred
30-second freshness either way.
"""
import time
import pytest
from src.cache.memory_cache import MemoryCache
from src.cache.disk_cache import DiskCache
@pytest.fixture
def disk(tmp_path):
return DiskCache(cache_dir=str(tmp_path))
def _record(ttl=None, age=0.0):
rec = {"data": {"v": 1}, "timestamp": time.time() - age}
if ttl is not None:
rec["ttl"] = ttl
return rec
class TestDiskCacheHonoursTtl:
def test_ttl_longer_than_max_age_keeps_the_entry(self, disk):
# The odds case: written wanting an hour, expired at 30 minutes.
disk.set("odds_espn_football_nfl_401", _record(ttl=3600, age=1900))
assert disk.get("odds_espn_football_nfl_401", max_age=1800) is not None
def test_ttl_shorter_than_max_age_expires_the_entry(self, disk):
# The news case: written wanting 10 minutes, kept for an hour.
disk.set("news_NHL_1", _record(ttl=600, age=900))
assert disk.get("news_NHL_1", max_age=3600) is None
def test_without_a_ttl_max_age_still_applies(self, disk):
disk.set("plain_key", _record(age=400))
assert disk.get("plain_key", max_age=300) is None
disk.set("plain_key2", _record(age=100))
assert disk.get("plain_key2", max_age=300) is not None
def test_a_fresh_entry_within_its_ttl_survives(self, disk):
disk.set("k", _record(ttl=600, age=10))
assert disk.get("k", max_age=30) is not None
def test_ttl_zero_expires_immediately(self, disk):
# 0 means zero seconds, not "forever" -- max_age=None is how a caller
# asks for no expiry.
disk.set("k", _record(ttl=0, age=1))
assert disk.get("k", max_age=99999) is None
@pytest.mark.parametrize("bad", ["600", None, True, False, -5, {"a": 1}])
def test_a_nonsense_ttl_falls_back_to_max_age(self, disk, bad):
# Including bools: True is an int in Python and must not become a 1s ttl.
rec = _record(age=400)
rec["ttl"] = bad
disk.set("k_%s" % type(bad).__name__, rec)
assert disk.get("k_%s" % type(bad).__name__, max_age=300) is None
class TestMemoryCacheHonoursTtl:
def test_ttl_longer_than_max_age_keeps_the_entry(self):
m = MemoryCache()
m.set("k", _record(ttl=3600))
m._timestamps["k"] = time.time() - 1900
assert m.get("k", max_age=1800) is not None
def test_ttl_shorter_than_max_age_expires_the_entry(self):
m = MemoryCache()
m.set("k", _record(ttl=600))
m._timestamps["k"] = time.time() - 900
assert m.get("k", max_age=3600) is None
def test_without_a_ttl_max_age_still_applies(self):
m = MemoryCache()
m.set("k", _record())
m._timestamps["k"] = time.time() - 400
assert m.get("k", max_age=300) is None
def test_both_layers_agree(self, tmp_path):
"""A record must not be live in one layer and expired in the other."""
rec = _record(ttl=3600, age=1900)
d = DiskCache(cache_dir=str(tmp_path))
d.set("k", rec)
m = MemoryCache()
m.set("k", rec)
m._timestamps["k"] = rec["timestamp"]
assert (d.get("k", max_age=1800) is not None) == (m.get("k", max_age=1800) is not None)
class TestEndToEnd:
def test_set_then_get_respects_the_ttl(self, tmp_path, monkeypatch):
"""The behaviour a caller of CacheManager.set(ttl=...) expects."""
from src.cache_manager import CacheManager
cm = CacheManager()
cm._disk_cache_component = DiskCache(cache_dir=str(tmp_path))
cm._memory_cache_component = MemoryCache()
cm.set("odds_espn_football_nfl_401", {"spread": 6.5}, ttl=3600)
# Age the stored record past the inferred max_age for odds (1800s) but
# within the ttl the caller asked for.
path = cm._disk_cache_component.get_cache_path("odds_espn_football_nfl_401")
import json
rec = json.load(open(path))
rec["timestamp"] = time.time() - 1900
json.dump(rec, open(path, "w"))
cm._memory_cache_component.clear() if hasattr(
cm._memory_cache_component, "clear") else None
got = cm.get_with_auto_strategy("odds_espn_football_nfl_401")
assert got is not None, "the ttl the caller asked for was ignored"
-42
View File
@@ -237,45 +237,3 @@ class TestDisplayManagerDoubleSided:
suppress_test_pattern=True)
assert dm.set_brightness(70) is True
assert mock_rgb_matrix['matrix_instance'].brightness == 70
class TestDisplayManagerOrientation:
"""The orientation setting composes onto pixel_mapper_config for panels
mounted upside down, without disturbing a custom pixel_mapper_config."""
def _config(self, **hardware_overrides):
config = {
'display': {
'hardware': {
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
},
'runtime': {'gpio_slowdown': 2},
},
'timezone': 'UTC',
'plugin_system': {'plugins_directory': 'plugins'},
}
config['display']['hardware'].update(hardware_overrides)
return config
def test_default_orientation_leaves_pixel_mapper_config_untouched(self, mock_rgb_matrix):
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
DisplayManager(self._config(), suppress_test_pattern=True)
options = mock_rgb_matrix['options_class'].return_value
assert options.pixel_mapper_config == ''
def test_orientation_180_appends_rotate_mapper(self, mock_rgb_matrix):
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
DisplayManager(self._config(orientation='180'), suppress_test_pattern=True)
options = mock_rgb_matrix['options_class'].return_value
assert options.pixel_mapper_config == 'Rotate:180'
def test_orientation_180_composes_with_existing_pixel_mapper_config(self, mock_rgb_matrix):
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
DisplayManager(self._config(orientation='180', pixel_mapper_config='U-mapper'),
suppress_test_pattern=True)
options = mock_rgb_matrix['options_class'].return_value
assert options.pixel_mapper_config == 'U-mapper;Rotate:180'
+49 -89
View File
@@ -10,15 +10,10 @@ and the update carrying every game's score was killed:
Invisible out of season -- preseason week 1 returns a single game -- and a
Sunday slate is around sixteen.
The request now goes through a session that identifies the caller, so the
tests patch `manager.session.get` rather than the module's `requests.get`.
"""
from unittest.mock import Mock
import requests
from src.base_odds_manager import BaseOddsManager
PLUGIN_BUDGET = 30.0 # PluginExecutor(default_timeout=30.0)
@@ -30,83 +25,43 @@ def _manager(cache=None):
return BaseOddsManager(cache_manager=cache, config_manager=None)
def _timing_out(manager):
"""Point the manager's session at a request that always times out."""
manager.session.get = Mock(side_effect=requests.exceptions.Timeout("x"))
return manager.session.get
def _returning(manager, payload):
resp = Mock()
resp.json.return_value = payload
resp.raise_for_status.return_value = None
manager.session.get = Mock(return_value=resp)
return manager.session.get
class TestRequestTimeout:
def test_leaves_room_in_the_operation_budget(self):
assert _manager().request_timeout < PLUGIN_BUDGET / 2
def test_the_timeout_is_the_one_actually_used(self):
m = _manager()
get = _timing_out(m)
m.get_odds("football", "nfl", "401")
assert get.call_args.kwargs["timeout"] == m.request_timeout
class TestIdentifiesItselfToEspn:
"""ESPN 403s python-requests' default agent, and bare custom tokens.
What it accepts is a token carrying a URL that says who is calling. This
path used a bare requests.get and so sent the default -- the one thing
known to be rejected. Everything else in the tree that talks to ESPN
already sends the header below.
"""
def test_the_user_agent_names_the_project_and_links_to_it(self):
ua = _manager().session.headers["User-Agent"]
assert "python-requests" not in ua
assert "LEDMatrix" in ua
assert "github.com/ChuckBuilds/LEDMatrix" in ua
def test_it_is_the_same_agent_the_rest_of_the_tree_sends(self):
# Compared against the live value rather than a copied literal, so the
# two cannot drift apart the next time ESPN moves the goalposts.
from src.common.api_helper import APIHelper
assert (_manager().session.headers["User-Agent"]
== APIHelper().session.headers["User-Agent"])
def test_the_header_reaches_the_request(self):
m = _manager()
get = _returning(m, {})
m._extract_espn_data = Mock(return_value=None)
m.get_odds("football", "nfl", "401")
# Sent via the session, so it applies without being passed per-call.
assert get.call_count == 1
assert "User-Agent" in m.session.headers
def test_no_retry_adapter_multiplies_the_timeout(self):
# api_helper mounts a retrying adapter; this path must not, or a 5s
# timeout becomes 15s and the budget fix is undone.
m = _manager()
for adapter in m.session.adapters.values():
retries = getattr(adapter, "max_retries", None)
assert getattr(retries, "total", 0) in (0, None), (
"odds session mounts a retrying adapter (total=%r); retries "
"multiply request_timeout" % getattr(retries, "total", None))
import src.base_odds_manager as mod
real = mod.requests.get
try:
mod.requests.get = Mock(side_effect=mod.requests.exceptions.Timeout("x"))
m.get_odds("football", "nfl", "401")
assert mod.requests.get.call_args.kwargs["timeout"] == m.request_timeout
finally:
mod.requests.get = real
class TestSlowEspnCannotKillTheUpdate:
def test_one_failure_stops_the_rest_of_the_slate_hitting_the_network(self):
m = _manager()
get = _timing_out(m)
for i in range(16): # a full slate, one game at a time
m.get_odds("football", "nfl", "4018730%02d" % i)
import src.base_odds_manager as mod
real = mod.requests.get
calls = {"n": 0}
assert get.call_count == 1, (
def timeout(*a, **k):
calls["n"] += 1
raise mod.requests.exceptions.Timeout("timed out")
try:
mod.requests.get = timeout
for i in range(16): # a full slate, one game at a time
m.get_odds("football", "nfl", "4018730%02d" % i)
finally:
mod.requests.get = real
assert calls["n"] == 1, (
"%d games each paid the timeout; the breaker should have stopped "
"after the first" % get.call_count)
"after the first" % calls["n"])
def test_worst_case_slate_stays_inside_the_budget(self):
m = _manager()
@@ -115,48 +70,53 @@ class TestSlowEspnCannotKillTheUpdate:
def test_recovery_is_automatic(self):
m = _manager()
import src.base_odds_manager as mod
real_monotonic = mod.time.monotonic
real_get, real_monotonic = mod.requests.get, mod.time.monotonic
clock = {"t": 1000.0}
try:
mod.time.monotonic = lambda: clock["t"]
get = _timing_out(m)
mod.requests.get = Mock(
side_effect=mod.requests.exceptions.Timeout("timed out"))
m.get_odds("football", "nfl", "401")
assert m._skip_network_until > clock["t"], "breaker did not open"
clock["t"] += 1
before = get.call_count
before = mod.requests.get.call_count
m.get_odds("football", "nfl", "402")
assert get.call_count == before, "should not have retried"
assert mod.requests.get.call_count == before, "should not have retried"
clock["t"] += m._FAILURE_COOLDOWN
m.get_odds("football", "nfl", "403")
assert get.call_count > before, "never retried"
assert mod.requests.get.call_count > before, "never retried"
finally:
mod.time.monotonic = real_monotonic
mod.requests.get, mod.time.monotonic = real_get, real_monotonic
def test_a_healthy_fetch_clears_the_breaker(self):
m = _manager()
m._skip_network_until = 0.0
m._extract_espn_data = Mock(return_value=None)
_returning(m, {})
m.get_odds("football", "nfl", "401")
import src.base_odds_manager as mod
real = mod.requests.get
try:
resp = Mock()
resp.json.return_value = {}
resp.raise_for_status.return_value = None
mod.requests.get = Mock(return_value=resp)
m.get_odds("football", "nfl", "401")
finally:
mod.requests.get = real
assert m._skip_network_until == 0.0
def test_a_403_opens_the_breaker_rather_than_hammering(self):
# raise_for_status raises HTTPError, a RequestException -- so a wrong
# or missing agent backs off instead of 403ing once per game.
m = _manager()
resp = Mock()
resp.raise_for_status.side_effect = requests.exceptions.HTTPError("403")
m.session.get = Mock(return_value=resp)
m.get_odds("football", "nfl", "401")
assert m._skip_network_until > 0.0
def test_the_stale_cache_fallback_still_works(self):
# The failing request must still hand back whatever was cached; only
# the *subsequent* games skip the network.
cache = Mock()
cache.get_with_auto_strategy.side_effect = [None, {"details": "stale"}]
m = BaseOddsManager(cache_manager=cache, config_manager=None)
_timing_out(m)
assert m.get_odds("football", "nfl", "401") == {"details": "stale"}
import src.base_odds_manager as mod
real = mod.requests.get
try:
mod.requests.get = Mock(
side_effect=mod.requests.exceptions.Timeout("timed out"))
assert m.get_odds("football", "nfl", "401") == {"details": "stale"}
finally:
mod.requests.get = real
-248
View File
@@ -1,248 +0,0 @@
"""Tests for surfacing the underlying error in web responses.
Regression under test: every failing endpoint returned "An error occurred; see
logs for details" and nothing else. On a device whose storage was failing that
sentence came back from the restart action, from /system/status, and from
/logs -- the log viewer itself -- because journalctl could not be executed. The
exception underneath said `[Errno 5] Input/output error: 'systemctl'`, which
names the fault outright, and nine handlers were discarding it entirely rather
than even logging it.
"""
import pytest
from src.web_interface.error_handler import describe_exception
class TestDescribeException:
def test_names_the_type_and_message(self):
detail = describe_exception(OSError(5, "Input/output error", "systemctl"))
assert detail == "OSError: [Errno 5] Input/output error: 'systemctl'"
def test_the_reported_failure_is_legible(self):
# The whole point: this string is the diagnosis.
assert "Input/output error" in describe_exception(
OSError(5, "Input/output error", "systemctl"))
def test_a_bare_exception_still_names_its_type(self):
# A PermissionError with no message still says more than "unknown".
assert describe_exception(PermissionError()) == "PermissionError"
assert describe_exception(Exception()) == "Exception"
def test_message_is_kept_when_present(self):
assert describe_exception(ValueError("bad port")) == "ValueError: bad port"
class TestCredentialRedaction:
"""Exception text quotes URLs, and plugins authenticate by query string."""
@pytest.mark.parametrize("secret_text,leaked", [
("failed: https://api.x.com/v1?api_key=SEC123&city=Tampa", "SEC123"),
("token=abcdef123456 was rejected", "abcdef123456"),
("connect failed password=hunter2", "hunter2"),
("GET /?access_token=zzz999", "zzz999"),
('{"secret": "topsecret"}', "topsecret"),
# requests quotes the URL it failed on, and both of these forms turn
# up in real client exceptions.
("401 for https://user:hunter2@example.com/api", "hunter2"),
("headers: {'Authorization': 'Bearer eyJ.SECRET.sig'}", "eyJ.SECRET.sig"),
("Authorization: Basic dXNlcjpwYXNzd29yZA==", "dXNlcjpwYXNzd29yZA=="),
("Proxy-Authorization: Bearer ptok999", "ptok999"),
# Any scheme, not a fixed list -- a list silently leaks whatever it
# does not name, and plugin APIs invent their own.
("Authorization: ApiKey SECRET123", "SECRET123"),
("Authorization: Negotiate YIIZnegotiateblob", "YIIZnegotiateblob"),
("Authorization: NTLM TlRMTVNTUAAB", "TlRMTVNTUAAB"),
("authorization: barecredential", "barecredential"),
])
def test_credentials_never_reach_the_response(self, secret_text, leaked):
detail = describe_exception(RuntimeError(secret_text))
assert leaked not in detail
assert "<redacted>" in detail
def test_the_parameter_name_survives_redaction(self):
# Knowing *which* credential was involved is part of the diagnosis.
detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123"))
assert "api_key" in detail
def test_unknown_schemes_keep_their_name(self):
for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"):
detail = describe_exception(
RuntimeError("Authorization: %s SECRETVALUE" % scheme))
assert scheme in detail, detail
assert "SECRETVALUE" not in detail, detail
def test_auth_scheme_and_username_survive(self):
# Which kind of credential, and whose, without the credential itself.
assert "Bearer" in describe_exception(
RuntimeError("Authorization: Bearer eyJ.SECRET.sig"))
assert "user" in describe_exception(
RuntimeError("https://user:hunter2@example.com"))
def test_non_secret_context_is_preserved(self):
detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa"))
assert "city=Tampa" in detail
assert "<redacted>" not in detail
class TestBounds:
def test_long_messages_are_truncated(self):
detail = describe_exception(ValueError("x" * 5000))
assert len(detail) <= 400
def test_newlines_are_collapsed_to_one_line(self):
detail = describe_exception(ValueError("line one\nline two\tthree"))
assert "\n" not in detail and "\t" not in detail
assert detail == "ValueError: line one line two three"
def test_custom_length_is_honoured(self):
assert len(describe_exception(ValueError("y" * 500), max_length=50)) <= 50
class TestHandlersCarryDetail:
"""The response shape callers actually see."""
def test_no_api_v3_handler_discards_its_exception(self):
"""Every generic-message handler must log a traceback and return detail.
Nine of them bound `e` and never used it, so the promised log entry was
never written either. Checking merely that *something* was logged is
too weak -- a `logger.info("failed")` would satisfy it while throwing
the exception away just as completely, so this asserts the two things
that actually make the failure diagnosable: an error-level record with
the traceback, and the sanitized detail in the response.
"""
import ast
src = open("web_interface/blueprints/api_v3.py").read()
tree = ast.parse(src)
generic = "An error occurred; see logs for details"
def logs_a_traceback(handler):
"""An error/exception-level log call carrying exc_info."""
for call in [n for n in ast.walk(handler) if isinstance(n, ast.Call)]:
func = call.func
if not isinstance(func, ast.Attribute):
continue
if func.attr == "exception": # implies exc_info
return True
if func.attr not in ("error", "critical"):
continue
if any(kw.arg == "exc_info" and getattr(kw.value, "value", False) is True
for kw in call.keywords):
return True
return False
def describes_this_exception(node, bound):
"""A describe_exception(<bound>) call anywhere under `node`."""
for call in [n for n in ast.walk(node) if isinstance(n, ast.Call)]:
if not (isinstance(call.func, ast.Name)
and call.func.id == "describe_exception"):
continue
if bound is None:
return True # bare `except:` cannot name it; accept
if any(isinstance(a, ast.Name) and a.id == bound
for a in call.args):
return True
return False
def returns_the_detail(handler):
"""The detail must be inside what the handler actually returns.
Looking anywhere in the handler is too weak: a handler could
compute describe_exception(e), drop it on the floor, and return the
generic message with no details field, while still passing. So the
call has to appear within a `return` expression.
"""
returns = [n for n in ast.walk(handler) if isinstance(n, ast.Return)]
if not returns:
return False
return all(describes_this_exception(r, handler.name) for r in returns)
offenders = []
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
seg = ast.get_source_segment(src, h) or ""
if generic not in seg:
continue
missing = []
if not logs_a_traceback(h):
missing.append("error-level log with exc_info")
if not returns_the_detail(h):
missing.append("describe_exception(e) in the response")
if missing:
offenders.append((h.lineno, missing))
assert not offenders, (
"handlers returning the generic message without %s: %r"
% ("both a traceback log and the detail", offenders))
def test_client_errors_keep_their_own_status(self):
"""A 405 must not be reported as a server-side UNKNOWN_ERROR.
Werkzeug's HTTPExceptions subclass Exception, so the catch-all saw them
too: a GET on a POST-only route came back 500 "an error occurred",
which tells the caller nothing and blames the wrong side. Found while
probing a device whose POST-only config endpoints answered every GET
with UNKNOWN_ERROR.
"""
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
@app.errorhandler(Exception)
def handle(error):
if isinstance(error, HTTPException):
return jsonify({
"status": "error",
"error_code": (error.name or "HTTP_ERROR").upper().replace(" ", "_"),
"message": error.description,
}), error.code or 500
return jsonify({
"status": "error",
"error_code": "UNKNOWN_ERROR",
"message": "An error occurred; see logs for details",
"details": describe_exception(error),
}), 500
@app.route("/only-post", methods=["POST"])
def only_post():
return jsonify({"ok": True})
@app.route("/boom")
def boom():
raise OSError(5, "Input/output error", "systemctl")
client = app.test_client()
resp = client.get("/only-post")
assert resp.status_code == 405, "a wrong method must stay a 405"
assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED"
# A genuine server fault still reports as one, with its detail.
resp = client.get("/boom")
assert resp.status_code == 500
assert "Input/output error" in resp.get_json()["details"]
def test_global_handler_reports_the_underlying_error(self):
from flask import Flask, jsonify
app = Flask(__name__)
@app.errorhandler(Exception)
def handle(error):
return jsonify({
"status": "error",
"error_code": "UNKNOWN_ERROR",
"message": "An error occurred; see logs for details",
"details": describe_exception(error),
}), 500
@app.route("/boom")
def boom():
raise OSError(5, "Input/output error", "systemctl")
client = app.test_client()
body = client.get("/boom").get_json()
assert body["error_code"] == "UNKNOWN_ERROR"
assert "Input/output error" in body["details"]
+3 -33
View File
@@ -16,8 +16,6 @@ from datetime import datetime, timedelta
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config_manager import ConfigManager
from src.web_interface.error_handler import describe_exception
from werkzeug.exceptions import HTTPException
from src.exceptions import ConfigError
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.store_manager import PluginStoreManager
@@ -393,42 +391,15 @@ def internal_error(error):
import logging
logger = logging.getLogger('web_interface')
logger.error("Internal server error", exc_info=True)
payload = {
return jsonify({
'status': 'error',
'error_code': 'INTERNAL_ERROR',
'message': 'An internal error occurred; see logs for details',
}
# Flask hands the original exception over as `error.original_exception`
# when propagation is off; without it there is nothing to describe.
original = getattr(error, 'original_exception', None) or (
error if isinstance(error, BaseException) else None)
if original is not None:
payload['details'] = describe_exception(original)
return jsonify(payload), 500
}), 500
@app.errorhandler(Exception)
def handle_exception(error):
"""Handle all unhandled exceptions.
Returning only "see logs for details" is fine until the logs are exactly
what you cannot reach. A device with failing storage answered every
endpoint with that sentence -- including the log viewer, because journalctl
could not be executed -- while the exception underneath said
`[Errno 5] Input/output error`. Naming the error costs nothing here and is
frequently the whole diagnosis, so include it alongside the log pointer.
"""
# Werkzeug's HTTPExceptions subclass Exception, so this catch-all sees
# them too and was reporting every 405, 400, 413 and 415 as a server-side
# UNKNOWN_ERROR 500. A GET on a POST-only route came back as "an error
# occurred" rather than "method not allowed", which tells the caller
# nothing and blames the wrong side. Hand those back as themselves.
if isinstance(error, HTTPException):
return jsonify({
'status': 'error',
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
'message': error.description,
}), error.code or 500
"""Handle all unhandled exceptions."""
import logging
logger = logging.getLogger('web_interface')
logger.error("Unhandled exception", exc_info=True)
@@ -436,7 +407,6 @@ def handle_exception(error):
'status': 'error',
'error_code': 'UNKNOWN_ERROR',
'message': 'An error occurred; see logs for details',
'details': describe_exception(error),
}), 500
# Captive portal redirect middleware
+70 -98
View File
@@ -22,7 +22,6 @@ logger = logging.getLogger(__name__)
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
from src.web_interface.errors import ErrorCode
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
from src.web_interface.error_handler import describe_exception
from src.plugin_system.operation_types import OperationType
from src.web_interface.validators import (
validate_file_upload
@@ -273,7 +272,7 @@ def get_main_config():
return jsonify({'status': 'success', 'data': config})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/config/schedule', methods=['GET'])
def get_schedule_config():
@@ -291,11 +290,9 @@ def get_schedule_config():
return success_response(data=schedule_config)
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return error_response(
ErrorCode.CONFIG_LOAD_FAILED,
"An error occurred; see logs for details",
details=describe_exception(e),
status_code=500
)
@@ -471,7 +468,7 @@ def save_schedule_config():
ErrorCode.CONFIG_SAVE_FAILED,
"An error occurred; see logs for details",
status_code=500, details=describe_exception(e)
status_code=500
)
@api_v3.route('/config/dim-schedule', methods=['GET'])
@@ -519,14 +516,14 @@ def get_dim_schedule_config():
return error_response(
ErrorCode.CONFIG_LOAD_FAILED,
"An error occurred; see logs for details",
status_code=500, details=describe_exception(e)
status_code=500
)
except Exception as e:
logging.error(f"[DIM SCHEDULE] Unexpected error loading config: {e}", exc_info=True)
return error_response(
ErrorCode.CONFIG_LOAD_FAILED,
"An error occurred; see logs for details",
status_code=500, details=describe_exception(e)
status_code=500
)
@api_v3.route('/config/dim-schedule', methods=['POST'])
@@ -690,7 +687,7 @@ def save_dim_schedule_config():
ErrorCode.CONFIG_SAVE_FAILED,
"An error occurred; see logs for details",
status_code=500, details=describe_exception(e)
status_code=500
)
@api_v3.route('/config/main', methods=['POST'])
@@ -796,7 +793,7 @@ def save_main_config():
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type',
'row_address_type', 'pixel_mapper_config', 'orientation']
'row_address_type', 'pixel_mapper_config']
if any(k in data for k in display_fields):
if 'display' not in current_config:
@@ -831,11 +828,6 @@ def save_main_config():
if 'pixel_mapper_config' in data and not isinstance(data['pixel_mapper_config'], str):
return jsonify({'status': 'error', 'message': 'pixel_mapper_config must be a string (e.g. "U-mapper;Rotate:90" or empty)'}), 400
# Validate orientation (physical mounting rotation; composed onto pixel_mapper_config at runtime)
ORIENTATION_ALLOWED = {'normal', '180'}
if 'orientation' in data and data['orientation'] not in ORIENTATION_ALLOWED:
return jsonify({'status': 'error', 'message': f"Invalid orientation '{data['orientation']}'. Allowed values: {', '.join(sorted(ORIENTATION_ALLOWED))}"}), 400
# Validate row_address_type
if 'row_address_type' in data:
try:
@@ -849,7 +841,7 @@ def save_main_config():
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type',
'pixel_mapper_config', 'orientation']:
'pixel_mapper_config']:
if field in data:
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
@@ -1322,7 +1314,7 @@ def save_main_config():
return error_response(
ErrorCode.CONFIG_SAVE_FAILED,
"An error occurred; see logs for details",
status_code=500, details=describe_exception(e)
status_code=500
)
@api_v3.route('/config/secrets', methods=['GET'])
@@ -1336,7 +1328,7 @@ def get_secrets_config():
return jsonify({'status': 'success', 'data': config})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/config/raw/main', methods=['POST'])
def save_raw_main_config():
@@ -1369,7 +1361,6 @@ def save_raw_main_config():
return error_response(
ErrorCode.CONFIG_SAVE_FAILED,
error_message,
details=describe_exception(e),
context={'config_path': e.config_path} if hasattr(e, 'config_path') and e.config_path else None,
status_code=500
@@ -1379,7 +1370,6 @@ def save_raw_main_config():
return error_response(
ErrorCode.UNKNOWN_ERROR,
error_message,
details=describe_exception(e),
status_code=500
)
@@ -1419,8 +1409,7 @@ def save_raw_secrets_config():
else:
error_message = 'An error occurred; see logs for details'
return jsonify({'status': 'error', 'message': error_message,
'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': error_message}), 500
@api_v3.route('/system/status', methods=['GET'])
def get_system_status():
@@ -1508,7 +1497,7 @@ def get_system_status():
return jsonify({'status': 'success', 'data': status})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/health', methods=['GET'])
def get_health():
@@ -1607,11 +1596,9 @@ def get_health():
return jsonify({'status': 'success', 'data': health_status})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e),
'data': {'status': 'unhealthy'}
}), 500
@@ -2381,7 +2368,7 @@ def get_display_current():
return jsonify({'status': 'success', 'data': display_data})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/display/on-demand/status', methods=['GET'])
def get_on_demand_status():
@@ -2405,7 +2392,7 @@ def get_on_demand_status():
})
except Exception as exc:
logger.error('Error in get_on_demand_status', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/display/on-demand/start', methods=['POST'])
def start_on_demand_display():
@@ -2508,7 +2495,7 @@ def start_on_demand_display():
return jsonify({'status': 'success', 'data': response_data})
except Exception as exc:
logger.error('Error in start_on_demand_display', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/display/on-demand/stop', methods=['POST'])
def stop_on_demand_display():
@@ -2544,7 +2531,7 @@ def stop_on_demand_display():
})
except Exception as exc:
logger.error('Error in stop_on_demand_display', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/installed', methods=['GET'])
def get_installed_plugins():
@@ -2692,7 +2679,7 @@ def get_installed_plugins():
return jsonify({'status': 'success', 'data': {'plugins': plugins}})
except Exception as e:
logger.error('Error in get_installed_plugins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
def _installed_plugin_ids():
"""Best-effort list of installed plugin IDs for the web process.
@@ -2758,7 +2745,7 @@ def get_plugin_health():
})
except Exception as e:
logger.error('Error in get_plugin_health', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/health/<plugin_id>', methods=['GET'])
def get_plugin_health_single(plugin_id):
@@ -2783,7 +2770,7 @@ def get_plugin_health_single(plugin_id):
})
except Exception as e:
logger.error('Error in get_plugin_health_single', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/health/<plugin_id>/reset', methods=['POST'])
def reset_plugin_health(plugin_id):
@@ -2808,7 +2795,7 @@ def reset_plugin_health(plugin_id):
})
except Exception as e:
logger.error('Error in reset_plugin_health', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/metrics', methods=['GET'])
def get_plugin_metrics():
@@ -2848,7 +2835,7 @@ def get_plugin_metrics():
})
except Exception as e:
logger.error('Error in get_plugin_metrics', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET'])
def get_plugin_metrics_single(plugin_id):
@@ -2873,7 +2860,7 @@ def get_plugin_metrics_single(plugin_id):
})
except Exception as e:
logger.error('Error in get_plugin_metrics_single', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/metrics/<plugin_id>/reset', methods=['POST'])
def reset_plugin_metrics(plugin_id):
@@ -2898,7 +2885,7 @@ def reset_plugin_metrics(plugin_id):
})
except Exception as e:
logger.error('Error in reset_plugin_metrics', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST'])
def manage_plugin_limits(plugin_id):
@@ -2953,7 +2940,7 @@ def manage_plugin_limits(plugin_id):
})
except Exception as e:
logger.error('Error in manage_plugin_limits', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/toggle', methods=['POST'])
def toggle_plugin():
@@ -3962,7 +3949,7 @@ def install_plugin():
except Exception as e:
logger.error('Error in install_plugin', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/install-from-url', methods=['POST'])
def install_plugin_from_url():
@@ -4017,7 +4004,7 @@ def install_plugin_from_url():
except Exception as e:
logger.error('Error in install_plugin_from_url', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/registry-from-url', methods=['POST'])
def get_registry_from_url():
@@ -4049,7 +4036,7 @@ def get_registry_from_url():
except Exception as e:
logger.error('Error in get_registry_from_url', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/saved-repositories', methods=['GET'])
def get_saved_repositories():
@@ -4062,7 +4049,7 @@ def get_saved_repositories():
return jsonify({'status': 'success', 'data': {'repositories': repositories}})
except Exception as e:
logger.error('Error in get_saved_repositories', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/saved-repositories', methods=['POST'])
def add_saved_repository():
@@ -4093,7 +4080,7 @@ def add_saved_repository():
}), 400
except Exception as e:
logger.error('Error in add_saved_repository', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/saved-repositories', methods=['DELETE'])
def remove_saved_repository():
@@ -4123,7 +4110,7 @@ def remove_saved_repository():
}), 404
except Exception as e:
logger.error('Error in remove_saved_repository', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/store/list', methods=['GET'])
def list_plugin_store():
@@ -4176,7 +4163,7 @@ def list_plugin_store():
return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}})
except Exception as e:
logger.error('Error in list_plugin_store', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/store/github-status', methods=['GET'])
def get_github_auth_status():
@@ -4227,7 +4214,7 @@ def get_github_auth_status():
})
except Exception as e:
logger.error('Error in get_github_auth_status', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/store/refresh', methods=['POST'])
def refresh_plugin_store():
@@ -4254,7 +4241,7 @@ def refresh_plugin_store():
})
except Exception as e:
logger.error('Error in refresh_plugin_store', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
def deep_merge(base_dict, update_dict):
"""
@@ -5776,7 +5763,7 @@ def get_plugin_schema():
return jsonify({'status': 'success', 'data': {'schema': default_schema}})
except Exception as e:
logger.error('Error in get_plugin_schema', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/skins', methods=['GET'])
def list_skins():
@@ -5811,9 +5798,9 @@ def list_skins():
'has_preview': bool(preview and (skin_dir / preview).is_file()),
})
return jsonify({'status': 'success', 'data': {'skins': payload}})
except Exception as e:
except Exception:
logger.error('Error in list_skins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/config/reset', methods=['POST'])
def reset_plugin_config():
@@ -5893,7 +5880,7 @@ def reset_plugin_config():
})
except Exception as e:
logger.error('Error in reset_plugin_config', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/action', methods=['POST'])
def execute_plugin_action():
@@ -6153,7 +6140,7 @@ sys.exit(proc.returncode)
logger.error("Error executing action step 1", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
else:
# Simple script execution
@@ -6203,7 +6190,7 @@ sys.exit(proc.returncode)
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
except Exception as e:
logger.error('Error in execute_plugin_action', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/authenticate/spotify', methods=['POST'])
def authenticate_spotify():
@@ -6336,12 +6323,12 @@ sys.exit(proc.returncode)
logger.error("Error getting Spotify auth URL", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
except Exception as e:
logger.error('Error in authenticate_spotify', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/authenticate/ytm', methods=['POST'])
def authenticate_ytm():
@@ -6391,7 +6378,7 @@ def authenticate_ytm():
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
except Exception as e:
logger.error('Error in authenticate_ytm', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/fonts/catalog', methods=['GET'])
def get_fonts_catalog():
@@ -6486,10 +6473,7 @@ def get_fonts_catalog():
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/fonts/tokens', methods=['GET'])
def get_font_tokens():
@@ -6508,7 +6492,7 @@ def get_font_tokens():
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/fonts/overrides', methods=['GET'])
def get_fonts_overrides():
@@ -6520,7 +6504,7 @@ def get_fonts_overrides():
return jsonify({'status': 'success', 'data': {'overrides': overrides}})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/fonts/overrides', methods=['POST'])
def save_fonts_overrides():
@@ -6534,7 +6518,7 @@ def save_fonts_overrides():
return jsonify({'status': 'success', 'message': 'Font overrides saved'})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/fonts/overrides/<element_key>', methods=['DELETE'])
def delete_font_override(element_key):
@@ -6544,7 +6528,7 @@ def delete_font_override(element_key):
return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/fonts/upload', methods=['POST'])
def upload_font():
@@ -6609,7 +6593,7 @@ def upload_font():
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/fonts/preview', methods=['GET'])
@@ -6754,7 +6738,7 @@ def get_font_preview() -> tuple[Response, int] | Response:
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
@@ -6842,7 +6826,7 @@ def delete_font(font_family: str) -> tuple[Response, int] | Response:
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/assets/upload', methods=['POST'])
@@ -6990,7 +6974,7 @@ def upload_plugin_asset():
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST'])
def upload_of_the_day_json():
@@ -7140,7 +7124,7 @@ def upload_of_the_day_json():
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST'])
def delete_of_the_day_json():
@@ -7187,7 +7171,7 @@ def delete_of_the_day_json():
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/<plugin_id>/static/<path:file_path>', methods=['GET'])
def serve_plugin_static(plugin_id, file_path):
@@ -7233,7 +7217,7 @@ def serve_plugin_static(plugin_id, file_path):
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/calendar/upload-credentials', methods=['POST'])
@@ -7315,7 +7299,7 @@ def upload_calendar_credentials():
except Exception as e:
logger.error('Error in upload_calendar_credentials', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/assets/delete', methods=['POST'])
def delete_plugin_asset():
@@ -7358,7 +7342,7 @@ def delete_plugin_asset():
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/assets/list', methods=['GET'])
def list_plugin_assets():
@@ -7386,7 +7370,7 @@ def list_plugin_assets():
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/display/current-status', methods=['GET'])
def get_current_display_status():
@@ -7407,9 +7391,9 @@ def get_current_display_status():
'last_updated': None,
}
return jsonify({'status': 'success', 'data': state})
except Exception as e:
except Exception:
logger.error('Error in get_current_display_status', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/logs', methods=['GET'])
def get_logs():
@@ -7448,11 +7432,9 @@ def get_logs():
'message': 'Timeout while fetching logs'
}), 500
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
# Multi-Display Sync Endpoints
@@ -7517,11 +7499,9 @@ def get_wifi_status():
}
})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/wifi/scan', methods=['GET'])
@@ -7643,7 +7623,7 @@ def connect_wifi():
logger.error("Error connecting to WiFi", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/wifi/disconnect', methods=['POST'])
@@ -7669,7 +7649,7 @@ def disconnect_wifi():
logger.error("Error disconnecting from WiFi", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/wifi/ap/enable', methods=['POST'])
@@ -7694,11 +7674,9 @@ def enable_ap_mode():
'message': message
}), 400
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/wifi/ap/disable', methods=['POST'])
@@ -7721,11 +7699,9 @@ def disable_ap_mode():
'message': message
}), 400
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
@@ -7744,11 +7720,9 @@ def get_auto_enable_ap_mode():
}
})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
@@ -7778,11 +7752,9 @@ def set_auto_enable_ap_mode():
}
})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/wifi/radio', methods=['GET'])
@@ -7802,7 +7774,7 @@ def get_wifi_radio():
logger.error("Error getting WiFi radio state", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/wifi/radio', methods=['POST'])
@@ -7850,7 +7822,7 @@ def set_wifi_radio():
logger.error("Error setting WiFi radio state", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/cache/list', methods=['GET'])
@@ -7875,7 +7847,7 @@ def list_cache_files():
})
except Exception as e:
logger.error('Error in list_cache_files', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/cache/delete', methods=['POST'])
def delete_cache_file():
@@ -7901,7 +7873,7 @@ def delete_cache_file():
})
except Exception as e:
logger.error('Error in delete_cache_file', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
# =============================================================================
@@ -117,14 +117,6 @@
</select>
</div>
<div class="form-group" id="setting-display-orientation" data-setting-key="display.hardware.orientation">
<label for="orientation" class="block text-sm font-medium text-gray-700">Panel Orientation{{ ui.help_tip('Rotates the rendered image to match how the panel is physically mounted.\nUse "Upside Down" if you flipped the panel 180° to move the Raspberry Pi / wiring to a more convenient side.', 'Panel Orientation') }}</label>
<select id="orientation" name="orientation" class="form-control">
<option value="normal" {% if main_config.display.hardware.get('orientation', 'normal') == "normal" %}selected{% endif %}>Normal</option>
<option value="180" {% if main_config.display.hardware.get('orientation', 'normal') == "180" %}selected{% endif %}>Upside Down (180°)</option>
</select>
</div>
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">