From 9b2f02681d8e3bbb098c23d5ae593dc354598e29 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:35:55 -0400 Subject: [PATCH] feat(web-ui): detect and surface Raspberry Pi under-voltage/throttling (#383) * 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 * 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 * 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 * fix(web-ui): address second round of review findings on power monitoring - updatePowerStatus's falsy-power branch only hid power-stat, leaving power-warning-banner visible with stale text if _get_power_status() fails transiently. Now hides the banner too and resets the dismissed flag, same as the "not active" branch. - The Tools tab's status badge (and the pre-existing dirty/clean badge right next to it) build class names like bg-${color}-100/text-${color}-800 at runtime. This project hand-rolls its own Tailwind-named utility classes in app.css rather than running a real Tailwind build, and the light-mode base rules for bg-red-100/bg-yellow-100/bg-green-100/ text-red-800/text-yellow-800/text-green-800 were simply never defined -- only some had dark-mode overrides, which are no-ops without a base rule in light mode. Added the missing light-mode bases plus the two missing dark-mode overrides (bg-yellow-100/bg-green-100), fixing both badges. - The "occurred earlier" tooltip was a hardcoded string regardless of which flag(s) actually fired. Generalized _activePowerConditionLabels() to take a suffix ('_now' or '_occurred') and reused it for both tooltips. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * refactor(web-ui): move Power Supply status off the Overview tab The Overview tab's "Power Supply" stat card duplicated what the Tools tab's diagnostics section already shows (summary badge + full flag breakdown), so drop the card and its now-dead JS rather than keep two copies in sync. The header badge and warning banner (visible on every page) are unaffected -- only the Overview-tab card is removed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --------- Co-authored-by: Claude Sonnet 5 --- web_interface/app.py | 36 +++++- web_interface/static/v3/app.css | 33 ++++++ web_interface/templates/v3/base.html | 111 ++++++++++++++++++ .../templates/v3/partials/tools.html | 81 +++++++++++++ 4 files changed, 260 insertions(+), 1 deletion(-) diff --git a/web_interface/app.py b/web_interface/app.py index b1b3416a..af795a46 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -3,6 +3,7 @@ import json import logging import os import queue +import re import shutil import sys import subprocess @@ -27,6 +28,7 @@ from src.plugin_system.health_monitor import PluginHealthMonitor _JOURNALCTL = shutil.which('journalctl') _SYSTEMCTL = shutil.which('systemctl') +_VCGENCMD = shutil.which('vcgencmd') # Create Flask app app = Flask(__name__) @@ -498,6 +500,37 @@ class _StreamBroadcaster: 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.warning("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 8894b9de..7ed7574a 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -84,6 +84,8 @@ [data-theme="dark"] .hover\:text-gray-700:hover { color: #e5e7eb; } [data-theme="dark"] .hover\:border-gray-300:hover { border-color: #6b7280; } [data-theme="dark"] .bg-red-100 { background-color: #450a0a; } +[data-theme="dark"] .bg-yellow-100 { background-color: #422006; } +[data-theme="dark"] .bg-green-100 { background-color: #022c22; } [data-theme="dark"] .text-red-700 { color: #fca5a5; } [data-theme="dark"] .hover\:bg-red-200:hover { background-color: #7f1d1d; } @@ -137,6 +139,14 @@ body { .text-green-600 { color: #059669; } .text-red-600 { color: #dc2626; } +/* Status badge chips (e.g. tools.html's dirty/clean and power-status badges) */ +.bg-red-100 { background-color: #fee2e2; } +.bg-yellow-100 { background-color: #fef9c3; } +.bg-green-100 { background-color: #dcfce7; } +.text-red-800 { color: #991b1b; } +.text-yellow-800 { color: #854d0e; } +.text-green-800 { color: #166534; } + .border-gray-200 { border-color: #e5e7eb; } .border-gray-300 { border-color: #d1d5db; } .border-gray-700 { border-color: #374151; } @@ -1040,3 +1050,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 c69c75ba..c3f396dc 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,89 @@ 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; + }; + + // Labels for whichever flags from _get_power_status() are set (pass + // suffix='_occurred' for the "happened earlier" variant), used to + // build accurate banner/tooltip text instead of hardcoding + // "under-voltage" for what may actually be throttling/freq-capping/ + // thermal limiting. + function _activePowerConditionLabels(power, suffix) { + suffix = suffix || '_now'; + const labels = []; + if (power['under_voltage' + suffix]) labels.push('under-voltage'); + if (power['throttled' + suffix]) labels.push('throttling'); + if (power['freq_capped' + suffix]) labels.push('CPU frequency capped'); + if (power['soft_temp_limit' + suffix]) labels.push('soft thermal limit'); + return labels; + } + + function updatePowerStatus(power) { + const statEl = document.getElementById('power-stat'); + const banner = document.getElementById('power-warning-banner'); + const bannerText = document.getElementById('power-warning-banner-text'); + + if (!power) { + if (statEl) statEl.classList.add('hidden'); + if (banner) { + banner.style.display = 'none'; + // Let a future occurrence show the banner again rather + // than leaving stale text/visibility from before this + // (likely transient) missing-data tick. + window._powerWarningDismissed = false; + } + return; + } + + const activeNow = power.under_voltage_now || power.throttled_now || + power.freq_capped_now || power.soft_temp_limit_now; + const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred || + power.freq_capped_occurred || power.soft_temp_limit_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 = _activePowerConditionLabels(power).join('/') + + ' detected right now — check your power supply and cooling'; + } else if (occurredEarlier) { + statEl.classList.remove('hidden'); + statEl.classList.add('flex', 'text-yellow-600'); + const occurredLabels = _activePowerConditionLabels(power, '_occurred'); + statEl.title = (occurredLabels.length ? occurredLabels.join('/') : 'An issue') + + ' was detected earlier (currently OK)'; + } else { + statEl.classList.add('hidden'); + } + } + + if (banner) { + if (activeNow) { + if (bannerText) { + const labels = _activePowerConditionLabels(power); + bannerText.textContent = (labels.length ? labels.join('/') : 'A power/thermal issue') + + ' detected right now — the display may flicker or degrade. Check your power supply and cooling.'; + } + 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 +1575,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) { diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index 3e249988..7ac8cf87 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -112,6 +112,17 @@ + +
+
+

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,76 @@ }); } + // ── 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 || + power.freq_capped_now || power.soft_temp_limit_now; + const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred || + power.freq_capped_occurred || power.soft_temp_limit_occurred; + const summaryColor = activeNow ? 'red' : (occurredEarlier ? 'yellow' : 'green'); + // _activePowerConditionLabels is defined in base.html's inline script; + // both are classic (non-module) scripts sharing the global scope. + const activeLabels = (typeof _activePowerConditionLabels === 'function') + ? _activePowerConditionLabels(power) : []; + const summaryText = activeNow + ? `Actively ${activeLabels.length ? activeLabels.join('/') : '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(); })();