Compare commits

..
Author SHA1 Message Date
Chuck 185d5f0aa0 Merge main into claude/on-demand-error-clears-session 2026-10-09 12:15:52 -04:00
ChuckandClaude Opus 5.5 65ede54cba fix(web): keep exception messages out of API responses (py/stack-trace-exposure) (#778)
CodeQL had ~40 open py/stack-trace-exposure alerts on main. Almost all
flowed through describe_exception(), which returned "TypeName: message"
(redacted, capped); the rest through _run_systemctl_command's str(err),
WiFiManager's `return False, str(e)`, unit_refresh's f-strings and two
str(e)/f"{err}" messages in api_v3/__init__.py.

describe_exception() now returns a reason code -- the type, plus the
errno symbol for an OSError ("OSError:EIO", "PermissionError:EACCES") --
and logs the redacted message itself. That keeps what #538 wanted (a
failing disk still says EIO in the response) without quoting paths,
URLs or library internals, and fixes every call site at once; the
test_no_api_v3_handler_discards_its_exception policy still holds.

Service results: _get_display_service_status returns active/returncode
only, and the on-demand start/stop `service` result keeps
returncode/active/started/status but drops systemctl stdout/stderr
(logged on failure). Nothing in web_interface/static, the templates or
the MQTT bridge reads those fields. The Starlark SIGKILL-restart error
no longer returns systemctl stderr as `details`.

WiFi, unit-refresh, config-save and plugin-removal failures now say
what failed with the reason code and point at the log. display.py is
untouched (draft #773 edits it).

Tests: test_api_v3_no_exception_text.py drives one route per affected
file with a marker in the exception message and asserts it never
reaches the body; all 13 fail on origin/main, and targeted mutations
(drop the service filter, put stderr back, str(e) in WiFiManager,
{e} in unit_refresh, {install_err} in system.py, message back in
describe_exception) each fail at least one. Tests that asserted the old
message-in-details contract now assert the reason code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:15:40 -04:00
ChuckandClaude Opus 5.5 20bae8b609 fix(display): a failed on-demand request drops the session it ended (#779)
_set_on_demand_error ends any running session (_reset_on_demand_fields)
but left its saved copy, display_on_demand_config, in the cache. A failed
request that replaced a running session therefore made the next restart
resume the session that had already ended.

Clear the saved copy where every error path goes through, and drop the
two restore-failed callers' own clears, which this now covers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:05:22 -04:00
Chuck 9234332c5a Merge main into claude/on-demand-error-clears-session 2026-10-09 11:55:22 -04:00
ChuckandClaude Sonnet 5.5 1bcb524fc3 feat(scroll): report a panel that cannot reach its refresh cap, and suggest one it can hold (#759)
* feat(scroll): report a panel that cannot reach its refresh cap, and suggest one it can hold

Scroll speeds are solved against display.hardware.limit_refresh_rate_hz,
which is only a ceiling. A panel that cannot reach it still moves whole
pixels per frame, but every scroll runs slow by the shortfall and the
"smooth" ladder is the cap's, not the panel's. A user rig (Pi 4, 2x128x64,
adafruit-hat-pwm, pwm_bits 9, gpio_slowdown 5) measured 107.6-113.1 Hz under
a 120 Hz cap: 60 px/s ran at 55, and nothing said why.

- scroll_config: refresh_shortfall() (more than 3% under the planned rate),
  holdable_cap() (a multiple of 10, 5% under the measurement, since the
  measurement is the fast end of an uncapped panel's drift), and
  describe_refresh_shortfall().
- FrameTimingRecorder.plan_refresh(): once the measured period has held for
  three trusted windows, a shortfall is logged once as a warning naming the
  cap to use. DisplayManager calls it only for a real panel, not the
  emulator or the fallback canvas. The stats file records
  planned_refresh_hz (additive).
- GET /api/v3/config/refresh-rate, plus a hint under the Display tab's
  Limit Refresh Rate field (js/pages/display.js) with a button that fills in
  the suggested cap.
- _panel_refresh_hz (behind the Vegas slider's advice) ignores a measurement
  written under a different cap, so a changed cap stops being advised from
  the old rate before the display restarts.

Verified on ledpi with a temporary 200 Hz cap: the warning logged about a
minute after the restart ("about 132 Hz ... Set Limit Refresh Rate to
120 Hz"), the endpoint returned the same shortfall, and the Display tab
showed the hint; its button filled in 120. ledpi was restored afterwards.
Rebased onto main after the Display tab became an ES-module page (#771); the
hint moved from inline script into display.js, with a jsdom test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(scroll): count only agreeing windows toward the refresh shortfall; ignore a null planned rate

Review fixes on #759.

- A window the period estimate rejects (more than MAX_REFRESH_DROP faster
  than the adopted period) no longer counts toward the shortfall check, and
  it restarts the run. After a loaded start fixed a slow period, later
  windows at the real, faster rate were rejected yet still counted, so the
  warning could name the slow rate against a cap the panel was meeting. It
  now needs REFRESH_CHECK_WINDOWS consecutive windows that agree with the
  period.
- _panel_refresh_hz treats a stats file whose planned_refresh_hz key is
  present but null as no measurement. The emulator and the fallback canvas
  write it that way (DisplayManager never plans a refresh for them), and
  their frame rate says nothing about the cap. A file with no such key (an
  older display) keeps the old behaviour.

Three new tests fail on the previous code and pass now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:40:08 -04:00
ChuckandClaude Opus 5.5 4b9fc3c755 fix(display): a failed on-demand request drops the session it ended
_set_on_demand_error ends any running session (_reset_on_demand_fields)
but left its saved copy, display_on_demand_config, in the cache. A failed
request that replaced a running session therefore made the next restart
resume the session that had already ended.

Clear the saved copy where every error path goes through, and drop the
two restore-failed callers' own clears, which this now covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:18:36 -04:00
29 changed files with 756 additions and 101 deletions
+38
View File
@@ -19,6 +19,37 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
### Scroll speed: a panel slower than its refresh cap is reported
- Scroll speeds are solved against `limit_refresh_rate_hz`, so a panel that
cannot reach its cap ran every scroll slow by the shortfall, with no sign
why (one Pi 4 on a 120 Hz cap refreshed at ~110 Hz: 60 px/s ran at 55).
Once the display has measured the real rate over three windows of
scrolling, a panel more than 3% short of the cap is logged once, as a
warning from `src.common.frame_timing` that names a cap it can hold (a
multiple of 10, 5% under the measurement). The Display tab shows the same
under Limit Refresh Rate, with a button that fills it in, from the new
`GET /api/v3/config/refresh-rate`. Not checked in the emulator or on the
fallback canvas.
- The frame-stats file records `planned_refresh_hz` (additive), and the
scroll-speed advice behind the Vegas slider ignores a measurement written
under a different cap. Until now, after the cap changed, the slider kept
advising from the old rate until the display restarted.
- New in `src.common.scroll_config`: `refresh_shortfall()`, `holdable_cap()`
and `describe_refresh_shortfall()`.
### Fixes
- Web API error responses no longer carry an exception's message (CodeQL
`py/stack-trace-exposure`). `describe_exception()` now returns a reason
code -- the exception type, plus the errno for an `OSError`
(`OSError:EIO`, `PermissionError:EACCES`) -- and logs the message
instead, so `details` still names the fault without quoting paths, URLs
or library internals. The display service status and the on-demand
start/stop `service` results keep `active`, `returncode` and `started`
but drop systemctl's `stdout`/`stderr`; WiFi, unit-refresh and
config-save failures say what failed and point at the log.
## 3.8.3 ## 3.8.3
Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported
@@ -149,6 +180,13 @@ plugin-facing methods only get `@deprecated` (see below).
- Unused pins dropped: `markupsafe` (Flask still installs it) and - Unused pins dropped: `markupsafe` (Flask still installs it) and
`pytest-mock`. `pytest-mock`.
### Fixes
- A failed on-demand request no longer comes back after a restart as the
session it ended. A failed request ends any running session, but the
saved copy of that session (`display_on_demand_config`) was left behind,
so the next restart of the display resumed it.
## 3.8.2 ## 3.8.2
The display hands freed memory back to the OS (#774), and sports consolidation The display hands freed memory back to the OS (#774), and sports consolidation
+25
View File
@@ -77,6 +77,31 @@ The Vegas **Scroll Speed** slider in the web UI shows the same thing live: a
line under it says what your speed will run as on this panel, and links to the line under it says what your speed will run as on this panel, and links to the
nearest smooth speeds. nearest smooth speeds.
### A panel that cannot reach its cap
Speeds are solved against `limit_refresh_rate_hz`, the configured cap, but a
cap is only a ceiling: a long chain, a high `pwm_bits` or a big
`gpio_slowdown` can leave the panel below it. One Pi 4 driving 2×128×64 on
`adafruit-hat-pwm` with `pwm_bits 9` and `gpio_slowdown 5` measured
107.6–113.1 Hz under a 120 Hz cap. Frames still move whole pixels, but
every scroll runs that much slower than configured (60 px/s ran at 55 px/s),
and the smooth speeds are the cap's rather than the panel's.
The display measures the real rate from its own frames. About a minute
into scrolling, a panel more than 3% short of its cap is logged once:
```
WARNING - src.common.frame_timing - The panel refreshes at about 113 Hz, below
the 120 Hz that scroll speeds are planned for ... Set Limit Refresh Rate to
100 Hz (web UI, Display tab), which this panel can hold, and restart.
```
The Display tab says the same under **Limit Refresh Rate**, with a button
that fills in the suggested cap (`GET /api/v3/config/refresh-rate`). The
suggestion is a multiple of 10 at least 5% under the measurement, because
an uncapped panel drifts and the measurement is the fast end of it. A cap the
panel holds also stops the drift.
### How a slow speed stays crisp ### How a slow speed stays crisp
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel `SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
+49
View File
@@ -135,6 +135,8 @@ import time
import traceback import traceback
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
from src.common import scroll_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: Bumped when a field changes meaning, so a reader can refuse stale files. #: Bumped when a field changes meaning, so a reader can refuse stale files.
@@ -179,6 +181,12 @@ MAX_REFRESH_DROP = 0.2
#: trusted -- about a second of scrolling. #: trusted -- about a second of scrolling.
MIN_FRAMES_FOR_REFRESH = 90 MIN_FRAMES_FOR_REFRESH = 90
#: Consecutive windows that agree with the adopted refresh period (the one
#: that adopted it counts) before a panel slower than its cap is reported. The
#: estimate can still fall by up to MAX_REFRESH_DROP per window early on; the
#: warning should not name a rate one more window would have corrected.
REFRESH_CHECK_WINDOWS = 3
FLUSH_INTERVAL = 10.0 FLUSH_INTERVAL = 10.0
#: A scroll's last frame older than this is a stall worth a stack dump. #: A scroll's last frame older than this is a stall worth a stack dump.
@@ -441,6 +449,12 @@ class FrameTimingRecorder:
1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None) 1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None)
# The first estimate, until a second window agrees with it. # The first estimate, until a second window agrees with it.
self._refresh_candidate: Optional[float] = None self._refresh_candidate: Optional[float] = None
# The rate scroll speeds are solved against; see plan_refresh().
self.planned_refresh_hz: Optional[float] = None
# Trusted windows seen since the period was adopted, until the
# shortfall check has run.
self._refresh_windows = 0
self._shortfall_checked = True
self.totals: Dict[str, Any] = { self.totals: Dict[str, Any] = {
"static_frames": 0, "static_frames": 0,
"scroll_frames": 0, "scroll_frames": 0,
@@ -639,7 +653,20 @@ class FrameTimingRecorder:
self._refresh_candidate = estimate self._refresh_candidate = estimate
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current: elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
self.refresh_period = estimate self.refresh_period = estimate
# Only a run of windows that agree with the period counts toward
# the shortfall check. One that disagrees (a faster one than the
# period may fall to, say, after a loaded start fixed a slow one)
# was rejected above, so the period does not reflect it, and a
# warning built on that period would name a rate the panel is not
# at. It resets the run; the check then waits for three that agree.
current = self.refresh_period
if current is not None:
agrees = abs(estimate - current) <= current * MAX_REFRESH_DROP
self._refresh_windows = self._refresh_windows + 1 if agrees else 0
period = self.refresh_period period = self.refresh_period
if (period and not self._shortfall_checked
and self._refresh_windows >= REFRESH_CHECK_WINDOWS):
self._check_refresh_shortfall(1.0 / period)
histograms = self.histograms histograms = self.histograms
for frame in batch: for frame in batch:
@@ -688,6 +715,25 @@ class FrameTimingRecorder:
elif missed <= -1: elif missed <= -1:
totals["early_frames"] += 1 totals["early_frames"] += 1
def plan_refresh(self, hz: Optional[float]) -> None:
"""Say what rate scroll speeds are solved against, before frames arrive.
``DisplayManager.refresh_hz``: the configured cap. Once the measured
rate has held for :data:`REFRESH_CHECK_WINDOWS` windows in a row, a panel that
falls short of it is logged once, with a cap it can hold (see
:func:`src.common.scroll_config.refresh_shortfall`). The display
manager calls this only for a real panel.
"""
self.planned_refresh_hz = hz
self._shortfall_checked = not hz
def _check_refresh_shortfall(self, measured_hz: float) -> None:
"""Log, once, a panel that cannot reach the rate speeds assume."""
self._shortfall_checked = True
shortfall = scroll_config.refresh_shortfall(measured_hz, self.planned_refresh_hz)
if shortfall:
logger.warning(scroll_config.describe_refresh_shortfall(shortfall))
def snapshot(self) -> Dict[str, Any]: def snapshot(self) -> Dict[str, Any]:
"""The JSON document: cumulative since this process started.""" """The JSON document: cumulative since this process started."""
if not self._binding_checked: if not self._binding_checked:
@@ -704,6 +750,9 @@ class FrameTimingRecorder:
"bucket_ms": BUCKET_MS, "bucket_ms": BUCKET_MS,
"freeze_seconds": FREEZE_SECONDS, "freeze_seconds": FREEZE_SECONDS,
"measured_refresh_hz": round(1.0 / period, 2) if period else None, "measured_refresh_hz": round(1.0 / period, 2) if period else None,
# Additive: what scroll speeds were solved against, so a reader can
# tell a stale file (written under another cap) from this one.
"planned_refresh_hz": self.planned_refresh_hz,
"binding_releases_gil": self._binding_gil, "binding_releases_gil": self._binding_gil,
"info": info, "info": info,
"totals": copy.deepcopy(self.totals), "totals": copy.deepcopy(self.totals),
+63
View File
@@ -538,3 +538,66 @@ def speed_advice(
"smooth": smooth, "smooth": smooth,
"alternatives": [as_dict(c) for c in alternatives], "alternatives": [as_dict(c) for c in alternatives],
} }
#: A measured refresh this far below the rate speeds are planned for means
#: the panel cannot reach its cap. Smaller gaps are the cap's own slack and
#: the estimate's: one rig measured 99.95 Hz under a 100 Hz cap.
REFRESH_SHORTFALL = 0.03
#: How far under the measured rate a suggested cap sits. The measurement is
#: the fast end of the panel's refreshes (frame_timing takes the 10th
#: percentile of intervals), and an uncapped panel drifts: one read
#: 107.6-113.1 Hz over 15 seconds. A cap inside that band would not hold.
CAP_HEADROOM = 0.05
def holdable_cap(measured_hz: Any) -> Optional[int]:
"""A refresh cap the panel can hold: a multiple of 10, 5% under what it measured.
A multiple of 10 because its whole-pixel speeds are round numbers (a
100 Hz cap gives 50 and 100 px/s). None without a usable measurement, or
when the panel is too slow for any cap of 10 Hz or more.
"""
hz = _coerce(measured_hz)
if hz is None:
return None
cap = int(hz * (1.0 - CAP_HEADROOM) // 10) * 10
return cap if cap >= 10 else None
def refresh_shortfall(measured_hz: Any, planned_hz: Any) -> Optional[Dict[str, Any]]:
"""When the panel refreshes measurably slower than speeds are planned for.
``planned_hz`` is what :func:`configure` solves against -- the
``limit_refresh_rate_hz`` cap, or :data:`DEFAULT_REFRESH_HZ` when it is 0.
A panel that cannot reach it still moves whole pixels per frame, but every
speed runs slow by the shortfall and the ladder of smooth speeds is the
cap's, not the panel's. None when there is no measurement, or the panel
reaches the cap (or beats it, as some do by a few Hz).
"""
measured, planned = _coerce(measured_hz), _coerce(planned_hz)
if measured is None or planned is None:
return None
if measured >= planned * (1.0 - REFRESH_SHORTFALL):
return None
return {
"measured_hz": round(measured, 1),
"planned_hz": round(planned, 1),
"suggested_cap_hz": holdable_cap(measured),
"slow_percent": round((1.0 - measured / planned) * 100),
}
def describe_refresh_shortfall(shortfall: Dict[str, Any]) -> str:
"""One log line for :func:`refresh_shortfall`'s answer."""
text = (
f"The panel refreshes at about {shortfall['measured_hz']:.0f} Hz, below "
f"the {shortfall['planned_hz']:.0f} Hz that scroll speeds are planned "
f"for (display.hardware.limit_refresh_rate_hz), so every scroll runs "
f"about {shortfall['slow_percent']}% slower than configured and the "
f"smooth speeds are worked out for a rate this panel never reaches.")
if shortfall.get("suggested_cap_hz"):
text += (f" Set Limit Refresh Rate to {shortfall['suggested_cap_hz']} Hz "
f"(web UI, Display tab), which this panel can hold, and restart.")
return text
+7 -3
View File
@@ -655,7 +655,6 @@ class DisplayController:
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
cached_session = None cached_session = None
if self.on_demand_active or cached_session: if self.on_demand_active or cached_session:
self.cache_manager.clear_cache('display_on_demand_config')
self._set_on_demand_error('restore-failed') self._set_on_demand_error('restore-failed')
# Its state machine no longer describes what runs; let the last # Its state machine no longer describes what runs; let the last
# snapshot go stale (readers then say unknown) rather than keep # snapshot go stale (readers then say unknown) rather than keep
@@ -1804,8 +1803,14 @@ class DisplayController:
logger.error("Failed to publish on-demand state: %s", err, exc_info=True) logger.error("Failed to publish on-demand state: %s", err, exc_info=True)
def _set_on_demand_error(self, message: str) -> None: def _set_on_demand_error(self, message: str) -> None:
"""Set on-demand state to error and publish.""" """Set on-demand state to error and publish.
Ends any running session, so its saved copy goes too: a failed
request that replaced a session left display_on_demand_config
behind, and the next restart resumed the session that had ended.
"""
self._reset_on_demand_fields() self._reset_on_demand_fields()
self.cache_manager.clear_cache('display_on_demand_config')
self.on_demand_status = 'error' self.on_demand_status = 'error'
self.on_demand_last_error = message self.on_demand_last_error = message
self.on_demand_last_event = None self.on_demand_last_event = None
@@ -2743,7 +2748,6 @@ class DisplayController:
logger.error("On-demand session for plugin '%s' cannot resume after the " logger.error("On-demand session for plugin '%s' cannot resume after the "
"restart: the plugin has no loaded display modes (did it " "restart: the plugin has no loaded display modes (did it "
"fail to load?); ending it", plugin_id) "fail to load?); ending it", plugin_id)
self.cache_manager.clear_cache('display_on_demand_config')
self._set_on_demand_error('restore-failed') self._set_on_demand_error('restore-failed')
return return
+8 -1
View File
@@ -393,6 +393,11 @@ class DisplayManager:
self._setup_matrix() self._setup_matrix()
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time) logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
# Only a real panel's swaps wait on its refresh: the emulator and the
# fallback canvas pace themselves, so "slower than the cap" would be
# noise there.
if self.matrix is not None and os.environ.get('EMULATOR', 'false') != 'true':
self.frame_timing.plan_refresh(self.refresh_hz)
self._setup_scan_order_compensation() self._setup_scan_order_compensation()
font_time = time.time() font_time = time.time()
@@ -1501,7 +1506,9 @@ class DisplayManager:
fractional-pixel motion. See src/common/scroll_config.py. fractional-pixel motion. See src/common/scroll_config.py.
Note this is the configured *cap*, not necessarily what the panel Note this is the configured *cap*, not necessarily what the panel
achieves -- scripts/scroll_speeds.py --measure reports the real rate. achieves -- scripts/scroll_speeds.py --measure reports the real rate,
and the frame-timing recorder logs a warning, with a cap the panel can
hold, once it has measured a panel that falls short of this.
""" """
hardware = (self.config.get('display') or {}).get('hardware') or {} hardware = (self.config.get('display') or {}).get('hardware') or {}
try: try:
+19 -11
View File
@@ -5,18 +5,22 @@ Safe exception descriptions and the bodies for exceptions no route handled.
The standard success/error responses are in api_helpers. The standard success/error responses are in api_helpers.
""" """
import errno
from src.logging_config import get_logger
from src.redaction import redact_credentials from src.redaction import redact_credentials
logger = get_logger(__name__)
# Long enough for an errno string with a path, short enough not to dump a # Long enough for an errno string with a path, short enough not to dump a
# parser's worth of context into a JSON field. # parser's worth of context into a JSON field.
_MAX_DETAIL_LENGTH = 400 _MAX_DETAIL_LENGTH = 400
def describe_exception(exc: BaseException, def describe_exception(exc: BaseException) -> str:
max_length: int = _MAX_DETAIL_LENGTH) -> str:
""" """
One-line, safe-to-return description of an exception. Machine-readable reason code for an exception, safe to return over HTTP.
The generic "an error occurred; see logs for details" tells a user nothing 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 and, when the failure is bad enough, the logs are unreachable too: a device
@@ -24,20 +28,24 @@ def describe_exception(exc: BaseException,
*including* the log viewer, because journalctl could not be executed. The *including* the log viewer, because journalctl could not be executed. The
underlying `[Errno 5] Input/output error` named the fault immediately. underlying `[Errno 5] Input/output error` named the fault immediately.
Returns "TypeName: message", credentials redacted and length capped. The So the type and errno still go back -- "OSError:EIO", "PermissionError:
type alone is worth carrying -- a bare PermissionError says more than any EACCES", "TimeoutExpired" -- but never the exception's message, which can
generic sentence. quote paths, URLs, credentials or a library's internals (CodeQL
py/stack-trace-exposure). The message is logged here instead, so every
reason code a client sees has its full text in the log.
Args: Args:
exc: The exception to describe exc: The exception to describe
max_length: Truncate beyond this many characters
Returns: Returns:
A single-line description, never empty "TypeName" or "TypeName:ERRNO", never empty
""" """
message = str(exc).strip() code = type(exc).__name__
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__ exc_errno = getattr(exc, 'errno', None)
return redact_text(text, max_length) if isinstance(exc_errno, int) and exc_errno in errno.errorcode:
code = f"{code}:{errno.errorcode[exc_errno]}"
logger.warning("Error reported to the client as %s: %s", code, redact_text(str(exc)))
return code
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str: def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
+9 -9
View File
@@ -1369,7 +1369,7 @@ class WiFiManager:
self.enable_ap_mode(force=True) self.enable_ap_mode(force=True)
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True) logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
return False, str(e) return False, f"Connection failed ({type(e).__name__}); see logs for details"
def _failsafe_ap(self, enabled_msg: str, failed_msg: str) -> Tuple[bool, str]: def _failsafe_ap(self, enabled_msg: str, failed_msg: str) -> Tuple[bool, str]:
"""Force the setup AP up after a connect that left no working network, """Force the setup AP up after a connect that left no working network,
@@ -1585,7 +1585,7 @@ class WiFiManager:
except Exception as e: except Exception as e:
logger.error(f"Error connecting with nmcli: {e}") logger.error(f"Error connecting with nmcli: {e}")
self._show_led_message("Connection error", duration=5) self._show_led_message("Connection error", duration=5)
return False, str(e) return False, f"Connection failed ({type(e).__name__}); see logs for details"
# 802.11 caps an SSID at 32 octets. Control characters cannot appear in a # 802.11 caps an SSID at 32 octets. Control characters cannot appear in a
# real one, and a leading "-" would be read by nmcli as an option rather # real one, and a leading "-" would be read by nmcli as an option rather
@@ -1725,7 +1725,7 @@ class WiFiManager:
return False, "nmcli is required to disconnect from WiFi" return False, "nmcli is required to disconnect from WiFi"
except Exception as e: except Exception as e:
logger.error(f"Error disconnecting from WiFi: {e}") logger.error(f"Error disconnecting from WiFi: {e}")
return False, str(e) return False, f"Disconnect failed ({type(e).__name__}); see logs for details"
def _ensure_wifi_radio_enabled(self, max_retries: int = 3) -> bool: def _ensure_wifi_radio_enabled(self, max_retries: int = 3) -> bool:
""" """
@@ -2004,7 +2004,7 @@ class WiFiManager:
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)" return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
except Exception as e: except Exception as e:
logger.error(f"Error in enable_ap_mode: {e}") logger.error(f"Error in enable_ap_mode: {e}")
return False, str(e) return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
def _mark_forced(self) -> None: def _mark_forced(self) -> None:
"""Record that AP mode was forced on, so the periodic check leaves it """Record that AP mode was forced on, so the periodic check leaves it
@@ -2099,10 +2099,10 @@ class WiFiManager:
return True, "AP mode enabled" return True, "AP mode enabled"
except Exception as e: except Exception as e:
logger.error(f"Error starting AP services: {e}") logger.error(f"Error starting AP services: {e}")
return False, str(e) return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
except Exception as e: except Exception as e:
logger.error(f"Error enabling AP mode: {e}") logger.error(f"Error enabling AP mode: {e}")
return False, str(e) return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]: def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
""" """
@@ -2227,7 +2227,7 @@ class WiFiManager:
logger.error(f"Error starting AP mode with nmcli: {e}") logger.error(f"Error starting AP mode with nmcli: {e}")
self._remove_nm_dnsmasq_captive_conf() self._remove_nm_dnsmasq_captive_conf()
self._show_led_message("Setup mode error", duration=5) self._show_led_message("Setup mode error", duration=5)
return False, str(e) return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
def _get_ap_status_nmcli(self) -> Dict: def _get_ap_status_nmcli(self) -> Dict:
""" """
@@ -2409,10 +2409,10 @@ class WiFiManager:
return True, "AP mode disabled" return True, "AP mode disabled"
except Exception as e: except Exception as e:
logger.error(f"Error stopping AP services: {e}") logger.error(f"Error stopping AP services: {e}")
return False, str(e) return False, f"Could not disable AP mode ({type(e).__name__}); see logs for details"
except Exception as e: except Exception as e:
logger.error(f"Error disabling AP mode: {e}") logger.error(f"Error disabling AP mode: {e}")
return False, str(e) return False, f"Could not disable AP mode ({type(e).__name__}); see logs for details"
def _create_hostapd_config(self): def _create_hostapd_config(self):
"""Create hostapd configuration file""" """Create hostapd configuration file"""
+9
View File
@@ -175,6 +175,15 @@
"POST" "POST"
] ]
], ],
[
"/api/v3/config/refresh-rate",
"api_v3.get_refresh_rate",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[ [
"/api/v3/config/schedule", "/api/v3/config/schedule",
"api_v3.get_schedule_config", "api_v3.get_schedule_config",
+1 -1
View File
@@ -71,7 +71,7 @@ server has none.
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points | | `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points | | `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
| `dom/test_visibility_service.js` | yes (no server) | `js/core/visibility.js` with the real `LEDVisibility` from `app-shell.js` and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-`LEDVisibility` fallback | | `dom/test_visibility_service.js` | yes (no server) | `js/core/visibility.js` with the real `LEDVisibility` from `app-shell.js` and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-`LEDVisibility` fallback |
| `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, `updateSyncUI`'s entry point | | `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, the refresh-cap hint and its "Use N Hz" button, `updateSyncUI`'s entry point |
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points | | `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points | | `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from | | `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
+11
View File
@@ -88,6 +88,8 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
let syncAnswer = { status: 'success', data: { role: 'leader', state: 'no_peer' } }; let syncAnswer = { status: 'success', data: { role: 'leader', state: 'no_peer' } };
let syncMode = 'ok'; let syncMode = 'ok';
let advice = smooth; let advice = smooth;
const shortfall = { measured_hz: 110.4, planned_hz: 120, suggested_cap_hz: 100, slow_percent: 8 };
let refreshAnswer = { status: 'success', data: { planned_hz: 120, measured_hz: 110.4, shortfall } };
const requests = []; const requests = [];
function fakeFetch(url, init = {}) { function fakeFetch(url, init = {}) {
requests.push(url); requests.push(url);
@@ -99,6 +101,7 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
}); });
if (url === '/api/v3/plugins/installed') return respond(200, { status: 'success', data: { plugins } }); if (url === '/api/v3/plugins/installed') return respond(200, { status: 'success', data: { plugins } });
if (url.startsWith('/api/v3/config/scroll-speed-advice?')) return respond(200, advice); if (url.startsWith('/api/v3/config/scroll-speed-advice?')) return respond(200, advice);
if (url === '/api/v3/config/refresh-rate') return respond(200, refreshAnswer);
if (url === '/api/v3/sync/status') { if (url === '/api/v3/sync/status') {
if (syncMode === 'network') return Promise.reject(new TypeError('Failed to fetch')); if (syncMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (syncMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' }); if (syncMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
@@ -148,6 +151,14 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
// ── first load ────────────────────────────────────────────────────────── // ── first load ──────────────────────────────────────────────────────────
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1, requests); ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1, requests);
ok('one scroll-speed hint request on start (after the debounce)', count('/api/v3/config/scroll-speed-advice') === 1, requests); ok('one scroll-speed hint request on start (after the debounce)', count('/api/v3/config/scroll-speed-advice') === 1, requests);
const refreshHint = $('limit_refresh_rate_hz_hint');
ok('a panel short of its cap says so, as text, with a button for a cap it can hold',
/about 110 Hz, below this 120 Hz cap.*8% slower/.test(refreshHint.textContent)
&& refreshHint.querySelector('button').textContent === 'Use 100 Hz', refreshHint.textContent);
refreshHint.querySelector('button').click();
ok('the button fills the field and says to save and restart',
$('limit_refresh_rate_hz').value === '100' && /Save, then restart/.test(refreshHint.textContent),
[$('limit_refresh_rate_hz').value, refreshHint.textContent]);
ok('the saved role is standalone: no sync request, no interval work', ok('the saved role is standalone: no sync request, no interval work',
$('sync_role').value === 'standalone' && syncPolls() === 0, [$('sync_role').value, syncPolls()]); $('sync_role').value === 'standalone' && syncPolls() === 0, [$('sync_role').value, syncPolls()]);
ok('the sync poll interval runs while the tab is on screen', intervals.size === 1 ok('the sync poll interval runs while the tab is on screen', intervals.size === 1
+2 -1
View File
@@ -147,7 +147,8 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
side_effect=RuntimeError("disk is gone")) side_effect=RuntimeError("disk is gone"))
resp = api_v3_client.get('/api/v3/display/modes') resp = api_v3_client.get('/api/v3/display/modes')
assert resp.status_code == 500 assert resp.status_code == 500
assert 'disk is gone' in resp.get_json()['details'] assert resp.get_json()['details'] == 'RuntimeError'
assert 'disk is gone' not in json.dumps(resp.get_json())
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client): def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
"""describe_exception is what makes returning detail safe.""" """describe_exception is what makes returning detail safe."""
+147
View File
@@ -0,0 +1,147 @@
"""No API response carries an exception's message (CodeQL py/stack-trace-exposure).
One representative route per file that had open alerts. Each forces a failure
whose message holds a marker and asserts the marker is nowhere in the body:
the message goes to the log, the client gets a fixed message plus a reason
code (describe_exception: the type, and the errno for an OSError).
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
LEAK = "LEAKED-/home/pi/secret token=abc123"
API = "web_interface.blueprints.api_v3"
def _assert_no_leak(response):
body = response.get_data(as_text=True)
assert "LEAKED" not in body, body
assert "abc123" not in body, body
return json.loads(body)
def test_display_service_status_drops_systemctl_output(api_v3_module, api_v3_client,
monkeypatch):
"""display.py: the on-demand routes return the service status verbatim."""
api_v3_module.api_v3.cache_manager.get.return_value = None
monkeypatch.setattr(f"{API}.display.display_state.read_state", lambda: None)
with patch(f"{API}.subprocess.run", side_effect=OSError(13, LEAK)):
body = _assert_no_leak(api_v3_client.get("/api/v3/display/on-demand/status"))
assert body["data"]["service"] == {"active": False, "returncode": -1}
@pytest.mark.parametrize("helper", ["_ensure_display_service_running",
"_stop_display_service"])
def test_service_results_keep_returncode_but_not_output(api_v3_module, helper):
"""display.py start/stop: returncode/active/started stay, stdout/stderr go."""
failed = MagicMock(returncode=1, stdout=LEAK, stderr=LEAK)
with patch(f"{API}.subprocess.run", return_value=failed):
result = getattr(api_v3_module, helper)()
assert "LEAKED" not in json.dumps(result)
assert result["returncode"] == 1 and result["active"] is False
assert "stdout" not in result and "stderr" not in result
def test_wifi_connect_failure(api_v3_client):
"""wifi.py: a raising connect, and the attempt /wifi/status reports after."""
with patch("src.wifi_manager.WiFiManager") as cls:
cls.return_value._is_ap_mode_active.return_value = False
cls.return_value.connect_to_network.side_effect = RuntimeError(LEAK)
body = _assert_no_leak(api_v3_client.post(
"/api/v3/wifi/connect", json={"ssid": "HomeNet", "password": "pw"}))
assert body["details"] == "RuntimeError"
cls.return_value.get_wifi_status.return_value = MagicMock(
connected=False, ssid=None, ip_address=None, signal=0, ap_mode_active=False)
cls.return_value.config = {}
status = _assert_no_leak(api_v3_client.get("/api/v3/wifi/status"))
assert status["data"]["last_connect_attempt"]["message"] == (
"Failed to connect to network (RuntimeError)")
def test_wifi_manager_messages_carry_no_exception_text():
"""src/wifi_manager.py: its (success, message) is what the wifi routes return."""
from src.wifi_manager import WiFiManager
manager = WiFiManager.__new__(WiFiManager) # no __init__: no host access
manager.get_wifi_status = MagicMock(side_effect=OSError(5, LEAK))
success, message = manager.disconnect_from_network()
assert success is False
assert "LEAKED" not in message and "OSError" in message
def test_system_action_exception(api_v3_client):
"""system.py: execute_system_action's catch-all."""
with patch("subprocess.run", side_effect=OSError(5, LEAK)):
body = _assert_no_leak(api_v3_client.post(
"/api/v3/system/action", json={"action": "stop_display"}))
assert body["details"] == "OSError:EIO"
def test_calendar_registration_failure(api_v3_client, tmp_path, monkeypatch):
"""plugin_calendar.py: the auth script could not be run."""
plugin_dir = tmp_path / "calendar"
plugin_dir.mkdir()
(plugin_dir / "credentials.json").write_text("{}", encoding="utf-8")
(plugin_dir / "calendar_registration.py").write_text("", encoding="utf-8")
monkeypatch.setattr(f"{API}._calendar_plugin_dir", lambda: plugin_dir)
with patch(f"{API}.subprocess.run", side_effect=OSError(13, LEAK)):
body = _assert_no_leak(api_v3_client.post(
"/api/v3/plugins/calendar/authenticate", json={"code": "x"}))
assert "EACCES" in body["message"]
def test_health_failure(api_v3_client, monkeypatch):
"""misc.py: get_health's catch-all."""
def boom():
raise RuntimeError(LEAK)
monkeypatch.setattr(f"{API}.misc._get_display_service_status", boom)
body = _assert_no_leak(api_v3_client.get("/api/v3/health"))
assert body["details"] == "RuntimeError"
def test_config_route_failure(api_v3_module, api_v3_client):
"""error_handler.py: create_error_response, as config.py's routes use it."""
api_v3_module.api_v3.config_manager.load_config.side_effect = RuntimeError(LEAK)
body = _assert_no_leak(api_v3_client.get("/api/v3/config/schedule"))
assert body["details"] == "RuntimeError"
def test_plugin_route_failure(api_v3_module, api_v3_client):
"""plugins.py: an unhandled error in a plugin route."""
api_v3_module.api_v3.plugin_catalog.get_all_plugin_info.side_effect = RuntimeError(LEAK)
body = _assert_no_leak(api_v3_client.get("/api/v3/plugins/installed"))
assert body["details"] == "RuntimeError"
def test_starlark_route_failure(api_v3_client):
"""starlark.py: one of its catch-alls."""
with patch(f"{API}._get_starlark_plugin", side_effect=RuntimeError(LEAK)):
body = _assert_no_leak(api_v3_client.get("/api/v3/starlark/status"))
assert body["details"] == "RuntimeError"
def test_unit_refresh_failure(monkeypatch):
"""system.py git_pull: perform_core_update appends unit_refresh's message."""
from web_interface import unit_refresh
def boom(*_a, **_k):
raise RuntimeError(LEAK)
monkeypatch.setattr(unit_refresh, "stale_units", boom)
result = unit_refresh.refresh_after_update()
assert result["status"] == unit_refresh.FAILED
assert "LEAKED" not in result["message"]
def test_install_base_requirements_failure(api_v3_client):
"""system.py: a pip install that could not start, in the action's output."""
with patch(f"{API}.system._pip_install_requirements", side_effect=OSError(5, LEAK)):
body = _assert_no_leak(api_v3_client.post(
"/api/v3/system/action", json={"action": "install_base_requirements"}))
assert "Failed: OSError:EIO" in body["output"]
+4 -3
View File
@@ -95,9 +95,10 @@ class TestRefreshPluginStore:
RuntimeError("failed at /home/user/LEDMatrix/src/secret.py line 42")) RuntimeError("failed at /home/user/LEDMatrix/src/secret.py line 42"))
body = api_v3_client.post(self.URL, json={}).get_json() body = api_v3_client.post(self.URL, json={}).get_json()
assert "Traceback" not in str(body) assert "Traceback" not in str(body)
# `details` is describe_exception output: one line, type-named, # `details` is describe_exception output: the type, never the
# credential-redacted. It may quote the message, but never a stack. # message or a stack.
assert body["details"].startswith("RuntimeError:") assert body["details"] == "RuntimeError"
assert "secret.py" not in str(body)
assert "\n" not in body["details"] assert "\n" not in body["details"]
+77
View File
@@ -807,3 +807,80 @@ def test_a_process_with_the_gc_monitor_exits_cleanly():
assert proc.returncode == 0, proc.stderr assert proc.returncode == 0, proc.stderr
assert "Exception ignored" not in proc.stderr assert "Exception ignored" not in proc.stderr
assert "installed at exit: False" in proc.stdout assert "installed at exit: False" in proc.stdout
SLOW = 1 / 110.0 # a panel that cannot reach a 120 Hz cap
def _windows(recorder, n, interval, start=0.0):
for i in range(n):
_feed(recorder, [interval] * 200, start=start + 50.0 * i)
_aggregate(recorder)
def _shortfall_warnings(caplog):
return [r for r in caplog.records
if r.name == "src.common.frame_timing" and "Limit Refresh Rate" in r.getMessage()]
def test_a_panel_slower_than_its_cap_is_reported_once(tmp_path, caplog):
r = _recorder(tmp_path)
r.plan_refresh(120.0)
caplog.set_level("WARNING")
_windows(r, 3, SLOW) # adopted on the 2nd window, checked on the 4th
assert _shortfall_warnings(caplog) == []
_windows(r, 3, SLOW, start=1000.0)
warnings = _shortfall_warnings(caplog)
assert len(warnings) == 1
assert "about 110 Hz" in warnings[0].getMessage()
assert "to 100 Hz" in warnings[0].getMessage()
def test_a_panel_that_reaches_its_cap_is_not_reported(tmp_path, caplog):
r = _recorder(tmp_path)
r.plan_refresh(100.0)
caplog.set_level("WARNING")
_windows(r, 6, PERIOD)
assert _shortfall_warnings(caplog) == []
def test_without_a_planned_rate_nothing_is_checked(tmp_path, caplog):
# The emulator and the fallback canvas: DisplayManager never calls
# plan_refresh(), since their frames are not paced by a panel.
r = _recorder(tmp_path)
caplog.set_level("WARNING")
_windows(r, 6, SLOW)
assert _shortfall_warnings(caplog) == []
def test_the_snapshot_records_the_planned_rate(tmp_path):
r = _recorder(tmp_path)
assert r.snapshot()["planned_refresh_hz"] is None
r.plan_refresh(120.0)
assert r.snapshot()["planned_refresh_hz"] == 120.0
def test_windows_the_period_rejected_do_not_count_toward_the_warning(tmp_path, caplog):
# A loaded start fixed 60 Hz (two windows agreed); the panel really runs at
# 100 Hz, but a window that much faster is ignored by the estimate, so the
# period stays 60 Hz. Warning "60 Hz is under your 100 Hz cap" would be wrong.
r = _recorder(tmp_path)
r.plan_refresh(100.0)
caplog.set_level("WARNING")
_windows(r, 2, 1 / 60.0)
assert abs(1.0 / r.refresh_period - 60.0) < 0.5
_windows(r, 6, PERIOD, start=1000.0)
assert abs(1.0 / r.refresh_period - 60.0) < 0.5 # still ignored
assert _shortfall_warnings(caplog) == []
def test_one_disagreeing_window_restarts_the_run(tmp_path, caplog):
r = _recorder(tmp_path)
r.plan_refresh(120.0)
caplog.set_level("WARNING")
_windows(r, 3, SLOW) # two windows toward three
_windows(r, 1, 1 / 250.0, start=1000.0) # far faster: rejected, resets
_windows(r, 1, SLOW, start=2000.0)
assert _shortfall_warnings(caplog) == []
_windows(r, 2, SLOW, start=3000.0) # three in a row now
assert len(_shortfall_warnings(caplog)) == 1
+9
View File
@@ -217,6 +217,15 @@ class TestReleasingThePlugin:
assert controller.current_display_mode == 'clock' assert controller.current_display_mode == 'clock'
assert controller.force_change is True assert controller.force_change is True
def test_a_failed_request_that_ends_the_session_drops_its_saved_copy(self, controller):
"""Otherwise the next restart resumes the session that just ended."""
_start(controller, plugin_id='clock')
controller.cache_manager.clear_cache.reset_mock()
_start(controller, plugin_id='uninstalled')
controller.cache_manager.clear_cache.assert_called_once_with('display_on_demand_config')
def test_a_plugin_enabled_during_the_session_stays_loaded(self, controller): def test_a_plugin_enabled_during_the_session_stays_loaded(self, controller):
_start(controller) _start(controller)
controller.test_config['preview-me'] = {'enabled': True} controller.test_config['preview-me'] = {'enabled': True}
+44
View File
@@ -21,6 +21,7 @@ from src.common.scroll_config import ( # noqa: E402
refresh_hz_from_config, refresh_hz_from_config,
resolve, resolve,
) )
from src.common import scroll_config # noqa: E402
class FakeHelper: class FakeHelper:
@@ -504,3 +505,46 @@ class TestSpeedAdvice:
got = solve_crisp(50, 125.74) got = solve_crisp(50, 125.74)
assert got.steppiness == "smooth" assert got.steppiness == "smooth"
assert got.pixels_per_frame == 1 assert got.pixels_per_frame == 1
class TestRefreshShortfall:
"""A panel that cannot reach its cap runs every scroll slow."""
def test_the_ledmatrix_rig_is_told_to_cap_at_100(self):
# Pi 4, 2x128x64 on adafruit-hat-pwm under a 120 Hz cap: measured
# 107.6-113.1 Hz, and frame_timing reports the fast end.
s = scroll_config.refresh_shortfall(113.1, 120)
assert s == {"measured_hz": 113.1, "planned_hz": 120.0,
"suggested_cap_hz": 100, "slow_percent": 6}
def test_a_panel_that_holds_its_cap_is_fine(self):
assert scroll_config.refresh_shortfall(99.95, 100) is None
assert scroll_config.refresh_shortfall(97.5, 100) is None
def test_a_panel_that_beats_its_cap_is_fine(self):
assert scroll_config.refresh_shortfall(125.7, 120) is None
def test_nothing_measured_says_nothing(self):
assert scroll_config.refresh_shortfall(None, 120) is None
assert scroll_config.refresh_shortfall(0, 120) is None
assert scroll_config.refresh_shortfall("fast", 120) is None
def test_the_suggestion_leaves_headroom_under_the_measurement(self):
assert scroll_config.holdable_cap(113.1) == 100
assert scroll_config.holdable_cap(95.0) == 90
# 5% under 105 is 99.75: 100 would sit inside the panel's drift.
assert scroll_config.holdable_cap(105.0) == 90
assert scroll_config.holdable_cap(9.0) is None
assert scroll_config.holdable_cap(None) is None
def test_the_log_line_names_the_cap_to_use(self):
text = scroll_config.describe_refresh_shortfall(
scroll_config.refresh_shortfall(113.1, 120))
assert "about 113 Hz" in text and "120 Hz" in text
assert "6% slower" in text
assert "Set Limit Refresh Rate to 100 Hz" in text
def test_no_suggestion_for_a_panel_too_slow_for_any_cap(self):
text = scroll_config.describe_refresh_shortfall(
scroll_config.refresh_shortfall(9.0, 100))
assert "Set Limit Refresh Rate" not in text
+31 -27
View File
@@ -11,26 +11,32 @@ than even logging it.
import pytest import pytest
from src.web_interface.error_handler import describe_exception from src.web_interface.error_handler import describe_exception, redact_text
class TestDescribeException: class TestDescribeException:
def test_names_the_type_and_message(self): """describe_exception is a reason code: type and errno, never the message.
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 message can quote paths, URLs or credentials (CodeQL
# The whole point: this string is the diagnosis. py/stack-trace-exposure), so it goes to the log; the code still names the
assert "Input/output error" in describe_exception( fault, as "[Errno 5]" did.
OSError(5, "Input/output error", "systemctl")) """
def test_an_oserror_names_its_errno(self):
assert describe_exception(
OSError(5, "Input/output error", "systemctl")) == "OSError:EIO"
def test_a_bare_exception_still_names_its_type(self): 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(PermissionError()) == "PermissionError"
assert describe_exception(Exception()) == "Exception" assert describe_exception(Exception()) == "Exception"
def test_message_is_kept_when_present(self): def test_the_message_never_reaches_the_code(self):
assert describe_exception(ValueError("bad port")) == "ValueError: bad port" assert describe_exception(ValueError("bad port /etc/secret")) == "ValueError"
def test_the_message_is_logged_instead(self, caplog):
describe_exception(RuntimeError("disk on fire token=abc123"))
assert "disk on fire" in caplog.text
assert "abc123" not in caplog.text
class TestCredentialRedaction: class TestCredentialRedaction:
@@ -56,47 +62,44 @@ class TestCredentialRedaction:
("authorization: barecredential", "barecredential"), ("authorization: barecredential", "barecredential"),
]) ])
def test_credentials_never_reach_the_response(self, secret_text, leaked): def test_credentials_never_reach_the_response(self, secret_text, leaked):
detail = describe_exception(RuntimeError(secret_text)) detail = redact_text(secret_text)
assert leaked not in detail assert leaked not in detail
assert "<redacted>" in detail assert "<redacted>" in detail
def test_the_parameter_name_survives_redaction(self): def test_the_parameter_name_survives_redaction(self):
# Knowing *which* credential was involved is part of the diagnosis. # Knowing *which* credential was involved is part of the diagnosis.
detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123")) detail = redact_text("https://x/y?api_key=SEC123")
assert "api_key" in detail assert "api_key" in detail
def test_unknown_schemes_keep_their_name(self): def test_unknown_schemes_keep_their_name(self):
for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"): for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"):
detail = describe_exception( detail = redact_text("Authorization: %s SECRETVALUE" % scheme)
RuntimeError("Authorization: %s SECRETVALUE" % scheme))
assert scheme in detail, detail assert scheme in detail, detail
assert "SECRETVALUE" not in detail, detail assert "SECRETVALUE" not in detail, detail
def test_auth_scheme_and_username_survive(self): def test_auth_scheme_and_username_survive(self):
# Which kind of credential, and whose, without the credential itself. # Which kind of credential, and whose, without the credential itself.
assert "Bearer" in describe_exception( assert "Bearer" in redact_text("Authorization: Bearer eyJ.SECRET.sig")
RuntimeError("Authorization: Bearer eyJ.SECRET.sig")) assert "user" in redact_text("https://user:hunter2@example.com")
assert "user" in describe_exception(
RuntimeError("https://user:hunter2@example.com"))
def test_non_secret_context_is_preserved(self): def test_non_secret_context_is_preserved(self):
detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa")) detail = redact_text("https://api.x.com/v1?city=Tampa")
assert "city=Tampa" in detail assert "city=Tampa" in detail
assert "<redacted>" not in detail assert "<redacted>" not in detail
class TestBounds: class TestBounds:
def test_long_messages_are_truncated(self): def test_long_messages_are_truncated(self):
detail = describe_exception(ValueError("x" * 5000)) detail = redact_text("x" * 5000)
assert len(detail) <= 400 assert len(detail) <= 400
def test_newlines_are_collapsed_to_one_line(self): def test_newlines_are_collapsed_to_one_line(self):
detail = describe_exception(ValueError("line one\nline two\tthree")) detail = redact_text("line one\nline two\tthree")
assert "\n" not in detail and "\t" not in detail assert "\n" not in detail and "\t" not in detail
assert detail == "ValueError: line one line two three" assert detail == "line one line two three"
def test_custom_length_is_honoured(self): def test_custom_length_is_honoured(self):
assert len(describe_exception(ValueError("y" * 500), max_length=50)) <= 50 assert len(redact_text("y" * 500, max_length=50)) <= 50
class TestHandlersCarryDetail: class TestHandlersCarryDetail:
@@ -319,10 +322,10 @@ class TestHandlersCarryDetail:
assert resp.status_code == 405, "a wrong method must stay a 405" assert resp.status_code == 405, "a wrong method must stay a 405"
assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED" assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED"
# A genuine server fault still reports as one, with its detail. # A genuine server fault still reports as one, with its reason code.
resp = client.get("/boom") resp = client.get("/boom")
assert resp.status_code == 500 assert resp.status_code == 500
assert "Input/output error" in resp.get_json()["details"] assert resp.get_json()["details"] == "OSError:EIO"
def test_global_handler_reports_the_underlying_error(self): def test_global_handler_reports_the_underlying_error(self):
from flask import Flask, jsonify from flask import Flask, jsonify
@@ -345,4 +348,5 @@ class TestHandlersCarryDetail:
client = app.test_client() client = app.test_client()
body = client.get("/boom").get_json() body = client.get("/boom").get_json()
assert body["error_code"] == "UNKNOWN_ERROR" assert body["error_code"] == "UNKNOWN_ERROR"
assert "Input/output error" in body["details"] assert body["details"] == "OSError:EIO"
assert "Input/output error" not in str(body)
@@ -0,0 +1,73 @@
"""GET /api/v3/config/refresh-rate: the cap, the measured rate, a cap to hold."""
import json
from unittest.mock import MagicMock
import pytest
from flask import Flask
from web_interface.blueprints.api_v3 import api_v3
@pytest.fixture
def client(monkeypatch, tmp_path):
stats = tmp_path / "stats.json"
monkeypatch.setattr("src.common.frame_timing.default_stats_path", lambda: str(stats))
manager = MagicMock()
manager.load_config.return_value = {
"display": {"hardware": {"limit_refresh_rate_hz": 120}}}
monkeypatch.setattr(api_v3, "config_manager", manager, raising=False)
app = Flask(__name__)
app.register_blueprint(api_v3, url_prefix="/api/v3")
c = app.test_client()
c.stats_path = stats
return c
def _get(client):
body = client.get("/api/v3/config/refresh-rate").get_json()
assert body["status"] == "success"
return body["data"]
def test_nothing_measured_yet(client):
data = _get(client)
assert data == {"planned_hz": 120.0, "measured_hz": None, "shortfall": None}
def test_a_panel_short_of_its_cap_gets_a_cap_it_can_hold(client):
client.stats_path.write_text(json.dumps(
{"measured_refresh_hz": 110.4, "planned_refresh_hz": 120.0}))
data = _get(client)
assert data["measured_hz"] == 110.4
assert data["shortfall"]["suggested_cap_hz"] == 100
assert data["shortfall"]["slow_percent"] == 8
def test_a_panel_at_its_cap_has_no_shortfall(client):
client.stats_path.write_text(json.dumps(
{"measured_refresh_hz": 121.3, "planned_refresh_hz": 120.0}))
assert _get(client)["shortfall"] is None
def test_a_file_written_under_another_cap_is_stale(client):
# The cap was changed to 120 but the display still runs under 100 Hz.
client.stats_path.write_text(json.dumps(
{"measured_refresh_hz": 99.9, "planned_refresh_hz": 100.0}))
data = _get(client)
assert data["measured_hz"] is None
assert data["shortfall"] is None
def test_a_file_from_a_display_too_old_to_record_its_cap_still_counts(client):
client.stats_path.write_text(json.dumps({"measured_refresh_hz": 110.4}))
assert _get(client)["shortfall"]["suggested_cap_hz"] == 100
def test_a_measurement_recorded_without_a_planned_rate_is_not_a_panel(client):
# The emulator and the fallback canvas write the key as null: their frames
# are not paced by a panel, so a rate under the cap is no shortfall.
client.stats_path.write_text(json.dumps(
{"measured_refresh_hz": 60.0, "planned_refresh_hz": None}))
data = _get(client)
assert data["measured_hz"] is None
assert data["shortfall"] is None
@@ -114,12 +114,11 @@ def test_the_answer_is_what_the_catch_all_returned(client, caplog, method, url,
assert records[-1].exc_info[1] is FORCED assert records[-1].exc_info[1] is FORCED
def test_credentials_are_redacted_from_the_detail(client): def test_the_exception_message_never_reaches_the_detail(client):
body = client.get("/api/v3/plugins/installed").get_json() body = client.get("/api/v3/plugins/installed").get_json()
for secret in ("SECRET123", "pw1", "K1"): for secret in ("SECRET123", "pw1", "K1", "forced failure"):
assert secret not in body["details"] assert secret not in str(body)
assert "<redacted>" in body["details"] assert body["details"] == "RuntimeError"
assert body["details"].startswith("RuntimeError: forced failure")
def _raise_415(): def _raise_415():
@@ -218,7 +217,7 @@ class TestPluginActionStep1:
encoding="utf-8") encoding="utf-8")
return d return d
def test_the_script_error_reaches_the_response(self, plugin_dir, monkeypatch): def test_the_script_error_is_reported_by_type(self, plugin_dir, monkeypatch):
from unittest.mock import MagicMock from unittest.mock import MagicMock
manager = MagicMock() manager = MagicMock()
manager.get_plugin_directory.return_value = str(plugin_dir) manager.get_plugin_directory.return_value = str(plugin_dir)
@@ -232,5 +231,6 @@ class TestPluginActionStep1:
assert resp.status_code == 500 assert resp.status_code == 500
body = resp.get_json() body = resp.get_json()
assert body["details"] == "RuntimeError: the auth script failed" assert body["details"] == "RuntimeError"
assert "the auth script failed" not in str(body)
assert body["message"] == 'An error occurred; see logs for details' assert body["message"] == 'An error occurred; see logs for details'
@@ -946,21 +946,27 @@ class TestTheStoreReportsWhyItIsEmpty:
class TestACrashCarriesItsDetail: class TestACrashCarriesItsDetail:
"""Seventeen Starlark handlers answered 5xx with no detail at all.""" """Seventeen Starlark handlers answered 5xx with no detail at all.
def test_browse_returns_the_exception_detail(self, client): The detail is a reason code (the exception type), not the exception's
message, which stays in the log (CodeQL py/stack-trace-exposure).
"""
def test_browse_returns_the_reason_code(self, client):
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class', with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
side_effect=ImportError("No module named 'yaml'")): side_effect=ImportError("No module named 'yaml'")):
body = client.get('/api/v3/starlark/repository/browse').get_json() body = client.get('/api/v3/starlark/repository/browse').get_json()
assert 'yaml' in body.get('details', ''), body assert body.get('details') == 'ImportError', body
assert 'yaml' not in str(body), body
def test_status_returns_the_exception_detail(self, client): def test_status_returns_the_reason_code(self, client):
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
side_effect=RuntimeError("plugin manager is not attached")): side_effect=RuntimeError("plugin manager is not attached")):
body = client.get('/api/v3/starlark/status').get_json() body = client.get('/api/v3/starlark/status').get_json()
assert 'plugin manager is not attached' in body.get('details', ''), body assert body.get('details') == 'RuntimeError', body
assert 'plugin manager is not attached' not in str(body), body
class TestTheListingIsNotCappedAtOneThousand: class TestTheListingIsNotCappedAtOneThousand:
+26 -21
View File
@@ -223,7 +223,7 @@ def _save_config_atomic(config_manager, config_data, create_backup=True):
config_manager.save_config(config_data) config_manager.save_config(config_data)
return True, None return True, None
except Exception as e: except Exception as e:
return False, str(e) return False, f"Failed to save configuration ({describe_exception(e)})"
def _coerce_to_bool(value): def _coerce_to_bool(value):
""" """
Coerce a form value to a proper Python boolean. Coerce a form value to a proper Python boolean.
@@ -247,7 +247,11 @@ def _coerce_to_bool(value):
return value.lower() in ('true', 'on', '1', 'yes') return value.lower() in ('true', 'on', '1', 'yes')
return False return False
def _get_display_service_status(): def _get_display_service_status():
"""Return status information about the ledmatrix service.""" """Return status information about the ledmatrix service.
active/returncode only: this goes back in API responses, and systemctl's
output (or an exception's text) is logged rather than returned.
"""
try: try:
result = subprocess.run( result = subprocess.run(
['systemctl', 'is-active', 'ledmatrix'], ['systemctl', 'is-active', 'ledmatrix'],
@@ -255,26 +259,18 @@ def _get_display_service_status():
text=True, text=True,
timeout=3 timeout=3
) )
if result.stderr.strip():
logger.debug('systemctl is-active ledmatrix: %s', result.stderr.strip())
return { return {
'active': result.stdout.strip() == 'active', 'active': result.stdout.strip() == 'active',
'returncode': result.returncode, 'returncode': result.returncode,
'stdout': result.stdout.strip(),
'stderr': result.stderr.strip()
} }
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return { logger.warning('systemctl is-active ledmatrix timed out')
'active': False, return {'active': False, 'returncode': -1}
'returncode': -1, except Exception:
'stdout': '', logger.warning('Could not query ledmatrix.service status', exc_info=True)
'stderr': 'timeout' return {'active': False, 'returncode': -1}
}
except Exception as err:
return {
'active': False,
'returncode': -1,
'stdout': '',
'stderr': str(err)
}
def _run_systemctl_command(args): def _run_systemctl_command(args):
"""Run a systemctl command safely.""" """Run a systemctl command safely."""
try: try:
@@ -296,18 +292,26 @@ def _run_systemctl_command(args):
'stderr': 'timeout' 'stderr': 'timeout'
} }
except Exception as err: except Exception as err:
logger.warning('%s failed', ' '.join(args), exc_info=True)
return { return {
'returncode': -1, 'returncode': -1,
'stdout': '', 'stdout': '',
'stderr': str(err) 'stderr': describe_exception(err)
} }
def _public_service_result(result):
"""A _run_systemctl_command result fit for a response: no stdout/stderr."""
if result.get('returncode') != 0:
logger.error('systemctl exited %s: %s', result.get('returncode'),
(result.get('stderr') or '').strip())
return {k: v for k, v in result.items() if k not in ('stdout', 'stderr')}
def _ensure_display_service_running(): def _ensure_display_service_running():
"""Ensure the ledmatrix display service is running.""" """Ensure the ledmatrix display service is running."""
status = _get_display_service_status() status = _get_display_service_status()
if status.get('active'): if status.get('active'):
status['started'] = False status['started'] = False
return status return status
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service']) result = _public_service_result(
_run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service']))
service_status = _get_display_service_status() service_status = _get_display_service_status()
result['started'] = result.get('returncode') == 0 result['started'] = result.get('returncode') == 0
result['active'] = service_status.get('active') result['active'] = service_status.get('active')
@@ -315,7 +319,8 @@ def _ensure_display_service_running():
return result return result
def _stop_display_service(): def _stop_display_service():
"""Stop the ledmatrix display service.""" """Stop the ledmatrix display service."""
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service']) result = _public_service_result(
_run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service']))
status = _get_display_service_status() status = _get_display_service_status()
result['active'] = status.get('active') result['active'] = status.get('active')
result['status'] = status result['status'] = status
@@ -713,7 +718,7 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id) success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
except Exception as remove_err: except Exception as remove_err:
_rollback() _rollback()
return False, f"Failed to remove plugin {plugin_id}: {remove_err}" return False, f"Failed to remove plugin {plugin_id} ({describe_exception(remove_err)})"
if not success: if not success:
_rollback() _rollback()
+38 -3
View File
@@ -158,11 +158,23 @@ def _panel_refresh_hz(config):
cap = scroll_config.refresh_hz_from_config(config) cap = scroll_config.refresh_hz_from_config(config)
try: try:
with open(frame_timing.default_stats_path(), encoding='utf-8') as fh: with open(frame_timing.default_stats_path(), encoding='utf-8') as fh:
measured = float(json.load(fh).get('measured_refresh_hz') or 0) stats = json.load(fh)
measured = float(stats.get('measured_refresh_hz') or 0)
recorded = 'planned_refresh_hz' in stats
planned = float(stats.get('planned_refresh_hz') or 0)
except (OSError, ValueError, TypeError, AttributeError): except (OSError, ValueError, TypeError, AttributeError):
measured = planned = 0.0
recorded = False
# Reject a stale file from a previous hardware config: one written under
# another cap (the display has not restarted since it changed), or, from
# a display too old to record its cap (no such key), a measurement far
# off this one. A key that is present but null means the display's frames
# are not paced by a panel (the emulator, the fallback canvas): its
# "refresh rate" says nothing about the cap.
if recorded and not planned:
measured = 0.0
elif planned and abs(planned - cap) > 0.5:
measured = 0.0 measured = 0.0
# Reject a stale file from a previous hardware config: a measurement far
# off the cap says the config changed since it was written.
if measured > 0 and 0.5 * cap <= measured <= 1.5 * cap: if measured > 0 and 0.5 * cap <= measured <= 1.5 * cap:
return measured, 'measured' return measured, 'measured'
return cap, 'configured' return cap, 'configured'
@@ -189,6 +201,29 @@ def get_scroll_speed_advice():
return jsonify({'status': 'success', 'data': advice}) return jsonify({'status': 'success', 'data': advice})
@api_v3.route('/config/refresh-rate', methods=['GET'])
def get_refresh_rate():
"""The refresh cap, what the panel measured, and a cap it can hold.
Backs the hint under the Display tab's Limit Refresh Rate field. Scroll
speeds are solved against the cap, so a panel that cannot reach it runs
every scroll slow; ``shortfall`` (None when the panel keeps up, or nothing
has been measured yet) says by how much and suggests a cap.
"""
from src.common import scroll_config
if not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
config = api_v3.config_manager.load_config()
planned = scroll_config.refresh_hz_from_config(config)
hz, source = _panel_refresh_hz(config)
measured = hz if source == 'measured' else None
return jsonify({'status': 'success', 'data': {
'planned_hz': planned,
'measured_hz': round(measured, 1) if measured else None,
'shortfall': scroll_config.refresh_shortfall(measured, planned),
}})
@api_v3.route('/config/schedule', methods=['GET']) @api_v3.route('/config/schedule', methods=['GET'])
def get_schedule_config(): def get_schedule_config():
"""Get current schedule configuration""" """Get current schedule configuration"""
@@ -983,7 +983,6 @@ def stop_pixlet_editor():
'status': 'error', 'status': 'error',
'message': 'Editor force-stopped, but the display could not be ' 'message': 'Editor force-stopped, but the display could not be '
'restarted automatically - start it manually.', 'restarted automatically - start it manually.',
'details': (result.get('stderr') or '').strip(),
'data': {'running': False}}), 500 'data': {'running': False}}), 500
return jsonify({'status': 'success', return jsonify({'status': 'success',
'message': 'Editor force-stopped; the display has been ' 'message': 'Editor force-stopped; the display has been '
+3 -4
View File
@@ -689,7 +689,7 @@ def execute_system_action():
logger.warning("install_base_requirements timed out for %s", label) logger.warning("install_base_requirements timed out for %s", label)
except OSError as install_err: except OSError as install_err:
all_ok = False all_ok = False
outputs.append(f"== {label} ==\nFailed: {install_err}") outputs.append(f"== {label} ==\nFailed: {describe_exception(install_err)}")
logger.warning("install_base_requirements errored for %s: %s", label, install_err) logger.warning("install_base_requirements errored for %s: %s", label, install_err)
return jsonify({ return jsonify({
'status': 'success' if all_ok else 'error', 'status': 'success' if all_ok else 'error',
@@ -784,11 +784,10 @@ def execute_system_action():
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'}) return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
except Exception as e: except Exception as e:
logger.error("execute_system_action failed: %s", e, exc_info=True) logger.error("execute_system_action failed: %s", e, exc_info=True)
detail = describe_exception(e)
resp = { resp = {
'status': 'error', 'status': 'error',
'message': _sudo_hint_for(detail) or 'Action failed; see logs for details', 'message': _sudo_hint_for(str(e)) or 'Action failed; see logs for details',
'details': detail, 'details': describe_exception(e),
} }
return jsonify(resp), 500 return jsonify(resp), 500
@api_v3.route('/system/git-info', methods=['GET']) @api_v3.route('/system/git-info', methods=['GET'])
+2 -2
View File
@@ -67,7 +67,7 @@ def _run_background_connect(ssid, password):
payload = _connect_result_payload(ssid, success, message) payload = _connect_result_payload(ssid, success, message)
except Exception as e: except Exception as e:
logger.error("Background WiFi connect failed", exc_info=True) logger.error("Background WiFi connect failed", exc_info=True)
payload = {'status': 'error', 'message': describe_exception(e)} payload = {'status': 'error', 'message': f'Failed to connect to network ({describe_exception(e)})'}
_record_connect_result(ssid, payload) _record_connect_result(ssid, payload)
@@ -276,7 +276,7 @@ def connect_wifi():
try: try:
success, message = wifi_manager.connect_to_network(ssid, password) success, message = wifi_manager.connect_to_network(ssid, password)
except Exception as e: except Exception as e:
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)}) _record_connect_result(ssid, {'status': 'error', 'message': f'Failed to connect to network ({describe_exception(e)})'})
raise raise
payload = _connect_result_payload(ssid, success, message) payload = _connect_result_payload(ssid, success, message)
_record_connect_result(ssid, payload) _record_connect_result(ssid, payload)
@@ -92,6 +92,45 @@ function refreshScrollSpeedHint(root, ctx) {
}, HINT_DELAY_MS); }, HINT_DELAY_MS);
} }
// ── the refresh-cap hint ─────────────────────────────────────────────────────
// Scroll speeds are worked out against the cap, so a panel that cannot reach
// it runs every scroll slow. The display has measured a cap it can hold.
function showRefreshRateHint(root, ctx) {
const hint = root.querySelector('#limit_refresh_rate_hz_hint');
const input = root.querySelector('#limit_refresh_rate_hz');
if (!hint || !input) return;
const doc = root.ownerDocument;
const win = doc.defaultView;
ctx.api.get('/api/v3/config/refresh-rate', { signal: ctx.signal })
.then(function(body) {
const s = body.status === 'success' && body.data.shortfall;
hint.textContent = '';
if (!s) return;
hint.appendChild(doc.createTextNode(
'This panel refreshes at about ' + Math.round(s.measured_hz) +
' Hz, below this ' + Math.round(s.planned_hz) + ' Hz cap, so scrolls run about ' +
s.slow_percent + '% slower than set.' + (s.suggested_cap_hz ? ' ' : '')));
if (!s.suggested_cap_hz) return;
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = 'Use ' + s.suggested_cap_hz + ' Hz';
btn.addEventListener('click', function() {
input.value = s.suggested_cap_hz;
input.dispatchEvent(new win.Event('input', { bubbles: true }));
input.dispatchEvent(new win.Event('change', { bubbles: true }));
hint.textContent = 'Save, then restart the display, to apply ' +
s.suggested_cap_hz + ' Hz.';
});
hint.appendChild(btn);
hint.appendChild(doc.createTextNode(', a cap it can hold.'));
})
.catch(function(error) {
if (quiet(error) || error.body) return;
hint.textContent = '';
});
}
function renderScrollSpeedHint(root, hint, slider, a) { function renderScrollSpeedHint(root, hint, slider, a) {
const doc = root.ownerDocument; const doc = root.ownerDocument;
const win = doc.defaultView; const win = doc.defaultView;
@@ -303,6 +342,7 @@ export function init(root, ctx) {
// when it already is), then every 5 s while it stays there. // when it already is), then every 5 s while it stays there.
ctx.visibility.every(SYNC_POLL_MS, function() { pollSyncStatus(root, ctx); }); ctx.visibility.every(SYNC_POLL_MS, function() { pollSyncStatus(root, ctx); });
showRefreshRateHint(root, ctx);
startPluginOrder(root, ctx); startPluginOrder(root, ctx);
active = ctx; active = ctx;
} }
@@ -319,6 +319,7 @@
min="0" min="0"
max="1000" max="1000"
class="form-control"> class="form-control">
<p id="limit_refresh_rate_hz_hint" class="mt-1 text-xs text-amber-700" aria-live="polite"></p>
</div> </div>
</div> </div>
+2 -2
View File
@@ -86,7 +86,7 @@ def refresh_after_update(run=None, systemd_dir=None, helper_source=None, helper_
except Exception as e: # a broken template must not fail the update itself except Exception as e: # a broken template must not fail the update itself
if type(e).__name__ != 'UnitsUnreadable': if type(e).__name__ != 'UnitsUnreadable':
logger.warning("Could not compare the installed systemd units with the new templates: %s", e) logger.warning("Could not compare the installed systemd units with the new templates: %s", e)
return _result(FAILED, f'The service settings could not be checked: {e}.') return _result(FAILED, 'The service settings could not be checked; see logs for details.')
# Units installed mode 0600 (install_service.sh run on its own, before # Units installed mode 0600 (install_service.sh run on its own, before
# it set 0644): only root can compare them, so let the helper decide. # it set 0644): only root can compare them, so let the helper decide.
stale = [] stale = []
@@ -110,7 +110,7 @@ def refresh_after_update(run=None, systemd_dir=None, helper_source=None, helper_
timeout=TIMEOUT_SECONDS) timeout=TIMEOUT_SECONDS)
except (subprocess.SubprocessError, OSError) as e: except (subprocess.SubprocessError, OSError) as e:
logger.warning("Refreshing the systemd units failed: %s", e) logger.warning("Refreshing the systemd units failed: %s", e)
return _result(FAILED, f'Updating the service settings ({names}) failed: {e}.', stale) return _result(FAILED, f'Updating the service settings ({names}) failed; see logs for details.', stale)
if result.returncode == 0: if result.returncode == 0:
# The helper says what it did: "units refreshed: a b" or "units: up to date". # The helper says what it did: "units refreshed: a b" or "units: up to date".
done = next((line.split(':', 1)[1].split() for line in (result.stdout or '').splitlines() done = next((line.split(':', 1)[1].split() for line in (result.stdout or '').splitlines()