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

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,76 @@

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 || + 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(); })();