101 Commits

Author SHA1 Message Date
PaulVua
cb98e38a3e Admin: ajout liens Gitea pour mise a jour hors-ligne
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:16:34 +01:00
PaulVua
4fe79ad112 Admin: bloquer update firmware en mode hotspot avec message explicatif
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:14:32 +01:00
PaulVua
b869ac3e9e v1.4.5 — Mise a jour changelog (reflete l'etat final)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:11:19 +01:00
PaulVua
c09fa3ca72 Fix forget_wifi scan: delai 5s + rescan explicite avant scan WiFi
Apres disconnect wlan0, l'interface a besoin de temps pour etre
prete a scanner. Ajout sleep 5 + nmcli wifi rescan + sleep 3
avant le scan. Log du contenu CSV pour debug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:08:59 +01:00
PaulVua
79d9be2c85 Fix: restaurer topbar-logo.js original (supprimer fetch config)
Le fetch get_config_sqlite dans topbar-logo.js au DOMContentLoaded
saturait les 6 connexions par domaine du navigateur.
Retour au topbar-logo.js v1.4.4 d'origine. Le badge hotspot est
maintenant gere dans le window.onload de wifi.html.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:57:08 +01:00
PaulVua
903dcce2d7 Fix: config.json -> get_config_sqlite dans wifi.html
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:54:44 +01:00
PaulVua
425a89de3f wifi.html: rebuild propre depuis v1.4.4 + nouvelles features
Repart du code v1.4.4 qui fonctionne (elementsToLoad, config.json,
window.onload) et ajoute proprement: bouton oublier reseau, cards
contextuelles, infos WiFi detaillees, scan ameliore avec cache notice.
Ne touche PAS au systeme de chargement d'origine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:52:52 +01:00
PaulVua
8f88eae575 Revert optimisations sidebar/fetch qui causaient des blocages navigateur
Retour a l'etat 408ab76. Les tentatives d'optimisation du nombre
de fetch (sidebar unique, config partagee, sequencement) causaient
des blocages sur Chrome/Firefox. On garde les features (forget wifi,
hotspot badge, UI wifi) mais on revient au chargement d'origine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:48:38 +01:00
PaulVua
ffead8597a Fix connexion slots: topbar-logo fetch config a window.onload
Deplace le fetch get_config_sqlite de DOMContentLoaded vers
window.onload dans topbar-logo.js. Les requetes sont maintenant
sequencees: DOMContentLoaded (sidebar+topbar+i18n) -> onload
(config) -> event (internet/scan). Max 3-4 requetes simultanees.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:46:16 +01:00
PaulVua
196176667f Fix: window.onload au lieu de event listener pour sequencer les requetes
window.onload attend que les ressources initiales soient chargees,
liberant les slots HTTP avant de lancer les AJAX (internet, scan).
Reutilise la config deja fetched par topbar-logo.js via window global.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:42:56 +01:00
PaulVua
87ddb76e39 Fix: retirer applyTranslations du MutationObserver (boucle infinie)
applyTranslations modifie le DOM -> declenche MutationObserver
-> re-appelle applyConfig + applyTranslations -> boucle infinie.
Le re-apply reste dans le callback fetch sidebar de chaque page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:41:15 +01:00
PaulVua
dbe6c71d33 Fix: re-appliquer i18n apres chargement dynamique sidebar/topbar
Les textes data-i18n de la sidebar etaient vides car les traductions
s'appliquaient avant que la sidebar soit chargee via fetch.
topbar-logo.js re-applique maintenant les traductions via son
MutationObserver, ce qui corrige le probleme sur toutes les pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:40:11 +01:00
PaulVua
537abb682e Fix syntax error: accolade en trop dans wifi.html
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:37:54 +01:00
PaulVua
8d74e3e678 Reduce fetch count: config partagee + suppression doublons internet
- topbar-logo.js expose la config via event 'nebuleair-config-ready'
- wifi.html ecoute l'event au lieu de re-fetcher get_config_sqlite
- Supprime le doublon load_ethernet_info (get_internet fait deja tout)
- Passe de ~9 requetes simultanees a ~5 au chargement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:34:58 +01:00
PaulVua
5849190220 Fix: sidebar.html charge une seule fois au lieu de deux sur toutes les pages
Reduit de 3 a 2 les fetch au DOMContentLoaded, liberant un slot
de connexion HTTP. Corrige le blocage "pending" cause par la limite
de 6 connexions simultanees par domaine dans Chrome/Firefox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:32:22 +01:00
PaulVua
408ab767e1 Revert: retrait nmcli de get_config_sqlite (cause lenteur pages)
L'appel nmcli dans get_config_sqlite bloquait les workers Apache.
Le statut WiFi est maintenant gere uniquement par les scripts shell
(connexion.sh, forget_wifi.sh, boot_hotspot.sh).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:15:02 +01:00
PaulVua
2949c78b56 Fix: timeout 2s sur nmcli dans get_config_sqlite pour eviter blocage
Si nmcli est lent ou bloque, on garde la valeur DB au lieu de
freezer toutes les pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:12:15 +01:00
PaulVua
c83f8396aa WiFi status: detection live wlan0 au lieu de se fier a la DB
Le WIFI_status en DB peut etre desynchronise (ex: reconnexion
manuelle via SSH). Maintenant get_config_sqlite detecte le vrai
etat de wlan0 via nmcli et corrige la DB si necessaire.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:09:39 +01:00
PaulVua
ecd59e537e forget_wifi: scan WiFi avant hotspot pour remplir wifi_list.csv
Sans ce scan, le CSV est vide/perime et la page WiFi en hotspot
ne peut pas afficher les reseaux disponibles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:05:40 +01:00
PaulVua
83d854b596 Hotspot: scan WiFi depuis cache CSV + timeout scan live + auto-load
En mode hotspot, le scan live est impossible (wlan0 occupée).
Utilise wifi_list.csv (scan au boot) avec notice explicative.
Ajout timeout 10s sur le scan live pour eviter blocage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:54:44 +01:00
PaulVua
a0f8b4b8eb Fix hotspot IP: 192.168.43.1 -> 10.42.0.1 (NetworkManager default)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:50:28 +01:00
PaulVua
8d0507852a Fix forget WiFi: appel bash explicite + disconnect wlan0 avant delete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:34:30 +01:00
PaulVua
6e17f39a2c v1.4.5 — Page WiFi: oublier réseau + badge hotspot sidebar + refonte UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:28:46 +01:00
PaulVua
5a2b3bb19d v1.4.4 — Self-test partagé sur Accueil/Capteurs/Admin + test RTC DS3231
Extraction du code self-test dans des fichiers partagés (selftest.js +
selftest-modal.html) pour éviter la duplication. Ajout du bouton Run
Self Test sur les pages index, sensors et admin. Nouveau test RTC qui
vérifie la connexion du module DS3231 et la synchronisation horloge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:56:11 +01:00
PaulVua
5bffec10a1 v1.4.3 — Payload UDP bruit cur_leq + cur_level, améliorations page database
- UDP bytes 22-23: noise_cur_leq, 24-25: noise_cur_level, 26-27: max_noise (réservé)
- Page database: validation dates obligatoire + bouton télécharger toute la table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:03:08 +01:00
PaulVua
e0e8a4cefe Database: validation dates + bouton télécharger toute la table
- Empêche le téléchargement par dates si début/fin non renseignées
- Ajoute une carte "Télécharger toute la table" (bypass dates)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:19:02 +01:00
paul_vua
d5b2e9c6c3 v1.4.2 — Fix bug AT+USOWR leak dans payload UDP Miotiq
Corrige une desynchronisation serie qui causait l'envoi de la commande
AT+USOWR comme donnees UDP au lieu du payload capteurs. Ajout de flush
buffer serie, verification du prompt @, et abort propre a chaque etape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:53:59 +01:00
PaulVua
7ab06f3413 v1.4.1 — Migration capteur bruit I2C vers NSRT MK4 USB
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:04:17 +01:00
PaulVua
794b86fb9b Fix self-test bruit: parser le JSON du NSRT MK4 au lieu de texte brut
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:02:28 +01:00
PaulVua
7479344df7 Mise à jour capteur bruit: ancien I2C → NSRT MK4 USB
- Nouveau script sound_meter/read.py pour lecture à la demande (JSON)
- launcher.php: appel du script Python au lieu de l'ancien binaire C
- sensors.html: carte USB, suppression boutons start/stop, affichage JSON
- Traductions fr/en: I2C → USB, NSRT MK4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:23:54 +01:00
PaulVua
98b5b43190 v1.4.0 — Mise à jour firmware hors-ligne par upload ZIP
Nouvelle fonctionnalité permettant de mettre à jour le firmware sans
connexion internet, via upload d'un fichier .zip depuis l'interface admin.

Fichiers ajoutés:
- update_firmware_from_file.sh (rsync + exclusions + chown + restart services)
- .update-exclude (liste d'exclusions évolutive, versionnée)
- html/.htaccess (limite upload PHP 50MB)

Fichiers modifiés:
- html/launcher.php (handler upload_firmware)
- html/admin.html (UI upload + barre de progression)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:30:51 +01:00
PaulVua
1298e79688 Fix: Vider le buffer série avant chaque commande AT dans sara.py
Évite de lire des URCs résiduelles (ex: +USECMNG, AT+USECPRF) émises
par le modem SARA pendant son initialisation au démarrage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:12:53 +01:00
PaulVua
7a7d1c0c3f Ajout bouton Get Data (IMSI) sur la page modem 4G
- Sépare le bouton SIM en deux : Get Data (ICCID) et Get Data (IMSI)
- Ajout fonction getImsiInfo() avec commande AT+CIMI
- Parse la réponse pour extraire le numéro IMSI (15 chiffres)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:44:53 +01:00
PaulVua
7c30ccd8f7 Amélioration récap installation: IMSI, lien cliquable, suppression eth0
- Capture l'IMSI dans une variable pour l'afficher dans le récap
- Affiche l'URL complète http://<ip>/html/admin.html (cliquable dans le terminal)
- Supprime la ligne eth0 (pas de connexion ethernet)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:42:46 +01:00
PaulVua
bc2aec7946 Ajout lecture IMSI (AT+CIMI) dans installation_part2.sh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:05:12 +01:00
PaulVua
6b8d0c18c9 Fix: Rendre les commandes SARA non-bloquantes dans installation_part2.sh
Les appels sara.py (ATI, LED, CCID) affichent désormais un warning
au lieu de faire échouer le script si le module SARA n'est pas détecté.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 11:12:07 +01:00
PaulVua
b65e9571dc Fix: Keep HDMI enabled in installation script (needed for screen control) 2026-02-17 17:36:35 +01:00
PaulVua
e8cef5b593 add visdo auth for pkill 2026-02-17 17:20:07 +01:00
PaulVua
36d4bac0a5 Fix: Revert to using sudo in launcher.php to support visudo configuration 2026-02-17 17:14:20 +01:00
PaulVua
a208540093 Fix: Remove sudo usage in launcher.php to avoid password prompt; enable logging 2026-02-17 12:54:40 +01:00
PaulVua
02687f6d74 Docs: Update changelog for v1.3.0 (Screen Control Feature) 2026-02-17 12:53:39 +01:00
PaulVua
8c55798e34 Fix UI: Add button IDs and fix status message selector in screen.html; Capture stderr in launcher.php 2026-02-17 12:51:39 +01:00
PaulVua
cf502abfef Fix: Broaden pkill pattern to match 'screen.py' for stop command 2026-02-17 12:46:39 +01:00
PaulVua
e659696044 Fix: Run screen script with sudo to ensure access to video device 2026-02-17 12:43:28 +01:00
PaulVua
d086a440dd Docs: Update screen.py comments with correct paths (by user) 2026-02-17 12:41:00 +01:00
PaulVua
86c2d1eb41 Fix: Correct screen.py path in launcher.php for production environment 2026-02-17 12:33:41 +01:00
PaulVua
aa1b90e3d5 Fix: Apply translations after sidebar load to resolve 'sidebar.screen' display issue 2026-02-17 12:30:14 +01:00
PaulVua
f1d716d900 Docs: Add usage instructions to screen.py 2026-02-17 12:28:58 +01:00
PaulVua
248732bac9 Refine Screen tab: Move above Sensors, add translations 2026-02-17 12:27:39 +01:00
PaulVua
7b0fb0650a Fix Screen tab visibility: Use class selector to handle multiple sidebar instances 2026-02-17 12:23:19 +01:00
PaulVua
3e5ee9c77e Add Screen control features: Screen tab in sidebar, Kivy script, and backend logic 2026-02-17 12:19:05 +01:00
PaulVua
8106af624f fix(mhz19): gestion d'erreurs JSON pour le capteur CO2
Le script get_data.py retourne maintenant toujours du JSON, meme en cas
d'erreur (port serie, absence de donnees). Cote web, les erreurs sont
affichees proprement dans la carte capteur.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:08:36 +01:00
PaulVua
30bc04b89e release: v1.2.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:05:31 +01:00
PaulVua
198836fa13 feat: intégration capteur CO2 MH-Z19
- Scripts MH-Z19/get_data.py (lecture standalone) et write_data.py (écriture SQLite)
- Table data_MHZ19, config MHZ19, cleanup et service systemd (120s)
- Web UI : carte test sensors, checkbox admin, boutons database + CSV download
- SARA_send_data_v2.py non modifié (sera fait dans un second temps)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:04:45 +01:00
PaulVua
ea2642685c fix(ui): logo dynamique ne s'affichait pas (innerHTML n'exécute pas les scripts)
Le script dans topbar.html ne s'exécutait pas car innerHTML ignore les
balises <script>. Déplacé la logique dans un fichier JS séparé
(topbar-logo.js) avec MutationObserver pour détecter l'insertion du topbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:43:38 +01:00
PaulVua
f8f5300b9b fix: add missing ModuleAir Pro logo image
Le logo ModuleAir n'était pas suivi par git, empêchant
l'affichage sur les capteurs configurés en ModuleAir Pro.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:39:54 +01:00
PaulVua
b88d2bc1d9 release: v1.1.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:21:12 +01:00
PaulVua
88680f07b0 feat(ui): add full table CSV download on database stats card
Each table row in the stats card now has a download button that exports
the entire table as CSV with proper column headers, generated server-side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:18:46 +01:00
PaulVua
20c6a12251 feat(ui): add database stats card on database page
Show table info (entry count, oldest/newest dates, total DB size) in a
new card on the database page, with auto-refresh and i18n support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:07:58 +01:00
PaulVua
e20bb0b8fc feat(ui): switch logo dynamically based on device_type
Topbar logo now loads from config: NebuleAir Pro or ModuleAir Pro.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:24:47 +01:00
PaulVua
50a8cdd938 feat: add firmware versioning and device_type support (NebuleAir/ModuleAir)
- Add VERSION file (1.0.0) and changelog.json for firmware tracking
- Add device_type config param (nebuleair_pro default, backward compatible via INSERT OR IGNORE)
- Add device_type select in admin.html Protected Settings
- Add version badge and changelog modal in Updates section
- Add get_firmware_version and get_changelog PHP endpoints
- Display firmware version in update_firmware.sh after git pull

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:12:18 +01:00
PaulVua
dc1739e033 feat(ui): reorder self-test to run sensor tests before communication
Sensor tests (NPM, BME280, Noise, Envea) now run first, followed by
communication tests (WiFi, Modem, SIM, Signal, Network) with a
visual separator between the two sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 17:05:35 +01:00
PaulVua
544eebd715 feat(ui): add NextPM firmware version button on sensors page
Add a "Firmware Version" button next to "Get Data" in the NextPM card
that calls firmware_version.py and displays the result as a badge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 17:05:10 +01:00
PaulVua
6bdaef8c24 feat(ui): add sensor tests to modem self-test
Add dynamic sensor testing (NPM, BME280, Noise, Envea) to the self-test
based on enabled sensors in config. Results are included in the diagnostic report.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 16:58:45 +01:00
PaulVua
98cb1ea517 feat(ui): replace copy with share modal and download option
- Add new "Share Report" modal with readable textarea
- Add instructions to send logs to contact@aircarto.fr
- Add "Download (.txt)" button to save report as file
- Add "Select All" button for easy manual copy
- Remove complex clipboard API code that wasn't working
- Filename format: logs_nebuleair_{deviceId}_{date}.txt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 11:16:05 +01:00
PaulVua
4ed185de0c fix(ui): fix copy report and add more device info
- Add system info collection at start of test (device ID, name, RTC time, GPS)
- Display device info in logs header
- Fix clipboard copy with fallback for non-HTTPS contexts
- Use execCommand fallback for older browsers
- Use ASCII-safe characters for better compatibility
- Add error handling with manual copy fallback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 11:09:25 +01:00
PaulVua
3d61ce22d3 feat(ui): add copy report functionality to self-test
Add "Copy Report" button that generates a formatted diagnostic report:
- Device info (ID, modem version, timestamp)
- Test results summary with status icons
- Raw AT command responses for debugging
- Detailed execution logs
- Nicely formatted for sharing with manufacturer support

Enhanced logging with raw AT responses displayed in monospace format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 11:03:37 +01:00
PaulVua
3a6b529cba feat(ui): add WiFi/network info to self-test
Add informational network status check at the beginning of self-test:
- Shows connection mode (Hotspot, WiFi, or Ethernet)
- Displays SSID/connection name
- Shows IP address and hostname.local
- Add wifi_status endpoint in launcher.php using nmcli

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 10:39:02 +01:00
PaulVua
3c8558ea1d feat(ui): add signal and network tests to self-test
Add two more tests to the modem self-test:
- Test 3: Signal Strength (AT+CSQ) with quality thresholds
- Test 4: Network Connection (AT+COPS?) with operator name lookup
Respects 1 second delay between each AT command.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 10:33:51 +01:00
PaulVua
49a4623d85 feat(ui): add modem self-test functionality
Add "Run Self Test" button that opens a modal and runs diagnostic tests:
- Automatically enables modem_config_mode before tests
- Test 1: Modem connection (ATI command)
- Test 2: SIM card detection (AT+CCID? command)
- Respects delays between AT commands
- Always disables modem_config_mode after tests (even on failure)
- Shows real-time progress and detailed logs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 10:28:59 +01:00
PaulVua
d3d72410c1 fix(ui): align signal thresholds with Python implementation
Use correct thresholds: 0=Very poor, 1-24=Poor, 25-26=Good,
27-28=Very good, 29-30=Excellent, 31=Very Strong, 99=No signal.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 10:17:33 +01:00
PaulVua
fcfbe4f2d4 feat(ui): improve signal strength display with visual bars
Add colored signal bars (1-5) based on signal power level.
Show signal quality description, RSSI in dBm, and quality index.
Color coding: red (poor) -> orange -> yellow -> green -> blue (excellent).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 10:15:11 +01:00
PaulVua
53e7c77322 feat(ui): improve network connection display with operator lookup
Add operators.json with MCC/MNC codes for common operators.
Parse AT+COPS? response to show operator name, country, technology,
and connection mode in a user-friendly format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 10:10:42 +01:00
PaulVua
20ba897cde feat(ui): improve SIM card info display and switch to English
Add user-friendly alert for SIM card status with ICCID number.
Change all modem/SIM status messages to English.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 10:04:22 +01:00
PaulVua
15e43513f4 feat(ui): improve modem info display with user-friendly alerts
Replace raw AT command output with Bootstrap alerts showing modem
connection status and model. Add collapsible section for raw logs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 10:00:43 +01:00
PaulVua
a5717df182 add more infos at instalation 2026-02-09 11:14:23 +01:00
PaulVua
8aaed1b93f add more infos at instalation 2026-02-09 11:04:10 +01:00
PaulVua
1fa7a2d695 fix(noise): use correct variable for payload CSV assignment
Replace undefined DB_A_value with cur_level which holds the same value from last_row[2].

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:31:10 +01:00
PaulVua
80bc16fb26 fix(envea): add retry logic for sensor read failures
- Retry up to 3 times if no valid frame is received
- Reduced wait time per attempt to 0.8s (total max ~3s with retries)
- Small delay between retries (0.2s)
- Only logs command on first attempt to reduce noise

This should eliminate the occasional 0 values caused by timing issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 17:35:16 +01:00
PaulVua
042b2efa93 fix(envea): increase sensor response wait time to 1.5s
Some readings were returning 0 because the sensor hadn't fully
responded within 1 second. Increased to 1.5s for more reliable reads.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 17:30:44 +01:00
PaulVua
80fcd8bf37 fix(envea): improve serial read timing and frame detection
- Increase wait time to 1 second for complete sensor response
- Read all available bytes from buffer instead of fixed 32
- Search for frame header (FF 02) anywhere in response data
  (handles command echo or garbage before actual frame)
- Extract frame from header position for correct byte alignment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 17:24:51 +01:00
PaulVua
b60f044105 fix(envea): remove double read that consumed sensor response
The CAIRSENS sensor sends response in a single block, not two parts.
The initial read was consuming all 25 bytes leaving nothing for the
actual data read.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 17:22:45 +01:00
PaulVua
eeaaeca4a7 fix(envea): correct serial read to prevent spurious spikes
- Replace readline() with read(32) to avoid truncation at 0x0A bytes
- Add reset_input_buffer() to clear stale data before each read
- Add initial read to consume echo/acknowledgment from sensor
- Add frame header validation (0xFF 0x02) to reject invalid data
- Add delays to allow sensor response time

Fixes issue where NO2/H2S sensors showed random spikes due to
binary data containing newline characters being misinterpreted.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 17:20:04 +01:00
PaulVua
91a4e7c841 wifi hotspot improvements 2026-01-15 16:45:47 +01:00
PaulVua
8291475e36 add cpu power mode 2026-01-15 14:13:41 +01:00
PaulVua
994bbf7a8d improve wifi reconect 2026-01-15 13:44:52 +01:00
PaulVua
e5770b09dc improve pdp reconnect 2026-01-15 11:21:30 +01:00
PaulVua
79d7f61e4a improve pdp reconnect 2026-01-15 11:19:56 +01:00
PaulVua
d593449171 improve pdp reconnect 2026-01-15 10:39:08 +01:00
PaulVua
c571bbd408 improve pdp reconnect 2026-01-15 10:35:46 +01:00
PaulVua
5742cc7e49 reboot when udp send error 2026-01-15 10:16:29 +01:00
PaulVua
4c552e4a31 more logs 2026-01-14 14:43:00 +01:00
PaulVua
5777b35770 Add WiFi and HDMI power saving features for remote sensors
Implements power saving optimizations to extend battery life on solar-powered remote air quality sensors:

- WiFi Power Saving: Disable WiFi 10 minutes after boot to save ~100-200mA
  - Configurable via web UI checkbox in admin panel
  - WiFi automatically re-enables after reboot for 10-minute configuration window
  - Systemd timer (nebuleair-wifi-powersave.timer) manages automatic disable
  - New wifi/power_save.py script checks database config and disables WiFi via nmcli

- HDMI Disable: Added hdmi_blanking=2 to boot config to save ~20-30mA
  - Automatically configured during installation

- Database: Added wifi_power_saving boolean config (default: disabled)
  - Uses INSERT OR IGNORE for safe updates to existing installations

- UI: Added checkbox control in admin.html for WiFi power saving
  - Includes helpful description of power savings and behavior

- Services: Updated setup_services.sh and update_firmware.sh to manage new timer

Total power savings: ~120-230mA when WiFi power saving enabled

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-13 12:05:21 +01:00
PaulVua
13445d574c Fix device name not showing in page title and sidebar
Fixed race condition issues where device name wasn't displaying properly:

Page title fixes:
- Added document.title update to sensors.html (was missing)
- wifi.html already had it but improved reliability

Sidebar device name fixes:
- Created updateSidebarDeviceName() function with retry logic
- Attempts update immediately, then at 100ms and 500ms delays
- Handles async sidebar loading timing issues
- Added console logging for debugging
- Both sensors.html and wifi.html now reliably show device name

This ensures the device ID/name always appears in:
1. Browser tab title (e.g., "NebuleAir_001")
2. Sidebar footer (bottom of navigation)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 15:27:37 +01:00
PaulVua
10f84f0c1b Fix most recent row highlighting by increasing CSS specificity
The light green background for the most recent data row wasn't displaying because Bootstrap's table striping was overriding it.

Changed CSS from targeting the row to targeting individual cells:
- .table .most-recent-row td
- .table-striped .most-recent-row td

Both with !important to override Bootstrap's table styles. Now the first row correctly displays with light green background.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 15:00:49 +01:00
PaulVua
4f1b140a75 Replace individual Envea sensor cards with single debug card
Modified the sensors page to display a unified debug view for all Envea gas sensors:

Backend changes:
- Added new 'envea_debug' endpoint in launcher.php
- Calls: /usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_value_v2.py -d
- Returns raw debug output without parsing

Frontend changes:
- Replaced individual sensor cards with single combined card
- Card displays if any gas sensor is connected
- Shows list of connected sensors (NO2, H2S, NH3, etc.)
- New getENVEA_debug_values() function fetches debug data
- Raw output displayed in scrollable <pre> block
- No JSON parsing, no table formatting - just raw debug text
- Card width set to col-sm-6 for better visibility

This makes it easier to check if all sensors are working correctly by viewing the raw output.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 14:56:08 +01:00
PaulVua
a3b2bef5c1 Improve database page layout and highlight most recent data
Layout improvements:
- Reorganized cards to fit 3 across on large screens (col-lg-4)
- View database, download data, and danger zone now on same row
- Added h-100 class to cards for equal height
- Made delete button larger and full-width for better visibility

Button spacing:
- Added mb-2 (margin-bottom) to all measurement trigger buttons
- Improved vertical spacing for better readability

Data visualization:
- Added light green background (#d4edda) to first table row
- First row now highlights the most recent data entry
- Makes it easy to see the latest sensor reading at a glance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 14:48:34 +01:00
PaulVua
f679732591 Fix i18n translations not applying properly on database page
Fixed race condition where translations weren't being applied to the database page when French language was selected. The issue was caused by the i18n system loading asynchronously while page content was being rendered.

Changes:
- Added applyTranslationsWhenReady() function that waits for translations to load
- Re-apply translations after dynamic content (sidebar/topbar) is loaded
- Added languageChanged event listener to re-apply translations on language switch
- Ensures all text appears in the selected language (French/English)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 14:42:33 +01:00
PaulVua
857d590b8f Order database table display by insertion order (ROWID) instead of timestamp
Changed the data retrieval query to use ROWID DESC instead of timestamp DESC, ensuring that the most recently inserted data appears first regardless of timestamp field values. This fixes the issue where entries with "not connected" or invalid timestamps would appear in wrong order.

Benefits:
- Most recent database entries always shown at top
- Works correctly even when timestamp is null, "not connected", or incorrect
- Based on actual insertion order rather than timestamp field

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 14:38:22 +01:00
PaulVua
141dd68716 Add database cleanup feature to empty all sensor tables
Added a "Danger Zone" section on the database page that allows users to empty all sensor data tables while preserving configuration and timestamp tables. The feature includes:

- New Python script (sqlite/empty_sensor_tables.py) to safely empty sensor tables
- Backend endpoint in launcher.php (empty_sensor_tables)
- Frontend UI with red warning card and confirmation dialog
- Detailed feedback showing deleted record counts per table
- i18n support for French and English

Tables emptied: data_NPM, data_NPM_5channels, data_BME280, data_envea, data_WIND, data_MPPT, data_NOISE, modem_status
Tables preserved: timestamp_table, config_table, envea_sondes_table, config_scripts_table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 14:33:21 +01:00
PaulVua
79a9217307 Add i18n translations for page content (Home, Database, Logs)
Extended internationalization support by adding translation keys and data-i18n attributes to page content. Previously only the sidebar was translated - now the main content of Home, Database, and Logs pages will switch between French and English when the user changes the language selector.

Translation keys added:
- Home page: title, welcome message, PM measures, Linux stats, disk/memory usage
- Database page: all UI labels, buttons, and dropdown options
- Logs page: title, description, log types, and action buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 16:42:43 +01:00
51 changed files with 6549 additions and 1128 deletions

26
.update-exclude Normal file
View File

@@ -0,0 +1,26 @@
# NebuleAir Pro 4G - Fichiers exclus lors de la mise à jour par upload
# Ce fichier est versionné dans le repo et voyage avec chaque release.
# Quand on ajoute un nouveau capteur avec du cache local, mettre à jour cette liste.
# Base de données (données capteur, config locale)
sqlite/sensors.db
sqlite/*.db-journal
sqlite/*.db-wal
# Logs applicatifs
logs/
# Historique git (pour que git pull fonctionne toujours après)
.git/
# Fichiers de configuration locale
config.json
deviceID.txt
wifi_list.csv
# Données capteurs en cache
envea/data/
NPM/data/
# Verrous
*.lock

53
MH-Z19/get_data.py Normal file
View File

@@ -0,0 +1,53 @@
'''
Script to get CO2 values from MH-Z19 sensor
need parameter: CO2_port
/usr/bin/python3 /var/www/nebuleair_pro_4g/MH-Z19/get_data.py ttyAMA4
'''
import serial
import json
import sys
import time
parameter = sys.argv[1:]
port = '/dev/' + parameter[0]
def read_co2():
try:
ser = serial.Serial(
port=port,
baudrate=9600,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1
)
except serial.SerialException as e:
print(json.dumps({"error": f"Serial port error: {e}"}))
return
READ_CO2_COMMAND = b'\xFF\x01\x86\x00\x00\x00\x00\x00\x79'
try:
ser.write(READ_CO2_COMMAND)
time.sleep(2)
response = ser.read(9)
if len(response) < 9:
print(json.dumps({"error": "No data or incomplete data received from sensor"}))
return
if response[0] == 0xFF:
co2_concentration = response[2] * 256 + response[3]
print(json.dumps({"CO2": co2_concentration}))
else:
print(json.dumps({"error": "Invalid response from sensor"}))
except Exception as e:
print(json.dumps({"error": str(e)}))
finally:
ser.close()
if __name__ == '__main__':
read_co2()

66
MH-Z19/write_data.py Normal file
View File

@@ -0,0 +1,66 @@
'''
Script to get CO2 values from MH-Z19 sensor and write to database
/usr/bin/python3 /var/www/nebuleair_pro_4g/MH-Z19/write_data.py
'''
import serial
import json
import sys
import time
import sqlite3
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
mh_z19_port = "/dev/ttyAMA4"
ser = serial.Serial(
port=mh_z19_port,
baudrate=9600,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1
)
READ_CO2_COMMAND = b'\xFF\x01\x86\x00\x00\x00\x00\x00\x79'
def read_co2():
ser.write(READ_CO2_COMMAND)
time.sleep(2)
response = ser.read(9)
if len(response) < 9:
print("Error: No data or incomplete data received from CO2 sensor.")
return None
if response[0] == 0xFF:
co2_concentration = response[2] * 256 + response[3]
return co2_concentration
else:
print("Error reading data from CO2 sensor.")
return None
def main():
try:
co2 = read_co2()
if co2 is not None:
# Get RTC time from SQLite
cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone()
rtc_time_str = row[1]
# Save to SQLite
cursor.execute('INSERT INTO data_MHZ19 (timestamp, CO2) VALUES (?, ?)', (rtc_time_str, co2))
conn.commit()
print(f"CO2: {co2} ppm (saved at {rtc_time_str})")
else:
print("Failed to get CO2 data.")
except KeyboardInterrupt:
print("Program terminated.")
finally:
ser.close()
conn.close()
if __name__ == '__main__':
main()

View File

@@ -60,6 +60,7 @@ www-data ALL=(ALL) NOPASSWD: /usr/bin/git pull
www-data ALL=(ALL) NOPASSWD: /usr/bin/ssh www-data ALL=(ALL) NOPASSWD: /usr/bin/ssh
www-data ALL=(ALL) NOPASSWD: /usr/bin/python3 * www-data ALL=(ALL) NOPASSWD: /usr/bin/python3 *
www-data ALL=(ALL) NOPASSWD: /bin/systemctl * www-data ALL=(ALL) NOPASSWD: /bin/systemctl *
www-data ALL=(ALL) NOPASSWD: /usr/bin/pkill
www-data ALL=(ALL) NOPASSWD: /var/www/nebuleair_pro_4g/* www-data ALL=(ALL) NOPASSWD: /var/www/nebuleair_pro_4g/*
``` ```
## Serial ## Serial

View File

@@ -46,6 +46,9 @@ try:
timeout = timeout timeout = timeout
) )
# Flush any leftover data from previous commands or modem boot URCs
ser.reset_input_buffer()
ser.write((command + '\r').encode('utf-8')) ser.write((command + '\r').encode('utf-8'))
#ser.write(b'ATI\r') #General Information #ser.write(b'ATI\r') #General Information

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.4.5

View File

@@ -62,6 +62,21 @@ SSH_TUNNEL_PORT=$(sqlite3 /var/www/nebuleair_pro_4g/sqlite/sensors.db "SELECT va
#need to wait for the network manager to be ready #need to wait for the network manager to be ready
sleep 20 sleep 20
# IMPORTANT: Always enable WiFi radio at boot (in case it was disabled by power save)
WIFI_RADIO_STATE=$(nmcli radio wifi)
echo "WiFi radio state: $WIFI_RADIO_STATE"
if [ "$WIFI_RADIO_STATE" == "disabled" ]; then
echo "WiFi radio is disabled, enabling it..."
nmcli radio wifi on
# Wait longer for NetworkManager to scan and reconnect to known networks
echo "Waiting 15 seconds for WiFi to reconnect to known networks..."
sleep 15
else
echo "WiFi radio is already enabled"
fi
# Get the connection state of wlan0 # Get the connection state of wlan0
STATE=$(nmcli -g GENERAL.STATE device show wlan0) STATE=$(nmcli -g GENERAL.STATE device show wlan0)

202
changelog.json Normal file
View File

@@ -0,0 +1,202 @@
{
"versions": [
{
"version": "1.4.5",
"date": "2026-03-17",
"changes": {
"features": [
"Page WiFi: bouton Oublier le reseau pour passer en mode hotspot sans reboot",
"Page WiFi: badge Mode Hotspot visible dans la sidebar (lien vers page WiFi)",
"Page WiFi: scan des reseaux WiFi en mode hotspot via cache CSV (scan au demarrage)"
],
"improvements": [
"Page WiFi: refonte UI avec cards contextuelles (infos connexion detaillees si connecte, scan si hotspot)",
"Page WiFi: affichage SSID, signal, IP, passerelle, hostname, frequence, securite",
"Page WiFi: scan WiFi masque quand deja connecte, scan avec colonnes signal et securite",
"Page WiFi: migration de config.json vers get_config_sqlite",
"Endpoint internet enrichi: SSID, signal, frequence, securite, passerelle, hostname",
"Scan WiFi en mode hotspot: lecture du fichier wifi_list.csv avec notice explicative",
"forget_wifi.sh: scan WiFi avec rescan explicite et delai avant lancement hotspot"
],
"fixes": [
"Correction VERSION 1.4.3 -> 1.4.4",
"Fix IP hotspot: 192.168.43.1 -> 10.42.0.1 (defaut NetworkManager)",
"Fix forget_wifi.sh: appel bash explicite + disconnect wlan0 avant delete"
],
"compatibility": []
},
"notes": "Le bouton Oublier le reseau supprime la connexion WiFi sauvegardee, scanne les reseaux disponibles, puis demarre le hotspot (pas de reboot necessaire). En mode hotspot, la page WiFi affiche les reseaux scannes au demarrage via un cache CSV. Adresse hotspot: http://10.42.0.1/html/"
},
{
"version": "1.4.4",
"date": "2026-03-16",
"changes": {
"features": [
"Bouton Self Test disponible sur les pages Accueil, Capteurs et Admin (en plus de Modem 4G)",
"Test du module RTC DS3231 integre dans le self-test (connexion + synchronisation horloge)"
],
"improvements": [
"Refactoring self-test : code JS et HTML des modals extraits dans des fichiers partages (selftest.js, selftest-modal.html)",
"Le modal self-test est charge dynamiquement via fetch, plus besoin de dupliquer le HTML"
],
"fixes": [],
"compatibility": []
},
"notes": "Le self-test est maintenant accessible depuis toutes les pages principales. Le test RTC verifie la connexion du module et l'ecart avec l'heure systeme."
},
{
"version": "1.4.3",
"date": "2026-03-16",
"changes": {
"features": [
"Page database: bouton telecharger toute la table (bypass filtre dates)",
"Page database: validation obligatoire des dates avant telechargement par periode"
],
"improvements": [
"Payload UDP bruit: bytes 22-23 = noise_cur_leq, 24-25 = noise_cur_level, 26-27 = max_noise (reserve)",
"Envoi des deux valeurs bruit (cur_LEQ + DB_A_value) en UDP Miotiq au lieu d'une seule"
],
"fixes": [],
"compatibility": [
"Necessite mise a jour du parser Miotiq pour decoder les nouveaux champs noise_cur_leq et noise_cur_level"
]
},
"notes": "Mise a jour structure UDP bruit pour alignement avec parser Miotiq et ameliorations page database."
},
{
"version": "1.4.2",
"date": "2026-03-14",
"changes": {
"features": [],
"improvements": [],
"fixes": [
"Fix envoi UDP Miotiq: desynchronisation serie causant l'envoi de la commande AT+USOWR comme payload au lieu des donnees capteurs",
"Ajout flush buffer serie (reset_input_buffer) avant chaque etape UDP critique",
"Verification du prompt @ du modem avant envoi des donnees binaires",
"Abort propre de l'envoi UDP si creation socket, connexion ou prompt @ echoue",
"Retry creation socket apres reset PDP reussi"
],
"compatibility": []
},
"notes": "Corrige un bug ou le modem SARA envoyait la commande AT+USOWR comme donnees UDP, causant des erreurs UNKNOWN_DEVICE sur le parser Miotiq."
},
{
"version": "1.4.1",
"date": "2026-03-12",
"changes": {
"features": [],
"improvements": [
"Migration capteur bruit de l'ancien systeme I2C vers le sonometre NSRT MK4 en USB",
"Nouveau script sound_meter/read.py pour lecture a la demande (retour JSON)",
"Page capteurs: carte USB avec affichage LEQ et dB(A) au lieu de l'ancien format texte",
"Self-test modem: parsing JSON du NSRT MK4 au lieu de texte brut"
],
"fixes": [
"Correction du self-test bruit qui affichait 'Unexpected value' avec le nouveau capteur"
],
"compatibility": []
},
"notes": "Mise a jour necessaire si le sonometre NSRT MK4 est connecte en USB. L'ancien capteur I2C n'est plus supporte sur la page capteurs."
},
{
"version": "1.4.0",
"date": "2026-03-10",
"changes": {
"features": [
"Mise a jour firmware hors-ligne par upload de fichier ZIP via l'interface web admin",
"Barre de progression pour suivre l'upload du fichier",
"Fichier .update-exclude versionne pour gerer les exclusions rsync de maniere evolutive"
],
"improvements": [
"Vidage du buffer serie avant chaque commande AT dans sara.py (evite les URCs residuelles au demarrage)"
],
"fixes": [],
"compatibility": [
"Necessite l'ajout de update_firmware_from_file.sh dans les permissions sudo de www-data",
"Necessite Apache mod_rewrite pour html/.htaccess (upload 50MB)"
]
},
"notes": "Permet la mise a jour du firmware sans connexion internet : telecharger le .zip depuis Gitea, se connecter au hotspot du capteur, et uploader via admin.html."
},
{
"version": "1.3.0",
"date": "2026-02-17",
"changes": {
"features": [
"Onglet 'Ecran' pour le controle de l'affichage HDMI (ModuleAir Pro uniquement)",
"Demarrage et arret du script d'affichage via l'interface web",
"Verification automatique du type d'appareil pour afficher l'onglet"
],
"improvements": [
"Ajout de logs console pour le debougage des commandes web",
"Traduction de l'element de menu 'Ecran'"
],
"fixes": [
"Correction des permissions d'execution des scripts python via web (sudo)",
"Correction de la visibilite des onglets du menu lateral (doublons ID)"
],
"compatibility": [
"Necessite python3-kivy installe",
"Necessite l'ajout de permissions sudo pour www-data (voir documentation)"
]
},
"notes": "Ajout de la fonctionnalite de controle d'ecran pour les demonstrations."
},
{
"version": "1.2.0",
"date": "2026-02-17",
"changes": {
"features": [
"Integration capteur CO2 MH-Z19 (scripts, base de donnees, service systemd, interface web)",
"Carte test CO2 sur la page capteurs",
"Checkbox activation CO2 sur la page admin",
"Consultation et telechargement des mesures CO2 sur la page base de donnees"
],
"improvements": [],
"fixes": [
"Logo ModuleAir Pro ne s'affichait pas (script dans innerHTML non execute)"
],
"compatibility": [
"Necessite re-execution de create_db.py, set_config.py et setup_services.sh apres mise a jour"
]
},
"notes": "Ajout du support capteur CO2 MH-Z19 pour le ModuleAir Pro. La transmission SARA sera integree dans une version ulterieure."
},
{
"version": "1.1.0",
"date": "2026-02-16",
"changes": {
"features": [
"Card informations base de donnees (taille, nombre d'entrees, dates min/max par table)",
"Telechargement CSV complet par table depuis la page base de donnees",
"Bouton version firmware NextPM sur la page capteurs",
"Tests capteurs integres dans l'auto-test modem",
"Logo dynamique selon le type d'appareil (NebuleAir/ModuleAir)"
],
"improvements": [
"Reordonnancement de l'auto-test : capteurs avant communication"
],
"fixes": [],
"compatibility": []
},
"notes": "Ameliorations de l'interface web : meilleure visibilite sur l'etat de la base de donnees et des capteurs."
},
{
"version": "1.0.0",
"date": "2026-02-11",
"changes": {
"features": [
"Support multi-device : NebuleAir Pro / ModuleAir Pro",
"Systeme de versioning firmware",
"Changelog viewer dans l'interface web"
],
"improvements": [],
"fixes": [],
"compatibility": [
"Les capteurs existants sont automatiquement configures en 'nebuleair_pro'"
]
},
"notes": "Premiere version tracee. Les capteurs anterieurs recevront device_type=nebuleair_pro par defaut lors de la mise a jour."
}
]
}

View File

@@ -105,23 +105,55 @@ try:
serial_connection = serial_connections[name] serial_connection = serial_connections[name]
try: try:
debug_print(f"Reading from {name}...") debug_print(f"Reading from {name}...")
# Send command to sensor calculated_value = None
command = b"\xFF\x02\x13\x30\x01\x02\x03\x04\x05\x06\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x12\xAF\x88\x03" max_retries = 3
serial_connection.write(command)
debug_print(f" → Sent command: {command.hex()}") for attempt in range(max_retries):
# Flush input buffer to clear any stale data
# Read response serial_connection.reset_input_buffer()
data_envea = serial_connection.readline()
debug_print(f" ← Received {len(data_envea)} bytes: {data_envea.hex()}") # Send command to sensor
command = b"\xFF\x02\x13\x30\x01\x02\x03\x04\x05\x06\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x12\xAF\x88\x03"
if len(data_envea) >= 20: serial_connection.write(command)
byte_20 = data_envea[19] if attempt == 0:
raw_value = byte_20 debug_print(f" → Sent command: {command.hex()}")
calculated_value = byte_20 * coefficient
debug_print(f" → Byte 20 value: {raw_value} (0x{raw_value:02X})") # Wait for sensor response
debug_print(f" → Calculated value: {raw_value} × {coefficient} = {calculated_value}") time.sleep(0.8)
# Read all available data from buffer
bytes_available = serial_connection.in_waiting
debug_print(f" ← Attempt {attempt + 1}: {bytes_available} bytes available")
if bytes_available > 0:
data_envea = serial_connection.read(bytes_available)
else:
data_envea = serial_connection.read(32)
if len(data_envea) > 0:
debug_print(f" ← Received {len(data_envea)} bytes: {data_envea.hex()}")
# Find frame start (0xFF 0x02) in received data
frame_start = -1
for i in range(len(data_envea) - 1):
if data_envea[i] == 0xFF and data_envea[i + 1] == 0x02:
frame_start = i
break
if frame_start >= 0:
frame_data = data_envea[frame_start:]
if len(frame_data) >= 20:
byte_20 = frame_data[19]
calculated_value = byte_20 * coefficient
debug_print(f" → Found valid frame at position {frame_start}")
debug_print(f" → Byte 20 = {byte_20} × {coefficient} = {calculated_value}")
break # Success, exit retry loop
debug_print(f" ✗ Attempt {attempt + 1} failed, {'retrying...' if attempt < max_retries - 1 else 'giving up'}")
time.sleep(0.2)
if calculated_value is not None:
if name == "h2s": if name == "h2s":
data_h2s = calculated_value data_h2s = calculated_value
elif name == "no2": elif name == "no2":
@@ -134,10 +166,9 @@ try:
data_nh3 = calculated_value data_nh3 = calculated_value
elif name == "so2": elif name == "so2":
data_so2 = calculated_value data_so2 = calculated_value
debug_print(f"{name.upper()} = {calculated_value}") debug_print(f"{name.upper()} = {calculated_value}")
else: else:
debug_print(f"Response too short (expected ≥20 bytes)") debug_print(f"Failed to read {name} after {max_retries} attempts")
except serial.SerialException as e: except serial.SerialException as e:
debug_print(f"✗ Error communicating with {name}: {e}") debug_print(f"✗ Error communicating with {name}: {e}")

55
forget_wifi.sh Normal file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
echo "-------"
echo "Start forget WiFi shell script at $(date)"
# Get deviceName from database for hotspot SSID
DEVICE_NAME=$(sqlite3 /var/www/nebuleair_pro_4g/sqlite/sensors.db "SELECT value FROM config_table WHERE key='deviceName'")
echo "Device Name: $DEVICE_NAME"
# Get current active WiFi connection name
ACTIVE_WIFI=$(nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep ':802-11-wireless:wlan0' | cut -d: -f1)
if [ -z "$ACTIVE_WIFI" ]; then
echo "No active WiFi connection found on wlan0"
echo "End forget WiFi shell script"
echo "-------"
exit 1
fi
echo "Forgetting WiFi connection: $ACTIVE_WIFI"
# Disconnect wlan0 first to prevent NetworkManager from auto-reconnecting
sudo nmcli device disconnect wlan0
echo "wlan0 disconnected"
# Delete (forget) the saved connection
sudo nmcli connection delete "$ACTIVE_WIFI"
if [ $? -eq 0 ]; then
echo "Connection '$ACTIVE_WIFI' deleted successfully"
else
echo "Failed to delete connection '$ACTIVE_WIFI'"
fi
sleep 5
# Scan WiFi networks BEFORE starting hotspot (scan impossible once hotspot is active)
OUTPUT_FILE="/var/www/nebuleair_pro_4g/wifi_list.csv"
echo "Scanning WiFi networks (waiting for wlan0 to be ready)..."
nmcli device wifi rescan ifname wlan0 2>/dev/null
sleep 3
nmcli -f SSID,SIGNAL,SECURITY device wifi list ifname wlan0 | awk 'BEGIN { OFS=","; print "SSID,SIGNAL,SECURITY" } NR>1 { print $1,$2,$3 }' > "$OUTPUT_FILE"
echo "WiFi scan saved to $OUTPUT_FILE"
cat "$OUTPUT_FILE"
# Start hotspot
echo "Starting hotspot with SSID: $DEVICE_NAME"
sudo nmcli device wifi hotspot ifname wlan0 ssid "$DEVICE_NAME" password nebuleaircfg
# Update SQLite to reflect hotspot mode
sqlite3 /var/www/nebuleair_pro_4g/sqlite/sensors.db "UPDATE config_table SET value='hotspot' WHERE key='WIFI_status'"
echo "Updated database: WIFI_status = hotspot"
echo "Hotspot started with SSID: $DEVICE_NAME"
echo "End forget WiFi shell script"
echo "-------"

3
html/.htaccess Normal file
View File

@@ -0,0 +1,3 @@
php_value upload_max_filesize 50M
php_value post_max_size 55M
php_value max_execution_time 300

View File

@@ -51,7 +51,14 @@
<main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4"> <main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4">Admin</h1> <h1 class="mt-4">Admin</h1>
<button class="btn btn-success mb-3 btn_selfTest" onclick="runSelfTest()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2-circle me-1" viewBox="0 0 16 16">
<path d="M2.5 8a5.5 5.5 0 1 1 11 0 5.5 5.5 0 0 1-11 0z"/>
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l7-7z"/>
</svg>
Run Self Test
</button>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-lg-3 col-12"> <div class="col-lg-3 col-12">
@@ -118,6 +125,37 @@
</label> </label>
</div> </div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="" id="check_mhz19" onchange="update_config_sqlite('MHZ19', this.checked)">
<label class="form-check-label" for="check_mhz19">
Send CO2 data (MH-Z19)
</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="" id="check_wifi_power_saving" onchange="update_config_sqlite('wifi_power_saving', this.checked)">
<label class="form-check-label" for="check_wifi_power_saving">
WiFi Power Saving
</label>
<small class="form-text text-muted d-block ms-4">
Disable WiFi 10 minutes after boot to save power (~100-200mA). WiFi will re-enable after reboot.
</small>
</div>
<div class="mb-3">
<label for="cpu_power_mode" class="form-label">CPU Power Mode</label>
<select class="form-select" id="cpu_power_mode" onchange="set_cpu_power_mode(this.value)">
<option value="normal">Normal (600-1500MHz dynamic)</option>
<option value="powersave">Power Saving (600MHz fixed)</option>
</select>
<small class="form-text text-muted d-block">
<span id="cpu_mode_status" class="text-success"></span>
</small>
<small class="form-text text-muted d-block">
Power saving mode reduces CPU performance by ~30-40% but saves power.
</small>
</div>
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-bold">Protected Settings</span> <span class="fw-bold">Protected Settings</span>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="toggleProtectedSettings()" id="unlockBtn"> <button type="button" class="btn btn-sm btn-outline-primary" onclick="toggleProtectedSettings()" id="unlockBtn">
@@ -150,6 +188,13 @@
</label> </label>
</div> </div>
<div class="mb-3">
<label for="device_type" class="form-label">Device Type</label>
<select class="form-select protected-checkbox" id="device_type" onchange="update_config_sqlite('device_type', this.value)" disabled>
<option value="nebuleair_pro">NebuleAir Pro</option>
<option value="moduleair_pro">ModuleAir Pro</option>
</select>
</div>
<div class="input-group mb-3" id="sondes_envea_div"></div> <div class="input-group mb-3" id="sondes_envea_div"></div>
@@ -195,13 +240,35 @@
<!-- UPDATE--> <!-- UPDATE-->
<div class="col-lg-4 col-12"> <div class="col-lg-4 col-12">
<h3 class="mt-4">Updates</h3> <div class="d-flex align-items-center mt-4 mb-2">
<h3 class="mb-0 me-2">Updates</h3>
<span id="firmwareVersionBadge" class="badge bg-secondary">Version...</span>
<button type="button" class="btn btn-sm btn-outline-info ms-2" onclick="showChangelogModal()">Changelog</button>
</div>
<button type="submit" class="btn btn-primary" onclick="updateFirmware()" id="updateBtn"> <button type="submit" class="btn btn-primary" onclick="updateFirmware()" id="updateBtn">
<span id="updateBtnText">Update firmware</span> <span id="updateBtnText">Update firmware</span>
<span id="updateSpinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span> <span id="updateSpinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button> </button>
<hr class="my-3">
<label class="form-label fw-bold">Mise à jour hors-ligne (upload)</label>
<div class="input-group mb-2">
<input type="file" class="form-control" id="firmwareFileInput" accept=".zip">
<button class="btn btn-warning" type="button" onclick="uploadFirmware()" id="uploadBtn">
<span id="uploadBtnText">Upload & Install</span>
<span id="uploadSpinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
<div class="progress mb-2" id="uploadProgressBar" style="display: none; height: 20px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%" id="uploadProgress">0%</div>
</div>
<small class="text-muted">
1. Telecharger le .zip depuis <a href="http://gitea.aircarto.fr/PaulVua/nebuleair_pro_4g/releases" target="_blank">Gitea (releases)</a>
ou <a href="http://gitea.aircarto.fr/PaulVua/nebuleair_pro_4g/archive/main.zip" target="_blank">derniere version (main.zip)</a><br>
2. Deposer le fichier .zip ci-dessus puis cliquer sur Upload & Install
</small>
<!-- Update Output Console --> <!-- Update Output Console -->
<div id="updateOutput" class="mt-3" style="display: none;"> <div id="updateOutput" class="mt-3" style="display: none;">
<div class="card"> <div class="card">
@@ -310,6 +377,28 @@
</div> </div>
<!-- Changelog Modal -->
<div class="modal fade" id="changelogModal" tabindex="-1" aria-labelledby="changelogModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="changelogModalLabel">Changelog</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="changelogModalBody">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</main> </main>
</div> </div>
</div> </div>
@@ -322,6 +411,8 @@
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system --> <!-- i18n translation system -->
<script src="assets/js/i18n.js"></script> <script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script src="assets/js/selftest.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
@@ -367,6 +458,7 @@ window.onload = function() {
success: function(response) { success: function(response) {
console.log("Getting SQLite config table:"); console.log("Getting SQLite config table:");
console.log(response); console.log(response);
window._adminConfig = response;
//device name //device name
const deviceName = document.getElementById("device_name"); const deviceName = document.getElementById("device_name");
deviceName.value = response.deviceName; deviceName.value = response.deviceName;
@@ -399,6 +491,8 @@ window.onload = function() {
const checkbox_envea = document.getElementById("check_envea"); const checkbox_envea = document.getElementById("check_envea");
const checkbox_solar = document.getElementById("check_solarBattery"); const checkbox_solar = document.getElementById("check_solarBattery");
const checkbox_noise = document.getElementById("check_NOISE"); const checkbox_noise = document.getElementById("check_NOISE");
const checkbox_mhz19 = document.getElementById("check_mhz19");
const checkbox_wifi_power_saving = document.getElementById("check_wifi_power_saving");
checkbox_bme.checked = response["BME280"]; checkbox_bme.checked = response["BME280"];
checkbox_envea.checked = response["envea"]; checkbox_envea.checked = response["envea"];
@@ -406,11 +500,29 @@ window.onload = function() {
checkbox_nmp5channels.checked = response.npm_5channel; checkbox_nmp5channels.checked = response.npm_5channel;
checkbox_wind.checked = response["windMeter"]; checkbox_wind.checked = response["windMeter"];
checkbox_noise.checked = response["NOISE"]; checkbox_noise.checked = response["NOISE"];
checkbox_mhz19.checked = response["MHZ19"];
checkbox_wifi_power_saving.checked = response["wifi_power_saving"];
checkbox_uSpot.checked = response["send_uSpot"]; checkbox_uSpot.checked = response["send_uSpot"];
checkbox_aircarto.checked = response["send_aircarto"]; checkbox_aircarto.checked = response["send_aircarto"];
checkbox_miotiq.checked = response["send_miotiq"]; checkbox_miotiq.checked = response["send_miotiq"];
// Set device type
const device_type_select = document.getElementById("device_type");
if (response["device_type"]) {
device_type_select.value = response["device_type"];
}
// Set CPU power mode
const cpu_power_mode_select = document.getElementById("cpu_power_mode");
if (response["cpu_power_mode"]) {
cpu_power_mode_select.value = response["cpu_power_mode"];
// Update status display
const statusElement = document.getElementById('cpu_mode_status');
statusElement.textContent = `Current: ${response["cpu_power_mode"]}`;
statusElement.className = 'text-success';
}
// If envea is enabled, show the envea sondes container // If envea is enabled, show the envea sondes container
if (response["envea"]) { if (response["envea"]) {
add_sondeEnveaContainer(); add_sondeEnveaContainer();
@@ -519,7 +631,10 @@ window.onload = function() {
// Load services on page load // Load services on page load
refreshServices(); refreshServices();
// Load firmware version
loadFirmwareVersion();
} //end window.onload } //end window.onload
@@ -577,6 +692,83 @@ function update_config_sqlite(param, value){
} }
function set_cpu_power_mode(mode) {
console.log("Setting CPU power mode to:", mode);
const toastLiveExample = document.getElementById('liveToast');
const toastBody = toastLiveExample.querySelector('.toast-body');
const statusElement = document.getElementById('cpu_mode_status');
// Show loading status
statusElement.textContent = 'Applying mode...';
statusElement.className = 'text-warning';
$.ajax({
url: 'launcher.php?type=set_cpu_power_mode&mode=' + mode,
dataType: 'json',
method: 'GET',
cache: false,
success: function(response) {
console.log(response);
let formattedMessage;
if (response.success) {
// Success message
toastLiveExample.classList.remove('text-bg-danger');
toastLiveExample.classList.add('text-bg-success');
formattedMessage = `
<strong>Success!</strong><br>
CPU mode set to: <strong>${mode}</strong><br>
${response.description || ''}
`;
// Update status
statusElement.textContent = `Current: ${mode}`;
statusElement.className = 'text-success';
} else {
// Error message
toastLiveExample.classList.remove('text-bg-success');
toastLiveExample.classList.add('text-bg-danger');
formattedMessage = `
<strong>Error!</strong><br>
${response.error || 'Failed to set CPU power mode'}
`;
// Reset status
statusElement.textContent = 'Error setting mode';
statusElement.className = 'text-danger';
}
// Update the toast body with formatted content
toastBody.innerHTML = formattedMessage;
// Show the toast
const toastBootstrap = bootstrap.Toast.getOrCreateInstance(toastLiveExample);
toastBootstrap.show();
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
// Show error in toast
toastLiveExample.classList.remove('text-bg-success');
toastLiveExample.classList.add('text-bg-danger');
toastBody.innerHTML = `<strong>Error!</strong><br>Network error: ${error}`;
const toastBootstrap = bootstrap.Toast.getOrCreateInstance(toastLiveExample);
toastBootstrap.show();
// Update status
statusElement.textContent = 'Network error';
statusElement.className = 'text-danger';
}
});
}
function update_config(param, value){ function update_config(param, value){
console.log("Updating ",param," : ", value); console.log("Updating ",param," : ", value);
$.ajax({ $.ajax({
@@ -594,8 +786,14 @@ function update_config(param, value){
} }
function updateFirmware() { function updateFirmware() {
// Check if connected to internet (not in hotspot mode)
if (window._adminConfig && window._adminConfig.WIFI_status === 'hotspot') {
alert('Mise à jour impossible en mode hotspot.\nConnectez d\'abord le capteur à un réseau WiFi avec accès internet.');
return;
}
console.log("Starting comprehensive firmware update..."); console.log("Starting comprehensive firmware update...");
// Show loading state // Show loading state
const updateBtn = document.getElementById('updateBtn'); const updateBtn = document.getElementById('updateBtn');
const updateBtnText = document.getElementById('updateBtnText'); const updateBtnText = document.getElementById('updateBtnText');
@@ -659,6 +857,116 @@ function updateFirmware() {
}); });
} }
function uploadFirmware() {
const fileInput = document.getElementById('firmwareFileInput');
const file = fileInput.files[0];
if (!file) {
showToast('Please select a .zip file first', 'warning');
return;
}
// Validate extension
if (!file.name.toLowerCase().endsWith('.zip')) {
showToast('Only .zip files are allowed', 'error');
return;
}
// Validate size (50MB)
if (file.size > 50 * 1024 * 1024) {
showToast('File too large (max 50MB)', 'error');
return;
}
if (!confirm('Install firmware from "' + file.name + '"?\nThis will update the system files and restart services.')) {
return;
}
// UI elements
const uploadBtn = document.getElementById('uploadBtn');
const uploadBtnText = document.getElementById('uploadBtnText');
const uploadSpinner = document.getElementById('uploadSpinner');
const progressBar = document.getElementById('uploadProgressBar');
const progress = document.getElementById('uploadProgress');
const updateOutput = document.getElementById('updateOutput');
const updateOutputContent = document.getElementById('updateOutputContent');
// Show loading state
uploadBtn.disabled = true;
uploadBtnText.textContent = 'Uploading...';
uploadSpinner.style.display = 'inline-block';
progressBar.style.display = 'flex';
progress.style.width = '0%';
progress.textContent = '0%';
updateOutput.style.display = 'block';
updateOutputContent.textContent = 'Uploading firmware file...\n';
// Build FormData
const formData = new FormData();
formData.append('firmware_file', file);
// Use XMLHttpRequest for upload progress
const xhr = new XMLHttpRequest();
xhr.timeout = 300000; // 5 minutes
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progress.style.width = pct + '%';
progress.textContent = pct + '%';
if (pct >= 100) {
uploadBtnText.textContent = 'Installing...';
updateOutputContent.textContent = 'Upload complete. Installing firmware...\n';
}
}
});
xhr.addEventListener('load', function() {
try {
const response = JSON.parse(xhr.responseText);
if (response.success && response.output) {
const formattedOutput = response.output
.replace(/✓/g, '<span style="color: #28a745;">✓</span>')
.replace(/✗/g, '<span style="color: #dc3545;">✗</span>')
.replace(/⚠/g, '<span style="color: #ffc107;">⚠</span>')
.replace(//g, '<span style="color: #17a2b8;"></span>');
updateOutputContent.innerHTML = formattedOutput;
showToast('Firmware updated: ' + (response.old_version || '?') + ' → ' + (response.new_version || '?'), 'success');
document.getElementById('reloadBtn').style.display = 'inline-block';
} else {
updateOutputContent.textContent = 'Error: ' + (response.message || 'Unknown error');
showToast('Upload failed: ' + (response.message || 'Unknown error'), 'error');
}
} catch (e) {
updateOutputContent.textContent = 'Error parsing response: ' + xhr.responseText;
showToast('Update failed: invalid server response', 'error');
}
resetUploadUI();
});
xhr.addEventListener('error', function() {
updateOutputContent.textContent = 'Network error during upload';
showToast('Upload failed: network error', 'error');
resetUploadUI();
});
xhr.addEventListener('timeout', function() {
updateOutputContent.textContent = 'Upload timed out (5 min limit)';
showToast('Upload timed out', 'error');
resetUploadUI();
});
function resetUploadUI() {
uploadBtn.disabled = false;
uploadBtnText.textContent = 'Upload & Install';
uploadSpinner.style.display = 'none';
progressBar.style.display = 'none';
}
xhr.open('POST', 'launcher.php?type=upload_firmware');
xhr.send(formData);
}
function clearUpdateOutput() { function clearUpdateOutput() {
const updateOutput = document.getElementById('updateOutput'); const updateOutput = document.getElementById('updateOutput');
const updateOutputContent = document.getElementById('updateOutputContent'); const updateOutputContent = document.getElementById('updateOutputContent');
@@ -1531,6 +1839,123 @@ function toggleProtectedSettings() {
} }
} }
/*
__ __ _ _
\ \ / /__ _ __ ___(_) ___ _ __ (_)_ __ __ _
\ \ / / _ \ '__/ __| |/ _ \| '_ \| | '_ \ / _` |
\ V / __/ | \__ \ | (_) | | | | | | | | (_| |
\_/ \___|_| |___/_|\___/|_| |_|_|_| |_|\__, |
|___/
*/
function loadFirmwareVersion() {
$.ajax({
url: 'launcher.php?type=get_firmware_version',
dataType: 'json',
method: 'GET',
cache: false,
success: function(response) {
const badge = document.getElementById('firmwareVersionBadge');
if (response.success) {
badge.textContent = 'v' + response.version;
badge.className = 'badge bg-primary';
} else {
badge.textContent = 'Version unknown';
badge.className = 'badge bg-secondary';
}
},
error: function() {
const badge = document.getElementById('firmwareVersionBadge');
badge.textContent = 'Version unknown';
badge.className = 'badge bg-secondary';
}
});
}
function showChangelogModal() {
const modal = new bootstrap.Modal(document.getElementById('changelogModal'));
modal.show();
// Load changelog data
$.ajax({
url: 'launcher.php?type=get_changelog',
dataType: 'json',
method: 'GET',
cache: false,
success: function(response) {
if (response.success && response.changelog) {
displayChangelog(response.changelog);
} else {
document.getElementById('changelogModalBody').innerHTML =
'<div class="alert alert-warning">Could not load changelog.</div>';
}
},
error: function() {
document.getElementById('changelogModalBody').innerHTML =
'<div class="alert alert-danger">Failed to load changelog.</div>';
}
});
}
function displayChangelog(data) {
const container = document.getElementById('changelogModalBody');
let html = '';
data.versions.forEach(function(version) {
html += `<div class="card mb-3">`;
html += `<div class="card-header d-flex justify-content-between align-items-center">`;
html += `<h5 class="mb-0">v${version.version}</h5>`;
html += `<span class="text-muted">${version.date}</span>`;
html += `</div>`;
html += `<div class="card-body">`;
// Features
if (version.changes.features && version.changes.features.length > 0) {
html += `<h6 class="text-success">Features</h6><ul>`;
version.changes.features.forEach(function(f) {
html += `<li>${f}</li>`;
});
html += `</ul>`;
}
// Improvements
if (version.changes.improvements && version.changes.improvements.length > 0) {
html += `<h6 class="text-info">Improvements</h6><ul>`;
version.changes.improvements.forEach(function(i) {
html += `<li>${i}</li>`;
});
html += `</ul>`;
}
// Fixes
if (version.changes.fixes && version.changes.fixes.length > 0) {
html += `<h6 class="text-danger">Fixes</h6><ul>`;
version.changes.fixes.forEach(function(f) {
html += `<li>${f}</li>`;
});
html += `</ul>`;
}
// Compatibility
if (version.changes.compatibility && version.changes.compatibility.length > 0) {
html += `<div class="alert alert-warning mt-2 mb-0"><strong>Compatibility:</strong><ul class="mb-0">`;
version.changes.compatibility.forEach(function(c) {
html += `<li>${c}</li>`;
});
html += `</ul></div>`;
}
// Notes
if (version.notes) {
html += `<p class="text-muted mt-2 mb-0"><em>${version.notes}</em></p>`;
}
html += `</div></div>`;
});
container.innerHTML = html;
}
</script> </script>

View File

@@ -0,0 +1,66 @@
{
"operators": {
"20801": { "name": "Orange", "country": "France" },
"20802": { "name": "Orange", "country": "France" },
"20810": { "name": "SFR", "country": "France" },
"20811": { "name": "SFR", "country": "France" },
"20813": { "name": "SFR", "country": "France" },
"20815": { "name": "Free Mobile", "country": "France" },
"20816": { "name": "Free Mobile", "country": "France" },
"20820": { "name": "Bouygues Telecom", "country": "France" },
"20821": { "name": "Bouygues Telecom", "country": "France" },
"20826": { "name": "NRJ Mobile", "country": "France" },
"20888": { "name": "Bouygues Telecom", "country": "France" },
"22201": { "name": "TIM", "country": "Italy" },
"22210": { "name": "Vodafone", "country": "Italy" },
"22288": { "name": "WIND", "country": "Italy" },
"22299": { "name": "3 Italia", "country": "Italy" },
"23410": { "name": "O2", "country": "UK" },
"23415": { "name": "Vodafone", "country": "UK" },
"23420": { "name": "3", "country": "UK" },
"23430": { "name": "EE", "country": "UK" },
"23433": { "name": "EE", "country": "UK" },
"26201": { "name": "Telekom", "country": "Germany" },
"26202": { "name": "Vodafone", "country": "Germany" },
"26203": { "name": "O2", "country": "Germany" },
"26207": { "name": "O2", "country": "Germany" },
"21401": { "name": "Vodafone", "country": "Spain" },
"21403": { "name": "Orange", "country": "Spain" },
"21404": { "name": "Yoigo", "country": "Spain" },
"21407": { "name": "Movistar", "country": "Spain" },
"22801": { "name": "Swisscom", "country": "Switzerland" },
"22802": { "name": "Sunrise", "country": "Switzerland" },
"22803": { "name": "Salt", "country": "Switzerland" },
"20601": { "name": "Proximus", "country": "Belgium" },
"20610": { "name": "Orange", "country": "Belgium" },
"20620": { "name": "Base", "country": "Belgium" },
"20404": { "name": "Vodafone", "country": "Netherlands" },
"20408": { "name": "KPN", "country": "Netherlands" },
"20412": { "name": "T-Mobile", "country": "Netherlands" },
"20416": { "name": "T-Mobile", "country": "Netherlands" },
"26801": { "name": "Vodafone", "country": "Portugal" },
"26803": { "name": "NOS", "country": "Portugal" },
"26806": { "name": "MEO", "country": "Portugal" },
"29340": { "name": "SI Mobil", "country": "Slovenia" },
"29341": { "name": "Mobitel", "country": "Slovenia" }
},
"modes": {
"0": "Automatic",
"1": "Manual",
"2": "Deregistered",
"3": "Format only",
"4": "Manual/Automatic"
},
"accessTechnology": {
"0": "GSM",
"1": "GSM Compact",
"2": "UTRAN (3G)",
"3": "GSM/GPRS with EDGE",
"4": "UTRAN with HSDPA",
"5": "UTRAN with HSUPA",
"6": "UTRAN with HSDPA/HSUPA",
"7": "LTE (4G)",
"8": "EC-GSM-IoT",
"9": "LTE Cat-M / NB-IoT"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

943
html/assets/js/selftest.js Normal file
View File

@@ -0,0 +1,943 @@
// ============================================
// SELF TEST FUNCTIONS (shared across pages)
// ============================================
// Cache for operators data
let operatorsDataSelfTest = null;
function loadOperatorsDataSelfTest() {
return new Promise((resolve, reject) => {
if (operatorsDataSelfTest) {
resolve(operatorsDataSelfTest);
return;
}
$.ajax({
url: 'assets/data/operators.json',
dataType: 'json',
method: 'GET',
success: function(data) {
operatorsDataSelfTest = data;
resolve(data);
},
error: function(xhr, status, error) {
console.error('Failed to load operators data:', error);
reject(error);
}
});
});
}
// Global object to store test results for report
let selfTestReport = {
timestamp: '',
deviceId: '',
modemVersion: '',
results: {},
rawResponses: {}
};
function runSelfTest() {
console.log("Starting Self Test...");
// Reset report
selfTestReport = {
timestamp: new Date().toISOString(),
deviceId: document.querySelector('.sideBar_sensorName')?.textContent || 'Unknown',
modemVersion: document.getElementById('modem_version')?.textContent || 'Unknown',
results: {},
rawResponses: {}
};
// Reset UI
resetSelfTestUI();
// Show modal
const modal = new bootstrap.Modal(document.getElementById('selfTestModal'));
modal.show();
// Disable buttons during test
document.getElementById('selfTestCloseBtn').disabled = true;
document.getElementById('selfTestDoneBtn').disabled = true;
document.getElementById('selfTestCopyBtn').disabled = true;
document.querySelectorAll('.btn_selfTest').forEach(btn => btn.disabled = true);
// Start test sequence
selfTestSequence();
}
function resetSelfTestUI() {
// Reset status
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Preparing test...</span>
</div>`;
// Reset test items
document.getElementById('test_wifi_status').className = 'badge bg-secondary';
document.getElementById('test_wifi_status').textContent = 'Pending';
document.getElementById('test_wifi_detail').textContent = 'Waiting...';
document.getElementById('test_modem_status').className = 'badge bg-secondary';
document.getElementById('test_modem_status').textContent = 'Pending';
document.getElementById('test_modem_detail').textContent = 'Waiting...';
document.getElementById('test_sim_status').className = 'badge bg-secondary';
document.getElementById('test_sim_status').textContent = 'Pending';
document.getElementById('test_sim_detail').textContent = 'Waiting...';
document.getElementById('test_signal_status').className = 'badge bg-secondary';
document.getElementById('test_signal_status').textContent = 'Pending';
document.getElementById('test_signal_detail').textContent = 'Waiting...';
document.getElementById('test_network_status').className = 'badge bg-secondary';
document.getElementById('test_network_status').textContent = 'Pending';
document.getElementById('test_network_detail').textContent = 'Waiting...';
// Reset sensor tests
document.getElementById('sensor_tests_container').innerHTML = '';
document.getElementById('comm_tests_separator').style.display = 'none';
// Reset logs
document.getElementById('selftest_logs').innerHTML = '';
// Reset summary
document.getElementById('selftest_summary').innerHTML = '';
}
function addSelfTestLog(message, isRaw = false) {
const logsEl = document.getElementById('selftest_logs');
const timestamp = new Date().toLocaleTimeString();
if (isRaw) {
// Raw AT response - format nicely
logsEl.textContent += `[${timestamp}] >>> RAW RESPONSE:\n${message}\n<<<\n`;
} else {
logsEl.textContent += `[${timestamp}] ${message}\n`;
}
// Auto-scroll to bottom
logsEl.parentElement.scrollTop = logsEl.parentElement.scrollHeight;
}
function updateTestStatus(testId, status, detail, badge) {
document.getElementById(`test_${testId}_status`).className = `badge ${badge}`;
document.getElementById(`test_${testId}_status`).textContent = status;
document.getElementById(`test_${testId}_detail`).textContent = detail;
// Store result in report
selfTestReport.results[testId] = {
status: status,
detail: detail
};
}
function setConfigMode(enabled) {
return new Promise((resolve, reject) => {
addSelfTestLog(`Setting modem_config_mode to ${enabled}...`);
$.ajax({
url: `launcher.php?type=update_config_sqlite&param=modem_config_mode&value=${enabled}`,
dataType: 'json',
method: 'GET',
cache: false,
success: function(response) {
if (response.success) {
addSelfTestLog(`modem_config_mode set to ${enabled}`);
// Update checkbox state if it exists on the page
const checkbox = document.getElementById('check_modem_configMode');
if (checkbox) checkbox.checked = enabled;
resolve(true);
} else {
addSelfTestLog(`Failed to set modem_config_mode: ${response.error || 'Unknown error'}`);
reject(new Error(response.error || 'Failed to set config mode'));
}
},
error: function(xhr, status, error) {
addSelfTestLog(`AJAX error setting config mode: ${error}`);
reject(new Error(error));
}
});
});
}
function sendATCommand(command, timeout) {
return new Promise((resolve, reject) => {
addSelfTestLog(`Sending AT command: ${command} (timeout: ${timeout}s)`);
$.ajax({
url: `launcher.php?type=sara&port=ttyAMA2&command=${encodeURIComponent(command)}&timeout=${timeout}`,
dataType: 'text',
method: 'GET',
success: function(response) {
// Store raw response in report
selfTestReport.rawResponses[command] = response;
// Log raw response
addSelfTestLog(response.trim(), true);
resolve(response);
},
error: function(xhr, status, error) {
addSelfTestLog(`AT command error: ${error}`);
selfTestReport.rawResponses[command] = `ERROR: ${error}`;
reject(new Error(error));
}
});
});
}
function delaySelfTest(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function selfTestSequence() {
let testsPassed = 0;
let testsFailed = 0;
try {
// Collect system info at the start
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-primary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Collecting system information...</span>
</div>`;
// Get system info from config
try {
const configResponse = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=get_config_sqlite',
dataType: 'json',
method: 'GET',
success: function(data) { resolve(data); },
error: function(xhr, status, error) { reject(new Error(error)); }
});
});
// Store in report
selfTestReport.deviceId = configResponse.deviceID || 'Unknown';
selfTestReport.deviceName = configResponse.deviceName || 'Unknown';
selfTestReport.modemVersion = configResponse.modem_version || 'Unknown';
selfTestReport.latitude = configResponse.latitude_raw || 'N/A';
selfTestReport.longitude = configResponse.longitude_raw || 'N/A';
selfTestReport.config = configResponse;
// Get RTC time
try {
const rtcTime = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=RTC_time',
dataType: 'text',
method: 'GET',
success: function(data) { resolve(data.trim()); },
error: function(xhr, status, error) { resolve('N/A'); }
});
});
selfTestReport.systemTime = rtcTime;
} catch (e) {
selfTestReport.systemTime = 'N/A';
}
// Log system info
addSelfTestLog('════════════════════════════════════════════════════════');
addSelfTestLog(' NEBULEAIR PRO 4G - SELF TEST');
addSelfTestLog('════════════════════════════════════════════════════════');
addSelfTestLog(`Device ID: ${selfTestReport.deviceId}`);
addSelfTestLog(`Device Name: ${selfTestReport.deviceName}`);
addSelfTestLog(`Modem Version: ${selfTestReport.modemVersion}`);
addSelfTestLog(`System Time (RTC): ${selfTestReport.systemTime}`);
addSelfTestLog(`Browser Time: ${new Date().toLocaleString()}`);
addSelfTestLog(`GPS: ${selfTestReport.latitude}, ${selfTestReport.longitude}`);
addSelfTestLog('────────────────────────────────────────────────────────');
addSelfTestLog('');
} catch (error) {
addSelfTestLog(`Warning: Could not get system config: ${error.message}`);
}
await delaySelfTest(300);
// ═══════════════════════════════════════════════════════
// SENSOR TESTS - Test enabled sensors based on config
// ═══════════════════════════════════════════════════════
const config = selfTestReport.config || {};
const sensorTests = [];
// NPM is always present
sensorTests.push({ id: 'npm', name: 'NextPM (Particles)', type: 'npm', port: 'ttyAMA5' });
// BME280 if enabled
if (config.BME280) {
sensorTests.push({ id: 'bme280', name: 'BME280 (Temp/Hum)', type: 'BME280' });
}
// Noise if enabled
if (config.NOISE) {
sensorTests.push({ id: 'noise', name: 'Noise Sensor', type: 'noise' });
}
// Envea if enabled
if (config.envea) {
sensorTests.push({ id: 'envea', name: 'Envea (Gas Sensors)', type: 'envea' });
}
// RTC module is always present (DS3231)
sensorTests.push({ id: 'rtc', name: 'RTC Module (DS3231)', type: 'rtc' });
// Create sensor test UI entries dynamically
const sensorContainer = document.getElementById('sensor_tests_container');
sensorContainer.innerHTML = '';
sensorTests.forEach(sensor => {
sensorContainer.innerHTML += `
<div class="list-group-item d-flex justify-content-between align-items-center" id="test_${sensor.id}">
<div>
<strong>${sensor.name}</strong>
<div class="small text-muted" id="test_${sensor.id}_detail">Waiting...</div>
</div>
<span id="test_${sensor.id}_status" class="badge bg-secondary">Pending</span>
</div>`;
});
addSelfTestLog('');
addSelfTestLog('────────────────────────────────────────────────────────');
addSelfTestLog('SENSOR TESTS');
addSelfTestLog('────────────────────────────────────────────────────────');
// Run each sensor test
for (const sensor of sensorTests) {
await delaySelfTest(500);
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-primary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Testing ${sensor.name}...</span>
</div>`;
updateTestStatus(sensor.id, 'Testing...', 'Reading sensor data...', 'bg-info');
addSelfTestLog(`Testing ${sensor.name}...`);
try {
if (sensor.type === 'npm') {
// NPM sensor test
const npmResult = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=npm&port=' + sensor.port,
dataType: 'json',
method: 'GET',
timeout: 15000,
success: function(data) { resolve(data); },
error: function(xhr, status, error) { reject(new Error(error || status)); }
});
});
selfTestReport.rawResponses['NPM Sensor'] = JSON.stringify(npmResult, null, 2);
addSelfTestLog(`NPM response: PM1=${npmResult.PM1}, PM2.5=${npmResult.PM25}, PM10=${npmResult.PM10}`);
// Check for errors
const npmErrors = ['notReady', 'fanError', 'laserError', 'heatError', 't_rhError', 'memoryError', 'degradedState'];
const activeErrors = npmErrors.filter(e => npmResult[e] === 1);
if (activeErrors.length > 0) {
updateTestStatus(sensor.id, 'Warning', `Errors: ${activeErrors.join(', ')}`, 'bg-warning');
testsFailed++;
} else if (npmResult.PM1 !== undefined && npmResult.PM25 !== undefined && npmResult.PM10 !== undefined) {
updateTestStatus(sensor.id, 'Passed', `PM1: ${npmResult.PM1} | PM2.5: ${npmResult.PM25} | PM10: ${npmResult.PM10} ug/m3`, 'bg-success');
testsPassed++;
} else {
updateTestStatus(sensor.id, 'Warning', 'Incomplete data received', 'bg-warning');
testsFailed++;
}
} else if (sensor.type === 'BME280') {
// BME280 sensor test
const bme280Result = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=BME280',
dataType: 'text',
method: 'GET',
timeout: 15000,
success: function(data) { resolve(data); },
error: function(xhr, status, error) { reject(new Error(error || status)); }
});
});
const bmeData = JSON.parse(bme280Result);
selfTestReport.rawResponses['BME280 Sensor'] = JSON.stringify(bmeData, null, 2);
addSelfTestLog(`BME280 response: temp=${bmeData.temp}, hum=${bmeData.hum}, press=${bmeData.press}`);
if (bmeData.temp !== undefined && bmeData.hum !== undefined && bmeData.press !== undefined) {
updateTestStatus(sensor.id, 'Passed', `${bmeData.temp}°C | ${bmeData.hum}% | ${bmeData.press} hPa`, 'bg-success');
testsPassed++;
} else {
updateTestStatus(sensor.id, 'Warning', 'Incomplete data received', 'bg-warning');
testsFailed++;
}
} else if (sensor.type === 'noise') {
// NSRT MK4 noise sensor test (returns JSON)
const noiseResult = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=noise',
dataType: 'json',
method: 'GET',
timeout: 15000,
success: function(data) { resolve(data); },
error: function(xhr, status, error) { reject(new Error(error || status)); }
});
});
selfTestReport.rawResponses['Noise Sensor'] = JSON.stringify(noiseResult);
addSelfTestLog(`Noise response: ${JSON.stringify(noiseResult)}`);
if (noiseResult.error) {
updateTestStatus(sensor.id, 'Failed', noiseResult.error, 'bg-danger');
testsFailed++;
} else if (noiseResult.LEQ > 0 && noiseResult.dBA > 0) {
updateTestStatus(sensor.id, 'Passed', `LEQ: ${noiseResult.LEQ} dB | dB(A): ${noiseResult.dBA}`, 'bg-success');
testsPassed++;
} else {
updateTestStatus(sensor.id, 'Warning', `Unexpected values: LEQ=${noiseResult.LEQ}, dBA=${noiseResult.dBA}`, 'bg-warning');
testsFailed++;
}
} else if (sensor.type === 'envea') {
// Envea sensor test - use the debug endpoint for all sensors
const enveaResult = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=envea_debug',
dataType: 'text',
method: 'GET',
timeout: 30000,
success: function(data) { resolve(data); },
error: function(xhr, status, error) { reject(new Error(error || status)); }
});
});
selfTestReport.rawResponses['Envea Sensors'] = enveaResult;
addSelfTestLog(`Envea response: ${enveaResult.trim().substring(0, 200)}`);
if (enveaResult.trim() !== '' && !enveaResult.toLowerCase().includes('error')) {
updateTestStatus(sensor.id, 'Passed', 'Sensors responding', 'bg-success');
testsPassed++;
} else if (enveaResult.toLowerCase().includes('error')) {
updateTestStatus(sensor.id, 'Failed', 'Sensor error detected', 'bg-danger');
testsFailed++;
} else {
updateTestStatus(sensor.id, 'Failed', 'No data received', 'bg-danger');
testsFailed++;
}
} else if (sensor.type === 'rtc') {
// RTC DS3231 module test
const rtcResult = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=sys_RTC_module_time',
dataType: 'json',
method: 'GET',
timeout: 10000,
success: function(data) { resolve(data); },
error: function(xhr, status, error) { reject(new Error(error || status)); }
});
});
selfTestReport.rawResponses['RTC Module'] = JSON.stringify(rtcResult, null, 2);
addSelfTestLog(`RTC response: ${JSON.stringify(rtcResult)}`);
if (rtcResult.rtc_module_time === 'not connected') {
updateTestStatus(sensor.id, 'Failed', 'RTC module not connected', 'bg-danger');
testsFailed++;
} else if (rtcResult.rtc_module_time) {
const timeDiff = rtcResult.time_difference_seconds;
if (typeof timeDiff === 'number' && Math.abs(timeDiff) <= 60) {
updateTestStatus(sensor.id, 'Passed', `${rtcResult.rtc_module_time} (sync OK, diff: ${timeDiff}s)`, 'bg-success');
testsPassed++;
} else if (typeof timeDiff === 'number') {
updateTestStatus(sensor.id, 'Warning', `${rtcResult.rtc_module_time} (out of sync: ${timeDiff}s)`, 'bg-warning');
testsFailed++;
} else {
updateTestStatus(sensor.id, 'Passed', `${rtcResult.rtc_module_time}`, 'bg-success');
testsPassed++;
}
} else {
updateTestStatus(sensor.id, 'Warning', 'Unexpected response', 'bg-warning');
testsFailed++;
}
}
} catch (error) {
addSelfTestLog(`${sensor.name} test error: ${error.message}`);
updateTestStatus(sensor.id, 'Failed', error.message, 'bg-danger');
selfTestReport.rawResponses[`${sensor.name}`] = `ERROR: ${error.message}`;
testsFailed++;
}
}
// ═══════════════════════════════════════════════════════
// COMMUNICATION TESTS - WiFi, Modem, SIM, Signal, Network
// ═══════════════════════════════════════════════════════
addSelfTestLog('');
addSelfTestLog('────────────────────────────────────────────────────────');
addSelfTestLog('COMMUNICATION TESTS');
addSelfTestLog('────────────────────────────────────────────────────────');
document.getElementById('comm_tests_separator').style.display = '';
// Check WiFi / Network status (informational, no pass/fail)
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-primary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Checking network status...</span>
</div>`;
updateTestStatus('wifi', 'Checking...', 'Getting network info...', 'bg-info');
try {
const wifiResponse = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=wifi_status',
dataType: 'json',
method: 'GET',
success: function(data) {
addSelfTestLog(`WiFi status received`);
// Store raw response
selfTestReport.rawResponses['WiFi Status'] = JSON.stringify(data, null, 2);
resolve(data);
},
error: function(xhr, status, error) {
addSelfTestLog(`WiFi status error: ${error}`);
selfTestReport.rawResponses['WiFi Status'] = `ERROR: ${error}`;
reject(new Error(error));
}
});
});
// Log detailed WiFi info
addSelfTestLog(`Mode: ${wifiResponse.mode}, SSID: ${wifiResponse.ssid}, IP: ${wifiResponse.ip}, Hostname: ${wifiResponse.hostname}`);
if (wifiResponse.connected) {
let modeLabel = '';
let badgeClass = 'bg-info';
if (wifiResponse.mode === 'hotspot') {
modeLabel = 'Hotspot';
badgeClass = 'bg-warning text-dark';
} else if (wifiResponse.mode === 'wifi') {
modeLabel = 'WiFi';
badgeClass = 'bg-info';
} else if (wifiResponse.mode === 'ethernet') {
modeLabel = 'Ethernet';
badgeClass = 'bg-info';
}
const detailText = `${wifiResponse.ssid} | ${wifiResponse.ip} | ${wifiResponse.hostname}.local`;
updateTestStatus('wifi', modeLabel, detailText, badgeClass);
} else {
updateTestStatus('wifi', 'Disconnected', 'No network connection', 'bg-secondary');
}
} catch (error) {
updateTestStatus('wifi', 'Error', error.message, 'bg-secondary');
}
await delaySelfTest(500);
// Enable config mode
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-primary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Enabling configuration mode...</span>
</div>`;
await setConfigMode(true);
// Wait for SARA script to release the port (2 seconds should be enough)
addSelfTestLog('Waiting for modem port to be available...');
await delaySelfTest(2000);
// Test Modem Connection (ATI)
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-primary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Testing modem connection...</span>
</div>`;
updateTestStatus('modem', 'Testing...', 'Sending ATI command...', 'bg-info');
try {
const modemResponse = await sendATCommand('ATI', 5);
if (modemResponse.includes('OK') && (modemResponse.toUpperCase().includes('SARA-R5') || modemResponse.toUpperCase().includes('SARA-R4'))) {
// Extract model
const modelMatch = modemResponse.match(/SARA-R[45]\d*[A-Z]*-\d+[A-Z]*-\d+/i);
const model = modelMatch ? modelMatch[0] : 'SARA module';
updateTestStatus('modem', 'Passed', `Model: ${model}`, 'bg-success');
testsPassed++;
} else if (modemResponse.includes('OK')) {
updateTestStatus('modem', 'Passed', 'Modem responding', 'bg-success');
testsPassed++;
} else {
updateTestStatus('modem', 'Failed', 'No valid response', 'bg-danger');
testsFailed++;
}
} catch (error) {
updateTestStatus('modem', 'Failed', error.message, 'bg-danger');
testsFailed++;
}
// Delay between AT commands
await delaySelfTest(1000);
// Test SIM Card (AT+CCID?)
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-primary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Testing SIM card...</span>
</div>`;
updateTestStatus('sim', 'Testing...', 'Sending AT+CCID? command...', 'bg-info');
try {
const simResponse = await sendATCommand('AT+CCID?', 5);
const ccidMatch = simResponse.match(/\+CCID:\s*(\d{18,22})/);
if (simResponse.includes('OK') && ccidMatch) {
const iccid = ccidMatch[1];
// Show last 4 digits only for privacy
const maskedIccid = '****' + iccid.slice(-4);
updateTestStatus('sim', 'Passed', `ICCID: ...${maskedIccid}`, 'bg-success');
testsPassed++;
} else if (simResponse.includes('ERROR')) {
updateTestStatus('sim', 'Failed', 'SIM card not detected', 'bg-danger');
testsFailed++;
} else {
updateTestStatus('sim', 'Warning', 'Unable to read ICCID', 'bg-warning');
testsFailed++;
}
} catch (error) {
updateTestStatus('sim', 'Failed', error.message, 'bg-danger');
testsFailed++;
}
// Delay between AT commands
await delaySelfTest(1000);
// Test Signal Strength (AT+CSQ)
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-primary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Testing signal strength...</span>
</div>`;
updateTestStatus('signal', 'Testing...', 'Sending AT+CSQ command...', 'bg-info');
try {
const signalResponse = await sendATCommand('AT+CSQ', 5);
const csqMatch = signalResponse.match(/\+CSQ:\s*(\d+),(\d+)/);
if (signalResponse.includes('OK') && csqMatch) {
const signalPower = parseInt(csqMatch[1]);
if (signalPower === 99) {
updateTestStatus('signal', 'Failed', 'No signal detected', 'bg-danger');
testsFailed++;
} else if (signalPower === 0) {
updateTestStatus('signal', 'Warning', 'Very poor signal (0/31)', 'bg-warning');
testsFailed++;
} else if (signalPower <= 24) {
updateTestStatus('signal', 'Passed', `Poor signal (${signalPower}/31)`, 'bg-success');
testsPassed++;
} else if (signalPower <= 26) {
updateTestStatus('signal', 'Passed', `Good signal (${signalPower}/31)`, 'bg-success');
testsPassed++;
} else if (signalPower <= 28) {
updateTestStatus('signal', 'Passed', `Very good signal (${signalPower}/31)`, 'bg-success');
testsPassed++;
} else {
updateTestStatus('signal', 'Passed', `Excellent signal (${signalPower}/31)`, 'bg-success');
testsPassed++;
}
} else if (signalResponse.includes('ERROR')) {
updateTestStatus('signal', 'Failed', 'Unable to read signal', 'bg-danger');
testsFailed++;
} else {
updateTestStatus('signal', 'Warning', 'Unexpected response', 'bg-warning');
testsFailed++;
}
} catch (error) {
updateTestStatus('signal', 'Failed', error.message, 'bg-danger');
testsFailed++;
}
// Delay between AT commands
await delaySelfTest(1000);
// Test Network Connection (AT+COPS?)
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-primary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Testing network connection...</span>
</div>`;
updateTestStatus('network', 'Testing...', 'Sending AT+COPS? command...', 'bg-info');
try {
// Load operators data for network name lookup
let opData = null;
try {
opData = await loadOperatorsDataSelfTest();
} catch (e) {
addSelfTestLog('Warning: Could not load operators data');
}
const networkResponse = await sendATCommand('AT+COPS?', 5);
const copsMatch = networkResponse.match(/\+COPS:\s*(\d+)(?:,(\d+),"?([^",]+)"?,(\d+))?/);
if (networkResponse.includes('OK') && copsMatch) {
const mode = copsMatch[1];
const oper = copsMatch[3];
const act = copsMatch[4];
if (oper) {
// Get operator name from lookup table
let operatorName = oper;
if (opData && opData.operators && opData.operators[oper]) {
operatorName = opData.operators[oper].name;
}
// Get access technology
let actDesc = 'Unknown';
if (opData && opData.accessTechnology && opData.accessTechnology[act]) {
actDesc = opData.accessTechnology[act];
}
updateTestStatus('network', 'Passed', `${operatorName} (${actDesc})`, 'bg-success');
testsPassed++;
} else {
updateTestStatus('network', 'Warning', 'Not registered to network', 'bg-warning');
testsFailed++;
}
} else if (networkResponse.includes('ERROR')) {
updateTestStatus('network', 'Failed', 'Unable to get network info', 'bg-danger');
testsFailed++;
} else {
updateTestStatus('network', 'Warning', 'Unexpected response', 'bg-warning');
testsFailed++;
}
} catch (error) {
updateTestStatus('network', 'Failed', error.message, 'bg-danger');
testsFailed++;
}
} catch (error) {
addSelfTestLog(`Test sequence error: ${error.message}`);
} finally {
// Always disable config mode at the end
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center text-primary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Disabling configuration mode...</span>
</div>`;
try {
await delaySelfTest(500);
await setConfigMode(false);
} catch (error) {
addSelfTestLog(`Warning: Failed to disable config mode: ${error.message}`);
}
// Show final status
const totalTests = testsPassed + testsFailed;
let statusClass, statusIcon, statusText;
if (testsFailed === 0) {
statusClass = 'text-success';
statusIcon = '✓';
statusText = 'All tests passed';
} else if (testsPassed === 0) {
statusClass = 'text-danger';
statusIcon = '✗';
statusText = 'All tests failed';
} else {
statusClass = 'text-warning';
statusIcon = '!';
statusText = 'Some tests failed';
}
document.getElementById('selftest_status').innerHTML = `
<div class="d-flex align-items-center ${statusClass}">
<span class="fs-4 me-2">${statusIcon}</span>
<span><strong>${statusText}</strong></span>
</div>`;
document.getElementById('selftest_summary').innerHTML = `
<span class="badge bg-success me-1">${testsPassed} passed</span>
<span class="badge bg-danger">${testsFailed} failed</span>`;
// Store summary in report
selfTestReport.summary = {
passed: testsPassed,
failed: testsFailed,
status: statusText
};
// Enable buttons
document.getElementById('selfTestCloseBtn').disabled = false;
document.getElementById('selfTestDoneBtn').disabled = false;
document.getElementById('selfTestCopyBtn').disabled = false;
document.querySelectorAll('.btn_selfTest').forEach(btn => btn.disabled = false);
addSelfTestLog('Self test completed.');
addSelfTestLog('Click "Copy Report" to share results with support.');
}
}
function generateReport() {
// Build formatted report
let report = `===============================================================
NEBULEAIR PRO 4G - SELF TEST REPORT
===============================================================
DEVICE INFORMATION
------------------
Device ID: ${selfTestReport.deviceId || 'Unknown'}
Device Name: ${selfTestReport.deviceName || 'Unknown'}
Modem Version: ${selfTestReport.modemVersion || 'Unknown'}
System Time: ${selfTestReport.systemTime || 'Unknown'}
Report Date: ${selfTestReport.timestamp}
GPS Location: ${selfTestReport.latitude || 'N/A'}, ${selfTestReport.longitude || 'N/A'}
===============================================================
TEST RESULTS
===============================================================
`;
// Add test results (sensors first, then communication)
const testNames = {
npm: 'NextPM (Particles)',
bme280: 'BME280 (Temp/Hum)',
noise: 'Noise Sensor',
envea: 'Envea (Gas Sensors)',
rtc: 'RTC Module (DS3231)',
wifi: 'WiFi/Network',
modem: 'Modem Connection',
sim: 'SIM Card',
signal: 'Signal Strength',
network: 'Network Connection'
};
for (const [testId, name] of Object.entries(testNames)) {
if (selfTestReport.results[testId]) {
const result = selfTestReport.results[testId];
const statusIcon = result.status === 'Passed' ? '[OK]' :
result.status === 'Failed' ? '[FAIL]' :
result.status.includes('Hotspot') || result.status.includes('WiFi') || result.status.includes('Ethernet') ? '[INFO]' : '[WARN]';
report += `${statusIcon} ${name}
Status: ${result.status}
Detail: ${result.detail}
`;
}
}
// Add summary
if (selfTestReport.summary) {
report += `===============================================================
SUMMARY
===============================================================
Passed: ${selfTestReport.summary.passed}
Failed: ${selfTestReport.summary.failed}
Status: ${selfTestReport.summary.status}
`;
}
// Add raw AT responses
report += `===============================================================
RAW AT RESPONSES
===============================================================
`;
for (const [command, response] of Object.entries(selfTestReport.rawResponses)) {
report += `--- ${command} ---
${response}
`;
}
// Add full logs
report += `===============================================================
DETAILED LOGS
===============================================================
${document.getElementById('selftest_logs').textContent}
===============================================================
END OF REPORT - Generated by NebuleAir Pro 4G
===============================================================
`;
return report;
}
function openShareReportModal() {
// Generate the report
const report = generateReport();
// Put report in textarea
document.getElementById('shareReportText').value = report;
// Open the share modal
const shareModal = new bootstrap.Modal(document.getElementById('shareReportModal'));
shareModal.show();
}
function selectAllReportText() {
const textarea = document.getElementById('shareReportText');
textarea.select();
textarea.setSelectionRange(0, textarea.value.length); // For mobile devices
}
function downloadReport() {
const report = generateReport();
// Create filename with device ID
const deviceId = selfTestReport.deviceId || 'unknown';
const date = new Date().toISOString().slice(0, 10);
const filename = `logs_nebuleair_${deviceId}_${date}.txt`;
// Create blob and download
const blob = new Blob([report], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
// Cleanup
setTimeout(function() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
}
// Load the self-test modal HTML into the page
function initSelfTestModal() {
fetch('selftest-modal.html')
.then(response => response.text())
.then(html => {
// Insert modal HTML before </body>
const container = document.createElement('div');
container.id = 'selftest-modal-container';
container.innerHTML = html;
document.body.appendChild(container);
})
.catch(error => console.error('Error loading selftest modal:', error));
}
// Auto-init when DOM is ready
document.addEventListener('DOMContentLoaded', initSelfTestModal);

View File

@@ -0,0 +1,58 @@
/**
* Global configuration handler for UI elements
* - Updates Topbar Logo based on device type
* - Shows/Hides "Screen" sidebar tab based on device type
*/
document.addEventListener('DOMContentLoaded', () => {
let config = null;
// Fetch config once
fetch('launcher.php?type=get_config_sqlite')
.then(response => response.json())
.then(data => {
config = data;
applyConfig(); // Apply immediately if elements are ready
})
.catch(error => console.error('Error loading config:', error));
// Observe DOM changes to handle dynamically loaded elements (sidebar, topbar)
const observer = new MutationObserver(() => {
if (config) applyConfig();
});
observer.observe(document.body, { childList: true, subtree: true });
function applyConfig() {
if (!config) return;
const isModuleAirPro = (config.device_type === 'moduleair_pro' || config.type === 'moduleair_pro');
// 1. Topbar Logo Logic
const logo = document.getElementById('topbar-logo');
if (logo && isModuleAirPro) {
// prevent unnecessary re-assignments
if (!logo.src.includes('logoModuleAir.png')) {
logo.src = 'assets/img/logoModuleAir.png';
}
}
// 2. Sidebar Screen Tab Logic - Use class since ID might be duplicated (desktop/mobile)
const navScreenElements = document.querySelectorAll('.nav-screen-item');
if (navScreenElements.length > 0) {
navScreenElements.forEach(navScreen => {
if (isModuleAirPro) {
// Ensure it's visible (bootstrap nav-link usually block or flex)
if (navScreen.style.display === 'none') {
navScreen.style.display = 'flex';
}
} else {
// Hide if not pro
if (navScreen.style.display !== 'none') {
navScreen.style.display = 'none';
}
}
});
}
}
});

View File

@@ -26,6 +26,13 @@
.offcanvas-backdrop { .offcanvas-backdrop {
z-index: 1040; z-index: 1040;
} }
/* Highlight most recent data row with light green background */
.table .most-recent-row td {
background-color: #d4edda !important;
}
.table-striped .most-recent-row td {
background-color: #d4edda !important;
}
</style> </style>
</head> </head>
<body> <body>
@@ -49,66 +56,108 @@
</aside> </aside>
<!-- Main content --> <!-- Main content -->
<main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4"> <main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4">Base de données</h1> <h1 class="mt-4" data-i18n="database.title">Base de données</h1>
<p>Le capteur enregistre en local les données de mesures. Vous pouvez ici les consulter et les télécharger.</p> <p data-i18n="database.description">Le capteur enregistre en local les données de mesures. Vous pouvez ici les consulter et les télécharger.</p>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-5"> <div class="col-lg-4 col-md-6 mb-3">
<div class="card text-dark bg-light"> <div class="card text-dark bg-light h-100">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Consulter la base de donnée</h5> <h5 class="card-title" data-i18n="database.viewDatabase">Consulter la base de donnée</h5>
<!-- Dropdown to select number of records --> <!-- Dropdown to select number of records -->
<div class="d-flex align-items-center mb-3"> <div class="d-flex align-items-center mb-3">
<label for="records_limit" class="form-label me-2">Nombre de mesures:</label> <label for="records_limit" class="form-label me-2" data-i18n="database.numberOfMeasures">Nombre de mesures:</label>
<select id="records_limit" class="form-select w-auto"> <select id="records_limit" class="form-select w-auto">
<option value="10" selected>10 dernières</option> <option value="10" selected data-i18n="database.last10">10 dernières</option>
<option value="20">20 dernières</option> <option value="20" data-i18n="database.last20">20 dernières</option>
<option value="30">30 dernières</option> <option value="30" data-i18n="database.last30">30 dernières</option>
</select> </select>
</div> </div>
<button class="btn btn-primary" onclick="get_data_sqlite('data_NPM',getSelectedLimit(),false)">Mesures PM</button> <button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_NPM',getSelectedLimit(),false)" data-i18n="database.pmMeasures">Mesures PM</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_BME280',getSelectedLimit(),false)">Mesures Temp/Hum</button> <button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_BME280',getSelectedLimit(),false)" data-i18n="database.tempHumMeasures">Mesures Temp/Hum</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_NPM_5channels',getSelectedLimit(),false)">Mesures PM (5 canaux)</button> <button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_NPM_5channels',getSelectedLimit(),false)" data-i18n="database.pm5Channels">Mesures PM (5 canaux)</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_envea',getSelectedLimit(),false)">Sonde Cairsens</button> <button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_envea',getSelectedLimit(),false)" data-i18n="database.cairsensProbe">Sonde Cairsens</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_NOISE',getSelectedLimit(),false)">Sonde bruit</button> <button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_NOISE',getSelectedLimit(),false)" data-i18n="database.noiseProbe">Sonde bruit</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_WIND',getSelectedLimit(),false)" data-i18n="database.windProbe">Sonde Vent</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_WIND',getSelectedLimit(),false)">Sonde Vent</button> <button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_MPPT',getSelectedLimit(),false)" data-i18n="database.battery">Batterie</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_MPPT',getSelectedLimit(),false)">Batterie</button> <button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_MHZ19',getSelectedLimit(),false)">Mesures CO2</button>
<button class="btn btn-warning mb-2" onclick="get_data_sqlite('timestamp_table',getSelectedLimit(),false)" data-i18n="database.timestampTable">Timestamp Table</button>
<button class="btn btn-warning" onclick="get_data_sqlite('timestamp_table',getSelectedLimit(),false)">Timestamp Table</button>
</div> </div>
</div> </div>
</div> </div>
<div class="col-sm-5"> <div class="col-lg-4 col-md-6 mb-3">
<div class="card text-dark bg-light"> <div class="card text-dark bg-light h-100">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Télécharger les données</h5> <h5 class="card-title" data-i18n="database.downloadData">Télécharger les données</h5>
<!-- Date selection for download --> <!-- Date selection for download -->
<div class="d-flex align-items-center gap-3 mb-3"> <div class="d-flex align-items-center gap-3 mb-3">
<label for="start_date" class="form-label">Date de début:</label> <label for="start_date" class="form-label" data-i18n="database.startDate">Date de début:</label>
<input type="date" id="start_date" class="form-control w-auto"> <input type="date" id="start_date" class="form-control w-auto">
<label for="end_date" class="form-label">Date de fin:</label> <label for="end_date" class="form-label" data-i18n="database.endDate">Date de fin:</label>
<input type="date" id="end_date" class="form-control w-auto"> <input type="date" id="end_date" class="form-control w-auto">
</div> </div>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_NPM')" data-i18n="database.pmMeasures">Mesures PM</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_NPM',10,true, getStartDate(), getEndDate())">Mesures PM</button> <button class="btn btn-primary mb-2" onclick="downloadByDate('data_BME280')" data-i18n="database.tempHumMeasures">Mesures Temp/Hum</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_BME280',10,true, getStartDate(), getEndDate())">Mesures Temp/Hum</button> <button class="btn btn-primary mb-2" onclick="downloadByDate('data_NPM_5channels')" data-i18n="database.pm5Channels">Mesures PM (5 canaux)</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_NPM_5channels',10,true, getStartDate(), getEndDate())">Mesures PM (5 canaux)</button> <button class="btn btn-primary mb-2" onclick="downloadByDate('data_envea')" data-i18n="database.cairsensProbe">Sonde Cairsens</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_envea',10,true, getStartDate(), getEndDate())">Sonde Cairsens</button> <button class="btn btn-primary mb-2" onclick="downloadByDate('data_NOISE')" data-i18n="database.noiseProbe">Sonde Bruit</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_NOISE',10,true, getStartDate(), getEndDate())">Sonde Bruit</button> <button class="btn btn-primary mb-2" onclick="downloadByDate('data_mppt')" data-i18n="database.battery">Batterie</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_MHZ19')">Mesures CO2</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_mppt',10,true, getStartDate(), getEndDate())">Batterie</button>
</table>
</div> </div>
</div> </div>
</div> </div>
<div> <div class="col-lg-4 col-md-6 mb-3">
<div class="card text-dark bg-light h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.downloadAll">Télécharger toute la table</h5>
<p class="text-muted small" data-i18n="database.downloadAllDesc">Télécharge l'intégralité des données sans filtre de date.</p>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_NPM')" data-i18n="database.pmMeasures">Mesures PM</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_BME280')" data-i18n="database.tempHumMeasures">Mesures Temp/Hum</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_NPM_5channels')" data-i18n="database.pm5Channels">Mesures PM (5 canaux)</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_envea')" data-i18n="database.cairsensProbe">Sonde Cairsens</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_NOISE')" data-i18n="database.noiseProbe">Sonde Bruit</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_MPPT')" data-i18n="database.battery">Batterie</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_MHZ19')">Mesures CO2</button>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-lg-4 col-md-6 mb-3">
<div class="card text-dark bg-light h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.statsTitle">Informations sur la base</h5>
<div id="db_stats_content">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2" data-i18n="common.loading">Chargement...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-lg-4 col-md-6 mb-3">
<div class="card text-white bg-danger h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.dangerZone">Zone dangereuse</h5>
<p class="card-text" data-i18n="database.dangerWarning">Attention: Cette action est irréversible!</p>
<button class="btn btn-dark btn-lg w-100 mb-2" onclick="emptySensorTables()" data-i18n="database.emptyAllTables">Vider toutes les tables de capteurs</button>
<small class="d-block mt-2" data-i18n="database.emptyTablesNote">Note: Les tables de configuration et horodatage seront préservées.</small>
</div>
</div>
</div>
</div>
<div class="row mt-2"> <div class="row mt-2">
<div id="table_data"></div> <div id="table_data"></div>
@@ -126,6 +175,7 @@
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system --> <!-- i18n translation system -->
<script src="assets/js/i18n.js"></script> <script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
@@ -137,6 +187,19 @@
{ id: 'sidebar_mobile', file: 'sidebar.html' } { id: 'sidebar_mobile', file: 'sidebar.html' }
]; ];
let loadedCount = 0;
const totalElements = elementsToLoad.length;
function applyTranslationsWhenReady() {
if (typeof i18n !== 'undefined' && i18n.translations && Object.keys(i18n.translations).length > 0) {
console.log("Applying translations to dynamically loaded content");
i18n.applyTranslations();
} else {
// Retry after a short delay if translations aren't loaded yet
setTimeout(applyTranslationsWhenReady, 100);
}
}
elementsToLoad.forEach(({ id, file }) => { elementsToLoad.forEach(({ id, file }) => {
fetch(file) fetch(file)
.then(response => response.text()) .then(response => response.text())
@@ -145,11 +208,22 @@
if (element) { if (element) {
element.innerHTML = data; element.innerHTML = data;
} }
loadedCount++;
// Re-apply translations after all dynamic content is loaded
if (loadedCount === totalElements) {
applyTranslationsWhenReady();
}
}) })
.catch(error => console.error(`Error loading ${file}:`, error)); .catch(error => console.error(`Error loading ${file}:`, error));
}); });
// Also listen for language change events to re-apply translations
document.addEventListener('languageChanged', function() {
console.log("Language changed, re-applying translations");
i18n.applyTranslations();
});
}); });
@@ -189,6 +263,9 @@ window.onload = function() {
}); //end ajax }); //end ajax
// Get database table stats
loadDbStats();
//get local RTC //get local RTC
$.ajax({ $.ajax({
url: 'launcher.php?type=RTC_time', url: 'launcher.php?type=RTC_time',
@@ -303,7 +380,12 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<th>Timestamp</th> <th>Timestamp</th>
<th>Curent LEQ</th> <th>Curent LEQ</th>
<th>DB_A_value</th> <th>DB_A_value</th>
`;
}else if (table === "data_MHZ19") {
tableHTML += `
<th>Timestamp</th>
<th>CO2 (ppm)</th>
`; `;
} }
@@ -311,9 +393,11 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
tableHTML += `</tr></thead><tbody>`; tableHTML += `</tr></thead><tbody>`;
// Loop through rows and create table rows // Loop through rows and create table rows
rows.forEach(row => { rows.forEach((row, index) => {
let columns = row.replace(/[()]/g, "").split(", "); // Remove parentheses and split let columns = row.replace(/[()]/g, "").split(", "); // Remove parentheses and split
tableHTML += "<tr>"; // Add special class to first row (most recent data)
const rowClass = index === 0 ? ' class="most-recent-row"' : '';
tableHTML += `<tr${rowClass}>`;
if (table === "data_NPM") { if (table === "data_NPM") {
tableHTML += ` tableHTML += `
@@ -376,7 +460,12 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<td>${columns[0]}</td> <td>${columns[0]}</td>
<td>${columns[1]}</td> <td>${columns[1]}</td>
<td>${columns[2]}</td> <td>${columns[2]}</td>
`;
}else if (table === "data_MHZ19") {
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
`; `;
} }
@@ -402,11 +491,25 @@ function getSelectedLimit() {
} }
function getStartDate() { function getStartDate() {
return document.getElementById("start_date").value || "2025-01-01"; // Default to a safe date return document.getElementById("start_date").value;
} }
function getEndDate() { function getEndDate() {
return document.getElementById("end_date").value || "2025-12-31"; // Default to a safe date return document.getElementById("end_date").value;
}
function downloadByDate(table) {
const startDate = getStartDate();
const endDate = getEndDate();
if (!startDate || !endDate) {
alert("Veuillez sélectionner une date de début et une date de fin.");
return;
}
get_data_sqlite(table, 10, true, startDate, endDate);
}
function downloadFullTable(table) {
window.location.href = 'launcher.php?type=download_full_table&table=' + encodeURIComponent(table);
} }
function downloadCSV(response, table) { function downloadCSV(response, table) {
@@ -423,6 +526,9 @@ function downloadCSV(response, table) {
else if (table === "data_NPM_5channels") { else if (table === "data_NPM_5channels") {
csvContent += "TimestampUTC,PM_ch1,PM_ch2,PM_ch3,PM_ch4,PM_ch5\n"; csvContent += "TimestampUTC,PM_ch1,PM_ch2,PM_ch3,PM_ch4,PM_ch5\n";
} }
else if (table === "data_MHZ19") {
csvContent += "TimestampUTC,CO2_ppm\n";
}
// Format rows as CSV // Format rows as CSV
rows.forEach(row => { rows.forEach(row => {
@@ -441,6 +547,140 @@ function downloadCSV(response, table) {
document.body.removeChild(a); document.body.removeChild(a);
} }
// Table display names
const tableDisplayNames = {
'data_NPM': 'PM (NextPM)',
'data_NPM_5channels': 'PM 5 canaux',
'data_BME280': 'Temp/Hum (BME280)',
'data_envea': 'Gaz (Cairsens)',
'data_WIND': 'Vent',
'data_MPPT': 'Batterie (MPPT)',
'data_NOISE': 'Bruit'
};
function loadDbStats() {
$.ajax({
url: 'launcher.php?type=db_table_stats',
dataType: 'json',
method: 'GET',
success: function(response) {
if (!response.success) {
document.getElementById('db_stats_content').innerHTML =
'<div class="alert alert-danger mb-0">' + (response.error || 'Erreur') + '</div>';
return;
}
let html = '<p class="mb-2"><strong data-i18n="database.statsDbSize">Taille totale:</strong> ' + response.size_mb + ' MB</p>';
html += '<div class="table-responsive"><table class="table table-sm table-bordered mb-0">';
html += '<thead class="table-secondary"><tr>';
html += '<th data-i18n="database.statsTable">Table</th>';
html += '<th data-i18n="database.statsCount">Entrées</th>';
html += '<th data-i18n="database.statsOldest">Plus ancienne</th>';
html += '<th data-i18n="database.statsNewest">Plus récente</th>';
html += '<th data-i18n="database.statsDownload">CSV</th>';
html += '</tr></thead><tbody>';
response.tables.forEach(function(t) {
const displayName = tableDisplayNames[t.name] || t.name;
const oldest = t.oldest ? t.oldest.substring(0, 16) : '-';
const newest = t.newest ? t.newest.substring(0, 16) : '-';
const downloadBtn = t.count > 0
? '<a href="launcher.php?type=download_full_table&table=' + t.name + '" class="btn btn-outline-primary btn-sm py-0 px-1" title="Download CSV"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg></a>'
: '-';
html += '<tr>';
html += '<td>' + displayName + '</td>';
html += '<td>' + t.count.toLocaleString() + '</td>';
html += '<td><small>' + oldest + '</small></td>';
html += '<td><small>' + newest + '</small></td>';
html += '<td class="text-center">' + downloadBtn + '</td>';
html += '</tr>';
});
html += '</tbody></table></div>';
html += '<button class="btn btn-outline-secondary btn-sm mt-2" onclick="loadDbStats()" data-i18n="logs.refresh">Refresh</button>';
document.getElementById('db_stats_content').innerHTML = html;
// Re-apply translations if i18n is loaded
if (typeof i18n !== 'undefined' && i18n.translations && Object.keys(i18n.translations).length > 0) {
i18n.applyTranslations();
}
},
error: function(xhr, status, error) {
document.getElementById('db_stats_content').innerHTML =
'<div class="alert alert-danger mb-0">Erreur: ' + error + '</div>';
}
});
}
// Function to empty all sensor tables
function emptySensorTables() {
// Show confirmation dialog
const confirmed = confirm(
"WARNING: This will permanently delete ALL sensor data from the database!\n\n" +
"The following tables will be emptied:\n" +
"- data_NPM\n" +
"- data_NPM_5channels\n" +
"- data_BME280\n" +
"- data_envea\n" +
"- data_WIND\n" +
"- data_MPPT\n" +
"- data_NOISE\n\n" +
"Configuration and timestamp tables will be preserved.\n\n" +
"Are you absolutely sure you want to continue?"
);
if (!confirmed) {
console.log("Empty sensor tables operation cancelled by user");
return;
}
// Show loading message
const tableDataDiv = document.getElementById("table_data");
tableDataDiv.innerHTML = '<div class="alert alert-info">Emptying sensor tables... Please wait...</div>';
// Make AJAX request to empty tables
$.ajax({
url: 'launcher.php?type=empty_sensor_tables',
dataType: 'json',
method: 'GET',
success: function(response) {
console.log("Empty sensor tables response:", response);
if (response.success) {
// Show success message
let message = '<div class="alert alert-success">';
message += '<h5>Success!</h5>';
message += '<p>' + response.message + '</p>';
if (response.tables_processed && response.tables_processed.length > 0) {
message += '<p><strong>Tables emptied:</strong></p><ul>';
response.tables_processed.forEach(table => {
message += `<li>${table.name}: ${table.deleted} records deleted</li>`;
});
message += '</ul>';
}
message += '</div>';
tableDataDiv.innerHTML = message;
} else {
// Show error message
tableDataDiv.innerHTML = `<div class="alert alert-danger">
<h5>Error!</h5>
<p>${response.message || response.error || 'Unknown error occurred'}</p>
</div>`;
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
tableDataDiv.innerHTML = `<div class="alert alert-danger">
<h5>Error!</h5>
<p>Failed to empty sensor tables: ${error}</p>
</div>`;
}
});
}
</script> </script>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -11,80 +12,100 @@
body { body {
overflow-x: hidden; overflow-x: hidden;
} }
#sidebar a.nav-link { #sidebar a.nav-link {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
} }
#sidebar a.nav-link:hover { #sidebar a.nav-link:hover {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
} }
#sidebar a.nav-link svg { #sidebar a.nav-link svg {
margin-right: 8px; /* Add spacing between icons and text */ margin-right: 8px;
/* Add spacing between icons and text */
} }
#sidebar { #sidebar {
transition: transform 0.3s ease-in-out; transition: transform 0.3s ease-in-out;
} }
.offcanvas-backdrop { .offcanvas-backdrop {
z-index: 1040; z-index: 1040;
} }
</style> </style>
</head> </head>
<body> <body>
<!-- Topbar --> <!-- Topbar -->
<span id="topbar"></span> <span id="topbar"></span>
<!-- Sidebar Offcanvas for Mobile --> <!-- Sidebar Offcanvas for Mobile -->
<div class="offcanvas offcanvas-start text-white bg-dark" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel"> <div class="offcanvas offcanvas-start text-white bg-dark" tabindex="-1" id="sidebarOffcanvas"
aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header"> <div class="offcanvas-header">
<h5 class="offcanvas-title" id="sidebarOffcanvasLabel">NebuleAir</h5> <h5 class="offcanvas-title" id="sidebarOffcanvasLabel">NebuleAir</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div> </div>
<div class="offcanvas-body" id="sidebar_mobile"> <div class="offcanvas-body" id="sidebar_mobile">
</div> </div>
</div> </div>
<div class="container-fluid mt-5"> <div class="container-fluid mt-5">
<div class="row"> <div class="row">
<aside class="col-md-2 col-lg-1 d-none d-md-block vh-100 position-fixed bg-dark text-white" id="sidebar"> <aside class="col-md-2 col-lg-1 d-none d-md-block vh-100 position-fixed bg-dark text-white" id="sidebar">
</aside> </aside>
<!-- Main content --> <!-- Main content -->
<main class="col-md-9 ms-sm-auto col-lg-10 offset-md-3 offset-lg-2 px-md-4"> <main class="col-md-9 ms-sm-auto col-lg-10 offset-md-3 offset-lg-2 px-md-4">
<h1 class="mt-4">Votre capteur</h1> <h1 class="mt-4" data-i18n="home.title">Votre capteur</h1>
<p>Bienvenue sur votre interface de configuration de votre capteur.</p> <p data-i18n="home.welcome">Bienvenue sur votre interface de configuration de votre capteur.</p>
<button class="btn btn-success mb-3 btn_selfTest" onclick="runSelfTest()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2-circle me-1" viewBox="0 0 16 16">
<path d="M2.5 8a5.5 5.5 0 1 1 11 0 5.5 5.5 0 0 1-11 0z"/>
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l7-7z"/>
</svg>
Run Self Test
</button>
<div class="row mb-3"> <div class="row mb-3">
<!-- Card NPM values --> <!-- Card NPM values -->
<div class="col-sm-4 mt-2"> <div class="col-sm-4 mt-2">
<div class="card"> <div class="card">
<div class="card-body">
<h5 class="card-title">Mesures PM</h5>
<canvas id="sensorPMChart" style="width: 100%; max-width: 600px; height: 200px;"></canvas>
</div>
</div>
</div>
<!-- Card Linux Stats -->
<div class="col-sm-4 mt-2">
<div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Linux stats</h5> <h5 class="card-title" data-i18n="home.pmMeasures">Mesures PM</h5>
<p class="card-text">Disk usage (total size <span id="disk_size"></span> Gb) </p> <canvas id="sensorPMChart" style="width: 100%; max-width: 600px; height: 200px;"></canvas>
<div id="disk_space"></div>
<p class="card-text">Memory usage (total size <span id="memory_size"></span> Mb) </p>
<div id="memory_space"></div>
<p class="card-text"> Database size: <span id="database_size"></span> </p>
</div> </div>
</div> </div>
</div> </div>
<!-- Card Linux Stats -->
<div class="col-sm-4 mt-2">
<div class="card">
<div class="card-body">
<h5 class="card-title" data-i18n="home.linuxStats">Statistiques Linux</h5>
<p class="card-text"><span data-i18n="home.diskUsage">Utilisation du disque (taille totale</span> <span
id="disk_size"></span> Gb) </p>
<div id="disk_space"></div>
<p class="card-text"><span data-i18n="home.memoryUsage">Utilisation de la mémoire (taille totale</span>
<span id="memory_size"></span> Mb)
</p>
<div id="memory_space"></div>
<p class="card-text"><span data-i18n="home.databaseSize">Taille de la base de données:</span> <span
id="database_size"></span> </p>
</div>
</div>
</div>
</div>
<!--
</div>
<!--
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-4 mt-2"> <div class="col-sm-4 mt-2">
@@ -102,364 +123,378 @@
</div> </div>
</div> </div>
<!-- JAVASCRIPT --> <!-- JAVASCRIPT -->
<!-- Link Ajax locally --> <!-- Link Ajax locally -->
<script src="assets/jquery/jquery-3.7.1.min.js"></script> <script src="assets/jquery/jquery-3.7.1.min.js"></script>
<!-- Link Bootstrap JS and Popper.js locally --> <!-- Link Bootstrap JS and Popper.js locally -->
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system --> <!-- i18n translation system -->
<script src="assets/js/i18n.js"></script> <script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script src="assets/js/selftest.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const elementsToLoad = [ const elementsToLoad = [
{ id: 'topbar', file: 'topbar.html' }, { id: 'topbar', file: 'topbar.html' },
{ id: 'sidebar', file: 'sidebar.html' }, { id: 'sidebar', file: 'sidebar.html' },
{ id: 'sidebar_mobile', file: 'sidebar.html' } { id: 'sidebar_mobile', file: 'sidebar.html' }
]; ];
elementsToLoad.forEach(({ id, file }) => { elementsToLoad.forEach(({ id, file }) => {
fetch(file) fetch(file)
.then(response => response.text()) .then(response => response.text())
.then(data => { .then(data => {
const element = document.getElementById(id); const element = document.getElementById(id);
if (element) { if (element) {
element.innerHTML = data; element.innerHTML = data;
} // Apply translations after loading dynamic content
}) if (window.i18n && typeof window.i18n.applyTranslations === 'function') {
.catch(error => console.error(`Error loading ${file}:`, error)); window.i18n.applyTranslations();
}
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
});
}); });
});
window.onload = function () {
window.onload = function() { //NEW way to get data from SQLITE
$.ajax({
url: 'launcher.php?type=get_config_sqlite',
dataType: 'json',
//dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function (response) {
console.log("Getting SQLite config table:");
console.log(response);
//NEW way to get data from SQLITE //get device Name (for the side bar)
$.ajax({ const deviceName = response.deviceName;
url: 'launcher.php?type=get_config_sqlite', const elements = document.querySelectorAll('.sideBar_sensorName');
dataType:'json', elements.forEach((element) => {
//dataType: 'json', // Specify that you expect a JSON response element.innerText = deviceName;
method: 'GET', // Use GET or POST depending on your needs });
success: function(response) {
console.log("Getting SQLite config table:");
console.log(response);
//get device Name (for the side bar) //device name html page title
const deviceName = response.deviceName; if (response.deviceName) {
const elements = document.querySelectorAll('.sideBar_sensorName'); document.title = response.deviceName;
elements.forEach((element) => { }
element.innerText = deviceName;
});
//device name html page title // Check for device type to show Screen tab
if (response.deviceName) { // Assuming the key in config is 'device_type' or 'type'
document.title = response.deviceName; if (response.device_type === 'moduleair_pro' || response.type === 'moduleair_pro') {
} $('.nav-screen-item').show();
$('.nav-screen-item').css('display', 'flex'); // Ensure flex display to match others
}, }
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
}); //end ajax
/* OLD way of getting config data },
fetch('../config.json') // Replace 'deviceID.txt' with 'config.json' error: function (xhr, status, error) {
.then(response => response.json()) // Parse response as JSON console.error('AJAX request failed:', status, error);
.then(data => { }
console.log("Getting config file (onload)"); }); //end ajax
//get device ID
const deviceID = data.deviceID.trim().toUpperCase();
//document.getElementById('pageTitle_plus_ID').innerText = 'token: ' + deviceID;
//get device Name
const deviceName = data.deviceName;
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = deviceName;
});
//end fetch config
})
.catch(error => console.error('Error loading config.json:', error));
//end windows on load
*/
//get local RTC
$.ajax({
url: 'launcher.php?type=RTC_time',
dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log("Local RTC: " + response);
const RTC_Element = document.getElementById("RTC_time");
RTC_Element.textContent = response;
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
//get database size
$.ajax({
url: 'launcher.php?type=database_size',
dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log(response);
if (response.size_megabytes !== undefined) {
// Extract and format the size in MB
const databaseSizeMB = response.size_megabytes + " MB";
// Update the HTML element with the database size
const databaseSizeElement = document.getElementById("database_size");
databaseSizeElement.textContent = databaseSizeMB;
console.log("Database size:", databaseSizeMB);
} else if (response.error) {
// Handle errors from the PHP response
console.error("Error from server:", response.error);
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
/* OLD way of getting config data
fetch('../config.json') // Replace 'deviceID.txt' with 'config.json'
.then(response => response.json()) // Parse response as JSON
.then(data => {
console.log("Getting config file (onload)");
//get device ID
const deviceID = data.deviceID.trim().toUpperCase();
//document.getElementById('pageTitle_plus_ID').innerText = 'token: ' + deviceID;
//get disk free space //get device Name
$.ajax({ const deviceName = data.deviceName;
url: 'launcher.php?type=linux_disk',
dataType: 'text', // Specify that you expect a JSON response const elements = document.querySelectorAll('.sideBar_sensorName');
method: 'GET', // Use GET or POST depending on your needs elements.forEach((element) => {
success: function(response) { element.innerText = deviceName;
console.log("Linux disk space: " + response); });
//1. disk size
const disk_size = document.getElementById("disk_size"); //end fetch config
const firstNumber = response.match(/(?<!\w)(\d+(\.\d+)?)(?=\D)/)[1]; })
.catch(error => console.error('Error loading config.json:', error));
disk_size.innerHTML = firstNumber; //end windows on load
//2. Free space */
const match = response.match(/(\d+)%/); //get local RTC
const diskSpace = document.getElementById("disk_space"); $.ajax({
const percentage = match[1]; url: 'launcher.php?type=RTC_time',
dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function (response) {
console.log("Local RTC: " + response);
const RTC_Element = document.getElementById("RTC_time");
RTC_Element.textContent = response;
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
// Create the outer div with class and attributes //get database size
const progressDiv = document.createElement('div'); $.ajax({
progressDiv.className = 'progress mb-3'; url: 'launcher.php?type=database_size',
progressDiv.setAttribute('role', 'progressbar'); dataType: 'json', // Specify that you expect a JSON response
progressDiv.setAttribute('aria-label', 'Example with label'); method: 'GET', // Use GET or POST depending on your needs
progressDiv.setAttribute('aria-valuenow', percentage); success: function (response) {
progressDiv.setAttribute('aria-valuemin', 0); console.log(response);
progressDiv.setAttribute('aria-valuemax', 100);
// Create the inner progress bar div if (response.size_megabytes !== undefined) {
const progressBarDiv = document.createElement('div'); // Extract and format the size in MB
progressBarDiv.className = 'progress-bar'; const databaseSizeMB = response.size_megabytes + " MB";
progressBarDiv.style.width = `${percentage}%`; // Set the width dynamically
progressBarDiv.textContent = `${percentage}%`; // Set the text dynamically
// Append the progress bar to the outer div // Update the HTML element with the database size
progressDiv.appendChild(progressBarDiv); const databaseSizeElement = document.getElementById("database_size");
databaseSizeElement.textContent = databaseSizeMB;
// Append the entire progress bar to the body (or any other container) console.log("Database size:", databaseSizeMB);
diskSpace.appendChild(progressDiv); } else if (response.error) {
// Handle errors from the PHP response
}, console.error("Error from server:", response.error);
error: function(xhr, status, error) { }
console.error('AJAX request failed:', status, error); },
} error: function (xhr, status, error) {
}); console.error('AJAX request failed:', status, error);
}
//get memory free space });
$.ajax({
url: 'launcher.php?type=linux_memory',
dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log("Linux memory space: " + response);
//1. memory size
const memory_size = document.getElementById("memory_size");
const memorySpace = document.getElementById("memory_space");
const memLine = response.match(/Mem:\s+(\d+\.?\d*)Mi\s+(\d+\.?\d*)Mi/); //get disk free space
const totalMemory = parseFloat(memLine[1]); // Total memory in MiB $.ajax({
const usedMemory = parseFloat(memLine[2]); // Used memory in MiB url: 'launcher.php?type=linux_disk',
dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function (response) {
console.log("Linux disk space: " + response);
//1. disk size
const disk_size = document.getElementById("disk_size");
const firstNumber = response.match(/(?<!\w)(\d+(\.\d+)?)(?=\D)/)[1];
// Calculate the percentage disk_size.innerHTML = firstNumber;
const percentageUsed = ((usedMemory / totalMemory) * 100).toFixed(2); //2. Free space
const match = response.match(/(\d+)%/);
const diskSpace = document.getElementById("disk_space");
const percentage = match[1];
console.log(totalMemory); // Create the outer div with class and attributes
const progressDiv = document.createElement('div');
memory_size.innerHTML = totalMemory; progressDiv.className = 'progress mb-3';
progressDiv.setAttribute('role', 'progressbar');
progressDiv.setAttribute('aria-label', 'Example with label');
progressDiv.setAttribute('aria-valuenow', percentage);
progressDiv.setAttribute('aria-valuemin', 0);
progressDiv.setAttribute('aria-valuemax', 100);
// Create the inner progress bar div
const progressBarDiv = document.createElement('div');
progressBarDiv.className = 'progress-bar';
progressBarDiv.style.width = `${percentage}%`; // Set the width dynamically
progressBarDiv.textContent = `${percentage}%`; // Set the text dynamically
// Append the progress bar to the outer div
progressDiv.appendChild(progressBarDiv);
// Append the entire progress bar to the body (or any other container)
diskSpace.appendChild(progressDiv);
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
//get memory free space
$.ajax({
url: 'launcher.php?type=linux_memory',
dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function (response) {
console.log("Linux memory space: " + response);
//1. memory size
const memory_size = document.getElementById("memory_size");
const memorySpace = document.getElementById("memory_space");
console.log(usedMemory); const memLine = response.match(/Mem:\s+(\d+\.?\d*)Mi\s+(\d+\.?\d*)Mi/);
console.log(percentageUsed); const totalMemory = parseFloat(memLine[1]); // Total memory in MiB
const usedMemory = parseFloat(memLine[2]); // Used memory in MiB
// Create the outer div with class and attributes // Calculate the percentage
const progressDiv = document.createElement('div'); const percentageUsed = ((usedMemory / totalMemory) * 100).toFixed(2);
progressDiv.className = 'progress mb-3';
progressDiv.setAttribute('role', 'progressbar');
progressDiv.setAttribute('aria-label', 'Example with label');
progressDiv.setAttribute('aria-valuenow', percentageUsed);
progressDiv.setAttribute('aria-valuemin', 0);
progressDiv.setAttribute('aria-valuemax', 100);
// Create the inner progress bar div console.log(totalMemory);
const progressBarDiv = document.createElement('div');
progressBarDiv.className = 'progress-bar';
progressBarDiv.style.width = `${percentageUsed}%`; // Set the width dynamically
progressBarDiv.textContent = `${percentageUsed}%`; // Set the text dynamically
// Append the progress bar to the outer div memory_size.innerHTML = totalMemory;
progressDiv.appendChild(progressBarDiv);
// Append the entire progress bar to the body (or any other container)
memorySpace.appendChild(progressDiv);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
// GET NPM SQLite values console.log(usedMemory);
$.ajax({ console.log(percentageUsed);
url: 'launcher.php?type=get_npm_sqlite_data',
dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log(response);
updatePMChart(response);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
let chart; // Store the Chart.js instance globally // Create the outer div with class and attributes
const progressDiv = document.createElement('div');
progressDiv.className = 'progress mb-3';
progressDiv.setAttribute('role', 'progressbar');
progressDiv.setAttribute('aria-label', 'Example with label');
progressDiv.setAttribute('aria-valuenow', percentageUsed);
progressDiv.setAttribute('aria-valuemin', 0);
progressDiv.setAttribute('aria-valuemax', 100);
function updatePMChart(data) { // Create the inner progress bar div
const labels = data.map(d => d.timestamp); const progressBarDiv = document.createElement('div');
const PM1 = data.map(d => d.PM1); progressBarDiv.className = 'progress-bar';
const PM25 = data.map(d => d.PM25); progressBarDiv.style.width = `${percentageUsed}%`; // Set the width dynamically
const PM10 = data.map(d => d.PM10); progressBarDiv.textContent = `${percentageUsed}%`; // Set the text dynamically
const ctx = document.getElementById('sensorPMChart').getContext('2d'); // Append the progress bar to the outer div
progressDiv.appendChild(progressBarDiv);
if (!chart) { // Append the entire progress bar to the body (or any other container)
chart = new Chart(ctx, { memorySpace.appendChild(progressDiv);
type: 'line',
data: { },
labels: labels, error: function (xhr, status, error) {
datasets: [ console.error('AJAX request failed:', status, error);
{ }
label: "PM1", });
data: PM1,
borderColor: "rgba(0, 51, 153, 1)",
backgroundColor: "rgba(0, 51, 153, 0.2)", // Very light blue background // GET NPM SQLite values
fill: true, $.ajax({
tension: 0.4, // Smooth curves url: 'launcher.php?type=get_npm_sqlite_data',
pointRadius: 2, // Larger points dataType: 'json', // Specify that you expect a JSON response
pointHoverRadius: 6 // Bigger hover points method: 'GET', // Use GET or POST depending on your needs
}, success: function (response) {
{ console.log(response);
label: "PM2.5", updatePMChart(response);
data: PM25, },
borderColor: "rgba(30, 144, 255, 1)", error: function (xhr, status, error) {
backgroundColor: "rgba(30, 144, 255, 0.2)", // Very light medium blue background console.error('AJAX request failed:', status, error);
fill: true, }
tension: 0.4, });
pointRadius: 2,
pointHoverRadius: 6 let chart; // Store the Chart.js instance globally
},
{ function updatePMChart(data) {
label: "PM10", const labels = data.map(d => d.timestamp);
data: PM10, const PM1 = data.map(d => d.PM1);
borderColor: "rgba(135, 206, 250, 1)", const PM25 = data.map(d => d.PM25);
backgroundColor: "rgba(135, 206, 250, 0.2)", // Very light blue background const PM10 = data.map(d => d.PM10);
fill: true,
tension: 0.4, const ctx = document.getElementById('sensorPMChart').getContext('2d');
pointRadius: 2,
pointHoverRadius: 6 if (!chart) {
} chart = new Chart(ctx, {
] type: 'line',
data: {
labels: labels,
datasets: [
{
label: "PM1",
data: PM1,
borderColor: "rgba(0, 51, 153, 1)",
backgroundColor: "rgba(0, 51, 153, 0.2)", // Very light blue background
fill: true,
tension: 0.4, // Smooth curves
pointRadius: 2, // Larger points
pointHoverRadius: 6 // Bigger hover points
},
{
label: "PM2.5",
data: PM25,
borderColor: "rgba(30, 144, 255, 1)",
backgroundColor: "rgba(30, 144, 255, 0.2)", // Very light medium blue background
fill: true,
tension: 0.4,
pointRadius: 2,
pointHoverRadius: 6
},
{
label: "PM10",
data: PM10,
borderColor: "rgba(135, 206, 250, 1)",
backgroundColor: "rgba(135, 206, 250, 0.2)", // Very light blue background
fill: true,
tension: 0.4,
pointRadius: 2,
pointHoverRadius: 6
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'top'
}
},
scales: {
x: {
title: {
display: true,
text: 'Time (UTC)',
font: {
size: 16,
family: 'Arial, sans-serif'
}, },
options: { color: '#4A4A4A'
responsive: true, },
maintainAspectRatio: true, ticks: {
plugins: { autoSkip: true,
legend: { maxTicksLimit: 5,
position: 'top' color: '#4A4A4A',
} callback: function (value, index) {
}, // Access the correct label from the `labels` array
scales: { const label = labels[index]; // Use the original `labels` array
x: { if (label && typeof label === 'string' && label.includes(' ')) {
title: { return label.split(' ')[1].slice(0, 5); // Extract "HH:MM"
display: true, }
text: 'Time (UTC)', return value; // Fallback for invalid labels
font: {
size: 16,
family: 'Arial, sans-serif'
},
color: '#4A4A4A'
},
ticks: {
autoSkip: true,
maxTicksLimit: 5,
color: '#4A4A4A',
callback: function(value, index) {
// Access the correct label from the `labels` array
const label = labels[index]; // Use the original `labels` array
if (label && typeof label === 'string' && label.includes(' ')) {
return label.split(' ')[1].slice(0, 5); // Extract "HH:MM"
}
return value; // Fallback for invalid labels
}
},
grid: {
display: false // Remove gridlines for a cleaner look
}
},
y: {
title: {
display: true,
text: 'Values (µg/m³)',
font: {
size: 16,
family: 'Arial, sans-serif'
},
color: '#4A4A4A'
}
}
} }
},
grid: {
display: false // Remove gridlines for a cleaner look
}
},
y: {
title: {
display: true,
text: 'Values (µg/m³)',
font: {
size: 16,
family: 'Arial, sans-serif'
},
color: '#4A4A4A'
}
}
}
}
});
} else {
chart.data.labels = labels;
chart.data.datasets[0].data = PM1;
chart.data.datasets[1].data = PM25;
chart.data.datasets[2].data = PM10;
chart.update();
} }
} });
} else {
chart.data.labels = labels;
chart.data.datasets[0].data = PM1;
chart.data.datasets[1].data = PM25;
chart.data.datasets[2].data = PM10;
chart.update();
}
} }
</script>
}
</script>
</body> </body>
</html>
</html>

View File

@@ -23,9 +23,9 @@
"press": "Pressure" "press": "Pressure"
}, },
"noise": { "noise": {
"title": "Decibel Meter", "title": "NSRT MK4",
"description": "Noise sensor on I2C port.", "description": "NSRT MK4 sound level meter on USB port.",
"headerI2c": "I2C Port" "headerUsb": "USB Port"
}, },
"envea": { "envea": {
"title": "Envea Probe", "title": "Envea Probe",
@@ -52,6 +52,7 @@
}, },
"sidebar": { "sidebar": {
"home": "Home", "home": "Home",
"screen": "Screen",
"sensors": "Sensors", "sensors": "Sensors",
"database": "Database", "database": "Database",
"modem4g": "4G Modem", "modem4g": "4G Modem",
@@ -60,5 +61,53 @@
"map": "Map", "map": "Map",
"terminal": "Terminal", "terminal": "Terminal",
"admin": "Admin" "admin": "Admin"
},
"home": {
"title": "Your Sensor",
"welcome": "Welcome to your sensor configuration interface.",
"pmMeasures": "PM Measurements",
"linuxStats": "Linux Statistics",
"diskUsage": "Disk usage (total size",
"memoryUsage": "Memory usage (total size",
"databaseSize": "Database size:"
},
"database": {
"title": "Database",
"description": "The sensor records measurement data locally. You can view and download it here.",
"viewDatabase": "View Database",
"numberOfMeasures": "Number of measurements:",
"last10": "Last 10",
"last20": "Last 20",
"last30": "Last 30",
"pmMeasures": "PM Measurements",
"tempHumMeasures": "Temp/Hum Measurements",
"pm5Channels": "PM Measurements (5 channels)",
"cairsensProbe": "Cairsens Probe",
"noiseProbe": "Noise Probe",
"windProbe": "Wind Probe",
"battery": "Battery",
"timestampTable": "Timestamp Table",
"downloadData": "Download Data",
"startDate": "Start date:",
"endDate": "End date:",
"dangerZone": "Danger Zone",
"dangerWarning": "Warning: This action is irreversible!",
"emptyAllTables": "Empty all sensor tables",
"emptyTablesNote": "Note: Configuration and timestamp tables will be preserved.",
"statsTitle": "Database Information",
"statsDbSize": "Total size:",
"statsTable": "Table",
"statsCount": "Entries",
"statsOldest": "Oldest",
"statsNewest": "Newest",
"statsDownload": "CSV"
},
"logs": {
"title": "The Log",
"description": "The log allows you to know if the sensor processes are running correctly.",
"saraLogs": "Sara logs",
"bootLogs": "Boot logs",
"refresh": "Refresh",
"clear": "Clear"
} }
} }

View File

@@ -23,9 +23,9 @@
"press": "Pression" "press": "Pression"
}, },
"noise": { "noise": {
"title": "Sonomètre", "title": "NSRT MK4",
"description": "Capteur bruit sur le port I2C.", "description": "Sonomètre NSRT MK4 sur port USB.",
"headerI2c": "Port I2C" "headerUsb": "Port USB"
}, },
"envea": { "envea": {
"title": "Sonde Envea", "title": "Sonde Envea",
@@ -52,6 +52,7 @@
}, },
"sidebar": { "sidebar": {
"home": "Accueil", "home": "Accueil",
"screen": "Écran",
"sensors": "Capteurs", "sensors": "Capteurs",
"database": "Base de données", "database": "Base de données",
"modem4g": "Modem 4G", "modem4g": "Modem 4G",
@@ -60,5 +61,53 @@
"map": "Carte", "map": "Carte",
"terminal": "Terminal", "terminal": "Terminal",
"admin": "Admin" "admin": "Admin"
},
"home": {
"title": "Votre capteur",
"welcome": "Bienvenue sur votre interface de configuration de votre capteur.",
"pmMeasures": "Mesures PM",
"linuxStats": "Statistiques Linux",
"diskUsage": "Utilisation du disque (taille totale",
"memoryUsage": "Utilisation de la mémoire (taille totale",
"databaseSize": "Taille de la base de données:"
},
"database": {
"title": "Base de données",
"description": "Le capteur enregistre en local les données de mesures. Vous pouvez ici les consulter et les télécharger.",
"viewDatabase": "Consulter la base de donnée",
"numberOfMeasures": "Nombre de mesures:",
"last10": "10 dernières",
"last20": "20 dernières",
"last30": "30 dernières",
"pmMeasures": "Mesures PM",
"tempHumMeasures": "Mesures Temp/Hum",
"pm5Channels": "Mesures PM (5 canaux)",
"cairsensProbe": "Sonde Cairsens",
"noiseProbe": "Sonde bruit",
"windProbe": "Sonde Vent",
"battery": "Batterie",
"timestampTable": "Timestamp Table",
"downloadData": "Télécharger les données",
"startDate": "Date de début:",
"endDate": "Date de fin:",
"dangerZone": "Zone dangereuse",
"dangerWarning": "Attention: Cette action est irréversible!",
"emptyAllTables": "Vider toutes les tables de capteurs",
"emptyTablesNote": "Note: Les tables de configuration et horodatage seront préservées.",
"statsTitle": "Informations sur la base",
"statsDbSize": "Taille totale:",
"statsTable": "Table",
"statsCount": "Entrées",
"statsOldest": "Plus ancienne",
"statsNewest": "Plus récente",
"statsDownload": "CSV"
},
"logs": {
"title": "Le journal",
"description": "Le journal des logs permet de savoir si les processus du capteur se déroulent correctement.",
"saraLogs": "Sara logs",
"bootLogs": "Boot logs",
"refresh": "Refresh",
"clear": "Clear"
} }
} }

View File

@@ -69,7 +69,7 @@ if ($type == "get_config_sqlite") {
// Return JSON response // Return JSON response
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($result, JSON_PRETTY_PRINT); echo json_encode($result, JSON_PRETTY_PRINT);
} catch (PDOException $e) { } catch (PDOException $e) {
// Return error as JSON // Return error as JSON
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -410,6 +410,107 @@ if ($type == "update_firmware") {
]); ]);
} }
if ($type == "upload_firmware") {
// Firmware update via ZIP file upload (offline mode)
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'POST method required']);
exit;
}
// Check file upload
if (!isset($_FILES['firmware_file']) || $_FILES['firmware_file']['error'] !== UPLOAD_ERR_OK) {
$upload_errors = [
UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit',
UPLOAD_ERR_FORM_SIZE => 'File exceeds form upload limit',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
UPLOAD_ERR_NO_FILE => 'No file was uploaded',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
];
$error_code = $_FILES['firmware_file']['error'] ?? UPLOAD_ERR_NO_FILE;
$error_msg = $upload_errors[$error_code] ?? 'Unknown upload error';
echo json_encode(['success' => false, 'message' => $error_msg]);
exit;
}
$file = $_FILES['firmware_file'];
// Validate extension
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'zip') {
echo json_encode(['success' => false, 'message' => 'Only .zip files are allowed']);
exit;
}
// Validate size (50MB max)
if ($file['size'] > 50 * 1024 * 1024) {
echo json_encode(['success' => false, 'message' => 'File too large (max 50MB)']);
exit;
}
// Get current version before update
$old_version = 'unknown';
if (file_exists('/var/www/nebuleair_pro_4g/VERSION')) {
$old_version = trim(file_get_contents('/var/www/nebuleair_pro_4g/VERSION'));
}
// Prepare extraction directory
$tmp_dir = '/tmp/nebuleair_update';
$extract_dir = "$tmp_dir/extracted";
shell_exec("rm -rf $tmp_dir");
mkdir($extract_dir, 0755, true);
// Move uploaded file
$zip_path = "$tmp_dir/firmware.zip";
if (!move_uploaded_file($file['tmp_name'], $zip_path)) {
echo json_encode(['success' => false, 'message' => 'Failed to move uploaded file']);
exit;
}
// Extract ZIP
$unzip_output = shell_exec("unzip -o '$zip_path' -d '$extract_dir' 2>&1");
// Detect project root folder (Gitea creates nebuleair_pro_4g-main/ inside the zip)
$source_dir = $extract_dir;
$entries = scandir($extract_dir);
$subdirs = array_filter($entries, function($e) use ($extract_dir) {
return $e !== '.' && $e !== '..' && is_dir("$extract_dir/$e");
});
if (count($subdirs) === 1) {
$subdir = reset($subdirs);
$candidate = "$extract_dir/$subdir";
if (file_exists("$candidate/VERSION")) {
$source_dir = $candidate;
}
}
// Validate VERSION exists in the archive
if (!file_exists("$source_dir/VERSION")) {
shell_exec("rm -rf $tmp_dir");
echo json_encode(['success' => false, 'message' => 'Invalid archive: VERSION file not found']);
exit;
}
$new_version = trim(file_get_contents("$source_dir/VERSION"));
// Execute update script
$command = "sudo /var/www/nebuleair_pro_4g/update_firmware_from_file.sh '$source_dir' 2>&1";
$output = shell_exec($command);
// Cleanup (also done in script, but just in case)
shell_exec("rm -rf $tmp_dir");
echo json_encode([
'success' => true,
'output' => $output,
'old_version' => $old_version,
'new_version' => $new_version,
'timestamp' => date('Y-m-d H:i:s')
]);
exit;
}
if ($type == "set_RTC_withNTP") { if ($type == "set_RTC_withNTP") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_NTP.py'; $command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_NTP.py';
$output = shell_exec($command); $output = shell_exec($command);
@@ -530,6 +631,107 @@ if ($type == "database_size") {
} }
if ($type == "db_table_stats") {
$databasePath = '/var/www/nebuleair_pro_4g/sqlite/sensors.db';
if (file_exists($databasePath)) {
try {
$db = new PDO("sqlite:$databasePath");
// Database file size
$fileSizeBytes = filesize($databasePath);
$fileSizeMB = round($fileSizeBytes / (1024 * 1024), 2);
// Sensor data tables to inspect
$tables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19'];
$tableStats = [];
foreach ($tables as $tableName) {
// Check if table exists
$check = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='$tableName'");
if ($check->fetch()) {
$countResult = $db->query("SELECT COUNT(*) as cnt FROM $tableName")->fetch();
$count = (int)$countResult['cnt'];
$oldest = null;
$newest = null;
if ($count > 0) {
$oldestResult = $db->query("SELECT MIN(timestamp) as ts FROM $tableName")->fetch();
$newestResult = $db->query("SELECT MAX(timestamp) as ts FROM $tableName")->fetch();
$oldest = $oldestResult['ts'];
$newest = $newestResult['ts'];
}
$tableStats[] = [
'name' => $tableName,
'count' => $count,
'oldest' => $oldest,
'newest' => $newest
];
}
}
echo json_encode([
'success' => true,
'size_mb' => $fileSizeMB,
'size_bytes' => $fileSizeBytes,
'tables' => $tableStats
]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'error' => 'Database query failed: ' . $e->getMessage()]);
}
} else {
echo json_encode(['success' => false, 'error' => 'Database file not found']);
}
}
if ($type == "download_full_table") {
$databasePath = '/var/www/nebuleair_pro_4g/sqlite/sensors.db';
$table = $_GET['table'] ?? '';
// Whitelist of allowed tables
$allowedTables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19'];
if (!in_array($table, $allowedTables)) {
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid table name']);
exit;
}
// CSV headers per table
$csvHeaders = [
'data_NPM' => 'TimestampUTC,PM1,PM2.5,PM10,Temperature_sensor,Humidity_sensor',
'data_NPM_5channels' => 'TimestampUTC,PM_ch1,PM_ch2,PM_ch3,PM_ch4,PM_ch5',
'data_BME280' => 'TimestampUTC,Temperature,Humidity,Pressure',
'data_envea' => 'TimestampUTC,NO2,H2S,NH3,CO,O3,SO2',
'data_WIND' => 'TimestampUTC,Wind_speed_kmh,Wind_direction_V',
'data_MPPT' => 'TimestampUTC,Battery_voltage,Battery_current,Solar_voltage,Solar_power,Charger_status',
'data_NOISE' => 'TimestampUTC,Current_LEQ,DB_A_value',
'data_MHZ19' => 'TimestampUTC,CO2_ppm'
];
try {
$db = new PDO("sqlite:$databasePath");
$rows = $db->query("SELECT * FROM $table ORDER BY timestamp ASC")->fetchAll(PDO::FETCH_NUM);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $table . '_full.csv"');
$output = fopen('php://output', 'w');
// Write header
fputcsv($output, explode(',', $csvHeaders[$table]));
// Write data rows
foreach ($rows as $row) {
fputcsv($output, $row);
}
fclose($output);
} catch (PDOException $e) {
header('Content-Type: application/json');
echo json_encode(['error' => 'Database query failed: ' . $e->getMessage()]);
}
exit;
}
if ($type == "linux_disk") { if ($type == "linux_disk") {
$command = 'df -h /'; $command = 'df -h /';
$output = shell_exec($command); $output = shell_exec($command);
@@ -542,6 +744,53 @@ if ($type == "linux_memory") {
echo $output; echo $output;
} }
if ($type == "wifi_status") {
header('Content-Type: application/json');
$result = array(
'connected' => false,
'mode' => 'unknown',
'ssid' => '',
'ip' => '',
'hostname' => ''
);
// Get hostname
$result['hostname'] = trim(shell_exec('hostname'));
// Get wlan0 connection info
$connection = trim(shell_exec("nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2"));
if (!empty($connection) && $connection != '--') {
$result['connected'] = true;
$result['ssid'] = $connection;
// Check if it's a hotspot
if (strpos(strtolower($connection), 'hotspot') !== false || strpos($connection, 'nebuleair') !== false) {
$result['mode'] = 'hotspot';
} else {
$result['mode'] = 'wifi';
}
// Get IP address
$ip = trim(shell_exec("nmcli -t -f IP4.ADDRESS device show wlan0 2>/dev/null | head -1 | cut -d: -f2 | cut -d/ -f1"));
if (!empty($ip)) {
$result['ip'] = $ip;
}
} else {
// Check if eth0 is connected
$eth_ip = trim(shell_exec("nmcli -t -f IP4.ADDRESS device show eth0 2>/dev/null | head -1 | cut -d: -f2 | cut -d/ -f1"));
if (!empty($eth_ip)) {
$result['connected'] = true;
$result['mode'] = 'ethernet';
$result['ssid'] = 'Ethernet';
$result['ip'] = $eth_ip;
}
}
echo json_encode($result);
}
if ($type == "sshTunnel") { if ($type == "sshTunnel") {
$ssh_port=$_GET['ssh_port']; $ssh_port=$_GET['ssh_port'];
$command = 'sudo ssh -i /var/www/.ssh/id_rsa -f -N -R "'.$ssh_port.':localhost:22" -p 50221 -o StrictHostKeyChecking=no "airlab_server1@aircarto.fr"'; $command = 'sudo ssh -i /var/www/.ssh/id_rsa -f -N -R "'.$ssh_port.':localhost:22" -p 50221 -o StrictHostKeyChecking=no "airlab_server1@aircarto.fr"';
@@ -561,6 +810,13 @@ if ($type == "npm") {
echo $output; echo $output;
} }
if ($type == "npm_firmware") {
$port=$_GET['port'];
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/firmware_version.py ' . $port;
$output = shell_exec($command);
echo $output;
}
if ($type == "envea") { if ($type == "envea") {
$port=$_GET['port']; $port=$_GET['port'];
$name=$_GET['name']; $name=$_GET['name'];
@@ -569,8 +825,14 @@ if ($type == "envea") {
echo $output; echo $output;
} }
if ($type == "envea_debug") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_value_v2.py -d 2>&1';
$output = shell_exec($command);
echo $output;
}
if ($type == "noise") { if ($type == "noise") {
$command = '/var/www/nebuleair_pro_4g/sound_meter/sound_meter'; $command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/sound_meter/read.py';
$output = shell_exec($command); $output = shell_exec($command);
echo $output; echo $output;
} }
@@ -581,6 +843,12 @@ if ($type == "BME280") {
echo $output; echo $output;
} }
if ($type == "mhz19") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/MH-Z19/get_data.py ttyAMA4';
$output = shell_exec($command);
echo $output;
}
if ($type == "table_mesure") { if ($type == "table_mesure") {
$table=$_GET['table']; $table=$_GET['table'];
@@ -774,35 +1042,64 @@ if ($type == "sara_sendMessage") {
} }
if ($type == "internet") { if ($type == "internet") {
//eth0 // eth0
$command = 'nmcli -g GENERAL.STATE device show eth0'; $eth0_connStatus = str_replace("\n", "", shell_exec('nmcli -g GENERAL.STATE device show eth0 2>/dev/null'));
$eth0_connStatus = shell_exec($command); $eth0_IPAddr = str_replace("\n", "", shell_exec('nmcli -g IP4.ADDRESS device show eth0 2>/dev/null'));
$eth0_connStatus = str_replace("\n", "", $eth0_connStatus);
$command = 'nmcli -g IP4.ADDRESS device show eth0';
$eth0_IPAddr = shell_exec($command);
$eth0_IPAddr = str_replace("\n", "", $eth0_IPAddr);
//wlan0 // wlan0 basic
$command = 'nmcli -g GENERAL.STATE device show wlan0'; $wlan0_connStatus = str_replace("\n", "", shell_exec('nmcli -g GENERAL.STATE device show wlan0 2>/dev/null'));
$wlan0_connStatus = shell_exec($command); $wlan0_IPAddr = str_replace("\n", "", shell_exec('nmcli -g IP4.ADDRESS device show wlan0 2>/dev/null'));
$wlan0_connStatus = str_replace("\n", "", $wlan0_connStatus);
$command = 'nmcli -g IP4.ADDRESS device show wlan0';
$wlan0_IPAddr = shell_exec($command);
$wlan0_IPAddr = str_replace("\n", "", $wlan0_IPAddr);
$data= array( // wlan0 detailed info (connection name, signal, frequency, security, gateway, etc.)
$wlan0_connection = str_replace("\n", "", shell_exec("nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2"));
$wlan0_gateway = str_replace("\n", "", shell_exec('nmcli -g IP4.GATEWAY device show wlan0 2>/dev/null'));
// Get active WiFi details (signal, frequency, security) from nmcli
$wifi_signal = '';
$wifi_freq = '';
$wifi_security = '';
$wifi_ssid = '';
$wifi_output = shell_exec('nmcli -t -f ACTIVE,SSID,SIGNAL,FREQ,SECURITY device wifi list ifname wlan0 2>/dev/null');
if ($wifi_output) {
$lines = explode("\n", trim($wifi_output));
foreach ($lines as $line) {
// Active connection line starts with "yes:" (nmcli -t uses : separator)
if (strpos($line, 'yes:') === 0) {
// Format: yes:SSID:SIGNAL:FREQ:SECURITY
// Use explode with limit to handle SSIDs containing ':'
$parts = explode(':', $line);
if (count($parts) >= 5) {
$wifi_ssid = $parts[1];
$wifi_signal = $parts[2];
$wifi_freq = $parts[3];
$wifi_security = $parts[4];
}
break;
}
}
}
// Hostname
$hostname = trim(shell_exec('hostname 2>/dev/null'));
$data = array(
"ethernet" => array( "ethernet" => array(
"connection" => $eth0_connStatus, "connection" => $eth0_connStatus,
"IP" => $eth0_IPAddr "IP" => $eth0_IPAddr
), ),
"wifi" => array( "wifi" => array(
"connection" => $wlan0_connStatus, "connection" => $wlan0_connStatus,
"IP" => $wlan0_IPAddr "IP" => $wlan0_IPAddr,
"ssid" => $wifi_ssid ?: $wlan0_connection,
"signal" => $wifi_signal,
"frequency" => $wifi_freq,
"security" => $wifi_security,
"gateway" => $wlan0_gateway,
"hostname" => $hostname
) )
); );
$json_data = json_encode($data);
echo $json_data; echo json_encode($data);
} }
# IMPORTANT # IMPORTANT
@@ -811,59 +1108,148 @@ if ($type == "wifi_connect") {
$SSID=$_GET['SSID']; $SSID=$_GET['SSID'];
$PASS=$_GET['pass']; $PASS=$_GET['pass'];
echo "will try to connect to </br>"; // Get device name from database for instructions
echo "SSID: " . $SSID; try {
echo "</br>"; $db = new PDO("sqlite:$database_path");
echo "Password: " . $PASS; $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "</br>"; $stmt = $db->prepare("SELECT value FROM config_table WHERE key = 'deviceName'");
echo "</br>"; $stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$deviceName = $result ? $result['value'] : 'NebuleAir';
$db = null;
} catch (PDOException $e) {
$deviceName = 'NebuleAir';
}
echo "You will be disconnected. If connection is successfull you can find the device on your local network."; // Launch connection script in background
$script_path = '/var/www/nebuleair_pro_4g/connexion.sh'; $script_path = '/var/www/nebuleair_pro_4g/connexion.sh';
$log_file = '/var/www/nebuleair_pro_4g/logs/app.log'; $log_file = '/var/www/nebuleair_pro_4g/logs/app.log';
shell_exec("$script_path $SSID $PASS >> $log_file 2>&1 &"); shell_exec("$script_path $SSID $PASS >> $log_file 2>&1 &");
#$output = shell_exec('sudo nmcli connection down Hotspot'); // Return JSON response with instructions
#$output2 = shell_exec('sudo nmcli device wifi connect "AirLab" password "123plouf"'); header('Content-Type: application/json');
echo json_encode([
'success' => true,
'ssid' => $SSID,
'deviceName' => $deviceName,
'message' => 'Connection attempt started',
'instructions' => [
'fr' => [
'title' => 'Connexion en cours...',
'step1' => "Le capteur tente de se connecter au réseau « $SSID »",
'step2' => "Vous allez être déconnecté du hotspot dans quelques secondes",
'step3' => "Reconnectez-vous au WiFi « $SSID » sur votre appareil",
'step4' => "Accédez au capteur via http://$deviceName.local ou cherchez son IP dans votre routeur",
'warning' => "Si la connexion échoue, le capteur recréera automatiquement le hotspot"
],
'en' => [
'title' => 'Connection in progress...',
'step1' => "The sensor is attempting to connect to network « $SSID »",
'step2' => "You will be disconnected from the hotspot in a few seconds",
'step3' => "Reconnect your device to WiFi « $SSID »",
'step4' => "Access the sensor via http://$deviceName.local or find its IP in your router",
'warning' => "If connection fails, the sensor will automatically recreate the hotspot"
]
]
]);
}
if ($type == "wifi_forget") {
// Get device name from database
try {
$db = new PDO("sqlite:$database_path");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$stmt = $db->prepare("SELECT value FROM config_table WHERE key = 'deviceName'");
$stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$deviceName = $result ? $result['value'] : 'NebuleAir';
$db = null;
} catch (PDOException $e) {
$deviceName = 'NebuleAir';
}
// Launch forget script in background
$script_path = '/var/www/nebuleair_pro_4g/forget_wifi.sh';
$log_file = '/var/www/nebuleair_pro_4g/logs/app.log';
shell_exec("bash $script_path >> $log_file 2>&1 &");
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'deviceName' => $deviceName,
'instructions' => [
'fr' => [
'title' => 'Réseau WiFi oublié',
'step1' => "Le capteur oublie le réseau WiFi actuel",
'step2' => "Le hotspot va démarrer automatiquement",
'step3' => "Connectez-vous au WiFi « $deviceName » (mot de passe : nebuleaircfg)",
'step4' => "Accédez au capteur via http://10.42.0.1/html/",
'warning' => "Le capteur ne se reconnectera plus automatiquement à ce réseau"
],
'en' => [
'title' => 'WiFi network forgotten',
'step1' => "The sensor is forgetting the current WiFi network",
'step2' => "The hotspot will start automatically",
'step3' => "Connect to WiFi « $deviceName » (password: nebuleaircfg)",
'step4' => "Access the sensor via http://10.42.0.1/html/",
'warning' => "The sensor will no longer auto-connect to this network"
]
]
]);
} }
if ($type == "wifi_scan") { if ($type == "wifi_scan") {
// Perform live WiFi scan instead of reading stale CSV file
$output = shell_exec('nmcli -f SSID,SIGNAL,SECURITY device wifi list ifname wlan0 2>/dev/null');
// Initialize an array to hold the JSON data
$jsonData = []; $jsonData = [];
if ($output) { // Check if wlan0 is in hotspot mode — if so, use cached CSV from boot scan
// Split the output into lines // (live scan is impossible while wlan0 is serving the hotspot)
$lines = explode("\n", trim($output)); $wlan0_connection = trim(shell_exec("nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2"));
$is_hotspot = (strpos(strtolower($wlan0_connection), 'hotspot') !== false);
// Skip the header line and process each network if ($is_hotspot) {
for ($i = 1; $i < count($lines); $i++) { // Read cached scan from boot (wifi_list.csv)
$line = trim($lines[$i]); $csv_path = '/var/www/nebuleair_pro_4g/wifi_list.csv';
if (empty($line)) continue; if (file_exists($csv_path)) {
$lines = file($csv_path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// Skip header line (SSID,SIGNAL,SECURITY)
for ($i = 1; $i < count($lines); $i++) {
$parts = str_getcsv($lines[$i]);
if (count($parts) >= 2 && !empty(trim($parts[0]))) {
$jsonData[] = [
'SSID' => trim($parts[0]),
'SIGNAL' => trim($parts[1]),
'SECURITY' => isset($parts[2]) ? trim($parts[2]) : '--',
'cached' => true
];
}
}
}
} else {
// Live scan (wlan0 is free)
$output = shell_exec('timeout 10 nmcli -f SSID,SIGNAL,SECURITY device wifi list ifname wlan0 2>/dev/null');
// Split by multiple spaces (nmcli uses column formatting) if ($output) {
$parts = preg_split('/\s{2,}/', $line, 3); $lines = explode("\n", trim($output));
for ($i = 1; $i < count($lines); $i++) {
$line = trim($lines[$i]);
if (empty($line)) continue;
if (count($parts) >= 2) { $parts = preg_split('/\s{2,}/', $line, 3);
$jsonData[] = [ if (count($parts) >= 2) {
'SSID' => trim($parts[0]), $jsonData[] = [
'SIGNAL' => trim($parts[1]), 'SSID' => trim($parts[0]),
'SECURITY' => isset($parts[2]) ? trim($parts[2]) : '--' 'SIGNAL' => trim($parts[1]),
]; 'SECURITY' => isset($parts[2]) ? trim($parts[2]) : '--'
];
}
} }
} }
} }
// Set the content type to JSON
header('Content-Type: application/json'); header('Content-Type: application/json');
// Convert the array to JSON format and output it
echo json_encode($jsonData, JSON_PRETTY_PRINT); echo json_encode($jsonData, JSON_PRETTY_PRINT);
} }
@@ -1088,6 +1474,10 @@ if ($type == "get_systemd_services") {
'description' => 'Get Data from noise sensor', 'description' => 'Get Data from noise sensor',
'frequency' => 'Every minute' 'frequency' => 'Every minute'
], ],
'nebuleair-mhz19-data.timer' => [
'description' => 'Reads CO2 concentration from MH-Z19 sensor',
'frequency' => 'Every 2 minutes'
],
'nebuleair-db-cleanup-data.timer' => [ 'nebuleair-db-cleanup-data.timer' => [
'description' => 'Cleans up old data from database', 'description' => 'Cleans up old data from database',
'frequency' => 'Daily' 'frequency' => 'Daily'
@@ -1153,6 +1543,7 @@ if ($type == "restart_systemd_service") {
'nebuleair-sara-data.timer', 'nebuleair-sara-data.timer',
'nebuleair-bme280-data.timer', 'nebuleair-bme280-data.timer',
'nebuleair-mppt-data.timer', 'nebuleair-mppt-data.timer',
'nebuleair-mhz19-data.timer',
'nebuleair-db-cleanup-data.timer' 'nebuleair-db-cleanup-data.timer'
]; ];
@@ -1213,6 +1604,7 @@ if ($type == "toggle_systemd_service") {
'nebuleair-sara-data.timer', 'nebuleair-sara-data.timer',
'nebuleair-bme280-data.timer', 'nebuleair-bme280-data.timer',
'nebuleair-mppt-data.timer', 'nebuleair-mppt-data.timer',
'nebuleair-mhz19-data.timer',
'nebuleair-db-cleanup-data.timer' 'nebuleair-db-cleanup-data.timer'
]; ];
@@ -1270,13 +1662,68 @@ if ($type == "toggle_systemd_service") {
} }
} }
// Empty all sensor tables (preserve config and timestamp tables)
if ($type == "empty_sensor_tables") {
try {
// Execute the empty sensor tables script
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/empty_sensor_tables.py 2>&1';
$output = shell_exec($command);
// Try to extract JSON result from output
$json_start = strpos($output, '[JSON_RESULT]');
if ($json_start !== false) {
$json_data = substr($output, $json_start + strlen('[JSON_RESULT]'));
$json_data = trim($json_data);
// Find the first { and last }
$first_brace = strpos($json_data, '{');
$last_brace = strrpos($json_data, '}');
if ($first_brace !== false && $last_brace !== false) {
$json_data = substr($json_data, $first_brace, $last_brace - $first_brace + 1);
$result = json_decode($json_data, true);
if ($result !== null) {
echo json_encode($result);
} else {
// JSON decode failed, return raw output
echo json_encode([
'success' => true,
'message' => 'Tables emptied',
'output' => $output
]);
}
} else {
echo json_encode([
'success' => true,
'message' => 'Tables emptied',
'output' => $output
]);
}
} else {
// No JSON marker found, return raw output
echo json_encode([
'success' => true,
'message' => 'Tables emptied',
'output' => $output
]);
}
} catch (Exception $e) {
echo json_encode([
'success' => false,
'error' => 'Script execution failed: ' . $e->getMessage()
]);
}
}
/* /*
_____ ____ _ _ _ _____ ____ _ _ _
| ____|_ ____ _____ __ _ | _ \ ___| |_ ___ ___| |_(_) ___ _ __ | ____|_ ____ _____ __ _ | _ \ ___| |_ ___ ___| |_(_) ___ _ __
| _| | '_ \ \ / / _ \/ _` | | | | |/ _ \ __/ _ \/ __| __| |/ _ \| '_ \ | _| | '_ \ \ / / _ \/ _` | | | | |/ _ \ __/ _ \/ __| __| |/ _ \| '_ \
| |___| | | \ V / __/ (_| | | |_| | __/ || __/ (__| |_| | (_) | | | | | |___| | | \ V / __/ (_| | | |_| | __/ || __/ (__| |_| | (_) | | | |
|_____|_| |_|\_/ \___|\__,_| |____/ \___|\__\___|\___|\__|_|\___/|_| |_| |_____|_| |_|\_/ \___|\__,_| |____/ \___|\__\___|\___|\__|_|\___/|_| |_|
*/ */
// Detect Envea devices on specified port // Detect Envea devices on specified port
@@ -1362,3 +1809,151 @@ if ($type == "detect_envea_device") {
]); ]);
} }
} }
/*
____ ____ _ _ ____ __ __ _
/ ___| _ \| | | | | _ \ _____ _____ _ _| \/ | __ _ _ __ __ _ __ _ ___ _ __ ___ ___ _ __ | |_
| | | |_) | | | | | |_) / _ \ \ /\ / / _ \ '__| |\/| |/ _` | '_ \ / _` |/ _` |/ _ \ '_ ` _ \ / _ \ '_ \| __|
| |___| __/| |_| | | __/ (_) \ V V / __/ | | | | | (_| | | | | (_| | (_| | __/ | | | | | __/ | | | |_
\____|_| \___/ |_| \___/ \_/\_/ \___|_| |_| |_|\__,_|_| |_|\__,_|\__, |\___|_| |_| |_|\___|_| |_|\__|
|___/
*/
// Get firmware version from VERSION file
if ($type == "get_firmware_version") {
$versionFile = '/var/www/nebuleair_pro_4g/VERSION';
if (file_exists($versionFile)) {
$version = trim(file_get_contents($versionFile));
echo json_encode([
'success' => true,
'version' => $version
]);
} else {
echo json_encode([
'success' => false,
'version' => 'unknown'
]);
}
}
// Get changelog from changelog.json
if ($type == "get_changelog") {
$changelogFile = '/var/www/nebuleair_pro_4g/changelog.json';
if (file_exists($changelogFile)) {
$changelog = json_decode(file_get_contents($changelogFile), true);
if ($changelog !== null) {
echo json_encode([
'success' => true,
'changelog' => $changelog
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Invalid changelog format'
]);
}
} else {
echo json_encode([
'success' => false,
'error' => 'Changelog file not found'
]);
}
}
// Get current CPU power mode
if ($type == "get_cpu_power_mode") {
try {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/power/set_cpu_mode.py get 2>&1';
$output = shell_exec($command);
// Try to parse JSON output
$result = json_decode($output, true);
if ($result && isset($result['success']) && $result['success']) {
echo json_encode([
'success' => true,
'mode' => $result['config_mode'] ?? 'unknown',
'cpu_state' => $result['cpu_state'] ?? null
], JSON_PRETTY_PRINT);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to get CPU power mode',
'output' => $output
]);
}
} catch (Exception $e) {
echo json_encode([
'success' => false,
'error' => 'Script execution failed: ' . $e->getMessage()
]);
}
}
// Set CPU power mode
if ($type == "set_cpu_power_mode") {
$mode = $_GET['mode'] ?? null;
if (empty($mode)) {
echo json_encode([
'success' => false,
'error' => 'No mode specified'
]);
exit;
}
// Validate mode (whitelist)
$allowedModes = ['normal', 'powersave'];
if (!in_array($mode, $allowedModes)) {
echo json_encode([
'success' => false,
'error' => 'Invalid mode. Allowed: normal, powersave'
]);
exit;
}
try {
// Execute the CPU power mode script
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/power/set_cpu_mode.py ' . escapeshellarg($mode) . ' 2>&1';
$output = shell_exec($command);
// Try to parse JSON output
$result = json_decode($output, true);
if ($result && isset($result['success']) && $result['success']) {
echo json_encode([
'success' => true,
'mode' => $mode,
'message' => "CPU power mode set to: $mode",
'description' => $result['description'] ?? ''
], JSON_PRETTY_PRINT);
} else {
echo json_encode([
'success' => false,
'error' => $result['error'] ?? 'Failed to set CPU power mode',
'output' => $output
]);
}
} catch (Exception $e) {
echo json_encode([
'success' => false,
'error' => 'Script execution failed: ' . $e->getMessage()
]);
}
}
if ($type == "screen_control") {
$action = $_GET['action'];
if ($action == "start") {
// Run as background process with sudo (requires nopasswd in sudoers)
// Redirecting to a temp log file to debug startup issues
$command = 'export DISPLAY=:0 && nohup sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/screen_control/screen.py > /tmp/screen_control.log 2>&1 &';
shell_exec($command);
echo "Started. Check /tmp/screen_control.log for details.";
} elseif ($action == "stop") {
$command = 'sudo pkill -f "screen.py" 2>&1';
$output = shell_exec($command);
echo "Stopped. Output: " . $output;
}
}

View File

@@ -49,30 +49,30 @@
</aside> </aside>
<!-- Main content --> <!-- Main content -->
<main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4"> <main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4">Le journal</h1> <h1 class="mt-4" data-i18n="logs.title">Le journal</h1>
<p>Le journal des logs permet de savoir si les processus du capteur se déroulent correctement.</p> <p data-i18n="logs.description">Le journal des logs permet de savoir si les processus du capteur se déroulent correctement.</p>
<div class="row"> <div class="row">
<!-- card 1 --> <!-- card 1 -->
<div class="col-lg-6 col-12"> <div class="col-lg-6 col-12">
<div class="card" style="height: 80vh;"> <div class="card" style="height: 80vh;">
<div class="card-header"> <div class="card-header">
Sara logs <span data-i18n="logs.saraLogs">Sara logs</span>
<button type="submit" class="btn btn-secondary btn-sm" id="refresh-master-log">Refresh</button> <button type="submit" class="btn btn-secondary btn-sm" id="refresh-master-log" data-i18n="logs.refresh">Refresh</button>
<button type="submit" class="btn btn-secondary btn-sm" onclick="clear_loopLogs()">Clear</button> <button type="submit" class="btn btn-secondary btn-sm" onclick="clear_loopLogs()" data-i18n="logs.clear">Clear</button>
<span id="script_running"></span> <span id="script_running"></span>
</div> </div>
<div class="card-body overflow-auto" id="card_loop_content"> <div class="card-body overflow-auto" id="card_loop_content">
</div> </div>
</div> </div>
</div> </div>
<!-- card 2 --> <!-- card 2 -->
<div class="col-lg-6 col-12"> <div class="col-lg-6 col-12">
<div class="card" style="height: 80vh;"> <div class="card" style="height: 80vh;">
<div class="card-header"> <div class="card-header">
Boot logs <span data-i18n="logs.bootLogs">Boot logs</span>
<button type="submit" class="btn btn-secondary btn-sm" id="refresh-boot-log">Refresh</button> <button type="submit" class="btn btn-secondary btn-sm" id="refresh-boot-log" data-i18n="logs.refresh">Refresh</button>
</div> </div>
<div class="card-body overflow-auto" id="card_boot_content"> <div class="card-body overflow-auto" id="card_boot_content">
@@ -92,6 +92,7 @@
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system --> <!-- i18n translation system -->
<script src="assets/js/i18n.js"></script> <script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {

View File

@@ -117,6 +117,7 @@
<script src="assets/jquery/jquery-3.7.1.min.js"></script> <script src="assets/jquery/jquery-3.7.1.min.js"></script>
<!-- Link Bootstrap JS and Popper.js locally --> <!-- Link Bootstrap JS and Popper.js locally -->
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {

View File

@@ -58,6 +58,14 @@
<label class="form-check-label" for="check_modem_configMode">Mode configuration</label> <label class="form-check-label" for="check_modem_configMode">Mode configuration</label>
</div> </div>
<button class="btn btn-success mb-3 btn_selfTest" onclick="runSelfTest()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2-circle me-1" viewBox="0 0 16 16">
<path d="M2.5 8a5.5 5.5 0 1 1 11 0 5.5 5.5 0 0 1-11 0z"/>
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l7-7z"/>
</svg>
Run Self Test
</button>
<span id="modem_status_message"></span> <span id="modem_status_message"></span>
<!-- <!--
<h3> <h3>
@@ -67,53 +75,78 @@
--> -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-2"> <div class="col-sm-3">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<p class="card-text">General information. </p> <p class="card-text">General information. </p>
<button class="btn btn-primary" onclick="getData_saraR4('ttyAMA2', 'ATI', 1)">Get Data</button> <button class="btn btn-primary" onclick="getModemInfo('ttyAMA2', 1)">Get Data</button>
<div id="loading_ttyAMA2_ATI" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div> <div id="loading_ttyAMA2_ATI" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="response_ttyAMA2_ATI"></div> <div id="modem_info_alert"></div>
<div class="collapse mt-2" id="modem_info_logs">
<div class="card card-body bg-light">
<small><code id="response_ttyAMA2_ATI"></code></small>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-sm-2"> <div class="col-sm-3">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<p class="card-text">SIM card information.</p> <p class="card-text">SIM card information.</p>
<button class="btn btn-primary" onclick="getData_saraR4('ttyAMA2', 'AT+CCID?', 1)">Get Data</button> <button class="btn btn-primary me-1" onclick="getSimInfo('ttyAMA2', 1)">Get Data (ICCID)</button>
<div id="loading_ttyAMA2_AT_CCID_" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div> <div id="loading_ttyAMA2_AT_CCID_" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="response_ttyAMA2_AT_CCID_"></div> <div id="sim_info_alert"></div>
<div class="collapse mt-2" id="sim_info_logs">
<div class="card card-body bg-light">
<small><code id="response_ttyAMA2_AT_CCID_"></code></small>
</div>
</div>
<hr>
<button class="btn btn-primary me-1" onclick="getImsiInfo('ttyAMA2', 1)">Get Data (IMSI)</button>
<div id="loading_ttyAMA2_AT_CIMI" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="imsi_info_alert"></div>
<div class="collapse mt-2" id="imsi_info_logs">
<div class="card card-body bg-light">
<small><code id="response_ttyAMA2_AT_CIMI"></code></small>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-sm-2"> <div class="col-sm-3">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<p class="card-text">Actual Network connection</p> <p class="card-text">Actual Network connection</p>
<button class="btn btn-primary" onclick="getData_saraR4('ttyAMA2', 'AT+COPS?', 2)">Get Data</button> <button class="btn btn-primary" onclick="getNetworkInfo('ttyAMA2', 2)">Get Data</button>
<div id="loading_ttyAMA2_AT_COPS_" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div> <div id="loading_ttyAMA2_AT_COPS_" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="response_ttyAMA2_AT_COPS_"></div> <div id="network_info_alert"></div>
<div class="collapse mt-2" id="network_info_logs">
</table> <div class="card card-body bg-light">
<small><code id="response_ttyAMA2_AT_COPS_"></code></small>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-sm-2"> <div class="col-sm-3">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<p class="card-text">Signal strength </p> <p class="card-text">Signal strength </p>
<button class="btn btn-primary" onclick="getData_saraR4('ttyAMA2', 'AT+CSQ', 1)">Get Data</button> <button class="btn btn-primary" onclick="getSignalInfo('ttyAMA2', 1)">Get Data</button>
<div id="loading_ttyAMA2_AT_CSQ" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div> <div id="loading_ttyAMA2_AT_CSQ" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="response_ttyAMA2_AT_CSQ"></div> <div id="signal_info_alert"></div>
</table> <div class="collapse mt-2" id="signal_info_logs">
<div class="card card-body bg-light">
<small><code id="response_ttyAMA2_AT_CSQ"></code></small>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -359,6 +392,8 @@
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system --> <!-- i18n translation system -->
<script src="assets/js/i18n.js"></script> <script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script src="assets/js/selftest.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
@@ -473,6 +508,522 @@ window.onload = function() {
} }
function getModemInfo(port, timeout) {
console.log("Getting modem info from port " + port);
$("#loading_ttyAMA2_ATI").show();
$("#modem_info_alert").empty();
$("#response_ttyAMA2_ATI").empty();
$.ajax({
url: 'launcher.php?type=sara&port=' + port + '&command=' + encodeURIComponent('ATI') + '&timeout=' + timeout,
dataType: 'text',
method: 'GET',
success: function(response) {
console.log("ATI response:", response);
$("#loading_ttyAMA2_ATI").hide();
// Store raw logs
const formattedLogs = response.replace(/\n/g, "<br>");
$("#response_ttyAMA2_ATI").html(formattedLogs);
// Parse response to detect modem model
let alertHtml = '';
const responseUpper = response.toUpperCase();
if (response.includes('OK') && (responseUpper.includes('SARA-R5') || responseUpper.includes('SARA-R4'))) {
// Extract model name
let modelName = 'SARA';
const modelMatch = response.match(/SARA-R[45]\d*[A-Z]*-\d+[A-Z]*-\d+/i);
if (modelMatch) {
modelName = modelMatch[0];
} else if (responseUpper.includes('SARA-R5')) {
modelName = 'SARA-R5';
} else if (responseUpper.includes('SARA-R4')) {
modelName = 'SARA-R4';
}
alertHtml = `
<div class="alert alert-success py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Modem connected</strong><br>
<small>Model: ${modelName}</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#modem_info_logs" aria-expanded="false" aria-controls="modem_info_logs">
<small>+</small>
</button>
</div>`;
} else if (response.includes('ERROR') || response.trim() === '' || !response.includes('OK')) {
alertHtml = `
<div class="alert alert-danger py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Modem not connected</strong><br>
<small>No response from modem</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#modem_info_logs" aria-expanded="false" aria-controls="modem_info_logs">
<small>+</small>
</button>
</div>`;
} else {
// Unknown response but got something
alertHtml = `
<div class="alert alert-warning py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Modem detected</strong><br>
<small>Unexpected response</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#modem_info_logs" aria-expanded="false" aria-controls="modem_info_logs">
<small>+</small>
</button>
</div>`;
}
$("#modem_info_alert").html(alertHtml);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_ttyAMA2_ATI").hide();
$("#modem_info_alert").html(`
<div class="alert alert-danger py-2 mb-0 mt-2" role="alert">
<strong>Communication error</strong><br>
<small>${error}</small>
</div>`);
}
});
}
function getSimInfo(port, timeout) {
console.log("Getting SIM info from port " + port);
$("#loading_ttyAMA2_AT_CCID_").show();
$("#sim_info_alert").empty();
$("#response_ttyAMA2_AT_CCID_").empty();
$.ajax({
url: 'launcher.php?type=sara&port=' + port + '&command=' + encodeURIComponent('AT+CCID?') + '&timeout=' + timeout,
dataType: 'text',
method: 'GET',
success: function(response) {
console.log("CCID response:", response);
$("#loading_ttyAMA2_AT_CCID_").hide();
// Store raw logs
const formattedLogs = response.replace(/\n/g, "<br>");
$("#response_ttyAMA2_AT_CCID_").html(formattedLogs);
// Parse response to extract SIM card number
let alertHtml = '';
// Match CCID number (typically 19-20 digits)
const ccidMatch = response.match(/\+CCID:\s*(\d{18,22})/);
if (response.includes('OK') && ccidMatch) {
const simNumber = ccidMatch[1];
alertHtml = `
<div class="alert alert-success py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>SIM card connected</strong><br>
<small>ICCID: ${simNumber}</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#sim_info_logs" aria-expanded="false" aria-controls="sim_info_logs">
<small>+</small>
</button>
</div>`;
} else if (response.includes('ERROR') || response.trim() === '' || !response.includes('OK')) {
alertHtml = `
<div class="alert alert-danger py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>SIM card not detected</strong><br>
<small>No SIM card or read error</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#sim_info_logs" aria-expanded="false" aria-controls="sim_info_logs">
<small>+</small>
</button>
</div>`;
} else {
alertHtml = `
<div class="alert alert-warning py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>SIM card detected</strong><br>
<small>Unable to read ICCID</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#sim_info_logs" aria-expanded="false" aria-controls="sim_info_logs">
<small>+</small>
</button>
</div>`;
}
$("#sim_info_alert").html(alertHtml);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_ttyAMA2_AT_CCID_").hide();
$("#sim_info_alert").html(`
<div class="alert alert-danger py-2 mb-0 mt-2" role="alert">
<strong>Communication error</strong><br>
<small>${error}</small>
</div>`);
}
});
}
function getImsiInfo(port, timeout) {
console.log("Getting IMSI from port " + port);
$("#loading_ttyAMA2_AT_CIMI").show();
$("#imsi_info_alert").empty();
$("#response_ttyAMA2_AT_CIMI").empty();
$.ajax({
url: 'launcher.php?type=sara&port=' + port + '&command=' + encodeURIComponent('AT+CIMI') + '&timeout=' + timeout,
dataType: 'text',
method: 'GET',
success: function(response) {
console.log("IMSI response:", response);
$("#loading_ttyAMA2_AT_CIMI").hide();
// Store raw logs
const formattedLogs = response.replace(/\n/g, "<br>");
$("#response_ttyAMA2_AT_CIMI").html(formattedLogs);
// Parse response to extract IMSI (15-digit number)
let alertHtml = '';
const imsiMatch = response.match(/(\d{15})/);
if (response.includes('OK') && imsiMatch) {
const imsiNumber = imsiMatch[1];
alertHtml = `
<div class="alert alert-success py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>IMSI read successfully</strong><br>
<small>IMSI: ${imsiNumber}</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#imsi_info_logs" aria-expanded="false" aria-controls="imsi_info_logs">
<small>+</small>
</button>
</div>`;
} else if (response.includes('ERROR') || response.trim() === '' || !response.includes('OK')) {
alertHtml = `
<div class="alert alert-danger py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>IMSI not available</strong><br>
<small>No SIM card or read error</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#imsi_info_logs" aria-expanded="false" aria-controls="imsi_info_logs">
<small>+</small>
</button>
</div>`;
} else {
alertHtml = `
<div class="alert alert-warning py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>SIM card detected</strong><br>
<small>Unable to read IMSI</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#imsi_info_logs" aria-expanded="false" aria-controls="imsi_info_logs">
<small>+</small>
</button>
</div>`;
}
$("#imsi_info_alert").html(alertHtml);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_ttyAMA2_AT_CIMI").hide();
$("#imsi_info_alert").html(`
<div class="alert alert-danger py-2 mb-0 mt-2" role="alert">
<strong>Communication error</strong><br>
<small>${error}</small>
</div>`);
}
});
}
// Cache for operators data
let operatorsData = null;
function loadOperatorsData() {
return new Promise((resolve, reject) => {
if (operatorsData) {
resolve(operatorsData);
return;
}
$.ajax({
url: 'assets/data/operators.json',
dataType: 'json',
method: 'GET',
success: function(data) {
operatorsData = data;
resolve(data);
},
error: function(xhr, status, error) {
console.error('Failed to load operators data:', error);
reject(error);
}
});
});
}
function getNetworkInfo(port, timeout) {
console.log("Getting network info from port " + port);
$("#loading_ttyAMA2_AT_COPS_").show();
$("#network_info_alert").empty();
$("#response_ttyAMA2_AT_COPS_").empty();
// Load operators data first, then query modem
loadOperatorsData().then(function(opData) {
$.ajax({
url: 'launcher.php?type=sara&port=' + port + '&command=' + encodeURIComponent('AT+COPS?') + '&timeout=' + timeout,
dataType: 'text',
method: 'GET',
success: function(response) {
console.log("COPS response:", response);
$("#loading_ttyAMA2_AT_COPS_").hide();
// Store raw logs
const formattedLogs = response.replace(/\n/g, "<br>");
$("#response_ttyAMA2_AT_COPS_").html(formattedLogs);
// Parse response: +COPS: <mode>[,<format>,<oper>[,<AcT>]]
let alertHtml = '';
const copsMatch = response.match(/\+COPS:\s*(\d+)(?:,(\d+),"?([^",]+)"?,(\d+))?/);
if (response.includes('OK') && copsMatch) {
const mode = copsMatch[1];
const format = copsMatch[2];
const oper = copsMatch[3];
const act = copsMatch[4];
// Get mode description
const modeDesc = opData.modes[mode] || 'Unknown';
// Get operator name
let operatorName = oper || 'Not registered';
let operatorCountry = '';
if (oper && opData.operators[oper]) {
operatorName = opData.operators[oper].name;
operatorCountry = opData.operators[oper].country;
}
// Get access technology
const actDesc = act ? (opData.accessTechnology[act] || 'Unknown') : 'N/A';
if (oper) {
alertHtml = `
<div class="alert alert-success py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Connected to network</strong><br>
<small>
Operator: ${operatorName}${operatorCountry ? ' (' + operatorCountry + ')' : ''}<br>
Technology: ${actDesc}<br>
Mode: ${modeDesc}
</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#network_info_logs" aria-expanded="false" aria-controls="network_info_logs">
<small>+</small>
</button>
</div>`;
} else {
alertHtml = `
<div class="alert alert-warning py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Not registered</strong><br>
<small>Mode: ${modeDesc}</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#network_info_logs" aria-expanded="false" aria-controls="network_info_logs">
<small>+</small>
</button>
</div>`;
}
} else if (response.includes('ERROR') || response.trim() === '' || !response.includes('OK')) {
alertHtml = `
<div class="alert alert-danger py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Network error</strong><br>
<small>Unable to get network info</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#network_info_logs" aria-expanded="false" aria-controls="network_info_logs">
<small>+</small>
</button>
</div>`;
} else {
alertHtml = `
<div class="alert alert-warning py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Unknown response</strong><br>
<small>Check logs for details</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#network_info_logs" aria-expanded="false" aria-controls="network_info_logs">
<small>+</small>
</button>
</div>`;
}
$("#network_info_alert").html(alertHtml);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_ttyAMA2_AT_COPS_").hide();
$("#network_info_alert").html(`
<div class="alert alert-danger py-2 mb-0 mt-2" role="alert">
<strong>Communication error</strong><br>
<small>${error}</small>
</div>`);
}
});
}).catch(function(error) {
$("#loading_ttyAMA2_AT_COPS_").hide();
$("#network_info_alert").html(`
<div class="alert alert-danger py-2 mb-0 mt-2" role="alert">
<strong>Configuration error</strong><br>
<small>Failed to load operators data</small>
</div>`);
});
}
function getSignalInfo(port, timeout) {
console.log("Getting signal info from port " + port);
$("#loading_ttyAMA2_AT_CSQ").show();
$("#signal_info_alert").empty();
$("#response_ttyAMA2_AT_CSQ").empty();
$.ajax({
url: 'launcher.php?type=sara&port=' + port + '&command=' + encodeURIComponent('AT+CSQ') + '&timeout=' + timeout,
dataType: 'text',
method: 'GET',
success: function(response) {
console.log("CSQ response:", response);
$("#loading_ttyAMA2_AT_CSQ").hide();
// Store raw logs
const formattedLogs = response.replace(/\n/g, "<br>");
$("#response_ttyAMA2_AT_CSQ").html(formattedLogs);
// Parse response: +CSQ: <signal_power>,<qual>
let alertHtml = '';
const csqMatch = response.match(/\+CSQ:\s*(\d+),(\d+)/);
if (response.includes('OK') && csqMatch) {
const signalPower = parseInt(csqMatch[1]);
const qual = parseInt(csqMatch[2]);
// Determine signal quality and color (matching Python thresholds)
let signalDesc, signalColor, signalIcon, alertClass;
if (signalPower === 99) {
signalDesc = 'No signal';
signalColor = '#333333';
signalIcon = '⚫';
alertClass = 'alert-dark';
} else if (signalPower === 0) {
signalDesc = 'Very poor';
signalColor = '#dc3545';
signalIcon = '🔴';
alertClass = 'alert-danger';
} else if (signalPower <= 24) {
signalDesc = 'Poor';
signalColor = '#fd7e14';
signalIcon = '🟠';
alertClass = 'alert-warning';
} else if (signalPower <= 26) {
signalDesc = 'Good';
signalColor = '#ffc107';
signalIcon = '🟡';
alertClass = 'alert-warning';
} else if (signalPower <= 28) {
signalDesc = 'Very good';
signalColor = '#198754';
signalIcon = '🟢';
alertClass = 'alert-success';
} else if (signalPower <= 30) {
signalDesc = 'Excellent';
signalColor = '#0d6efd';
signalIcon = '🔵';
alertClass = 'alert-primary';
} else {
signalDesc = 'Maximum';
signalColor = '#6f42c1';
signalIcon = '🟣';
alertClass = 'alert-primary';
}
// Calculate approximate dBm (for RSSI: -113 + 2*signalPower)
let rssiDbm = signalPower !== 99 ? (-113 + 2 * signalPower) + ' dBm' : 'N/A';
// Signal bars visualization (1-5 bars based on signal power, matching thresholds)
let bars = 0;
if (signalPower !== 99) {
if (signalPower >= 29) bars = 5; // Excellent / Very Strong
else if (signalPower >= 27) bars = 4; // Very good
else if (signalPower >= 25) bars = 3; // Good
else if (signalPower >= 10) bars = 2; // Poor (mid)
else if (signalPower >= 1) bars = 1; // Poor (low)
}
const barsHtml = `
<span style="font-size: 1.2em; letter-spacing: 2px;">
${[1,2,3,4,5].map(i =>
`<span style="color: ${i <= bars ? signalColor : '#dee2e6'};">▮</span>`
).join('')}
</span>`;
alertHtml = `
<div class="alert ${alertClass} py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<div class="d-flex align-items-center mb-1">
${barsHtml}
<span class="ms-2"><strong>${signalDesc}</strong></span>
</div>
<small>
Signal: ${signalPower}/31 (${rssiDbm})<br>
Quality: ${qual}/7
</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#signal_info_logs" aria-expanded="false" aria-controls="signal_info_logs">
<small>+</small>
</button>
</div>`;
} else if (response.includes('ERROR') || response.trim() === '' || !response.includes('OK')) {
alertHtml = `
<div class="alert alert-danger py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Signal error</strong><br>
<small>Unable to get signal info</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#signal_info_logs" aria-expanded="false" aria-controls="signal_info_logs">
<small>+</small>
</button>
</div>`;
} else {
alertHtml = `
<div class="alert alert-warning py-2 mb-0 mt-2 d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Unknown response</strong><br>
<small>Check logs for details</small>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#signal_info_logs" aria-expanded="false" aria-controls="signal_info_logs">
<small>+</small>
</button>
</div>`;
}
$("#signal_info_alert").html(alertHtml);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_ttyAMA2_AT_CSQ").hide();
$("#signal_info_alert").html(`
<div class="alert alert-danger py-2 mb-0 mt-2" role="alert">
<strong>Communication error</strong><br>
<small>${error}</small>
</div>`);
}
});
}
function getData_saraR4(port, command, timeout){ function getData_saraR4(port, command, timeout){
console.log("Data from SaraR4"); console.log("Data from SaraR4");
console.log("Port: " + port ); console.log("Port: " + port );
@@ -940,6 +1491,8 @@ function update_modem_configMode(param, checked){
// Self test functions are now in assets/js/selftest.js
</script> </script>

179
html/screen.html Normal file
View File

@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Screen Control</title>
<link rel="stylesheet" href="assets/css/bootstrap.min.css">
<style>
body {
overflow-x: hidden;
}
#sidebar a.nav-link {
position: relative;
display: flex;
align-items: center;
}
#sidebar a.nav-link:hover {
background-color: rgba(0, 0, 0, 0.5);
}
#sidebar a.nav-link svg {
margin-right: 8px;
/* Add spacing between icons and text */
}
#sidebar {
transition: transform 0.3s ease-in-out;
}
.offcanvas-backdrop {
z-index: 1040;
}
</style>
</head>
<body>
<!-- Topbar -->
<span id="topbar"></span>
<!-- Sidebar Offcanvas for Mobile -->
<div class="offcanvas offcanvas-start text-white bg-dark" tabindex="-1" id="sidebarOffcanvas"
aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="sidebarOffcanvasLabel">NebuleAir</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"
aria-label="Close"></button>
</div>
<div class="offcanvas-body" id="sidebar_mobile">
</div>
</div>
<div class="container-fluid mt-5">
<div class="row">
<aside class="col-md-2 col-lg-1 d-none d-md-block vh-100 position-fixed bg-dark text-white" id="sidebar">
</aside>
<!-- Main content -->
<main class="col-md-9 ms-sm-auto col-lg-10 offset-md-3 offset-lg-2 px-md-4">
<h1 class="mt-4" data-i18n="screen.title">Contrôle de l'écran</h1>
<p data-i18n="screen.description">Gérer l'affichage sur l'écran HDMI.</p>
<div class="row mt-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Actions</h5>
<p class="card-text">Démarrer ou arrêter l'application d'affichage sur l'écran HDMI.</p>
<button id="startBtn" class="btn btn-success m-2" onclick="controlScreen('start')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-play-fill" viewBox="0 0 16 16">
<path
d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z" />
</svg>
Démarrer
</button>
<button id="stopBtn" class="btn btn-danger m-2" onclick="controlScreen('stop')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-stop-fill" viewBox="0 0 16 16">
<path
d="M5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11V5A1.5 1.5 0 0 1 5 3.5z" />
</svg>
Arrêter
</button>
</div>
</div>
</div>
</div>
<div id="status-message" class="mt-3 col-md-6"></div>
</main>
</div>
</div>
<!-- JAVASCRIPT -->
<!-- Link Ajax locally -->
<script src="assets/jquery/jquery-3.7.1.min.js"></script>
<!-- Link Bootstrap JS and Popper.js locally -->
<script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system -->
<script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const elementsToLoad = [
{ id: 'topbar', file: 'topbar.html' },
{ id: 'sidebar', file: 'sidebar.html' },
{ id: 'sidebar_mobile', file: 'sidebar.html' }
];
elementsToLoad.forEach(({ id, file }) => {
fetch(file)
.then(response => response.text())
.then(data => {
const element = document.getElementById(id);
if (element) {
element.innerHTML = data;
// Apply translations after loading dynamic content
if (window.i18n && typeof window.i18n.applyTranslations === 'function') {
window.i18n.applyTranslations();
}
// Ensure the screen tab is visible here as well
if (id.includes('sidebar')) {
setTimeout(() => {
const navScreenElements = element.querySelectorAll('.nav-screen-item');
navScreenElements.forEach(el => el.style.display = 'flex');
}, 100);
}
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
});
// Translation fallback for now if keys are missing
setTimeout(() => {
if (document.querySelector('[data-i18n="screen.title"]').innerText === "screen.title") {
document.querySelector('[data-i18n="screen.title"]').innerText = "Contrôle de l'écran";
}
if (document.querySelector('[data-i18n="screen.description"]').innerText === "screen.description") {
document.querySelector('[data-i18n="screen.description"]').innerText = "Gérer l'affichage sur l'écran HDMI.";
}
}, 500);
});
function controlScreen(action) {
console.log("Sending Screen Control Action:", action);
$.ajax({
url: 'launcher.php?type=screen_control&action=' + action,
dataType: 'text',
method: 'GET',
success: function (response) {
console.log("Server Response:", response);
if (action == 'start') {
$('#startBtn').removeClass('btn-success').addClass('btn-secondary').prop('disabled', true);
$('#stopBtn').removeClass('btn-secondary').addClass('btn-danger').prop('disabled', false);
$('#status-message').html('<div class="alert alert-success">L\'écran a été démarré. Réponse: ' + response + '</div>');
} else {
$('#startBtn').removeClass('btn-secondary').addClass('btn-success').prop('disabled', false);
$('#stopBtn').removeClass('btn-danger').addClass('btn-secondary').prop('disabled', true);
$('#status-message').html('<div class="alert alert-warning">L\'écran a été arrêté. Réponse: ' + response + '</div>');
}
},
error: function (xhr, status, error) {
console.error("AJAX Error:", status, error);
$('#status-message').html('<div class="alert alert-danger">Erreur: ' + error + '</div>');
}
});
}
</script>
</body>
</html>

134
html/selftest-modal.html Normal file
View File

@@ -0,0 +1,134 @@
<!-- Self Test Modal -->
<div class="modal fade" id="selfTestModal" tabindex="-1" aria-labelledby="selfTestModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="selfTestModalLabel">Modem Self Test</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="selfTestCloseBtn" disabled></button>
</div>
<div class="modal-body">
<div id="selftest_status" class="mb-3">
<div class="d-flex align-items-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Preparing test...</span>
</div>
</div>
<div class="list-group" id="selftest_results">
<!-- Dynamic sensor test entries will be added here -->
<div id="sensor_tests_container"></div>
<!-- Separator for communication tests -->
<div id="comm_tests_separator" class="list-group-item bg-light text-center py-1" style="display:none;">
<small class="text-muted fw-bold">COMMUNICATION</small>
</div>
<!-- Info: WiFi Status -->
<div class="list-group-item d-flex justify-content-between align-items-center" id="test_wifi">
<div>
<strong>WiFi / Network</strong>
<div class="small text-muted" id="test_wifi_detail">Waiting...</div>
</div>
<span id="test_wifi_status" class="badge bg-secondary">Pending</span>
</div>
<!-- Test: Modem Connection -->
<div class="list-group-item d-flex justify-content-between align-items-center" id="test_modem">
<div>
<strong>Modem Connection</strong>
<div class="small text-muted" id="test_modem_detail">Waiting...</div>
</div>
<span id="test_modem_status" class="badge bg-secondary">Pending</span>
</div>
<!-- Test: SIM Card -->
<div class="list-group-item d-flex justify-content-between align-items-center" id="test_sim">
<div>
<strong>SIM Card</strong>
<div class="small text-muted" id="test_sim_detail">Waiting...</div>
</div>
<span id="test_sim_status" class="badge bg-secondary">Pending</span>
</div>
<!-- Test: Signal Strength -->
<div class="list-group-item d-flex justify-content-between align-items-center" id="test_signal">
<div>
<strong>Signal Strength</strong>
<div class="small text-muted" id="test_signal_detail">Waiting...</div>
</div>
<span id="test_signal_status" class="badge bg-secondary">Pending</span>
</div>
<!-- Test: Network Connection -->
<div class="list-group-item d-flex justify-content-between align-items-center" id="test_network">
<div>
<strong>Network Connection</strong>
<div class="small text-muted" id="test_network_detail">Waiting...</div>
</div>
<span id="test_network_status" class="badge bg-secondary">Pending</span>
</div>
</div>
<!-- Logs section -->
<div class="mt-3">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#selftest_logs_collapse">
Show detailed logs
</button>
<div class="collapse mt-2" id="selftest_logs_collapse">
<div class="card card-body bg-dark text-light" style="max-height: 250px; overflow-y: auto; font-family: monospace; font-size: 0.75rem;">
<pre id="selftest_logs" style="margin: 0; white-space: pre-wrap; word-wrap: break-word;"></pre>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<div id="selftest_summary" class="me-auto"></div>
<button type="button" class="btn btn-primary" id="selfTestCopyBtn" onclick="openShareReportModal()" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-share me-1" viewBox="0 0 16 16">
<path d="M13.5 1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.499 2.499 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5zm-8.5 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm11 5.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"/>
</svg>
Share Report
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="selfTestDoneBtn" disabled>Close</button>
</div>
</div>
</div>
</div>
<!-- Share Report Modal -->
<div class="modal fade" id="shareReportModal" tabindex="-1" aria-labelledby="shareReportModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="shareReportModalLabel">Share Diagnostic Report</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<strong>Need help?</strong> You can send this diagnostic report to our support team at
<a href="mailto:contact@aircarto.fr?subject=NebuleAir%20Diagnostic%20Report" class="alert-link">contact@aircarto.fr</a>
<br><small>Select all the text below (Ctrl+A) and copy it (Ctrl+C), or use the Download button.</small>
</div>
<div class="mb-3">
<textarea id="shareReportText" class="form-control font-monospace" rows="15" readonly style="font-size: 0.75rem; background-color: #1e1e1e; color: #d4d4d4;"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" onclick="downloadReport()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download me-1" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
Download (.txt)
</button>
<button type="button" class="btn btn-outline-primary" onclick="selectAllReportText()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cursor-text me-1" viewBox="0 0 16 16">
<path d="M5 2a.5.5 0 0 1 .5-.5c.862 0 1.573.287 2.06.566.174.099.321.198.44.286.119-.088.266-.187.44-.286A4.165 4.165 0 0 1 10.5 1.5a.5.5 0 0 1 0 1c-.638 0-1.177.213-1.564.434a3.49 3.49 0 0 0-.436.294V7.5H9a.5.5 0 0 1 0 1h-.5v4.272c.1.08.248.187.436.294.387.221.926.434 1.564.434a.5.5 0 0 1 0 1 4.165 4.165 0 0 1-2.06-.566A4.561 4.561 0 0 1 8 13.65a4.561 4.561 0 0 1-.44.285 4.165 4.165 0 0 1-2.06.566.5.5 0 0 1 0-1c.638 0 1.177-.213 1.564-.434.188-.107.335-.214.436-.294V8.5H7a.5.5 0 0 1 0-1h.5V3.228a3.49 3.49 0 0 0-.436-.294A3.166 3.166 0 0 0 5.5 2.5.5.5 0 0 1 5 2z"/>
</svg>
Select All
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -9,176 +10,226 @@
body { body {
overflow-x: hidden; overflow-x: hidden;
} }
#sidebar a.nav-link { #sidebar a.nav-link {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
} }
#sidebar a.nav-link:hover { #sidebar a.nav-link:hover {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
} }
#sidebar a.nav-link svg { #sidebar a.nav-link svg {
margin-right: 8px; /* Add spacing between icons and text */ margin-right: 8px;
/* Add spacing between icons and text */
} }
#sidebar { #sidebar {
transition: transform 0.3s ease-in-out; transition: transform 0.3s ease-in-out;
} }
.offcanvas-backdrop { .offcanvas-backdrop {
z-index: 1040; z-index: 1040;
} }
</style> </style>
</head> </head>
<body> <body>
<!-- Topbar --> <!-- Topbar -->
<span id="topbar"></span> <span id="topbar"></span>
<!-- Sidebar Offcanvas for Mobile --> <!-- Sidebar Offcanvas for Mobile -->
<div class="offcanvas offcanvas-start text-white bg-dark" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel"> <div class="offcanvas offcanvas-start text-white bg-dark" tabindex="-1" id="sidebarOffcanvas"
aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header"> <div class="offcanvas-header">
<h5 class="offcanvas-title" id="sidebarOffcanvasLabel">NebuleAir</h5> <h5 class="offcanvas-title" id="sidebarOffcanvasLabel">NebuleAir</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div> </div>
<div class="offcanvas-body" id="sidebar_mobile"> <div class="offcanvas-body" id="sidebar_mobile">
</div> </div>
</div> </div>
<div class="container-fluid mt-5"> <div class="container-fluid mt-5">
<div class="row"> <div class="row">
<aside class="col-md-2 col-lg-1 d-none d-md-block vh-100 position-fixed bg-dark text-white" id="sidebar"> <aside class="col-md-2 col-lg-1 d-none d-md-block vh-100 position-fixed bg-dark text-white" id="sidebar">
</aside> </aside>
<!-- Main content --> <!-- Main content -->
<main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4"> <main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4" data-i18n="sensors.title">Les sondes de mesure</h1> <h1 class="mt-4" data-i18n="sensors.title">Les sondes de mesure</h1>
<p data-i18n="sensors.description">Votre capteur NebuleAir est équipé de une ou plusieurs sondes qui permettent de mesurer certaines variables environnementales. La mesure <p data-i18n="sensors.description">Votre capteur NebuleAir est équipé de une ou plusieurs sondes qui permettent
est automatique mais vous pouvez ici vous assurer de leur bon fonctionnement. de mesurer certaines variables environnementales. La mesure
est automatique mais vous pouvez ici vous assurer de leur bon fonctionnement.
</p> </p>
<button class="btn btn-success mb-3 btn_selfTest" onclick="runSelfTest()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2-circle me-1" viewBox="0 0 16 16">
<path d="M2.5 8a5.5 5.5 0 1 1 11 0 5.5 5.5 0 0 1-11 0z"/>
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l7-7z"/>
</svg>
Run Self Test
</button>
<div class="row mb-3" id="card-container"></div> <div class="row mb-3" id="card-container"></div>
</main> </main>
</div> </div>
</div> </div>
<!-- JAVASCRIPT --> <!-- JAVASCRIPT -->
<!-- Link Ajax locally --> <!-- Link Ajax locally -->
<script src="assets/jquery/jquery-3.7.1.min.js"></script> <script src="assets/jquery/jquery-3.7.1.min.js"></script>
<!-- Link Bootstrap JS and Popper.js locally --> <!-- Link Bootstrap JS and Popper.js locally -->
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system --> <!-- i18n translation system -->
<script src="assets/js/i18n.js"></script> <script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script src="assets/js/selftest.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const elementsToLoad = [ const elementsToLoad = [
{ id: 'topbar', file: 'topbar.html' }, { id: 'topbar', file: 'topbar.html' },
{ id: 'sidebar', file: 'sidebar.html' }, { id: 'sidebar', file: 'sidebar.html' },
{ id: 'sidebar_mobile', file: 'sidebar.html' } { id: 'sidebar_mobile', file: 'sidebar.html' }
]; ];
elementsToLoad.forEach(({ id, file }) => { elementsToLoad.forEach(({ id, file }) => {
fetch(file) fetch(file)
.then(response => response.text()) .then(response => response.text())
.then(data => { .then(data => {
const element = document.getElementById(id); const element = document.getElementById(id);
if (element) { if (element) {
element.innerHTML = data; element.innerHTML = data;
} // Apply translations after loading dynamic content
}) if (window.i18n && typeof window.i18n.applyTranslations === 'function') {
.catch(error => console.error(`Error loading ${file}:`, error)); window.i18n.applyTranslations();
}
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
});
}); });
});
function getNPM_values(port){ function getNPM_values(port) {
console.log("Data from NPM (port "+port+"):"); console.log("Data from NPM (port " + port + "):");
$("#loading_"+port).show(); $("#loading_" + port).show();
$.ajax({ $.ajax({
url: 'launcher.php?type=npm&port='+port, url: 'launcher.php?type=npm&port=' + port,
dataType: 'json', // Specify that you expect a JSON response dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs method: 'GET', // Use GET or POST depending on your needs
success: function(response) { success: function (response) {
console.log(response); console.log(response);
const tableBody = document.getElementById("data-table-body_"+port); const tableBody = document.getElementById("data-table-body_" + port);
tableBody.innerHTML = ""; tableBody.innerHTML = "";
$("#loading_"+port).hide(); $("#loading_" + port).hide();
// Create an array of the desired keys // Create an array of the desired keys
const keysToShow = ["PM1", "PM25", "PM10","message"]; const keysToShow = ["PM1", "PM25", "PM10", "message"];
// Error messages mapping // Error messages mapping
const errorMessages = { const errorMessages = {
"notReady": "Sensor is not ready", "notReady": "Sensor is not ready",
"fanError": "Fan malfunction detected", "fanError": "Fan malfunction detected",
"laserError": "Laser malfunction detected", "laserError": "Laser malfunction detected",
"heatError": "Heating system error", "heatError": "Heating system error",
"t_rhError": "Temperature/Humidity sensor error", "t_rhError": "Temperature/Humidity sensor error",
"memoryError": "Memory failure detected", "memoryError": "Memory failure detected",
"degradedState": "Sensor in degraded state" "degradedState": "Sensor in degraded state"
}; };
// Add only the specified elements to the table // Add only the specified elements to the table
keysToShow.forEach(key => { keysToShow.forEach(key => {
if (response[key] !== undefined) { // Check if the key exists in the response if (response[key] !== undefined) { // Check if the key exists in the response
const value = response[key]; const value = response[key];
$("#data-table-body_"+port).append(` $("#data-table-body_" + port).append(`
<tr> <tr>
<td>${key}</td> <td>${key}</td>
<td>${value} µg/m³</td> <td>${value} µg/m³</td>
</tr> </tr>
`); `);
} }
}); });
// Check for errors and add them to the table // Check for errors and add them to the table
Object.keys(errorMessages).forEach(errorKey => { Object.keys(errorMessages).forEach(errorKey => {
if (response[errorKey] === 1) { if (response[errorKey] === 1) {
$("#data-table-body_" + port).append(` $("#data-table-body_" + port).append(`
<tr class="error-row"> <tr class="error-row">
<td><b>${errorKey}</b></td> <td><b>${errorKey}</b></td>
<td style="color: red;">⚠ ${errorMessages[errorKey]}</td> <td style="color: red;">⚠ ${errorMessages[errorKey]}</td>
</tr> </tr>
`); `);
} }
}); });
}, },
error: function(xhr, status, error) { error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error); console.error('AJAX request failed:', status, error);
}
});
} }
});
function getENVEA_values(port, name){ }
console.log("Data from Envea " + name + " (port " + port + "):");
$("#loading_envea" + name).show();
$.ajax({ function getNPM_firmware(port) {
console.log("Firmware version from NPM (port " + port + "):");
$("#loading_fw_" + port).show();
$.ajax({
url: 'launcher.php?type=npm_firmware&port=' + port,
dataType: 'json',
method: 'GET',
success: function (response) {
console.log(response);
$("#loading_fw_" + port).hide();
const fwSpan = document.getElementById("fw_version_" + port);
if (response.firmware_version !== undefined) {
fwSpan.innerHTML = '<span class="badge bg-success">Firmware: ' + response.firmware_version + '</span>';
} else {
fwSpan.innerHTML = '<span class="badge bg-danger">Error reading firmware</span>';
}
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_fw_" + port).hide();
const fwSpan = document.getElementById("fw_version_" + port);
fwSpan.innerHTML = '<span class="badge bg-danger">Error</span>';
}
});
}
function getENVEA_values(port, name) {
console.log("Data from Envea " + name + " (port " + port + "):");
$("#loading_envea" + name).show();
$.ajax({
url: 'launcher.php?type=envea&port=' + port + '&name=' + name, url: 'launcher.php?type=envea&port=' + port + '&name=' + name,
dataType: 'json', dataType: 'json',
method: 'GET', method: 'GET',
success: function(response) { success: function (response) {
console.log(response); console.log(response);
const tableBody = document.getElementById("data-table-body_envea" + name); const tableBody = document.getElementById("data-table-body_envea" + name);
tableBody.innerHTML = ""; tableBody.innerHTML = "";
$("#loading_envea" + name).hide(); $("#loading_envea" + name).hide();
const keysToShow = [name]; const keysToShow = [name];
keysToShow.forEach(key => { keysToShow.forEach(key => {
if (response !== undefined) { if (response !== undefined) {
const value = response; const value = response;
$("#data-table-body_envea" + name).append(` $("#data-table-body_envea" + name).append(`
<tr> <tr>
<td>${key}</td> <td>${key}</td>
<td>${value} ppb</td> <td>${value} ppb</td>
</tr> </tr>
`); `);
} }
}); });
}, },
error: function(xhr, status, error) { error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error); console.error('AJAX request failed:', status, error);
const tableBody = document.getElementById("data-table-body_envea" + name); const tableBody = document.getElementById("data-table-body_envea" + name);
$("#loading_envea" + name).hide(); $("#loading_envea" + name).hide();
tableBody.innerHTML = ` tableBody.innerHTML = `
<tr> <tr>
<td colspan="2" class="text-danger"> <td colspan="2" class="text-danger">
❌ Error: unable to get data from sensor.<br> ❌ Error: unable to get data from sensor.<br>
@@ -187,132 +238,233 @@ function getENVEA_values(port, name){
</tr> </tr>
`; `;
} }
}); });
} }
function getENVEA_debug_values() {
console.log("Getting debug data from all Envea sensors");
$("#loading_envea_debug").show();
$.ajax({
url: 'launcher.php?type=envea_debug',
dataType: 'text',
method: 'GET',
success: function (response) {
console.log("Envea debug output:", response);
const outputDiv = document.getElementById("envea-debug-output");
$("#loading_envea_debug").hide();
// Display raw output in a pre block
outputDiv.innerHTML = `<pre style="background-color: #f5f5f5; padding: 10px; border-radius: 5px; max-height: 500px; overflow-y: auto; font-size: 12px;">${response}</pre>`;
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
const outputDiv = document.getElementById("envea-debug-output");
$("#loading_envea_debug").hide();
outputDiv.innerHTML = `
<div class="alert alert-danger">
❌ Error: unable to get debug data from sensors.<br>
<small>${status}: ${error}</small>
</div>
`;
}
});
}
function getNoise_values(){ function getNoise_values() {
console.log("Data from I2C Noise Sensor:"); console.log("Data from NSRT MK4 Noise Sensor:");
$("#loading_noise").show(); $("#loading_noise").show();
$.ajax({ $.ajax({
url: 'launcher.php?type=noise', url: 'launcher.php?type=noise',
dataType: 'text', dataType: 'json',
method: 'GET', // Use GET or POST depending on your needs method: 'GET',
success: function(response) { success: function (response) {
console.log(response); console.log(response);
const tableBody = document.getElementById("data-table-body_noise"); const tableBody = document.getElementById("data-table-body_noise");
tableBody.innerHTML = ""; tableBody.innerHTML = "";
$("#loading_noise").hide(); $("#loading_noise").hide();
// Create an array of the desired keys if (response.error) {
const keysToShow = ["Noise"]; $("#data-table-body_noise").append(`
// Add only the specified elements to the table <tr><td colspan="2" class="text-danger">${response.error}</td></tr>
keysToShow.forEach(key => { `);
if (response !== undefined) { // Check if the key exists in the response return;
const value = response; }
$("#data-table-body_noise").append(`
<tr> const rows = [
<td>${key}</td> { label: "LEQ", value: response.LEQ + " dB" },
<td>${value} DB</td> { label: "dB(A)", value: response.dBA + " dBA" },
</tr> { label: "Weighting", value: response.weighting },
`); { label: "Tau", value: response.tau + " s" }
} ];
}); rows.forEach(row => {
}, $("#data-table-body_noise").append(`
error: function(xhr, status, error) { <tr>
console.error('AJAX request failed:', status, error); <td>${row.label}</td>
} <td>${row.value}</td>
}); </tr>
`);
});
},
error: function (xhr, status, error) {
$("#loading_noise").hide();
console.error('AJAX request failed:', status, error);
} }
});
function getBME280_values(){ }
function getMHZ19_values() {
console.log("Data from MH-Z19 CO2 sensor:");
$("#loading_mhz19").show();
$.ajax({
url: 'launcher.php?type=mhz19',
dataType: 'json',
method: 'GET',
success: function (response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_mhz19");
tableBody.innerHTML = "";
$("#loading_mhz19").hide();
if (response.error) {
$("#data-table-body_mhz19").append(`
<tr>
<td colspan="2" class="text-danger">
${response.error}
</td>
</tr>
`);
} else if (response.CO2 !== undefined) {
$("#data-table-body_mhz19").append(`
<tr>
<td>CO2</td>
<td>${response.CO2} ppm</td>
</tr>
`);
}
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_mhz19").hide();
const tableBody = document.getElementById("data-table-body_mhz19");
tableBody.innerHTML = `
<tr>
<td colspan="2" class="text-danger">
⚠ Erreur de communication avec le capteur
</td>
</tr>
`;
}
});
}
function getBME280_values() {
console.log("Data from I2C BME280:"); console.log("Data from I2C BME280:");
$("#loading_BME280").show(); $("#loading_BME280").show();
$.ajax({ $.ajax({
url: 'launcher.php?type=BME280', url: 'launcher.php?type=BME280',
dataType: 'text', dataType: 'text',
method: 'GET', // Use GET or POST depending on your needs method: 'GET', // Use GET or POST depending on your needs
success: function(response) { success: function (response) {
console.log(response); console.log(response);
const tableBody = document.getElementById("data-table-body_BME280"); const tableBody = document.getElementById("data-table-body_BME280");
tableBody.innerHTML = ""; tableBody.innerHTML = "";
$("#loading_BME280").hide(); $("#loading_BME280").hide();
// Parse the JSON response // Parse the JSON response
const data = JSON.parse(response); const data = JSON.parse(response);
const keysToShow = ["temp", "hum", "press"]; const keysToShow = ["temp", "hum", "press"];
// Add only the specified elements to the table // Add only the specified elements to the table
keysToShow.forEach(key => { keysToShow.forEach(key => {
if (response !== undefined) { // Check if the key exists in the response if (response !== undefined) { // Check if the key exists in the response
const value = data[key]; const value = data[key];
const unit = key === "temp" ? "°C" const unit = key === "temp" ? "°C"
: key === "hum" ? "%" : key === "hum" ? "%"
: key === "press" ? "hPa" : key === "press" ? "hPa"
: ""; // Add appropriate units : ""; // Add appropriate units
$("#data-table-body_BME280").append(` $("#data-table-body_BME280").append(`
<tr> <tr>
<td>${key.charAt(0).toUpperCase() + key.slice(1)}</td> <td>${key.charAt(0).toUpperCase() + key.slice(1)}</td>
<td>${value} ${unit}</td> <td>${value} ${unit}</td>
</tr> </tr>
`); `);
} }
}); });
}, },
error: function(xhr, status, error) { error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error); console.error('AJAX request failed:', status, error);
}
});
} }
window.onload = function() {
//NEW way to get config (SQLite)
let mainConfig = {}; // Store main config for use in sensor card creation
$.ajax({
url: 'launcher.php?type=get_config_sqlite',
dataType:'json',
//dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log("Getting SQLite config table:");
console.log(response);
mainConfig = response; // Store for later use
//device name_side bar
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = response.deviceName;
}); });
}
// After getting main config, create sensor cards
createSensorCards(mainConfig);
}, window.onload = function () {
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});//end AJAX
//Function to create sensor cards based on config //NEW way to get config (SQLite)
function createSensorCards(config) { let mainConfig = {}; // Store main config for use in sensor card creation
console.log("Creating sensor cards with config:");
console.log(config);
const container = document.getElementById('card-container'); // Conteneur des cartes $.ajax({
url: 'launcher.php?type=get_config_sqlite',
dataType: 'json',
//dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function (response) {
console.log("Getting SQLite config table:");
console.log(response);
//creates NPM card (by default) mainConfig = response; // Store for later use
const cardHTML = ` // Function to update sidebar device name
function updateSidebarDeviceName(deviceName) {
const elements = document.querySelectorAll('.sideBar_sensorName');
if (elements.length > 0) {
elements.forEach((element) => {
element.innerText = deviceName;
});
console.log("Device name updated in sidebar:", deviceName);
}
}
// Update device name immediately and with retries to handle async sidebar loading
if (response.deviceName) {
updateSidebarDeviceName(response.deviceName);
// Retry after delays to catch async sidebar load
setTimeout(() => updateSidebarDeviceName(response.deviceName), 100);
setTimeout(() => updateSidebarDeviceName(response.deviceName), 500);
// Set page title
document.title = response.deviceName;
}
// After getting main config, create sensor cards
createSensorCards(mainConfig);
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});//end AJAX
//Function to create sensor cards based on config
function createSensorCards(config) {
console.log("Creating sensor cards with config:");
console.log(config);
const container = document.getElementById('card-container'); // Conteneur des cartes
//creates NPM card (by default)
const cardHTML = `
<div class="col-sm-3"> <div class="col-sm-3">
<div class="card"> <div class="card">
<div class="card-header" data-i18n="sensors.npm.headerUart"> <div class="card-header" data-i18n="sensors.npm.headerUart">
@@ -322,8 +474,11 @@ error: function(xhr, status, error) {
<h5 class="card-title" data-i18n="sensors.npm.title">NextPM</h5> <h5 class="card-title" data-i18n="sensors.npm.title">NextPM</h5>
<p class="card-text" data-i18n="sensors.npm.description">Capteur particules fines.</p> <p class="card-text" data-i18n="sensors.npm.description">Capteur particules fines.</p>
<button class="btn btn-primary" onclick="getNPM_values('ttyAMA5')" data-i18n="common.getData">Get Data</button> <button class="btn btn-primary" onclick="getNPM_values('ttyAMA5')" data-i18n="common.getData">Get Data</button>
<button class="btn btn-secondary" onclick="getNPM_firmware('ttyAMA5')">Firmware Version</button>
<br> <br>
<div id="loading_ttyAMA5" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div> <div id="loading_ttyAMA5" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="loading_fw_ttyAMA5" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="fw_version_ttyAMA5" class="mt-1"></div>
<table class="table table-striped-columns"> <table class="table table-striped-columns">
<tbody id="data-table-body_ttyAMA5"></tbody> <tbody id="data-table-body_ttyAMA5"></tbody>
</table> </table>
@@ -331,12 +486,12 @@ error: function(xhr, status, error) {
</div> </div>
</div>`; </div>`;
container.innerHTML += cardHTML; // Add the I2C card if condition is met container.innerHTML += cardHTML; // Add the I2C card if condition is met
//creates i2c BME280 card //creates i2c BME280 card
if (config.BME280) { if (config.BME280) {
const i2C_BME_HTML = ` const i2C_BME_HTML = `
<div class="col-sm-3"> <div class="col-sm-3">
<div class="card"> <div class="card">
<div class="card-header" data-i18n="sensors.bme280.headerI2c"> <div class="card-header" data-i18n="sensors.bme280.headerI2c">
@@ -355,24 +510,21 @@ error: function(xhr, status, error) {
</div> </div>
</div>`; </div>`;
container.innerHTML += i2C_BME_HTML; // Add the I2C card if condition is met container.innerHTML += i2C_BME_HTML; // Add the I2C card if condition is met
} }
//creates i2c sound card //creates NSRT MK4 noise sensor card (USB)
if (config.NOISE) { if (config.NOISE) {
const i2C_HTML = ` const noiseHTML = `
<div class="col-sm-3"> <div class="col-sm-3">
<div class="card"> <div class="card">
<div class="card-header" data-i18n="sensors.noise.headerI2c"> <div class="card-header" data-i18n="sensors.noise.headerUsb">
Port I2C Port USB
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title" data-i18n="sensors.noise.title">Decibel Meter</h5> <h5 class="card-title" data-i18n="sensors.noise.title">NSRT MK4</h5>
<p class="card-text" data-i18n="sensors.noise.description">Capteur bruit sur le port I2C.</p> <p class="card-text" data-i18n="sensors.noise.description">Sonomètre NSRT MK4 sur port USB.</p>
<button class="btn btn-primary mb-1" onclick="getNoise_values()" data-i18n="common.getData">Get Data</button> <button class="btn btn-primary mb-1" onclick="getNoise_values()" data-i18n="common.getData">Get Data</button>
<br>
<button class="btn btn-success" onclick="startNoise()" data-i18n="common.startRecording">Start recording</button>
<button class="btn btn-danger" onclick="stopNoise()" data-i18n="common.stopRecording">Stop recording</button>
<div id="loading_noise" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div> <div id="loading_noise" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<table class="table table-striped-columns"> <table class="table table-striped-columns">
<tbody id="data-table-body_noise"></tbody> <tbody id="data-table-body_noise"></tbody>
@@ -381,85 +533,103 @@ error: function(xhr, status, error) {
</div> </div>
</div>`; </div>`;
container.innerHTML += i2C_HTML; // Add the I2C card if condition is met container.innerHTML += noiseHTML;
} }
//Si on a des SONDES ENVEA connectée il faut faire un deuxième call dans la table envea_sondes_table //creates MH-Z19 CO2 card
//creates ENVEA cards if (config.MHZ19) {
if (config.envea) { const MHZ19_HTML = `
console.log("Need to display ENVEA sondes"); <div class="col-sm-3">
//getting config_scripts table <div class="card">
$.ajax({ <div class="card-header">
url: 'launcher.php?type=get_envea_sondes_table_sqlite', Port UART 4
dataType:'json', </div>
//dataType: 'json', // Specify that you expect a JSON response <div class="card-body">
method: 'GET', // Use GET or POST depending on your needs <h5 class="card-title">MH-Z19 CO2</h5>
success: function(sondes) { <p class="card-text">Capteur de dioxyde de carbone.</p>
console.log("Getting SQLite envea sondes table:"); <button class="btn btn-primary mb-1" onclick="getMHZ19_values()" data-i18n="common.getData">Get Data</button>
console.log(sondes); <div id="loading_mhz19" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
const ENVEA_sensors = sondes.filter(sonde => sonde.connected); // Filter only connected sondes <table class="table table-striped-columns">
<tbody id="data-table-body_mhz19"></tbody>
</table>
</div>
</div>
</div>`;
ENVEA_sensors.forEach((sensor, index) => { container.innerHTML += MHZ19_HTML;
const port = sensor.port; // Port from the sensor object }
const name = sensor.name; // Port from the sensor object
const coefficient = sensor.coefficient; //Si on a des SONDES ENVEA connectée il faut faire un deuxième call dans la table envea_sondes_table
const cardHTML = ` //creates ENVEA debug card
<div class="col-sm-3"> if (config.envea) {
console.log("Need to display ENVEA sondes");
//getting config_scripts table
$.ajax({
url: 'launcher.php?type=get_envea_sondes_table_sqlite',
dataType: 'json',
method: 'GET',
success: function (sondes) {
console.log("Getting SQLite envea sondes table:");
console.log(sondes);
const ENVEA_sensors = sondes.filter(sonde => sonde.connected); // Filter only connected sondes
// Only create the card if there are connected sensors
if (ENVEA_sensors.length > 0) {
// Create a single debug card for all Envea sensors
const cardHTML = `
<div class="col-sm-6">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
Port UART ${port.replace('ttyAMA', '')} <strong>Sondes Envea (Debug)</strong>
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Sonde Envea ${name}</h5> <h5 class="card-title" data-i18n="sensors.envea.title">Sonde Envea</h5>
<p class="card-text" data-i18n="sensors.envea.description">Capteur gas.</p> <p class="card-text" data-i18n="sensors.envea.description">Capteur gaz.</p>
<button class="btn btn-primary" onclick="getENVEA_values('${port}','${name}')" data-i18n="common.getData">Get Data</button> <p class="text-muted small">Sondes connectées: ${ENVEA_sensors.map(s => s.name).join(', ')}</p>
<div id="loading_envea${name}" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div> <button class="btn btn-primary" onclick="getENVEA_debug_values()" data-i18n="common.getData">Get Data</button>
<table class="table table-striped-columns"> <div id="loading_envea_debug" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<tbody id="data-table-body_envea${name}"></tbody> <div id="envea-debug-output" class="mt-3"></div>
</table>
</div> </div>
</div> </div>
</div>`; </div>`;
container.innerHTML += cardHTML; // Ajouter la carte au conteneur container.innerHTML += cardHTML;
});
// Apply translations to dynamically created Envea cards
i18n.applyTranslations();
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});//end AJAX envea Sondes
}//end if envea
// Apply translations to all dynamically created sensor cards
i18n.applyTranslations();
} // end createSensorCards function
//get local RTC
$.ajax({
url: 'launcher.php?type=RTC_time',
dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log("Local RTC: " + response);
const RTC_Element = document.getElementById("RTC_time");
RTC_Element.textContent = response;
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
} }
// Apply translations to dynamically created Envea card
i18n.applyTranslations();
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});//end AJAX envea Sondes
}//end if envea
// Apply translations to all dynamically created sensor cards
i18n.applyTranslations();
} // end createSensorCards function
//get local RTC
$.ajax({
url: 'launcher.php?type=RTC_time',
dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function (response) {
console.log("Local RTC: " + response);
const RTC_Element = document.getElementById("RTC_time");
RTC_Element.textContent = response;
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
}); });
} //end windows onload } //end windows onload
</script> </script>
</body> </body>
</html>
</html>

View File

@@ -1,47 +1,70 @@
<!-- Sidebar --> <!-- Sidebar -->
<nav class="nav flex-column"> <nav class="nav flex-column">
<a class="nav-link text-white mt-4" href="index.html"> <a class="nav-link text-white mt-4" href="index.html">
<svg class="bi me-2" width="16" height="16" fill="currentColor" aria-hidden="true"> <svg class="bi me-2" width="16" height="16" fill="currentColor" aria-hidden="true">
<use xlink:href="assets/icons/bootstrap-icons.svg#house"></use> <use xlink:href="assets/icons/bootstrap-icons.svg#house"></use>
</svg> </svg>
<span data-i18n="sidebar.home">Accueil</span> <span data-i18n="sidebar.home">Accueil</span>
</a> </a>
<a class="nav-link text-white" href="sensors.html">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-thermometer-sun" viewBox="0 0 16 16">
<path d="M5 12.5a1.5 1.5 0 1 1-2-1.415V2.5a.5.5 0 0 1 1 0v8.585A1.5 1.5 0 0 1 5 12.5"/>
<path d="M1 2.5a2.5 2.5 0 0 1 5 0v7.55a3.5 3.5 0 1 1-5 0zM3.5 1A1.5 1.5 0 0 0 2 2.5v7.987l-.167.15a2.5 2.5 0 1 0 3.333 0L5 10.486V2.5A1.5 1.5 0 0 0 3.5 1m5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1a.5.5 0 0 1 .5-.5m4.243 1.757a.5.5 0 0 1 0 .707l-.707.708a.5.5 0 1 1-.708-.708l.708-.707a.5.5 0 0 1 .707 0M8 5.5a.5.5 0 0 1 .5-.5 3 3 0 1 1 0 6 .5.5 0 0 1 0-1 2 2 0 0 0 0-4 .5.5 0 0 1-.5-.5M12.5 8a.5.5 0 0 1 .5-.5h1a.5.5 0 1 1 0 1h-1a.5.5 0 0 1-.5-.5m-1.172 2.828a.5.5 0 0 1 .708 0l.707.708a.5.5 0 0 1-.707.707l-.708-.707a.5.5 0 0 1 0-.708M8.5 12a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1a.5.5 0 0 1 .5-.5"/>
</svg>
<span data-i18n="sidebar.sensors">Capteurs</span>
</a>
<a class="nav-link text-white" href="database.html">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-database" viewBox="0 0 16 16">
<path d="M4.318 2.687C5.234 2.271 6.536 2 8 2s2.766.27 3.682.687C12.644 3.125 13 3.627 13 4c0 .374-.356.875-1.318 1.313C10.766 5.729 9.464 6 8 6s-2.766-.27-3.682-.687C3.356 4.875 3 4.373 3 4c0-.374.356-.875 1.318-1.313M13 5.698V7c0 .374-.356.875-1.318 1.313C10.766 8.729 9.464 9 8 9s-2.766-.27-3.682-.687C3.356 7.875 3 7.373 3 7V5.698c.271.202.58.378.904.525C4.978 6.711 6.427 7 8 7s3.022-.289 4.096-.777A5 5 0 0 0 13 5.698M14 4c0-1.007-.875-1.755-1.904-2.223C11.022 1.289 9.573 1 8 1s-3.022.289-4.096.777C2.875 2.245 2 2.993 2 4v9c0 1.007.875 1.755 1.904 2.223C4.978 15.71 6.427 16 8 16s3.022-.289 4.096-.777C13.125 14.755 14 14.007 14 13zm-1 4.698V10c0 .374-.356.875-1.318 1.313C10.766 11.729 9.464 12 8 12s-2.766-.27-3.682-.687C3.356 10.875 3 10.373 3 10V8.698c.271.202.58.378.904.525C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777A5 5 0 0 0 13 8.698m0 3V13c0 .374-.356.875-1.318 1.313C10.766 14.729 9.464 15 8 15s-2.766-.27-3.682-.687C3.356 13.875 3 13.373 3 13v-1.302c.271.202.58.378.904.525C4.978 12.71 6.427 13 8 13s3.022-.289 4.096-.777c.324-.147.633-.323.904-.525"/>
</svg>
<span data-i18n="sidebar.database">Base de données</span> <!-- Screen Control (Hidden by default) -->
</a> <a class="nav-link text-white nav-screen-item" href="screen.html" id="nav-screen" style="display: none;">
<a class="nav-link text-white" href="saraR4.html"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-display"
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-reception-4" viewBox="0 0 16 16"> viewBox="0 0 16 16">
<path d="M0 11.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5z"/> <path
</svg> d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4c0 .667.083 1.167.25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75c.167-.333.25-.833.25-1.5H2s-2 0-2-2V4zm1.398-.855a.758.758 0 0 0-.254.302A1.46 1.46 0 0 0 1 4.01V10c0 .325.078.502.145.602.07.105.17.188.302.254a1.464 1.464 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.758.758 0 0 0 .254-.302 1.464 1.464 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.757.757 0 0 0-.302-.254A1.46 1.46 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145z" />
<span data-i18n="sidebar.modem4g">Modem 4G</span> </svg>
</a> <span data-i18n="sidebar.screen">Screen</span>
<a class="nav-link text-white" href="wifi.html"> </a>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-wifi" viewBox="0 0 16 16"> <a class="nav-link text-white" href="sensors.html">
<path d="M15.384 6.115a.485.485 0 0 0-.047-.736A12.44 12.44 0 0 0 8 3C5.259 3 2.723 3.882.663 5.379a.485.485 0 0 0-.048.736.52.52 0 0 0 .668.05A11.45 11.45 0 0 1 8 4c2.507 0 4.827.802 6.716 2.164.205.148.49.13.668-.049"/> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-thermometer-sun"
<path d="M13.229 8.271a.482.482 0 0 0-.063-.745A9.46 9.46 0 0 0 8 6c-1.905 0-3.68.56-5.166 1.526a.48.48 0 0 0-.063.745.525.525 0 0 0 .652.065A8.46 8.46 0 0 1 8 7a8.46 8.46 0 0 1 4.576 1.336c.206.132.48.108.653-.065m-2.183 2.183c.226-.226.185-.605-.1-.75A6.5 6.5 0 0 0 8 9c-1.06 0-2.062.254-2.946.704-.285.145-.326.524-.1.75l.015.015c.16.16.407.19.611.09A5.5 5.5 0 0 1 8 10c.868 0 1.69.201 2.42.56.203.1.45.07.61-.091zM9.06 12.44c.196-.196.198-.52-.04-.66A2 2 0 0 0 8 11.5a2 2 0 0 0-1.02.28c-.238.14-.236.464-.04.66l.706.706a.5.5 0 0 0 .707 0l.707-.707z"/> viewBox="0 0 16 16">
</svg> <path d="M5 12.5a1.5 1.5 0 1 1-2-1.415V2.5a.5.5 0 0 1 1 0v8.585A1.5 1.5 0 0 1 5 12.5" />
<span data-i18n="sidebar.wifi">WIFI</span> <path
</a> d="M1 2.5a2.5 2.5 0 0 1 5 0v7.55a3.5 3.5 0 1 1-5 0zM3.5 1A1.5 1.5 0 0 0 2 2.5v7.987l-.167.15a2.5 2.5 0 1 0 3.333 0L5 10.486V2.5A1.5 1.5 0 0 0 3.5 1m5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1a.5.5 0 0 1 .5-.5m4.243 1.757a.5.5 0 0 1 0 .707l-.707.708a.5.5 0 1 1-.708-.708l.708-.707a.5.5 0 0 1 .707 0M8 5.5a.5.5 0 0 1 .5-.5 3 3 0 1 1 0 6 .5.5 0 0 1 0-1 2 2 0 0 0 0-4 .5.5 0 0 1-.5-.5M12.5 8a.5.5 0 0 1 .5-.5h1a.5.5 0 1 1 0 1h-1a.5.5 0 0 1-.5-.5m-1.172 2.828a.5.5 0 0 1 .708 0l.707.708a.5.5 0 0 1-.707.707l-.708-.707a.5.5 0 0 1 0-.708M8.5 12a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1a.5.5 0 0 1 .5-.5" />
<a class="nav-link text-white" href="logs.html"> </svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-journal-code" viewBox="0 0 16 16"> <span data-i18n="sidebar.sensors">Capteurs</span>
<path fill-rule="evenodd" d="M8.646 5.646a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1 0 .708l-2 2a.5.5 0 0 1-.708-.708L10.293 8 8.646 6.354a.5.5 0 0 1 0-.708m-1.292 0a.5.5 0 0 0-.708 0l-2 2a.5.5 0 0 0 0 .708l2 2a.5.5 0 0 0 .708-.708L5.707 8l1.647-1.646a.5.5 0 0 0 0-.708"/> </a>
<path d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2"/> <a class="nav-link text-white" href="database.html">
<path d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z"/> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-database"
</svg> viewBox="0 0 16 16">
<span data-i18n="sidebar.logs">Logs</span> <path
</a> d="M4.318 2.687C5.234 2.271 6.536 2 8 2s2.766.27 3.682.687C12.644 3.125 13 3.627 13 4c0 .374-.356.875-1.318 1.313C10.766 5.729 9.464 6 8 6s-2.766-.27-3.682-.687C3.356 4.875 3 4.373 3 4c0-.374.356-.875 1.318-1.313M13 5.698V7c0 .374-.356.875-1.318 1.313C10.766 8.729 9.464 9 8 9s-2.766-.27-3.682-.687C3.356 7.875 3 7.373 3 7V5.698c.271.202.58.378.904.525C4.978 6.711 6.427 7 8 7s3.022-.289 4.096-.777A5 5 0 0 0 13 5.698M14 4c0-1.007-.875-1.755-1.904-2.223C11.022 1.289 9.573 1 8 1s-3.022.289-4.096.777C2.875 2.245 2 2.993 2 4v9c0 1.007.875 1.755 1.904 2.223C4.978 15.71 6.427 16 8 16s3.022-.289 4.096-.777C13.125 14.755 14 14.007 14 13zm-1 4.698V10c0 .374-.356.875-1.318 1.313C10.766 11.729 9.464 12 8 12s-2.766-.27-3.682-.687C3.356 10.875 3 10.373 3 10V8.698c.271.202.58.378.904.525C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777A5 5 0 0 0 13 8.698m0 3V13c0 .374-.356.875-1.318 1.313C10.766 14.729 9.464 15 8 15s-2.766-.27-3.682-.687C3.356 13.875 3 13.373 3 13v-1.302c.271.202.58.378.904.525C4.978 12.71 6.427 13 8 13s3.022-.289 4.096-.777c.324-.147.633-.323.904-.525" />
<!-- Hidden: Not ready yet </svg>
<span data-i18n="sidebar.database">Base de données</span>
</a>
<a class="nav-link text-white" href="saraR4.html">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-reception-4"
viewBox="0 0 16 16">
<path
d="M0 11.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5z" />
</svg>
<span data-i18n="sidebar.modem4g">Modem 4G</span>
</a>
<a class="nav-link text-white" href="wifi.html">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-wifi"
viewBox="0 0 16 16">
<path
d="M15.384 6.115a.485.485 0 0 0-.047-.736A12.44 12.44 0 0 0 8 3C5.259 3 2.723 3.882.663 5.379a.485.485 0 0 0-.048.736.52.52 0 0 0 .668.05A11.45 11.45 0 0 1 8 4c2.507 0 4.827.802 6.716 2.164.205.148.49.13.668-.049" />
<path
d="M13.229 8.271a.482.482 0 0 0-.063-.745A9.46 9.46 0 0 0 8 6c-1.905 0-3.68.56-5.166 1.526a.48.48 0 0 0-.063.745.525.525 0 0 0 .652.065A8.46 8.46 0 0 1 8 7a8.46 8.46 0 0 1 4.576 1.336c.206.132.48.108.653-.065m-2.183 2.183c.226-.226.185-.605-.1-.75A6.5 6.5 0 0 0 8 9c-1.06 0-2.062.254-2.946.704-.285.145-.326.524-.1.75l.015.015c.16.16.407.19.611.09A5.5 5.5 0 0 1 8 10c.868 0 1.69.201 2.42.56.203.1.45.07.61-.091zM9.06 12.44c.196-.196.198-.52-.04-.66A2 2 0 0 0 8 11.5a2 2 0 0 0-1.02.28c-.238.14-.236.464-.04.66l.706.706a.5.5 0 0 0 .707 0l.707-.707z" />
</svg>
<span data-i18n="sidebar.wifi">WIFI</span>
</a>
<a class="nav-link text-white" href="logs.html">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-journal-code"
viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M8.646 5.646a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1 0 .708l-2 2a.5.5 0 0 1-.708-.708L10.293 8 8.646 6.354a.5.5 0 0 1 0-.708m-1.292 0a.5.5 0 0 0-.708 0l-2 2a.5.5 0 0 0 0 .708l2 2a.5.5 0 0 0 .708-.708L5.707 8l1.647-1.646a.5.5 0 0 0 0-.708" />
<path
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2" />
<path
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z" />
</svg>
<span data-i18n="sidebar.logs">Logs</span>
</a>
<!-- Hidden: Not ready yet
<a class="nav-link text-white" href="map.html"> <a class="nav-link text-white" href="map.html">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-geo-alt-fill" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-geo-alt-fill" viewBox="0 0 16 16">
<path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10m0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6"/> <path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10m0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6"/>
@@ -55,18 +78,32 @@
<span data-i18n="sidebar.terminal">Terminal</span> <span data-i18n="sidebar.terminal">Terminal</span>
</a> </a>
--> -->
<a class="nav-link text-white" href="admin.html"> <a class="nav-link text-white" href="admin.html">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-tools" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-tools"
<path d="M1 0 0 1l2.2 3.081a1 1 0 0 0 .815.419h.07a1 1 0 0 1 .708.293l2.675 2.675-2.617 2.654A3.003 3.003 0 0 0 0 13a3 3 0 1 0 5.878-.851l2.654-2.617.968.968-.305.914a1 1 0 0 0 .242 1.023l3.27 3.27a.997.997 0 0 0 1.414 0l1.586-1.586a.997.997 0 0 0 0-1.414l-3.27-3.27a1 1 0 0 0-1.023-.242L10.5 9.5l-.96-.96 2.68-2.643A3.005 3.005 0 0 0 16 3q0-.405-.102-.777l-2.14 2.141L12 4l-.364-1.757L13.777.102a3 3 0 0 0-3.675 3.68L7.462 6.46 4.793 3.793a1 1 0 0 1-.293-.707v-.071a1 1 0 0 0-.419-.814zm9.646 10.646a.5.5 0 0 1 .708 0l2.914 2.915a.5.5 0 0 1-.707.707l-2.915-2.914a.5.5 0 0 1 0-.708M3 11l.471.242.529.026.287.445.445.287.026.529L5 13l-.242.471-.026.529-.445.287-.287.445-.529.026L3 15l-.471-.242L2 14.732l-.287-.445L1.268 14l-.026-.529L1 13l.242-.471.026-.529.445-.287.287-.445.529-.026z"/> viewBox="0 0 16 16">
<path
d="M1 0 0 1l2.2 3.081a1 1 0 0 0 .815.419h.07a1 1 0 0 1 .708.293l2.675 2.675-2.617 2.654A3.003 3.003 0 0 0 0 13a3 3 0 1 0 5.878-.851l2.654-2.617.968.968-.305.914a1 1 0 0 0 .242 1.023l3.27 3.27a.997.997 0 0 0 1.414 0l1.586-1.586a.997.997 0 0 0 0-1.414l-3.27-3.27a1 1 0 0 0-1.023-.242L10.5 9.5l-.96-.96 2.68-2.643A3.005 3.005 0 0 0 16 3q0-.405-.102-.777l-2.14 2.141L12 4l-.364-1.757L13.777.102a3 3 0 0 0-3.675 3.68L7.462 6.46 4.793 3.793a1 1 0 0 1-.293-.707v-.071a1 1 0 0 0-.419-.814zm9.646 10.646a.5.5 0 0 1 .708 0l2.914 2.915a.5.5 0 0 1-.707.707l-2.915-2.914a.5.5 0 0 1 0-.708M3 11l.471.242.529.026.287.445.445.287.026.529L5 13l-.242.471-.026.529-.445.287-.287.445-.529.026L3 15l-.471-.242L2 14.732l-.287-.445L1.268 14l-.026-.529L1 13l.242-.471.026-.529.445-.287.287-.445.529-.026z" />
</svg>
<span data-i18n="sidebar.admin">Admin</span>
</a>
<!-- New content at the bottom -->
<div class="sidebar-footer text-center text-white">
<hr>
<span class="sideBar_sensorName">NebuleAir</span>
<div class="sidebar-hotspot-badge mt-2" style="display:none;">
<a href="wifi.html" class="text-decoration-none">
<span class="badge text-bg-warning w-100 py-2" style="font-size: 0.75rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-broadcast me-1" viewBox="0 0 16 16">
<path d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707m2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708m5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708m2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM10 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0"/>
</svg> </svg>
<span data-i18n="sidebar.admin">Admin</span> Mode Hotspot
</span>
</a> </a>
<!-- New content at the bottom -->
<div class="sidebar-footer text-center text-white">
<hr>
<span class="sideBar_sensorName"> NebuleAir</span>
</div> </div>
</div>
</nav> </nav>

View File

@@ -175,6 +175,7 @@
<script src="assets/jquery/jquery-3.7.1.min.js"></script> <script src="assets/jquery/jquery-3.7.1.min.js"></script>
<!-- Link Bootstrap JS and Popper.js locally --> <!-- Link Bootstrap JS and Popper.js locally -->
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {

View File

@@ -2,8 +2,8 @@
<nav class="navbar navbar-dark fixed-top" style="background-color: #8d8d8f;" id="topbar"> <nav class="navbar navbar-dark fixed-top" style="background-color: #8d8d8f;" id="topbar">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="#"> <a class="navbar-brand" href="#">
<img src="assets/img/LogoNebuleAir.png" alt="Logo" height="30" class="d-inline-block align-text-top"> <img src="assets/img/LogoNebuleAir.png" alt="Logo" height="30" class="d-inline-block align-text-top" id="topbar-logo">
</a> </a>
<div class="d-flex"> <div class="d-flex">
<button class="btn btn-outline-light d-md-none me-2" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebarOffcanvas" aria-controls="sidebarOffcanvas" aria-label="Toggle Sidebar"></button> <button class="btn btn-outline-light d-md-none me-2" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebarOffcanvas" aria-controls="sidebarOffcanvas" aria-label="Toggle Sidebar"></button>

View File

@@ -39,7 +39,7 @@
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div> </div>
<div class="offcanvas-body" id="sidebar_mobile"> <div class="offcanvas-body" id="sidebar_mobile">
</div> </div>
</div> </div>
@@ -51,62 +51,120 @@
<main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4"> <main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4">Connection WIFI</h1> <h1 class="mt-4">Connection WIFI</h1>
<p>La connexion WIFI n'est pas obligatoire mais elle vous permet d'effectuer des mises à jour et d'activer le contrôle à distance.</p> <p>La connexion WIFI n'est pas obligatoire mais elle vous permet d'effectuer des mises à jour et d'activer le contrôle à distance.</p>
<h3>Status <h3>Status
<span id="wifi-status" class="badge">Loading...</span> <span id="wifi-status" class="badge">Loading...</span>
<button id="btn-forget-wifi" class="btn btn-outline-danger btn-sm ms-2" style="display:none;" onclick="wifi_forget()">Oublier le réseau</button>
</h3> </h3>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-4"> <!-- Connection Info Card (shown when connected to WiFi) -->
<div class="card text-dark bg-light"> <div class="col-sm-6" id="card-connection-info" style="display:none;">
<div class="card border-success">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">Connexion WiFi</h5>
</div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title">WIFI / Ethernet</h5> <div id="connection-info-loading" class="text-center py-3">
<p class="card-text">General information.</p> <div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<button class="btn btn-primary" onclick="get_internet()">Get Data</button> <span class="ms-2">Chargement...</span>
<table class="table table-striped-columns"> </div>
<tbody id="data-table-body_internet_general"></tbody> <table class="table table-sm mb-0" id="connection-info-table" style="display:none;">
<tbody>
<tr><td class="text-muted" style="width:40%">SSID</td><td><strong id="info-ssid">-</strong></td></tr>
<tr><td class="text-muted">Signal</td><td><span id="info-signal">-</span></td></tr>
<tr><td class="text-muted">Adresse IP</td><td><code id="info-ip">-</code></td></tr>
<tr><td class="text-muted">Passerelle</td><td><code id="info-gateway">-</code></td></tr>
<tr><td class="text-muted">Hostname</td><td><code id="info-hostname">-</code></td></tr>
<tr><td class="text-muted">Frequence</td><td id="info-freq">-</td></tr>
<tr><td class="text-muted">Securite</td><td id="info-security">-</td></tr>
</tbody>
</table>
<button class="btn btn-outline-primary btn-sm mt-2" onclick="get_internet()">Rafraichir</button>
</div>
</div>
</div>
<!-- Ethernet Card -->
<div class="col-sm-6" id="card-ethernet">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Ethernet</h5>
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tbody>
<tr><td class="text-muted" style="width:40%">Etat</td><td id="info-eth-status">-</td></tr>
<tr><td class="text-muted">Adresse IP</td><td><code id="info-eth-ip">-</code></td></tr>
</tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<div class="col-sm-8"> <!-- Hotspot Info Card (shown when in hotspot mode) -->
<div class="card text-dark bg-light"> <div class="col-sm-6" id="card-hotspot-info" style="display:none;">
<div class="card border-warning">
<div class="card-header bg-warning">
<h5 class="card-title mb-0">Mode Hotspot</h5>
</div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Wifi Scan</h5> <p class="mb-1">Le capteur n'est connecte a aucun reseau WiFi.</p>
<p class="card-text">Scan des réseaux WIFI disponibles.</p> <p class="text-muted mb-0">Utilisez le scan ci-dessous pour vous connecter a un reseau.</p>
<button class="btn btn-primary" onclick="wifi_scan()">Scan</button> </div>
<table class="table"> </div>
</div>
</div>
<!-- WiFi Scan Card (hidden when connected) -->
<div class="row mb-3" id="card-wifi-scan" style="display:none;">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Reseaux WiFi disponibles</h5>
<button class="btn btn-primary btn-sm" onclick="wifi_scan()">Scan</button>
</div>
<div class="card-body">
<div id="wifi-scan-cache-notice" class="alert alert-info py-2 mb-2" style="display:none; font-size: 0.85rem;">
Scan effectue au demarrage du capteur (scan live indisponible en mode hotspot).
</div>
<div id="wifi-scan-empty" class="text-center text-muted py-3">
Cliquez sur "Scan" pour rechercher les reseaux WiFi.
</div>
<table class="table table-hover mb-0" id="wifi-scan-table" style="display:none;">
<thead>
<tr><th>SSID</th><th>Signal</th><th>Securite</th><th></th></tr>
</thead>
<tbody id="data-table-body_wifi_scan"></tbody> <tbody id="data-table-body_wifi_scan"></tbody>
</table> </table>
</table>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Modal WIFI PASSWORD --> <!-- Modal WIFI PASSWORD -->
<!-- filled with JS --> <div class="modal fade" id="myModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal fade" id="myModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true"> <div class="modal-dialog">
<div class="modal-dialog"> <div class="modal-content">
<div class="modal-content"> <div class="modal-header">
<div class="modal-header"> <h1 class="modal-title fs-5" id="myModalLabel">Modal title</h1>
<h1 class="modal-title fs-5" id="myModalLabel">Modal title</h1> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> </div>
</div> <div class="modal-body" id="myModalBody">
<div class="modal-body" id="myModalBody"> ...
... </div>
</div> <div class="modal-footer" id="myModalFooter">
<div class="modal-footer" id="myModalFooter"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-primary">Save changes</button>
<button type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div> </div>
</div> </div>
</div>
</div>
<div> <div>
</main> </main>
</div> </div>
</div> </div>
@@ -119,6 +177,7 @@
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system --> <!-- i18n translation system -->
<script src="assets/js/i18n.js"></script> <script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
@@ -141,197 +200,280 @@
}) })
.catch(error => console.error(`Error loading ${file}:`, error)); .catch(error => console.error(`Error loading ${file}:`, error));
}); });
}); });
function getSignalBadge(signal) {
const val = parseInt(signal, 10);
if (isNaN(val)) return '';
let color, label;
if (val >= 70) { color = 'success'; label = 'Excellent'; }
else if (val >= 50) { color = 'primary'; label = 'Bon'; }
else if (val >= 30) { color = 'warning'; label = 'Faible'; }
else { color = 'danger'; label = 'Tres faible'; }
return `<span class="badge text-bg-${color}">${val}% — ${label}</span>`;
}
function get_internet(){ function get_internet(){
console.log("Getting internet general infos"); console.log("Getting internet general infos");
$.ajax({ document.getElementById('connection-info-loading').style.display = '';
url: 'launcher.php?type=internet', document.getElementById('connection-info-table').style.display = 'none';
dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log(response);
let tableBody = document.getElementById('data-table-body_internet_general');
tableBody.innerHTML = ''; // Clear existing table content
// Iterate through the data and create rows $.ajax({
for (let key in response) { url: 'launcher.php?type=internet',
let row = ` dataType: 'json',
<tr> method: 'GET',
<td>${key}</td> success: function(response) {
<td>${response[key].connection}</td> console.log(response);
<td>${response[key].IP ? response[key].IP : "No IP"}</td> const wifi = response.wifi;
</tr> const eth = response.ethernet;
`;
tableBody.innerHTML += row; // Append row to table body
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
}
document.getElementById('info-ssid').textContent = wifi.ssid || '-';
document.getElementById('info-signal').innerHTML = wifi.signal ? getSignalBadge(wifi.signal) : '-';
document.getElementById('info-ip').textContent = wifi.IP || '-';
document.getElementById('info-gateway').textContent = wifi.gateway || '-';
document.getElementById('info-hostname').textContent = wifi.hostname || '-';
document.getElementById('info-freq').textContent = wifi.frequency ? wifi.frequency + ' MHz' : '-';
document.getElementById('info-security').textContent = wifi.security || '-';
function wifi_connect(SSID, PASS){ document.getElementById('info-eth-status').textContent = eth.connection || '-';
console.log("Connecting to wifi"); document.getElementById('info-eth-ip').textContent = eth.IP || '-';
console.log(SSID);
console.log(PASS); document.getElementById('connection-info-loading').style.display = 'none';
if (typeof PASS === 'undefined') { document.getElementById('connection-info-table').style.display = '';
console.log("Need to add password"); },
//open bootstrap modal to ask for password error: function(xhr, status, error) {
var myModal = new bootstrap.Modal(document.getElementById('myModal')); console.error('AJAX request failed:', status, error);
//modifiy modal title document.getElementById('connection-info-loading').innerHTML = '<span class="text-danger">Erreur de chargement</span>';
document.getElementById('myModalLabel').innerHTML = "Enter password for "+SSID; }
//add input field to modal body });
document.getElementById('myModalBody').innerHTML = "<input type='text' id='wifi_pass' class='form-control' placeholder='Password'>"; }
//add button to modal footer
document.getElementById('myModalFooter').innerHTML = "<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Close</button><button type='button' class='btn btn-primary' onclick='wifi_connect(\""+SSID+"\", document.getElementById(\"wifi_pass\").value)'>Se connecter</button>"; function wifi_connect(SSID, PASS){
myModal.show(); console.log("Connecting to wifi");
} else { if (typeof PASS === 'undefined') {
console.log("Will try to connect to "+SSID+" with password "+PASS); var myModal = new bootstrap.Modal(document.getElementById('myModal'));
console.log("Start PHP script:"); document.getElementById('myModalLabel').innerHTML = "Enter password for "+SSID;
document.getElementById('myModalBody').innerHTML = "<input type='text' id='wifi_pass' class='form-control' placeholder='Password'>";
$.ajax({ document.getElementById('myModalFooter').innerHTML = "<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Close</button><button type='button' class='btn btn-primary' onclick='wifi_connect(\""+SSID+"\", document.getElementById(\"wifi_pass\").value)'>Se connecter</button>";
url: 'launcher.php?type=wifi_connect&SSID='+SSID+'&pass='+PASS, myModal.show();
dataType: 'text', // Specify that you expect a JSON response } else {
method: 'GET', // Use GET or POST depending on your needs var myModal = bootstrap.Modal.getInstance(document.getElementById('myModal'));
success: function(response) { if (myModal) { myModal.hide(); }
console.log(response);
$.ajax({
}, url: 'launcher.php?type=wifi_connect&SSID='+SSID+'&pass='+PASS,
error: function(xhr, status, error) { dataType: 'json',
console.error('AJAX request failed:', status, error); method: 'GET',
} success: function(response) {
}); console.log(response);
showConnectionStatus(response);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
alert('Error: Could not start connection process');
} }
} });
function wifi_scan(){
console.log("Scanning Wifi");
$.ajax({
url: 'launcher.php?type=wifi_scan',
dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_wifi_scan");
// Clear the existing table body
tableBody.innerHTML = "";
// Loop through the wifiNetworks array and create rows
response.forEach(network => {
const row = document.createElement("tr");
// Create and append cells for SSID, BARS, and SIGNAL
const ssidCell = document.createElement("td");
// Truncate SSID to 25 characters
const truncatedSSID = network.SSID.length > 20 ? network.SSID.substring(0, 20) + '...' : network.SSID;
ssidCell.textContent = truncatedSSID;
row.appendChild(ssidCell);
/*
const signalCell = document.createElement("td");
signalCell.textContent = network.SIGNAL;
row.appendChild(signalCell);
*/
// Create a button
const buttonCell = document.createElement("td");
const button = document.createElement("button");
button.textContent = "Connect"; // Button text
button.classList.add("btn", "btn-primary"); // Bootstrap button classes
// Determine button color based on SIGNAL value
const signalValue = parseInt(network.SIGNAL, 10); // Assuming SIGNAL is a numeric value
// Calculate color based on the signal strength
let buttonColor;
if (signalValue >= 100) {
buttonColor = "success"; // Green for strong signal
} else if (signalValue >= 50) {
buttonColor = "warning"; // Yellow for moderate signal
} else {
buttonColor = "danger"; // Red for weak signal
}
// Add Bootstrap button classes along with color
button.classList.add("btn", `btn-${buttonColor}`);
//Trigger function as soon as the button is clicked
button.addEventListener("click", () => wifi_connect(network.SSID));
// Append the button to the button cell
buttonCell.appendChild(button);
row.appendChild(buttonCell);
// Append the row to the table body
tableBody.appendChild(row);
});
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
}
function confirmSubmit() {
// You can display a simple confirmation message or customize the behavior
return confirm("Are you sure you want to connect to this Wi-Fi network?");
} }
}
function showConnectionStatus(response) {
const lang = localStorage.getItem('language') || 'fr';
const instructions = response.instructions[lang] || response.instructions['fr'];
const overlay = document.createElement('div');
overlay.id = 'connection-status-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;display:flex;align-items:center;justify-content:center;color:white;';
overlay.innerHTML = `
<div style="max-width: 600px; padding: 40px; text-align: center;">
<div class="spinner-border text-primary mb-4" role="status" style="width: 4rem; height: 4rem;"><span class="visually-hidden">Loading...</span></div>
<h2 class="mb-4">${instructions.title}</h2>
<div class="alert alert-info text-start" role="alert">
<ol class="mb-0" style="padding-left: 20px;">
<li class="mb-2"><strong>${instructions.step1}</strong></li>
<li class="mb-2">${instructions.step2}</li>
<li class="mb-2">${instructions.step3}</li>
<li class="mb-2">${instructions.step4}</li>
</ol>
</div>
<div class="alert alert-warning text-start" role="alert"><strong>Important:</strong> ${instructions.warning}</div>
<div class="mt-4">
<p class="text-muted">${lang === 'fr' ? 'Reconnexion à' : 'Reconnecting to'}: <strong class="text-white">${response.ssid}</strong></p>
<p class="text-muted">${lang === 'fr' ? 'Nom du capteur' : 'Sensor name'}: <strong class="text-white">${response.deviceName}</strong></p>
</div>
<div class="mt-4"><small class="text-muted">${lang === 'fr' ? 'Cette fenêtre va se fermer automatiquement...' : 'This window will close automatically...'}</small></div>
</div>`;
document.body.appendChild(overlay);
setTimeout(() => { const o = document.getElementById('connection-status-overlay'); if (o) o.remove(); }, 30000);
}
function wifi_forget(){
if (!confirm('Oublier le réseau WiFi actuel et passer en mode hotspot ?')) return;
$.ajax({
url: 'launcher.php?type=wifi_forget',
dataType: 'json',
method: 'GET',
success: function(response) {
console.log(response);
showForgetStatus(response);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
alert('Error: Could not forget WiFi network');
}
});
}
function showForgetStatus(response) {
const lang = localStorage.getItem('language') || 'fr';
const instructions = response.instructions[lang] || response.instructions['fr'];
const overlay = document.createElement('div');
overlay.id = 'connection-status-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;display:flex;align-items:center;justify-content:center;color:white;';
overlay.innerHTML = `
<div style="max-width: 600px; padding: 40px; text-align: center;">
<div class="spinner-border text-warning mb-4" role="status" style="width: 4rem; height: 4rem;"><span class="visually-hidden">Loading...</span></div>
<h2 class="mb-4">${instructions.title}</h2>
<div class="alert alert-info text-start" role="alert">
<ol class="mb-0" style="padding-left: 20px;">
<li class="mb-2"><strong>${instructions.step1}</strong></li>
<li class="mb-2">${instructions.step2}</li>
<li class="mb-2">${instructions.step3}</li>
<li class="mb-2">${instructions.step4}</li>
</ol>
</div>
<div class="alert alert-warning text-start" role="alert"><strong>Important:</strong> ${instructions.warning}</div>
<div class="mt-4"><p class="text-muted">Hotspot: <strong class="text-white">${response.deviceName}</strong></p></div>
</div>`;
document.body.appendChild(overlay);
setTimeout(() => { const o = document.getElementById('connection-status-overlay'); if (o) o.remove(); }, 30000);
}
function wifi_scan(){
console.log("Scanning Wifi");
document.getElementById('wifi-scan-empty').innerHTML = '<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Scan en cours...';
$.ajax({
url: 'launcher.php?type=wifi_scan',
dataType: 'json',
method: 'GET',
success: function(response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_wifi_scan");
tableBody.innerHTML = "";
if (response.length === 0) {
document.getElementById('wifi-scan-empty').textContent = 'Aucun reseau WiFi trouve.';
document.getElementById('wifi-scan-empty').style.display = '';
document.getElementById('wifi-scan-table').style.display = 'none';
return;
}
// Show cached scan notice if in hotspot mode
const isCached = response.length > 0 && response[0].cached;
const cacheNotice = document.getElementById('wifi-scan-cache-notice');
if (cacheNotice) cacheNotice.style.display = isCached ? '' : 'none';
document.getElementById('wifi-scan-empty').style.display = 'none';
document.getElementById('wifi-scan-table').style.display = '';
response.forEach(network => {
const row = document.createElement("tr");
const ssidCell = document.createElement("td");
ssidCell.textContent = network.SSID.length > 25 ? network.SSID.substring(0, 25) + '...' : network.SSID;
row.appendChild(ssidCell);
const signalCell = document.createElement("td");
signalCell.innerHTML = getSignalBadge(network.SIGNAL);
row.appendChild(signalCell);
const securityCell = document.createElement("td");
securityCell.textContent = network.SECURITY || '--';
securityCell.classList.add('text-muted');
row.appendChild(securityCell);
const buttonCell = document.createElement("td");
buttonCell.classList.add('text-end');
const button = document.createElement("button");
button.textContent = "Connecter";
button.classList.add("btn", "btn-primary", "btn-sm");
button.addEventListener("click", () => wifi_connect(network.SSID));
buttonCell.appendChild(button);
row.appendChild(buttonCell);
tableBody.appendChild(row);
});
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
document.getElementById('wifi-scan-empty').innerHTML = '<span class="text-danger">Erreur lors du scan</span>';
}
});
}
window.onload = function() { window.onload = function() {
fetch('../config.json') // Replace 'deviceID.txt' with 'config.json' $.ajax({
.then(response => response.json()) // Parse response as JSON url: 'launcher.php?type=get_config_sqlite',
.then(data => { dataType: 'json',
console.log("Getting config file (onload)"); method: 'GET',
//get device ID success: function(data) {
const deviceID = data.deviceID.trim().toUpperCase(); console.log("Getting config (onload)");
//document.getElementById('pageTitle_plus_ID').innerText = 'token: ' + deviceID;
//get device Name
const deviceName = data.deviceName; const deviceName = data.deviceName;
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = deviceName;
});
//device name html page title function updateSidebarDeviceName(deviceName) {
if (response.deviceName) { const elements = document.querySelectorAll('.sideBar_sensorName');
document.title = response.deviceName; if (elements.length > 0) {
} elements.forEach((element) => {
element.innerText = deviceName;
});
}
}
if (deviceName) {
updateSidebarDeviceName(deviceName);
setTimeout(() => updateSidebarDeviceName(deviceName), 100);
setTimeout(() => updateSidebarDeviceName(deviceName), 500);
document.title = deviceName;
}
//get wifi connection status
const WIFI_statusElement = document.getElementById("wifi-status"); const WIFI_statusElement = document.getElementById("wifi-status");
console.log("WIFI is: " + data.WIFI_status);
if (data.WIFI_status === "connected") { if (data.WIFI_status === "connected") {
WIFI_statusElement.textContent = "Connected"; WIFI_statusElement.textContent = "Connected";
WIFI_statusElement.className = "badge text-bg-success"; WIFI_statusElement.className = "badge text-bg-success";
document.getElementById('btn-forget-wifi').style.display = 'inline-block';
document.getElementById('card-connection-info').style.display = '';
document.getElementById('card-hotspot-info').style.display = 'none';
document.getElementById('card-wifi-scan').style.display = 'none';
get_internet();
} else if (data.WIFI_status === "hotspot") { } else if (data.WIFI_status === "hotspot") {
WIFI_statusElement.textContent = "Hotspot"; WIFI_statusElement.textContent = "Hotspot";
WIFI_statusElement.className = "badge text-bg-warning"; WIFI_statusElement.className = "badge text-bg-warning";
document.getElementById('btn-forget-wifi').style.display = 'none';
document.getElementById('card-connection-info').style.display = 'none';
document.getElementById('card-hotspot-info').style.display = '';
document.getElementById('card-wifi-scan').style.display = '';
wifi_scan();
} else { } else {
WIFI_statusElement.textContent = "Unknown"; WIFI_statusElement.textContent = "Unknown";
WIFI_statusElement.className = "badge text-bg-secondary"; WIFI_statusElement.className = "badge text-bg-secondary";
document.getElementById('btn-forget-wifi').style.display = 'none';
document.getElementById('card-connection-info').style.display = 'none';
document.getElementById('card-hotspot-info').style.display = 'none';
document.getElementById('card-wifi-scan').style.display = '';
} }
//get local RTC // Update hotspot badge in sidebar
document.querySelectorAll('.sidebar-hotspot-badge').forEach(function(badge) {
badge.style.display = (data.WIFI_status === 'hotspot') ? '' : 'none';
});
$.ajax({ $.ajax({
url: 'launcher.php?type=RTC_time', url: 'launcher.php?type=RTC_time',
dataType: 'text', // Specify that you expect a JSON response dataType: 'text',
method: 'GET', // Use GET or POST depending on your needs method: 'GET',
success: function(response) { success: function(response) {
console.log("Local RTC: " + response);
const RTC_Element = document.getElementById("RTC_time"); const RTC_Element = document.getElementById("RTC_time");
RTC_Element.textContent = response; RTC_Element.textContent = response;
}, },
@@ -340,10 +482,12 @@ function get_internet(){
} }
}); });
},
}) error: function(xhr, status, error) {
.catch(error => console.error('Error loading config.json:', error)); console.error('AJAX request failed:', status, error);
} }
});
}
</script> </script>

View File

@@ -81,9 +81,9 @@ fi
info "Set config..." info "Set config..."
if [[ -f "$REPO_DIR/sqlite/set_config.py" ]]; then if [[ -f "$REPO_DIR/sqlite/set_config.py" ]]; then
sudo /usr/bin/python3 "$REPO_DIR/sqlite/set_config.py" || error "Failed to set config." sudo /usr/bin/python3 "$REPO_DIR/sqlite/set_config.py" || error "Failed to set config."
success "Databases created successfully." success "Databases set configuration successfully."
else else
warning "Database creation script not found." warning "Database set configuration script not found."
fi fi
# Configure Apache # Configure Apache
@@ -118,6 +118,7 @@ www-data ALL=(ALL) NOPASSWD: /usr/bin/git pull
www-data ALL=(ALL) NOPASSWD: /usr/bin/ssh www-data ALL=(ALL) NOPASSWD: /usr/bin/ssh
www-data ALL=(ALL) NOPASSWD: /usr/bin/python3 * www-data ALL=(ALL) NOPASSWD: /usr/bin/python3 *
www-data ALL=(ALL) NOPASSWD: /bin/systemctl * www-data ALL=(ALL) NOPASSWD: /bin/systemctl *
www-data ALL=(ALL) NOPASSWD: /usr/bin/pkill *
www-data ALL=(ALL) NOPASSWD: /var/www/nebuleair_pro_4g/* www-data ALL=(ALL) NOPASSWD: /var/www/nebuleair_pro_4g/*
EOF EOF
@@ -141,11 +142,11 @@ if ! sudo visudo -c; then
error "Sudoers file has syntax errors! Please fix manually with 'sudo visudo'" error "Sudoers file has syntax errors! Please fix manually with 'sudo visudo'"
fi fi
# Open all UART serial ports (avoid duplication) # Open all UART serial ports and disable HDMI + Bluetooth (avoid duplication)
info "Configuring UART serial ports..." info "Configuring UART serial ports and disabling Bluetooth to save power..."
if ! grep -q "enable_uart=1" /boot/firmware/config.txt; then if ! grep -q "enable_uart=1" /boot/firmware/config.txt; then
echo -e "\nenable_uart=1\ndtoverlay=uart0\ndtoverlay=uart1\ndtoverlay=uart2\ndtoverlay=uart3\ndtoverlay=uart4\ndtoverlay=uart5" | sudo tee -a /boot/firmware/config.txt > /dev/null echo -e "\nenable_uart=1\ndtoverlay=uart0\ndtoverlay=uart1\ndtoverlay=uart2\ndtoverlay=uart3\ndtoverlay=uart4\ndtoverlay=uart5\n\n# Disable Bluetooth to save power (~20-30mA)\ndtoverlay=disable-bt" | sudo tee -a /boot/firmware/config.txt > /dev/null
success "UART configuration added." success "UART configuration, HDMI and Bluetooth disable added."
else else
warning "UART configuration already set. Skipping." warning "UART configuration already set. Skipping."
fi fi
@@ -159,10 +160,6 @@ info "Enabling I2C ports..."
sudo raspi-config nonint do_i2c 0 sudo raspi-config nonint do_i2c 0
success "I2C ports enabled." success "I2C ports enabled."
#creates databases
info "Creates sqlites databases..."
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/create_db.py
# Final sudoers check # Final sudoers check
if sudo visudo -c; then if sudo visudo -c; then
success "Sudoers file is valid." success "Sudoers file is valid."

View File

@@ -31,11 +31,19 @@ info "Set up the RTC"
info "Wake Up SARA" info "Wake Up SARA"
pinctrl set 16 op pinctrl set 16 op
pinctrl set 16 dh pinctrl set 16 dh
sleep 5
info "Waiting for SARA to wake up..."
for i in {1..8}; do
echo -ne " $i/8 seconds\r"
sleep 1
done
echo -e "\n"
#Check SARA connection (ATI) #Check SARA connection (ATI)
info "Check SARA connection (ATI)" info "Check SARA connection (ATI)"
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 ATI 2 /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 ATI 2 || warning "SARA not detected (ATI). Continuing..."
sleep 1
#set up SARA R4 APN #set up SARA R4 APN
#info "Set up Monogoto APN" #info "Set up Monogoto APN"
@@ -43,7 +51,22 @@ info "Check SARA connection (ATI)"
#activate blue network led on the SARA R4 #activate blue network led on the SARA R4
info "Activate blue LED" info "Activate blue LED"
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UGPIOC=16,2 2 /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UGPIOC=16,2 2 || warning "SARA LED activation failed. Continuing..."
sleep 1
#get SIM card CCID
info "Get SIM card CCID"
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+CCID? 2 || warning "SARA CCID read failed. Continuing..."
sleep 1
#get SIM card IMSI
info "Get SIM card IMSI"
imsi_output=$(/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+CIMI 2 2>&1) || warning "SARA IMSI read failed. Continuing..."
echo "$imsi_output"
# Extract IMSI (15-digit numeric string)
imsi_number=$(echo "$imsi_output" | grep -oP '^\d{15}$' || echo "N/A")
#Connect to network #Connect to network
#info "Connect SARA R4 to network" #info "Connect SARA R4 to network"
@@ -100,3 +123,28 @@ sudo systemctl enable rtc_save_to_db.service
# Start the service immediately # Start the service immediately
info "Starting the service..." info "Starting the service..."
sudo systemctl start rtc_save_to_db.service sudo systemctl start rtc_save_to_db.service
# Display device information
echo ""
echo "=========================================="
echo -e "${GREEN} Installation Complete!${NC}"
echo "=========================================="
# Get Raspberry Pi serial number (last 8 characters)
serial_number=$(cat /proc/cpuinfo | grep Serial | awk '{print substr($3, length($3) - 7)}' | tr '[:lower:]' '[:upper:]')
echo -e "${BLUE}Device ID (Serial):${NC} $serial_number"
# Display IMSI
echo -e "${BLUE}IMSI:${NC} ${imsi_number:-N/A}"
# Get IP address and make it a clickable link
ip_wlan0=$(ip -4 addr show wlan0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' || echo "Not connected")
if [[ "$ip_wlan0" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
admin_url="http://${ip_wlan0}/html/admin.html"
echo -e "${BLUE}IP Address (wlan0):${NC} ${admin_url}"
else
echo -e "${BLUE}IP Address (wlan0):${NC} $ip_wlan0"
fi
echo "=========================================="

View File

@@ -0,0 +1,107 @@
# Audit SARA_send_data_v2.py
Date: 2026-03-14
## Correction deja appliquee
**Bug AT+USOWR leak dans payload UDP Miotiq** — Le device_id recu par Miotiq etait `41542b55534f5752` = `AT+USOWR` (la commande AT elle-meme).
Cause: desynchronisation serie entre le script et le modem. Le code envoyait les donnees binaires sans verifier que le modem avait bien envoye le prompt `@`.
Corrections appliquees dans la section UDP (send_miotiq):
- `ser_sara.reset_input_buffer()` avant chaque commande AT critique
- Verification que `"@" in response` avant d'envoyer les donnees binaires
- Abort propre a chaque etape via `socket_id = None` si creation socket, connexion, ou prompt `@` echoue
- Retry `AT+USOCR=17` apres un PDP reset reussi
---
## Bugs critiques restants
### 1. Double `\r` sur plusieurs commandes AT
Certaines commandes AT ont `\r` dans le f-string ET un `+ '\r'` lors du write, ce qui envoie `\r\r` au modem.
**Lignes concernees:**
- **Ligne 988**: `command = f'AT+CSQ\r'` puis `ser_sara.write((command + '\r')...)`
- **Lignes 588, 628, 646, 656, 666, 674, 682, 690, 698**: fonctions `reset_server_hostname` et `reset_server_hostname_https`
- **Lignes 1403, 1541, 1570, 1694, 1741**: sections AirCarto et uSpot
Le modem tolere souvent le double `\r`, mais ca peut generer des reponses parasites dans le buffer serie et contribuer a des bugs de desynchronisation.
**Correction**: retirer le `\r` du f-string OU retirer le `+ '\r'` dans le write. Choisir une convention unique.
### 2. Double guillemet dans AT+URDFILE (ligne 1402)
```python
command = f'AT+URDFILE="aircarto_server_response.txt""\r'
# ^^ double "
```
Le `"` en trop peut causer une erreur AT ou une reponse inattendue.
**Correction**: `command = f'AT+URDFILE="aircarto_server_response.txt"\r'`
### 3. Crash si table SQLite vide (lignes 781-786, 820-826, 868-880)
```python
rows = cursor.fetchall()
data_values = [row[2:] for row in rows]
averages = [round(sum(col) / len(col),1) for col in zip(*data_values)]
```
Si `data_NPM`, `data_NPM_5channels` ou `data_envea` est vide, `data_values` sera `[]` et le calcul d'average crashera (IndexError / division par zero).
**Correction**: ajouter un check `if rows:` avant le calcul, comme c'est deja fait pour BME280 (ligne 840), wind (ligne 912) et MPPT (ligne 936).
### 4. Overflow struct.pack sur valeurs negatives (class SensorPayload)
```python
def set_npm_core(self, pm1, pm25, pm10):
self.payload[10:12] = struct.pack('>H', int(pm1 * 10)) # H = unsigned 16-bit
```
Si un capteur retourne une valeur negative (erreur capteur, -1, etc.), `struct.pack('>H', -10)` leve `struct.error`. Concerne: `set_npm_core`, `set_noise`, `set_envea`, `set_npm_5channels`, `set_wind`, `set_mppt` (sauf battery_current et temperatures qui utilisent `'>h'` signe).
**Correction**: clamper les valeurs avant pack: `max(0, int(value * 10))` pour les champs unsigned, ou verifier `value >= 0` avant le pack.
---
## Problemes importants
### 5. Port serie et SQLite jamais fermes
`ser_sara` (ligne 248) et `conn` (ligne 155) sont ouverts mais jamais fermes, meme dans le bloc `except` final (ligne 1755). Si le script crash, le port serie peut rester verrouille pour le prochain cycle.
**Correction**: ajouter un bloc `finally` apres le `except` (ligne 1757):
```python
finally:
ser_sara.close()
conn.close()
```
### 6. Code mort dans reset_server_hostname_https (lignes 717-722)
```python
if profile_id == 1: # ligne 613
...
elif profile_id == 1: # ligne 718 — jamais atteint car meme condition
pass
```
Copie-colle de `reset_server_hostname`. Le elif est mort.
**Correction**: supprimer le bloc elif (lignes 717-722).
---
## Resume
| # | Type | Description | Lignes |
|----|----------|------------------------------------------|----------------|
| 1 | Bug | Double \r sur commandes AT | 988, 588+, ... |
| 2 | Bug | Double guillemet AT+URDFILE | 1402 |
| 3 | Crash | Table SQLite vide -> IndexError | 781, 820, 868 |
| 4 | Crash | struct.pack overflow valeur negative | SensorPayload |
| 5 | Cleanup | Serial/SQLite jamais fermes (finally) | 248, 155 |
| 6 | Cleanup | Code mort elif profile_id==1 | 717-722 |

View File

@@ -60,7 +60,7 @@ CSV PAYLOAD (AirCarto Servers)
28 -> envea_O3 28 -> envea_O3
CSV FOR UDP (miotiq) CSV FOR UDP (miotiq)
{device_id},{timestamp},{PM1},{PM25},{PM10},{temp},{hum},{press},{avg_noise},{max_noise},{min_noise},{envea_no2},{envea_h2s},{envea_o3},{4g_signal_quality} {device_id},{timestamp},{PM1},{PM25},{PM10},{temp},{hum},{press},{noise_cur_leq},{noise_cur_level},{max_noise},{envea_no2},{envea_h2s},{envea_o3},{4g_signal_quality}
0 -> device ID 0 -> device ID
1 -> timestamp 1 -> timestamp
2 -> PM1 2 -> PM1
@@ -298,14 +298,18 @@ class SensorPayload:
if pressure is not None: if pressure is not None:
self.payload[20:22] = struct.pack('>H', int(pressure)) self.payload[20:22] = struct.pack('>H', int(pressure))
def set_noise(self, avg_noise, max_noise=None, min_noise=None): def set_noise(self, cur_leq, cur_level, max_noise=None):
"""Set noise values (bytes 22-27)""" """Set noise values (bytes 22-27)
if avg_noise is not None: 22-23: noise_cur_leq (dBA × 10)
self.payload[22:24] = struct.pack('>H', int(avg_noise * 10)) 24-25: noise_cur_level (dBA × 10)
26-27: max_noise (dBA × 10, reserved for future use)
"""
if cur_leq is not None:
self.payload[22:24] = struct.pack('>H', int(cur_leq * 10))
if cur_level is not None:
self.payload[24:26] = struct.pack('>H', int(cur_level * 10))
if max_noise is not None: if max_noise is not None:
self.payload[24:26] = struct.pack('>H', int(max_noise * 10)) self.payload[26:28] = struct.pack('>H', int(max_noise * 10))
if min_noise is not None:
self.payload[26:28] = struct.pack('>H', int(min_noise * 10))
def set_envea(self, no2, h2s, nh3, co, o3): def set_envea(self, no2, h2s, nh3, co, o3):
"""Set ENVEA gas sensor values (bytes 28-37)""" """Set ENVEA gas sensor values (bytes 28-37)"""
@@ -515,6 +519,24 @@ def reset_PSD_CSD_connection():
""" """
print("Reseting PDP connection ") print("Reseting PDP connection ")
pdp_reset_success = True pdp_reset_success = True
#check if PDP context is already active
print('➡️ Check if PDP context is already active')
command = f'AT+CGACT?\r'
ser_sara.write(command.encode('utf-8'))
response_check = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_check, end="")
# 2. Parser la réponse
if '+CGACT: 1,1' in response_check:
print("✅ Contexte PDP déjà actif")
#return True
elif '+CGACT: 1,0' in response_check:
print("➡️ ⚠️ Contexte PDP inactif")
else:
print("⚠️ État PDP inconnu, reset nécessaire")
#return False
time.sleep(1)
# Activate PDP context 1 # Activate PDP context 1
print('➡️ Activate PDP context 1') print('➡️ Activate PDP context 1')
command = f'AT+CGACT=1,1\r' command = f'AT+CGACT=1,1\r'
@@ -952,13 +974,12 @@ try:
cur_level = last_row[2] cur_level = last_row[2]
#Add data to payload CSV #Add data to payload CSV
payload_csv[6] = DB_A_value payload_csv[6] = cur_level
#Add data to payload UDP #Add data to payload UDP
payload.set_noise( payload.set_noise(
avg_noise=last_row[2], # DB_A_value cur_leq=cur_LEQ, # current LEQ (dBA)
max_noise=None, # Add if available cur_level=cur_level # current level (dBA)
min_noise=None # Add if available
) )
#print("Verify SARA connection (AT)") #print("Verify SARA connection (AT)")
@@ -1073,10 +1094,13 @@ try:
print('<p class="fw-bold">➡SEND TO MIOTIQ</p>', end="") print('<p class="fw-bold">➡SEND TO MIOTIQ</p>', end="")
binary_data = payload.get_bytes() binary_data = payload.get_bytes()
print(f"Binary payload: {len(binary_data)} bytes") print(f"Binary payload: {len(binary_data)} bytes")
#print(f"Binary payload: {binary_data}") #print(f"Binary payload: {binary_data}")
# Flush serial buffer to avoid stale data from previous operations
ser_sara.reset_input_buffer()
#create UDP socket (will return socket number) -> 17 is UDP protocol and 6 is TCP protocol #create UDP socket (will return socket number) -> 17 is UDP protocol and 6 is TCP protocol
# IF ERROR -> need to create the PDP connection # IF ERROR -> need to create the PDP connection
print("Create Socket:", end="") print("Create Socket:", end="")
@@ -1092,69 +1116,103 @@ try:
psd_csd_resets = reset_PSD_CSD_connection() psd_csd_resets = reset_PSD_CSD_connection()
if psd_csd_resets: if psd_csd_resets:
print("✅PSD CSD connection reset successfully") print("✅PSD CSD connection reset successfully")
# Retry socket creation after PDP reset
ser_sara.reset_input_buffer()
command = f'AT+USOCR=17\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_1 = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"], debug=False)
print("Retry create socket:", end="")
print(response_SARA_1)
else: else:
print("⛔There were issues with the modem CSD PSD reinitialize process") print("⛔There were issues with the modem CSD PSD reinitialize process")
# Clignotement LED rouge en cas d'erreur # Clignotement LED rouge en cas d'erreur
led_thread = Thread(target=blink_led, args=(24, 5, 0.5)) led_thread = Thread(target=blink_led, args=(24, 5, 0.5))
led_thread.start() led_thread.start()
#Retreive Socket ID #Retreive Socket ID
socket_id = None
match = re.search(r'\+USOCR:\s*(\d+)', response_SARA_1) match = re.search(r'\+USOCR:\s*(\d+)', response_SARA_1)
if match: if match:
socket_id = match.group(1) socket_id = match.group(1)
print(f"Socket ID: {socket_id}", end="") print(f"Socket ID: {socket_id}", end="")
else: else:
print("Failed to extract socket ID") print('<span style="color: red;font-weight: bold;">⚠Failed to extract socket ID - skip UDP send⚠</span>')
#Connect to UDP server (USOCO) #Connect to UDP server (USOCO)
print("Connect to server:", end="") if socket_id is not None:
command = f'AT+USOCO={socket_id},"192.168.0.20",4242\r' print("Connect to server:", end="")
ser_sara.write(command.encode('utf-8')) ser_sara.reset_input_buffer()
response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"], debug=False) command = f'AT+USOCO={socket_id},"192.168.0.20",4242\r'
print('<p class="text-danger-emphasis">', end="") ser_sara.write(command.encode('utf-8'))
print(response_SARA_2) response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"], debug=False)
print("</p>", end="") print('<p class="text-danger-emphasis">', end="")
print(response_SARA_2)
print("</p>", end="")
# Write data and send if "+CME ERROR" in response_SARA_2 or "ERROR" in response_SARA_2:
print('<span style="color: red;font-weight: bold;">⚠ATTENTION: Error connecting socket - skip UDP send⚠</span>')
ser_sara.write(f'AT+USOCL={socket_id}\r'.encode('utf-8'))
read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"], timeout=1, end_of_response_timeout=1, debug=False)
socket_id = None
print(f"Write data: {len(binary_data)} bytes", end="") # Write data and send
command = f'AT+USOWR={socket_id},{len(binary_data)}\r' if socket_id is not None:
ser_sara.write(command.encode('utf-8')) print(f"Write data: {len(binary_data)} bytes", end="")
response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["@","OK", "+CME ERROR", "ERROR"], debug=False) ser_sara.reset_input_buffer()
print('<p class="text-danger-emphasis">', end="") command = f'AT+USOWR={socket_id},{len(binary_data)}\r'
print(response_SARA_2) ser_sara.write(command.encode('utf-8'))
print("</p>", end="") response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["@","OK", "+CME ERROR", "ERROR"], debug=False)
print('<p class="text-danger-emphasis">', end="")
print(response_SARA_2)
print("</p>", end="")
# Send the raw payload bytes (already prepared) # Verify modem sent @ prompt (ready for binary data)
ser_sara.write(binary_data) if "@" not in response_SARA_2:
response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["@","OK", "+CME ERROR", "ERROR"], debug=False) print('<span style="color: red;font-weight: bold;">⚠Modem did not send @ prompt - skip data send to avoid AT+USOWR leak⚠</span>')
print('<p class="text-danger-emphasis">', end="") ser_sara.reset_input_buffer()
print(response_SARA_2) ser_sara.write(f'AT+USOCL={socket_id}\r'.encode('utf-8'))
print("</p>", end="") read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"], timeout=1, end_of_response_timeout=1, debug=False)
socket_id = None
#Read reply from server (USORD) if socket_id is not None:
#print("Read reply:", end="") # Send the raw payload bytes (already prepared)
#command = f'AT+USORD=0,100\r' ser_sara.write(binary_data)
#ser_sara.write(command.encode('utf-8')) response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"], debug=False)
#response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"], debug=False) print('<p class="text-danger-emphasis">', end="")
#print('<p class="text-danger-emphasis">') print(response_SARA_2)
#print(response_SARA_2) print("</p>", end="")
#print("</p>", end="")
#Close socket #parfois ici on peut avoir une erreur ERROR
print("Close socket:", end="") if "+CME ERROR" in response_SARA_2 or "ERROR" in response_SARA_2:
command = f'AT+USOCL={socket_id}\r' print('<span style="color: red;font-weight: bold;">⚠ATTENTION: Error while sending data⚠</span>')
ser_sara.write(command.encode('utf-8')) print('🛑STOP LOOP🛑')
response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"], debug=False) print("<hr>")
#blink green LEDs #Send notification (WIFI)
led_thread = Thread(target=blink_led, args=(23, 5, 0.5)) send_error_notification(device_id, "UDP sending issue")
led_thread.start() #Hardware Reboot
hardware_reboot_success = modem_hardware_reboot()
if hardware_reboot_success:
print("✅Modem successfully rebooted and reinitialized")
else:
print("⛔There were issues with the modem reboot/reinitialize process")
print('<p class="text-danger-emphasis">', end="") #end loop
print(response_SARA_2) sys.exit()
print("</p>", end="")
#Close socket
print("Close socket:", end="")
command = f'AT+USOCL={socket_id}\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"], debug=False)
#blink green LEDs
led_thread = Thread(target=blink_led, args=(23, 5, 0.5))
led_thread.start()
print('<p class="text-danger-emphasis">', end="")
print(response_SARA_2)
print("</p>", end="")

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
Apply CPU Power Mode from Database at Boot
This script is called by systemd at boot to apply the CPU power mode
stored in the database (cpu_power_mode config parameter).
Usage:
/usr/bin/python3 /var/www/nebuleair_pro_4g/power/apply_cpu_mode_from_db.py
"""
import sqlite3
import sys
import subprocess
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
SET_MODE_SCRIPT = "/var/www/nebuleair_pro_4g/power/set_cpu_mode.py"
def get_cpu_mode_from_db():
"""Read cpu_power_mode from database"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT value FROM config_table WHERE key = 'cpu_power_mode'")
result = cursor.fetchone()
conn.close()
return result[0] if result else None
except sqlite3.Error as e:
print(f"Database error: {e}", file=sys.stderr)
return None
def main():
"""Main function"""
print("=== Applying CPU Power Mode from Database ===")
# Get mode from database
mode = get_cpu_mode_from_db()
if mode is None:
print("No cpu_power_mode found in database, using default: normal")
mode = "normal"
print(f"CPU power mode from database: {mode}")
# Call set_cpu_mode.py to apply the mode
try:
result = subprocess.run(
["/usr/bin/python3", SET_MODE_SCRIPT, mode],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
print(f"Successfully applied CPU power mode: {mode}")
print(result.stdout)
return 0
else:
print(f"Failed to apply CPU power mode: {mode}", file=sys.stderr)
print(result.stderr, file=sys.stderr)
return 1
except subprocess.TimeoutExpired:
print("Timeout while applying CPU power mode", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

256
power/set_cpu_mode.py Normal file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""
____ ____ _ _ ____ __ __ _
/ ___| _ \| | | | | _ \ _____ _____ _ _| \/ | ___ __| | ___
| | | |_) | | | | | |_) / _ \ \ /\ / / _ \ '__| |\/| |/ _ \ / _` |/ _ \
| |___| __/| |_| | | __/ (_) \ V V / __/ | | | | | (_) | (_| | __/
\____|_| \___/ |_| \___/ \_/\_/ \___|_| |_| |_|\___/ \__,_|\___|
CPU Power Mode Management Script
Switches between Normal and Power Saving CPU modes.
Modes:
- normal: CPU governor ondemand (600MHz-1500MHz dynamic)
- powersave: CPU governor powersave (600MHz fixed)
Usage:
/usr/bin/python3 /var/www/nebuleair_pro_4g/power/set_cpu_mode.py <mode>
/usr/bin/python3 /var/www/nebuleair_pro_4g/power/set_cpu_mode.py normal
/usr/bin/python3 /var/www/nebuleair_pro_4g/power/set_cpu_mode.py powersave
Or get current mode:
/usr/bin/python3 /var/www/nebuleair_pro_4g/power/set_cpu_mode.py get
"""
import sqlite3
import subprocess
import sys
import datetime
import json
# Paths
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
LOG_PATH = "/var/www/nebuleair_pro_4g/logs/app.log"
# Available modes
MODES = {
"normal": {
"governor": "ondemand",
"description": "Normal mode - CPU 600MHz-1500MHz dynamic",
"min_freq": 600000, # 600 MHz in kHz
"max_freq": 1500000 # 1500 MHz in kHz
},
"powersave": {
"governor": "powersave",
"description": "Power saving mode - CPU 600MHz fixed",
"min_freq": 600000,
"max_freq": 600000
}
}
def log_message(message):
"""Write message to log file with timestamp"""
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] [CPU Power Mode] {message}\n"
try:
with open(LOG_PATH, "a") as log_file:
log_file.write(log_entry)
except Exception as e:
print(f"Failed to write to log: {e}", file=sys.stderr)
print(log_entry.strip())
def get_cpu_count():
"""Get number of CPU cores"""
try:
result = subprocess.run(
["nproc"],
capture_output=True,
text=True,
timeout=5
)
return int(result.stdout.strip())
except Exception as e:
log_message(f"Failed to get CPU count: {e}")
return 4 # Default to 4 cores for CM4
def set_cpu_governor(governor, cpu_id):
"""Set CPU governor for specific CPU core"""
try:
with open(f"/sys/devices/system/cpu/cpu{cpu_id}/cpufreq/scaling_governor", "w") as f:
f.write(governor)
return True
except Exception as e:
log_message(f"Failed to set governor for CPU{cpu_id}: {e}")
return False
def set_cpu_freq(min_freq, max_freq, cpu_id):
"""Set CPU frequency limits for specific CPU core"""
try:
# Set min frequency
with open(f"/sys/devices/system/cpu/cpu{cpu_id}/cpufreq/scaling_min_freq", "w") as f:
f.write(str(min_freq))
# Set max frequency
with open(f"/sys/devices/system/cpu/cpu{cpu_id}/cpufreq/scaling_max_freq", "w") as f:
f.write(str(max_freq))
return True
except Exception as e:
log_message(f"Failed to set frequency for CPU{cpu_id}: {e}")
return False
def get_current_cpu_state():
"""Get current CPU governor and frequencies"""
try:
with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor", "r") as f:
governor = f.read().strip()
with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_min_freq", "r") as f:
min_freq = int(f.read().strip())
with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq", "r") as f:
max_freq = int(f.read().strip())
with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq", "r") as f:
cur_freq = int(f.read().strip())
return {
"governor": governor,
"min_freq_khz": min_freq,
"max_freq_khz": max_freq,
"current_freq_khz": cur_freq,
"min_freq_mhz": min_freq / 1000,
"max_freq_mhz": max_freq / 1000,
"current_freq_mhz": cur_freq / 1000
}
except Exception as e:
log_message(f"Failed to get current CPU state: {e}")
return None
def update_config_db(mode):
"""Update cpu_power_mode in database"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"UPDATE config_table SET value = ? WHERE key = 'cpu_power_mode'",
(mode,)
)
conn.commit()
conn.close()
return True
except sqlite3.Error as e:
log_message(f"Database error: {e}")
return False
def get_config_from_db():
"""Read cpu_power_mode from database"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT value FROM config_table WHERE key = 'cpu_power_mode'")
result = cursor.fetchone()
conn.close()
return result[0] if result else None
except sqlite3.Error as e:
log_message(f"Database error: {e}")
return None
def apply_mode(mode):
"""Apply CPU power mode"""
if mode not in MODES:
log_message(f"Invalid mode: {mode}. Valid modes: {', '.join(MODES.keys())}")
return False
mode_config = MODES[mode]
log_message(f"Applying mode: {mode} - {mode_config['description']}")
cpu_count = get_cpu_count()
log_message(f"Detected {cpu_count} CPU cores")
success = True
# Apply settings to all CPU cores
for cpu_id in range(cpu_count):
# Set frequency limits first
if not set_cpu_freq(mode_config['min_freq'], mode_config['max_freq'], cpu_id):
success = False
# Then set governor
if not set_cpu_governor(mode_config['governor'], cpu_id):
success = False
if success:
log_message(f"Successfully applied {mode} mode to all {cpu_count} cores")
# Update database
if update_config_db(mode):
log_message(f"Updated database: cpu_power_mode = {mode}")
else:
log_message("Warning: Failed to update database")
# Log current state
state = get_current_cpu_state()
if state:
log_message(f"Current CPU state: {state['governor']} governor, "
f"{state['min_freq_mhz']:.0f}-{state['max_freq_mhz']:.0f} MHz "
f"(current: {state['current_freq_mhz']:.0f} MHz)")
else:
log_message(f"Failed to apply {mode} mode completely")
return success
def main():
"""Main function"""
if len(sys.argv) < 2:
print("Usage: set_cpu_mode.py <mode>")
print("Modes: normal, powersave")
print("Or: set_cpu_mode.py get (to get current state)")
return 1
command = sys.argv[1].lower()
# Handle "get" command to return current state
if command == "get":
state = get_current_cpu_state()
db_mode = get_config_from_db()
if state:
output = {
"success": True,
"cpu_state": state,
"config_mode": db_mode
}
print(json.dumps(output))
return 0
else:
output = {
"success": False,
"error": "Failed to read CPU state"
}
print(json.dumps(output))
return 1
# Handle mode setting
log_message("=== CPU Power Mode Script Started ===")
if apply_mode(command):
log_message("=== CPU Power Mode Script Completed Successfully ===")
output = {
"success": True,
"mode": command,
"description": MODES[command]['description']
}
print(json.dumps(output))
return 0
else:
log_message("=== CPU Power Mode Script Failed ===")
output = {
"success": False,
"error": f"Failed to apply mode: {command}"
}
print(json.dumps(output))
return 1
if __name__ == "__main__":
sys.exit(main())

40
screen_control/screen.py Normal file
View File

@@ -0,0 +1,40 @@
# Screen Control Script (Kivy)
#
# This script displays a simple "Bonjour" message on the HDMI screen using Kivy.
# It is designed to be run on a device without a desktop environment (headless/framebuffer).
#
# HOW TO RUN (CLI):
# -----------------
# 1. Ensure Kivy is installed (python3-kivy).
# 2. You may need to set the DISPLAY environment variable if running from SSH or outside a graphical session:
# export DISPLAY=:0
# 3. Run the script:
# python3 /var/www/nebuleair_pro_4g/screen_control/screen.py
#
# HOW TO RUN (BACKGROUND):
# ------------------------
# nohup python3 /var/www/nebuleair_pro_4g/screen_control/screen.py > /dev/null 2>&1 &
#
# HOW TO STOP:
# ------------
# pkill -f "screen_control/screen.py"
#
import os
os.environ['KIVY_GL_BACKEND'] = 'gl'
from kivy.app import App
from kivy.uix.label import Label
from kivy.core.window import Window
# Set background color to black (optional, but good for screens)
Window.clearcolor = (0, 0, 0, 1)
class ScreenApp(App):
def build(self):
# Create a label with large text "Bonjour" centered on the screen
label = Label(text='Bonjour', font_size='150sp', color=(1, 1, 1, 1))
return label
if __name__ == '__main__':
ScreenApp().run()

View File

@@ -0,0 +1,15 @@
[Unit]
Description=NebuleAir CPU Power Mode Service
After=multi-user.target
Wants=multi-user.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/power/apply_cpu_mode_from_db.py
User=root
StandardOutput=journal
StandardError=journal
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=NebuleAir WiFi Power Save Service
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/wifi/power_save.py
User=root
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,12 @@
[Unit]
Description=NebuleAir WiFi Power Save Timer (10 minutes after boot)
After=network-online.target
[Timer]
# Run 10 minutes after system boot
OnBootSec=10min
# Don't persist timer across reboots
Persistent=false
[Install]
WantedBy=timers.target

View File

@@ -205,6 +205,38 @@ AccuracySec=1s
WantedBy=timers.target WantedBy=timers.target
EOL EOL
# Create service and timer files for MH-Z19 CO2 Data
cat > /etc/systemd/system/nebuleair-mhz19-data.service << 'EOL'
[Unit]
Description=NebuleAir MH-Z19 CO2 Data Collection Service
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/MH-Z19/write_data.py
User=root
WorkingDirectory=/var/www/nebuleair_pro_4g
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/mhz19_service.log
StandardError=append:/var/www/nebuleair_pro_4g/logs/mhz19_service_errors.log
[Install]
WantedBy=multi-user.target
EOL
cat > /etc/systemd/system/nebuleair-mhz19-data.timer << 'EOL'
[Unit]
Description=Run NebuleAir MH-Z19 CO2 Data Collection every 120 seconds
Requires=nebuleair-mhz19-data.service
[Timer]
OnBootSec=120s
OnUnitActiveSec=120s
AccuracySec=1s
[Install]
WantedBy=timers.target
EOL
# Create service and timer files for Database Cleanup # Create service and timer files for Database Cleanup
cat > /etc/systemd/system/nebuleair-db-cleanup-data.service << 'EOL' cat > /etc/systemd/system/nebuleair-db-cleanup-data.service << 'EOL'
[Unit] [Unit]
@@ -237,17 +269,79 @@ AccuracySec=1h
WantedBy=timers.target WantedBy=timers.target
EOL EOL
# Create service and timer files for WiFi Power Save
cat > /etc/systemd/system/nebuleair-wifi-powersave.service << 'EOL'
[Unit]
Description=NebuleAir WiFi Power Save Service
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/wifi/power_save.py
User=root
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOL
cat > /etc/systemd/system/nebuleair-wifi-powersave.timer << 'EOL'
[Unit]
Description=NebuleAir WiFi Power Save Timer (10 minutes after boot)
After=network-online.target
[Timer]
# Run 10 minutes after system boot
OnBootSec=10min
# Don't persist timer across reboots
Persistent=false
[Install]
WantedBy=timers.target
EOL
# Create service file for CPU Power Mode (runs once at boot)
cat > /etc/systemd/system/nebuleair-cpu-power.service << 'EOL'
[Unit]
Description=NebuleAir CPU Power Mode Service
After=multi-user.target
Wants=multi-user.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/power/apply_cpu_mode_from_db.py
User=root
StandardOutput=journal
StandardError=journal
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOL
# Reload systemd to recognize new services # Reload systemd to recognize new services
systemctl daemon-reload systemctl daemon-reload
# Enable and start all timers # Enable and start all timers
echo "Enabling and starting all services..." echo "Enabling and starting all services..."
for service in npm envea sara bme280 mppt db-cleanup noise; do for service in npm envea sara bme280 mppt mhz19 db-cleanup noise; do
systemctl enable nebuleair-$service-data.timer systemctl enable nebuleair-$service-data.timer
systemctl start nebuleair-$service-data.timer systemctl start nebuleair-$service-data.timer
echo "Started nebuleair-$service-data timer" echo "Started nebuleair-$service-data timer"
done done
# Enable and start WiFi power save timer (separate naming convention)
systemctl enable nebuleair-wifi-powersave.timer
systemctl start nebuleair-wifi-powersave.timer
echo "Started nebuleair-wifi-powersave timer"
# Enable and start CPU power mode service (runs once at boot)
systemctl enable nebuleair-cpu-power.service
systemctl start nebuleair-cpu-power.service
echo "Started nebuleair-cpu-power service"
echo "Checking status of all timers..." echo "Checking status of all timers..."
systemctl list-timers | grep nebuleair systemctl list-timers | grep nebuleair

34
sound_meter/read.py Normal file
View File

@@ -0,0 +1,34 @@
'''
____ ___ _ _ _ _ ____
/ ___| / _ \| | | | \ | | _ \
\___ \| | | | | | | \| | | | |
___) | |_| | |_| | |\ | |_| |
|____/ \___/ \___/|_| \_|____/
python3 /var/www/nebuleair_pro_4g/sound_meter/read.py
Read current values from NSRT MK4 Sound Level Meter (USB /dev/ttyACM0)
Used by the web interface "Get Data" button via launcher.php
'''
import json
import nsrt_mk3_dev
try:
nsrt = nsrt_mk3_dev.NsrtMk3Dev('/dev/ttyACM0')
leq_level = nsrt.read_leq()
weighted_level = nsrt.read_level()
weighting = nsrt.read_weighting()
time_constant = nsrt.read_tau()
data = {
"LEQ": round(leq_level, 2),
"dBA": round(weighted_level, 2),
"weighting": str(weighting),
"tau": time_constant
}
print(json.dumps(data))
except Exception as e:
print(json.dumps({"error": str(e)}))

View File

@@ -136,6 +136,14 @@ CREATE TABLE IF NOT EXISTS data_NOISE (
) )
""") """)
# Create a table MHZ19 (CO2 sensor)
cursor.execute("""
CREATE TABLE IF NOT EXISTS data_MHZ19 (
timestamp TEXT,
CO2 REAL
)
""")
# Commit and close the connection # Commit and close the connection
conn.commit() conn.commit()
conn.close() conn.close()

View File

@@ -0,0 +1,204 @@
'''
____ ___ _ _ _
/ ___| / _ \| | (_) |_ ___
\___ \| | | | | | | __/ _ \
___) | |_| | |___| | || __/
|____/ \__\_\_____|_|\__\___|
Script to empty (delete all data from) sensor tables in the SQLite database
This script empties sensor data tables but preserves:
- timestamp_table
- config_table
- envea_sondes_table
- config_scripts_table
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/empty_sensor_tables.py
'''
import sqlite3
import sys
import json
def table_exists(cursor, table_name):
"""Check if a table exists in the database"""
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
return cursor.fetchone() is not None
except sqlite3.Error as e:
print(f"[ERROR] Failed to check if table '{table_name}' exists: {e}")
return False
def get_table_count(cursor, table_name):
"""Get the number of records in a table"""
try:
cursor.execute(f"SELECT COUNT(*) FROM {table_name}")
return cursor.fetchone()[0]
except sqlite3.Error as e:
print(f"[WARNING] Could not get count for table '{table_name}': {e}")
return 0
def empty_table(cursor, table_name):
"""Delete all records from a specific table"""
try:
# Get record count before deletion
initial_count = get_table_count(cursor, table_name)
if initial_count == 0:
print(f"[INFO] Table '{table_name}' is already empty")
return True, 0
# Delete all records
cursor.execute(f"DELETE FROM {table_name}")
deleted_count = cursor.rowcount
print(f"[SUCCESS] Deleted {deleted_count} records from '{table_name}'")
return True, deleted_count
except sqlite3.Error as e:
print(f"[ERROR] Failed to empty table '{table_name}': {e}")
return False, 0
def main():
result = {
'success': False,
'message': '',
'tables_processed': [],
'total_deleted': 0
}
try:
# Connect to the SQLite database
print("[INFO] Connecting to database...")
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
# Check database connection
cursor.execute("SELECT sqlite_version()")
version = cursor.fetchone()[0]
print(f"[INFO] Connected to SQLite version: {version}")
# List of sensor tables to empty (EXCLUDING timestamp_table and config tables)
sensor_tables = [
"data_NPM",
"data_NPM_5channels",
"data_BME280",
"data_envea",
"data_WIND",
"data_MPPT",
"data_NOISE",
"modem_status"
]
# Tables to PRESERVE (not empty)
preserved_tables = [
"timestamp_table",
"config_table",
"envea_sondes_table",
"config_scripts_table"
]
print(f"[INFO] Will empty the following sensor tables: {', '.join(sensor_tables)}")
print(f"[INFO] Will preserve the following tables: {', '.join(preserved_tables)}")
# Check which tables actually exist
existing_tables = []
missing_tables = []
for table in sensor_tables:
if table_exists(cursor, table):
existing_tables.append(table)
record_count = get_table_count(cursor, table)
print(f"[INFO] Table '{table}' exists with {record_count} records")
else:
missing_tables.append(table)
print(f"[WARNING] Table '{table}' does not exist - skipping")
if missing_tables:
print(f"[INFO] Missing tables: {', '.join(missing_tables)}")
if not existing_tables:
result['success'] = True
result['message'] = "No sensor tables found to empty"
print("[WARNING] No sensor tables found to empty!")
print(json.dumps(result))
return True
# Loop through existing tables and empty them
successful_operations = 0
failed_operations = 0
total_deleted = 0
for table in existing_tables:
success, deleted = empty_table(cursor, table)
if success:
successful_operations += 1
total_deleted += deleted
result['tables_processed'].append({
'name': table,
'deleted': deleted
})
else:
failed_operations += 1
# Commit changes
print("[INFO] Committing changes...")
conn.commit()
print("[SUCCESS] Changes committed successfully!")
# Run VACUUM to optimize database space
if total_deleted > 0:
print("[INFO] Running VACUUM to optimize database space...")
try:
cursor.execute("VACUUM")
print("[SUCCESS] Database optimized successfully!")
except sqlite3.Error as e:
print(f"[WARNING] VACUUM failed: {e}")
# Summary
print(f"\n[SUMMARY]")
print(f"Tables emptied successfully: {successful_operations}")
print(f"Tables with errors: {failed_operations}")
print(f"Tables skipped (missing): {len(missing_tables)}")
print(f"Total records deleted: {total_deleted}")
result['success'] = True
result['message'] = f"Successfully emptied {successful_operations} sensor tables. Total records deleted: {total_deleted}"
result['total_deleted'] = total_deleted
if failed_operations == 0:
print("[SUCCESS] All sensor tables emptied successfully!")
else:
result['message'] = f"Partial success: {successful_operations} tables emptied, {failed_operations} failed"
print("[WARNING] Some operations failed - check logs above")
# Output JSON result for web interface
print("\n[JSON_RESULT]")
print(json.dumps(result))
return failed_operations == 0
except sqlite3.Error as e:
result['message'] = f"Database error: {e}"
print(f"[ERROR] Database error: {e}")
print("\n[JSON_RESULT]")
print(json.dumps(result))
return False
except Exception as e:
result['message'] = f"Unexpected error: {e}"
print(f"[ERROR] Unexpected error: {e}")
print("\n[JSON_RESULT]")
print(json.dumps(result))
return False
finally:
# Always close the database connection
if 'conn' in locals():
conn.close()
print("[INFO] Database connection closed")
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)

View File

@@ -22,6 +22,7 @@ timestamp_table
data_MPPT data_MPPT
data_NOISE data_NOISE
data_WIND data_WIND
data_MHZ19
''' '''
@@ -124,7 +125,8 @@ def main():
"data_envea", "data_envea",
"data_WIND", "data_WIND",
"data_MPPT", "data_MPPT",
"data_NOISE" "data_NOISE",
"data_MHZ19"
] ]
# Check which tables actually exist # Check which tables actually exist

View File

@@ -30,19 +30,17 @@ limit_num=parameter[1]
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db") conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor() cursor = conn.cursor()
# Retrieve the last 10 sensor readings # Retrieve the last sensor readings based on insertion order (ROWID)
#cursor.execute("SELECT * FROM data_NPM ORDER BY timestamp DESC LIMIT 10") # This ensures we get the most recently inserted data, regardless of timestamp value
#cursor.execute("SELECT * FROM data_BME280 ORDER BY timestamp DESC LIMIT 10")
#cursor.execute("SELECT * FROM timestamp_table")
if table_name == "timestamp_table": if table_name == "timestamp_table":
cursor.execute("SELECT * FROM timestamp_table") cursor.execute("SELECT * FROM timestamp_table")
else: else:
query = f"SELECT * FROM {table_name} ORDER BY timestamp DESC LIMIT ?" # Order by ROWID DESC to get most recently inserted rows first
query = f"SELECT * FROM {table_name} ORDER BY ROWID DESC LIMIT ?"
cursor.execute(query, (limit_num,)) cursor.execute(query, (limit_num,))
rows = cursor.fetchall() rows = cursor.fetchall()
rows.reverse() # Reverse the order in Python (to get ascending order) # Keep DESC order - most recently inserted data first
# Display the results # Display the results

View File

@@ -41,17 +41,21 @@ config_entries = [
("SARA_R4_network_status", "connected", "str"), ("SARA_R4_network_status", "connected", "str"),
("SARA_R4_neworkID", "20810", "int"), ("SARA_R4_neworkID", "20810", "int"),
("WIFI_status", "connected", "str"), ("WIFI_status", "connected", "str"),
("send_aircarto", "1", "bool"), ("send_aircarto", "0", "bool"),
("send_uSpot", "0", "bool"), ("send_uSpot", "0", "bool"),
("send_miotiq", "0", "bool"), ("send_miotiq", "1", "bool"),
("npm_5channel", "0", "bool"), ("npm_5channel", "0", "bool"),
("envea", "0", "bool"), ("envea", "0", "bool"),
("windMeter", "0", "bool"), ("windMeter", "0", "bool"),
("BME280", "0", "bool"), ("BME280", "1", "bool"),
("MPPT", "0", "bool"), ("MPPT", "0", "bool"),
("NOISE", "0", "bool"), ("NOISE", "0", "bool"),
("MHZ19", "0", "bool"),
("modem_version", "XXX", "str"), ("modem_version", "XXX", "str"),
("language", "fr", "str") ("device_type", "nebuleair_pro", "str"),
("language", "fr", "str"),
("wifi_power_saving", "0", "bool"),
("cpu_power_mode", "normal", "str")
] ]
for key, value, value_type in config_entries: for key, value, value_type in config_entries:

View File

@@ -57,6 +57,11 @@ fi
git pull origin $(git branch --show-current) git pull origin $(git branch --show-current)
check_status "Git pull" check_status "Git pull"
# Display firmware version
if [ -f "/var/www/nebuleair_pro_4g/VERSION" ]; then
print_status "Firmware version: $(cat /var/www/nebuleair_pro_4g/VERSION)"
fi
# Step 2: Update database configuration # Step 2: Update database configuration
print_status "" print_status ""
print_status "Step 2: Updating database configuration..." print_status "Step 2: Updating database configuration..."
@@ -73,6 +78,7 @@ sudo chmod 755 /var/www/nebuleair_pro_4g/BME280/*.py
sudo chmod 755 /var/www/nebuleair_pro_4g/SARA/*.py sudo chmod 755 /var/www/nebuleair_pro_4g/SARA/*.py
sudo chmod 755 /var/www/nebuleair_pro_4g/envea/*.py sudo chmod 755 /var/www/nebuleair_pro_4g/envea/*.py
sudo chmod 755 /var/www/nebuleair_pro_4g/MPPT/*.py 2>/dev/null sudo chmod 755 /var/www/nebuleair_pro_4g/MPPT/*.py 2>/dev/null
sudo chmod 755 /var/www/nebuleair_pro_4g/wifi/*.py 2>/dev/null
check_status "File permissions update" check_status "File permissions update"
# Step 4: Restart critical services if they exist # Step 4: Restart critical services if they exist
@@ -87,6 +93,7 @@ services=(
"nebuleair-bme280-data.timer" "nebuleair-bme280-data.timer"
"nebuleair-mppt-data.timer" "nebuleair-mppt-data.timer"
"nebuleair-noise-data.timer" "nebuleair-noise-data.timer"
"nebuleair-wifi-powersave.timer"
) )
for service in "${services[@]}"; do for service in "${services[@]}"; do

View File

@@ -0,0 +1,185 @@
#!/bin/bash
# NebuleAir Pro 4G - Update from uploaded ZIP file
# Usage: sudo ./update_firmware_from_file.sh /path/to/extracted/folder
echo "======================================"
echo "NebuleAir Pro 4G - Firmware Update (File Upload)"
echo "======================================"
echo "Started at: $(date)"
echo ""
# Set target directory
TARGET_DIR="/var/www/nebuleair_pro_4g"
EXCLUDE_FILE="$TARGET_DIR/.update-exclude"
# Function to print status messages
print_status() {
echo "[$(date '+%H:%M:%S')] $1"
}
# Function to check command success
check_status() {
if [ $? -eq 0 ]; then
print_status "$1 completed successfully"
else
print_status "$1 failed"
return 1
fi
}
# Validate arguments
if [ -z "$1" ]; then
print_status "✗ Error: No source directory provided"
print_status "Usage: $0 /path/to/extracted/folder"
exit 1
fi
SOURCE_DIR="$1"
if [ ! -d "$SOURCE_DIR" ]; then
print_status "✗ Error: Source directory does not exist: $SOURCE_DIR"
exit 1
fi
# Step 1: Validate source and compare versions
print_status "Step 1: Validating update package..."
if [ ! -f "$SOURCE_DIR/VERSION" ]; then
print_status "✗ Error: VERSION file not found in update package"
exit 1
fi
NEW_VERSION=$(cat "$SOURCE_DIR/VERSION" | tr -d '[:space:]')
OLD_VERSION="unknown"
if [ -f "$TARGET_DIR/VERSION" ]; then
OLD_VERSION=$(cat "$TARGET_DIR/VERSION" | tr -d '[:space:]')
fi
print_status "Current version: $OLD_VERSION"
print_status "New version: $NEW_VERSION"
# Step 2: Rsync with exclusions from .update-exclude
print_status ""
print_status "Step 2: Syncing files..."
# Build exclude args: use .update-exclude from the SOURCE (new version) if available,
# otherwise fall back to the one already installed
if [ -f "$SOURCE_DIR/.update-exclude" ]; then
EXCLUDE_FILE="$SOURCE_DIR/.update-exclude"
print_status "Using .update-exclude from update package"
elif [ -f "$TARGET_DIR/.update-exclude" ]; then
EXCLUDE_FILE="$TARGET_DIR/.update-exclude"
print_status "Using .update-exclude from current installation"
else
print_status "⚠ No .update-exclude file found, using built-in defaults"
# Fallback minimal exclusions
EXCLUDE_FILE=$(mktemp)
cat > "$EXCLUDE_FILE" <<'EXCL'
sqlite/sensors.db
sqlite/*.db-journal
sqlite/*.db-wal
logs/
.git/
config.json
deviceID.txt
wifi_list.csv
envea/data/
NPM/data/
*.lock
EXCL
fi
rsync -av --delete --exclude-from="$EXCLUDE_FILE" "$SOURCE_DIR/" "$TARGET_DIR/"
check_status "File sync (rsync)"
# Fix ownership and permissions
print_status "Fixing ownership..."
chown -R www-data:www-data "$TARGET_DIR/"
check_status "Ownership fix (chown)"
# Step 3: Update database configuration
print_status ""
print_status "Step 3: Updating database configuration..."
/usr/bin/python3 "$TARGET_DIR/sqlite/set_config.py"
check_status "Database configuration update"
# Step 4: Check and fix file permissions
print_status ""
print_status "Step 4: Checking file permissions..."
chmod +x "$TARGET_DIR/update_firmware.sh"
chmod +x "$TARGET_DIR/update_firmware_from_file.sh"
chmod 755 "$TARGET_DIR/sqlite/"*.py
chmod 755 "$TARGET_DIR/NPM/"*.py
chmod 755 "$TARGET_DIR/BME280/"*.py
chmod 755 "$TARGET_DIR/SARA/"*.py
chmod 755 "$TARGET_DIR/envea/"*.py
chmod 755 "$TARGET_DIR/MPPT/"*.py 2>/dev/null
chmod 755 "$TARGET_DIR/wifi/"*.py 2>/dev/null
check_status "File permissions update"
# Step 5: Restart critical services
print_status ""
print_status "Step 5: Managing system services..."
services=(
"nebuleair-npm-data.timer"
"nebuleair-envea-data.timer"
"nebuleair-sara-data.timer"
"nebuleair-bme280-data.timer"
"nebuleair-mppt-data.timer"
"nebuleair-noise-data.timer"
"nebuleair-wifi-powersave.timer"
)
for service in "${services[@]}"; do
if systemctl list-unit-files | grep -q "$service"; then
if systemctl is-enabled --quiet "$service" 2>/dev/null; then
print_status "Restarting enabled service: $service"
systemctl restart "$service"
if systemctl is-active --quiet "$service"; then
print_status "$service is running"
else
print_status "$service failed to start"
fi
else
print_status " Service $service is disabled, skipping restart"
fi
else
print_status " Service $service not found (may not be installed)"
fi
done
# Step 6: System health check
print_status ""
print_status "Step 6: System health check..."
disk_usage=$(df / | awk 'NR==2 {print $5}' | sed 's/%//')
if [ "$disk_usage" -gt 90 ]; then
print_status "⚠ Warning: Disk usage is high ($disk_usage%)"
else
print_status "✓ Disk usage is acceptable ($disk_usage%)"
fi
if [ -f "$TARGET_DIR/sqlite/sensors.db" ]; then
print_status "✓ Database file exists"
else
print_status "⚠ Warning: Database file not found"
fi
# Cleanup logs > 10MB
find "$TARGET_DIR/logs" -name "*.log" -size +10M -exec truncate -s 0 {} \;
check_status "Log cleanup"
# Step 7: Cleanup temporary files
print_status ""
print_status "Step 7: Cleaning up temporary files..."
rm -rf /tmp/nebuleair_update/
check_status "Temp cleanup"
print_status ""
print_status "======================================"
print_status "Update from $OLD_VERSION to $NEW_VERSION completed successfully!"
print_status "======================================"
exit 0

131
wifi/power_save.py Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
r'''
__ ___ ___ ___ ____
\ \ / (_) _(_) | | _ \ _____ _____ _ __
\ \ /\ / /| | | | | | | |_) / _ \ \ /\ / / _ \ '__|
\ V V / | | | | | | | __/ (_) \ V V / __/ |
\_/\_/ |_|_| |_|_| |_| \___/ \_/\_/ \___|_|
____
/ ___| __ ___ _____
\___ \ / _` \ \ / / _ \
___) | (_| |\ V / __/
|____/ \__,_| \_/ \___|
WiFi Power Saving Script
Disables WiFi completely after 10 minutes of boot if wifi_power_saving is enabled in config.
Saves ~100-200mA of power consumption.
WiFi is automatically re-enabled at next boot (see boot_hotspot.sh).
Usage:
/usr/bin/python3 /var/www/nebuleair_pro_4g/wifi/power_save.py
'''
import sqlite3
import subprocess
import sys
import datetime
# Paths
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
LOG_PATH = "/var/www/nebuleair_pro_4g/logs/app.log"
def log_message(message):
"""Write message to log file with timestamp"""
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] [WiFi Power Save] {message}\n"
try:
with open(LOG_PATH, "a") as log_file:
log_file.write(log_entry)
except Exception as e:
print(f"Failed to write to log: {e}", file=sys.stderr)
print(log_entry.strip())
def get_config_value(key):
"""Read configuration value from database"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT value FROM config_table WHERE key = ?", (key,))
result = cursor.fetchone()
conn.close()
return result[0] if result else None
except sqlite3.Error as e:
log_message(f"Database error: {e}")
return None
def is_wifi_enabled():
"""Check if WiFi radio is currently enabled"""
try:
result = subprocess.run(
["nmcli", "radio", "wifi"],
capture_output=True,
text=True,
timeout=10
)
return result.stdout.strip() == "enabled"
except Exception as e:
log_message(f"Failed to check WiFi status: {e}")
return None
def disable_wifi():
"""Disable WiFi radio completely using nmcli"""
try:
# Disable WiFi radio completely to save maximum power
# WiFi will be re-enabled automatically at next boot by boot_hotspot.sh
result = subprocess.run(
["sudo", "nmcli", "radio", "wifi", "off"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
log_message("WiFi disabled completely - saving ~100-200mA power (will re-enable at next boot)")
return True
else:
log_message(f"Failed to disable WiFi: {result.stderr}")
return False
except subprocess.TimeoutExpired:
log_message("Timeout while trying to disable WiFi")
return False
except Exception as e:
log_message(f"Error disabling WiFi: {e}")
return False
def main():
"""Main function"""
log_message("WiFi power save script started")
# Check if wifi_power_saving is enabled in config
wifi_power_saving = get_config_value("wifi_power_saving")
if wifi_power_saving is None:
log_message("wifi_power_saving config not found - skipping (run migration or set_config.py)")
return 0
if wifi_power_saving == "0":
log_message("WiFi power saving is disabled in config - WiFi will remain enabled")
return 0
log_message("WiFi power saving is enabled in config")
# Check current WiFi status
wifi_enabled = is_wifi_enabled()
if wifi_enabled is None:
log_message("Could not determine WiFi status - aborting")
return 1
if not wifi_enabled:
log_message("WiFi is already disabled - nothing to do")
return 0
# Disable WiFi completely after 10-minute configuration window
log_message("Disabling WiFi completely after 10-minute configuration window...")
if disable_wifi():
log_message("WiFi disabled successfully - will re-enable at next boot")
return 0
else:
log_message("WiFi disable failed")
return 1
if __name__ == "__main__":
sys.exit(main())