diff --git a/VERSION b/VERSION
index 6ae756c..f0a2883 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.9.9
+1.9.10
diff --git a/changelog.json b/changelog.json
index 3af5b08..3bf24ba 100644
--- a/changelog.json
+++ b/changelog.json
@@ -1,5 +1,20 @@
{
"versions": [
+ {
+ "version": "1.9.10",
+ "date": "2026-05-28",
+ "changes": {
+ "features": [
+ "Self Test: nouveau check 'Power Supply' qui lit 'vcgencmd get_throttled' et détecte la sous-tension du Pi. Passed = alim OK, Warning = sous-tension survenue depuis le boot, Failed = sous-tension active. Apparaît en tête des résultats et dans le rapport copiable. La sous-tension est une cause fréquente de capteurs USB instables, corruptions SD et reboots."
+ ],
+ "improvements": [],
+ "fixes": [],
+ "compatibility": [
+ "Backend: nouvel endpoint launcher.php?type=throttled + script power/get_throttled.py (lancé via sudo python3, déjà whitelisté dans sudoers — aucun changement /etc/sudoers requis)."
+ ]
+ },
+ "notes": "Complément de la v1.9.9 (retry sonde bruit): permet de diagnostiquer à distance la cause racine (alimentation 5V insuffisante / câble) depuis l'interface admin, sur n'importe quel boîtier de la flotte."
+ },
{
"version": "1.9.9",
"date": "2026-05-28",
diff --git a/html/assets/js/selftest.js b/html/assets/js/selftest.js
index e2709c7..f7f5bc2 100644
--- a/html/assets/js/selftest.js
+++ b/html/assets/js/selftest.js
@@ -74,6 +74,10 @@ function resetSelfTestUI() {
`;
// Reset test items
+ document.getElementById('test_power_status').className = 'badge bg-secondary';
+ document.getElementById('test_power_status').textContent = 'Pending';
+ document.getElementById('test_power_detail').textContent = 'Waiting...';
+
document.getElementById('test_wifi_status').className = 'badge bg-secondary';
document.getElementById('test_wifi_status').textContent = 'Pending';
document.getElementById('test_wifi_detail').textContent = 'Waiting...';
@@ -260,6 +264,59 @@ async function selfTestSequence() {
await delaySelfTest(300);
+ // ═══════════════════════════════════════════════════════
+ // SYSTEM TEST - Power supply (under-voltage detection)
+ // ═══════════════════════════════════════════════════════
+ addSelfTestLog('');
+ addSelfTestLog('────────────────────────────────────────────────────────');
+ addSelfTestLog('SYSTEM TEST');
+ addSelfTestLog('────────────────────────────────────────────────────────');
+
+ document.getElementById('selftest_status').innerHTML = `
+
+
+
Checking power supply...
+
`;
+
+ updateTestStatus('power', 'Testing...', 'Reading vcgencmd get_throttled...', 'bg-info');
+ addSelfTestLog('Checking power supply (under-voltage)...');
+
+ try {
+ const powerResult = await new Promise((resolve, reject) => {
+ $.ajax({
+ url: 'launcher.php?type=throttled',
+ dataType: 'json',
+ method: 'GET',
+ cache: false,
+ timeout: 10000,
+ success: function(data) { resolve(data); },
+ error: function(xhr, status, error) { reject(new Error(error || status)); }
+ });
+ });
+
+ selfTestReport.rawResponses['Power Supply'] = JSON.stringify(powerResult, null, 2);
+ addSelfTestLog(`Power response: ${JSON.stringify(powerResult)}`);
+
+ if (!powerResult.available) {
+ updateTestStatus('power', 'Warning', powerResult.error || 'vcgencmd indisponible', 'bg-warning');
+ testsFailed++;
+ } else if (powerResult.status === 'critical') {
+ updateTestStatus('power', 'Failed', powerResult.message, 'bg-danger');
+ testsFailed++;
+ } else if (powerResult.status === 'warning') {
+ updateTestStatus('power', 'Warning', powerResult.message, 'bg-warning');
+ testsFailed++;
+ } else {
+ updateTestStatus('power', 'Passed', powerResult.message || 'Alimentation OK', 'bg-success');
+ testsPassed++;
+ }
+ } catch (error) {
+ addSelfTestLog(`Power supply test error: ${error.message}`);
+ updateTestStatus('power', 'Failed', error.message, 'bg-danger');
+ selfTestReport.rawResponses['Power Supply'] = `ERROR: ${error.message}`;
+ testsFailed++;
+ }
+
// ═══════════════════════════════════════════════════════
// SENSOR TESTS - Test enabled sensors based on config
// ═══════════════════════════════════════════════════════
@@ -856,6 +913,7 @@ GPS Location: ${selfTestReport.latitude || 'N/A'}, ${selfTestReport.longitud
// Add test results (sensors first, then communication)
const testNames = {
+ power: 'Power Supply',
npm: 'NextPM (Particles)',
bme280: 'BME280 (Temp/Hum)',
noise: 'Noise Sensor',
diff --git a/html/launcher.php b/html/launcher.php
index 9967d87..7a7e20d 100755
--- a/html/launcher.php
+++ b/html/launcher.php
@@ -990,6 +990,13 @@ if ($type == "noise") {
echo $output;
}
+if ($type == "throttled") {
+ header('Content-Type: application/json');
+ $command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/power/get_throttled.py';
+ $output = shell_exec($command);
+ echo $output;
+}
+
if ($type == "BME280") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/BME280/read.py';
$output = shell_exec($command);
diff --git a/html/selftest-modal.html b/html/selftest-modal.html
index a96ea17..0b4bfd2 100644
--- a/html/selftest-modal.html
+++ b/html/selftest-modal.html
@@ -15,6 +15,15 @@
+
+
+
+
Power Supply
+
Waiting...
+
+
Pending
+
+
diff --git a/power/get_throttled.py b/power/get_throttled.py
new file mode 100644
index 0000000..416d70d
--- /dev/null
+++ b/power/get_throttled.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""
+python3 /var/www/nebuleair_pro_4g/power/get_throttled.py
+
+Lit l'état d'alimentation du Raspberry Pi via `vcgencmd get_throttled` et
+renvoie un JSON décodé. Utilisé par le Self Test (launcher.php?type=throttled)
+pour détecter une sous-tension (cause fréquente de capteurs USB instables,
+corruptions SD, reboots).
+
+Doit tourner en root (vcgencmd a besoin de /dev/vcio) : appelé via
+`sudo /usr/bin/python3 ...` depuis launcher.php.
+
+Bits de get_throttled (cf. doc Raspberry Pi) :
+ 0 : sous-tension active maintenant
+ 1 : freq ARM bridée maintenant
+ 2 : throttling actif maintenant
+ 3 : limite temperature douce active maintenant
+ 16 : sous-tension survenue depuis le boot
+ 17 : bridage freq ARM survenu depuis le boot
+ 18 : throttling survenu depuis le boot
+ 19 : limite temperature douce survenue depuis le boot
+"""
+
+import json
+import subprocess
+
+
+def main():
+ try:
+ raw = subprocess.check_output(
+ ['/usr/bin/vcgencmd', 'get_throttled'],
+ stderr=subprocess.STDOUT,
+ timeout=5,
+ ).decode('utf-8', errors='ignore').strip()
+ except FileNotFoundError:
+ print(json.dumps({"available": False, "error": "vcgencmd introuvable (pas un Raspberry Pi ?)"}))
+ return
+ except Exception as e:
+ print(json.dumps({"available": False, "error": str(e)}))
+ return
+
+ # Sortie attendue : "throttled=0x50000"
+ if '=' not in raw:
+ print(json.dumps({"available": False, "error": f"sortie inattendue: {raw}"}))
+ return
+
+ hex_str = raw.split('=', 1)[1].strip()
+ try:
+ value = int(hex_str, 16)
+ except ValueError:
+ print(json.dumps({"available": False, "error": f"valeur illisible: {raw}"}))
+ return
+
+ flags = {
+ "under_voltage_now": bool(value & 0x1),
+ "arm_freq_capped_now": bool(value & 0x2),
+ "throttled_now": bool(value & 0x4),
+ "soft_temp_limit_now": bool(value & 0x8),
+ "under_voltage_occurred": bool(value & 0x10000),
+ "arm_freq_capped_occurred": bool(value & 0x20000),
+ "throttling_occurred": bool(value & 0x40000),
+ "soft_temp_limit_occurred": bool(value & 0x80000),
+ }
+
+ # Niveau de gravite pour le Self Test
+ if flags["under_voltage_now"] or flags["throttled_now"]:
+ status = "critical"
+ message = "Sous-tension ACTIVE — alimentation 5V insuffisante (alim/cable a remplacer)"
+ elif flags["under_voltage_occurred"] or flags["throttling_occurred"]:
+ status = "warning"
+ message = "Sous-tension survenue depuis le demarrage — verifier alim 5V / cable USB"
+ else:
+ status = "ok"
+ message = "Alimentation OK"
+
+ result = {"available": True, "raw": hex_str, "value": value, "status": status, "message": message}
+ result.update(flags)
+ print(json.dumps(result))
+
+
+if __name__ == "__main__":
+ main()