Add adaptive FPS and honest device reachability during streaming

DDP uses fire-and-forget UDP, so when a WiFi device becomes overwhelmed
by sustained traffic, sends appear successful while the device is
actually unreachable. This adds:

- HTTP liveness probe (GET /json/info, 2s timeout) every 10s during
  streaming, exposed as device_streaming_reachable in target state
- Adaptive FPS (opt-in): exponential backoff when device is unreachable,
  gradual recovery when it stabilizes — finds sustainable send rate
- Honest health checks: removed the lie that forced device_online=true
  during streaming; now runs actual health checks regardless
- Target editor toggle, FPS display shows effective rate when throttled,
  health dot reflects streaming reachability, red highlight when
  unreachable
- Auto-backup scheduling support in settings modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 20:22:58 +03:00
parent f8656b72a6
commit cadef971e7
23 changed files with 873 additions and 21 deletions

View File

@@ -143,6 +143,7 @@ class TargetEditorModal extends Modal {
brightness_threshold: document.getElementById('target-editor-brightness-threshold').value,
fps: document.getElementById('target-editor-fps').value,
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked,
};
}
}
@@ -272,6 +273,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-brightness-threshold').value = thresh;
document.getElementById('target-editor-brightness-threshold-value').textContent = thresh;
document.getElementById('target-editor-adaptive-fps').checked = target.adaptive_fps ?? false;
_populateCssDropdown(target.color_strip_source_id || '');
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
} else if (cloneData) {
@@ -290,6 +293,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-brightness-threshold').value = cloneThresh;
document.getElementById('target-editor-brightness-threshold-value').textContent = cloneThresh;
document.getElementById('target-editor-adaptive-fps').checked = cloneData.adaptive_fps ?? false;
_populateCssDropdown(cloneData.color_strip_source_id || '');
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
} else {
@@ -305,6 +310,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-brightness-threshold').value = 0;
document.getElementById('target-editor-brightness-threshold-value').textContent = '0';
document.getElementById('target-editor-adaptive-fps').checked = false;
_populateCssDropdown('');
_populateBrightnessVsDropdown('');
}
@@ -364,6 +371,8 @@ export async function saveTargetEditor() {
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0;
const adaptiveFps = document.getElementById('target-editor-adaptive-fps').checked;
const payload = {
name,
device_id: deviceId,
@@ -372,6 +381,7 @@ export async function saveTargetEditor() {
min_brightness_threshold: minBrightnessThreshold,
fps,
keepalive_interval: standbyInterval,
adaptive_fps: adaptiveFps,
};
try {
@@ -765,8 +775,29 @@ function _patchTargetMetrics(target) {
const metrics = target.metrics || {};
const fps = card.querySelector('[data-tm="fps"]');
if (fps) fps.innerHTML = `${state.fps_current ?? 0}<span class="target-fps-target">/${state.fps_target || 0}</span>`
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
if (fps) {
const effFps = state.fps_effective;
const tgtFps = state.fps_target || 0;
const fpsLabel = (effFps != null && effFps < tgtFps)
? `${state.fps_current ?? 0}<span class="target-fps-target">/${effFps}${tgtFps}</span>`
: `${state.fps_current ?? 0}<span class="target-fps-target">/${tgtFps}</span>`;
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
}
// Update health dot to reflect streaming reachability when processing
const healthDot = card.querySelector('.health-dot');
if (healthDot && state.processing) {
const reachable = state.device_streaming_reachable;
if (reachable === false) {
healthDot.className = 'health-dot health-offline';
healthDot.title = t('device.health.streaming_unreachable') || 'Unreachable during streaming';
} else if (reachable === true) {
healthDot.className = 'health-dot health-online';
healthDot.title = t('device.health.online');
}
}
const timing = card.querySelector('[data-tm="timing"]');
if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state);