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 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-04 17:11:09 -04:00
co-authored by Claude Opus 5.5
parent a74b5a2f0f
commit e39dcb18b4
11 changed files with 390 additions and 4 deletions
@@ -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;