diff --git a/.gitignore b/.gitignore
index 280dd985..afb9b3df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,9 @@ __pycache__/
# Secrets
config/config_secrets.json
+# Atomic writes leave these behind when a save or a test is interrupted;
+# the suite drops several per run.
+config/.config_secrets.json.tmp.*
config/config.json
config/config.json.backup
config/wifi_config.json
diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py
index 4ca11e97..4136f2ba 100644
--- a/scripts/check_plugin.py
+++ b/scripts/check_plugin.py
@@ -78,6 +78,9 @@ logger = get_logger("[Check Plugin]")
DEFAULT_SEARCH_DIRS = [
str(PROJECT_ROOT / 'plugins'),
str(PROJECT_ROOT / 'plugin-repos'),
+ # The scoreboards live in the sibling ledmatrix-plugins checkout, not
+ # in this repo. Without this, --all silently skips every one of them.
+ str(PROJECT_ROOT.parent / 'ledmatrix-plugins' / 'plugins'),
]
diff --git a/src/common/scroll_helper.py b/src/common/scroll_helper.py
index 0f906f06..6c92ec9a 100644
--- a/src/common/scroll_helper.py
+++ b/src/common/scroll_helper.py
@@ -534,168 +534,6 @@ class ScrollHelper:
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
- def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
- """
- Get visible portion with sub-pixel interpolation for smooth scrolling.
- Uses bilinear interpolation to blend between pixels.
- """
- # We need to extract a region that's 1 pixel wider to allow for interpolation
- start_x = start_x_int
- end_x = start_x_int + self.display_width + 1
-
- # Check if we need wrap-around
- if end_x <= self.cached_image.width:
- # Normal case: extract region with 1 extra pixel for interpolation
- source_region = self.cached_array[:, start_x:end_x]
-
- # Use bilinear interpolation for sub-pixel shifting
- if HAS_SCIPY:
- # Use scipy for high-quality sub-pixel shifting
- shifted = shift(source_region, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
- # Extract the display_width portion
- frame_array = shifted[:, :self.display_width].astype(np.uint8)
- else:
- # Fallback: simple linear interpolation using numpy
- # Blend between current and next pixel based on fractional part
- frame_array = self._interpolate_subpixel(source_region, fractional)
-
- return Image.fromarray(frame_array)
- else:
- # Wrap-around case with sub-pixel
- # Use pre-allocated buffer
- if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
- self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
-
- width1 = self.cached_image.width - start_x
- if width1 > 0:
- # First part from end of image
- # Need width1 + 1 pixels for interpolation
- source1_width = min(width1 + 1, self.cached_image.width - start_x)
- source1 = self.cached_array[:, start_x:start_x + source1_width]
- if HAS_SCIPY:
- shifted1 = shift(source1, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
- # Ensure we get exactly width1 pixels, padding if necessary
- if shifted1.shape[1] >= width1:
- self._frame_buffer[:, :width1] = shifted1[:, :width1].astype(np.uint8)
- else:
- # Shifted array is smaller - pad with zeros or repeat last pixel
- actual_width = shifted1.shape[1]
- self._frame_buffer[:, :actual_width] = shifted1.astype(np.uint8)
- if actual_width < width1:
- # Pad with last pixel
- self._frame_buffer[:, actual_width:width1] = shifted1[:, -1:].astype(np.uint8)
- else:
- interpolated1 = self._interpolate_subpixel(source1, fractional, output_width=width1)
- # Ensure exact width match
- if interpolated1.shape[1] == width1:
- self._frame_buffer[:, :width1] = interpolated1
- else:
- # Handle size mismatch
- copy_width = min(width1, interpolated1.shape[1])
- self._frame_buffer[:, :copy_width] = interpolated1[:, :copy_width]
- if copy_width < width1:
- self._frame_buffer[:, copy_width:width1] = interpolated1[:, -1:]
-
- # Second part from beginning
- remaining_width = self.display_width - width1
- if remaining_width > 0:
- source2 = self.cached_array[:, :remaining_width + 1]
- if HAS_SCIPY:
- shifted2 = shift(source2, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
- # Ensure we get exactly remaining_width pixels
- if shifted2.shape[1] >= remaining_width:
- self._frame_buffer[:, width1:width1 + remaining_width] = shifted2[:, :remaining_width].astype(np.uint8)
- else:
- # Shifted array is smaller - pad if necessary
- actual_width = shifted2.shape[1]
- self._frame_buffer[:, width1:width1 + actual_width] = shifted2.astype(np.uint8)
- if actual_width < remaining_width:
- self._frame_buffer[:, width1 + actual_width:width1 + remaining_width] = shifted2[:, -1:].astype(np.uint8)
- else:
- interpolated2 = self._interpolate_subpixel(source2, fractional, output_width=remaining_width)
- # Ensure exact width match
- if interpolated2.shape[1] == remaining_width:
- self._frame_buffer[:, width1:] = interpolated2
- else:
- copy_width = min(remaining_width, interpolated2.shape[1])
- self._frame_buffer[:, width1:width1 + copy_width] = interpolated2[:, :copy_width]
- if copy_width < remaining_width:
- self._frame_buffer[:, width1 + copy_width:width1 + remaining_width] = interpolated2[:, -1:]
- else:
- # Edge case: wrap to beginning
- source = self.cached_array[:, :self.display_width + 1]
- if HAS_SCIPY:
- shifted = shift(source, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
- # Ensure we get exactly display_width pixels
- if shifted.shape[1] >= self.display_width:
- self._frame_buffer = shifted[:, :self.display_width].astype(np.uint8)
- else:
- # Shifted array is smaller - pad if necessary
- actual_width = shifted.shape[1]
- self._frame_buffer[:, :actual_width] = shifted.astype(np.uint8)
- if actual_width < self.display_width:
- self._frame_buffer[:, actual_width:] = shifted[:, -1:].astype(np.uint8)
- else:
- interpolated = self._interpolate_subpixel(source, fractional, output_width=self.display_width)
- # _interpolate_subpixel now always returns exact width, so this should work
- self._frame_buffer = interpolated
-
- return Image.fromarray(self._frame_buffer)
-
- def _interpolate_subpixel(self, source: np.ndarray, fractional: float, output_width: Optional[int] = None) -> np.ndarray:
- """
- Simple linear interpolation for sub-pixel positioning.
- Blends between adjacent pixels based on fractional offset.
-
- Args:
- source: Source array to interpolate (width should be at least output_width + 1)
- fractional: Fractional part of scroll position (0.0-1.0)
- output_width: Desired output width (defaults to display_width)
-
- Returns:
- Interpolated array of shape (height, output_width, 3) - ALWAYS exactly output_width
- """
- if output_width is None:
- output_width = self.display_width
-
- # Always return exactly output_width pixels, padding if necessary
- result = np.zeros((source.shape[0], output_width, 3), dtype=np.uint8)
-
- # Ensure we have enough source pixels for interpolation
- if source.shape[1] < 2:
- # Very small source - just copy what we have and pad
- copy_width = min(source.shape[1], output_width)
- result[:, :copy_width] = source[:, :copy_width].astype(np.uint8)
- if copy_width < output_width:
- # Pad with last pixel
- result[:, copy_width:] = source[:, -1:].astype(np.uint8)
- return result
-
- # Calculate how many pixels we can actually interpolate
- # Need at least 2 pixels to interpolate, so max output is source.shape[1] - 1
- max_interpolated_width = source.shape[1] - 1
- interpolated_width = min(output_width, max_interpolated_width)
-
- if interpolated_width > 0:
- # Extract pixels at x and x+1 for interpolation
- pixels_x = source[:, :interpolated_width].astype(np.float32)
- pixels_x1 = source[:, 1:interpolated_width + 1].astype(np.float32)
-
- # Linear interpolation
- interpolated = pixels_x * (1.0 - fractional) + pixels_x1 * fractional
-
- # Clip and convert back to uint8
- interpolated = np.clip(interpolated, 0, 255).astype(np.uint8)
-
- # Copy interpolated portion to result
- result[:, :interpolated_width] = interpolated
-
- # If we need more pixels than we can interpolate, pad with last pixel
- if interpolated_width < output_width:
- result[:, interpolated_width:] = source[:, -1:].astype(np.uint8)
-
- return result
-
def calculate_dynamic_duration(self) -> int:
"""
Calculate display duration based on content width and scroll settings.
diff --git a/test/test_color_picker_widget.py b/test/test_color_picker_widget.py
new file mode 100644
index 00000000..bd6a9577
--- /dev/null
+++ b/test/test_color_picker_widget.py
@@ -0,0 +1,25 @@
+"""Regression: the native color input in the color-picker widget must have
+an accessible name independent of showHexInput.
+
+CodeRabbit flagged (PR #568) that the only carried a
+`title` attribute -- screen readers don't reliably announce `title`, and
+when showHexInput is false there is no other label naming the control.
+"""
+from pathlib import Path
+
+WIDGET_JS = (
+ Path(__file__).resolve().parent.parent
+ / "web_interface" / "static" / "v3" / "js" / "widgets" / "color-picker.js"
+)
+
+
+def test_native_color_input_has_aria_label():
+ source = WIDGET_JS.read_text(encoding="utf-8")
+ start = source.index('", start)
+ tag = source[start:end]
+ assert 'aria-label=' in tag, (
+ "native color lost its accessible name; screen readers need "
+ "aria-label since it has no associated
diff --git a/web_interface/templates/v3/captive_setup.html b/web_interface/templates/v3/captive_setup.html
index bce4cd5e..cf92deab 100644
--- a/web_interface/templates/v3/captive_setup.html
+++ b/web_interface/templates/v3/captive_setup.html
@@ -3,39 +3,48 @@
@@ -45,44 +54,48 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
+
-
✓
-
Connected!
-
Your device is now on the network. Access the full interface at:
+
+
Connected!
+
Your device is now on the network. Access the full interface at:
-
You may need to reconnect your phone to the same WiFi network.
+
You may need to reconnect your phone to the same WiFi network.
@@ -94,17 +107,53 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
var selectedSSID = '';
var scanning = false;
var connecting = false;
+var msgTimer = null;
function $(id) { return document.getElementById(id); }
-function showMsg(text, type) {
- var el = $('msg');
- el.textContent = text;
- el.className = 'msg msg-' + (type || 'info');
- if (type === 'ok') setTimeout(function() { el.style.display = 'none'; }, 8000);
+// fetch() with a deadline: on a flaky AP link a request can otherwise hang
+// forever and leave the buttons stuck in their busy state.
+function fetchWithTimeout(url, options, ms) {
+ options = options || {};
+ if (typeof AbortController === 'undefined') return fetch(url, options);
+ var controller = new AbortController();
+ var timer = setTimeout(function() { controller.abort(); }, ms);
+ options.signal = controller.signal;
+ return fetch(url, options).finally(function() { clearTimeout(timer); });
}
-function clearMsg() { $('msg').className = 'msg'; }
+function readJson(r) {
+ return r.json().catch(function() {
+ throw new Error(r.ok ? 'bad-response' : 'http-' + r.status);
+ });
+}
+
+function setBusy(btn, label) {
+ btn.textContent = '';
+ var spin = document.createElement('span');
+ spin.className = 'spinner';
+ spin.setAttribute('aria-hidden', 'true');
+ btn.appendChild(spin);
+ btn.appendChild(document.createTextNode(label));
+}
+
+function showMsg(text, type) {
+ var el = $('msg');
+ if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
+ el.style.display = '';
+ // Errors interrupt; progress and success wait their turn.
+ el.setAttribute('role', type === 'err' ? 'alert' : 'status');
+ el.setAttribute('aria-live', type === 'err' ? 'assertive' : 'polite');
+ el.textContent = text;
+ el.className = 'msg msg-' + (type || 'info');
+ if (type === 'ok') msgTimer = setTimeout(function() { el.style.display = 'none'; }, 8000);
+}
+
+function clearMsg() {
+ if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
+ $('msg').className = 'msg';
+ $('msg').textContent = '';
+}
function updateConnectBtn() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
@@ -128,43 +177,58 @@ function doScan() {
scanning = true;
var btn = $('btn-scan');
btn.disabled = true;
- btn.innerHTML = '
Scanning';
+ btn.setAttribute('aria-busy', 'true');
+ setBusy(btn, 'Scanning');
clearMsg();
- fetch('/api/v3/wifi/scan')
- .then(function(r) { return r.json(); })
+ fetchWithTimeout('/api/v3/wifi/scan', {}, 30000)
+ .then(readJson)
.then(function(data) {
var sel = $('net-select');
+ var previous = sel.value;
sel.innerHTML = '
';
if (data.status === 'success' && Array.isArray(data.data)) {
- var nets = data.data;
+ var seen = {};
+ var nets = data.data.filter(function(n) {
+ if (!n || !n.ssid || seen[n.ssid]) return false;
+ seen[n.ssid] = true;
+ return true;
+ });
for (var i = 0; i < nets.length; i++) {
var n = nets[i];
var opt = document.createElement('option');
opt.value = n.ssid;
- opt.textContent = n.ssid + ' (' + n.signal + '% - ' + n.security + ')';
+ var details = [];
+ if (n.signal !== undefined && n.signal !== null) details.push(n.signal + '%');
+ if (n.security) details.push(n.security);
+ opt.textContent = n.ssid + (details.length ? ' (' + details.join(' - ') + ')' : '');
sel.appendChild(opt);
}
+ if (previous && seen[previous]) sel.value = previous;
if (nets.length > 0) {
- var msg = 'Found ' + nets.length + ' network' + (nets.length > 1 ? 's' : '');
+ var msg = 'Found ' + nets.length + (nets.length === 1 ? ' network' : ' networks');
if (data.cached) {
- msg += ' \u2014 Showing cached networks. Connect to see the latest.';
+ msg += ' — showing cached networks. Connect to see the latest.';
}
showMsg(msg, data.cached ? 'info' : 'ok');
} else {
- showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again.'), 'info');
+ showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again, or enter your network name manually.'), 'info');
}
} else {
- showMsg(data.message || 'Scan failed', 'err');
+ showMsg(data.message || 'Scan failed. Try again, or enter your network name manually.', 'err');
}
})
.catch(function(e) {
- showMsg('Scan failed: ' + e.message, 'err');
+ var why = e && e.name === 'AbortError'
+ ? 'The scan took too long.'
+ : 'Couldn’t reach the device to scan.';
+ showMsg(why + ' Stay close to the device and try again, or enter your network name manually.', 'err');
})
.finally(function() {
scanning = false;
btn.disabled = false;
- btn.innerHTML = 'Scan';
+ btn.removeAttribute('aria-busy');
+ btn.textContent = 'Scan';
updateConnectBtn();
});
}
@@ -175,16 +239,25 @@ function doConnect() {
connecting = true;
var btn = $('btn-connect');
btn.disabled = true;
- btn.innerHTML = '
Connecting...';
+ btn.setAttribute('aria-busy', 'true');
+ setBusy(btn, 'Connecting…');
clearMsg();
- showMsg('Connecting to ' + ssid + '... This may take 15-30 seconds.', 'info');
+ showMsg('Connecting to ' + ssid + '… This may take 15–30 seconds.', 'info');
- fetch('/api/v3/wifi/connect', {
+ function resetConnect() {
+ connecting = false;
+ btn.disabled = false;
+ btn.removeAttribute('aria-busy');
+ btn.textContent = 'Connect';
+ updateConnectBtn();
+ }
+
+ fetchWithTimeout('/api/v3/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid, password: $('password').value || '' })
- })
- .then(function(r) { return r.json(); })
+ }, 60000)
+ .then(readJson)
.then(function(data) {
if (data.status === 'success') {
clearMsg();
@@ -192,15 +265,19 @@ function doConnect() {
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else {
var msg = data.error_type === 'wrong_password'
- ? 'Incorrect password — please try again'
- : (data.message || 'Connection failed');
+ ? 'Incorrect password — please try again.'
+ : (data.message || 'Couldn’t connect to ' + ssid + '. Check the network name and password, then try again.');
showMsg(msg, 'err');
- connecting = false;
- btn.disabled = false;
- btn.innerHTML = 'Connect';
+ resetConnect();
+ if (data.error_type === 'wrong_password') $('password').focus();
}
})
.catch(function(e) {
+ if (e && /^http-|^bad-response$/.test(e.message)) {
+ showMsg('The device couldn’t start the connection. Try again in a moment.', 'err');
+ resetConnect();
+ return;
+ }
// Connection may drop if AP mode was disabled — that's expected
clearMsg();
showMsg('Connection attempt sent. If the page stops responding, the device is connecting to ' + ssid + '.', 'info');
@@ -216,8 +293,8 @@ function checkNewIP(ssid, retriesLeft) {
showSuccessFallback(ssid);
return;
}
- fetch('/api/v3/wifi/status')
- .then(function(r) { return r.json(); })
+ fetchWithTimeout('/api/v3/wifi/status', {}, 8000)
+ .then(readJson)
.then(function(data) {
if (data.status === 'success' && data.data && data.data.connected && data.data.ip_address) {
showSuccess(data.data.ip_address);
@@ -231,18 +308,21 @@ function checkNewIP(ssid, retriesLeft) {
});
}
-function showSuccess(ip) {
+function revealSuccess(text) {
$('setup-form').classList.add('hidden');
- $('success-view').classList.remove('hidden');
- $('new-ip').textContent = 'http://' + ip + ':5000';
- $('msg').className = 'msg';
+ var view = $('success-view');
+ view.classList.remove('hidden');
+ $('new-ip').textContent = text;
+ clearMsg();
+ view.focus();
+}
+
+function showSuccess(ip) {
+ revealSuccess('http://' + ip + ':5000');
}
function showSuccessFallback(ssid) {
- $('setup-form').classList.add('hidden');
- $('success-view').classList.remove('hidden');
- $('new-ip').textContent = 'Check your router for the device IP';
- $('msg').className = 'msg';
+ revealSuccess('Check your router for the device IP');
}
// Auto-scan on load
diff --git a/web_interface/templates/v3/partials/backup_restore.html b/web_interface/templates/v3/partials/backup_restore.html
index b4525a47..d742aeba 100644
--- a/web_interface/templates/v3/partials/backup_restore.html
+++ b/web_interface/templates/v3/partials/backup_restore.html
@@ -52,7 +52,7 @@
-
+
diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html
index 95e64d33..c5a0e322 100644
--- a/web_interface/templates/v3/partials/display.html
+++ b/web_interface/templates/v3/partials/display.html
@@ -1039,10 +1039,25 @@ window.showDisplaySaveResult = function(xhr) {
content.appendChild(textSpan);
}
- // Initial UI state and polling — guard against duplicate intervals on re-run
+ // Initial UI state, then the 5s sync-status poll — only while the Display
+ // tab is active and the page is visible. LEDVisibility keys the
+ // registration, so a re-run of this partial replaces the previous timer.
+ function stopSyncTimer() {
+ if (window.syncStatusInterval) {
+ clearInterval(window.syncStatusInterval);
+ window.syncStatusInterval = null;
+ }
+ }
+ function startSyncTimer() {
+ stopSyncTimer();
+ pollSyncStatus();
+ window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
+ }
function startSyncPolling() {
updateSyncUI();
- if (!window.syncStatusInterval) {
+ if (window.LEDVisibility) {
+ window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
+ } else if (!window.syncStatusInterval) {
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
}
diff --git a/web_interface/templates/v3/partials/fonts.html b/web_interface/templates/v3/partials/fonts.html
index 29038eba..5039a5ab 100644
--- a/web_interface/templates/v3/partials/fonts.html
+++ b/web_interface/templates/v3/partials/fonts.html
@@ -39,7 +39,7 @@
-
+
Custom name for this font (letters, numbers, underscores, hyphens)
@@ -75,24 +75,24 @@
-
![Font preview]()
+
Select a font to preview
-
+
-
+
-
+