123 Commits

Author SHA1 Message Date
PaulVua
11585b4783 Error flags: NPM deconnecte (0xFF) → ERR_NPM bit 3 dans byte 66
- npm_status 0xFF = pas de reponse du capteur → flag ERR_NPM (byte 66 bit 3)
  et byte 67 reste a 0x00 (pas de status valide a transmettre)
- npm_status valide → byte 67 tel quel, pas de flag dans byte 66

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:38:56 +01:00
PaulVua
52b86dbc3d NPM 0xFF = capteur deconnecte sur page sensors et self-test
Quand npm_status = 0xFF (aucune reponse du capteur), affiche
"Capteur deconnecte" au lieu de lister tous les flags d'erreur.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:31:23 +01:00
PaulVua
361c0d1a76 Self-test NPM: decodage npm_status au lieu des anciens champs erreur
Adapte le self-test au nouveau format retourne par get_data_modbus_v3.py
(npm_status numerique decode bit par bit au lieu de notReady/fanError/etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:12:50 +01:00
PaulVua
bd2e1f1eda v1.6.0: envoi npm_status dans payload UDP (byte 67)
- Lecture npm_status depuis derniere mesure en base (rowid DESC, pas de moyenne)
- Independant du RTC (pas de dependance au timestamp)
- Byte 67 du payload UDP = registre status NextPM

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:24:26 +01:00
PaulVua
2b4e9205c1 Fix: dry-run NPM silencieux (suppression print qui cassaient le JSON)
En mode --dry-run, les print d'erreur/warning/status sont desactives
pour que seul le JSON soit envoye en sortie standard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:12:29 +01:00
PaulVua
b3c019c27b v1.5.2: page capteurs NPM via get_data_modbus_v3.py --dry-run
- NPM: mode --dry-run (print JSON sans ecriture en base)
- launcher.php: endpoint npm appelle get_data_modbus_v3.py --dry-run
- sensors.html: affichage PM + temp + humidite + status NPM decode
- Suppression unite ug/m3 sur le champ status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:10:56 +01:00
PaulVua
e733cd27e8 Doc: parser Miotiq dans README + mise a jour error_flags.md
- README: ajout section parser Miotiq avec firmware version (bytes 69-71)
- error_flags.md: parser mis a jour (version_major/minor/patch + reserved 22)
- error_flags.md: correction note init bytes 66-68 a 0x00

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:46:32 +01:00
PaulVua
a9db7750b2 v1.5.1: envoi firmware version dans payload UDP (bytes 69-71)
- Lecture fichier VERSION et pack major.minor.patch dans bytes 69-71
- README: documentation complete structure 100 bytes + conso data
- Changelog mis a jour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:44:24 +01:00
PaulVua
c42656e0ae gitignore: ajout .env pour exclure les secrets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:03:47 +01:00
PaulVua
eb93ba49bd v1.5.0: error flags payload UDP + init bytes status a 0x00
- Bytes 66-68 (error_flags, npm_status, device_status) initialises a 0x00
  au lieu de 0xFF pour eviter faux positifs cote serveur
- Implementation flag RTC (byte 66) + methodes SensorPayload
- Escalade PDP reset: si echec → notification + hardware reboot + exit
- Changelog et VERSION mis a jour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:17:37 +01:00
PaulVua
3804a52fda Error flags byte 66: implementation RTC flags + escalade PDP reset → hardware reboot
- Constantes error_flags (byte 66) + methodes SensorPayload
- Construction byte 66 avec flags RTC (disconnected/reset)
- Escalade: si PDP reset echoue apres echec UDP → notification + hardware reboot + exit
- Doc: ajout byte 68 device_status (specification)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:58:39 +01:00
PaulVua
ee0577c504 Database page: affichage npm_status dans table NPM + export CSV
Colonne Status avec badge vert 'OK' si 0, badge orange '0xXX'
si erreur. Inclus dans le download CSV.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:31:22 +01:00
PaulVua
72fbbb82a1 DB migration dans set_config.py (execute a chaque update)
Ajoute la colonne npm_status a data_NPM via ALTER TABLE.
Place dans set_config.py car c'est le seul script DB appele
par les scripts d'update (create_db.py n'est pas appele).
Liste de migrations extensible pour les futurs ajouts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:28:56 +01:00
PaulVua
5b3769769d NPM: lecture registre status Modbus (reg 19) + colonne npm_status
- get_data_modbus_v3.py: requete Modbus separee pour lire le registre
  status (0x13) du NextPM apres les donnees. Stocke dans npm_status.
- create_db.py: ajout colonne npm_status (INTEGER DEFAULT 0) dans
  data_NPM + migration ALTER TABLE pour bases existantes.
- En cas d'erreur de lecture status, garde 0xFF (toutes erreurs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:27:03 +01:00
PaulVua
6be18b5bde Doc: error_flags.md — ajout byte 67 npm_status + plan implementation
Byte 66: erreurs systeme (RTC, BME280, NPM, Envea, bruit, MPPT, vent)
Byte 67: status NextPM (sleep, degraded, not_ready, heat, trh, fan,
         memory, laser) — copie directe du registre interne capteur.
Inclut le plan d'implementation en 3 etapes et le parser Miotiq.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:23:48 +01:00
PaulVua
7619caffc4 Doc: error_flags.md — specification byte 66 payload UDP Miotiq
Definition des 8 bits d'erreur (RTC, BME280, NPM, Envea, bruit,
MPPT, vent), exemples de valeurs, implementation Python, parser
Miotiq mis a jour, et lecture cote serveur.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:19:01 +01:00
PaulVua
85596c3882 Admin Clock: alerte rouge avec icone si module RTC deconnecte
Detecte rtc_module_time='not connected', affiche un warning
avec icone attention + message 'Verifiez la pile et les cables I2C'.
Le champ RTC passe en bordure rouge. Distingue clairement
deconnexion hardware vs simple desynchronisation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:01:16 +01:00
PaulVua
6a00ab85d9 Fix: overlay connexion WiFi affiche hostname.local au lieu de deviceName.local
Le mDNS utilise le hostname systeme (aircarto), pas le deviceName
de la DB (NebuleAir-pro034). Ajout de /html/ dans l'URL aussi.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:58:49 +01:00
PaulVua
2ff47dc877 Self-test: comparer RTC vs heure navigateur au lieu de system time
Coherent avec le changement fait sur la page Admin Clock.
Le self-test affiche l'ecart en minutes/secondes si desync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:55:49 +01:00
PaulVua
d2a3eafaa1 Upload firmware: message clair si limite PHP trop basse
Indique de faire d'abord une mise a jour via WiFi pour debloquer
l'upload hors-ligne (la MAJ en ligne corrige la config PHP).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:53:16 +01:00
PaulVua
6706b22f21 Update scripts: auto-config Apache AllowOverride + PHP upload 50M
Les capteurs deja deployes auront automatiquement la bonne config
Apache/PHP lors de la prochaine mise a jour (git pull ou upload zip).
Verifie si AllowOverride All est actif et si upload_max < 50M avant
de modifier. Pas de conflit avec installation_part1.sh (idempotent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:49:43 +01:00
PaulVua
ffe13d3639 Installation: AllowOverride All + PHP upload 50M pour mise a jour hors-ligne
Configure Apache pour accepter les .htaccess (AllowOverride All)
et augmente les limites PHP (upload_max_filesize=50M, post_max_size=55M)
directement dans php.ini comme fallback. Necessaire pour l'upload
de firmware .zip via la page admin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:47:24 +01:00
PaulVua
7b324f8ab8 v1.4.6 — Admin: RTC vs navigateur, blocage update hotspot, liens Gitea
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:34:25 +01:00
PaulVua
ad0f83ce71 Admin Clock: RTC en evidence, ajout Browser time UTC, System time replie
- RTC time mis en avant (label bold, input large, bordure bleue)
- Ajout champ Browser time (UTC) avec heure de l'appareil
- System time replie dans un details/summary (non utilise)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:31:38 +01:00
PaulVua
928c1a1d4e Admin: comparer RTC vs heure navigateur au lieu de system time
L'heure du navigateur (PC/Mac/tablette) est fiable meme sans internet
grace a la pile interne. Plus pertinent que system time Linux qui
n'est pas utilise par le capteur et peut etre faux sans NTP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:29:40 +01:00
PaulVua
24cb96e9a9 Admin: preciser que System time non utilise, RTC = horloge de reference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:26:53 +01:00
PaulVua
e2f765de8a Admin: ajout descriptions System time / RTC time / Set RTC
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:22:47 +01:00
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
52 changed files with 6987 additions and 1117 deletions

3
.gitignore vendored
View File

@@ -18,5 +18,8 @@ sqlite/*.sql
tests/
# Secrets
.env
# Claude Code local settings
.claude/settings.local.json

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

@@ -40,6 +40,9 @@ import crcmod
import sqlite3
import time
# Dry-run mode: print JSON output without writing to database
dry_run = "--dry-run" in sys.argv
# Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
@@ -72,6 +75,7 @@ channel_4 = 0
channel_5 = 0
relative_humidity = 0
temperature = 0
npm_status = 0xFF # Default: all errors (will be overwritten if read succeeds)
try:
# Initialize serial port
@@ -109,7 +113,8 @@ try:
# Validate response length
if len(byte_data) < response_length:
print(f"[ERROR] Incomplete response received: {byte_data.hex()}")
if not dry_run:
print(f"[ERROR] Incomplete response received: {byte_data.hex()}")
raise Exception("Incomplete response")
# Verify CRC
@@ -117,7 +122,8 @@ try:
calculated_crc = crc16(byte_data[:-2])
if received_crc != calculated_crc:
print("[ERROR] CRC check failed! Corrupted data received.")
if not dry_run:
print("[ERROR] CRC check failed! Corrupted data received.")
raise Exception("CRC check failed")
# Convert response to hex for debugging
@@ -176,22 +182,63 @@ try:
#print(f"Internal Relative Humidity: {relative_humidity} %")
#print(f"Internal temperature: {temperature} °C")
# Read NPM status register (register 19 = 0x13, 1 register)
# Modbus request: slave=0x01, func=0x03, addr=0x0013, qty=0x0001
status_request = b'\x01\x03\x00\x13\x00\x01'
status_crc = crc16(status_request)
status_request += bytes([status_crc & 0xFF, (status_crc >> 8) & 0xFF])
ser.flushInput()
ser.write(status_request)
time.sleep(0.2)
# Response: addr(1) + func(1) + byte_count(1) + data(2) + crc(2) = 7 bytes
status_response = ser.read(7)
if len(status_response) == 7:
status_recv_crc = int.from_bytes(status_response[-2:], byteorder='little')
status_calc_crc = crc16(status_response[:-2])
if status_recv_crc == status_calc_crc:
npm_status = int.from_bytes(status_response[3:5], byteorder='big') & 0xFF
if not dry_run:
print(f"NPM status: 0x{npm_status:02X} ({npm_status})")
else:
if not dry_run:
print("[WARNING] NPM status CRC check failed, keeping default")
else:
if not dry_run:
print(f"[WARNING] NPM status incomplete response ({len(status_response)} bytes)")
ser.close()
except Exception as e:
print(f"[ERROR] Sensor communication failed: {e}")
if not dry_run:
print(f"[ERROR] Sensor communication failed: {e}")
# Variables already set to -1 at the beginning
finally:
# Always save data to database, even if all values are -1
cursor.execute('''
INSERT INTO data_NPM_5channels (timestamp,PM_ch1, PM_ch2, PM_ch3, PM_ch4, PM_ch5) VALUES (?,?,?,?,?,?)'''
, (rtc_time_str, channel_1, channel_2, channel_3, channel_4, channel_5))
if dry_run:
# Print JSON output without writing to database
result = {
"PM1": pm1_10s,
"PM25": pm25_10s,
"PM10": pm10_10s,
"temperature": temperature,
"humidity": relative_humidity,
"npm_status": npm_status,
"npm_status_hex": f"0x{npm_status:02X}"
}
print(json.dumps(result))
else:
# Always save data to database, even if all values are 0
cursor.execute('''
INSERT INTO data_NPM_5channels (timestamp,PM_ch1, PM_ch2, PM_ch3, PM_ch4, PM_ch5) VALUES (?,?,?,?,?,?)'''
, (rtc_time_str, channel_1, channel_2, channel_3, channel_4, channel_5))
cursor.execute('''
INSERT INTO data_NPM (timestamp,PM1, PM25, PM10, temp_npm, hum_npm) VALUES (?,?,?,?,?,?)'''
, (rtc_time_str, pm1_10s, pm25_10s, pm10_10s, temperature, relative_humidity))
cursor.execute('''
INSERT INTO data_NPM (timestamp,PM1, PM25, PM10, temp_npm, hum_npm, npm_status) VALUES (?,?,?,?,?,?,?)'''
, (rtc_time_str, pm1_10s, pm25_10s, pm10_10s, temperature, relative_humidity, npm_status))
# Commit and close the connection
conn.commit()
# Commit and close the connection
conn.commit()
conn.close()

141
README.md
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/python3 *
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/*
```
## Serial
@@ -180,6 +181,146 @@ And set the base URL for Sara R4 communication:
```
## UDP Payload Miotiq — Structure 100 bytes
| Bytes | Taille | Nom | Format | Description |
|-------|--------|-----|--------|-------------|
| 0-7 | 8 | device_id | ASCII | Identifiant unique du capteur |
| 8 | 1 | signal_quality | uint8 | Qualite signal modem (AT+CSQ) |
| 9 | 1 | protocol_version | uint8 | Version protocole (0x01) |
| 10-11 | 2 | pm1 | uint16 BE | PM1.0 en ug/m3 (x10) |
| 12-13 | 2 | pm25 | uint16 BE | PM2.5 en ug/m3 (x10) |
| 14-15 | 2 | pm10 | uint16 BE | PM10 en ug/m3 (x10) |
| 16-17 | 2 | temperature | int16 BE | Temperature en C (x100, signe) |
| 18-19 | 2 | humidity | uint16 BE | Humidite en % (x100) |
| 20-21 | 2 | pressure | uint16 BE | Pression en hPa |
| 22-23 | 2 | noise_cur_leq | uint16 BE | Bruit LEQ en dB(A) (x10) |
| 24-25 | 2 | noise_cur_level | uint16 BE | Bruit instantane en dB(A) (x10) |
| 26-27 | 2 | noise_max | uint16 BE | Bruit max en dB(A) (x10) |
| 28-29 | 2 | envea_no2 | uint16 BE | NO2 en ppb |
| 30-31 | 2 | envea_h2s | uint16 BE | H2S en ppb |
| 32-33 | 2 | envea_nh3 | uint16 BE | NH3 en ppb |
| 34-35 | 2 | envea_co | uint16 BE | CO en ppb |
| 36-37 | 2 | envea_o3 | uint16 BE | O3 en ppb |
| 38-39 | 2 | npm_ch1 | uint16 BE | NPM canal 1 (5-channel) |
| 40-41 | 2 | npm_ch2 | uint16 BE | NPM canal 2 (5-channel) |
| 42-43 | 2 | npm_ch3 | uint16 BE | NPM canal 3 (5-channel) |
| 44-45 | 2 | npm_ch4 | uint16 BE | NPM canal 4 (5-channel) |
| 46-47 | 2 | npm_ch5 | uint16 BE | NPM canal 5 (5-channel) |
| 48-49 | 2 | mppt_temperature | int16 BE | Temperature MPPT en C (x10, signe) |
| 50-51 | 2 | mppt_humidity | uint16 BE | Humidite MPPT en % (x10) |
| 52-53 | 2 | battery_voltage | uint16 BE | Tension batterie en V (x100) |
| 54-55 | 2 | battery_current | int16 BE | Courant batterie en A (x100, signe) |
| 56-57 | 2 | solar_voltage | uint16 BE | Tension solaire en V (x100) |
| 58-59 | 2 | solar_power | uint16 BE | Puissance solaire en W |
| 60-61 | 2 | charger_status | uint16 BE | Status chargeur MPPT |
| 62-63 | 2 | wind_speed | uint16 BE | Vitesse vent en m/s (x10) |
| 64-65 | 2 | wind_direction | uint16 BE | Direction vent en degres |
| 66 | 1 | error_flags | uint8 | Erreurs systeme (voir detail) |
| 67 | 1 | npm_status | uint8 | Registre status NextPM |
| 68 | 1 | device_status | uint8 | Etat general du boitier |
| 69 | 1 | version_major | uint8 | Version firmware major |
| 70 | 1 | version_minor | uint8 | Version firmware minor |
| 71 | 1 | version_patch | uint8 | Version firmware patch |
| 72-99 | 28 | reserved | — | Reserve (initialise a 0xFF) |
### Consommation data (UDP Miotiq uniquement)
Taille par paquet : 100 bytes payload + 8 bytes UDP header + 20 bytes IP header = **128 bytes**
| | Toutes les 60s | Toutes les 10s |
|---|---|---|
| Paquets/jour | 1 440 | 8 640 |
| Par jour | ~180 KB | ~1.08 MB |
| Par mois | ~5.3 MB | ~32.4 MB |
| Par an | ~63.6 MB | ~388.8 MB |
> Note : ces chiffres ne comptent que l'UDP vers Miotiq. Les envois HTTP (AirCarto) et HTTPS (uSpot) consomment des donnees supplementaires.
### Parser Miotiq
```
16|device_id|string|||W
2|signal_quality|hex2dec|dB||
2|version|hex2dec|||W
4|ISO_68|hex2dec|ugm3|x/10|
4|ISO_39|hex2dec|ugm3|x/10|
4|ISO_24|hex2dec|ugm3|x/10|
4|ISO_54|hex2dec|degC|x/100|
4|ISO_55|hex2dec|%|x/100|
4|ISO_53|hex2dec|hPa||
4|noise_cur_leq|hex2dec|dB|x/10|
4|noise_cur_level|hex2dec|dB|x/10|
4|max_noise|hex2dec|dB|x/10|
4|ISO_03|hex2dec|ppb||
4|ISO_05|hex2dec|ppb||
4|ISO_21|hex2dec|ppb||
4|ISO_04|hex2dec|ppb||
4|ISO_08|hex2dec|ppb||
4|npm_ch1|hex2dec|count||
4|npm_ch2|hex2dec|count||
4|npm_ch3|hex2dec|count||
4|npm_ch4|hex2dec|count||
4|npm_ch5|hex2dec|count||
4|npm_temp|hex2dec|°C|x/10|
4|npm_humidity|hex2dec|%|x/10|
4|battery_voltage|hex2dec|V|x/100|
4|battery_current|hex2dec|A|x/100|
4|solar_voltage|hex2dec|V|x/100|
4|solar_power|hex2dec|W||
4|charger_status|hex2dec|||
4|wind_speed|hex2dec|m/s|x/10|
4|wind_direction|hex2dec|degrees||
2|error_flags|hex2dec|||
2|npm_status|hex2dec|||
2|device_status|hex2dec|||
2|version_major|hex2dec|||
2|version_minor|hex2dec|||
2|version_patch|hex2dec|||
22|reserved|skip|||
```
### Byte 66 — error_flags
| Bit | Masque | Description |
|-----|--------|-------------|
| 0 | 0x01 | RTC deconnecte |
| 1 | 0x02 | RTC reset (annee 2000) |
| 2 | 0x04 | BME280 erreur |
| 3 | 0x08 | NPM erreur |
| 4 | 0x10 | Envea erreur |
| 5 | 0x20 | Bruit erreur |
| 6 | 0x40 | MPPT erreur |
| 7 | 0x80 | Vent erreur |
### Byte 67 — npm_status
| Bit | Masque | Description |
|-----|--------|-------------|
| 0 | 0x01 | Sleep mode |
| 1 | 0x02 | Degraded mode |
| 2 | 0x04 | Not ready |
| 3 | 0x08 | Heater error |
| 4 | 0x10 | THP sensor error |
| 5 | 0x20 | Fan error |
| 6 | 0x40 | Memory error |
| 7 | 0x80 | Laser error |
### Byte 68 — device_status
| Bit | Masque | Description |
|-----|--------|-------------|
| 0 | 0x01 | Modem reboot au cycle precedent |
| 1 | 0x02 | WiFi connecte |
| 2 | 0x04 | Hotspot actif |
| 3 | 0x08 | Pas de fix GPS |
| 4 | 0x10 | Batterie faible |
| 5 | 0x20 | Disque plein |
| 6 | 0x40 | Erreur base SQLite |
| 7 | 0x80 | Boot recent (uptime < 5 min) |
---
# Notes
## Wifi Hotspot (AP)

View File

@@ -46,6 +46,9 @@ try:
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(b'ATI\r') #General Information

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.6.0

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
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
STATE=$(nmcli -g GENERAL.STATE device show wlan0)

299
changelog.json Normal file
View File

@@ -0,0 +1,299 @@
{
"versions": [
{
"version": "1.6.0",
"date": "2026-03-18",
"changes": {
"features": [
"Payload UDP Miotiq: envoi npm_status (byte 67) — registre status NextPM en temps reel"
],
"improvements": [
"npm_status lu depuis la derniere mesure en base (rowid DESC, pas de moyenne ni de timestamp)"
],
"fixes": [],
"compatibility": [
"Necessite mise a jour du parser Miotiq pour decoder le byte 67 (npm_status)"
]
},
"notes": "Le capteur envoie maintenant le registre status du NextPM dans chaque trame UDP (byte 67). La valeur est prise de la derniere mesure sans moyenne (un code erreur ne se moyenne pas). Utilise rowid pour eviter toute dependance au RTC."
},
{
"version": "1.5.2",
"date": "2026-03-18",
"changes": {
"features": [
"Page capteurs: lecture NPM via get_data_modbus_v3.py --dry-run (meme script que le timer)",
"Page capteurs: affichage temperature et humidite interne du NPM",
"Page capteurs: decodage npm_status avec flags d'erreur individuels"
],
"improvements": [
"NPM get_data_modbus_v3.py: mode --dry-run (print JSON sans ecriture en base)",
"Page capteurs: status NPM affiche en vert (OK) ou orange/rouge (erreurs decodees)"
],
"fixes": [
"Page capteurs: suppression unite ug/m3 sur le champ message/status"
],
"compatibility": []
},
"notes": "La page capteurs utilise maintenant le meme script Modbus que le timer systemd, en mode dry-run pour eviter les conflits d'ecriture SQLite. Le status NPM est decode bit par bit."
},
{
"version": "1.5.1",
"date": "2026-03-18",
"changes": {
"features": [
"Payload UDP Miotiq: bytes 69-71 firmware version (major.minor.patch)",
"README: documentation complete de la structure des 100 bytes UDP"
],
"improvements": [],
"fixes": [],
"compatibility": [
"Necessite mise a jour du parser Miotiq pour decoder les bytes 69-71 (firmware version)"
]
},
"notes": "Le capteur envoie maintenant sa version firmware dans chaque trame UDP. Cote serveur, bytes 69/70/71 = major/minor/patch. Documentation payload complete ajoutee au README."
},
{
"version": "1.5.0",
"date": "2026-03-18",
"changes": {
"features": [
"Payload UDP Miotiq: byte 66 error_flags (erreurs systeme RTC/capteurs)",
"Payload UDP Miotiq: byte 67 npm_status (registre status NextPM)",
"Payload UDP Miotiq: byte 68 device_status (etat general du boitier, specification)",
"Methodes SensorPayload: set_error_flags(), set_npm_status(), set_device_status()"
],
"improvements": [
"Initialisation bytes 66-68 a 0x00 au lieu de 0xFF pour eviter faux positifs cote serveur",
"Escalade erreur UDP: si PDP reset echoue, notification WiFi + hardware reboot + exit"
],
"fixes": [],
"compatibility": [
"Necessite mise a jour du parser Miotiq pour decoder les bytes 66-68 (error_flags, npm_status, device_status)"
]
},
"notes": "Ajout de registres d'erreur et d'etat dans la payload UDP (bytes 66-68). Les bytes de status sont initialises a 0x00 (aucune erreur) au lieu de 0xFF. Le flag RTC est implemente, les autres flags seront actives progressivement."
},
{
"version": "1.4.6",
"date": "2026-03-17",
"changes": {
"features": [
"Page Admin: comparaison RTC vs heure du navigateur (au lieu de system time)",
"Page Admin: ajout champ Browser time (UTC) dans l'onglet Clock",
"Page Admin: bloquer update firmware en mode hotspot avec message explicatif",
"Page Admin: liens Gitea pour mise a jour hors-ligne (releases + main.zip)"
],
"improvements": [
"Page Admin: RTC time mis en evidence (label bold, input large, bordure bleue)",
"Page Admin: System time replie dans un details/summary (non utilise par le capteur)",
"Page Admin: descriptions ajoutees pour System time, RTC time et Synchroniser le RTC"
],
"fixes": [
"Fix forget_wifi scan: delai 5s + rescan explicite pour remplir wifi_list.csv",
"Fix blocage navigateur: revert optimisations fetch qui saturaient la limite 6 connexions/domaine"
],
"compatibility": []
},
"notes": "L'onglet Clock compare maintenant le RTC a l'heure du navigateur, plus fiable que le system time Linux (non utilise par le capteur). L'update firmware est bloque en mode hotspot avec un message explicatif. La mise a jour hors-ligne via upload .zip reste disponible."
},
{
"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

@@ -106,22 +106,54 @@ try:
try:
debug_print(f"Reading from {name}...")
# 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"
serial_connection.write(command)
debug_print(f" → Sent command: {command.hex()}")
calculated_value = None
max_retries = 3
# Read response
data_envea = serial_connection.readline()
debug_print(f" ← Received {len(data_envea)} bytes: {data_envea.hex()}")
for attempt in range(max_retries):
# Flush input buffer to clear any stale data
serial_connection.reset_input_buffer()
if len(data_envea) >= 20:
byte_20 = data_envea[19]
raw_value = byte_20
calculated_value = byte_20 * coefficient
debug_print(f"Byte 20 value: {raw_value} (0x{raw_value:02X})")
debug_print(f" → Calculated value: {raw_value} × {coefficient} = {calculated_value}")
# 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"
serial_connection.write(command)
if attempt == 0:
debug_print(f"Sent command: {command.hex()}")
# Wait for sensor response
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":
data_h2s = calculated_value
elif name == "no2":
@@ -134,10 +166,9 @@ try:
data_nh3 = calculated_value
elif name == "so2":
data_so2 = calculated_value
debug_print(f"{name.upper()} = {calculated_value}")
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:
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,6 +51,13 @@
<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>
<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">
@@ -118,6 +125,37 @@
</label>
</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">
<span class="fw-bold">Protected Settings</span>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="toggleProtectedSettings()" id="unlockBtn">
@@ -150,6 +188,13 @@
</label>
</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>
@@ -166,24 +211,38 @@
<h3 class="mt-4">Clock</h3>
<div class="mb-3">
<label for="sys_local_time" class="form-label">System time (local)</label>
<input type="text" class="form-control" id="sys_local_time" disabled>
<label for="RTC_utc_time" class="form-label fw-bold fs-5">RTC time (UTC)</label>
<input type="text" class="form-control form-control-lg border-primary" id="RTC_utc_time" disabled>
<small class="text-muted">Module DS3231 avec pile de sauvegarde. Garde l'heure meme hors tension. Horloge de reference du capteur.</small>
</div>
<div class="mb-3">
<label for="sys_UTC_time" class="form-label">System time (UTC)</label>
<input type="text" class="form-control" id="sys_UTC_time" disabled>
<label for="browser_utc_time" class="form-label">Browser time (UTC)</label>
<input type="text" class="form-control" id="browser_utc_time" disabled>
<small class="text-muted">Heure de votre appareil (PC/Mac/tablette). Reference pour verifier le RTC.</small>
</div>
<div class="mb-3">
<label for="RTC_utc_time" class="form-label">RTC time (UTC)</label>
<input type="text" class="form-control" id="RTC_utc_time" disabled>
</div>
<hr>
<details class="mb-3">
<summary class="text-muted" style="cursor:pointer;">System time (non utilise par le capteur)</summary>
<div class="mt-2">
<div class="mb-3">
<label for="sys_local_time" class="form-label">System time (local)</label>
<input type="text" class="form-control form-control-sm" id="sys_local_time" disabled>
</div>
<div class="mb-3">
<label for="sys_UTC_time" class="form-label">System time (UTC)</label>
<input type="text" class="form-control form-control-sm" id="sys_UTC_time" disabled>
</div>
<small class="text-muted">Horloge Linux du Raspberry Pi. Se synchronise via internet (NTP). Non utilisee par le capteur.</small>
</div>
</details>
<div id="alert_container"></div>
<h5 class="mt-4">Set RTC</h5>
<h5 class="mt-4">Synchroniser le RTC</h5>
<small class="text-muted d-block mb-2">Met a jour l'horloge RTC pour qu'elle reste precise sans internet.</small>
<button type="submit" class="btn btn-primary mb-1" onclick="set_RTC_withNTP()">WiFi (NTP) </button>
<button type="submit" class="btn btn-primary mb-1" onclick="set_RTC_withBrowser()">Browser time </button>
@@ -195,13 +254,35 @@
<!-- UPDATE-->
<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">
<span id="updateBtnText">Update firmware</span>
<span id="updateSpinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</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 -->
<div id="updateOutput" class="mt-3" style="display: none;">
<div class="card">
@@ -310,6 +391,28 @@
</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>
</div>
</div>
@@ -322,6 +425,8 @@
<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 src="assets/js/selftest.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
@@ -367,6 +472,7 @@ window.onload = function() {
success: function(response) {
console.log("Getting SQLite config table:");
console.log(response);
window._adminConfig = response;
//device name
const deviceName = document.getElementById("device_name");
deviceName.value = response.deviceName;
@@ -399,6 +505,8 @@ window.onload = function() {
const checkbox_envea = document.getElementById("check_envea");
const checkbox_solar = document.getElementById("check_solarBattery");
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_envea.checked = response["envea"];
@@ -406,11 +514,29 @@ window.onload = function() {
checkbox_nmp5channels.checked = response.npm_5channel;
checkbox_wind.checked = response["windMeter"];
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_aircarto.checked = response["send_aircarto"];
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 (response["envea"]) {
add_sondeEnveaContainer();
@@ -473,29 +599,54 @@ window.onload = function() {
document.getElementById("sys_UTC_time").value = response.system_utc_time;
document.getElementById("RTC_utc_time").value = response.rtc_module_time;
// Get the time difference
const timeDiff = response.time_difference_seconds;
// Display browser time in UTC
const browserDate = new Date();
const browserUTC = browserDate.getUTCFullYear() + '-' +
String(browserDate.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(browserDate.getUTCDate()).padStart(2, '0') + ' ' +
String(browserDate.getUTCHours()).padStart(2, '0') + ':' +
String(browserDate.getUTCMinutes()).padStart(2, '0') + ':' +
String(browserDate.getUTCSeconds()).padStart(2, '0');
document.getElementById("browser_utc_time").value = browserUTC;
// Reference to the alert container
// Compare RTC time with browser time
const alertContainer = document.getElementById("alert_container");
// Remove any previous alert
alertContainer.innerHTML = "";
const rtcInput = document.getElementById("RTC_utc_time");
// Add an alert based on time difference
if (typeof timeDiff === "number") {
if (timeDiff >= 0 && timeDiff <= 10) {
if (response.rtc_module_time === 'not connected' || !response.rtc_module_time) {
// RTC module disconnected
rtcInput.classList.add('border-danger', 'text-danger');
rtcInput.classList.remove('border-primary');
alertContainer.innerHTML = `
<div class="alert alert-danger d-flex align-items-center" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-exclamation-triangle-fill me-2 flex-shrink-0" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.436-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div>
<strong>Module RTC deconnecte !</strong><br>
Verifiez la pile du module DS3231 et les cables I2C.
</div>
</div>`;
} else {
const rtcDate = new Date(response.rtc_module_time + ' UTC');
const timeDiff = Math.abs(Math.round((browserDate - rtcDate) / 1000));
if (timeDiff <= 30) {
alertContainer.innerHTML = `
<div class="alert alert-success" role="alert">
RTC and system time are in sync (Difference: ${timeDiff} sec).
RTC synchronise avec l'heure du navigateur (ecart: ${timeDiff} sec).
</div>`;
} else if (timeDiff > 10) {
} else {
const minutes = Math.floor(timeDiff / 60);
const label = minutes > 0 ? `${minutes} min ${timeDiff % 60} sec` : `${timeDiff} sec`;
alertContainer.innerHTML = `
<div class="alert alert-danger" role="alert">
RTC time is out of sync! (Difference: ${timeDiff} sec).
RTC desynchronise ! Ecart avec le navigateur: ${label}.
Utilisez "Synchroniser le RTC" ci-dessous.
</div>`;
}
}
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
@@ -520,6 +671,9 @@ window.onload = function() {
// Load services on page load
refreshServices();
// Load firmware version
loadFirmwareVersion();
} //end window.onload
@@ -577,6 +731,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){
console.log("Updating ",param," : ", value);
$.ajax({
@@ -594,6 +825,12 @@ function update_config(param, value){
}
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...");
// Show loading state
@@ -659,6 +896,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() {
const updateOutput = document.getElementById('updateOutput');
const updateOutputContent = document.getElementById('updateOutputContent');
@@ -1531,6 +1878,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>

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

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

@@ -0,0 +1,966 @@
// ============================================
// 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(`RTC Time: ${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 (uses get_data_modbus_v3.py --dry-run)
const npmResult = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=npm',
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}, status=${npmResult.npm_status_hex}`);
// Decode npm_status flags
const status = npmResult.npm_status !== undefined ? npmResult.npm_status : 0;
if (status === 0xFF) {
// 0xFF = no response = disconnected
updateTestStatus(sensor.id, 'Failed', 'Capteur déconnecté', 'bg-danger');
testsFailed++;
} else {
const statusFlags = {
0x01: "Sleep mode",
0x02: "Degraded mode",
0x04: "Not ready",
0x08: "Heater error",
0x10: "THP sensor error",
0x20: "Fan error",
0x40: "Memory error",
0x80: "Laser error"
};
const activeErrors = [];
Object.entries(statusFlags).forEach(([mask, label]) => {
if (status & mask) activeErrors.push(label);
});
if (activeErrors.length > 0) {
updateTestStatus(sensor.id, 'Warning', `Status ${npmResult.npm_status_hex}: ${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} µg/m³`, 'bg-success');
testsPassed++;
} else {
updateTestStatus(sensor.id, 'Warning', 'Incomplete data received', 'bg-warning');
testsFailed++;
}
} // end else (not 0xFF)
} 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) {
// Compare RTC with browser time (more reliable than system time)
const rtcDate = new Date(rtcResult.rtc_module_time + ' UTC');
const browserDate = new Date();
const timeDiff = Math.abs(Math.round((browserDate - rtcDate) / 1000));
if (timeDiff <= 60) {
updateTestStatus(sensor.id, 'Passed', `${rtcResult.rtc_module_time} (sync OK vs navigateur, ecart: ${timeDiff}s)`, 'bg-success');
testsPassed++;
} else {
const minutes = Math.floor(timeDiff / 60);
const label = minutes > 0 ? `${minutes}min ${timeDiff % 60}s` : `${timeDiff}s`;
updateTestStatus(sensor.id, 'Warning', `${rtcResult.rtc_module_time} (desync vs navigateur: ${label})`, 'bg-warning');
testsFailed++;
}
} 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

@@ -27,7 +27,10 @@
z-index: 1040;
}
/* Highlight most recent data row with light green background */
.most-recent-row {
.table .most-recent-row td {
background-color: #d4edda !important;
}
.table-striped .most-recent-row td {
background-color: #d4edda !important;
}
</style>
@@ -78,6 +81,7 @@
<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 mb-2" onclick="get_data_sqlite('data_MPPT',getSelectedLimit(),false)" data-i18n="database.battery">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>
</div>
</div>
@@ -95,17 +99,55 @@
<input type="date" id="end_date" class="form-control w-auto">
</div>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_NPM',10,true, getStartDate(), getEndDate())" data-i18n="database.pmMeasures">Mesures PM</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_BME280',10,true, getStartDate(), getEndDate())" data-i18n="database.tempHumMeasures">Mesures Temp/Hum</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_NPM_5channels',10,true, getStartDate(), getEndDate())" data-i18n="database.pm5Channels">Mesures PM (5 canaux)</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_envea',10,true, getStartDate(), getEndDate())" data-i18n="database.cairsensProbe">Sonde Cairsens</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_NOISE',10,true, getStartDate(), getEndDate())" data-i18n="database.noiseProbe">Sonde Bruit</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_mppt',10,true, getStartDate(), getEndDate())" data-i18n="database.battery">Batterie</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_NPM')" data-i18n="database.pmMeasures">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 mb-2" onclick="downloadByDate('data_NPM_5channels')" data-i18n="database.pm5Channels">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 mb-2" onclick="downloadByDate('data_NOISE')" data-i18n="database.noiseProbe">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>
</div>
</div>
</div>
<div class="col-lg-4 col-md-12 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.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>
@@ -115,7 +157,6 @@
</div>
</div>
</div>
</div>
<div class="row mt-2">
@@ -134,6 +175,7 @@
<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 () {
@@ -221,6 +263,9 @@ window.onload = function() {
}); //end ajax
// Get database table stats
loadDbStats();
//get local RTC
$.ajax({
url: 'launcher.php?type=RTC_time',
@@ -282,6 +327,7 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<th>PM10</th>
<th>Temperature (°C)</th>
<th>Humidity (%)</th>
<th>Status</th>
`;
} else if (table === "data_BME280") {
tableHTML += `
@@ -337,6 +383,11 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<th>DB_A_value</th>
`;
}else if (table === "data_MHZ19") {
tableHTML += `
<th>Timestamp</th>
<th>CO2 (ppm)</th>
`;
}
@@ -350,6 +401,10 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
tableHTML += `<tr${rowClass}>`;
if (table === "data_NPM") {
const statusVal = parseInt(columns[6]) || 0;
const statusBadge = statusVal === 0
? '<span class="badge text-bg-success">OK</span>'
: `<span class="badge text-bg-warning">0x${statusVal.toString(16).toUpperCase().padStart(2,'0')}</span>`;
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
@@ -357,6 +412,7 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<td>${columns[3]}</td>
<td>${columns[4]}</td>
<td>${columns[5]}</td>
<td>${statusBadge}</td>
`;
} else if (table === "data_BME280") {
tableHTML += `
@@ -412,6 +468,11 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<td>${columns[2]}</td>
`;
}else if (table === "data_MHZ19") {
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
`;
}
tableHTML += "</tr>";
@@ -436,11 +497,25 @@ function getSelectedLimit() {
}
function getStartDate() {
return document.getElementById("start_date").value || "2025-01-01"; // Default to a safe date
return document.getElementById("start_date").value;
}
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) {
@@ -450,13 +525,16 @@ function downloadCSV(response, table) {
// Add headers based on table type
if (table === "data_NPM") {
csvContent += "TimestampUTC,PM1,PM2.5,PM10,Temperature_sensor,Humidity_sensor\n";
csvContent += "TimestampUTC,PM1,PM2.5,PM10,Temperature_sensor,Humidity_sensor,npm_status\n";
} else if (table === "data_BME280") {
csvContent += "TimestampUTC,Temperature (°C),Humidity (%),Pressure (hPa)\n";
}
else if (table === "data_NPM_5channels") {
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
rows.forEach(row => {
@@ -475,6 +553,72 @@ function downloadCSV(response, table) {
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

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -11,31 +12,39 @@
body {
overflow-x: hidden;
}
#sidebar a.nav-link {
position: relative;
display: flex;
align-items: center;
position: relative;
display: flex;
align-items: center;
}
#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 {
margin-right: 8px; /* Add spacing between icons and text */
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>
<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 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>
@@ -47,44 +56,56 @@
<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>
<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="home.title">Votre capteur</h1>
<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">
<!-- Card NPM values -->
<div class="col-sm-4 mt-2">
<div class="card">
<div class="card-body">
<h5 class="card-title" data-i18n="home.pmMeasures">Mesures PM</h5>
<canvas id="sensorPMChart" style="width: 100%; max-width: 600px; height: 200px;"></canvas>
<div class="card">
<div class="card-body">
<h5 class="card-title" data-i18n="home.pmMeasures">Mesures PM</h5>
<canvas id="sensorPMChart" style="width: 100%; max-width: 600px; height: 200px;"></canvas>
</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>
<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>
<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>
<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="col-sm-4 mt-2">
@@ -102,364 +123,378 @@
</div>
</div>
<!-- JAVASCRIPT -->
<!-- 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>
<!-- 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 src="assets/js/selftest.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const elementsToLoad = [
<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 }) => {
elementsToLoad.forEach(({ id, file }) => {
fetch(file)
.then(response => response.text())
.then(data => {
const element = document.getElementById(id);
if (element) {
element.innerHTML = data;
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
.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();
}
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
});
});
});
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);
window.onload = function() {
//get device Name (for the side bar)
const deviceName = response.deviceName;
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = deviceName;
});
//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);
//device name html page title
if (response.deviceName) {
document.title = response.deviceName;
}
//get device Name (for the side bar)
const deviceName = response.deviceName;
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = deviceName;
});
// Check for device type to show Screen tab
// Assuming the key in config is 'device_type' or 'type'
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
}
//device name html page title
if (response.deviceName) {
document.title = response.deviceName;
}
},
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'
.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 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);
}
});
//get disk free space
$.ajax({
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];
disk_size.innerHTML = firstNumber;
//2. Free space
const match = response.match(/(\d+)%/);
const diskSpace = document.getElementById("disk_space");
const percentage = match[1];
// 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', 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");
const memLine = response.match(/Mem:\s+(\d+\.?\d*)Mi\s+(\d+\.?\d*)Mi/);
const totalMemory = parseFloat(memLine[1]); // Total memory in MiB
const usedMemory = parseFloat(memLine[2]); // Used memory in MiB
// Calculate the percentage
const percentageUsed = ((usedMemory / totalMemory) * 100).toFixed(2);
console.log(totalMemory);
memory_size.innerHTML = totalMemory;
console.log(usedMemory);
console.log(percentageUsed);
// 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);
// Create the inner progress bar div
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
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
$.ajax({
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
function updatePMChart(data) {
const labels = data.map(d => d.timestamp);
const PM1 = data.map(d => d.PM1);
const PM25 = data.map(d => d.PM25);
const PM10 = data.map(d => d.PM10);
const ctx = document.getElementById('sensorPMChart').getContext('2d');
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'
},
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'
}
}
}
}
});
} 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();
}
},
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'
.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 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);
}
});
//get disk free space
$.ajax({
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];
disk_size.innerHTML = firstNumber;
//2. Free space
const match = response.match(/(\d+)%/);
const diskSpace = document.getElementById("disk_space");
const percentage = match[1];
// 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', 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");
const memLine = response.match(/Mem:\s+(\d+\.?\d*)Mi\s+(\d+\.?\d*)Mi/);
const totalMemory = parseFloat(memLine[1]); // Total memory in MiB
const usedMemory = parseFloat(memLine[2]); // Used memory in MiB
// Calculate the percentage
const percentageUsed = ((usedMemory / totalMemory) * 100).toFixed(2);
console.log(totalMemory);
memory_size.innerHTML = totalMemory;
console.log(usedMemory);
console.log(percentageUsed);
// 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);
// Create the inner progress bar div
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
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
$.ajax({
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
function updatePMChart(data) {
const labels = data.map(d => d.timestamp);
const PM1 = data.map(d => d.PM1);
const PM25 = data.map(d => d.PM25);
const PM10 = data.map(d => d.PM10);
const ctx = document.getElementById('sensorPMChart').getContext('2d');
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'
},
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'
}
}
}
}
});
} 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>
</html>

View File

@@ -23,9 +23,9 @@
"press": "Pressure"
},
"noise": {
"title": "Decibel Meter",
"description": "Noise sensor on I2C port.",
"headerI2c": "I2C Port"
"title": "NSRT MK4",
"description": "NSRT MK4 sound level meter on USB port.",
"headerUsb": "USB Port"
},
"envea": {
"title": "Envea Probe",
@@ -52,6 +52,7 @@
},
"sidebar": {
"home": "Home",
"screen": "Screen",
"sensors": "Sensors",
"database": "Database",
"modem4g": "4G Modem",
@@ -92,7 +93,14 @@
"dangerZone": "Danger Zone",
"dangerWarning": "Warning: This action is irreversible!",
"emptyAllTables": "Empty all sensor tables",
"emptyTablesNote": "Note: Configuration and timestamp tables will be preserved."
"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",

View File

@@ -23,9 +23,9 @@
"press": "Pression"
},
"noise": {
"title": "Sonomètre",
"description": "Capteur bruit sur le port I2C.",
"headerI2c": "Port I2C"
"title": "NSRT MK4",
"description": "Sonomètre NSRT MK4 sur port USB.",
"headerUsb": "Port USB"
},
"envea": {
"title": "Sonde Envea",
@@ -52,6 +52,7 @@
},
"sidebar": {
"home": "Accueil",
"screen": "Écran",
"sensors": "Capteurs",
"database": "Base de données",
"modem4g": "Modem 4G",
@@ -92,7 +93,14 @@
"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."
"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",

View File

@@ -410,6 +410,108 @@ 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) {
$max_upload = ini_get('upload_max_filesize');
$upload_errors = [
UPLOAD_ERR_INI_SIZE => "Le fichier depasse la limite serveur (actuellement $max_upload). Effectuez d'abord une mise a jour via WiFi (bouton Update firmware) pour debloquer l'upload hors-ligne.",
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") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_NTP.py';
$output = shell_exec($command);
@@ -530,6 +632,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") {
$command = 'df -h /';
$output = shell_exec($command);
@@ -542,6 +745,53 @@ if ($type == "linux_memory") {
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") {
$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"';
@@ -555,8 +805,14 @@ if ($type == "reboot") {
}
if ($type == "npm") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_modbus_v3.py --dry-run';
$output = shell_exec($command);
echo $output;
}
if ($type == "npm_firmware") {
$port=$_GET['port'];
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data.py ' . $port;
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/firmware_version.py ' . $port;
$output = shell_exec($command);
echo $output;
}
@@ -569,8 +825,14 @@ if ($type == "envea") {
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") {
$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);
echo $output;
}
@@ -581,6 +843,12 @@ if ($type == "BME280") {
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") {
$table=$_GET['table'];
@@ -774,35 +1042,64 @@ if ($type == "sara_sendMessage") {
}
if ($type == "internet") {
//eth0
$command = 'nmcli -g GENERAL.STATE device show eth0';
$eth0_connStatus = shell_exec($command);
$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);
// eth0
$eth0_connStatus = str_replace("\n", "", shell_exec('nmcli -g GENERAL.STATE device show eth0 2>/dev/null'));
$eth0_IPAddr = str_replace("\n", "", shell_exec('nmcli -g IP4.ADDRESS device show eth0 2>/dev/null'));
//wlan0
$command = 'nmcli -g GENERAL.STATE device show wlan0';
$wlan0_connStatus = shell_exec($command);
$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);
// wlan0 basic
$wlan0_connStatus = str_replace("\n", "", shell_exec('nmcli -g GENERAL.STATE device show wlan0 2>/dev/null'));
$wlan0_IPAddr = str_replace("\n", "", shell_exec('nmcli -g IP4.ADDRESS device show wlan0 2>/dev/null'));
$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(
"connection" => $eth0_connStatus,
"IP" => $eth0_IPAddr
),
"wifi" => array(
"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
@@ -811,59 +1108,150 @@ if ($type == "wifi_connect") {
$SSID=$_GET['SSID'];
$PASS=$_GET['pass'];
echo "will try to connect to </br>";
echo "SSID: " . $SSID;
echo "</br>";
echo "Password: " . $PASS;
echo "</br>";
echo "</br>";
echo "You will be disconnected. If connection is successfull you can find the device on your local network.";
// Get device name and hostname for instructions
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';
}
$hostname = trim(shell_exec('hostname 2>/dev/null')) ?: 'aircarto';
// Launch connection script in background
$script_path = '/var/www/nebuleair_pro_4g/connexion.sh';
$log_file = '/var/www/nebuleair_pro_4g/logs/app.log';
shell_exec("$script_path $SSID $PASS >> $log_file 2>&1 &");
#$output = shell_exec('sudo nmcli connection down Hotspot');
#$output2 = shell_exec('sudo nmcli device wifi connect "AirLab" password "123plouf"');
// Return JSON response with instructions
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'ssid' => $SSID,
'deviceName' => $deviceName,
'hostname' => $hostname,
'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://$hostname.local/html/ 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://$hostname.local/html/ 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") {
// 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 = [];
if ($output) {
// Split the output into lines
$lines = explode("\n", trim($output));
// Check if wlan0 is in hotspot mode — if so, use cached CSV from boot scan
// (live scan is impossible while wlan0 is serving the hotspot)
$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
for ($i = 1; $i < count($lines); $i++) {
$line = trim($lines[$i]);
if (empty($line)) continue;
if ($is_hotspot) {
// Read cached scan from boot (wifi_list.csv)
$csv_path = '/var/www/nebuleair_pro_4g/wifi_list.csv';
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)
$parts = preg_split('/\s{2,}/', $line, 3);
if ($output) {
$lines = explode("\n", trim($output));
for ($i = 1; $i < count($lines); $i++) {
$line = trim($lines[$i]);
if (empty($line)) continue;
if (count($parts) >= 2) {
$jsonData[] = [
'SSID' => trim($parts[0]),
'SIGNAL' => trim($parts[1]),
'SECURITY' => isset($parts[2]) ? trim($parts[2]) : '--'
];
$parts = preg_split('/\s{2,}/', $line, 3);
if (count($parts) >= 2) {
$jsonData[] = [
'SSID' => trim($parts[0]),
'SIGNAL' => trim($parts[1]),
'SECURITY' => isset($parts[2]) ? trim($parts[2]) : '--'
];
}
}
}
}
// Set the content type to JSON
header('Content-Type: application/json');
// Convert the array to JSON format and output it
echo json_encode($jsonData, JSON_PRETTY_PRINT);
}
@@ -1088,6 +1476,10 @@ if ($type == "get_systemd_services") {
'description' => 'Get Data from noise sensor',
'frequency' => 'Every minute'
],
'nebuleair-mhz19-data.timer' => [
'description' => 'Reads CO2 concentration from MH-Z19 sensor',
'frequency' => 'Every 2 minutes'
],
'nebuleair-db-cleanup-data.timer' => [
'description' => 'Cleans up old data from database',
'frequency' => 'Daily'
@@ -1153,6 +1545,7 @@ if ($type == "restart_systemd_service") {
'nebuleair-sara-data.timer',
'nebuleair-bme280-data.timer',
'nebuleair-mppt-data.timer',
'nebuleair-mhz19-data.timer',
'nebuleair-db-cleanup-data.timer'
];
@@ -1213,6 +1606,7 @@ if ($type == "toggle_systemd_service") {
'nebuleair-sara-data.timer',
'nebuleair-bme280-data.timer',
'nebuleair-mppt-data.timer',
'nebuleair-mhz19-data.timer',
'nebuleair-db-cleanup-data.timer'
];
@@ -1417,3 +1811,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

@@ -92,6 +92,7 @@
<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 () {

View File

@@ -117,6 +117,7 @@
<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>
<script src="assets/js/topbar-logo.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {

View File

@@ -58,6 +58,14 @@
<label class="form-check-label" for="check_modem_configMode">Mode configuration</label>
</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>
<!--
<h3>
@@ -67,53 +75,78 @@
-->
<div class="row mb-3">
<div class="col-sm-2">
<div class="col-sm-3">
<div class="card">
<div class="card-body">
<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="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 class="col-sm-2">
<div class="col-sm-3">
<div class="card">
<div class="card-body">
<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="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 class="col-sm-2">
<div class="col-sm-3">
<div class="card">
<div class="card-body">
<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="response_ttyAMA2_AT_COPS_"></div>
</table>
<div id="network_info_alert"></div>
<div class="collapse mt-2" id="network_info_logs">
<div class="card card-body bg-light">
<small><code id="response_ttyAMA2_AT_COPS_"></code></small>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-2">
<div class="col-sm-3">
<div class="card">
<div class="card-body">
<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="response_ttyAMA2_AT_CSQ"></div>
</table>
<div id="signal_info_alert"></div>
<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>
@@ -359,6 +392,8 @@
<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 src="assets/js/selftest.js"></script>
<script>
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){
console.log("Data from SaraR4");
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>

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>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -9,31 +10,39 @@
body {
overflow-x: hidden;
}
#sidebar a.nav-link {
position: relative;
display: flex;
align-items: center;
position: relative;
display: flex;
align-items: center;
}
#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 {
margin-right: 8px; /* Add spacing between icons and text */
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>
<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 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>
@@ -45,140 +54,222 @@
<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>
<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-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>
<p data-i18n="sensors.description">Votre capteur NebuleAir est équipé de une ou plusieurs sondes qui permettent de mesurer certaines variables environnementales. La mesure
est automatique mais vous pouvez ici vous assurer de leur bon fonctionnement.
<p data-i18n="sensors.description">Votre capteur NebuleAir est équipé de une ou plusieurs sondes qui permettent
de mesurer certaines variables environnementales. La mesure
est automatique mais vous pouvez ici vous assurer de leur bon fonctionnement.
</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>
</main>
</main>
</div>
</div>
<!-- JAVASCRIPT -->
<!-- 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>
<!-- 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 src="assets/js/selftest.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const elementsToLoad = [
<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 }) => {
elementsToLoad.forEach(({ id, file }) => {
fetch(file)
.then(response => response.text())
.then(data => {
const element = document.getElementById(id);
if (element) {
element.innerHTML = data;
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
.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();
}
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
});
});
});
function getNPM_values(port){
console.log("Data from NPM (port "+port+"):");
$("#loading_"+port).show();
function getNPM_values(port) {
console.log("Data from NPM (port " + port + "):");
$("#loading_" + port).show();
$.ajax({
url: 'launcher.php?type=npm&port='+port,
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_"+port);
tableBody.innerHTML = "";
$.ajax({
url: 'launcher.php?type=npm',
dataType: 'json',
method: 'GET',
success: function (response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_" + port);
tableBody.innerHTML = "";
$("#loading_"+port).hide();
// Create an array of the desired keys
const keysToShow = ["PM1", "PM25", "PM10","message"];
// Error messages mapping
const errorMessages = {
"notReady": "Sensor is not ready",
"fanError": "Fan malfunction detected",
"laserError": "Laser malfunction detected",
"heatError": "Heating system error",
"t_rhError": "Temperature/Humidity sensor error",
"memoryError": "Memory failure detected",
"degradedState": "Sensor in degraded state"
};
// Add only the specified elements to the table
keysToShow.forEach(key => {
if (response[key] !== undefined) { // Check if the key exists in the response
const value = response[key];
$("#data-table-body_"+port).append(`
<tr>
<td>${key}</td>
<td>${value} µg/m³</td>
</tr>
`);
}
});
$("#loading_" + port).hide();
// Check for errors and add them to the table
Object.keys(errorMessages).forEach(errorKey => {
if (response[errorKey] === 1) {
$("#data-table-body_" + port).append(`
<tr class="error-row">
<td><b>${errorKey}</b></td>
<td style="color: red;">⚠ ${errorMessages[errorKey]}</td>
</tr>
`);
}
});
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
// PM values
const pmKeys = ["PM1", "PM25", "PM10"];
pmKeys.forEach(key => {
if (response[key] !== undefined) {
$("#data-table-body_" + port).append(`
<tr>
<td>${key}</td>
<td>${response[key]} µg/m³</td>
</tr>
`);
}
});
// Temperature & humidity
if (response.temperature !== undefined) {
$("#data-table-body_" + port).append(`
<tr><td>Temperature</td><td>${response.temperature} °C</td></tr>
`);
}
if (response.humidity !== undefined) {
$("#data-table-body_" + port).append(`
<tr><td>Humidity</td><td>${response.humidity} %</td></tr>
`);
}
// NPM status decoded
if (response.npm_status !== undefined) {
const status = response.npm_status;
if (status === 0xFF) {
// 0xFF = no response from sensor = disconnected
$("#data-table-body_" + port).append(`
<tr>
<td>Status</td>
<td style="color: red; font-weight: bold;">Capteur déconnecté</td>
</tr>
`);
} else if (status === 0) {
$("#data-table-body_" + port).append(`
<tr>
<td>Status</td>
<td style="color: green; font-weight: bold;">OK</td>
</tr>
`);
} else {
$("#data-table-body_" + port).append(`
<tr>
<td>Status</td>
<td style="color: orange; font-weight: bold;">${response.npm_status_hex}</td>
</tr>
`);
// Decode individual error bits
const statusFlags = {
0x01: "Sleep mode",
0x02: "Degraded mode",
0x04: "Not ready",
0x08: "Heater error",
0x10: "THP sensor error",
0x20: "Fan error",
0x40: "Memory error",
0x80: "Laser error"
};
Object.entries(statusFlags).forEach(([mask, label]) => {
if (status & mask) {
$("#data-table-body_" + port).append(`
<tr class="error-row">
<td></td>
<td style="color: red;">⚠ ${label}</td>
</tr>
`);
}
});
}
}
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_" + port).hide();
}
});
}
function getENVEA_values(port, name){
console.log("Data from Envea " + name + " (port " + port + "):");
$("#loading_envea" + name).show();
function getNPM_firmware(port) {
console.log("Firmware version from NPM (port " + port + "):");
$("#loading_fw_" + port).show();
$.ajax({
$.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,
dataType: 'json',
method: 'GET',
success: function(response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_envea" + name);
tableBody.innerHTML = "";
success: function (response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_envea" + name);
tableBody.innerHTML = "";
$("#loading_envea" + name).hide();
$("#loading_envea" + name).hide();
const keysToShow = [name];
keysToShow.forEach(key => {
if (response !== undefined) {
const value = response;
$("#data-table-body_envea" + name).append(`
const keysToShow = [name];
keysToShow.forEach(key => {
if (response !== undefined) {
const value = response;
$("#data-table-body_envea" + name).append(`
<tr>
<td>${key}</td>
<td>${value} ppb</td>
</tr>
`);
}
});
}
});
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
const tableBody = document.getElementById("data-table-body_envea" + name);
$("#loading_envea" + name).hide();
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
const tableBody = document.getElementById("data-table-body_envea" + name);
$("#loading_envea" + name).hide();
tableBody.innerHTML = `
tableBody.innerHTML = `
<tr>
<td colspan="2" class="text-danger">
❌ Error: unable to get data from sensor.<br>
@@ -187,132 +278,233 @@ function getENVEA_values(port, name){
</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(){
console.log("Data from I2C Noise Sensor:");
function getNoise_values() {
console.log("Data from NSRT MK4 Noise Sensor:");
$("#loading_noise").show();
$.ajax({
url: 'launcher.php?type=noise',
dataType: 'text',
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_noise");
tableBody.innerHTML = "";
$("#loading_noise").hide();
$.ajax({
url: 'launcher.php?type=noise',
dataType: 'json',
method: 'GET',
success: function (response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_noise");
tableBody.innerHTML = "";
$("#loading_noise").hide();
// Create an array of the desired keys
const keysToShow = ["Noise"];
// Add only the specified elements to the table
keysToShow.forEach(key => {
if (response !== undefined) { // Check if the key exists in the response
const value = response;
$("#data-table-body_noise").append(`
<tr>
<td>${key}</td>
<td>${value} DB</td>
</tr>
`);
}
});
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
if (response.error) {
$("#data-table-body_noise").append(`
<tr><td colspan="2" class="text-danger">${response.error}</td></tr>
`);
return;
}
const rows = [
{ label: "LEQ", value: response.LEQ + " dB" },
{ label: "dB(A)", value: response.dBA + " dBA" },
{ label: "Weighting", value: response.weighting },
{ label: "Tau", value: response.tau + " s" }
];
rows.forEach(row => {
$("#data-table-body_noise").append(`
<tr>
<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:");
$("#loading_BME280").show();
$.ajax({
url: 'launcher.php?type=BME280',
dataType: 'text',
$.ajax({
url: 'launcher.php?type=BME280',
dataType: 'text',
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log(response);
method: 'GET', // Use GET or POST depending on your needs
success: function (response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_BME280");
tableBody.innerHTML = "";
$("#loading_BME280").hide();
const tableBody = document.getElementById("data-table-body_BME280");
tableBody.innerHTML = "";
$("#loading_BME280").hide();
// Parse the JSON response
const data = JSON.parse(response);
const keysToShow = ["temp", "hum", "press"];
// Parse the JSON response
const data = JSON.parse(response);
const keysToShow = ["temp", "hum", "press"];
// Add only the specified elements to the table
keysToShow.forEach(key => {
if (response !== undefined) { // Check if the key exists in the response
const value = data[key];
const unit = key === "temp" ? "°C"
: key === "hum" ? "%"
: key === "press" ? "hPa"
: ""; // Add appropriate units
// Add only the specified elements to the table
keysToShow.forEach(key => {
if (response !== undefined) { // Check if the key exists in the response
const value = data[key];
const unit = key === "temp" ? "°C"
: key === "hum" ? "%"
: key === "press" ? "hPa"
: ""; // Add appropriate units
$("#data-table-body_BME280").append(`
$("#data-table-body_BME280").append(`
<tr>
<td>${key.charAt(0).toUpperCase() + key.slice(1)}</td>
<td>${value} ${unit}</td>
</tr>
`);
}
});
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
}
});
},
error: function (xhr, 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);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});//end AJAX
window.onload = function () {
//Function to create sensor cards based on config
function createSensorCards(config) {
console.log("Creating sensor cards with config:");
console.log(config);
//NEW way to get config (SQLite)
let mainConfig = {}; // Store main config for use in sensor card creation
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="card">
<div class="card-header" data-i18n="sensors.npm.headerUart">
@@ -322,8 +514,11 @@ error: function(xhr, status, error) {
<h5 class="card-title" data-i18n="sensors.npm.title">NextPM</h5>
<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-secondary" onclick="getNPM_firmware('ttyAMA5')">Firmware Version</button>
<br>
<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">
<tbody id="data-table-body_ttyAMA5"></tbody>
</table>
@@ -331,12 +526,12 @@ error: function(xhr, status, error) {
</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
if (config.BME280) {
const i2C_BME_HTML = `
//creates i2c BME280 card
if (config.BME280) {
const i2C_BME_HTML = `
<div class="col-sm-3">
<div class="card">
<div class="card-header" data-i18n="sensors.bme280.headerI2c">
@@ -355,24 +550,21 @@ error: function(xhr, status, error) {
</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
if (config.NOISE) {
const i2C_HTML = `
//creates NSRT MK4 noise sensor card (USB)
if (config.NOISE) {
const noiseHTML = `
<div class="col-sm-3">
<div class="card">
<div class="card-header" data-i18n="sensors.noise.headerI2c">
Port I2C
<div class="card-header" data-i18n="sensors.noise.headerUsb">
Port USB
</div>
<div class="card-body">
<h5 class="card-title" data-i18n="sensors.noise.title">Decibel Meter</h5>
<p class="card-text" data-i18n="sensors.noise.description">Capteur bruit sur le port I2C.</p>
<h5 class="card-title" data-i18n="sensors.noise.title">NSRT MK4</h5>
<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>
<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>
<table class="table table-striped-columns">
<tbody id="data-table-body_noise"></tbody>
@@ -381,85 +573,103 @@ error: function(xhr, status, error) {
</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 ENVEA cards
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',
//dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
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
//creates MH-Z19 CO2 card
if (config.MHZ19) {
const MHZ19_HTML = `
<div class="col-sm-3">
<div class="card">
<div class="card-header">
Port UART 4
</div>
<div class="card-body">
<h5 class="card-title">MH-Z19 CO2</h5>
<p class="card-text">Capteur de dioxyde de carbone.</p>
<button class="btn btn-primary mb-1" onclick="getMHZ19_values()" data-i18n="common.getData">Get Data</button>
<div id="loading_mhz19" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<table class="table table-striped-columns">
<tbody id="data-table-body_mhz19"></tbody>
</table>
</div>
</div>
</div>`;
ENVEA_sensors.forEach((sensor, index) => {
const port = sensor.port; // Port from the sensor object
const name = sensor.name; // Port from the sensor object
const coefficient = sensor.coefficient;
const cardHTML = `
<div class="col-sm-3">
container.innerHTML += MHZ19_HTML;
}
//Si on a des SONDES ENVEA connectée il faut faire un deuxième call dans la table envea_sondes_table
//creates ENVEA debug card
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-header">
Port UART ${port.replace('ttyAMA', '')}
<strong>Sondes Envea (Debug)</strong>
</div>
<div class="card-body">
<h5 class="card-title">Sonde Envea ${name}</h5>
<p class="card-text" data-i18n="sensors.envea.description">Capteur gas.</p>
<button class="btn btn-primary" onclick="getENVEA_values('${port}','${name}')" data-i18n="common.getData">Get Data</button>
<div id="loading_envea${name}" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<table class="table table-striped-columns">
<tbody id="data-table-body_envea${name}"></tbody>
</table>
<h5 class="card-title" data-i18n="sensors.envea.title">Sonde Envea</h5>
<p class="card-text" data-i18n="sensors.envea.description">Capteur gaz.</p>
<p class="text-muted small">Sondes connectées: ${ENVEA_sensors.map(s => s.name).join(', ')}</p>
<button class="btn btn-primary" onclick="getENVEA_debug_values()" data-i18n="common.getData">Get Data</button>
<div id="loading_envea_debug" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="envea-debug-output" class="mt-3"></div>
</div>
</div>
</div>`;
container.innerHTML += cardHTML; // Ajouter la carte au conteneur
});
container.innerHTML += cardHTML;
}
// Apply translations to dynamically created Envea cards
// 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 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);
}
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
} //end windows onload
</script>
} //end windows onload
</script>
</body>
</html>

View File

@@ -1,47 +1,70 @@
<!-- Sidebar -->
<nav class="nav flex-column">
<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">
<use xlink:href="assets/icons/bootstrap-icons.svg#house"></use>
</svg>
<span data-i18n="sidebar.home">Accueil</span>
</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>
<!-- Sidebar -->
<nav class="nav flex-column">
<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">
<use xlink:href="assets/icons/bootstrap-icons.svg#house"></use>
</svg>
<span data-i18n="sidebar.home">Accueil</span>
</a>
<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
<!-- Screen Control (Hidden by default) -->
<a class="nav-link text-white nav-screen-item" href="screen.html" id="nav-screen" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-display"
viewBox="0 0 16 16">
<path
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" />
</svg>
<span data-i18n="sidebar.screen">Screen</span>
</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>
</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">
<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"/>
@@ -55,18 +78,32 @@
<span data-i18n="sidebar.terminal">Terminal</span>
</a>
-->
<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">
<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"/>
<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">
<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>
<span data-i18n="sidebar.admin">Admin</span>
Mode Hotspot
</span>
</a>
<!-- New content at the bottom -->
<div class="sidebar-footer text-center text-white">
<hr>
<span class="sideBar_sensorName"> NebuleAir</span>
</div>
</div>
</nav>
</nav>

View File

@@ -175,6 +175,7 @@
<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>
<script src="assets/js/topbar-logo.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {

View File

@@ -2,7 +2,7 @@
<nav class="navbar navbar-dark fixed-top" style="background-color: #8d8d8f;" id="topbar">
<div class="container-fluid">
<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>
<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>

View File

@@ -54,56 +54,114 @@
<h3>Status
<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>
<div class="row mb-3">
<div class="col-sm-4">
<div class="card text-dark bg-light">
<!-- Connection Info Card (shown when connected to WiFi) -->
<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">
<h5 class="card-title">WIFI / Ethernet</h5>
<p class="card-text">General information.</p>
<button class="btn btn-primary" onclick="get_internet()">Get Data</button>
<table class="table table-striped-columns">
<tbody id="data-table-body_internet_general"></tbody>
<div id="connection-info-loading" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span class="ms-2">Chargement...</span>
</div>
<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>
</div>
</div>
</div>
<div class="col-sm-8">
<div class="card text-dark bg-light">
<!-- Hotspot Info Card (shown when in hotspot mode) -->
<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">
<h5 class="card-title">Wifi Scan</h5>
<p class="card-text">Scan des réseaux WIFI disponibles.</p>
<button class="btn btn-primary" onclick="wifi_scan()">Scan</button>
<table class="table">
<p class="mb-1">Le capteur n'est connecte a aucun reseau WiFi.</p>
<p class="text-muted mb-0">Utilisez le scan ci-dessous pour vous connecter a un reseau.</p>
</div>
</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>
</table>
</table>
</div>
</div>
</div>
</div>
<!-- Modal WIFI PASSWORD -->
<!-- filled with JS -->
<div class="modal fade" id="myModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<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>
</div>
<div class="modal-body" id="myModalBody">
...
</div>
<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-primary">Save changes</button>
</div>
</div>
<!-- Modal WIFI PASSWORD -->
<div class="modal fade" id="myModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<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>
</div>
<div class="modal-body" id="myModalBody">
...
</div>
<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-primary">Save changes</button>
</div>
</div>
</div>
</div>
<div>
@@ -119,6 +177,7 @@
<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 () {
@@ -141,197 +200,280 @@
})
.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(){
console.log("Getting internet general infos");
$.ajax({
url: 'launcher.php?type=internet',
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
console.log("Getting internet general infos");
document.getElementById('connection-info-loading').style.display = '';
document.getElementById('connection-info-table').style.display = 'none';
// Iterate through the data and create rows
for (let key in response) {
let row = `
<tr>
<td>${key}</td>
<td>${response[key].connection}</td>
<td>${response[key].IP ? response[key].IP : "No IP"}</td>
</tr>
`;
tableBody.innerHTML += row; // Append row to table body
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
}
$.ajax({
url: 'launcher.php?type=internet',
dataType: 'json',
method: 'GET',
success: function(response) {
console.log(response);
const wifi = response.wifi;
const eth = response.ethernet;
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){
console.log("Connecting to wifi");
console.log(SSID);
console.log(PASS);
if (typeof PASS === 'undefined') {
console.log("Need to add password");
//open bootstrap modal to ask for password
var myModal = new bootstrap.Modal(document.getElementById('myModal'));
//modifiy modal title
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>";
myModal.show();
} else {
console.log("Will try to connect to "+SSID+" with password "+PASS);
console.log("Start PHP script:");
document.getElementById('info-eth-status').textContent = eth.connection || '-';
document.getElementById('info-eth-ip').textContent = eth.IP || '-';
$.ajax({
url: 'launcher.php?type=wifi_connect&SSID='+SSID+'&pass='+PASS,
dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log(response);
document.getElementById('connection-info-loading').style.display = 'none';
document.getElementById('connection-info-table').style.display = '';
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
document.getElementById('connection-info-loading').innerHTML = '<span class="text-danger">Erreur de chargement</span>';
}
});
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
function wifi_connect(SSID, PASS){
console.log("Connecting to wifi");
if (typeof PASS === 'undefined') {
var myModal = new bootstrap.Modal(document.getElementById('myModal'));
document.getElementById('myModalLabel').innerHTML = "Enter password for "+SSID;
document.getElementById('myModalBody').innerHTML = "<input type='text' id='wifi_pass' class='form-control' placeholder='Password'>";
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>";
myModal.show();
} else {
var myModal = bootstrap.Modal.getInstance(document.getElementById('myModal'));
if (myModal) { myModal.hide(); }
$.ajax({
url: 'launcher.php?type=wifi_connect&SSID='+SSID+'&pass='+PASS,
dataType: 'json',
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() {
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 device Name
$.ajax({
url: 'launcher.php?type=get_config_sqlite',
dataType: 'json',
method: 'GET',
success: function(data) {
console.log("Getting config (onload)");
const deviceName = data.deviceName;
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = deviceName;
});
function updateSidebarDeviceName(deviceName) {
const elements = document.querySelectorAll('.sideBar_sensorName');
if (elements.length > 0) {
elements.forEach((element) => {
element.innerText = deviceName;
});
}
}
//device name html page title
if (response.deviceName) {
document.title = response.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");
console.log("WIFI is: " + data.WIFI_status);
if (data.WIFI_status === "connected") {
WIFI_statusElement.textContent = "Connected";
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") {
WIFI_statusElement.textContent = "Hotspot";
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 {
WIFI_statusElement.textContent = "Unknown";
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({
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
dataType: 'text',
method: 'GET',
success: function(response) {
console.log("Local RTC: " + response);
const RTC_Element = document.getElementById("RTC_time");
RTC_Element.textContent = response;
},
@@ -340,10 +482,12 @@ function get_internet(){
}
});
})
.catch(error => console.error('Error loading config.json:', error));
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
}
</script>

View File

@@ -81,22 +81,42 @@ fi
info "Set config..."
if [[ -f "$REPO_DIR/sqlite/set_config.py" ]]; then
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
warning "Database creation script not found."
warning "Database set configuration script not found."
fi
# Configure Apache
info "Configuring Apache..."
APACHE_CONF="/etc/apache2/sites-available/000-default.conf"
if grep -q "DocumentRoot /var/www/nebuleair_pro_4g" "$APACHE_CONF"; then
warning "Apache configuration already set. Skipping."
warning "Apache DocumentRoot already set. Skipping."
else
sudo sed -i 's|DocumentRoot /var/www/html|DocumentRoot /var/www/nebuleair_pro_4g|' "$APACHE_CONF"
sudo systemctl reload apache2
success "Apache configuration updated and reloaded."
success "Apache DocumentRoot updated."
fi
# Enable AllowOverride for .htaccess (needed for PHP upload limits)
APACHE_MAIN_CONF="/etc/apache2/apache2.conf"
if grep -q "AllowOverride All" "$APACHE_MAIN_CONF"; then
warning "AllowOverride already configured. Skipping."
else
# Replace AllowOverride None with AllowOverride All for /var/www/
sudo sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' "$APACHE_MAIN_CONF"
success "AllowOverride All enabled for /var/www/."
fi
# Also increase PHP limits directly in php.ini as fallback
PHP_INI=$(php -r "echo php_ini_loaded_file();" 2>/dev/null)
if [[ -n "$PHP_INI" ]]; then
sudo sed -i 's/upload_max_filesize = .*/upload_max_filesize = 50M/' "$PHP_INI"
sudo sed -i 's/post_max_size = .*/post_max_size = 55M/' "$PHP_INI"
success "PHP upload limits set to 50M in $PHP_INI"
fi
sudo systemctl reload apache2
success "Apache configuration updated and reloaded."
# Add sudo authorization (prevent duplicate entries)
info "Setting up sudo authorization..."
SUDOERS_FILE="/etc/sudoers"
@@ -118,6 +138,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/python3 *
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/*
EOF
@@ -141,11 +162,11 @@ if ! sudo visudo -c; then
error "Sudoers file has syntax errors! Please fix manually with 'sudo visudo'"
fi
# Open all UART serial ports (avoid duplication)
info "Configuring UART serial ports..."
# Open all UART serial ports and disable HDMI + Bluetooth (avoid duplication)
info "Configuring UART serial ports and disabling Bluetooth to save power..."
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
success "UART configuration added."
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, HDMI and Bluetooth disable added."
else
warning "UART configuration already set. Skipping."
fi
@@ -159,10 +180,6 @@ info "Enabling I2C ports..."
sudo raspi-config nonint do_i2c 0
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
if sudo visudo -c; then
success "Sudoers file is valid."

View File

@@ -31,11 +31,19 @@ info "Set up the RTC"
info "Wake Up SARA"
pinctrl set 16 op
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)
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
#info "Set up Monogoto APN"
@@ -43,7 +51,22 @@ info "Check SARA connection (ATI)"
#activate blue network led on the SARA R4
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
#info "Connect SARA R4 to network"
@@ -100,3 +123,28 @@ sudo systemctl enable rtc_save_to_db.service
# Start the service immediately
info "Starting the 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
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
1 -> timestamp
2 -> PM1
@@ -151,6 +151,16 @@ payload_json = {
aircarto_profile_id = 0
uSpot_profile_id = 1
# Error flags constants (byte 66)
ERR_RTC_DISCONNECTED = 0x01
ERR_RTC_RESET = 0x02
ERR_BME280 = 0x04
ERR_NPM = 0x08
ERR_ENVEA = 0x10
ERR_NOISE = 0x20
ERR_MPPT = 0x40
ERR_WIND = 0x80
# database connection
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
@@ -272,6 +282,12 @@ class SensorPayload:
self.payload[0:8] = device_id_bytes
# Status/error bytes default to 0x00 (no error)
# 0xFF = "no data" for sensor values, but for status bytes
# 0x00 = "no error/no flag" is the safe default
self.payload[66] = 0x00 # error_flags
self.payload[67] = 0x00 # npm_status
self.payload[68] = 0x00 # device_status
# Set protocol version (byte 9)
self.payload[9] = 0x01
@@ -298,14 +314,18 @@ class SensorPayload:
if pressure is not None:
self.payload[20:22] = struct.pack('>H', int(pressure))
def set_noise(self, avg_noise, max_noise=None, min_noise=None):
"""Set noise values (bytes 22-27)"""
if avg_noise is not None:
self.payload[22:24] = struct.pack('>H', int(avg_noise * 10))
def set_noise(self, cur_leq, cur_level, max_noise=None):
"""Set noise values (bytes 22-27)
22-23: noise_cur_leq (dBA × 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:
self.payload[24:26] = struct.pack('>H', int(max_noise * 10))
if min_noise is not None:
self.payload[26:28] = struct.pack('>H', int(min_noise * 10))
self.payload[26:28] = struct.pack('>H', int(max_noise * 10))
def set_envea(self, no2, h2s, nh3, co, o3):
"""Set ENVEA gas sensor values (bytes 28-37)"""
@@ -354,6 +374,28 @@ class SensorPayload:
if direction is not None:
self.payload[64:66] = struct.pack('>H', int(direction))
def set_error_flags(self, flags):
"""Set system error flags (byte 66)"""
self.payload[66] = flags & 0xFF
def set_npm_status(self, status):
"""Set NextPM status register (byte 67)"""
self.payload[67] = status & 0xFF
def set_device_status(self, status):
"""Set device status flags (byte 68)"""
self.payload[68] = status & 0xFF
def set_firmware_version(self, version_str):
"""Set firmware version bytes 69-71 (major.minor.patch)"""
try:
parts = version_str.strip().split('.')
self.payload[69] = int(parts[0]) & 0xFF
self.payload[70] = int(parts[1]) & 0xFF
self.payload[71] = int(parts[2]) & 0xFF
except (IndexError, ValueError):
pass # leave as 0xFF if VERSION file is malformed
def get_bytes(self):
"""Get the complete 100-byte payload"""
return bytes(self.payload)
@@ -515,6 +557,24 @@ def reset_PSD_CSD_connection():
"""
print("Reseting PDP connection ")
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
print('➡️ Activate PDP context 1')
command = f'AT+CGACT=1,1\r'
@@ -786,6 +846,19 @@ try:
payload_csv[18] = npm_temp
payload_csv[19] = npm_hum
# npm_status: last value only (no average), use rowid (not timestamp)
npm_status_value = rows[0][7] if rows and rows[0][7] is not None else 0xFF
npm_disconnected = False
if npm_status_value == 0xFF:
# 0xFF = NPM disconnected/no response → will set ERR_NPM in error_flags
npm_disconnected = True
print("NPM status: 0xFF (disconnected)")
else:
# Valid status from NPM → send as byte 67
payload.set_npm_status(npm_status_value)
print(f"NPM status: 0x{npm_status_value:02X}")
#add data to payload UDP
payload.set_npm_core(PM1, PM25, PM10)
payload.set_npm_internal(npm_temp, npm_hum)
@@ -952,13 +1025,12 @@ try:
cur_level = last_row[2]
#Add data to payload CSV
payload_csv[6] = DB_A_value
payload_csv[6] = cur_level
#Add data to payload UDP
payload.set_noise(
avg_noise=last_row[2], # DB_A_value
max_noise=None, # Add if available
min_noise=None # Add if available
cur_leq=cur_LEQ, # current LEQ (dBA)
cur_level=cur_level # current level (dBA)
)
#print("Verify SARA connection (AT)")
@@ -1069,6 +1141,23 @@ try:
'''
# ---- Build error_flags (byte 66) ----
error_flags = 0x00
if rtc_status == "disconnected":
error_flags |= ERR_RTC_DISCONNECTED
if rtc_status == "reset":
error_flags |= ERR_RTC_RESET
if npm_disconnected:
error_flags |= ERR_NPM
payload.set_error_flags(error_flags)
# ---- Firmware version (bytes 69-71) ----
try:
with open("/var/www/nebuleair_pro_4g/VERSION", "r") as f:
payload.set_firmware_version(f.read())
except FileNotFoundError:
pass
if send_miotiq:
print('<p class="fw-bold">➡SEND TO MIOTIQ</p>', end="")
@@ -1077,6 +1166,9 @@ try:
print(f"Binary payload: {len(binary_data)} bytes")
#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
# IF ERROR -> need to create the PDP connection
print("Create Socket:", end="")
@@ -1092,69 +1184,114 @@ try:
psd_csd_resets = reset_PSD_CSD_connection()
if psd_csd_resets:
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:
print("⛔There were issues with the modem CSD PSD reinitialize process")
print("🔄 PDP reset failed → escalating to hardware reboot")
# Clignotement LED rouge en cas d'erreur
led_thread = Thread(target=blink_led, args=(24, 5, 0.5))
led_thread.start()
#Send notification (WIFI)
send_error_notification(device_id, "UDP socket creation failed + PDP reset failed -> hardware reboot")
#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")
#end loop
sys.exit()
#Retreive Socket ID
socket_id = None
match = re.search(r'\+USOCR:\s*(\d+)', response_SARA_1)
if match:
socket_id = match.group(1)
print(f"Socket ID: {socket_id}", end="")
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)
print("Connect to server:", end="")
command = f'AT+USOCO={socket_id},"192.168.0.20",4242\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)
print('<p class="text-danger-emphasis">', end="")
print(response_SARA_2)
print("</p>", end="")
if socket_id is not None:
print("Connect to server:", end="")
ser_sara.reset_input_buffer()
command = f'AT+USOCO={socket_id},"192.168.0.20",4242\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)
print('<p class="text-danger-emphasis">', end="")
print(response_SARA_2)
print("</p>", end="")
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
# Write data and send
if socket_id is not None:
print(f"Write data: {len(binary_data)} bytes", end="")
ser_sara.reset_input_buffer()
command = f'AT+USOWR={socket_id},{len(binary_data)}\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)
print('<p class="text-danger-emphasis">', end="")
print(response_SARA_2)
print("</p>", end="")
print(f"Write data: {len(binary_data)} bytes", end="")
command = f'AT+USOWR={socket_id},{len(binary_data)}\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)
print('<p class="text-danger-emphasis">', end="")
print(response_SARA_2)
print("</p>", end="")
# Verify modem sent @ prompt (ready for binary data)
if "@" not in response_SARA_2:
print('<span style="color: red;font-weight: bold;">⚠Modem did not send @ prompt - skip data send to avoid AT+USOWR leak⚠</span>')
ser_sara.reset_input_buffer()
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
# Send the raw payload bytes (already prepared)
ser_sara.write(binary_data)
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="")
if socket_id is not None:
# Send the raw payload bytes (already prepared)
ser_sara.write(binary_data)
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="")
#Read reply from server (USORD)
#print("Read reply:", end="")
#command = f'AT+USORD=0,100\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)
#print('<p class="text-danger-emphasis">')
#print(response_SARA_2)
#print("</p>", end="")
#parfois ici on peut avoir une erreur ERROR
if "+CME ERROR" in response_SARA_2 or "ERROR" in response_SARA_2:
print('<span style="color: red;font-weight: bold;">⚠ATTENTION: Error while sending data⚠</span>')
print('🛑STOP LOOP🛑')
print("<hr>")
#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)
#Send notification (WIFI)
send_error_notification(device_id, "UDP sending issue")
#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")
#blink green LEDs
led_thread = Thread(target=blink_led, args=(23, 5, 0.5))
led_thread.start()
#end loop
sys.exit()
print('<p class="text-danger-emphasis">', end="")
print(response_SARA_2)
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="")

322
loop/error_flags.md Normal file
View File

@@ -0,0 +1,322 @@
# Error Flags — UDP Payload Miotiq (Bytes 66-68)
## Principe
Les bytes 66, 67 et 68 de la payload UDP (100 bytes) sont utilises comme registres d'erreurs
et d'etat. Chaque bit represente un etat independant. Plusieurs flags peuvent
etre actifs simultanement.
- **Byte 66** : erreurs systeme (RTC, capteurs)
- **Byte 67** : status NextPM (registre interne du capteur)
- **Byte 68** : status device (etat general du boitier)
## Position dans la payload
```
Bytes 0-65 : donnees capteurs (existant)
Byte 66 : error_flags (erreurs systeme)
Byte 67 : npm_status (status NextPM)
Byte 68 : device_status (etat general du boitier)
Bytes 69-99 : reserved (initialises a 0xFF)
```
---
## Byte 66 — Error Flags (erreurs systeme)
Chaque bit represente une erreur detectee par le script d'envoi (`SARA_send_data_v2.py`).
| Bit | Masque | Nom | Description | Source |
|-----|--------|-------------------|--------------------------------------------------|-------------------------------|
| 0 | 0x01 | RTC_DISCONNECTED | Module RTC DS3231 non detecte sur le bus I2C | timestamp_table → 'not connected' |
| 1 | 0x02 | RTC_RESET | RTC en date par defaut (annee 2000) | timestamp_table → annee 2000 |
| 2 | 0x04 | BME280_ERROR | Capteur BME280 non detecte ou erreur de lecture | data_BME280 → valeurs a 0 |
| 3 | 0x08 | NPM_ERROR | Capteur NextPM non detecte ou erreur communication| data_NPM → toutes valeurs a 0 |
| 4 | 0x10 | ENVEA_ERROR | Capteurs Envea non detectes ou erreur serie | data_envea → valeurs a 0 |
| 5 | 0x20 | NOISE_ERROR | Capteur bruit NSRT MK4 non detecte ou erreur | data_noise → valeurs a 0 |
| 6 | 0x40 | MPPT_ERROR | Chargeur solaire MPPT non detecte ou erreur | data_MPPT → valeurs a 0 |
| 7 | 0x80 | WIND_ERROR | Capteur vent non detecte ou erreur | data_windMeter → valeurs a 0 |
### Detection des erreurs
Les scripts de collecte (`get_data_modbus_v3.py`, `get_data_v2.py`, etc.) ecrivent des **0**
en base SQLite quand un capteur ne repond pas. Le script d'envoi (`SARA_send_data_v2.py`)
lit ces valeurs et peut detecter l'erreur quand toutes les valeurs d'un capteur sont a 0.
Pour le RTC, le champ `timestamp_table` contient directement `'not connected'` ou une date
en annee 2000 quand le module est deconnecte ou reinitialise.
### Exemples de valeurs
| Valeur dec | Hex | Signification |
|------------|------|---------------------------------------|
| 0 | 0x00 | Aucune erreur |
| 1 | 0x01 | RTC deconnecte |
| 2 | 0x02 | RTC reset (annee 2000) |
| 5 | 0x05 | RTC deconnecte + BME280 erreur |
| 9 | 0x09 | RTC deconnecte + NPM erreur |
| 255 | 0xFF | Toutes les erreurs (cas extreme) |
---
## Byte 67 — NPM Status (registre interne NextPM)
Le capteur NextPM possede un registre de status sur 9 bits (registre Modbus).
On stocke les 8 bits bas dans le byte 67. Ce registre est lu directement depuis
le capteur via Modbus, pas depuis SQLite.
| Bit | Masque | Nom | Description | Severite |
|-----|--------|-----------------|-----------------------------------------------------------------------|----------------|
| 0 | 0x01 | SLEEP_STATE | Capteur en veille (commande sleep). Seule la lecture status autorisee | Info |
| 1 | 0x02 | DEGRADED_STATE | Erreur mineure confirmee. Mesures possibles mais precision reduite | Warning |
| 2 | 0x04 | NOT_READY | Demarrage en cours (15s apres mise sous tension). Mesures non fiables | Info |
| 3 | 0x08 | HEAT_ERROR | Humidite relative > 60% pendant > 10 minutes | Warning |
| 4 | 0x10 | TRH_ERROR | Capteur T/RH interne hors specification | Warning |
| 5 | 0x20 | FAN_ERROR | Vitesse ventilateur hors plage (tourne encore) | Warning |
| 6 | 0x40 | MEMORY_ERROR | Acces memoire impossible, fonctions internes limitees | Warning |
| 7 | 0x80 | LASER_ERROR | Aucune particule detectee pendant > 240s, possible erreur laser | Warning |
Note : le bit 8 du registre NextPM (DEFAULT_STATE — ventilateur arrete apres 3 tentatives)
ne tient pas dans un byte. Si necessaire, il peut etre combine avec le bit 0 (SLEEP_STATE)
car les deux indiquent un capteur inactif.
### Exemples de valeurs
| Valeur dec | Hex | Signification |
|------------|------|--------------------------------------------|
| 0 | 0x00 | Capteur OK, mesures fiables |
| 4 | 0x04 | Demarrage en cours (NOT_READY) |
| 8 | 0x08 | Erreur humidite (HEAT_ERROR) |
| 32 | 0x20 | Erreur ventilateur (FAN_ERROR) |
| 128 | 0x80 | Possible erreur laser (LASER_ERROR) |
| 40 | 0x28 | HEAT_ERROR + FAN_ERROR |
---
## Byte 68 — Device Status (etat general du boitier)
Flags d'etat du device, determines par le script d'envoi (`SARA_send_data_v2.py`).
Ces flags donnent du contexte sur l'etat general du boitier pour le diagnostic a distance.
| Bit | Masque | Nom | Description | Source |
|-----|--------|----------------------|----------------------------------------------------------------|-------------------------------------|
| 0 | 0x01 | SARA_REBOOTED | Le modem a ete reboot hardware au cycle precedent | flag fichier ou SQLite |
| 1 | 0x02 | WIFI_CONNECTED | Le device est connecte en WiFi (atelier/maintenance) | nmcli device status |
| 2 | 0x04 | HOTSPOT_ACTIVE | Le hotspot WiFi est actif (configuration en cours) | nmcli device status |
| 3 | 0x08 | GPS_NO_FIX | Pas de position GPS valide | config_table latitude/longitude |
| 4 | 0x10 | BATTERY_LOW | Tension batterie sous seuil critique | data_MPPT → battery_voltage |
| 5 | 0x20 | DISK_FULL | Espace disque critique sur la Pi (< 5%) | os.statvfs ou shutil.disk_usage |
| 6 | 0x40 | DB_ERROR | Erreur d'acces a la base SQLite | try/except sur connexion SQLite |
| 7 | 0x80 | BOOT_RECENT | Le device a redemarre recemment (uptime < 5 min) | /proc/uptime |
### Exemples de valeurs
| Valeur dec | Hex | Signification |
|------------|------|--------------------------------------------------|
| 0 | 0x00 | Tout est normal |
| 1 | 0x01 | Modem reboot au cycle precedent |
| 2 | 0x02 | WiFi connecte (probablement en atelier) |
| 6 | 0x06 | WiFi + hotspot actifs (configuration en cours) |
| 128 | 0x80 | Boot recent (uptime < 5 min) |
| 145 | 0x91 | Modem reboot + batterie faible + boot recent |
---
## Implementation
### Etape 1 : Lire le status NPM depuis le capteur
Le script `NPM/get_data_modbus_v3.py` doit etre modifie pour :
1. Lire le registre de status du NextPM (adresse Modbus a determiner)
2. Stocker le status byte dans une nouvelle colonne SQLite (ex: `npm_status` dans `data_NPM`)
### Etape 2 : Construire les flags dans SARA_send_data_v2.py
```python
# Constantes error_flags (byte 66)
ERR_RTC_DISCONNECTED = 0x01
ERR_RTC_RESET = 0x02
ERR_BME280 = 0x04
ERR_NPM = 0x08
ERR_ENVEA = 0x10
ERR_NOISE = 0x20
ERR_MPPT = 0x40
ERR_WIND = 0x80
# Constantes device_status (byte 68)
DEV_SARA_REBOOTED = 0x01
DEV_WIFI_CONNECTED = 0x02
DEV_HOTSPOT_ACTIVE = 0x04
DEV_GPS_NO_FIX = 0x08
DEV_BATTERY_LOW = 0x10
DEV_DISK_FULL = 0x20
DEV_DB_ERROR = 0x40
DEV_BOOT_RECENT = 0x80
# Construction byte 66
error_flags = 0x00
if rtc_status == "disconnected":
error_flags |= ERR_RTC_DISCONNECTED
if rtc_status == "reset":
error_flags |= ERR_RTC_RESET
if PM1 == 0 and PM25 == 0 and PM10 == 0:
error_flags |= ERR_NPM
# ... autres capteurs
payload.set_error_flags(error_flags)
# Construction byte 67 (lu depuis SQLite, ecrit par get_data_modbus_v3.py)
npm_status = get_npm_status_from_db() # 0-255
payload.set_npm_status(npm_status)
# Construction byte 68
device_status = 0x00
if sara_was_rebooted(): # flag fichier persistant
device_status |= DEV_SARA_REBOOTED
if check_wifi_connected(): # nmcli device status
device_status |= DEV_WIFI_CONNECTED
if check_hotspot_active(): # nmcli device status
device_status |= DEV_HOTSPOT_ACTIVE
if latitude == 0.0 and longitude == 0.0: # config_table
device_status |= DEV_GPS_NO_FIX
if battery_voltage < 11.0: # data_MPPT seuil a ajuster
device_status |= DEV_BATTERY_LOW
if check_disk_usage() > 95: # shutil.disk_usage
device_status |= DEV_DISK_FULL
# DEV_DB_ERROR: set dans le try/except de la connexion SQLite
if get_uptime_seconds() < 300: # /proc/uptime
device_status |= DEV_BOOT_RECENT
payload.set_device_status(device_status)
```
### Etape 3 : Ajouter les methodes dans SensorPayload
```python
def set_error_flags(self, flags):
"""Set system error flags (byte 66)"""
self.payload[66] = flags & 0xFF
def set_npm_status(self, status):
"""Set NextPM status register (byte 67)"""
self.payload[67] = status & 0xFF
def set_device_status(self, status):
"""Set device status flags (byte 68)"""
self.payload[68] = status & 0xFF
```
---
## Parser Miotiq
```
16|device_id|string|||W
2|signal_quality|hex2dec|dB||
2|version|hex2dec|||W
4|ISO_68|hex2dec|ugm3|x/10|
4|ISO_39|hex2dec|ugm3|x/10|
4|ISO_24|hex2dec|ugm3|x/10|
4|ISO_54|hex2dec|degC|x/100|
4|ISO_55|hex2dec|%|x/100|
4|ISO_53|hex2dec|hPa||
4|noise_cur_leq|hex2dec|dB|x/10|
4|noise_cur_level|hex2dec|dB|x/10|
4|max_noise|hex2dec|dB|x/10|
4|ISO_03|hex2dec|ppb||
4|ISO_05|hex2dec|ppb||
4|ISO_21|hex2dec|ppb||
4|ISO_04|hex2dec|ppb||
4|ISO_08|hex2dec|ppb||
4|npm_ch1|hex2dec|count||
4|npm_ch2|hex2dec|count||
4|npm_ch3|hex2dec|count||
4|npm_ch4|hex2dec|count||
4|npm_ch5|hex2dec|count||
4|npm_temp|hex2dec|°C|x/10|
4|npm_humidity|hex2dec|%|x/10|
4|battery_voltage|hex2dec|V|x/100|
4|battery_current|hex2dec|A|x/100|
4|solar_voltage|hex2dec|V|x/100|
4|solar_power|hex2dec|W||
4|charger_status|hex2dec|||
4|wind_speed|hex2dec|m/s|x/10|
4|wind_direction|hex2dec|degrees||
2|error_flags|hex2dec|||
2|npm_status|hex2dec|||
2|device_status|hex2dec|||
2|version_major|hex2dec|||
2|version_minor|hex2dec|||
2|version_patch|hex2dec|||
22|reserved|skip|||
```
---
## Lecture cote serveur (exemple Python)
```python
# Byte 66 — erreurs systeme
error_flags = int(parsed_error_flags)
rtc_disconnected = bool(error_flags & 0x01)
rtc_reset = bool(error_flags & 0x02)
bme280_error = bool(error_flags & 0x04)
npm_error = bool(error_flags & 0x08)
envea_error = bool(error_flags & 0x10)
noise_error = bool(error_flags & 0x20)
mppt_error = bool(error_flags & 0x40)
wind_error = bool(error_flags & 0x80)
# Byte 67 — status NextPM
npm_status = int(parsed_npm_status)
npm_sleep = bool(npm_status & 0x01)
npm_degraded = bool(npm_status & 0x02)
npm_not_ready = bool(npm_status & 0x04)
npm_heat_err = bool(npm_status & 0x08)
npm_trh_err = bool(npm_status & 0x10)
npm_fan_err = bool(npm_status & 0x20)
npm_mem_err = bool(npm_status & 0x40)
npm_laser_err = bool(npm_status & 0x80)
# Byte 68 — status device
device_status = int(parsed_device_status)
sara_rebooted = bool(device_status & 0x01)
wifi_connected = bool(device_status & 0x02)
hotspot_active = bool(device_status & 0x04)
gps_no_fix = bool(device_status & 0x08)
battery_low = bool(device_status & 0x10)
disk_full = bool(device_status & 0x20)
db_error = bool(device_status & 0x40)
boot_recent = bool(device_status & 0x80)
# Alertes
if rtc_disconnected:
alert("RTC module deconnecte — verifier pile/cables I2C")
if npm_fan_err:
alert("NextPM: ventilateur hors plage — maintenance requise")
if npm_laser_err:
alert("NextPM: possible erreur laser — verifier le capteur")
if battery_low:
alert("Batterie faible — verifier alimentation solaire")
if disk_full:
alert("Espace disque critique — verifier logs/DB")
if sara_rebooted:
alert("Modem reboot hardware au cycle precedent — instabilite reseau")
```
---
## Notes
- Les bytes 66-68 sont initialises a 0x00 dans le constructeur SensorPayload
(0x00 = aucune erreur/aucun flag). Les bytes 69-71 restent a 0xFF si le
fichier VERSION est absent ou malformed.
- Le NPM status n'est pas encore lu par `get_data_modbus_v3.py`. Il faut d'abord
ajouter la lecture du registre de status Modbus et le stocker en SQLite.
- Les flags du byte 66 sont determines par le script d'envoi en analysant les
valeurs lues depuis SQLite (toutes a 0 = capteur en erreur).

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
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
cat > /etc/systemd/system/nebuleair-db-cleanup-data.service << 'EOL'
[Unit]
@@ -237,17 +269,79 @@ AccuracySec=1h
WantedBy=timers.target
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
systemctl daemon-reload
# Enable and start all timers
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 start nebuleair-$service-data.timer
echo "Started nebuleair-$service-data timer"
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..."
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

@@ -67,10 +67,18 @@ CREATE TABLE IF NOT EXISTS data_NPM (
PM25 REAL,
PM10 REAL,
temp_npm REAL,
hum_npm REAL
hum_npm REAL,
npm_status INTEGER DEFAULT 0
)
""")
# Add npm_status column to existing databases (migration)
try:
cursor.execute("ALTER TABLE data_NPM ADD COLUMN npm_status INTEGER DEFAULT 0")
print("Added npm_status column to data_NPM")
except:
pass # Column already exists
# Create a table BME280
cursor.execute("""
CREATE TABLE IF NOT EXISTS data_BME280 (
@@ -136,6 +144,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
conn.commit()
conn.close()

View File

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

View File

@@ -41,17 +41,21 @@ config_entries = [
("SARA_R4_network_status", "connected", "str"),
("SARA_R4_neworkID", "20810", "int"),
("WIFI_status", "connected", "str"),
("send_aircarto", "1", "bool"),
("send_aircarto", "0", "bool"),
("send_uSpot", "0", "bool"),
("send_miotiq", "0", "bool"),
("send_miotiq", "1", "bool"),
("npm_5channel", "0", "bool"),
("envea", "0", "bool"),
("windMeter", "0", "bool"),
("BME280", "0", "bool"),
("BME280", "1", "bool"),
("MPPT", "0", "bool"),
("NOISE", "0", "bool"),
("MHZ19", "0", "bool"),
("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:
@@ -103,6 +107,18 @@ for connected, port, name, coefficient in envea_sondes:
print(f"Envea sonde '{name}' already exists, skipping")
# Database migrations (add columns to existing tables)
migrations = [
("data_NPM", "npm_status", "INTEGER DEFAULT 0"),
]
for table, column, col_type in migrations:
try:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
print(f"Migration: added column '{column}' to {table}")
except:
pass # Column already exists
# Commit and close the connection
conn.commit()
conn.close()

View File

@@ -57,6 +57,11 @@ fi
git pull origin $(git branch --show-current)
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
print_status ""
print_status "Step 2: Updating database configuration..."
@@ -73,8 +78,35 @@ 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/envea/*.py
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"
# Step 3b: Ensure Apache/PHP config allows file uploads
APACHE_MAIN_CONF="/etc/apache2/apache2.conf"
if grep -q '<Directory /var/www/>' "$APACHE_MAIN_CONF"; then
if ! sed -n '/<Directory \/var\/www\/>/,/<\/Directory>/p' "$APACHE_MAIN_CONF" | grep -q "AllowOverride All"; then
sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' "$APACHE_MAIN_CONF"
print_status "✓ AllowOverride All enabled for Apache"
APACHE_CHANGED=true
fi
fi
PHP_INI=$(php -r "echo php_ini_loaded_file();" 2>/dev/null)
if [ -n "$PHP_INI" ]; then
CURRENT_UPLOAD=$(grep -oP 'upload_max_filesize = \K\d+' "$PHP_INI" 2>/dev/null)
if [ -n "$CURRENT_UPLOAD" ] && [ "$CURRENT_UPLOAD" -lt 50 ]; then
sed -i 's/upload_max_filesize = .*/upload_max_filesize = 50M/' "$PHP_INI"
sed -i 's/post_max_size = .*/post_max_size = 55M/' "$PHP_INI"
print_status "✓ PHP upload limits set to 50M"
APACHE_CHANGED=true
fi
fi
if [ "${APACHE_CHANGED:-false}" = true ]; then
systemctl reload apache2 2>/dev/null
print_status "✓ Apache reloaded"
fi
# Step 4: Restart critical services if they exist
print_status ""
print_status "Step 4: Managing system services..."
@@ -87,6 +119,7 @@ services=(
"nebuleair-bme280-data.timer"
"nebuleair-mppt-data.timer"
"nebuleair-noise-data.timer"
"nebuleair-wifi-powersave.timer"
)
for service in "${services[@]}"; do

View File

@@ -0,0 +1,211 @@
#!/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 4b: Ensure Apache/PHP config allows file uploads (.htaccess + php.ini)
APACHE_MAIN_CONF="/etc/apache2/apache2.conf"
if grep -q '<Directory /var/www/>' "$APACHE_MAIN_CONF"; then
if ! sed -n '/<Directory \/var\/www\/>/,/<\/Directory>/p' "$APACHE_MAIN_CONF" | grep -q "AllowOverride All"; then
sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' "$APACHE_MAIN_CONF"
print_status "✓ AllowOverride All enabled for Apache"
APACHE_CHANGED=true
fi
fi
PHP_INI=$(php -r "echo php_ini_loaded_file();" 2>/dev/null)
if [ -n "$PHP_INI" ]; then
CURRENT_UPLOAD=$(grep -oP 'upload_max_filesize = \K\d+' "$PHP_INI" 2>/dev/null)
if [ -n "$CURRENT_UPLOAD" ] && [ "$CURRENT_UPLOAD" -lt 50 ]; then
sed -i 's/upload_max_filesize = .*/upload_max_filesize = 50M/' "$PHP_INI"
sed -i 's/post_max_size = .*/post_max_size = 55M/' "$PHP_INI"
print_status "✓ PHP upload limits set to 50M"
APACHE_CHANGED=true
fi
fi
if [ "${APACHE_CHANGED:-false}" = true ]; then
systemctl reload apache2 2>/dev/null
print_status "✓ Apache reloaded"
fi
# 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())