From ac207c41e74026d1c1e64dc33b98d73a2040a526 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 6 Jul 2026 14:58:04 -0400 Subject: [PATCH 1/5] feat(web-ui): detect and surface Raspberry Pi under-voltage/throttling Adds a vcgencmd get_throttled check to the system-status SSE stream and surfaces it in the web UI: - A header badge (next to CPU/Memory/Temp) that stays hidden when healthy, turns red when under-voltage/throttling is happening right now, and yellow if it happened earlier this session but has since cleared. - A dismissible top banner (same pattern as the update-available banner) that appears while under-voltage/throttling is actively occurring, with guidance to check the power supply. Re-appears on a fresh occurrence even if a previous one was dismissed. - A "Power Supply" card on the Overview tab alongside CPU/Memory/Temp/ Display Status. Motivated by a real device showing intermittent brightness flicker that turned out to be ~1 under-voltage event every 30-90s (visible live via `vcgencmd get_throttled` and dmesg's "Undervoltage detected!" messages) -- there was no way to see this from the web UI, only by SSHing in. Returns None on non-Pi platforms (no vcgencmd on PATH), matching the existing guard pattern used for the CPU temperature read. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- web_interface/app.py | 36 +++++++++- web_interface/static/v3/app.css | 23 ++++++ web_interface/templates/v3/base.html | 100 ++++++++++++++++++++++++++ web_interface/templates/v3/index.html | 16 ++++- 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/web_interface/app.py b/web_interface/app.py index b1b3416ac..b3a40f8a7 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -3,6 +3,7 @@ import logging import os import queue +import re import shutil import sys import subprocess @@ -27,6 +28,7 @@ _JOURNALCTL = shutil.which('journalctl') _SYSTEMCTL = shutil.which('systemctl') +_VCGENCMD = shutil.which('vcgencmd') # Create Flask app app = Flask(__name__) @@ -498,6 +500,37 @@ def _broadcast(self): except queue.Full: pass +def _get_power_status(): + """Check Raspberry Pi under-voltage/throttling status via vcgencmd. + + Returns a dict of decoded flags, or None on non-Pi platforms (no + vcgencmd) or if the call fails for any reason. See: + https://www.raspberrypi.com/documentation/computers/os.html#get_throttled + """ + if not _VCGENCMD: + return None + try: + result = subprocess.run( + [_VCGENCMD, 'get_throttled'], capture_output=True, text=True, timeout=2 + ) + match = re.search(r'0x([0-9a-fA-F]+)', result.stdout) + if not match: + return None + bits = int(match.group(1), 16) + return { + 'under_voltage_now': bool(bits & 0x1), + 'freq_capped_now': bool(bits & 0x2), + 'throttled_now': bool(bits & 0x4), + 'soft_temp_limit_now': bool(bits & 0x8), + 'under_voltage_occurred': bool(bits & 0x10000), + 'freq_capped_occurred': bool(bits & 0x20000), + 'throttled_occurred': bool(bits & 0x40000), + 'soft_temp_limit_occurred': bool(bits & 0x80000), + } + except (subprocess.SubprocessError, OSError, ValueError) as e: + app.logger.debug("vcgencmd get_throttled failed: %s", e) + return None + # System status generator for SSE def system_status_generator(): """Generate system status updates""" @@ -544,7 +577,8 @@ def system_status_generator(): 'cpu_percent': cpu_percent, 'memory_used_percent': memory_used_percent, 'cpu_temp': cpu_temp, - 'disk_used_percent': 0 + 'disk_used_percent': 0, + 'power': _get_power_status() } yield status except Exception as e: diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index 8894b9de7..f6e34fa9d 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -1040,3 +1040,26 @@ button.bg-white { [data-theme="dark"] .update-banner-dismiss { color: #93c5fd; } + +/* Under-voltage / throttling warning banner */ +.power-warning-banner { + background-color: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} +.power-warning-banner-dismiss { + color: #991b1b; + opacity: 0.6; +} +.power-warning-banner-dismiss:hover { + opacity: 1; +} + +[data-theme="dark"] .power-warning-banner { + background-color: #450a0a; + border-color: #7f1d1d; + color: #fca5a5; +} +[data-theme="dark"] .power-warning-banner-dismiss { + color: #fca5a5; +} diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index c69c75ba3..c9e7db7a2 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -913,6 +913,10 @@

--°C + @@ -947,6 +951,27 @@

+ + +
@@ -1445,6 +1470,56 @@

window.statsSource.addEventListener('error', window._statsErrorHandler); window.displaySource.addEventListener('error', window._displayErrorHandler); + // Reset any time the currently-active warning clears, so a future + // (new) occurrence shows the banner again even if this one was dismissed. + window._powerWarningDismissed = false; + + window.dismissPowerWarningBanner = function() { + const banner = document.getElementById('power-warning-banner'); + if (banner) banner.style.display = 'none'; + window._powerWarningDismissed = true; + }; + + function updatePowerStatus(power) { + const statEl = document.getElementById('power-stat'); + const banner = document.getElementById('power-warning-banner'); + + if (!power) { + if (statEl) statEl.classList.add('hidden'); + return; + } + + const activeNow = power.under_voltage_now || power.throttled_now; + const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred; + + if (statEl) { + statEl.classList.remove('text-red-600', 'text-yellow-600'); + if (activeNow) { + statEl.classList.remove('hidden'); + statEl.classList.add('flex', 'text-red-600'); + statEl.title = 'Under-voltage/throttling detected right now — check your power supply'; + } else if (occurredEarlier) { + statEl.classList.remove('hidden'); + statEl.classList.add('flex', 'text-yellow-600'); + statEl.title = 'Under-voltage/throttling was detected earlier (currently OK)'; + } else { + statEl.classList.add('hidden'); + } + } + + if (banner) { + if (activeNow) { + if (!window._powerWarningDismissed) { + banner.style.display = ''; + } + } else { + banner.style.display = 'none'; + // Let a future occurrence show the banner again. + window._powerWarningDismissed = false; + } + } + } + function updateSystemStats(data) { // Update CPU in header const cpuEl = document.getElementById('cpu-stat'); @@ -1467,6 +1542,9 @@

if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C'; } + // Update Power (under-voltage / throttling) status in header + banner + updatePowerStatus(data.power); + // Update Overview tab stats (if visible) const cpuUsageEl = document.getElementById('cpu-usage'); if (cpuUsageEl && data.cpu_percent !== undefined) { @@ -1483,6 +1561,28 @@

cpuTempEl.textContent = data.cpu_temp + '°C'; } + const powerStatusEl = document.getElementById('power-status'); + const powerStatusIconEl = document.getElementById('power-status-icon'); + if (powerStatusEl && data.power) { + const activeNow = data.power.under_voltage_now || data.power.throttled_now; + const occurredEarlier = data.power.under_voltage_occurred || data.power.throttled_occurred; + if (activeNow) { + powerStatusEl.textContent = 'Under-voltage'; + powerStatusEl.className = 'text-lg font-medium text-red-600'; + } else if (occurredEarlier) { + powerStatusEl.textContent = 'OK (past issue)'; + powerStatusEl.className = 'text-lg font-medium text-yellow-600'; + } else { + powerStatusEl.textContent = 'OK'; + powerStatusEl.className = 'text-lg font-medium text-green-600'; + } + if (powerStatusIconEl) { + powerStatusIconEl.className = activeNow ? + 'fas fa-bolt text-red-600 text-xl' : + (occurredEarlier ? 'fas fa-bolt text-yellow-500 text-xl' : 'fas fa-bolt text-gray-400 text-xl'); + } + } + const displayStatusEl = document.getElementById('display-status'); if (displayStatusEl) { displayStatusEl.textContent = data.service_active ? 'Active' : 'Inactive'; diff --git a/web_interface/templates/v3/index.html b/web_interface/templates/v3/index.html index c064dfd18..366fd1db6 100644 --- a/web_interface/templates/v3/index.html +++ b/web_interface/templates/v3/index.html @@ -8,7 +8,7 @@

System Overview

-
+
@@ -64,6 +64,20 @@

System Overview

+ +
+
+
+ +
+
+
+
Power Supply
+
--
+
+
+
+
From bfd9d4a48f70050322dfb28522bbbcf0e9e7745b Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 6 Jul 2026 15:29:42 -0400 Subject: [PATCH 2/5] feat(web-ui): add power supply diagnostics detail to Tools tab The header badge/banner/Overview card added in the previous commit only show a collapsed "is it bad right now" signal. This adds a "Power Supply" section to the Tools tab with the full 8-flag breakdown (under-voltage, throttled, freq-capped, soft-temp-limit -- each split into "right now" vs "occurred since boot") for actually troubleshooting a recurring issue, plus a pointer to the README's power supply sizing guidance when something is or was flagged. Reuses the existing stats SSE stream (window.statsSource) rather than adding a new endpoint -- the same payload already drives the header/ banner/Overview card, so this just listens for it too. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- .../templates/v3/partials/tools.html | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index 3e249988c..30f30e7bb 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -112,6 +112,17 @@

Maintenance

+ +
+
+

Power Supply

+

Raspberry Pi under-voltage/throttling status (via vcgencmd get_throttled). A marginal power supply is a common cause of visible flicker or dimming on LED panels.

+
+
+
Waiting for live stats…
+
+
+
@@ -316,6 +327,70 @@

Services

}); } + // ── power supply diagnostics panel ──────────────────────────────────────── + // Reuses the same SSE stream (window.statsSource, set up in base.html) + // that already drives the header badge/banner and Overview card, instead + // of polling a separate endpoint. + + const FLAG_LABELS = [ + ['under_voltage_now', 'Under-voltage (right now)'], + ['throttled_now', 'Throttled (right now)'], + ['freq_capped_now', 'ARM frequency capped (right now)'], + ['soft_temp_limit_now', 'Soft temperature limit active (right now)'], + ['under_voltage_occurred', 'Under-voltage occurred (since boot)'], + ['throttled_occurred', 'Throttled occurred (since boot)'], + ['freq_capped_occurred', 'ARM frequency capped occurred (since boot)'], + ['soft_temp_limit_occurred', 'Soft temperature limit occurred (since boot)'], + ]; + + function renderPowerInfo(power) { + const panel = document.getElementById('power-info-panel'); + if (!panel) return; + + if (!power) { + panel.innerHTML = 'Not available on this platform (no vcgencmd found — this isn\'t a Raspberry Pi, or it\'s not on PATH).'; + return; + } + + const activeNow = power.under_voltage_now || power.throttled_now; + const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred; + const summaryColor = activeNow ? 'red' : (occurredEarlier ? 'yellow' : 'green'); + const summaryText = activeNow + ? 'Actively under-voltage/throttled right now' + : (occurredEarlier ? 'OK right now (but occurred earlier this boot)' : 'OK — no issues detected'); + + let html = ` +
+ + ${escHtml(summaryText)} + +
+
    `; + for (const [key, label] of FLAG_LABELS) { + const set = !!power[key]; + html += `
  • + + ${escHtml(label)} +
  • `; + } + html += `
`; + + if (activeNow || occurredEarlier) { + html += `

Consider a higher-amperage 5V supply wired directly to the panel(s), or check for a loose power connector. See the README's Power Supply section for sizing guidance.

`; + } + + panel.innerHTML = html; + } + + if (window.statsSource) { + window.statsSource.addEventListener('message', function(event) { + try { + const data = JSON.parse(event.data); + if (data && 'power' in data) renderPowerInfo(data.power); + } catch (e) { /* ignore malformed frames */ } + }); + } + // Load on first render; HTMX will have already swapped us in by this point. loadGitInfo(); })(); From d87f25d9650cc36a964721a7f861e5ff18262290 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 6 Jul 2026 17:44:21 -0400 Subject: [PATCH 3/5] fix(web-ui): address review findings on power supply monitoring - Overview card left its "--" placeholder forever on non-Pi platforms since the update only handled a truthy data.power. Now explicitly renders "Not available" with a neutral icon/color for that case. - vcgencmd failures logged at debug, invisible in default remote log output. Bumped to warning to match the nearby systemctl failure logging. - The banner/badge/card and the Tools summary line only looked at under_voltage_now/throttled_now (+ occurred), silently ignoring freq_capped_now/occurred and soft_temp_limit_now/occurred from _get_power_status() -- a Pi that's actively soft-thermal-limited or frequency-capped showed a green "OK" everywhere except the detailed flag table buried in Tools. All four surfaces now fold all four "now"/ "occurred" flags into the same active/occurred state. - The banner text was hardcoded to "Under-voltage detected..." even when the actual active condition was throttling/freq-capping/thermal limiting. Added _activePowerConditionLabels() (shared, non-module global scope) to build the banner/tooltip text from whichever flags are actually set. Skipped: TTL-caching _get_power_status() to avoid "multiplying forks across browser tabs" -- that premise doesn't hold against this codebase. _StreamBroadcaster (its own docstring says as much) already runs exactly one shared generator per tick regardless of client count, identical to the uncached cpu_temp file-read two lines above it; there's nothing to multiply. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- web_interface/app.py | 2 +- web_interface/templates/v3/base.html | 72 +++++++++++++------ .../templates/v3/partials/tools.html | 12 +++- 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/web_interface/app.py b/web_interface/app.py index b3a40f8a7..af795a465 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -528,7 +528,7 @@ def _get_power_status(): 'soft_temp_limit_occurred': bool(bits & 0x80000), } except (subprocess.SubprocessError, OSError, ValueError) as e: - app.logger.debug("vcgencmd get_throttled failed: %s", e) + app.logger.warning("vcgencmd get_throttled failed: %s", e) return None # System status generator for SSE diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index c9e7db7a2..450130660 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -960,7 +960,7 @@

- Under-voltage detected right now — the display may flicker. Check your power supply. + A power/thermal issue detected right now — the display may flicker or degrade. Check your power supply and cooling.

-
+
@@ -64,20 +64,6 @@

System Overview

- -
-
-
- -
-
-
-
Power Supply
-
--
-
-
-
-