mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e39dcb18b4 |
+17
-33
@@ -19,17 +19,24 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
### A scrolling screen held by its plugin's update() is reported
|
||||
### Scroll speed: a panel slower than its refresh cap is reported
|
||||
|
||||
- While a plugin's `update()` runs it holds the plugin's lock, and that
|
||||
plugin's frames are skipped: on a scroller, a frozen strip, with nothing
|
||||
logged (and a freeze of 5 s or more is a gap, not a freeze, to the frame
|
||||
stats). The high-FPS loop now times each run of skipped frames; one of
|
||||
250 ms or more logs `Display of <plugin> held N ms by its update()`
|
||||
(rate-limited per plugin) when it ends, and is recorded on the plugin's
|
||||
health as a `display hold` busy skip, which never counts toward the
|
||||
circuit breaker. The 1 Hz loop is left out: its frames are a second apart,
|
||||
so one skipped frame there measures nothing and freezes nothing visible.
|
||||
- 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()`.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -69,18 +76,6 @@ soccer-scoreboard 2.39.2, alternating runs: **~450 requests per start, peak
|
||||
spends one doomed 400 per window at every start (eleven at once from a
|
||||
soccer board); the range is still retried `RANGE_RETRY_SECONDS` in.
|
||||
|
||||
### Fetch stats: bytes on the wire, not just decoded
|
||||
|
||||
`GET /api/v3/plugins/fetch-stats` reported only `bytes`, the decoded body
|
||||
size, and that read as the download volume. ESPN gzips every scoreboard, so
|
||||
it overstated what crossed the network about 14x: a college football
|
||||
Saturday's scoreboard is 865 KB decoded and 63 KB on the wire, and ledpi's
|
||||
"643 MB in 6 hours" of football was ~47 MB of actual traffic. Every counter
|
||||
set (totals, per plugin, per host) now has `wire_bytes` too, read from
|
||||
urllib3's count of the raw bytes it took off the socket. A response with no
|
||||
urllib3 response behind it is counted at its decoded size. `bytes` keeps its
|
||||
meaning.
|
||||
|
||||
### Cheap per-frame and per-fetch savings
|
||||
|
||||
- `BaseOddsManager.get_odds()` no longer pretty-prints every odds response
|
||||
@@ -1441,17 +1436,6 @@ read any of them:
|
||||
|
||||
### Fixes
|
||||
|
||||
- Updating a plugin from the store no longer deletes the files it wrote
|
||||
beside itself. A monorepo update replaces the plugin directory with the
|
||||
fresh download and deletes the old copy, so calendar's Google OAuth files
|
||||
(`token.pickle`, `credentials.json`) were lost on every update and the
|
||||
calendar stopped until they were restored by hand. Before the old copy is
|
||||
removed, the update now copies over anything the plugin's `.gitignore`
|
||||
excludes plus known secret/state files (`*.pickle`, `token.json`,
|
||||
`credentials.json`, `config_secrets.json`, `.pkce_code_verifier`); files the
|
||||
new release ships are never overwritten, and byte code is not carried. A
|
||||
plugin updated with `git pull` no longer sweeps an untracked token into the
|
||||
auto-stash, which is never popped (`src/plugin_system/plugin_local_files.py`).
|
||||
- Quieter routine logging. Every rotation logged each mode twice
|
||||
("Switching to mode", then "Processing mode"), and a mode with nothing to
|
||||
show added "display() returned False" and "No content to display". Those
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
||||
|
||||
@@ -59,10 +59,7 @@ says how old with ``cache_max_age`` (``fetch_get(..., cache_max_age=ttl)``;
|
||||
Identical means what the validator store keys on: URL, query, effective
|
||||
headers and, for a session with cookies or auth, the session.
|
||||
|
||||
**Counters.** Requests, merged requests, bytes (``bytes`` decoded, as the
|
||||
caller reads them; ``wire_bytes`` as they crossed the network, which is
|
||||
what a metered connection pays for -- ESPN gzips, so the two differ ~14x),
|
||||
304s, errors, HTTP errors,
|
||||
**Counters.** Requests, merged requests, bytes, 304s, errors, HTTP errors,
|
||||
adapter retries, throttled requests and seconds waited, plus requests
|
||||
answered without the network: ``memo_hits`` (the response cache) and
|
||||
``cache_hits`` / ``legacy_cache_hits`` (a shared ESPN scoreboard cache entry,
|
||||
@@ -204,7 +201,6 @@ _COUNTER_FIELDS = (
|
||||
"throttled", # requests that waited for a host budget
|
||||
"overruns", # requests that went after max_wait_seconds anyway
|
||||
"bytes", # decoded response body bytes received
|
||||
"wire_bytes", # body bytes as they came off the socket (still compressed)
|
||||
"wait_seconds", # time spent waiting for host budgets
|
||||
"memo_hits", # answered from the response cache (max-age); nothing sent
|
||||
"cache_hits", # scoreboard fetches answered from a shared ESPN cache entry
|
||||
@@ -620,30 +616,6 @@ def _body_of(response: Any) -> Optional[bytes]:
|
||||
return content if isinstance(content, bytes) else None
|
||||
|
||||
|
||||
def _wire_bytes_of(response: Any, body: Optional[bytes]) -> int:
|
||||
"""How many body bytes came off the socket for ``response``: the
|
||||
compressed size when the server sent gzip, which ESPN does for every
|
||||
scoreboard (63 KB on the wire for an 865 KB college football Saturday).
|
||||
|
||||
urllib3's ``HTTPResponse.tell()`` counts the raw bytes read before
|
||||
decoding. A response without one (a test double, an adapter that is not
|
||||
urllib3) or one whose body was not read is counted at its decoded size,
|
||||
or as 0, so the counter never claims less than it can prove.
|
||||
"""
|
||||
if body is None:
|
||||
return 0
|
||||
raw = getattr(response, "raw", None)
|
||||
tell = getattr(raw, "tell", None)
|
||||
if callable(tell):
|
||||
try:
|
||||
read = tell()
|
||||
except Exception:
|
||||
read = None
|
||||
if isinstance(read, int) and not isinstance(read, bool) and read > 0:
|
||||
return read
|
||||
return len(body)
|
||||
|
||||
|
||||
def _retries_of(response: Any) -> int:
|
||||
raw = getattr(response, "raw", None)
|
||||
retries = getattr(raw, "retries", None)
|
||||
@@ -1145,7 +1117,6 @@ class FetchService:
|
||||
http_errors=int(status is not None and status >= 400),
|
||||
retries=_retries_of(response),
|
||||
bytes=len(body) if body is not None else 0,
|
||||
wire_bytes=_wire_bytes_of(response, body),
|
||||
throttled=int(waited > 0), overruns=int(overrun),
|
||||
wait_seconds=waited)
|
||||
except Exception:
|
||||
|
||||
@@ -135,6 +135,8 @@ import time
|
||||
import traceback
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
|
||||
|
||||
from src.common import scroll_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 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.
|
||||
MIN_FRAMES_FOR_REFRESH = 90
|
||||
|
||||
#: Trusted windows, counting the one that adopted the period, before a panel
|
||||
#: slower than its cap is reported. The estimate can still fall (the refresh
|
||||
#: rate rise) 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
|
||||
|
||||
#: 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)
|
||||
# The first estimate, until a second window agrees with it.
|
||||
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] = {
|
||||
"static_frames": 0,
|
||||
"scroll_frames": 0,
|
||||
@@ -639,7 +653,12 @@ class FrameTimingRecorder:
|
||||
self._refresh_candidate = estimate
|
||||
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
|
||||
self.refresh_period = estimate
|
||||
if self.refresh_period is not None:
|
||||
self._refresh_windows += 1
|
||||
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
|
||||
for frame in batch:
|
||||
@@ -688,6 +707,25 @@ class FrameTimingRecorder:
|
||||
elif missed <= -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, 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]:
|
||||
"""The JSON document: cumulative since this process started."""
|
||||
if not self._binding_checked:
|
||||
@@ -704,6 +742,9 @@ class FrameTimingRecorder:
|
||||
"bucket_ms": BUCKET_MS,
|
||||
"freeze_seconds": FREEZE_SECONDS,
|
||||
"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,
|
||||
"info": info,
|
||||
"totals": copy.deepcopy(self.totals),
|
||||
|
||||
@@ -538,3 +538,66 @@ def speed_advice(
|
||||
"smooth": smooth,
|
||||
"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
|
||||
|
||||
@@ -1164,55 +1164,6 @@ class DisplayController:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Error running scheduled plugin updates")
|
||||
|
||||
#: A run of frames skipped because a plugin's update() held its lock is
|
||||
#: reported once it has lasted this long.
|
||||
DISPLAY_HOLD_REPORT_SECONDS = 0.25
|
||||
|
||||
#: (plugin_id, monotonic start) of the current run of skipped frames.
|
||||
_display_hold: Optional[Tuple[str, float]] = None
|
||||
|
||||
def _note_display_hold(self, plugin_id: str, held: bool) -> None:
|
||||
"""Report how long a plugin's update() kept its display() from drawing.
|
||||
|
||||
While update() runs on the worker it holds the plugin's lock, and every
|
||||
frame of that plugin's screen is skipped: the panel keeps showing the
|
||||
last frame, which on a scroller is a frozen strip. Nothing said so --
|
||||
the frames are not failures, and a scroll freeze of 5 s or more is a
|
||||
gap to the frame stats, not a freeze. This times each such run and,
|
||||
when it ends after DISPLAY_HOLD_REPORT_SECONDS or more, logs it
|
||||
(rate-limited per plugin) and records it on the plugin's health as a
|
||||
busy skip, which never touches the circuit breaker. Only frames of the
|
||||
high-FPS loop are timed (see _display_once's ``report_hold``).
|
||||
"""
|
||||
# The clock is read only when a run starts or ends: on a frame that
|
||||
# draws with no run open, this is one attribute check.
|
||||
current = self._display_hold
|
||||
if held:
|
||||
if current is None or current[0] != plugin_id:
|
||||
self._display_hold = (plugin_id, time.monotonic())
|
||||
return
|
||||
if current is None:
|
||||
return
|
||||
self._display_hold = None
|
||||
if current[0] != plugin_id:
|
||||
return
|
||||
seconds = time.monotonic() - current[1]
|
||||
if seconds < self.DISPLAY_HOLD_REPORT_SECONDS:
|
||||
return
|
||||
pm = self.plugin_manager
|
||||
warn = getattr(pm, '_warn_rate_limited', None)
|
||||
if warn is not None:
|
||||
warn(f"display-hold:{plugin_id}",
|
||||
"Display of %s held %.0f ms by its update()",
|
||||
plugin_id, seconds * 1000.0)
|
||||
tracker = getattr(pm, 'health_tracker', None)
|
||||
record = getattr(tracker, 'record_busy_skip', None)
|
||||
if record is not None:
|
||||
try:
|
||||
record(plugin_id, "display hold", seconds)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.debug("Could not record a display hold", exc_info=True)
|
||||
|
||||
@contextmanager
|
||||
def _display_lock_or_skip(self, plugin_id):
|
||||
"""Try-lock guard keeping a plugin's display() off its in-flight update().
|
||||
@@ -1238,7 +1189,7 @@ class DisplayController:
|
||||
lock.release()
|
||||
|
||||
def _display_once(self, plugin, mode: str, accepts_display_mode: bool,
|
||||
force_clear: bool = False, report_hold: bool = False):
|
||||
force_clear: bool = False):
|
||||
"""Call ``plugin.display()`` directly for one frame of a render loop.
|
||||
|
||||
Frames after a screen's first dispatch come through here rather than
|
||||
@@ -1254,12 +1205,6 @@ class DisplayController:
|
||||
``display_mode`` so plugins with several modes stay on it.
|
||||
accepts_display_mode: Whether display() takes ``display_mode``.
|
||||
force_clear: Passed through to display().
|
||||
report_hold: Time runs of frames skipped because update() holds
|
||||
the plugin's lock (see _note_display_hold). Only the high-FPS
|
||||
loop asks: its frames are ~8 ms apart, so a run measures the
|
||||
hold, and a held scroller is a frozen strip. The 1 Hz loop's
|
||||
frames are a second apart, so one skipped frame there would
|
||||
read as a 1 s hold of a screen that did not visibly change.
|
||||
|
||||
Each call is timed (two monotonic reads) and handed to
|
||||
PluginManager.note_display_duration, which logs and records slow
|
||||
@@ -1274,12 +1219,6 @@ class DisplayController:
|
||||
display_watchdog.watchdog.beat()
|
||||
plugin_id = getattr(plugin, 'plugin_id', None)
|
||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||
if report_hold and plugin_id:
|
||||
self._note_display_hold(plugin_id, held=not can_display)
|
||||
elif can_display and self._display_hold is not None:
|
||||
# A drawn frame outside the high-FPS loop: whatever run was
|
||||
# open is over, unreported.
|
||||
self._display_hold = None
|
||||
if not can_display:
|
||||
return True
|
||||
started = time.monotonic()
|
||||
@@ -4048,8 +3987,7 @@ class DisplayController:
|
||||
_frame_start = time.perf_counter()
|
||||
try:
|
||||
result = self._display_once(
|
||||
manager_to_display, active_mode, _accepts_display_mode,
|
||||
report_hold=True)
|
||||
manager_to_display, active_mode, _accepts_display_mode)
|
||||
if isinstance(result, bool) and not result:
|
||||
logger.debug("Display returned False, breaking early")
|
||||
break
|
||||
|
||||
@@ -393,6 +393,11 @@ class DisplayManager:
|
||||
|
||||
self._setup_matrix()
|
||||
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()
|
||||
|
||||
font_time = time.time()
|
||||
@@ -1501,7 +1506,9 @@ class DisplayManager:
|
||||
fractional-pixel motion. See src/common/scroll_config.py.
|
||||
|
||||
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 {}
|
||||
try:
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
"""
|
||||
Files a plugin writes beside itself at runtime, which an update must keep.
|
||||
|
||||
A store update replaces a plugin's directory with a fresh download and then
|
||||
deletes the old copy. Anything the plugin created there -- OAuth tokens, a
|
||||
client-secrets file, a PKCE verifier, cached state -- is in no release, so the
|
||||
fresh download does not contain it and deleting the old copy destroys it. On
|
||||
2026-10-04 updating calendar 1.2.9 -> 1.2.12 that way deleted its
|
||||
``token.pickle`` and ``credentials.json``, and the calendar stopped until they
|
||||
were restored from a backup.
|
||||
|
||||
What counts as "the plugin's own local file" is the union of:
|
||||
|
||||
* :data:`KNOWN_STATE_PATTERNS` -- secret and state files plugins are known to
|
||||
write, kept even when a plugin forgot to gitignore them; and
|
||||
* whatever the plugin's own ``.gitignore`` (old copy or new) excludes. A file
|
||||
the author ignores is by definition not part of a release.
|
||||
|
||||
A file the new release ships is never overwritten: tracked content wins. Byte
|
||||
code (``__pycache__``, ``*.pyc``) and ``.git`` are never carried, since they
|
||||
belong to the old code rather than to the user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional, Pattern, Tuple
|
||||
|
||||
__all__ = [
|
||||
'KNOWN_STATE_PATTERNS',
|
||||
'carry_over_local_files',
|
||||
'is_known_state_file',
|
||||
'local_files_to_keep',
|
||||
]
|
||||
|
||||
# Basename globs. Kept even when the plugin's .gitignore does not list them.
|
||||
KNOWN_STATE_PATTERNS: Tuple[str, ...] = (
|
||||
'token.pickle',
|
||||
'*.pickle',
|
||||
'token.json',
|
||||
'credentials.json',
|
||||
'config_secrets.json',
|
||||
'.pkce_code_verifier',
|
||||
)
|
||||
|
||||
_NEVER_CARRY_DIRS = frozenset({'.git', '__pycache__'})
|
||||
_NEVER_CARRY_SUFFIXES = ('.pyc', '.pyo')
|
||||
|
||||
|
||||
def is_known_state_file(rel_path: str) -> bool:
|
||||
"""True when ``rel_path``'s basename is a known secret/state file."""
|
||||
name = rel_path.replace('\\', '/').rsplit('/', 1)[-1]
|
||||
return any(fnmatch.fnmatchcase(name, p) for p in KNOWN_STATE_PATTERNS)
|
||||
|
||||
|
||||
class _GitIgnore:
|
||||
"""The subset of gitignore semantics plugin .gitignore files use.
|
||||
|
||||
Supports comments, ``!`` negation (last match wins), a trailing ``/`` for
|
||||
directory-only patterns, anchoring by a leading or embedded ``/``, ``*``,
|
||||
``?``, ``[...]`` and ``**``. As in git, a file under an ignored directory
|
||||
is ignored regardless of later negations.
|
||||
"""
|
||||
|
||||
def __init__(self, lines: Iterable[str]):
|
||||
self._rules: List[Tuple[Pattern[str], bool, bool]] = []
|
||||
for raw in lines:
|
||||
line = raw.rstrip('\n').rstrip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
negate = line.startswith('!')
|
||||
if negate:
|
||||
line = line[1:]
|
||||
elif line.startswith('\\'):
|
||||
line = line[1:]
|
||||
dir_only = line.endswith('/')
|
||||
line = line.rstrip('/')
|
||||
if not line:
|
||||
continue
|
||||
anchored = '/' in line
|
||||
line = line.lstrip('/')
|
||||
body = self._translate(line)
|
||||
regex = body if anchored else r'(?:.*/)?' + body
|
||||
self._rules.append((re.compile(r'\A' + regex + r'\Z'), negate, dir_only))
|
||||
|
||||
@staticmethod
|
||||
def _translate(pattern: str) -> str:
|
||||
out, i, n = [], 0, len(pattern)
|
||||
while i < n:
|
||||
if pattern.startswith('**/', i):
|
||||
out.append(r'(?:.*/)?')
|
||||
i += 3
|
||||
elif pattern.startswith('/**', i) and i + 3 == n:
|
||||
out.append(r'/.*')
|
||||
i += 3
|
||||
elif pattern.startswith('**', i):
|
||||
out.append(r'.*')
|
||||
i += 2
|
||||
elif pattern[i] == '*':
|
||||
out.append(r'[^/]*')
|
||||
i += 1
|
||||
elif pattern[i] == '?':
|
||||
out.append(r'[^/]')
|
||||
i += 1
|
||||
elif pattern[i] == '[':
|
||||
end = pattern.find(']', i + 1)
|
||||
if end == -1:
|
||||
out.append(re.escape('['))
|
||||
i += 1
|
||||
else:
|
||||
cls = pattern[i + 1:end]
|
||||
if cls.startswith('!'):
|
||||
cls = '^' + cls[1:]
|
||||
out.append('[' + cls.replace('\\', '\\\\') + ']')
|
||||
i = end + 1
|
||||
else:
|
||||
out.append(re.escape(pattern[i]))
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
def _decide(self, rel: str, is_dir: bool) -> Optional[bool]:
|
||||
verdict = None
|
||||
for regex, negate, dir_only in self._rules:
|
||||
if dir_only and not is_dir:
|
||||
continue
|
||||
if regex.match(rel):
|
||||
verdict = not negate
|
||||
return verdict
|
||||
|
||||
def ignores(self, rel_path: str) -> bool:
|
||||
if not self._rules:
|
||||
return False
|
||||
parts = rel_path.replace('\\', '/').split('/')
|
||||
for depth in range(1, len(parts)):
|
||||
if self._decide('/'.join(parts[:depth]), True):
|
||||
return True
|
||||
return bool(self._decide('/'.join(parts), False))
|
||||
|
||||
|
||||
def _read_gitignore(plugin_dir: Path) -> List[str]:
|
||||
try:
|
||||
return (plugin_dir / '.gitignore').read_text(
|
||||
encoding='utf-8', errors='replace').splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def local_files_to_keep(old_dir: Path, new_dir: Path) -> List[str]:
|
||||
"""Relative paths (``/``-separated) in ``old_dir`` to copy into ``new_dir``.
|
||||
|
||||
Regular files only; symlinks and anything the new release already ships
|
||||
are skipped.
|
||||
"""
|
||||
old_dir, new_dir = Path(old_dir), Path(new_dir)
|
||||
ignore = _GitIgnore(_read_gitignore(old_dir) + _read_gitignore(new_dir))
|
||||
keep: List[str] = []
|
||||
for root, dirs, files in os.walk(old_dir):
|
||||
dirs[:] = sorted(d for d in dirs if d not in _NEVER_CARRY_DIRS
|
||||
and not os.path.islink(os.path.join(root, d)))
|
||||
rel_root = os.path.relpath(root, old_dir)
|
||||
for name in sorted(files):
|
||||
if name.endswith(_NEVER_CARRY_SUFFIXES):
|
||||
continue
|
||||
full = os.path.join(root, name)
|
||||
if os.path.islink(full) or not os.path.isfile(full):
|
||||
continue
|
||||
rel = name if rel_root == '.' else f"{rel_root}/{name}".replace('\\', '/')
|
||||
if not (is_known_state_file(rel) or ignore.ignores(rel)):
|
||||
continue
|
||||
if os.path.lexists(new_dir / rel):
|
||||
continue
|
||||
keep.append(rel)
|
||||
return keep
|
||||
|
||||
|
||||
def carry_over_local_files(
|
||||
old_dir: Path, new_dir: Path
|
||||
) -> Tuple[List[str], List[Tuple[str, str]]]:
|
||||
"""Copy the plugin's local files from ``old_dir`` into ``new_dir``.
|
||||
|
||||
Copies rather than moves, so ``old_dir`` stays a complete copy until the
|
||||
caller deletes it. Returns ``(copied, failed)`` where ``failed`` pairs a
|
||||
relative path with the error; the caller should keep ``old_dir`` when
|
||||
anything failed.
|
||||
"""
|
||||
copied: List[str] = []
|
||||
failed: List[Tuple[str, str]] = []
|
||||
try:
|
||||
candidates = local_files_to_keep(old_dir, new_dir)
|
||||
except OSError as e:
|
||||
return copied, [('.', str(e))]
|
||||
for rel in candidates:
|
||||
dest = Path(new_dir) / rel
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(Path(old_dir) / rel, dest)
|
||||
copied.append(rel)
|
||||
except OSError as e:
|
||||
failed.append((rel, str(e)))
|
||||
return copied, failed
|
||||
@@ -22,7 +22,6 @@ from src.plugin_system.plugin_loader import (
|
||||
contained_plugin_dir, requirements_to_install,
|
||||
)
|
||||
from src.plugin_system.plugin_dirs import BACKUP_MARKER
|
||||
from src.plugin_system.plugin_local_files import carry_over_local_files
|
||||
from src.plugin_system.repo_urls import (
|
||||
USER_AGENT, github_api_headers, github_owner_repo, normalize_repo_url,
|
||||
)
|
||||
@@ -93,9 +92,7 @@ class _InstallMixin:
|
||||
raise
|
||||
|
||||
if installed:
|
||||
self._discard_backup(
|
||||
plugin_id, backup_path, "install",
|
||||
new_path=self._existing_install(plugin_id) or plugin_path)
|
||||
self._discard_backup(plugin_id, backup_path, "install")
|
||||
return True
|
||||
|
||||
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
|
||||
@@ -136,33 +133,8 @@ class _InstallMixin:
|
||||
return f"could not set aside {plugin_path}: {e}"
|
||||
return None
|
||||
|
||||
def _discard_backup(
|
||||
self, plugin_id: str, backup_path: Path, action: str,
|
||||
new_path: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Remove the set-aside copy after a successful (re)install.
|
||||
|
||||
With ``new_path`` (where the new copy landed), first carries the
|
||||
plugin's own runtime files -- OAuth tokens, client secrets, anything
|
||||
its .gitignore excludes -- from the old copy into the new one: no
|
||||
release contains them, so deleting the old copy would destroy them.
|
||||
See src/plugin_system/plugin_local_files.py. If any could not be
|
||||
copied the old copy is kept, so nothing is lost.
|
||||
"""
|
||||
if new_path is not None and new_path.is_dir():
|
||||
copied, failed = carry_over_local_files(backup_path, new_path)
|
||||
if copied:
|
||||
self.logger.info(
|
||||
"Kept %d local file(s) of %s across the %s: %s",
|
||||
len(copied), plugin_id, action, ", ".join(copied))
|
||||
if failed:
|
||||
self.logger.error(
|
||||
"Could not carry %s's local files into the new copy (%s); "
|
||||
"the previous copy is kept at %s -- copy them back by hand",
|
||||
plugin_id,
|
||||
"; ".join(f"{rel}: {err}" for rel, err in failed),
|
||||
backup_path)
|
||||
return
|
||||
def _discard_backup(self, plugin_id: str, backup_path: Path, action: str) -> None:
|
||||
"""Remove the set-aside copy after a successful (re)install."""
|
||||
if not self._safe_remove_directory(backup_path):
|
||||
self.logger.warning(
|
||||
"%s of %s succeeded but the previous copy at %s could not be "
|
||||
@@ -570,8 +542,7 @@ class _InstallMixin:
|
||||
raise
|
||||
temp_dir = None # Prevent cleanup since we moved it
|
||||
if backup_path is not None:
|
||||
self._discard_backup(
|
||||
plugin_id, backup_path, "install", new_path=final_path)
|
||||
self._discard_backup(plugin_id, backup_path, "install")
|
||||
|
||||
# Install dependencies
|
||||
self._install_dependencies(final_path)
|
||||
|
||||
@@ -10,9 +10,6 @@ import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
from src.plugin_system.plugin_dirs import BACKUP_MARKER
|
||||
from src.plugin_system.plugin_local_files import (
|
||||
KNOWN_STATE_PATTERNS, is_known_state_file,
|
||||
)
|
||||
from src.plugin_system.repo_urls import same_repo
|
||||
|
||||
|
||||
@@ -305,11 +302,7 @@ class _UpdateMixin:
|
||||
installed = False
|
||||
|
||||
if installed:
|
||||
# install_plugin may land the new copy under the manifest id
|
||||
# rather than the old directory name.
|
||||
self._discard_backup(
|
||||
plugin_id, backup_path, "update",
|
||||
new_path=self._existing_install(plugin_id) or plugin_path)
|
||||
self._discard_backup(plugin_id, backup_path, "update")
|
||||
return True
|
||||
|
||||
# Bad network, registry error...: the user keeps a working plugin.
|
||||
@@ -516,12 +509,8 @@ class _UpdateMixin:
|
||||
for line in untracked_result.stdout.strip().split('\n'):
|
||||
if line.startswith('??'):
|
||||
# Untracked file
|
||||
file_path = line[3:].strip().strip('"')
|
||||
# Tokens and secrets stay out of the
|
||||
# stash (see below), so they alone are
|
||||
# not a reason to stash.
|
||||
if not is_known_state_file(file_path):
|
||||
untracked_files.append(file_path)
|
||||
file_path = line[3:].strip()
|
||||
untracked_files.append(file_path)
|
||||
|
||||
# Check for tracked file changes
|
||||
status_result = subprocess.run(
|
||||
@@ -548,17 +537,9 @@ class _UpdateMixin:
|
||||
if has_changes:
|
||||
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
||||
try:
|
||||
# Use -u to include untracked files in stash --
|
||||
# except the plugin's tokens and secrets, which a
|
||||
# repo may have forgotten to gitignore. The stash
|
||||
# is never popped, so a stashed token.pickle would
|
||||
# vanish from the plugin and break it.
|
||||
stash_cmd = (
|
||||
['git', '-C', str(plugin_path), 'stash', 'push', '-u',
|
||||
'-m', f'LEDMatrix auto-stash before update {plugin_id}', '--', '.']
|
||||
+ [f':(exclude,glob)**/{p}' for p in KNOWN_STATE_PATTERNS])
|
||||
# Use -u to include untracked files in stash
|
||||
stash_result = subprocess.run(
|
||||
stash_cmd,
|
||||
['git', '-C', str(plugin_path), 'stash', 'push', '-u', '-m', f'LEDMatrix auto-stash before update {plugin_id}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
|
||||
Vendored
+9
@@ -175,6 +175,15 @@
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/refresh-rate",
|
||||
"api_v3.get_refresh_rate",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/schedule",
|
||||
"api_v3.get_schedule_config",
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
"""The report of a scrolling screen held by its plugin's update().
|
||||
|
||||
While a plugin's update() runs it holds the plugin's lock, and its screen's
|
||||
frames are skipped -- on a scroller, a frozen strip -- with nothing logged.
|
||||
_note_display_hold times each such run and reports one of
|
||||
DISPLAY_HOLD_REPORT_SECONDS or more.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _Clock:
|
||||
"""display_controller's clock: moves only when run() sleeps or a test says."""
|
||||
|
||||
def __init__(self, start=10_000.0):
|
||||
self.t = start
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.t += max(seconds, 0.0005)
|
||||
|
||||
def module(self):
|
||||
return types.SimpleNamespace(time=self.now, monotonic=self.now,
|
||||
perf_counter=self.now, sleep=self.sleep)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
c = _Clock()
|
||||
monkeypatch.setattr("src.display_controller.time", c.module())
|
||||
return c
|
||||
|
||||
|
||||
class _Locks:
|
||||
"""get_plugin_lock for one plugin, whose lock the test can hold."""
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def __call__(self, plugin_id):
|
||||
return self.lock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def held(test_display_controller):
|
||||
c = test_display_controller
|
||||
locks = _Locks()
|
||||
c.plugin_manager.get_plugin_lock = locks
|
||||
c.plugin_manager._warn_rate_limited = MagicMock()
|
||||
c.plugin_manager.health_tracker = MagicMock()
|
||||
c._display_hold = None
|
||||
return c, locks.lock
|
||||
|
||||
|
||||
def _plugin(plugin_id):
|
||||
p = MagicMock()
|
||||
p.plugin_id = plugin_id
|
||||
p.display.return_value = True
|
||||
return p
|
||||
|
||||
|
||||
class TestTheDisplayHoldReport:
|
||||
def test_a_long_hold_is_reported_when_it_ends(self, held, clock):
|
||||
c, lock = held
|
||||
ticker = _plugin("ticker")
|
||||
lock.acquire() # update() running
|
||||
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
|
||||
clock.t += 0.2
|
||||
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
|
||||
assert ticker.display.call_count == 0
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
clock.t += 0.2
|
||||
lock.release() # update() done
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
assert ticker.display.call_count == 1
|
||||
key, message, plugin_id, ms = c.plugin_manager._warn_rate_limited.call_args[0]
|
||||
assert key == "display-hold:ticker" and plugin_id == "ticker"
|
||||
assert "held" in message and ms == pytest.approx(400.0)
|
||||
c.plugin_manager.health_tracker.record_busy_skip.assert_called_once_with(
|
||||
"ticker", "display hold", pytest.approx(0.4))
|
||||
|
||||
def test_a_short_hold_is_not(self, held, clock):
|
||||
c, lock = held
|
||||
ticker = _plugin("ticker")
|
||||
lock.acquire()
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
clock.t += 0.1
|
||||
lock.release()
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
|
||||
|
||||
def test_a_hold_that_ends_on_another_plugins_screen_is_not_blamed_on_it(
|
||||
self, held, clock):
|
||||
c, lock = held
|
||||
lock.acquire()
|
||||
c._display_once(_plugin("ticker"), "ticker", False, report_hold=True)
|
||||
clock.t += 1.0
|
||||
lock.release()
|
||||
c._display_once(_plugin("clock"), "clock", False, report_hold=True)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
assert c._display_hold is None
|
||||
|
||||
def test_frames_that_draw_report_nothing(self, held, clock):
|
||||
c, _lock = held
|
||||
ticker = _plugin("ticker")
|
||||
for _ in range(5):
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
clock.t += 0.5
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
|
||||
def test_the_1hz_loop_reports_no_holds(self, held, clock):
|
||||
# A static screen's frames are a second apart: one skipped frame is
|
||||
# not a measured hold, and nothing on the panel froze. (On ledpi the
|
||||
# first version reported every such skip as "held 1000 ms".)
|
||||
c, lock = held
|
||||
board = _plugin("board")
|
||||
lock.acquire()
|
||||
c._display_once(board, "board", False)
|
||||
clock.t += 1.0
|
||||
lock.release()
|
||||
c._display_once(board, "board", False)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
|
||||
|
||||
def test_a_run_left_open_is_dropped_by_a_1hz_frame(self, held, clock):
|
||||
c, lock = held
|
||||
ticker = _plugin("ticker")
|
||||
lock.acquire()
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
lock.release()
|
||||
c._display_once(ticker, "ticker", False) # the 1 Hz loop draws
|
||||
assert c._display_hold is None
|
||||
clock.t += 5.0
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
@@ -576,42 +576,6 @@ class TestCounters:
|
||||
assert snap["hosts"]["site.api.espn.com"]["requests"] == 1
|
||||
assert snap["totals"]["bytes"] == 3 * len(b'{"ok": 1}')
|
||||
|
||||
def test_wire_bytes_are_the_compressed_size(self, service):
|
||||
# Built the way requests builds a real response: a urllib3
|
||||
# HTTPResponse carrying a gzip body, decoded when .content is read.
|
||||
import gzip
|
||||
import io
|
||||
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.response import HTTPResponse
|
||||
|
||||
decoded = json.dumps({"events": [{"id": str(i), "name": "x" * 200}
|
||||
for i in range(50)]}).encode()
|
||||
wire = gzip.compress(decoded)
|
||||
|
||||
def handler(url, kwargs):
|
||||
raw = HTTPResponse(body=io.BytesIO(wire), status=200,
|
||||
headers={"Content-Encoding": "gzip",
|
||||
"Content-Type": "application/json"},
|
||||
preload_content=False, decode_content=True)
|
||||
request = requests.Request("GET", url).prepare()
|
||||
response = HTTPAdapter().build_response(request, raw)
|
||||
response.content # what Session.get does for a non-streamed call
|
||||
return response
|
||||
|
||||
response = service.get(FakeSession(handler), "https://site.api.espn.com/x")
|
||||
assert response.content == decoded
|
||||
totals = _counters(service)
|
||||
assert totals["bytes"] == len(decoded)
|
||||
assert totals["wire_bytes"] == len(wire) < len(decoded)
|
||||
|
||||
def test_wire_bytes_fall_back_to_the_decoded_size(self, service):
|
||||
# No urllib3 response behind it (a test double, another adapter):
|
||||
# count what is known rather than nothing.
|
||||
service.get(FakeSession(), "https://api.test/x")
|
||||
totals = _counters(service)
|
||||
assert totals["wire_bytes"] == totals["bytes"] == len(b'{"ok": 1}')
|
||||
|
||||
def test_errors_and_http_errors(self, service):
|
||||
def handler(url, kwargs):
|
||||
if url.endswith("/down"):
|
||||
|
||||
@@ -807,3 +807,54 @@ def test_a_process_with_the_gc_monitor_exits_cleanly():
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert "Exception ignored" not in proc.stderr
|
||||
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
|
||||
|
||||
@@ -87,10 +87,8 @@ class TestANamedLiveModeIsShown:
|
||||
def test_the_named_mode_survives_a_restart(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
# The last on-demand config write, not the last write of any key: the
|
||||
# font-usage publisher thread writes its own key at its own pace.
|
||||
saved = [c for c in football.cache_manager.set.call_args_list
|
||||
if c.args and c.args[0] == 'display_on_demand_config'][-1]
|
||||
saved = football.cache_manager.set.call_args_list[-1]
|
||||
assert saved.args[0] == 'display_on_demand_config'
|
||||
config = saved.args[1]
|
||||
assert config['named_mode'] == 'ncaa_fb_live'
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from src.common.scroll_config import ( # noqa: E402
|
||||
refresh_hz_from_config,
|
||||
resolve,
|
||||
)
|
||||
from src.common import scroll_config # noqa: E402
|
||||
|
||||
|
||||
class FakeHelper:
|
||||
@@ -504,3 +505,46 @@ class TestSpeedAdvice:
|
||||
got = solve_crisp(50, 125.74)
|
||||
assert got.steppiness == "smooth"
|
||||
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
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
"""A plugin update must keep the files the plugin wrote beside itself.
|
||||
|
||||
Field incident, 2026-10-04: updating calendar 1.2.9 -> 1.2.12 from the web UI
|
||||
replaced plugin-repos/calendar/ with the fresh download and deleted the old
|
||||
copy -- and with it token.pickle and credentials.json, the plugin's Google
|
||||
OAuth files. No release contains them (the repo gitignores them), so the hot
|
||||
reload logged "Credentials file not found" and the calendar stayed broken
|
||||
until the files were restored by hand.
|
||||
|
||||
Both update routes are covered: a monorepo plugin (registry ``plugin_path``),
|
||||
which is reinstalled into a fresh directory, and a plugin installed from its
|
||||
own git repository, which is updated with ``git pull`` after an auto-stash.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_local_files import (
|
||||
is_known_state_file, local_files_to_keep,
|
||||
)
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
PLUGIN_ID = "calendar"
|
||||
|
||||
|
||||
def _manifest(version):
|
||||
return {"id": PLUGIN_ID, "name": "Calendar", "class_name": "CalendarPlugin",
|
||||
"display_modes": ["calendar"], "version": version}
|
||||
|
||||
|
||||
def _write_release(target, version):
|
||||
"""What a download of ``version`` puts on disk."""
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
(target / "manifest.json").write_text(json.dumps(_manifest(version)))
|
||||
(target / "manager.py").write_text(f"VERSION = {version!r}\n")
|
||||
(target / ".gitignore").write_text("credentials.json\ntoken.pickle\ncache/\n")
|
||||
|
||||
|
||||
def _drop_local_files(plugin_dir):
|
||||
"""What the plugin writes at runtime: OAuth files plus cached state."""
|
||||
(plugin_dir / "token.pickle").write_bytes(b"\x80\x04oauth-token")
|
||||
(plugin_dir / "credentials.json").write_text('{"installed": {}}')
|
||||
(plugin_dir / "cache").mkdir()
|
||||
(plugin_dir / "cache" / "events.json").write_text("[]")
|
||||
|
||||
|
||||
def _assert_local_files_kept(plugin_dir):
|
||||
assert (plugin_dir / "token.pickle").read_bytes() == b"\x80\x04oauth-token"
|
||||
assert (plugin_dir / "credentials.json").read_text() == '{"installed": {}}'
|
||||
assert (plugin_dir / "cache" / "events.json").read_text() == "[]"
|
||||
|
||||
|
||||
def _leftover_backups(plugins_dir):
|
||||
return [p.name for p in plugins_dir.iterdir() if "standalone-backup" in p.name]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
mgr = PluginStoreManager(
|
||||
plugins_dir=str(tmp_path / "plugin-repos"),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
mgr.plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
||||
monkeypatch.setattr(mgr, "fetch_registry", lambda *a, **k: {"plugins": []})
|
||||
return mgr
|
||||
|
||||
|
||||
class TestMonorepoUpdate:
|
||||
@pytest.fixture
|
||||
def installed(self, store, monkeypatch):
|
||||
registry_entry = {
|
||||
"id": PLUGIN_ID, "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
||||
"plugin_path": "plugins/calendar", "branch": "main",
|
||||
"latest_version": "1.2.9",
|
||||
}
|
||||
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: registry_entry)
|
||||
release = {"version": "1.2.9"}
|
||||
|
||||
def fake_monorepo_download(download_url, plugin_subpath, target):
|
||||
assert plugin_subpath == "plugins/calendar"
|
||||
_write_release(target, release["version"])
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(store, "_install_from_monorepo", fake_monorepo_download)
|
||||
assert store.install_plugin(PLUGIN_ID) is True
|
||||
|
||||
def publish(version):
|
||||
registry_entry["latest_version"] = release["version"] = version
|
||||
return store, store.plugins_dir / PLUGIN_ID, publish
|
||||
|
||||
def test_update_keeps_token_and_gitignored_files(self, installed):
|
||||
store, plugin_dir, publish = installed
|
||||
_drop_local_files(plugin_dir)
|
||||
|
||||
publish("1.2.12")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "1.2.12"
|
||||
_assert_local_files_kept(plugin_dir)
|
||||
assert _leftover_backups(store.plugins_dir) == []
|
||||
|
||||
def test_token_is_kept_even_when_the_release_does_not_gitignore_it(self, installed):
|
||||
store, plugin_dir, publish = installed
|
||||
(plugin_dir / ".gitignore").unlink()
|
||||
(plugin_dir / "token.pickle").write_bytes(b"tok")
|
||||
(plugin_dir / "config_secrets.json").write_text("{}")
|
||||
|
||||
publish("1.2.12")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert (plugin_dir / "token.pickle").read_bytes() == b"tok"
|
||||
assert (plugin_dir / "config_secrets.json").read_text() == "{}"
|
||||
|
||||
def test_release_content_wins_and_old_code_is_not_carried(self, installed):
|
||||
store, plugin_dir, publish = installed
|
||||
# A file the old copy had that the new release dropped, byte code, and
|
||||
# an old copy of a file the new release also ships.
|
||||
(plugin_dir / "removed_module.py").write_text("OLD = True\n")
|
||||
(plugin_dir / "__pycache__").mkdir()
|
||||
(plugin_dir / "__pycache__" / "manager.cpython-313.pyc").write_bytes(b"pyc")
|
||||
|
||||
publish("1.2.12")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert not (plugin_dir / "removed_module.py").exists()
|
||||
assert not (plugin_dir / "__pycache__").exists()
|
||||
assert "1.2.12" in (plugin_dir / "manager.py").read_text()
|
||||
|
||||
def test_reinstall_over_an_existing_copy_keeps_them_too(self, installed):
|
||||
store, plugin_dir, publish = installed
|
||||
_drop_local_files(plugin_dir)
|
||||
|
||||
assert store.install_plugin(PLUGIN_ID) is True
|
||||
|
||||
_assert_local_files_kept(plugin_dir)
|
||||
assert _leftover_backups(store.plugins_dir) == []
|
||||
|
||||
|
||||
class TestInstallFromUrlReplace:
|
||||
def test_replacing_an_installed_copy_keeps_the_token(self, store, monkeypatch):
|
||||
plugin_dir = store.plugins_dir / PLUGIN_ID
|
||||
_write_release(plugin_dir, "1.0.0")
|
||||
_drop_local_files(plugin_dir)
|
||||
|
||||
def fake_clone(repo_url, target, branches):
|
||||
_write_release(target, "2.0.0")
|
||||
return "main"
|
||||
|
||||
monkeypatch.setattr(store, "_install_via_git", fake_clone)
|
||||
result = store.install_from_url(
|
||||
"https://github.com/example/ledmatrix-calendar", plugin_id=PLUGIN_ID)
|
||||
|
||||
assert result["success"] is True
|
||||
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "2.0.0"
|
||||
_assert_local_files_kept(plugin_dir)
|
||||
|
||||
|
||||
def _git(*args, cwd):
|
||||
subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=t",
|
||||
"-c", "core.autocrlf=false", *args],
|
||||
cwd=cwd, check=True, capture_output=True)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
|
||||
class TestGitRepoUpdate:
|
||||
@pytest.fixture
|
||||
def cloned(self, store, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: None)
|
||||
upstream = tmp_path / "upstream"
|
||||
_write_release(upstream, "1.0.0")
|
||||
# This repo does NOT gitignore the token: an untracked, non-ignored
|
||||
# file is exactly what `git stash push -u` used to sweep away.
|
||||
(upstream / ".gitignore").write_text("cache/\n")
|
||||
_git("init", "-q", "-b", "main", cwd=upstream)
|
||||
_git("add", ".", cwd=upstream)
|
||||
_git("commit", "-qm", "1.0.0", cwd=upstream)
|
||||
|
||||
plugin_dir = store.plugins_dir / PLUGIN_ID
|
||||
_git("clone", "-q", str(upstream), str(plugin_dir), cwd=tmp_path)
|
||||
|
||||
def publish(version):
|
||||
(upstream / "manifest.json").write_text(json.dumps(_manifest(version)))
|
||||
_git("commit", "-qam", version, cwd=upstream)
|
||||
return store, plugin_dir, publish
|
||||
|
||||
def test_pull_update_keeps_untracked_token(self, cloned):
|
||||
store, plugin_dir, publish = cloned
|
||||
_drop_local_files(plugin_dir)
|
||||
# An unrelated untracked file, so the update really does stash.
|
||||
(plugin_dir / "notes.txt").write_text("scratch")
|
||||
|
||||
publish("1.1.0")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "1.1.0"
|
||||
_assert_local_files_kept(plugin_dir)
|
||||
|
||||
def test_token_alone_does_not_trigger_a_stash(self, cloned):
|
||||
store, plugin_dir, publish = cloned
|
||||
(plugin_dir / "token.pickle").write_bytes(b"tok")
|
||||
|
||||
publish("1.1.0")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert (plugin_dir / "token.pickle").read_bytes() == b"tok"
|
||||
stashes = subprocess.run(["git", "-C", str(plugin_dir), "stash", "list"],
|
||||
capture_output=True, text=True, check=True)
|
||||
assert stashes.stdout.strip() == ""
|
||||
|
||||
|
||||
class TestWhatIsKept:
|
||||
@pytest.mark.parametrize("path,expected", [
|
||||
("token.pickle", True),
|
||||
("data/session.pickle", True),
|
||||
("credentials.json", True),
|
||||
("token.json", True),
|
||||
("config_secrets.json", True),
|
||||
(".pkce_code_verifier", True),
|
||||
("manager.py", False),
|
||||
("config.json", False),
|
||||
])
|
||||
def test_known_state_files(self, path, expected):
|
||||
assert is_known_state_file(path) is expected
|
||||
|
||||
def test_gitignore_rules(self, tmp_path):
|
||||
old, new = tmp_path / "old", tmp_path / "new"
|
||||
new.mkdir()
|
||||
for rel in ["a.log", "logs/x.txt", "sub/deep/b.log", "keep.log",
|
||||
"anchored.txt", "sub/anchored.txt", "assets/x/y_backup/z.png",
|
||||
"manager.py", "shipped.log"]:
|
||||
(old / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(old / rel).write_text("x")
|
||||
(new / "shipped.log").write_text("new")
|
||||
(old / ".gitignore").write_text(
|
||||
"# comment\n*.log\n!keep.log\nlogs/\n/anchored.txt\n"
|
||||
"assets/**/*_backup/\n")
|
||||
|
||||
assert local_files_to_keep(old, new) == [
|
||||
"a.log", "anchored.txt", "assets/x/y_backup/z.png",
|
||||
"logs/x.txt", "sub/deep/b.log",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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
|
||||
@@ -26,15 +26,8 @@ import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
from web_interface import app as web_app
|
||||
def client():
|
||||
from web_interface.app import app
|
||||
# The captive-portal before_request hook shells out to systemctl/nmcli
|
||||
# whenever its 30s cache is cold, so on a Linux host whether a request
|
||||
# here runs subprocess depended on how long ago the previous one was --
|
||||
# and several tests below stub subprocess. Pin it: no test in this file
|
||||
# is about AP mode.
|
||||
monkeypatch.setattr(web_app, 'is_ap_mode_active', lambda: False)
|
||||
app.config['TESTING'] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
@@ -1143,23 +1136,12 @@ class TestPixletEditorHostDefaultsButDoesNotOverride:
|
||||
captured['env'] = env
|
||||
return FakeProcess()
|
||||
|
||||
# Swap the route module's own ``subprocess`` binding, not the shared
|
||||
# ``subprocess.Popen``: patching the attribute on the real module is
|
||||
# process-wide, and the app's before_request hook (the captive-portal
|
||||
# check) runs ``subprocess.run`` -- ``with Popen(...)`` -- whenever its
|
||||
# 30s AP-mode cache is cold on a host with systemctl. On the Linux CI
|
||||
# runner that handed it this FakeProcess and 500'd the request, but
|
||||
# only when the previous request was more than 30s earlier.
|
||||
fake_subprocess = types.ModuleType('subprocess')
|
||||
fake_subprocess.__dict__.update(mod.subprocess.__dict__)
|
||||
fake_subprocess.Popen = fake_popen
|
||||
|
||||
with patch.object(mod, '_validate_starlark_app_path',
|
||||
return_value=(app_dir, None)), \
|
||||
patch.object(mod, '_PIXLET_EDITOR_SCRIPT', script), \
|
||||
patch.object(mod, '_PIXLET_EDITOR_STATE', state_file), \
|
||||
patch.object(mod, '_find_pixlet_binary', return_value='/usr/bin/pixlet'), \
|
||||
patch.object(mod, 'subprocess', fake_subprocess), \
|
||||
patch.object(mod.subprocess, 'Popen', side_effect=fake_popen), \
|
||||
patch.dict(os.environ):
|
||||
if operator_host is None:
|
||||
os.environ.pop('PIXLET_EDITOR_HOST', None)
|
||||
|
||||
@@ -158,11 +158,16 @@ def _panel_refresh_hz(config):
|
||||
cap = scroll_config.refresh_hz_from_config(config)
|
||||
try:
|
||||
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)
|
||||
planned = float(stats.get('planned_refresh_hz') or 0)
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
measured = planned = 0.0
|
||||
# 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, a measurement far off this one.
|
||||
if planned and abs(planned - cap) > 0.5:
|
||||
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:
|
||||
return measured, 'measured'
|
||||
return cap, 'configured'
|
||||
@@ -189,6 +194,29 @@ def get_scroll_speed_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'])
|
||||
def get_schedule_config():
|
||||
"""Get current schedule configuration"""
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
min="0"
|
||||
max="1000"
|
||||
class="form-control">
|
||||
<p id="limit_refresh_rate_hz_hint" class="mt-1 text-xs text-amber-700" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -915,6 +916,41 @@ document.getElementById('brightness').addEventListener('input', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Say so when the panel cannot reach its refresh cap. Scroll speeds are
|
||||
// worked out against the cap, so every scroll then runs slow, and the
|
||||
// display has measured a cap the panel can hold.
|
||||
(function refreshRateHint() {
|
||||
const hint = document.getElementById('limit_refresh_rate_hz_hint');
|
||||
const input = document.getElementById('limit_refresh_rate_hz');
|
||||
if (!hint || !input) return;
|
||||
fetch('/api/v3/config/refresh-rate')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(body) {
|
||||
const s = body.status === 'success' && body.data.shortfall;
|
||||
hint.textContent = '';
|
||||
if (!s) return;
|
||||
hint.appendChild(document.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 = document.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 Event('input', {bubbles: true}));
|
||||
input.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
hint.textContent = 'Save, then restart the display, to apply ' +
|
||||
s.suggested_cap_hz + ' Hz.';
|
||||
});
|
||||
hint.appendChild(btn);
|
||||
hint.appendChild(document.createTextNode(', a cap it can hold.'));
|
||||
})
|
||||
.catch(function() { hint.textContent = ''; });
|
||||
})();
|
||||
|
||||
// Declared before first use: let is not hoisted usably.
|
||||
let scrollHintTimer = null;
|
||||
let scrollHintSeq = 0;
|
||||
|
||||
Reference in New Issue
Block a user