367 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
PaulVua
a3b2bef5c1 Improve database page layout and highlight most recent data
Layout improvements:
- Reorganized cards to fit 3 across on large screens (col-lg-4)
- View database, download data, and danger zone now on same row
- Added h-100 class to cards for equal height
- Made delete button larger and full-width for better visibility

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 16:42:43 +01:00
PaulVua
fe604791f0 Enable i18n language switching on all pages and hide incomplete features
Added i18n.js script to all main pages (index, database, saraR4, wifi, logs, admin) to enable language switching functionality across the entire application. Commented out Map and Terminal menu items in the sidebar as these pages are not yet ready for production use.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 16:38:28 +01:00
PaulVua
624fb4abbc Add sidebar i18n support and fix language selector visibility
Extended the internationalization system to support sidebar navigation menu with French/English translations. Fixed language selector dropdown styling to improve text visibility with proper contrast (gray background instead of transparent).

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 16:29:44 +01:00
PaulVua
163d60bf34 Implement lightweight offline i18n system with French/English support
**Core System:**
- Add i18n.js translation library with data-attribute support
- Create translation files (fr.json, en.json) with offline support
- Store language preference in SQLite config_table
- Add backend endpoints for get/set language

**UI Features:**
- Add language switcher dropdown to topbar (🇫🇷 FR / 🇬🇧 EN)
- Auto-sync language selection across all pages
- Support for static HTML and dynamically created elements

**Implementation:**
- Migrate sensors.html as working example
- Add data-i18n attributes to all UI elements
- Support for buttons, inputs, and dynamic content
- Comprehensive README documentation in html/lang/

**Technical Details:**
- Works completely offline (local JSON files)
- No external dependencies
- Database-backed user preference
- Event-based language change notifications
- Automatic translation on page load

Next steps: Gradually migrate other pages (admin, wifi, index, etc.)

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-05 18:10:06 +01:00
PaulVua
906eaa851d Fix BME280 and sensor cards not displaying
- Refactor sensor card creation to use config_table instead of non-existent config_scripts_table
- Fix BME280 card check: use config.BME280 instead of response["BME280/get_data_v2.py"]
- Fix NOISE card check: use config.NOISE instead of response.i2C_sound
- Fix Envea card check: use config.envea instead of response["envea/read_value_v2.py"]
- Create dedicated createSensorCards() function for cleaner code
- Remove obsolete get_config_scripts_sqlite AJAX call

Now sensor cards properly display based on config_table settings.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-05 17:49:02 +01:00
PaulVua
954680ef6e Refactor Claude Code settings to use template pattern
- Create .claude/settings.json as project-wide template
- Untrack .claude/settings.local.json for local overrides
- Add .claude/settings.local.json to .gitignore
- Add .claude/README.md documenting the setup

This allows:
- Project defaults versioned in git
- Local customization without conflicts
- Clean separation of shared vs personal settings

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-05 17:33:13 +01:00
PaulVua
1f4d38257e Fix WiFi setup flow and improve hotspot management
- Fix deviceID overwrite bug: preserve manual configuration across reboots
- Use deviceName as hotspot SSID for better device identification
- Implement live WiFi scanning instead of reading stale CSV data
- Improve hotspot management with dynamic connection detection
- Add database status updates on WiFi connection success/failure
- Hide password in logs for better security

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-05 17:27:37 +01:00
Your Name
a38ce79555 update 2026-01-05 15:50:35 +00:00
Your Name
62ef47aa67 update 2025-11-26 09:55:45 +00:00
Your Name
ca7533a344 update 2025-11-26 09:11:04 +00:00
Your Name
403c57bf18 update 2025-11-26 09:00:07 +00:00
Your Name
129b2de68e update 2025-11-11 13:41:44 +00:00
Your Name
d2c88e0d18 update 2025-11-11 13:23:30 +00:00
Your Name
fba5af53cb update 2025-11-05 15:57:11 +01:00
Your Name
04fbf81798 update 2025-11-05 14:50:29 +01:00
Your Name
65beead82b update 2025-11-05 14:44:30 +01:00
Your Name
26ee893a96 update 2025-11-03 18:59:09 +01:00
Your Name
5cf37c3cee update 2025-11-03 17:45:18 +01:00
root
3ecc27fd3e update 2025-11-03 17:42:39 +01:00
AirLab
072fca72cc update 2025-11-03 17:31:13 +01:00
AirLab
c038084343 update 2025-11-03 17:20:37 +01:00
AirLab
6069ab04cf update 2025-10-07 15:03:11 +02:00
AirLab
79f3ede17f Add password protection for critical transmission settings
- Add unlock/lock button for protected settings in admin panel
- Protect AirCarto, uSpot, and miotiq transmission checkboxes
- Require password '123plouf' to enable editing protected checkboxes
- Visual feedback with lock/unlock icons and toast notifications
- Add CLAUDE.md documentation file for development guidance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 14:59:38 +02:00
root
9de903f2db update 2025-10-02 15:53:14 +01:00
Your Name
77fcdaa08e update 2025-09-19 14:08:01 +02:00
Your Name
1fca3091eb update 2025-09-18 16:46:25 +01:00
Your Name
d0b49bf30c update 2025-09-18 16:45:08 +01:00
Your Name
4779f426d9 update 2025-09-18 16:43:49 +01:00
Your Name
9aab95edb6 update 2025-09-09 09:47:45 +02:00
root
fe61b56b5b update 2025-07-22 16:38:41 +01:00
Your Name
25c5a7a65a update 2025-07-22 15:36:36 +02:00
root
4d512685a0 update 2025-07-22 10:39:13 +02:00
root
44b2e2189d update 2025-07-21 12:22:56 +01:00
root
74fc3baece update 2025-07-21 11:11:09 +01:00
Your Name
0539cb67af update 2025-07-02 08:25:29 +01:00
Your Name
98115ab22b update 2025-07-02 08:01:41 +01:00
Your Name
2989a7a9ed update 2025-06-30 15:10:29 +01:00
Your Name
aa458fbac4 update 2025-06-30 14:59:40 +01:00
707dffd6f8 Actualiser installation_part2.sh 2025-06-24 20:39:37 +00:00
c917131b2d Actualiser installation_part1.sh 2025-06-23 09:36:04 +00:00
root
057dc7d87b update 2025-06-05 16:48:38 +02:00
Your Name
fcc30243f5 update 2025-06-05 15:06:08 +02:00
Your Name
75774cea62 update 2025-06-05 12:50:45 +02:00
Your Name
3731c2b7cf update 2025-06-05 12:42:35 +02:00
Your Name
1240ebf6cd update 2025-06-04 15:54:43 +02:00
root
e27f2430b7 update 2025-05-28 16:00:02 +02:00
root
ebdc4ae353 update 2025-05-28 15:59:39 +02:00
root
6cd5191138 update 2025-05-28 15:40:53 +02:00
Your Name
8d989de425 update 2025-05-27 16:48:48 +02:00
Your Name
381cf85336 update 2025-05-27 16:42:53 +02:00
root
caf5488b06 update 2025-05-27 12:09:34 +02:00
root
5d4f7225b0 update 2025-05-26 14:59:18 +02:00
Your Name
6d997ff550 update_firmware.sh 2025-05-26 09:51:31 +02:00
Your Name
aa71e359bb update 2025-05-26 09:48:55 +02:00
Your Name
7bd1d81bf9 update 2025-05-26 09:34:07 +02:00
Your Name
4bc0dc2acc update 2025-05-26 09:24:47 +02:00
Your Name
694edfaf27 update 2025-05-23 17:52:15 +02:00
Your Name
93d77db853 update 2025-05-23 17:49:03 +02:00
Your Name
122763a4e5 update 2025-05-23 17:43:45 +02:00
Your Name
c6a8b02c38 update 2025-05-23 17:22:37 +02:00
Your Name
b93f205fd4 update 2025-05-23 16:22:32 +02:00
Your Name
8fdd1d6ac5 update 2025-05-23 16:07:00 +02:00
Your Name
6796aa95bb update 2025-05-23 16:03:28 +02:00
Your Name
020594e065 updates 2025-05-23 15:09:22 +02:00
PaulVua
5a1a4e0d81 updates 2025-05-23 14:38:32 +02:00
PaulVua
3cd5b13c25 updates 2025-05-23 14:31:23 +02:00
PaulVua
5a0f1c0745 updates 2025-05-23 14:30:18 +02:00
PaulVua
2516a3bd1c updates 2025-05-23 14:08:21 +02:00
PaulVua
1b8dc54fe0 updates 2025-05-23 14:03:57 +02:00
PaulVua
2bd74ca91a updates 2025-05-23 11:02:06 +02:00
PaulVua
f40c105abf updates 2025-05-23 10:48:41 +02:00
Your Name
fdef8e2df0 update 2025-05-20 11:57:55 +02:00
Your Name
386ad6fb03 update 2025-05-20 11:24:38 +02:00
Your Name
a7c138e93f update 2025-05-20 11:23:43 +02:00
Your Name
4e4832b128 update 2025-05-20 10:43:27 +02:00
Your Name
11463b175c update 2025-05-20 09:50:15 +02:00
Your Name
c06741b11d Merge remote-tracking branch 'refs/remotes/origin/main' 2025-05-20 09:46:46 +02:00
Your Name
b1352261e7 update 2025-05-20 09:45:59 +02:00
Your Name
376ff454bf update 2025-05-20 09:33:14 +02:00
Your Name
932fdf83a2 update 2025-05-20 09:21:34 +02:00
Your Name
1ca3e2ada2 update 2025-05-20 09:14:25 +02:00
Your Name
fd1d32a62b udpate 2025-05-19 10:26:27 +02:00
Your Name
61b302fe35 update 2025-05-16 11:08:23 +02:00
Your Name
2aaa229e82 update 2025-05-14 17:37:53 +02:00
Your Name
fd28069b0c update 2025-05-14 17:24:01 +02:00
Your Name
b17c996f2f update 2025-05-13 17:14:29 +02:00
Your Name
8273307cab update 2025-05-07 18:48:47 +02:00
Your Name
a73eb30d32 update 2025-05-07 18:25:30 +02:00
Your Name
ba889feee9 update 2025-05-06 17:30:14 +02:00
Your Name
12c7a0b6af update 2025-05-01 11:01:29 +02:00
Your Name
08c5ed8841 update 2025-04-16 09:53:54 +02:00
Your Name
7f5eb7608c update 2025-04-16 09:46:12 +02:00
Your Name
44f44c3361 update 2025-04-07 15:57:58 +02:00
Your Name
a8350332ac update 2025-04-07 11:47:21 +02:00
Your Name
6c6eed1ad6 update 2025-04-03 10:49:55 +02:00
Your Name
ee71c28d33 update 2025-04-03 09:30:54 +02:00
Your Name
6d3220665e update 2025-04-03 09:07:20 +02:00
Your Name
98e5a239f5 update 2025-04-02 16:10:27 +02:00
Your Name
17f4ce46dd update 2025-04-02 15:09:10 +02:00
Your Name
338b8a049f update 2025-04-02 14:43:18 +02:00
Your Name
1e9e80ae55 update 2025-04-01 17:35:35 +02:00
Your Name
9d280c6e37 update 2025-04-01 11:57:39 +02:00
Your Name
d4c1178b3d update 2025-03-28 14:29:51 +01:00
Your Name
f7f6fccd60 update 2025-03-27 17:13:40 +01:00
Your Name
afceb34c1b update 2025-03-27 16:43:55 +01:00
Your Name
7a958d5c8e update 2025-03-27 16:40:24 +01:00
Your Name
8fd76001f2 update 2025-03-27 16:02:56 +01:00
Your Name
e320a3bc2b update 2025-03-27 16:02:05 +01:00
Your Name
8a4e184699 update 2025-03-27 15:58:22 +01:00
Your Name
e61b0a76da update 2025-03-27 15:50:59 +01:00
Your Name
970a36598c update 2025-03-26 10:30:24 +01:00
Your Name
e75caff929 update 2025-03-26 08:29:24 +01:00
Your Name
e82d75a4d6 update 2025-03-26 08:27:28 +01:00
Your Name
dc27e5f139 update 2025-03-26 08:25:42 +01:00
Your Name
4bc05091be update 2025-03-25 21:18:12 +01:00
Your Name
29f9ec445a Merge remote-tracking branch 'refs/remotes/origin/main' 2025-03-25 20:23:01 +01:00
Your Name
7b398d0d6d update 2025-03-25 20:22:42 +01:00
PaulVua
76336d0073 update 2025-03-25 16:27:01 +01:00
PaulVua
46a8e21e64 update 2025-03-25 16:20:19 +01:00
Your Name
2129d45ef6 update 2025-03-25 14:55:50 +01:00
Your Name
6312cd8d72 update 2025-03-24 18:04:19 +01:00
Your Name
7c17ec82f5 update 2025-03-24 17:57:47 +01:00
Your Name
b7a6f4c907 add sqlite config management 2025-03-24 15:19:27 +01:00
Your Name
6b3329b9b8 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-03-24 10:23:37 +01:00
PaulVua
e9b1e0e88e Merge remote-tracking branch 'refs/remotes/origin/main' 2025-03-24 10:21:25 +01:00
Your Name
2db732ebb3 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-03-20 17:46:09 +01:00
Your Name
d5302f78ba udpate 2025-03-20 17:45:47 +01:00
Your Name
5b7de91d50 update 2025-03-20 10:54:41 +01:00
Your Name
4d15076d4b update 2025-03-20 09:56:36 +01:00
Your Name
809742b6d5 update 2025-03-20 09:49:14 +01:00
PaulVua
bca975b0c5 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-03-18 18:02:14 +01:00
PaulVua
dfba956685 update 2025-03-18 18:01:33 +01:00
Your Name
d07314262e update 2025-03-18 16:24:22 +01:00
Your Name
dffa639574 update 2025-03-18 12:08:30 +01:00
PaulVua
1fd5a3e75c update 2025-03-18 11:50:39 +01:00
Your Name
e674b21eaa update 2025-03-17 15:17:07 +01:00
Your Name
efc94ba5e1 update 2025-03-17 15:13:16 +01:00
root
26328dec99 update 2025-03-17 12:29:06 +01:00
PaulVua
ec3e81e99e update 2025-03-17 11:00:55 +01:00
Your Name
1c6af36313 update 2025-03-14 10:57:45 +01:00
Your Name
f1d6f595ac update 2025-03-14 09:28:28 +01:00
Your Name
cfc2e0c47f update 2025-03-14 08:54:35 +01:00
Your Name
1037207df3 update 2025-03-13 11:39:40 +01:00
Your Name
14044a8856 update 2025-03-12 17:55:30 +01:00
Your Name
d57a47ef68 update 2025-03-11 16:35:49 +01:00
Your Name
5e7375cd4e update 2025-03-11 15:40:22 +01:00
Your Name
c42b16ddb6 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-03-10 17:48:04 +01:00
Your Name
283a46eb0b update 2025-03-10 17:44:03 +01:00
Your Name
33b24a9f53 update 2025-03-10 15:00:00 +01:00
Your Name
10c4348e54 update 2025-03-10 13:38:02 +01:00
Your Name
072f98ef95 update 2025-03-10 12:15:05 +01:00
Your Name
7b4ff011ec update 2025-03-05 16:24:15 +01:00
Your Name
ab2124f50d update 2025-03-05 16:19:49 +01:00
Your Name
b493d30a41 update 2025-03-05 16:07:22 +01:00
Your Name
659effb7c4 update 2025-03-05 15:58:40 +01:00
Your Name
ebb0fd0a2b update 2025-03-05 09:29:53 +01:00
Your Name
5d121761e7 update 2025-03-04 13:29:02 +01:00
Your Name
d90fb14c90 update 2025-03-04 13:23:26 +01:00
Your Name
3f329e0afa update 2025-03-04 10:16:24 +01:00
Your Name
cd030a9e14 update 2025-03-04 10:06:26 +01:00
Your Name
709cad6981 update 2025-02-27 14:30:22 +01:00
Your Name
c59246e320 update 2025-02-27 12:17:13 +01:00
Your Name
1a15d70aa7 udpate 2025-02-27 12:08:54 +01:00
Your Name
bf9ece8589 update 2025-02-27 11:54:53 +01:00
Your Name
3d507ae659 update 2025-02-27 11:48:42 +01:00
Your Name
700de9c9f4 update 2025-02-27 11:07:52 +01:00
Your Name
ec6fbf6bb2 update 2025-02-26 10:01:56 +01:00
Your Name
8fecde5d56 update 2025-02-26 09:48:56 +01:00
Your Name
49e93ab3ad update 2025-02-25 15:51:43 +01:00
Your Name
e0d7614ad8 update 2025-02-25 15:41:17 +01:00
Your Name
516f6367fa update 2025-02-25 15:33:13 +01:00
Your Name
0549471669 update 2025-02-25 15:25:21 +01:00
Your Name
20a0786380 update 2025-02-25 15:15:29 +01:00
Your Name
92ec2a0bb9 update 2025-02-25 14:57:29 +01:00
Your Name
c7fb474f66 update 2025-02-25 14:40:44 +01:00
Your Name
8c5d831878 update 2025-02-25 14:32:10 +01:00
Your Name
b3f5ee9795 update 2025-02-25 13:02:34 +01:00
Your Name
4f7a704779 update 2025-02-25 12:27:48 +01:00
Your Name
b5aeafeb56 update 2025-02-25 11:59:57 +01:00
Your Name
144d904813 update 2025-02-25 11:55:44 +01:00
Your Name
e3607143a1 update 2025-02-24 17:00:12 +01:00
Your Name
7e8bf1294c update 2025-02-24 16:17:13 +01:00
Your Name
eea1acd701 update 2025-02-20 13:34:36 +01:00
Your Name
accfd3e371 update 2025-02-20 13:13:25 +01:00
PaulVua
dfbae99ba5 update 2025-02-20 09:58:40 +01:00
PaulVua
4c4c6ce77e update 2025-02-20 09:45:26 +01:00
PaulVua
c4fb7aed72 update 2025-02-20 09:41:56 +01:00
PaulVua
cee6c7f79b update 2025-02-19 13:29:53 +01:00
Your Name
8fb1882864 update 2025-02-18 13:46:55 +01:00
PaulVua
67fcd78aac update 2025-02-18 12:16:53 +01:00
PaulVua
727fa4cfeb update 2025-02-18 12:12:48 +01:00
PaulVua
6622be14ad update 2025-02-18 12:05:43 +01:00
PaulVua
9774215e7c update 2025-02-11 14:55:10 +01:00
PaulVua
ecd61f765a update 2025-02-11 10:06:44 +01:00
PaulVua
62c729b63b update 2025-02-10 17:25:34 +01:00
PaulVua
e609c38ca0 update 2025-02-10 15:41:52 +01:00
PaulVua
1cb1b05b51 update 2025-02-10 10:06:16 +01:00
PaulVua
7cac769795 update 2025-02-07 15:04:11 +01:00
PaulVua
fb44b57ac1 update 2025-02-07 13:31:07 +01:00
PaulVua
d98eb48535 update 2025-02-05 17:37:27 +01:00
PaulVua
46303b9c19 update 2025-02-05 16:54:59 +01:00
PaulVua
49be391eb3 database 2025-02-05 15:09:15 +01:00
PaulVua
268a0586b8 update 2025-02-05 10:51:20 +01:00
PaulVua
7de382a43d update 2025-02-04 18:48:42 +01:00
PaulVua
c3e2866fab update 2025-02-04 18:36:58 +01:00
PaulVua
a90552148c update 2025-02-04 18:00:01 +01:00
Your Name
c6073b49b9 update 2025-02-03 13:27:21 +01:00
PaulVua
aaeb20aece update 2025-02-03 10:50:03 +01:00
Your Name
10cc46a079 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-01-31 17:35:12 +01:00
Your Name
b8150535e8 update 2025-01-31 17:34:48 +01:00
PaulVua
ef2bd6b895 update 2025-01-31 17:31:14 +01:00
Your Name
456db5da98 update 2025-01-31 13:38:26 +01:00
Your Name
c15838fc47 update 2025-01-30 17:33:16 +01:00
PaulVua
d732f3ad2d update 2025-01-30 17:24:47 +01:00
PaulVua
437be5cad9 update 2025-01-30 13:51:49 +01:00
PaulVua
4f59928b1e update 2025-01-30 13:46:22 +01:00
Your Name
a689a7d2de update 2025-01-30 12:11:52 +01:00
PaulVua
970f62658b update 2025-01-30 11:58:59 +01:00
PaulVua
6e0dc1b257 rtc udpate 2025-01-30 11:52:43 +01:00
Your Name
578721a9f2 add RTC 2025-01-30 11:42:02 +01:00
PaulVua
a8ca15505e update 2025-01-29 16:22:20 +01:00
Your Name
280dcd9be3 update 2025-01-29 10:28:55 +01:00
Your Name
4a5e0b3577 update 2025-01-29 10:04:01 +01:00
Your Name
d095e53cd6 update 2025-01-28 18:14:55 +01:00
Your Name
083d342373 update 2025-01-27 14:09:14 +01:00
Your Name
c8b9cb46f6 update 2025-01-24 15:21:57 +01:00
Your Name
4123f977b2 update 2025-01-24 14:35:05 +01:00
PaulVua
833ed458a7 update 2025-01-24 10:43:34 +01:00
PaulVua
155a2bd453 update 2025-01-23 18:03:11 +01:00
PaulVua
660af80ab0 update 2025-01-23 16:56:36 +01:00
Your Name
8596690f32 update 2025-01-23 16:44:43 +01:00
PaulVua
bd902a0c46 update 2025-01-23 16:16:42 +01:00
PaulVua
545f5f8f3a update 2025-01-23 14:30:28 +01:00
PaulVua
0fb7118abb update 2025-01-23 14:28:44 +01:00
PaulVua
aa3b4d238b update 2025-01-23 14:19:46 +01:00
Your Name
838d6d7357 update 2025-01-23 11:35:51 +01:00
Your Name
806576b8b8 update 2025-01-23 11:24:42 +01:00
PaulVua
2650954ecc update 2025-01-22 17:22:09 +01:00
Your Name
78339fa585 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-01-22 13:46:32 +01:00
Your Name
3ea7b16b1a update 2025-01-22 13:45:47 +01:00
151 changed files with 51447 additions and 1890 deletions

21
.claude/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Claude Code Settings
This directory contains configuration for Claude Code.
## Files
- **settings.json**: Project-wide default settings (tracked in git)
- **settings.local.json**: Local user-specific overrides (not tracked in git)
## Usage
The `settings.json` file contains the default configuration that applies to all developers/devices. If you need to customize settings for your local environment, create a `settings.local.json` file which will override the defaults.
### Example: Create local overrides
```bash
cp .claude/settings.json .claude/settings.local.json
# Edit settings.local.json with your preferences
```
Your local changes will not be committed to git.

9
.claude/settings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(python3:*)"
],
"deny": []
},
"enableAllProjectMcpServers": false
}

16
.gitignore vendored
View File

@@ -1,4 +1,4 @@
logs/app.log
logs/*.log
logs/loop.log
deviceID.txt
loop/loop.log
@@ -9,3 +9,17 @@ config.json
sound_meter/moving_avg_minute.txt
wifi_list.csv
envea/data/*.txt
envea/data/*.json
NPM/data/*.txt
NPM/data/*.json
*.lock
sqlite/*.db
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

74
BME280/get_data_v2.py Executable file
View File

@@ -0,0 +1,74 @@
'''
____ __ __ _____ ____ ___ ___
| __ )| \/ | ____|___ \( _ ) / _ \
| _ \| |\/| | _| __) / _ \| | | |
| |_) | | | | |___ / __/ (_) | |_| |
|____/|_| |_|_____|_____\___/ \___/
Script to read data from BME280
Sensor connected to i2c on address 76 (use sudo i2cdetect -y 1 to get the address )
-> save data to database (table data_BME280 )
sudo python3 /var/www/nebuleair_pro_4g/BME280/get_data_v2.py
'''
import board
import busio
import json
import sqlite3
from adafruit_bme280 import basic as adafruit_bme280
# Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
# Create I2C bus
i2c = busio.I2C(board.SCL, board.SDA)
bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x76)
# Configure settings
bme280.sea_level_pressure = 1013.25 # Update this value for your location
# Read sensor data
#print(f"Temperature: {bme280.temperature:.2f} °C")
#print(f"Humidity: {bme280.humidity:.2f} %")
#print(f"Pressure: {bme280.pressure:.2f} hPa")
#print(f"Altitude: {bme280.altitude:.2f} m")
temperature = round(bme280.temperature, 2)
humidity = round(bme280.humidity, 2)
pressure = round(bme280.pressure, 2)
sensor_data = {
"temp": temperature, # Temperature in °C
"hum": humidity, # Humidity in %
"press": pressure # Pressure in hPa
}
#GET RTC TIME from SQlite
cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone() # Get the first (and only) row
rtc_time_str = row[1] # '2025-02-07 12:30:45'
# Convert to JSON and print
#print(json.dumps(sensor_data, indent=4))
#save to sqlite database
try:
cursor.execute('''
INSERT INTO data_BME280 (timestamp,temperature, humidity, pressure) VALUES (?,?,?,?)'''
, (rtc_time_str,temperature,humidity,pressure))
# Commit and close the connection
conn.commit()
#print("Sensor data saved successfully!")
except Exception as e:
print(f"Database error: {e}")
conn.close()

View File

@@ -1,5 +1,5 @@
# Script to read data from BME280
# Sensor connected to i2c on address 77 (use sudo i2cdetect -y 1 to get the address )
# Sensor connected to i2c on address 76 (use sudo i2cdetect -y 1 to get the address )
# sudo python3 /var/www/nebuleair_pro_4g/BME280/read.py
import board

270
CLAUDE.md Normal file
View File

@@ -0,0 +1,270 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
NebuleAir Pro 4G is an environmental monitoring system running on Raspberry Pi 4/CM4. It collects air quality and environmental data from multiple sensors and transmits it via 4G cellular modem. The system includes a self-hosted web interface for configuration and monitoring.
## Architecture
### Data Flow
1. **Data Collection**: Sensors are polled by individual Python scripts triggered by systemd timers
2. **Local Storage**: All sensor data is stored in SQLite database (`sqlite/sensors.db`)
3. **Data Transmission**: Main loop script reads aggregated data from SQLite and transmits via SARA R4/R5 4G modem
4. **Web Interface**: Apache serves PHP pages that interact with SQLite and execute Python scripts
### Key Components
**Sensors & Hardware:**
- NextPM (NPM): Particulate matter sensor via Modbus (ttyAMA0)
- Envea CAIRSENS: Gas sensors (NO2, H2S, NH3, CO, O3) via serial (ttyAMA2-5)
- BME280: Temperature, humidity, pressure via I2C (0x76)
- NSRT MK4: Noise sensor via I2C (0x48)
- SARA R4/R5: 4G cellular modem (ttyAMA2)
- Wind meter: via ADS1115 ADC
- MPPT: Solar charger monitoring
**Software Stack:**
- OS: Raspberry Pi OS (Linux)
- Web server: Apache2
- Database: SQLite3
- Languages: Python 3, PHP, Bash
- Network: NetworkManager (nmcli)
### Directory Structure
- `NPM/`: NextPM sensor scripts
- `envea/`: Envea sensor scripts
- `BME280/`: BME280 sensor scripts
- `sound_meter/`: Noise sensor code (C program)
- `SARA/`: 4G modem communication (AT commands)
- `windMeter/`: Wind sensor scripts
- `MPPT/`: Solar charger scripts
- `RTC/`: DS3231 real-time clock module scripts
- `sqlite/`: Database scripts (create, read, write, config)
- `loop/`: Main data transmission script
- `html/`: Web interface files
- `services/`: Systemd service/timer configuration
- `logs/`: Application logs
## Common Development Commands
### Database Operations
```bash
# Initialize database
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/create_db.py
# Set configuration
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/set_config.py
# Read data from specific table
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/read.py <table_name> <limit>
```
### Systemd Services
The system uses systemd timers for scheduling sensor data collection:
```bash
# View all NebuleAir timers
systemctl list-timers | grep nebuleair
# Check specific service status
systemctl status nebuleair-npm-data.service
systemctl status nebuleair-sara-data.service
# View service logs
journalctl -u nebuleair-npm-data.service
journalctl -u nebuleair-sara-data.service -f # follow
# Restart services
systemctl restart nebuleair-npm-data.timer
systemctl restart nebuleair-sara-data.timer
# Setup all services
sudo /var/www/nebuleair_pro_4g/services/setup_services.sh
```
**Service Schedule:**
- `nebuleair-npm-data.timer`: Every 10 seconds (NextPM sensor)
- `nebuleair-envea-data.timer`: Every 10 seconds (Envea sensors)
- `nebuleair-sara-data.timer`: Every 60 seconds (4G data transmission)
- `nebuleair-bme280-data.timer`: Every 120 seconds (BME280 sensor)
- `nebuleair-mppt-data.timer`: Every 120 seconds (MPPT charger)
- `nebuleair-noise-data.timer`: Every 60 seconds (Noise sensor)
- `nebuleair-db-cleanup-data.timer`: Daily (database cleanup)
### Sensor Testing
```bash
# Test NextPM sensor
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_modbus_v3.py
# Test Envea sensors (read reference/ID)
/usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_ref_v2.py
# Test Envea sensor values
/usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_value_v2.py
# Test BME280
/usr/bin/python3 /var/www/nebuleair_pro_4g/BME280/get_data_v2.py
# Test noise sensor
/usr/bin/python3 /var/www/nebuleair_pro_4g/sound_meter/NSRT_mk4_get_data.py
# Test RTC module
/usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/read.py
# Test MPPT charger
/usr/bin/python3 /var/www/nebuleair_pro_4g/MPPT/read.py
```
### 4G Modem (SARA R4/R5)
```bash
# Send AT command
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 "AT+CSQ" 5
# Check network connection
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_connectNetwork.py ttyAMA2 <networkID> 120
# Set server URL (HTTP profile 0 for AirCarto)
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setURL.py ttyAMA2 data.nebuleair.fr 0
# Check modem status
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/check_running.py
```
### Network Configuration
```bash
# Scan WiFi networks (saved to wifi_list.csv)
nmcli device wifi list ifname wlan0
# Connect to WiFi
sudo nmcli device wifi connect "SSID" password "PASSWORD"
# Create hotspot (done automatically at boot if no connection)
sudo nmcli device wifi hotspot ifname wlan0 ssid nebuleair_pro password nebuleaircfg
# Check connection status
nmcli device show wlan0
nmcli device show eth0
```
### Permissions & Serial Ports
```bash
# Grant serial port access (run at boot via cron)
chmod 777 /dev/ttyAMA* /dev/i2c-1
# Check I2C devices
sudo i2cdetect -y 1
```
### Logs
```bash
# View main application log
tail -f /var/www/nebuleair_pro_4g/logs/app.log
# View SARA transmission log
tail -f /var/www/nebuleair_pro_4g/logs/sara_service.log
# View service-specific logs
tail -f /var/www/nebuleair_pro_4g/logs/npm_service.log
tail -f /var/www/nebuleair_pro_4g/logs/envea_service.log
# Clear logs (done daily via cron)
find /var/www/nebuleair_pro_4g/logs -name "*.log" -type f -exec truncate -s 0 {} \;
```
## Configuration System
Configuration is stored in SQLite database (`sqlite/sensors.db`) in the `config_table`:
**Key Configuration Parameters:**
- `deviceID`: Unique device identifier (string)
- `modem_config_mode`: When true, disables data transmission loop (bool)
- `modem_version`: SARA R4 or R5 (string)
- `SaraR4_baudrate`: Modem baudrate, usually 115200 or 9600 (int)
- `SARA_R4_neworkID`: Cellular network ID for connection (int)
- `send_miotiq`: Enable UDP transmission to Miotiq server (bool)
- `send_aircarto`: Enable HTTP transmission to AirCarto server (bool)
- `send_uSpot`: Enable HTTPS transmission to uSpot server (bool)
- `npm_5channel`: Enable 5-channel particle size distribution (bool)
- `envea`: Enable Envea gas sensors (bool)
- `windMeter`: Enable wind meter (bool)
- `BME280`: Enable BME280 sensor (bool)
- `MPPT`: Enable MPPT charger monitoring (bool)
- `NOISE`: Enable noise sensor (bool)
- `latitude_raw`, `longitude_raw`: GPS coordinates (float)
Configuration can be updated via web interface (launcher.php) or Python scripts in `sqlite/`.
## Data Transmission Protocols
### 1. UDP to Miotiq (Binary Protocol)
- 100-byte fixed binary payload via UDP socket
- Server: 192.168.0.20:4242
- Format: Device ID (8 bytes) + signal quality + sensor data (all packed as binary)
### 2. HTTP POST to AirCarto
- CSV payload sent as file attachment
- URL: `data.nebuleair.fr/pro_4G/data.php?sensor_id={id}&datetime={timestamp}`
- Uses SARA HTTP client (AT+UHTTPC profile 0)
### 3. HTTPS POST to uSpot
- JSON payload with SSL/TLS
- URL: `api-prod.uspot.probesys.net/nebuleair?token=2AFF6dQk68daFZ` (port 443)
- Uses SARA HTTPS client with certificate validation (AT+UHTTPC profile 1)
## Important Implementation Notes
### SARA R4/R5 Modem Communication
- The main transmission script (`loop/SARA_send_data_v2.py`) handles complex error recovery
- Error codes from AT+UHTTPER command trigger specific recovery actions:
- Error 4: Invalid hostname → Reset HTTP profile URL
- Error 11: Server connection error → Hardware modem reboot
- Error 22: PDP connection issue → Reset PSD/CSD connection (R5 only)
- Error 26/44: Timeout/connection lost → Send WiFi notification
- Error 73: SSL error → Re-import certificate and reset HTTPS profile
- Modem hardware reboot uses GPIO 16 (transistor control to cut power)
- Script waits 2 minutes after system boot before executing
### Serial Communication
- All UART ports must be enabled in `/boot/firmware/config.txt`
- Permissions reset after reboot, must be re-granted via cron @reboot
- Read functions use custom timeout logic to handle slow modem responses
- Special handling for multi-line AT command responses using `wait_for_lines` parameter
### Time Synchronization
- DS3231 RTC module maintains time when no network available
- RTC timestamp stored in SQLite (`timestamp_table`)
- Script compares server datetime (from HTTP headers) with RTC
- Auto-updates RTC if difference > 60 seconds
- Handles RTC reset condition (year 2000) and disconnection
### LED Indicators
- GPIO 23 (blue LED): Successful data transmission
- GPIO 24 (red LED): Transmission errors
- Uses thread locking to prevent GPIO conflicts
### Web Interface
- Apache DocumentRoot set to `/var/www/nebuleair_pro_4g`
- `html/launcher.php` provides REST-like API for all operations
- Uses shell_exec() to run Python scripts with proper sudo permissions
- Configuration updates modify SQLite database, not JSON files
### Security Considerations
- www-data user has sudo access to specific commands (defined in /etc/sudoers)
- Terminal command execution in launcher.php has whitelist of allowed commands
- No sensitive credentials should be committed (all in database/config files)
## Boot Sequence
1. Grant serial/I2C permissions (cron @reboot)
2. Check WiFi connection, start hotspot if needed (`boot_hotspot.sh`)
3. Start SARA modem initialization (`SARA/reboot/start.py`)
4. Systemd timers begin sensor data collection
5. SARA transmission loop begins after 2-minute delay
## Cron Jobs
Located in `/var/www/nebuleair_pro_4g/cron_jobs`:
- @reboot: Permissions setup, hotspot check, SARA initialization
- Daily 00:00: Truncate log files

40
GPIO/control.py Normal file
View File

@@ -0,0 +1,40 @@
'''
____ ____ ___ ___
/ ___| _ \_ _/ _ \
| | _| |_) | | | | |
| |_| | __/| | |_| |
\____|_| |___\___/
script to control GPIO output
GPIO 16 -> SARA 5V
GPIO 20 -> SARA PWR ON
option 1:
CLI tool like pinctrl
pinctrl set 16 op
pinctrl set 16 dh
pinctrl set 16 dl
option 2:
python library RPI.GPIO
/usr/bin/python3 /var/www/nebuleair_pro_4g/GPIO/control.py
'''
import RPi.GPIO as GPIO
import time
selected_GPIO = 16
GPIO.setmode(GPIO.BCM) # Use BCM numbering
GPIO.setup(selected_GPIO, GPIO.OUT) # Set GPIO17 as an output
while True:
GPIO.output(selected_GPIO, GPIO.HIGH) # Turn ON
time.sleep(1) # Wait 1 sec
GPIO.output(selected_GPIO, GPIO.LOW) # Turn OFF
time.sleep(1) # Wait 1 sec

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()

282
MPPT/read.py Normal file
View File

@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""
__ __ ____ ____ _____
| \/ | _ \| _ \_ _|
| |\/| | |_) | |_) || |
| | | | __/| __/ | |
|_| |_|_| |_| |_|
MPPT Chargeur solaire Victron interface UART
MPPT connections
5V / Rx / TX / GND
RPI connection
-- / GPIO9 / GPIO8 / GND
* pas besoin de connecter le 5V (le GND uniquement)
Fixed version - properly handles continuous data stream
"""
import serial
import time
import sqlite3
import os
# ===== LOGGING CONFIGURATION =====
# Set to True to enable all print statements, False to run silently
DEBUG_MODE = False
# Alternative: Use environment variable (can be set in systemd service)
# DEBUG_MODE = os.environ.get('MPPT_DEBUG', 'false').lower() == 'true'
# Alternative: Check if running under systemd
# DEBUG_MODE = os.isatty(1) # True if running in terminal, False if systemd/cron
# Alternative: Use different log levels
# LOG_LEVEL = "ERROR" # Options: "DEBUG", "INFO", "ERROR", "NONE"
# =================================
# Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
# Logging function
def log(message, level="INFO"):
"""Print message only if DEBUG_MODE is True"""
if DEBUG_MODE:
print(message)
# Alternative: could write to a log file instead
# with open('/var/log/mppt.log', 'a') as f:
# f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} [{level}] {message}\n")
def read_vedirect(port='/dev/ttyAMA4', baudrate=19200, timeout=10):
"""
Read and parse data from Victron MPPT controller
Returns parsed data as a dictionary
"""
required_keys = ['V', 'I', 'VPV', 'PPV', 'CS'] # Essential keys we need
try:
log(f"Opening serial port {port} at {baudrate} baud...")
ser = serial.Serial(port, baudrate, timeout=1)
# Clear any buffered data
ser.reset_input_buffer()
time.sleep(0.5)
# Initialize data dictionary
data = {}
start_time = time.time()
lines_read = 0
blocks_seen = 0
while time.time() - start_time < timeout:
try:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if not line:
continue
lines_read += 1
# Check if this line contains tab-separated key-value pair
if '\t' in line:
parts = line.split('\t', 1)
if len(parts) == 2:
key, value = parts
data[key] = value
log(f"{key}: {value}")
# Check for checksum line (end of block)
elif line.startswith('Checksum'):
blocks_seen += 1
log(f"--- End of block {blocks_seen} ---")
# Check if we have all required keys
missing_keys = [key for key in required_keys if key not in data]
if not missing_keys:
log(f"✓ Complete data block received after {lines_read} lines!")
ser.close()
return data
else:
log(f"Block {blocks_seen} incomplete, missing: {', '.join(missing_keys)}")
# Don't clear data, maybe we missed the beginning of first block
if blocks_seen > 1:
# If we've seen multiple blocks and still missing data,
# something is wrong
log("Multiple incomplete blocks, clearing data...")
data = {}
except UnicodeDecodeError as e:
log(f"Decode error: {e}", "ERROR")
continue
except Exception as e:
log(f"Error reading line: {e}", "ERROR")
continue
# Timeout reached
log(f"Timeout after {timeout}s, read {lines_read} lines, saw {blocks_seen} blocks")
ser.close()
# If we have some data but not all required keys, return what we have
if data and len(data) >= len(required_keys) - 1:
log("Returning partial data...")
return data
except serial.SerialException as e:
log(f"Serial port error: {e}", "ERROR")
except Exception as e:
log(f"Unexpected error: {e}", "ERROR")
return None
def parse_values(data):
"""Convert string values to appropriate types"""
if not data:
return None
parsed = {}
# Define conversions for each key
conversions = {
'PID': str,
'FW': int,
'SER#': str,
'V': lambda x: float(x)/1000, # Convert mV to V
'I': lambda x: float(x)/1000, # Convert mA to A
'VPV': lambda x: float(x)/1000 if x != '---' else 0, # Convert mV to V
'PPV': int,
'CS': int,
'MPPT': int,
'OR': str,
'ERR': int,
'LOAD': str,
'IL': lambda x: float(x)/1000, # Convert mA to A
'H19': float, # Total energy absorbed in kWh (already in kWh)
'H20': float, # Total energy discharged in kWh
'H21': int, # Maximum power today (W)
'H22': float, # Energy generated today (kWh)
'H23': int, # Maximum power yesterday (W)
'HSDS': int # Day sequence number
}
# Convert values according to their type
for key, value in data.items():
if key in conversions:
try:
parsed[key] = conversions[key](value)
except (ValueError, TypeError) as e:
log(f"Conversion error for {key}={value}: {e}", "ERROR")
parsed[key] = value # Keep as string if conversion fails
else:
parsed[key] = value
return parsed
def get_charger_status(cs_value):
"""Convert CS numeric value to human-readable status"""
status_map = {
0: "Off",
2: "Fault",
3: "Bulk",
4: "Absorption",
5: "Float",
6: "Storage",
7: "Equalize",
9: "Inverting",
11: "Power supply",
245: "Starting-up",
247: "Repeated absorption",
252: "External control"
}
return status_map.get(cs_value, f"Unknown ({cs_value})")
def get_mppt_status(mppt_value):
"""Convert MPPT value to human-readable status"""
mppt_map = {
0: "Off",
1: "Voltage or current limited",
2: "MPP Tracker active"
}
return mppt_map.get(mppt_value, f"Unknown ({mppt_value})")
if __name__ == "__main__":
log("=== Victron MPPT Reader ===")
log(f"Started at: {time.strftime('%Y-%m-%d %H:%M:%S')}")
# Read data
raw_data = read_vedirect()
if raw_data:
# Parse data
parsed_data = parse_values(raw_data)
if parsed_data:
# Display summary
log("\n===== MPPT Status Summary =====")
log(f"Product: {parsed_data.get('PID', 'Unknown')} (FW: {parsed_data.get('FW', '?')})")
log(f"Serial: {parsed_data.get('SER#', 'Unknown')}")
log(f"\nBattery: {parsed_data.get('V', 0):.2f}V, {parsed_data.get('I', 0):.2f}A")
log(f"Solar Panel: {parsed_data.get('VPV', 0):.2f}V, {parsed_data.get('PPV', 0)}W")
log(f"Charger Status: {get_charger_status(parsed_data.get('CS', 0))}")
log(f"MPPT Status: {get_mppt_status(parsed_data.get('MPPT', 0))}")
log(f"Load Output: {parsed_data.get('LOAD', 'Unknown')}, {parsed_data.get('IL', 0):.2f}A")
log(f"\nToday's Energy: {parsed_data.get('H22', 0)}kWh (Max: {parsed_data.get('H21', 0)}W)")
log(f"Total Energy: {parsed_data.get('H19', 0)}kWh")
# Validate critical values
battery_voltage = parsed_data.get('V', 0)
if battery_voltage > 0:
# Get timestamp
try:
cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone()
rtc_time_str = row[1] if row else time.strftime('%Y-%m-%d %H:%M:%S')
except:
rtc_time_str = time.strftime('%Y-%m-%d %H:%M:%S')
# Extract values for database
battery_current = parsed_data.get('I', 0)
solar_voltage = parsed_data.get('VPV', 0)
solar_power = parsed_data.get('PPV', 0)
charger_status = parsed_data.get('CS', 0)
# Save to database
try:
cursor.execute('''
INSERT INTO data_MPPT (timestamp, battery_voltage, battery_current, solar_voltage, solar_power, charger_status)
VALUES (?, ?, ?, ?, ?, ?)''',
(rtc_time_str, battery_voltage, battery_current, solar_voltage, solar_power, charger_status))
conn.commit()
log(f"\n✓ Data saved to database at {rtc_time_str}")
except sqlite3.Error as e:
# Always log database errors regardless of DEBUG_MODE
if not DEBUG_MODE:
print(f"Database error: {e}")
else:
log(f"\n✗ Database error: {e}", "ERROR")
conn.rollback()
else:
log("\n✗ Invalid data: Battery voltage is zero or missing", "ERROR")
else:
log("\n✗ Failed to parse data", "ERROR")
else:
log("\n✗ No valid data received from MPPT controller", "ERROR")
log("\nPossible issues:")
log("- Check serial connection (TX/RX/GND)")
log("- Verify port is /dev/ttyAMA4")
log("- Ensure MPPT is powered on")
log("- Check baudrate (should be 19200)")
# Always close the connection
conn.close()
log("\nDone.")

64
NPM/firmware_version.py Executable file
View File

@@ -0,0 +1,64 @@
'''
Script to get NPM firmware version
need parameter: port
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/firmware_version.py ttyAMA5
'''
import serial
import requests
import json
import sys
parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
port='/dev/'+parameter[0]
ser = serial.Serial(
port=port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 1
)
ser.write(b'\x81\x17\x68') #firmware version
while True:
try:
byte_data = ser.readline()
formatted = ''.join(f'\\x{byte:02x}' for byte in byte_data)
#print(formatted)
'''
la réponse est de type
\x81\x17\x00\x10\x46\x12
avec
\x81 address
\x17 command code
\x00 state
\x10\x46 firmware version
\x12 checksum
'''
# Extract byte 4 and byte 5
byte4 = byte_data[3] # 4th byte (index 3)
byte5 = byte_data[4] # 5th byte (index 4)
firmware_version = int(f"{byte4:02x}{byte5:02x}")
data = {
'firmware_version': firmware_version,
}
json_data = json.dumps(data)
print(json_data)
break
except KeyboardInterrupt:
print("User interrupt encountered. Exiting...")
time.sleep(3)
exit()
except:
# for all other kinds of error, but not specifying which one
print("Unknown error...")
time.sleep(3)
exit()

126
NPM/get_data.py Executable file
View File

@@ -0,0 +1,126 @@
'''
_ _ ____ __ __
| \ | | _ \| \/ |
| \| | |_) | |\/| |
| |\ | __/| | | |
|_| \_|_| |_| |_|
Script to get NPM values
need parameter: port
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data.py ttyAMA5
'''
import serial
import requests
import json
import sys
import time
parameter = sys.argv[1:] # Exclude the script name
port='/dev/'+parameter[0]
ser = serial.Serial(
port=port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 1
)
ser.write(b'\x81\x11\x6E') #data10s
#ser.write(b'\x81\x12\x6D') #data60s
while True:
try:
byte_data = ser.readline()
# Convert raw data to hex string for debugging
raw_hex = byte_data.hex() if byte_data else ""
# Check if we received data
if not byte_data or len(byte_data) < 15:
data = {
'PM1': 0.0,
'PM25': 0.0,
'PM10': 0.0,
'sleep': 0,
'degradedState': 0,
'notReady': 0,
'heatError': 0,
't_rhError': 0,
'fanError': 0,
'memoryError': 0,
'laserError': 0,
'raw': raw_hex,
'message': f"No data received or incomplete frame (length: {len(byte_data)})"
}
json_data = json.dumps(data)
print(json_data)
break
stateByte = int.from_bytes(byte_data[2:3], byteorder='big')
Statebits = [int(bit) for bit in bin(stateByte)[2:].zfill(8)]
PM1 = int.from_bytes(byte_data[9:11], byteorder='big')/10
PM25 = int.from_bytes(byte_data[11:13], byteorder='big')/10
PM10 = int.from_bytes(byte_data[13:15], byteorder='big')/10
# Create JSON with raw data and status message
data = {
'PM1': PM1,
'PM25': PM25,
'PM10': PM10,
'sleep': Statebits[0],
'degradedState': Statebits[1],
'notReady': Statebits[2],
'heatError': Statebits[3],
't_rhError': Statebits[4],
'fanError': Statebits[5],
'memoryError': Statebits[6],
'laserError': Statebits[7],
'raw': raw_hex,
'message': 'OK' if sum(Statebits[1:]) == 0 else 'Sensor error detected'
}
json_data = json.dumps(data)
print(json_data)
break
except KeyboardInterrupt:
data = {
'PM1': 0.0,
'PM25': 0.0,
'PM10': 0.0,
'sleep': 0,
'degradedState': 0,
'notReady': 0,
'heatError': 0,
't_rhError': 0,
'fanError': 0,
'memoryError': 0,
'laserError': 0,
'raw': '',
'message': 'User interrupt encountered'
}
print(json.dumps(data))
time.sleep(3)
exit()
except Exception as e:
data = {
'PM1': 0.0,
'PM25': 0.0,
'PM10': 0.0,
'sleep': 0,
'degradedState': 0,
'notReady': 0,
'heatError': 0,
't_rhError': 0,
'fanError': 0,
'memoryError': 0,
'laserError': 0,
'raw': '',
'message': f'Error: {str(e)}'
}
print(json.dumps(data))
time.sleep(3)
exit()

137
NPM/get_data_modbus.py Executable file
View File

@@ -0,0 +1,137 @@
'''
_ _ ____ __ __
| \ | | _ \| \/ |
| \| | |_) | |\/| |
| |\ | __/| | | |
|_| \_|_| |_| |_|
Script to get NPM data via Modbus
need parameter: port
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_modbus.py
Modbus RTU
[Slave Address][Function Code][Starting Address][Quantity of Registers][CRC]
Pour récupérer les 5 cannaux (a partir du registre 0x80)
Donnée actualisée toutes les 10 secondes
Request
\x01\x03\x00\x80\x00\x0A\xE4\x1E
\x01 Slave Address (slave device address)
\x03 Function code (read multiple holding registers)
\x00\x80 Starting Address (The request starts reading from holding register address 0x80 or 128)
\x00\x0A Quantity of Registers (Requests to read 0x0A or 10 consecutive registers starting from address 128)
\xE4\x1E Cyclic Redundancy Check (checksum )
'''
import serial
import requests
import json
import sys
import crcmod
import sqlite3
# Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Load the configuration data
config_file = '/var/www/nebuleair_pro_4g/config.json'
config = load_config(config_file)
npm_solo_port = config.get('NPM_solo_port', '') #port du NPM solo
ser = serial.Serial(
port=npm_solo_port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 0.5
)
# Define Modbus CRC-16 function
crc16 = crcmod.predefined.mkPredefinedCrcFun('modbus')
# Request frame without CRC
data = b'\x01\x03\x00\x80\x00\x0A'
# Calculate CRC
crc = crc16(data)
crc_low = crc & 0xFF
crc_high = (crc >> 8) & 0xFF
# Append CRC to the frame
request = data + bytes([crc_low, crc_high])
#print(f"Request frame: {request.hex()}")
ser.write(request)
#GET RTC TIME from SQlite
cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone() # Get the first (and only) row
rtc_time_str = row[1] # '2025-02-07 12:30:45'
while True:
try:
byte_data = ser.readline()
formatted = ''.join(f'\\x{byte:02x}' for byte in byte_data)
#print(formatted)
# Extract LSW (first 2 bytes) and MSW (last 2 bytes)
lsw_channel1 = int.from_bytes(byte_data[3:5], byteorder='big')
msw_chanel1 = int.from_bytes(byte_data[5:7], byteorder='big')
raw_value_channel1 = (msw_chanel1 << 16) | lsw_channel1
lsw_channel2 = int.from_bytes(byte_data[7:9], byteorder='big')
msw_chanel2 = int.from_bytes(byte_data[9:11], byteorder='big')
raw_value_channel2 = (msw_chanel2 << 16) | lsw_channel2
lsw_channel3 = int.from_bytes(byte_data[11:13], byteorder='big')
msw_chanel3 = int.from_bytes(byte_data[13:15], byteorder='big')
raw_value_channel3 = (msw_chanel3 << 16) | lsw_channel3
lsw_channel4 = int.from_bytes(byte_data[15:17], byteorder='big')
msw_chanel4 = int.from_bytes(byte_data[17:19], byteorder='big')
raw_value_channel4 = (msw_chanel1 << 16) | lsw_channel4
lsw_channel5 = int.from_bytes(byte_data[19:21], byteorder='big')
msw_chanel5 = int.from_bytes(byte_data[21:23], byteorder='big')
raw_value_channel5 = (msw_chanel5 << 16) | lsw_channel5
print(f"Channel 1 (0.2->0.5): {raw_value_channel1}")
print(f"Channel 2 (0.5->1.0): {raw_value_channel2}")
print(f"Channel 3 (1.0->2.5): {raw_value_channel3}")
print(f"Channel 4 (2.5->5.0): {raw_value_channel4}")
print(f"Channel 5 (5.0->10.): {raw_value_channel5}")
cursor.execute('''
INSERT INTO data_NPM_5channels (timestamp,PM_ch1, PM_ch2, PM_ch3, PM_ch4, PM_ch5) VALUES (?,?,?,?,?,?)'''
, (rtc_time_str,raw_value_channel1,raw_value_channel2,raw_value_channel3,raw_value_channel4,raw_value_channel5))
# Commit and close the connection
conn.commit()
break
except KeyboardInterrupt:
print("User interrupt encountered. Exiting...")
time.sleep(3)
exit()
except:
# for all other kinds of error, but not specifying which one
print("Unknown error...")
time.sleep(3)
exit()
conn.close()

188
NPM/get_data_modbus_v2.py Executable file
View File

@@ -0,0 +1,188 @@
'''
_ _ ____ __ __
| \ | | _ \| \/ |
| \| | |_) | |\/| |
| |\ | __/| | | |
|_| \_|_| |_| |_|
Script to get NPM data via Modbus
need parameter: port
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_modbus_v2.py
Modbus RTU
[Slave Address][Function Code][Starting Address][Quantity of Registers][CRC]
Pour récupérer
les concentrations en PM1, PM10 et PM2.5 (a partir du registre 0x38)
les 5 cannaux
la température et l'humidité à l'intérieur du capteur
Donnée actualisée toutes les 10 secondes
Request
\x01\x03\x00\x38\x00\x55\...\...
\x01 Slave Address (slave device address)
\x03 Function code (read multiple holding registers)
\x00\x38 Starting Address (The request starts reading from holding register address x38 or 56)
\x00\x55 Quantity of Registers (Requests to read x55 or 85 consecutive registers starting from address 56)
\...\... Cyclic Redundancy Check (checksum )
'''
import serial
import requests
import json
import sys
import crcmod
import sqlite3
# Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Load the configuration data
config_file = '/var/www/nebuleair_pro_4g/config.json'
config = load_config(config_file)
npm_solo_port = config.get('NPM_solo_port', '') #port du NPM solo
ser = serial.Serial(
port=npm_solo_port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
)
# Define Modbus CRC-16 function
crc16 = crcmod.predefined.mkPredefinedCrcFun('modbus')
# Request frame without CRC
data = b'\x01\x03\x00\x38\x00\x55'
# Calculate CRC
crc = crc16(data)
crc_low = crc & 0xFF
crc_high = (crc >> 8) & 0xFF
# Append CRC to the frame
request = data + bytes([crc_low, crc_high])
#print(f"Request frame: {request.hex()}")
ser.write(request)
#GET RTC TIME from SQlite
cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone() # Get the first (and only) row
rtc_time_str = row[1] # '2025-02-07 12:30:45'
while True:
try:
byte_data = ser.readline()
formatted = ''.join(f'\\x{byte:02x}' for byte in byte_data)
print(formatted)
# Register base (56 = 0x38)
REGISTER_START = 56
# Function to extract 32-bit values from Modbus response
def extract_value(byte_data, register, scale=1, single_register=False, round_to=None):
"""Extracts a value from Modbus response.
- `register`: Modbus register to read.
- `scale`: Value is divided by this (e.g., `1000` for PM values).
- `single_register`: If `True`, only reads 16 bits (one register).
"""
offset = (register - REGISTER_START) * 2 + 3 # Calculate byte offset
if single_register:
value = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
else:
lsw = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
msw = int.from_bytes(byte_data[offset+2:offset+4], byteorder='big')
value = (msw << 16) | lsw # 32-bit value
value = value / scale # Apply scaling
if round_to == 0:
return int(value) # Convert to integer to remove .0
elif round_to is not None:
return round(value, round_to) # Apply normal rounding
else:
return value # No rounding if round_to is None
# 10-sec PM Concentration (PM1, PM2.5, PM10)
pm1_10s = extract_value(byte_data, 56, 1000, round_to=1)
pm25_10s = extract_value(byte_data, 58, 1000, round_to=1)
pm10_10s = extract_value(byte_data, 60, 1000, round_to=1)
print("10 sec concentration:")
print(f"PM1: {pm1_10s}")
print(f"PM2.5: {pm25_10s}")
print(f"PM10: {pm10_10s}")
# 1-min PM Concentration
pm1_1min = extract_value(byte_data, 68, 1000, round_to=1)
pm25_1min = extract_value(byte_data, 70, 1000, round_to=1)
pm10_1min = extract_value(byte_data, 72, 1000, round_to=1)
#print("1 min concentration:")
#print(f"PM1: {pm1_1min}")
#print(f"PM2.5: {pm25_1min}")
#print(f"PM10: {pm10_1min}")
# Extract values for 5 channels
channel_1 = extract_value(byte_data, 128, round_to=0) # 0.2 - 0.5μm
channel_2 = extract_value(byte_data, 130, round_to=0) # 0.5 - 1.0μm
channel_3 = extract_value(byte_data, 132, round_to=0) # 1.0 - 2.5μm
channel_4 = extract_value(byte_data, 134, round_to=0) # 2.5 - 5.0μm
channel_5 = extract_value(byte_data, 136, round_to=0) # 5.0 - 10.0μm
print(f"Channel 1 (0.2->0.5): {channel_1}")
print(f"Channel 2 (0.5->1.0): {channel_2}")
print(f"Channel 3 (1.0->2.5): {channel_3}")
print(f"Channel 4 (2.5->5.0): {channel_4}")
print(f"Channel 5 (5.0->10.): {channel_5}")
# Retrieve relative humidity from register 106 (0x6A)
relative_humidity = extract_value(byte_data, 106, 100, single_register=True)
#print(f"Internal Relative Humidity: {relative_humidity} %")
# Retrieve temperature from register 106 (0x6A)
temperature = extract_value(byte_data, 107, 100, single_register=True)
#print(f"Internal temperature: {temperature} °C")
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 ))
# Commit and close the connection
conn.commit()
break
except KeyboardInterrupt:
print("User interrupt encountered. Exiting...")
time.sleep(3)
exit()
except:
# for all other kinds of error, but not specifying which one
print("Unknown error...")
time.sleep(3)
exit()
conn.close()

177
NPM/get_data_modbus_v2_1.py Normal file
View File

@@ -0,0 +1,177 @@
'''
_ _ ____ __ __
| \ | | _ \| \/ |
| \| | |_) | |\/| |
| |\ | __/| | | |
|_| \_|_| |_| |_|
Script to get NPM data via Modbus
Improved version with data stream lenght check
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_modbus_v3.py
Modbus RTU
[Slave Address][Function Code][Starting Address][Quantity of Registers][CRC]
Pour récupérer
les concentrations en PM1, PM10 et PM2.5 (a partir du registre 0x38)
les 5 cannaux
la température et l'humidité à l'intérieur du capteur
Donnée actualisée toutes les 10 secondes
Request
\x01\x03\x00\x38\x00\x55\...\...
\x01 Slave Address (slave device address)
\x03 Function code (read multiple holding registers)
\x00\x38 Starting Address (The request starts reading from holding register address x38 or 56)
\x00\x55 Quantity of Registers (Requests to read x55 or 85 consecutive registers starting from address 56)
\...\... Cyclic Redundancy Check (checksum )
'''
import serial
import requests
import json
import sys
import crcmod
import sqlite3
import time
# Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Load the configuration data
npm_solo_port = "/dev/ttyAMA5" #port du NPM solo
#GET RTC TIME from SQlite
cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone() # Get the first (and only) row
rtc_time_str = row[1] # '2025-02-07 12:30:45'
# Initialize serial port
ser = serial.Serial(
port=npm_solo_port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=2
)
# Define Modbus CRC-16 function
crc16 = crcmod.predefined.mkPredefinedCrcFun('modbus')
# Request frame without CRC
data = b'\x01\x03\x00\x38\x00\x55'
# Calculate and append CRC
crc = crc16(data)
crc_low = crc & 0xFF
crc_high = (crc >> 8) & 0xFF
request = data + bytes([crc_low, crc_high])
# Clear serial buffer before sending
ser.flushInput()
# Send request
ser.write(request)
time.sleep(0.2) # Wait for sensor to respond
# Read response
response_length = 2 + 1 + (2 * 85) + 2 # Address + Function + Data + CRC
byte_data = ser.read(response_length)
# Validate response length
if len(byte_data) < response_length:
print("[ERROR] Incomplete response received:", byte_data.hex())
exit()
# Verify CRC
received_crc = int.from_bytes(byte_data[-2:], byteorder='little')
calculated_crc = crc16(byte_data[:-2])
if received_crc != calculated_crc:
print("[ERROR] CRC check failed! Corrupted data received.")
exit()
# Convert response to hex for debugging
formatted = ''.join(f'\\x{byte:02x}' for byte in byte_data)
#print("Response:", formatted)
# Extract and print PM values
def extract_value(byte_data, register, scale=1, single_register=False, round_to=None):
REGISTER_START = 56
offset = (register - REGISTER_START) * 2 + 3
if single_register:
value = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
else:
lsw = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
msw = int.from_bytes(byte_data[offset+2:offset+4], byteorder='big')
value = (msw << 16) | lsw
value = value / scale
if round_to == 0:
return int(value)
elif round_to is not None:
return round(value, round_to)
else:
return value
pm1_10s = extract_value(byte_data, 56, 1000, round_to=1)
pm25_10s = extract_value(byte_data, 58, 1000, round_to=1)
pm10_10s = extract_value(byte_data, 60, 1000, round_to=1)
#print("10 sec concentration:")
#print(f"PM1: {pm1_10s}")
#print(f"PM2.5: {pm25_10s}")
#print(f"PM10: {pm10_10s}")
# Extract values for 5 channels
channel_1 = extract_value(byte_data, 128, round_to=0) # 0.2 - 0.5μm
channel_2 = extract_value(byte_data, 130, round_to=0) # 0.5 - 1.0μm
channel_3 = extract_value(byte_data, 132, round_to=0) # 1.0 - 2.5μm
channel_4 = extract_value(byte_data, 134, round_to=0) # 2.5 - 5.0μm
channel_5 = extract_value(byte_data, 136, round_to=0) # 5.0 - 10.0μm
#print(f"Channel 1 (0.2->0.5): {channel_1}")
#print(f"Channel 2 (0.5->1.0): {channel_2}")
#print(f"Channel 3 (1.0->2.5): {channel_3}")
#print(f"Channel 4 (2.5->5.0): {channel_4}")
#print(f"Channel 5 (5.0->10.): {channel_5}")
# Retrieve relative humidity from register 106 (0x6A)
relative_humidity = extract_value(byte_data, 106, 100, single_register=True)
# Retrieve temperature from register 106 (0x6A)
temperature = extract_value(byte_data, 107, 100, single_register=True)
#print(f"Internal Relative Humidity: {relative_humidity} %")
#print(f"Internal temperature: {temperature} °C")
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 ))
# Commit and close the connection
conn.commit()
conn.close()

244
NPM/get_data_modbus_v3.py Executable file
View File

@@ -0,0 +1,244 @@
'''
_ _ ____ __ __
| \ | | _ \| \/ |
| \| | |_) | |\/| |
| |\ | __/| | | |
|_| \_|_| |_| |_|
Script to get NPM data via Modbus
Improved version with data stream lenght check
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_modbus_v3.py
Modbus RTU
[Slave Address][Function Code][Starting Address][Quantity of Registers][CRC]
Pour récupérer
les concentrations en PM1, PM10 et PM2.5 (a partir du registre 0x38)
les 5 cannaux
la température et l'humidité à l'intérieur du capteur
Donnée actualisée toutes les 10 secondes
Request
\x01\x03\x00\x38\x00\x55\...\...
\x01 Slave Address (slave device address)
\x03 Function code (read multiple holding registers)
\x00\x38 Starting Address (The request starts reading from holding register address x38 or 56)
\x00\x55 Quantity of Registers (Requests to read x55 or 85 consecutive registers starting from address 56)
\...\... Cyclic Redundancy Check (checksum )
MAJ 2026 --> renvoie des 0 si pas de réponse du NPM
'''
import serial
import requests
import json
import sys
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()
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Load the configuration data
npm_solo_port = "/dev/ttyAMA5" #port du NPM solo
#GET RTC TIME from SQlite
cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone() # Get the first (and only) row
rtc_time_str = row[1] # '2025-02-07 12:30:45'
# Initialize default error values
pm1_10s = 0
pm25_10s = 0
pm10_10s = 0
channel_1 = 0
channel_2 = 0
channel_3 = 0
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
ser = serial.Serial(
port=npm_solo_port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=2
)
# Define Modbus CRC-16 function
crc16 = crcmod.predefined.mkPredefinedCrcFun('modbus')
# Request frame without CRC
data = b'\x01\x03\x00\x38\x00\x55'
# Calculate and append CRC
crc = crc16(data)
crc_low = crc & 0xFF
crc_high = (crc >> 8) & 0xFF
request = data + bytes([crc_low, crc_high])
# Clear serial buffer before sending
ser.flushInput()
# Send request
ser.write(request)
time.sleep(0.2) # Wait for sensor to respond
# Read response
response_length = 2 + 1 + (2 * 85) + 2 # Address + Function + Data + CRC
byte_data = ser.read(response_length)
# Validate response length
if len(byte_data) < response_length:
if not dry_run:
print(f"[ERROR] Incomplete response received: {byte_data.hex()}")
raise Exception("Incomplete response")
# Verify CRC
received_crc = int.from_bytes(byte_data[-2:], byteorder='little')
calculated_crc = crc16(byte_data[:-2])
if received_crc != calculated_crc:
if not dry_run:
print("[ERROR] CRC check failed! Corrupted data received.")
raise Exception("CRC check failed")
# Convert response to hex for debugging
formatted = ''.join(f'\\x{byte:02x}' for byte in byte_data)
#print("Response:", formatted)
# Extract and print PM values
def extract_value(byte_data, register, scale=1, single_register=False, round_to=None):
REGISTER_START = 56
offset = (register - REGISTER_START) * 2 + 3
if single_register:
value = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
else:
lsw = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
msw = int.from_bytes(byte_data[offset+2:offset+4], byteorder='big')
value = (msw << 16) | lsw
value = value / scale
if round_to == 0:
return int(value)
elif round_to is not None:
return round(value, round_to)
else:
return value
pm1_10s = extract_value(byte_data, 56, 1000, round_to=1)
pm25_10s = extract_value(byte_data, 58, 1000, round_to=1)
pm10_10s = extract_value(byte_data, 60, 1000, round_to=1)
#print("10 sec concentration:")
#print(f"PM1: {pm1_10s}")
#print(f"PM2.5: {pm25_10s}")
#print(f"PM10: {pm10_10s}")
# Extract values for 5 channels
channel_1 = extract_value(byte_data, 128, round_to=0) # 0.2 - 0.5μm
channel_2 = extract_value(byte_data, 130, round_to=0) # 0.5 - 1.0μm
channel_3 = extract_value(byte_data, 132, round_to=0) # 1.0 - 2.5μm
channel_4 = extract_value(byte_data, 134, round_to=0) # 2.5 - 5.0μm
channel_5 = extract_value(byte_data, 136, round_to=0) # 5.0 - 10.0μm
#print(f"Channel 1 (0.2->0.5): {channel_1}")
#print(f"Channel 2 (0.5->1.0): {channel_2}")
#print(f"Channel 3 (1.0->2.5): {channel_3}")
#print(f"Channel 4 (2.5->5.0): {channel_4}")
#print(f"Channel 5 (5.0->10.): {channel_5}")
# Retrieve relative humidity from register 106 (0x6A)
relative_humidity = extract_value(byte_data, 106, 100, single_register=True)
# Retrieve temperature from register 106 (0x6A)
temperature = extract_value(byte_data, 107, 100, single_register=True)
#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:
if not dry_run:
print(f"[ERROR] Sensor communication failed: {e}")
# Variables already set to -1 at the beginning
finally:
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, npm_status) VALUES (?,?,?,?,?,?,?)'''
, (rtc_time_str, pm1_10s, pm25_10s, pm10_10s, temperature, relative_humidity, npm_status))
# Commit and close the connection
conn.commit()
conn.close()

52
NPM/get_data_temp_hum.py Executable file
View File

@@ -0,0 +1,52 @@
'''
_ _ ____ __ __
| \ | | _ \| \/ |
| \| | |_) | |\/| |
| |\ | __/| | | |
|_| \_|_| |_| |_|
Script to get NPM values: ONLY temp and hum
need parameter: port
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_temp_hum.py ttyAMA5
'''
import serial
import requests
import json
import sys
parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
port='/dev/'+parameter[0]
ser = serial.Serial(
port=port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 1
)
ser.write(b'\x81\x14\x6B') # Temp and humidity command
while True:
try:
byte_data_temp_hum = ser.readline()
# Decode temperature and humidity values
temperature = int.from_bytes(byte_data_temp_hum[3:5], byteorder='big') / 100.0
humidity = int.from_bytes(byte_data_temp_hum[5:7], byteorder='big') / 100.0
print(f"temp: {temperature}")
print(f"hum: {humidity}")
break
except KeyboardInterrupt:
print("User interrupt encountered. Exiting...")
time.sleep(3)
exit()
except:
# for all other kinds of error, but not specifying which one
print("Unknown error...")
time.sleep(3)
exit()

104
NPM/get_data_v2.py Executable file
View File

@@ -0,0 +1,104 @@
'''
_ _ ____ __ __
| \ | | _ \| \/ |
| \| | |_) | |\/| |
| |\ | __/| | | |
|_| \_|_| |_| |_|
Script to get NPM values (PM1, PM2.5 and PM10)
PM and the sensor temp/hum
And store them inside sqlite database
Uses RTC module for timing (from SQLite db)
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_v2.py
'''
import serial
import requests
import json
import sys
import sqlite3
import smbus2
import time
from datetime import datetime
# Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Load the configuration data
config_file = '/var/www/nebuleair_pro_4g/config.json'
config = load_config(config_file)
npm_solo_port = config.get('NPM_solo_port', '') #port du NPM solo
ser = serial.Serial(
port=npm_solo_port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 0.5
)
# 1⃣ Request PM Data (PM1, PM2.5, PM10)
#ser.write(b'\x81\x11\x6E') #data10s
ser.write(b'\x81\x12\x6D') #data60s
time.sleep(0.5) # Small delay to allow the sensor to process the request
#print("Start get_data_v2.py script")
byte_data = ser.readline()
#print(byte_data)
stateByte = int.from_bytes(byte_data[2:3], byteorder='big')
Statebits = [int(bit) for bit in bin(stateByte)[2:].zfill(8)]
PM1 = int.from_bytes(byte_data[9:11], byteorder='big')/10
PM25 = int.from_bytes(byte_data[11:13], byteorder='big')/10
PM10 = int.from_bytes(byte_data[13:15], byteorder='big')/10
# 2⃣ Request Temperature & Humidity
ser.write(b'\x81\x14\x6B') # Temp and humidity command
time.sleep(0.5) # Small delay to allow the sensor to process the request
byte_data_temp_hum = ser.readline()
# Decode temperature and humidity values
temperature = int.from_bytes(byte_data_temp_hum[3:5], byteorder='big') / 100.0
humidity = int.from_bytes(byte_data_temp_hum[5:7], byteorder='big') / 100.0
#print(f"State: {Statebits}")
#print(f"PM1: {PM1}")
#print(f"PM25: {PM25}")
#print(f"PM10: {PM10}")
#print(f"temp: {temperature}")
#print(f"hum: {humidity}")
#GET RTC TIME from SQlite
cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone() # Get the first (and only) row
rtc_time_str = row[1] # '2025-02-07 12:30:45'
#save to sqlite database
try:
cursor.execute('''
INSERT INTO data_NPM (timestamp,PM1, PM25, PM10, temp_npm, hum_npm) VALUES (?,?,?,?,?,?)'''
, (rtc_time_str,PM1,PM25,PM10,temperature,humidity ))
# Commit and close the connection
conn.commit()
#print("Sensor data saved successfully!")
except Exception as e:
print(f"Database error: {e}")
conn.close()

172
NPM/old/get_data_modbus_loop.py Executable file
View File

@@ -0,0 +1,172 @@
'''
Loop to run every minutes
* * * * * /usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_modbus_loop.py ttyAMA5
saves data (avaerage) to a json file /var/www/nebuleair_pro_4g/NPM/data/data.json
saves data (all) to a sqlite database
first time running the script?
sudo mkdir /var/www/nebuleair_pro_4g/NPM/data
sudo touch /var/www/nebuleair_pro_4g/NPM/data/data.json
'''
import serial
import sys
import crcmod
import time
import json
import os
import sqlite3
import smbus2 # For RTC DS3231
from datetime import datetime
# Ensure a port argument is provided
if len(sys.argv) < 2:
print("Usage: python3 get_data_modbus.py <serial_port>")
sys.exit(1)
port = '/dev/' + sys.argv[1]
# Initialize serial communication
try:
ser = serial.Serial(
port=port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=0.5
)
except Exception as e:
print(f"Error opening serial port {port}: {e}")
sys.exit(1)
# Define Modbus CRC-16 function
crc16 = crcmod.predefined.mkPredefinedCrcFun('modbus')
# Request frame without CRC
data = b'\x01\x03\x00\x80\x00\x0A'
# Calculate CRC
crc = crc16(data)
crc_low = crc & 0xFF
crc_high = (crc >> 8) & 0xFF
# Append CRC to the frame
request = data + bytes([crc_low, crc_high])
# Log request frame
print(f"Request frame: {request.hex()}")
# Initialize SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
# RTC Module (DS3231) Setup
RTC_I2C_ADDR = 0x68 # DS3231 I2C Address
bus = smbus2.SMBus(1)
def bcd_to_dec(bcd):
return (bcd // 16 * 10) + (bcd % 16)
def get_rtc_time():
"""Reads time from RTC module (DS3231)"""
try:
data = bus.read_i2c_block_data(RTC_I2C_ADDR, 0x00, 7)
seconds = bcd_to_dec(data[0] & 0x7F)
minutes = bcd_to_dec(data[1])
hours = bcd_to_dec(data[2] & 0x3F)
day = bcd_to_dec(data[4])
month = bcd_to_dec(data[5])
year = bcd_to_dec(data[6]) + 2000
return datetime(year, month, day, hours, minutes, seconds).strftime("%Y-%m-%d %H:%M:%S")
except Exception as e:
print(f"RTC Read Error: {e}")
return "N/A"
# Initialize storage for averaging
num_samples = 6
channel_sums = [0] * 5 # 5 channels
#do not start immediately to prevent multiple write/read over NPM serial port
time.sleep(3)
# Loop 6 times to collect data every 10 seconds
for i in range(num_samples):
print(f"\nIteration {i+1}/{num_samples}")
ser.write(request)
rtc_timestamp = get_rtc_time()
try:
byte_data = ser.readline()
formatted = ''.join(f'\\x{byte:02x}' for byte in byte_data)
print(f"Raw Response: {formatted}")
if len(byte_data) < 23:
print("Incomplete response, skipping this sample.")
time.sleep(9)
continue
# Extract and process the 5 channels
channels = []
for j in range(5):
lsw = int.from_bytes(byte_data[3 + j*4 : 5 + j*4], byteorder='little')
msw = int.from_bytes(byte_data[5 + j*4 : 7 + j*4], byteorder='little')
raw_value = (msw << 16) | lsw
channels.append(raw_value)
# Accumulate sum for each channel
for j in range(5):
channel_sums[j] += channels[j]
# Print collected values
print(f"Timestamp (RTC): {rtc_timestamp}")
for j in range(5):
print(f"Channel {j+1}: {channels[j]}")
# Save the individual reading to the database
cursor.execute('''
INSERT INTO data (timestamp, PM_ch1, PM_ch2, PM_ch3, PM_ch4, PM_ch5)
VALUES (?, ?, ?, ?, ?, ?)
''', (rtc_timestamp, channels[0], channels[1], channels[2], channels[3], channels[4]))
conn.commit()
except Exception as e:
print(f"Error reading data: {e}")
# Wait 10 seconds before next reading
time.sleep(10)
# Compute the average values
channel_means = [int(s / num_samples) for s in channel_sums]
# Create JSON structure
data_json = {
"channel_1": channel_means[0],
"channel_2": channel_means[1],
"channel_3": channel_means[2],
"channel_4": channel_means[3],
"channel_5": channel_means[4]
}
# Print final JSON data
print("\nFinal JSON Data (Averaged over 6 readings):")
print(json.dumps(data_json, indent=4))
# Define JSON file path
output_file = "/var/www/nebuleair_pro_4g/NPM/data/data.json"
# Write results to a JSON file
try:
with open(output_file, "w") as f:
json.dump(data_json, f, indent=4)
print(f"\nAveraged JSON data saved to {output_file}")
except Exception as e:
print(f"Error writing to file: {e}")
# Close serial connection
ser.close()

126
NPM/old/test_modbus.py Executable file
View File

@@ -0,0 +1,126 @@
'''
_ _ ____ __ __
| \ | | _ \| \/ |
| \| | |_) | |\/| |
| |\ | __/| | | |
|_| \_|_| |_| |_|
Script to get NPM data via Modbus
need parameter: port
/usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/old/test_modbus.py
Modbus RTU
[Slave Address][Function Code][Starting Address][Quantity of Registers][CRC]
Pour récupérer
les concentrations en PM1, PM10 et PM2.5 (a partir du registre 0x38)
les 5 cannaux
Donnée actualisée toutes les 10 secondes
Request
\x01\x03\x00\x38\x00\x55\...\...
\x01 Slave Address (slave device address)
\x03 Function code (read multiple holding registers)
\x00\x38 Starting Address (The request starts reading from holding register address x38 or 56)
\x00\x55 Quantity of Registers (Requests to read x55 or 85 consecutive registers starting from address 56)
\...\... Cyclic Redundancy Check (checksum )
'''
import serial
import requests
import json
import sys
import crcmod
import sqlite3
# Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Load the configuration data
config_file = '/var/www/nebuleair_pro_4g/config.json'
config = load_config(config_file)
npm_solo_port = config.get('NPM_solo_port', '') #port du NPM solo
ser = serial.Serial(
port=npm_solo_port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 0.5
)
# Define Modbus CRC-16 function
crc16 = crcmod.predefined.mkPredefinedCrcFun('modbus')
# Request frame without CRC
data = b'\x01\x03\x00\x44\x00\x06'
# Calculate CRC
crc = crc16(data)
crc_low = crc & 0xFF
crc_high = (crc >> 8) & 0xFF
# Append CRC to the frame
request = data + bytes([crc_low, crc_high])
#print(f"Request frame: {request.hex()}")
ser.write(request)
while True:
try:
byte_data = ser.readline()
formatted = ''.join(f'\\x{byte:02x}' for byte in byte_data)
print(formatted)
if len(byte_data) < 14:
print(f"Error: Received {len(byte_data)} bytes, expected 14!")
continue
#10 secs concentration
lsw_pm1 = int.from_bytes(byte_data[3:5], byteorder='big')
msw_pm1 = int.from_bytes(byte_data[5:7], byteorder='big')
raw_value_pm1 = (msw_pm1 << 16) | lsw_pm1
raw_value_pm1 = raw_value_pm1 / 1000
lsw_pm25 = int.from_bytes(byte_data[7:9], byteorder='big')
msw_pm25 = int.from_bytes(byte_data[9:11], byteorder='big')
raw_value_pm25 = (msw_pm25 << 16) | lsw_pm25
raw_value_pm25 = raw_value_pm25 / 1000
lsw_pm10 = int.from_bytes(byte_data[11:13], byteorder='big')
msw_pm10 = int.from_bytes(byte_data[13:15], byteorder='big')
raw_value_pm10 = (msw_pm10 << 16) | lsw_pm10
raw_value_pm10 = raw_value_pm10 / 1000
print("1 min")
print(f"PM1: {raw_value_pm1}")
print(f"PM2.5: {raw_value_pm25}")
print(f"PM10: {raw_value_pm10}")
break
except KeyboardInterrupt:
print("User interrupt encountered. Exiting...")
time.sleep(3)
exit()
except:
# for all other kinds of error, but not specifying which one
print("Unknown error...")
time.sleep(3)
exit()
conn.close()

232
README.md
View File

@@ -4,30 +4,46 @@ Based on the Rpi4 or CM4.
# Installation
Installation can be made with Ansible or the classic way.
# Express
You can download the `installation_part1.sh` and run it:
```
wget http://gitea.aircarto.fr/PaulVua/nebuleair_pro_4g/raw/branch/main/installation_part1.sh
chmod +x installation_part1.sh
sudo ./installation_part1.sh
```
After reboot you can do the same with part 2.
```
wget http://gitea.aircarto.fr/PaulVua/nebuleair_pro_4g/raw/branch/main/installation_part2.sh
chmod +x installation_part2.sh
sudo ./installation_part2.sh
```
## Ansible (WORK IN PROGRESS)
Installation with Ansible will use a playbook `install_software.yml`.
## General
See `installation.sh`
Line by line installation.
```
sudo apt update
sudo apt install git gh apache2 php python3 python3-pip jq autossh i2c-tools python3-smbus -y
sudo pip3 install pyserial requests RPi.GPIO adafruit-circuitpython-bme280 --break-system-packages
sudo apt install git gh apache2 sqlite3 php php-sqlite3 python3 python3-pip jq autossh i2c-tools python3-smbus -y
sudo pip3 install pyserial requests RPi.GPIO adafruit-circuitpython-bme280 crcmod psutil ntplib pytz gpiozero adafruit-circuitpython-ads1x15 numpy nsrt-mk3-dev --break-system-packages
sudo mkdir -p /var/www/.ssh
sudo ssh-keygen -t rsa -b 4096 -f /var/www/.ssh/id_rsa -N ""
sudo ssh-copy-id -i /var/www/.ssh/id_rsa.pub -p 50221 airlab_server1@aircarto.fr
sudo git clone http://gitea.aircarto.fr/PaulVua/nebuleair_pro_4g.git /var/www/nebuleair_pro_4g
sudo mkdir /var/www/nebuleair_pro_4g/logs
sudo touch /var/www/nebuleair_pro_4g/logs/app.log /var/www/nebuleair_pro_4g/logs/loop.log /var/www/nebuleair_pro_4g/wifi_list.csv
sudo cp /var/www/nebuleair_pro_4g/config.json.dist /var/www/nebuleair_pro_4g/config.json
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/create_db.py
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/set_config.py
sudo chmod -R 777 /var/www/nebuleair_pro_4g/
git config --global core.fileMode false
git -C /var/www/nebuleair_pro_4g config core.fileMode false
git config --global --add safe.directory /var/www/nebuleair_pro_4g
sudo crontab /var/www/nebuleair_pro_4g/cron_jobs
sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/create_db.py
```
## Apache
Configuration of Apache to redirect to the html homepage project
@@ -42,7 +58,10 @@ To make things simpler we will allow all users to use "nmcli" as sudo without en
ALL ALL=(ALL) NOPASSWD: /usr/bin/nmcli, /usr/sbin/reboot
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
@@ -66,7 +85,7 @@ sudo chmod 777 /dev/ttyAMA*
## I2C
Decibel meter and BME280 is connected via I2C.
Decibel meter, BME280 and the RTC module (DS3231) is connected via I2C.
Need to activate by modifying `sudo nano /boot/firmware/config.txt`
@@ -82,14 +101,26 @@ sudo chmod 777 /dev/i2c-1
Attention: sometimes activation with config.txt do not work, you need to activate i2c with `sudo raspi-config` and go to "Interface" -> I2C -> enable.
It is possible to manage raspi-config only with cli: `sudo raspi-config nonint do_i2c 0`
I2C addresses: use `sudo i2cdetect -y 1` to check the connected devices.
### BME280
The python script is triggered by the main loop every minutes to get instant temp, hum and press values (no need to have a minute average).
BME280 address is 0x76.
### RTC module (DS3231)
### Noise sensor
As noise varies a lot, we keep the C program running every seconds to create a moving average for the last 60 seconds (we also gather max and min values).
To keep the script running at boot and stay on we create a systemd service
To keep the script running at boot and stay on we create a systemd service.
Nois sensor address is 0x48.
Create the service with `sudo nano /etc/systemd/system/sound_meter.service` and add:
```
@@ -149,43 +180,146 @@ And set the base URL for Sara R4 communication:
@reboot sleep 30 && /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setURL.py ttyAMA2 data.nebuleair.fr >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
```
### With only 1 NPM
Loop every minutes to get the PM values and send it to the server:
## 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
```
* * * * * /usr/bin/python3 /var/www/nebuleair_pro_4g/loop/1_NPM/send_data.py >> /var/www/nebuleair_pro_4g/logs/loop.log 2>&1
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|||
```
All in one:
### Byte 66 — error_flags
```
@reboot chmod 777 /dev/ttyAMA*
@reboot /var/www/nebuleair_pro_4g/boot_hotspot.sh >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
@reboot sleep 30 && /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setURL.py ttyAMA2 data.nebuleair.fr >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
* * * * * /usr/bin/python3 /var/www/nebuleair_pro_4g/loop/1_NPM/send_data.py >> /var/www/nebuleair_pro_4g/logs/loop.log 2>&1
0 0 */2 * * > /var/www/nebuleair_pro_4g/logs/loop.log
```
| 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 |
### With 3 NPM
Loop every minutes to get the PM values and send it to the server:
### Byte 67 — npm_status
```
* * * * * /usr/bin/python3 /var/www/nebuleair_pro_4g/loop/3_NPM/get_data_closest_pair.py >> /var/www/nebuleair_pro_4g/logs/loop.log 2>&1
* * * * * sleep 5 && /usr/bin/python3 /var/www/nebuleair_pro_4g/loop/3_NPM/send_data.py >> /var/www/nebuleair_pro_4g/logs/loop.log 2>&1
```
| 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 |
All in one:
### Byte 68 — device_status
```
@reboot chmod 777 /dev/ttyAMA* /dev/i2c-1
@reboot /var/www/nebuleair_pro_4g/boot_hotspot.sh >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
@reboot sleep 30 && /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setURL.py ttyAMA2 data.nebuleair.fr >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
* * * * * /usr/bin/python3 /var/www/nebuleair_pro_4g/loop/3_NPM/get_data_closest_pair.py >> /var/www/nebuleair_pro_4g/logs/loop.log 2>&1
* * * * * sleep 5 && /usr/bin/python3 /var/www/nebuleair_pro_4g/loop/3_NPM/send_data.py >> /var/www/nebuleair_pro_4g/logs/loop.log 2>&1
0 0 */2 * * > /var/www/nebuleair_pro_4g/logs/loop.log
```
| 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
@@ -219,4 +353,28 @@ This can be doned with script boot_hotspot.sh.
@reboot /var/www/nebuleair_pro_4g/boot_hotspot.sh
```
## Claude Code
Instructions to use claude code on the RPI.
### Install NPM
```
sudo apt install -y nodejs npm
node -v
npm -v
```
### Install Claude
```
sudo npm install -g @anthropic-ai/claude-code
```
### Run claude
```
claude
```

77
RTC/read.py Executable file
View File

@@ -0,0 +1,77 @@
'''
____ _____ ____
| _ \_ _/ ___|
| |_) || || |
| _ < | || |___
|_| \_\|_| \____|
Script to read time from RTC module
I2C connection
Address 0x68
/usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/read.py
'''
import smbus2
import time
import json
from datetime import datetime
# DS3231 I2C address
DS3231_ADDR = 0x68
# Registers for DS3231
REG_TIME = 0x00
def bcd_to_dec(bcd):
return (bcd // 16 * 10) + (bcd % 16)
def read_time(bus):
"""Try to read and decode time from the RTC module (DS3231)."""
try:
data = bus.read_i2c_block_data(DS3231_ADDR, REG_TIME, 7)
seconds = bcd_to_dec(data[0] & 0x7F)
minutes = bcd_to_dec(data[1])
hours = bcd_to_dec(data[2] & 0x3F)
day = bcd_to_dec(data[4])
month = bcd_to_dec(data[5])
year = bcd_to_dec(data[6]) + 2000
return datetime(year, month, day, hours, minutes, seconds)
except OSError:
return None # RTC module not connected
def main():
# Read RTC time
bus = smbus2.SMBus(1)
# Try to read RTC time
rtc_time = read_time(bus)
# Get current system time
system_time = datetime.now() #local
utc_time = datetime.utcnow() #UTC
# If RTC is not connected, set default message
# Calculate time difference (in seconds) if RTC is connected
if rtc_time:
rtc_time_str = rtc_time.strftime('%Y-%m-%d %H:%M:%S')
time_difference = int((utc_time - rtc_time).total_seconds()) # Convert to int
else:
rtc_time_str = "not connected"
time_difference = "N/A" # Not applicable
# Print both times
#print(f"RTC module Time: {rtc_time.strftime('%Y-%m-%d %H:%M:%S')}")
#print(f"Sys local Time: {system_time.strftime('%Y-%m-%d %H:%M:%S')}")
#print(f"Sys UTC Time: {utc_time.strftime('%Y-%m-%d %H:%M:%S')}")
# Create JSON output
time_data = {
"rtc_module_time":rtc_time_str,
"system_local_time": system_time.strftime('%Y-%m-%d %H:%M:%S'),
"system_utc_time": utc_time.strftime('%Y-%m-%d %H:%M:%S'),
"time_difference_seconds": time_difference
}
print(json.dumps(time_data, indent=4))
if __name__ == "__main__":
main()

129
RTC/save_to_db.py Executable file
View File

@@ -0,0 +1,129 @@
'''
____ _____ ____
| _ \_ _/ ___|
| |_) || || |
| _ < | || |___
|_| \_\|_| \____|
Script to read time from RTC module and save it to DB
I2C connection
Address 0x68
/usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/save_to_db.py
This need to be run as a system service
--> sudo nano /etc/systemd/system/rtc_save_to_db.service
⬇️
[Unit]
Description=RTC Save to DB Script
After=network.target
[Service]
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/save_to_db.py
Restart=always
RestartSec=1
User=root
WorkingDirectory=/var/www/nebuleair_pro_4g
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/rtc_save_to_db.log
StandardError=append:/var/www/nebuleair_pro_4g/logs/rtc_save_to_db_errors.log
[Install]
WantedBy=multi-user.target
⬆️
sudo systemctl daemon-reload
sudo systemctl enable rtc_save_to_db.service
sudo systemctl start rtc_save_to_db.service
sudo systemctl status rtc_save_to_db.service
'''
import smbus2
import time
import json
from datetime import datetime
import sqlite3
# DS3231 I2C address
DS3231_ADDR = 0x68
# Registers for DS3231
REG_TIME = 0x00
# Connect to (or create if not existent) the database
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
def bcd_to_dec(bcd):
return (bcd // 16 * 10) + (bcd % 16)
def read_time(bus):
"""Try to read and decode time from the RTC module (DS3231)."""
try:
data = bus.read_i2c_block_data(DS3231_ADDR, REG_TIME, 7)
seconds = bcd_to_dec(data[0] & 0x7F)
minutes = bcd_to_dec(data[1])
hours = bcd_to_dec(data[2] & 0x3F)
day = bcd_to_dec(data[4])
month = bcd_to_dec(data[5])
year = bcd_to_dec(data[6]) + 2000
return datetime(year, month, day, hours, minutes, seconds)
except OSError:
return None # RTC module not connected
def main():
# Read RTC time
bus = smbus2.SMBus(1)
while True:
# Open a new database connection inside the loop to prevent connection loss
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Try to read RTC time
rtc_time = read_time(bus)
# Get current system time
system_time = datetime.now() #local
utc_time = datetime.utcnow() #UTC
# If RTC is not connected, set default message
# Calculate time difference (in seconds) if RTC is connected
if rtc_time:
rtc_time_str = rtc_time.strftime('%Y-%m-%d %H:%M:%S')
time_difference = int((utc_time - rtc_time).total_seconds()) # Convert to int
else:
rtc_time_str = "not connected"
time_difference = "N/A" # Not applicable
# Print both times
#print(f"RTC module Time: {rtc_time.strftime('%Y-%m-%d %H:%M:%S')}")
#print(f"Sys local Time: {system_time.strftime('%Y-%m-%d %H:%M:%S')}")
#print(f"Sys UTC Time: {utc_time.strftime('%Y-%m-%d %H:%M:%S')}")
# Create JSON output
time_data = {
"rtc_module_time":rtc_time_str,
"system_local_time": system_time.strftime('%Y-%m-%d %H:%M:%S'),
"system_utc_time": utc_time.strftime('%Y-%m-%d %H:%M:%S'),
"time_difference_seconds": time_difference
}
#print(json.dumps(time_data, indent=4))
# Save to database
try:
cursor.execute("UPDATE timestamp_table SET last_updated = ? WHERE id = 1", (rtc_time_str,))
conn.commit()
#print("Sensor data saved successfully!")
except sqlite3.Error as e:
print(f"Database error: {e}")
conn.close() # Close connection to avoid database locking issues
time.sleep(1) # Wait for 1 second before reading again
if __name__ == "__main__":
main()

183
RTC/set_with_NTP.py Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/python3
"""
____ _____ ____
| _ \_ _/ ___|
| |_) || || |
| _ < | || |___
|_| \_\|_| \____|
Script to set the RTC using an NTP server (script used by web UI)
RPI needs to be connected to the internet (WIFI).
Requires ntplib and pytz:
sudo pip3 install ntplib pytz --break-system-packages
"""
import smbus2
import time
from datetime import datetime
import ntplib
import pytz # For timezone handling
# DS3231 I2C address
DS3231_ADDR = 0x68
# Registers for DS3231
REG_TIME = 0x00
def bcd_to_dec(bcd):
"""Convert BCD to decimal."""
return (bcd // 16 * 10) + (bcd % 16)
def dec_to_bcd(dec):
"""Convert decimal to BCD."""
return (dec // 10 * 16) + (dec % 10)
def set_time(bus, year, month, day, hour, minute, second):
"""Set the RTC time."""
# Convert the time to BCD format
second_bcd = dec_to_bcd(second)
minute_bcd = dec_to_bcd(minute)
hour_bcd = dec_to_bcd(hour)
day_bcd = dec_to_bcd(day)
month_bcd = dec_to_bcd(month)
year_bcd = dec_to_bcd(year - 2000) # DS3231 uses year from 2000
# Write time to DS3231
bus.write_i2c_block_data(DS3231_ADDR, REG_TIME, [
second_bcd,
minute_bcd,
hour_bcd,
0x01, # Day of the week (1=Monday, etc.)
day_bcd,
month_bcd,
year_bcd
])
def read_time(bus):
"""Read the RTC time and validate the values."""
try:
data = bus.read_i2c_block_data(DS3231_ADDR, REG_TIME, 7)
# Convert from BCD
second = bcd_to_dec(data[0] & 0x7F)
minute = bcd_to_dec(data[1])
hour = bcd_to_dec(data[2] & 0x3F)
day = bcd_to_dec(data[4])
month = bcd_to_dec(data[5])
year = bcd_to_dec(data[6]) + 2000
# Print raw values for debugging
print(f"Raw RTC values: {data}")
print(f"Decoded values: Y:{year} M:{month} D:{day} H:{hour} M:{minute} S:{second}")
# Validate date values
if not (1 <= month <= 12):
print(f"Invalid month value: {month}, using default")
month = 1
# Check days in month (simplified)
days_in_month = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if not (1 <= day <= days_in_month[month]):
print(f"Invalid day value: {day} for month {month}, using default")
day = 1
# Validate time values
if not (0 <= hour <= 23):
print(f"Invalid hour value: {hour}, using default")
hour = 0
if not (0 <= minute <= 59):
print(f"Invalid minute value: {minute}, using default")
minute = 0
if not (0 <= second <= 59):
print(f"Invalid second value: {second}, using default")
second = 0
return (year, month, day, hour, minute, second)
except Exception as e:
print(f"Error reading RTC: {e}")
# Return a safe default date (2023-01-01 00:00:00)
return (2023, 1, 1, 0, 0, 0)
def get_internet_time():
"""Get the current time from an NTP server."""
ntp_client = ntplib.NTPClient()
# Try multiple NTP servers in case one fails
servers = ['pool.ntp.org', 'time.google.com', 'time.windows.com', 'time.apple.com']
for server in servers:
try:
print(f"Trying NTP server: {server}")
response = ntp_client.request(server, timeout=2)
utc_time = datetime.utcfromtimestamp(response.tx_time)
print(f"Successfully got time from {server}")
return utc_time
except Exception as e:
print(f"Failed to get time from {server}: {e}")
# If all servers fail, raise exception
raise Exception("All NTP servers failed")
def main():
try:
bus = smbus2.SMBus(1)
# Test if RTC is accessible
try:
bus.read_byte(DS3231_ADDR)
print("RTC module is accessible")
except Exception as e:
print(f"Error accessing RTC module: {e}")
print("Please check connections and I2C configuration")
return
# Get the current time from the RTC
try:
year, month, day, hours, minutes, seconds = read_time(bus)
# Create datetime object with validation to handle invalid dates
rtc_time = datetime(year, month, day, hours, minutes, seconds)
print(f"Actual RTC Time : {rtc_time.strftime('%Y-%m-%d %H:%M:%S')}")
except ValueError as e:
print(f"Invalid date/time read from RTC: {e}")
print("Will proceed with setting RTC from internet time")
rtc_time = None
# Get current UTC time from an NTP server
try:
internet_utc_time = get_internet_time()
print(f"Time from Internet (UTC) : {internet_utc_time.strftime('%Y-%m-%d %H:%M:%S')}")
except Exception as e:
print(f"Error retrieving time from the internet: {e}")
if rtc_time is None:
print("Cannot proceed without either valid RTC time or internet time")
return
print("Will keep current RTC time")
return
# Set the RTC to UTC time
print("Setting RTC to internet time...")
set_time(bus, internet_utc_time.year, internet_utc_time.month, internet_utc_time.day,
internet_utc_time.hour, internet_utc_time.minute, internet_utc_time.second)
# Read and print the new time from RTC
print("Reading back new RTC time...")
year, month, day, hour, minute, second = read_time(bus)
rtc_time_new = datetime(year, month, day, hour, minute, second)
print(f"New RTC Time (UTC) : {rtc_time_new.strftime('%Y-%m-%d %H:%M:%S')}")
# Calculate difference to verify accuracy
time_diff = abs((rtc_time_new - internet_utc_time).total_seconds())
print(f"Time difference : {time_diff:.2f} seconds")
if time_diff > 5:
print("Warning: RTC time differs significantly from internet time")
print("You may need to retry or check RTC module")
else:
print("RTC successfully synchronized with internet time")
except Exception as e:
print(f"Unexpected error: {e}")
if __name__ == "__main__":
main()

53
RTC/set_with_browserTime.py Executable file
View File

@@ -0,0 +1,53 @@
"""
____ _____ ____
| _ \_ _/ ___|
| |_) || || |
| _ < | || |___
|_| \_\|_| \____|
Script to set the RTC using the browser time (script used by the web UI).
/usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_browserTime.py '2024-01-30 12:48:39'
"""
import sys
from datetime import datetime
import smbus2
# DS3231 I2C address
DS3231_ADDR = 0x68
REG_TIME = 0x00
def dec_to_bcd(dec):
"""Convert decimal to BCD."""
return (dec // 10 * 16) + (dec % 10)
def set_rtc(bus, year, month, day, hour, minute, second):
"""Set the RTC time."""
second_bcd = dec_to_bcd(second)
minute_bcd = dec_to_bcd(minute)
hour_bcd = dec_to_bcd(hour)
day_bcd = dec_to_bcd(day)
month_bcd = dec_to_bcd(month)
year_bcd = dec_to_bcd(year - 2000) # RTC stores years since 2000
bus.write_i2c_block_data(DS3231_ADDR, REG_TIME, [
second_bcd, minute_bcd, hour_bcd, 0x01, day_bcd, month_bcd, year_bcd
])
def main():
if len(sys.argv) != 2:
print("Usage: python3 set_rtc.py 'YYYY-MM-DD HH:MM:SS'")
sys.exit(1)
rtc_time_str = sys.argv[1]
rtc_time = datetime.strptime(rtc_time_str, '%Y-%m-%d %H:%M:%S')
bus = smbus2.SMBus(1)
set_rtc(bus, rtc_time.year, rtc_time.month, rtc_time.day,
rtc_time.hour, rtc_time.minute, rtc_time.second)
print(f"RTC updated to: {rtc_time}")
if __name__ == "__main__":
main()

14
SARA/PPP/README.md Normal file
View File

@@ -0,0 +1,14 @@
# PPP activation
Une fois la connexion PPP activée on peut retrouver la connexion pp0 avec `ifconfig`.
### Test avec curl
On peut forcer l'utilisation du réseau pp0 avec curl:
`curl --interface ppp0 https://ifconfig.me`
ou avec ping:
`ping -I ppp0 google.com`

4
SARA/PPP/activate_ppp.sh Normal file
View File

@@ -0,0 +1,4 @@
sudo pppd /dev/ttyAMA2 115200 \
connect '/usr/sbin/chat -v -s "" "AT" OK "ATD*99#" CONNECT' \
noauth debug dump nodetach nocrtscts

121
SARA/R5/setPDP.py Normal file
View File

@@ -0,0 +1,121 @@
'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to set the PDP context for the SARA R5
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/R5/setPDP.py
'''
import serial
import time
import sys
import json
import re
ser_sara = serial.Serial(
port='/dev/ttyAMA2',
baudrate=115200, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None, debug=True):
'''
Fonction très importante !!!
Reads the complete response from a serial connection and waits for specific lines.
'''
if wait_for_lines is None:
wait_for_lines = [] # Default to an empty list if not provided
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for any target line
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
if debug:
print(f"[DEBUG] 🔎 Found target line: {target_line} (in {elapsed_time:.2f}s)")
return decoded_response # Return response immediately if a target line is found
elif time.time() > end_time:
if debug:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
if debug:
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
# Check if the elapsed time exceeded 10 seconds
if total_elapsed_time > 10 and debug:
print(f"[ALERT] 🚨 The operation took too long 🚨")
print(f'<span style="color: red;font-weight: bold;">[ALERT] ⚠️{total_elapsed_time:.2f}s⚠</span>')
return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
try:
print('Start script')
# 1. Check connection
print('Check SARA R5 connexion')
command = f'ATI0\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_1 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_1, end="")
time.sleep(1)
# 2. Activate PDP context 1
print('Activate PDP context 1')
command = f'AT+CGACT=1,1\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_2, end="")
time.sleep(1)
# 2. Set the PDP type
print('Set the PDP type to IPv4 referring to the outputof the +CGDCONT read command')
command = f'AT+UPSD=0,0,0\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_3 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_3, end="")
time.sleep(1)
# 2. Profile #0 is mapped on CID=1.
print('Profile #0 is mapped on CID=1.')
command = f'AT+UPSD=0,100,1\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_3 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_3, end="")
time.sleep(1)
# 2. Set the PDP type
print('Activate the PSD profile #0: the IPv4 address is already assigned by the network.')
command = f'AT+UPSDA=0,3\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_3 = read_complete_response(ser_sara, wait_for_lines=["OK","+UUPSDA"])
print(response_SARA_3, end="")
time.sleep(1)
except Exception as e:
print("An error occurred:", e)
traceback.print_exc() # This prints the full traceback

View File

@@ -25,38 +25,35 @@ url = parameter[1] # ex: data.mobileair.fr
profile_id = 3
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
baudrate = 115200
send_uSpot = False
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
send_uSpot = config.get('send_uSpot', False)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2):
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_line=None):
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for the specific line
if wait_for_line:
decoded_response = response.decode('utf-8', errors='replace')
if wait_for_line in decoded_response:
print(f"[DEBUG] 🔎Found target line: {wait_for_line}")
break
elif time.time() > end_time:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
return response.decode('utf-8', errors='replace')
ser_sara = serial.Serial(
@@ -97,7 +94,8 @@ try:
print("Trigger POST REQUEST")
command = f'AT+UHTTPC={profile_id},1,"/pro_4G/test.php","http.resp"\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_6 = read_complete_response(ser_sara)
response_SARA_6 = read_complete_response(ser_sara, timeout=5, end_of_response_timeout=50, wait_for_line="+UUHTTPCR")
print(response_SARA_6)
time.sleep(1)

View File

@@ -26,23 +26,8 @@ url = parameter[1] # ex: data.mobileair.fr
profile_id = 3
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
send_uSpot = config.get('send_uSpot', False)
baudrate = 115200
send_uSpot = False
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2):
response = bytearray()

View File

@@ -1,7 +1,10 @@
'''
Script to set the URL for a HTTP request and trigger the POST Request
Ex:
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/full_test_HTTPS_POST.py ttyAMA2 api-prod.uspot.probesys.net
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/full_test_HTTPS_POST.py ttyAMA2 api-prod.uspot.probesys.net /nebuleair?token=2AFF6dQk68daFZ
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/full_test_HTTPS_POST.py ttyAMA2 webhook.site /0904d7b1-2558-43b9-8b35-df5bc40df967
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/full_test_HTTPS_POST.py ttyAMA2 aircarto.fr /tests/test.php
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/full_test_HTTPS_POST.py ttyAMA2 ssl.aircarto.fr /test.php
First profile id:
@@ -22,25 +25,11 @@ parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
port='/dev/'+parameter[0] # ex: ttyAMA2
url = parameter[1] # ex: data.mobileair.fr
endpoint = parameter[2]
profile_id = 2
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
send_uSpot = config.get('send_uSpot', False)
baudrate = 115200
send_uSpot = False
def color_text(text, color):
colors = {
@@ -96,15 +85,15 @@ ser_sara = serial.Serial(
try:
#step 1: import the certificate
print("****")
with open("/var/www/nebuleair_pro_4g/SARA/SSL/certificate/e6.der", "rb") as cert_file:
certificate_name = "e6"
with open("/var/www/nebuleair_pro_4g/SARA/SSL/certificate/e6.pem", "rb") as cert_file:
certificate = cert_file.read()
size_of_string = len(certificate)
print("\033[0;33m Import certificate\033[0m")
# AT+USECMNG=0,<type>,<internal_name>,<data_size>
# type-> 0 -> trusted root CA
command = f'AT+USECMNG=0,0,"e6",{size_of_string}\r'
command = f'AT+USECMNG=0,0,"{certificate_name}",{size_of_string}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_1 = read_complete_response(ser_sara)
print(response_SARA_1)
@@ -129,30 +118,34 @@ try:
# *******************************
# SECURITY PROFILE
# AT+USECPRF=<profile_id>[,<op_code>[,<param_val>]]
security_profile_id = 1
# op_code: 0 -> certificate validation level
# param_val : 0 -> Level 0 No validation; 1-> Level 1 Root certificate validation
security_profile_id = 0
print("\033[0;33mSet the security profile (params)\033[0m")
command = f'AT+USECPRF={security_profile_id},0,0\r'
certification_level=0
command = f'AT+USECPRF={security_profile_id},0,{certification_level}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5b = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5b)
time.sleep(0.5)
# op_code: 1 -> minimum SSL/TLS version
# param_val : 0 -> any; server can use any version for the connection; 1-> LSv1.0;
# param_val : 0 -> any; server can use any version for the connection; 1-> LSv1.0; 2->TLSv1.1; 3->TLSv1.2;
print("\033[0;33mSet the security profile (params)\033[0m")
command = f'AT+USECPRF={security_profile_id},1,0\r'
minimum_SSL_version = 0
command = f'AT+USECPRF={security_profile_id},1,{minimum_SSL_version}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5bb = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5bb)
time.sleep(0.5)
#op_code: 2 -> cipher suite
#op_code: 2 -> legacy cipher suite selection
# 0 (factory-programmed value): a list of default cipher suites is proposed at the beginning of handshake process, and a cipher suite will be negotiated among the cipher suites proposed in the list.
print("\033[0;33mSet cipher \033[0m")
command = f'AT+USECPRF={security_profile_id},2,0\r'
cipher_suite = 0
command = f'AT+USECPRF={security_profile_id},2,{cipher_suite}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5cc = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5cc)
@@ -160,12 +153,21 @@ try:
# op_code: 3 -> trusted root certificate internal name
print("\033[0;33mSet the security profile (choose cert)\033[0m")
command = f'AT+USECPRF={security_profile_id},3,"e6"\r'
command = f'AT+USECPRF={security_profile_id},3,"{certificate_name}"\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5c = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5c)
time.sleep(0.5)
# op_code: 10 -> SNI (server name indication)
print("\033[0;33mSet the SNI\033[0m")
command = f'AT+USECPRF={security_profile_id},10,"{url}"\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5cf = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5cf)
time.sleep(0.5)
# *************************
# *************************
@@ -256,10 +258,12 @@ try:
#step 4: trigger the request (http_command=1 for GET and http_command=1 for POST)
print("****")
print("\033[0;33mPOST REQUEST\033[0m")
#parameter (POST)
command = f'AT+UHTTPC={profile_id},4,"{endpoint}","https.resp","sensordata_json.json",4\r'
#AIRCARTO
#command = f'AT+UHTTPC={profile_id},4,"/tests/test.php","https.resp","sensordata_json.json",4\r'
#uSPOT
command = f'AT+UHTTPC={profile_id},4,"/nebuleair?token=2AFF6dQk68daFZ","https.resp","sensordata_json.json",4\r'
#command = f'AT+UHTTPC={profile_id},4,"/nebuleair?token=2AFF6dQk68daFZ","https.resp","sensordata_json.json",4\r'
#AtmoSud
#command = f'AT+UHTTPC={profile_id},1,"/","https.resp"\r'
#Webhook
@@ -271,7 +275,7 @@ try:
# Wait for the +UUHTTPCR response
print("Waiting for +UUHTTPCR response...")
response_SARA_3 = read_complete_response(ser_sara, timeout=5, end_of_response_timeout=30, wait_for_line="+UUHTTPCR")
response_SARA_3 = read_complete_response(ser_sara, timeout=5, end_of_response_timeout=50, wait_for_line="+UUHTTPCR")
print("\033[0;34m")
print(response_SARA_3)
@@ -307,7 +311,7 @@ try:
print(response_SARA_8)
# Get error code
print("\033[0;33mEmpty Memory\033[0m")
print("\033[0;33mGet error code\033[0m")
command = f'AT+UHTTPER={profile_id}\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_9 = read_complete_response(ser_sara, wait_for_line="OK")
@@ -321,8 +325,10 @@ try:
3 HTTP Protocol error class
10 Wrong HTTP API USAGE
error_code
error_code (for error_class 3 or 10)
0 No error
11 Server connection error
22 PSD or CSD connection not established
73 Secure socket connect error
'''

View File

@@ -4,6 +4,8 @@ FONCTIONNE SUR data.nebuleair.fr
FONCTIONNE SUR uSpot
Ex:
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/full_test_HTTP_POST.py ttyAMA2 api-prod.uspot.probesys.net
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/full_test_HTTP_POST.py ttyAMA2 aircarto.fr /tests/test.php
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/full_test_HTTP_POST.py ttyAMA2 ssl.aircarto.fr /test.php
To do: need to add profile id as parameter
@@ -25,25 +27,12 @@ parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
port='/dev/'+parameter[0] # ex: ttyAMA2
url = parameter[1] # ex: data.mobileair.fr
profile_id = 1
endpoint = parameter[2]
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
profile_id = 3
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
send_uSpot = config.get('send_uSpot', False)
baudrate = 115200
send_uSpot = False
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2):
response = bytearray()
@@ -82,19 +71,11 @@ try:
print(response_SARA_5)
time.sleep(1)
#step 4: set url to SSL (op_code = 6) (http_secure = 1 for HTTPS)(USECMNG_PROFILE = 2)
print("****")
print("SET SSL")
command = f'AT+UHTTP={profile_id},6,0\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_5 = read_complete_response(ser_sara)
print(response_SARA_5)
time.sleep(1)
#step 4: set PORT (op_code = 5)
print("****")
print("SET PORT")
command = f'AT+UHTTP={profile_id},5,81\r'
command = f'AT+UHTTP={profile_id},5,80\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_55 = read_complete_response(ser_sara)
print(response_SARA_55)
@@ -163,10 +144,14 @@ try:
#step 4: trigger the request (http_command=1 for GET and http_command=1 for POST)
print("****")
print("Trigger POST REQUEST")
command = f'AT+UHTTPC={profile_id},4,"{endpoint}","http.resp","sensordata_json.json",4\r'
#AirCarto
#command = f'AT+UHTTPC={profile_id},1,"/tests/test.php","http.resp"\r'
command = f'AT+UHTTPC={profile_id},4,"/nebuleair?token=2AFF6dQk68daFZ","http.resp","sensordata_json.json",4\r'
#command = f'AT+UHTTPC={profile_id},4,"/wifi.php","http.resp","sensordata_json.json",4\r'
#command = f'AT+UHTTPC={profile_id},4,"/pro_4G/data.php?sensor_id=52E7573A","http.resp","sensordata_json.json",4\r'
#AtmoSud
#command = f'AT+UHTTPC={profile_id},4,"/nebuleair?token=2AFF6dQk68daFZ","http.resp","sensordata_json.json",4\r'
ser_sara.write(command.encode('utf-8'))

View File

@@ -21,23 +21,8 @@ port='/dev/'+parameter[0] # ex: ttyAMA2
url = parameter[1] # ex: data.mobileair.fr
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
send_uSpot = config.get('send_uSpot', False)
baudrate = 115200
send_uSpot = False
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2):
response = bytearray()

View File

@@ -23,24 +23,8 @@ parameter = sys.argv[1:] # Exclude the script name
port='/dev/'+parameter[0] # ex: ttyAMA2
url = parameter[1] # ex: data.mobileair.fr
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
send_uSpot = config.get('send_uSpot', False)
baudrate = 115200
send_uSpot = False
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2):
response = bytearray()

View File

@@ -14,19 +14,7 @@ parameter = sys.argv[1:] # Exclude the script name
port = '/dev/' + parameter[0] # e.g., ttyAMA2
timeout = float(parameter[1]) # e.g., 2 seconds
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
config = load_config(config_file)
baudrate = config.get('SaraR4_baudrate', 115200)
baudrate = 115200
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2):
response = bytearray()

325
SARA/SSL/test_22.py Executable file
View File

@@ -0,0 +1,325 @@
'''
Script to set the URL for a HTTP request and trigger the POST Request
Ex:
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 api-prod.uspot.probesys.net /nebuleair?token=2AFF6dQk68daFZ
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 webhook.site /6bee2237-099a-4ff4-8452-9f4126df7151
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 aircarto.fr /tests/test.php
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 ssl.aircarto.fr /test.php
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 vps.aircarto.fr /test.php
First profile id:
AT+UHTTP=0,1,"data.nebuleair.fr"
Second profile id:
AT+UHTTP=1,1,"api-prod.uspot.probesys.net"
Third profile id:
AT+UHTTP=2,1,"aircarto.fr"
'''
import serial
import time
import sys
import json
parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
port='/dev/'+parameter[0] # ex: ttyAMA2
url = parameter[1] # ex: data.mobileair.fr
endpoint = parameter[2]
profile_id = 3
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
send_uSpot = config.get('send_uSpot', False)
def color_text(text, color):
colors = {
"red": "\033[31m",
"green": "\033[32m",
"yellow": "\033[33m",
"blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
}
reset = "\033[0m"
return f"{colors.get(color, '')}{text}{reset}"
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_line=None):
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for the specific line
if wait_for_line:
decoded_response = response.decode('utf-8', errors='replace')
if wait_for_line in decoded_response:
print(f"[DEBUG] 🔎Found target line: {wait_for_line}")
break
elif time.time() > end_time:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
return response.decode('utf-8', errors='replace')
ser_sara = serial.Serial(
port=port, #USB0 or ttyS0
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
)
try:
#step 1: import the certificate
print("****")
with open("/var/www/nebuleair_pro_4g/SARA/SSL/certificate/isrgrootx1.der", "rb") as cert_file:
certificate = cert_file.read()
size_of_string = len(certificate)
print("\033[0;33m Import certificate\033[0m")
# AT+USECMNG=0,<type>,<internal_name>,<data_size>
# type-> 0 -> trusted root CA
command = f'AT+USECMNG=0,0,"isrgrootx1",{size_of_string}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_1 = read_complete_response(ser_sara)
print(response_SARA_1)
time.sleep(0.5)
print("\033[0;33mAdd certificate\033[0m")
ser_sara.write(certificate)
response_SARA_2 = read_complete_response(ser_sara)
print(response_SARA_2)
time.sleep(0.5)
#check certificate (List all available certificates and private keys)
print("\033[0;33mCheck certificate\033[0m")
command = f'AT+USECMNG=3\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5b = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5b)
time.sleep(0.5)
# *******************************
# SECURITY PROFILE
# AT+USECPRF=<profile_id>[,<op_code>[,<param_val>]]
security_profile_id = 2
# op_code: 0 -> certificate validation level
# param_val : 0 -> Level 0 No validation; 1-> Level 1 Root certificate validation
print("\033[0;33mSet the security profile (params)\033[0m")
certification_level=1
command = f'AT+USECPRF={security_profile_id},0,{certification_level}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5b = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5b)
time.sleep(0.5)
# op_code: 3 -> trusted root certificate internal name
print("\033[0;33mSet the security profile (choose cert)\033[0m")
command = f'AT+USECPRF={security_profile_id},3,"isrgrootx1"\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5c = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5c)
time.sleep(0.5)
# *************************
# *************************
#step 4: set url (op_code = 1)
print("\033[0;33mSET URL\033[0m")
command = f'AT+UHTTP={profile_id},1,"{url}"\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5 = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5)
time.sleep(1)
#step 4: set url to SSL (op_code = 6) (http_secure = 1 for HTTPS)(USECMNG_PROFILE = 2)
print("\033[0;33mSET SSL\033[0m")
http_secure = 1
command = f'AT+UHTTP={profile_id},6,{http_secure},{security_profile_id}\r'
#command = f'AT+UHTTP={profile_id},6,{http_secure}\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_5 = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5)
time.sleep(1)
# Write Data to saraR4
payload_json = {
"nebuleairid": "C04F8B8D3A08",
"software_version": "ModuleAir-V1-012023",
"sensordatavalues": [
{
"value_type": "NPM_P0",
"value": "2.3"
},
{
"value_type": "NPM_P0",
"value": "3.30"
},
{
"value_type": "NPM_P1",
"value": "9.05"
},
{
"value_type": "NPM_P2",
"value": "20.60"
},
{
"value_type": "NPM_N1",
"value": "49.00"
},
{
"value_type": "NPM_N10",
"value": "49.00"
},
{
"value_type": "NPM_N25",
"value": "49.00"
},
{
"value_type": "BME280_temperature",
"value": "25.82"
}
]
}
# 1. Open sensordata_csv.json (with correct data size)
payload_string = json.dumps(payload_json) # Convert dict to JSON string
size_of_string = len(payload_string)
print("\033[0;33mOPEN JSON\033[0m")
command = f'AT+UDWNFILE="sensordata_json.json",{size_of_string}\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_1 = read_complete_response(ser_sara, wait_for_line=">")
print(response_SARA_1)
time.sleep(0.5)
#2. Write to shell
print("\033[0;33mWrite to Memory\033[0m")
ser_sara.write(payload_string.encode())
response_SARA_2 = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_2)
#step 4: trigger the request (http_command=1 for GET and http_command=1 for POST)
print("****")
print("\033[0;33mPOST REQUEST\033[0m")
#parameter (POST)
command = f'AT+UHTTPC={profile_id},4,"{endpoint}","https.resp","sensordata_json.json",4\r'
#AIRCARTO
#command = f'AT+UHTTPC={profile_id},4,"/tests/test.php","https.resp","sensordata_json.json",4\r'
#uSPOT
#command = f'AT+UHTTPC={profile_id},4,"/nebuleair?token=2AFF6dQk68daFZ","https.resp","sensordata_json.json",4\r'
#AtmoSud
#command = f'AT+UHTTPC={profile_id},1,"/","https.resp"\r'
#Webhook
#command = f'AT+UHTTPC={profile_id},4,"/6bee2237-099a-4ff4-8452-9f4126df7151","https.resp","sensordata_json.json",4\r'
ser_sara.write(command.encode('utf-8'))
# Wait for the +UUHTTPCR response
print("Waiting for +UUHTTPCR response...")
response_SARA_3 = read_complete_response(ser_sara, timeout=5, end_of_response_timeout=30, wait_for_line="+UUHTTPCR")
print("\033[0;34m")
print(response_SARA_3)
print("\033[0m")
if "+UUHTTPCR" in response_SARA_3:
print("✅ Received +UUHTTPCR response.")
lines = response_SARA_3.strip().splitlines()
http_response = lines[-1] # "+UUHTTPCR: 0,4,0"
parts = http_response.split(',')
# code 0 (HTTP failed)
if len(parts) == 3 and parts[-1] == '0': # The third value indicates success
print("\033[0;31mATTENTION: HTTP operation failed\033[0m")
else:
print("\033[0;32m HTTP operation successful!!!\033[0m")
#READ REPLY
print("****")
print("\033[0;33mREPLY SERVER\033[0m")
ser_sara.write(b'AT+URDFILE="https.resp"\r')
response_SARA_7 = read_complete_response(ser_sara, wait_for_line="OK")
print("Reply from server:")
print("\033[0;32m")
print(response_SARA_7)
print("\033[0m")
#5. empty json
print("\033[0;33mEmpty Memory\033[0m")
ser_sara.write(b'AT+UDELFILE="sensordata_json.json"\r')
response_SARA_8 = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_8)
# Get error code
print("\033[0;33mEmpty Memory\033[0m")
command = f'AT+UHTTPER={profile_id}\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_9 = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_9)
'''
+UHTTPER: profile_id,error_class,error_code
error_class
0 OK, no error
3 HTTP Protocol error class
10 Wrong HTTP API USAGE
error_code (for error_class 3)
0 No error
11 Server connection error
73 Secure socket connect error
'''
except serial.SerialException as e:
print(f"Error: {e}")
finally:
if ser_sara.is_open:
ser_sara.close()
print("****")
#print("Serial closed")

133
SARA/SSL/test_33.py Executable file
View File

@@ -0,0 +1,133 @@
'''
Script to set the URL for a HTTP request and trigger the POST Request
Ex:
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 api-prod.uspot.probesys.net /nebuleair?token=2AFF6dQk68daFZ
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 webhook.site /6bee2237-099a-4ff4-8452-9f4126df7151
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 aircarto.fr /tests/test.php
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 ssl.aircarto.fr /test.php
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/test_22.py ttyAMA2 vps.aircarto.fr /test.php
First profile id:
AT+UHTTP=0,1,"data.nebuleair.fr"
Second profile id:
AT+UHTTP=1,1,"api-prod.uspot.probesys.net"
Third profile id:
AT+UHTTP=2,1,"aircarto.fr"
'''
import serial
import time
import sys
import json
parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
port='/dev/'+parameter[0] # ex: ttyAMA2
url = parameter[1] # ex: data.mobileair.fr
endpoint = parameter[2]
profile_id = 3
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
send_uSpot = config.get('send_uSpot', False)
def color_text(text, color):
colors = {
"red": "\033[31m",
"green": "\033[32m",
"yellow": "\033[33m",
"blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
}
reset = "\033[0m"
return f"{colors.get(color, '')}{text}{reset}"
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_line=None):
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for the specific line
if wait_for_line:
decoded_response = response.decode('utf-8', errors='replace')
if wait_for_line in decoded_response:
print(f"[DEBUG] 🔎Found target line: {wait_for_line}")
break
elif time.time() > end_time:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
return response.decode('utf-8', errors='replace')
ser_sara = serial.Serial(
port=port, #USB0 or ttyS0
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
)
try:
#check certificate (List all available certificates and private keys)
print("\033[0;33mCheck certificate\033[0m")
command = f'AT+USECMNG=3\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5b = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5b)
time.sleep(0.5)
#security layer
#1
print("\033[0;33mCheck certificate\033[0m")
command = f'AT+USECPRF=<profile_id>,<op_code>\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5b = read_complete_response(ser_sara, wait_for_line="OK")
print(response_SARA_5b)
time.sleep(0.5)
except serial.SerialException as e:
print(f"Error: {e}")
finally:
if ser_sara.is_open:
ser_sara.close()
print("****")
#print("Serial closed")

View File

@@ -0,0 +1,12 @@
'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to read UDP message
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/UDP/receiveUDP_downlink.py
'''

129
SARA/UDP/sendUDP_message.py Normal file
View File

@@ -0,0 +1,129 @@
'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to send UDP message
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/UDP/sendUDP_message.py
'''
import serial
import time
import sys
import json
import re
ser_sara = serial.Serial(
port='/dev/ttyAMA2',
baudrate=115200, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None, debug=True):
'''
Fonction très importante !!!
Reads the complete response from a serial connection and waits for specific lines.
'''
if wait_for_lines is None:
wait_for_lines = [] # Default to an empty list if not provided
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for any target line
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
if debug:
print(f"[DEBUG] 🔎 Found target line: {target_line} (in {elapsed_time:.2f}s)")
return decoded_response # Return response immediately if a target line is found
elif time.time() > end_time:
if debug:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
if debug:
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
# Check if the elapsed time exceeded 10 seconds
if total_elapsed_time > 10 and debug:
print(f"[ALERT] 🚨 The operation took too long 🚨")
print(f'<span style="color: red;font-weight: bold;">[ALERT] ⚠️{total_elapsed_time:.2f}s⚠</span>')
return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
try:
print('Start script')
# Increase verbosity
command = f'AT+CMEE=2\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_1 = read_complete_response(ser_sara, wait_for_lines=["OK", "ERROR"])
print(response_SARA_1, end="")
time.sleep(1)
# 1. Create SOCKET
print('Create SOCKET')
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", "ERROR"])
print(response_SARA_1, end="")
time.sleep(1)
# 2. Retreive Socket ID
match = re.search(r'\+USOCR:\s*(\d+)', response_SARA_1)
if match:
socket_id = match.group(1)
print(f"Socket ID: {socket_id}")
else:
print("Failed to extract socket ID")
#3. Connect to UDP server
print("Connect to server:")
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(response_SARA_2)
# 4. Write data and send
print("Write data:")
command = f'AT+USOWR={socket_id},10\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(response_SARA_2)
ser_sara.write("1234567890".encode())
response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["@","OK", "+CME ERROR", "ERROR"], debug=False)
print(response_SARA_2)
#Close socket
print("Close socket:")
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)
print(response_SARA_2)
except Exception as e:
print("An error occurred:", e)
traceback.print_exc() # This prints the full traceback

109
SARA/cellLocate/get_loc.py Normal file
View File

@@ -0,0 +1,109 @@
'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to get Location from GSM
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/cellLocate/get_loc.py ttyAMA2 1
AT+ULOC=
<mode>, ->2 -> single shot position
<sensor>, ->2 -> use cellulare CellLocate
<response_type>, ->0 -> standard
<timeout>, ->2 -> seconds
<accuracy> ->1 -> in meters
[,<num_hypothesis>]
exemple: AT+ULOC=2,2,0,2,1
'''
import serial
import time
import sys
import json
parameter = sys.argv[1:] # Exclude the script name
port='/dev/'+parameter[0] # ex: ttyAMA2
timeout = float(parameter[1]) # ex:2
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
ser = serial.Serial(
port=port, #USB0 or ttyS0
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = timeout
)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None, debug=True):
'''
Fonction très importante !!!
Reads the complete response from a serial connection and waits for specific lines.
'''
if wait_for_lines is None:
wait_for_lines = [] # Default to an empty list if not provided
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for any target line
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
if debug:
print(f"[DEBUG] 🔎 Found target line: {target_line} (in {elapsed_time:.2f}s)")
return decoded_response # Return response immediately if a target line is found
elif time.time() > end_time:
if debug:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
if debug:
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
# Check if the elapsed time exceeded 10 seconds
if total_elapsed_time > 10 and debug:
print(f"[ALERT] 🚨 The operation took too long 🚨")
print(f'<span style="color: red;font-weight: bold;">[ALERT] ⚠️{total_elapsed_time:.2f}s⚠</span>')
return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
#command = f'ATI\r'
command = f'AT+ULOC=2,2,0,2,1\r'
ser.write((command + '\r').encode('utf-8'))
response = read_complete_response(ser, wait_for_lines=["+UULOC"])
print(response)

View File

@@ -0,0 +1,103 @@
'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to Configures the network connection to a Multi GNSS Assistance (MGA) server used also per CellLocate
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/cellLocate/server_conf.py ttyAMA2 1
AT+UGSRV="cell-live1.services.u-blox.com","cell-live2.services.u-blox.com","XkEKfGqVSbmNE1eZfBZm4Q"
'''
import serial
import time
import sys
import json
parameter = sys.argv[1:] # Exclude the script name
port='/dev/'+parameter[0] # ex: ttyAMA2
timeout = float(parameter[1]) # ex:2
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
ser = serial.Serial(
port=port, #USB0 or ttyS0
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = timeout
)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None, debug=True):
'''
Fonction très importante !!!
Reads the complete response from a serial connection and waits for specific lines.
'''
if wait_for_lines is None:
wait_for_lines = [] # Default to an empty list if not provided
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for any target line
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
if debug:
print(f"[DEBUG] 🔎 Found target line: {target_line} (in {elapsed_time:.2f}s)")
return decoded_response # Return response immediately if a target line is found
elif time.time() > end_time:
if debug:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
if debug:
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
# Check if the elapsed time exceeded 10 seconds
if total_elapsed_time > 10 and debug:
print(f"[ALERT] 🚨 The operation took too long 🚨")
print(f'<span style="color: red;font-weight: bold;">[ALERT] ⚠️{total_elapsed_time:.2f}s⚠</span>')
return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
#command = f'ATI\r'
command = f'AT+UGSRV="cell-live1.services.u-blox.com","cell-live2.services.u-blox.com","XkEKfGqVSbmNE1eZfBZm4Q"\r'
ser.write((command + '\r').encode('utf-8'))
response = read_complete_response(ser, wait_for_lines=["+UULOC"])
print(response)

27
SARA/check_running.py Executable file
View File

@@ -0,0 +1,27 @@
'''
Check if the main loop is running
/usr/bin/python3 /var/www/nebuleair_pro_4g/tests/check_running.py
'''
import psutil
import json
def is_script_running(script_name):
"""Check if a given Python script is currently running."""
for process in psutil.process_iter(['pid', 'cmdline']):
if process.info['cmdline'] and script_name in " ".join(process.info['cmdline']):
return True # Script is running
return False # Script is not running
script_to_check = "/var/www/nebuleair_pro_4g/loop/SARA_send_data_v2.py"
# Determine script status
is_running = is_script_running(script_to_check)
# Create JSON response
response = {
"message": "The script is still running.❌❌❌" if is_running else "The script is NOT running.✅✅✅",
"running": is_running
}
# Print JSON output
print(json.dumps(response, indent=4)) # Pretty print for readability

400
SARA/reboot/start.py Normal file
View File

@@ -0,0 +1,400 @@
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script that starts at the boot of the RPI (with cron)
@reboot sleep 30 && /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/reboot/start.py >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/reboot/start.py
'''
import serial
import RPi.GPIO as GPIO
import time
import sys
import json
import re
import sqlite3
import traceback
#GPIO
SARA_power_GPIO = 16
SARA_ON_GPIO = 20
GPIO.setmode(GPIO.BCM) # Use BCM numbering
GPIO.setup(SARA_power_GPIO, GPIO.OUT) # Set GPIO17 as an output
# database connection
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
#get config data from SQLite table
def load_config_sqlite():
"""
Load configuration data from SQLite config table
Returns:
dict: Configuration data with proper type conversion
"""
try:
# Query the config table
cursor.execute("SELECT key, value, type FROM config_table")
rows = cursor.fetchall()
# Create config dictionary
config_data = {}
for key, value, type_name in rows:
# Convert value based on its type
if type_name == 'bool':
config_data[key] = value == '1' or value == 'true'
elif type_name == 'int':
config_data[key] = int(value)
elif type_name == 'float':
config_data[key] = float(value)
else:
config_data[key] = value
return config_data
except Exception as e:
print(f"Error loading config from SQLite: {e}")
return {}
def update_sqlite_config(key, value):
"""
Updates a specific key in the SQLite config_table with a new value.
:param key: The key to update in the config_table.
:param value: The new value to assign to the key.
"""
try:
# Check if the key exists and get its type
cursor.execute("SELECT type FROM config_table WHERE key = ?", (key,))
result = cursor.fetchone()
if result is None:
print(f"Key '{key}' not found in the config_table.")
conn.close()
return
# Get the type of the value from the database
value_type = result[0]
# Convert the value to the appropriate string representation based on its type
if value_type == 'bool':
# Convert Python boolean or string 'true'/'false' to '1'/'0'
if isinstance(value, bool):
str_value = '1' if value else '0'
else:
str_value = '1' if str(value).lower() in ('true', '1', 'yes', 'y') else '0'
elif value_type == 'int':
str_value = str(int(value))
elif value_type == 'float':
str_value = str(float(value))
else:
str_value = str(value)
# Update the value in the database
cursor.execute("UPDATE config_table SET value = ? WHERE key = ?", (str_value, key))
# Commit the changes and close the connection
conn.commit()
print(f"💾 Updated '{key}' to '{value}' in database.")
except Exception as e:
print(f"Error updating the SQLite database: {e}")
#Load config
config = load_config_sqlite()
#config
baudrate = config.get('SaraR4_baudrate', 115200) #baudrate du sara R4
device_id = config.get('deviceID', '').upper() #device ID en maj
sara_r5_DPD_setup = False
ser_sara = serial.Serial(
port='/dev/ttyAMA2',
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None, debug=True):
'''
Fonction très importante !!!
Reads the complete response from a serial connection and waits for specific lines.
'''
if wait_for_lines is None:
wait_for_lines = [] # Default to an empty list if not provided
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for any target line
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
if debug:
print(f"[DEBUG] 🔎 Found target line: {target_line} (in {elapsed_time:.2f}s)")
return decoded_response # Return response immediately if a target line is found
elif time.time() > end_time:
if debug:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
if debug:
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
# Check if the elapsed time exceeded 10 seconds
if total_elapsed_time > 10 and debug:
print(f"[ALERT] 🚨 The operation took too long 🚨")
print(f'<span style="color: red;font-weight: bold;">[ALERT] ⚠️{total_elapsed_time:.2f}s⚠</span>')
return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
try:
print('<h3>Start reboot python script</h3>')
#First we need to power on the module (if connected to mosfet via gpio16)
GPIO.output(SARA_power_GPIO, GPIO.HIGH)
time.sleep(5)
#check modem status
#Attention:
# SARA R4 response: Manufacturer: u-blox Model: SARA-R410M-02B
# SArA R5 response: SARA-R500S-01B-00
print("Check SARA Status")
command = f'ATI\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_ATI = read_complete_response(ser_sara, wait_for_lines=["IMEI"])
print(response_SARA_ATI)
# Check for SARA model with more robust regex
model = "Unknown"
if "SARA-R410M" in response_SARA_ATI:
model = "SARA-R410M"
print("📱 Detected SARA R4 modem")
elif "SARA-R500" in response_SARA_ATI:
model = "SARA-R500"
print("📱 Detected SARA R5 modem")
sara_r5_DPD_setup = True
else:
# Fallback to regex match if direct string match fails
match = re.search(r"Model:\s*([A-Za-z0-9\-]+)", response_SARA_ATI)
if match:
model = match.group(1).strip()
else:
model = "Unknown"
print("⚠️ Could not identify modem model")
print(f"🔍 Model: {model}")
update_sqlite_config("modem_version", model)
time.sleep(1)
'''
AIRCARTO
'''
# 1. Set AIRCARTO URL (profile id = 0)
print('Set aircarto URL')
aircarto_profile_id = 0
aircarto_url="data.nebuleair.fr"
command = f'AT+UHTTP={aircarto_profile_id},1,"{aircarto_url}"\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_1 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_1)
time.sleep(1)
'''
uSpot
'''
print("Set uSpot URL with SSL")
security_profile_id = 1
uSpot_profile_id = 1
uSpot_url="api-prod.uspot.probesys.net"
#step 1: import the certificate
print("➡️ import certificate")
certificate_name = "e6"
with open("/var/www/nebuleair_pro_4g/SARA/SSL/certificate/e6.pem", "rb") as cert_file:
certificate = cert_file.read()
size_of_string = len(certificate)
# AT+USECMNG=0,<type>,<internal_name>,<data_size>
# type-> 0 -> trusted root CA
command = f'AT+USECMNG=0,0,"{certificate_name}",{size_of_string}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_1 = read_complete_response(ser_sara)
print(response_SARA_1)
time.sleep(0.5)
print("➡️ add certificate")
ser_sara.write(certificate)
response_SARA_2 = read_complete_response(ser_sara)
print(response_SARA_2)
time.sleep(0.5)
# op_code: 0 -> certificate validation level
# param_val : 0 -> Level 0 No validation; 1-> Level 1 Root certificate validation
print("Set the security profile (params)")
certification_level=0
command = f'AT+USECPRF={security_profile_id},0,{certification_level}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5b = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_5b)
time.sleep(0.5)
# op_code: 1 -> minimum SSL/TLS version
# param_val : 0 -> any; server can use any version for the connection; 1-> LSv1.0; 2->TLSv1.1; 3->TLSv1.2;
print("Set the security profile (params)")
minimum_SSL_version = 0
command = f'AT+USECPRF={security_profile_id},1,{minimum_SSL_version}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5bb = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_5bb)
time.sleep(0.5)
#op_code: 2 -> legacy cipher suite selection
# 0 (factory-programmed value): a list of default cipher suites is proposed at the beginning of handshake process, and a cipher suite will be negotiated among the cipher suites proposed in the list.
print("Set cipher")
cipher_suite = 0
command = f'AT+USECPRF={security_profile_id},2,{cipher_suite}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5cc = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_5cc)
time.sleep(0.5)
# op_code: 3 -> trusted root certificate internal name
print("Set the security profile (choose cert)")
command = f'AT+USECPRF={security_profile_id},3,"{certificate_name}"\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5c = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_5c)
time.sleep(0.5)
# op_code: 10 -> SNI (server name indication)
print("Set the SNI")
command = f'AT+USECPRF={security_profile_id},10,"{uSpot_url}"\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5cf = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_5cf)
time.sleep(0.5)
#step 4: set url (op_code = 1)
print("SET URL")
command = f'AT+UHTTP={uSpot_profile_id},1,"{uSpot_url}"\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_5 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_5)
time.sleep(1)
#step 4: set PORT (op_code = 5)
print("SET PORT")
port = 443
command = f'AT+UHTTP={uSpot_profile_id},5,{port}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_55 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_55)
time.sleep(1)
#step 4: set url to SSL (op_code = 6) (http_secure = 1 for HTTPS)(USECMNG_PROFILE = 2)
print("SET SSL")
http_secure = 1
command = f'AT+UHTTP={uSpot_profile_id},6,{http_secure},{security_profile_id}\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_5fg = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_5fg)
time.sleep(1)
'''
SARA R5
'''
if sara_r5_DPD_setup:
print("SARA R5 PDP SETUP")
# 2. Activate PDP context 1
print('Activate PDP context 1')
command = f'AT+CGACT=1,1\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_2, end="")
time.sleep(1)
# 2. Set the PDP type
print('Set the PDP type to IPv4 referring to the outputof the +CGDCONT read command')
command = f'AT+UPSD=0,0,0\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_3 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_3, end="")
time.sleep(1)
# 2. Profile #0 is mapped on CID=1.
print('Profile #0 is mapped on CID=1.')
command = f'AT+UPSD=0,100,1\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_3 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_3, end="")
time.sleep(1)
# 2. Set the PDP type
print('Activate the PSD profile #0: the IPv4 address is already assigned by the network.')
command = f'AT+UPSDA=0,3\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_3 = read_complete_response(ser_sara, wait_for_lines=["OK","+UUPSDA"])
print(response_SARA_3, end="")
time.sleep(1)
#3. Get localisation (CellLocate)
mode = 2 #single shot position
sensor = 2 #use cellular CellLocate® location information
response_type = 0
timeout_s = 2
accuracy_m = 1
command = f'AT+ULOC={mode},{sensor},{response_type},{timeout_s},{accuracy_m}\r'
ser_sara.write((command + '\r').encode('utf-8'))
response_SARA_3 = read_complete_response(ser_sara, wait_for_lines=["+UULOC"])
print(response_SARA_3)
match = re.search(r"\+UULOC: \d{2}/\d{2}/\d{4},\d{2}:\d{2}:\d{2}\.\d{3},([-+]?\d+\.\d+),([-+]?\d+\.\d+)", response_SARA_3)
if match:
latitude = match.group(1)
longitude = match.group(2)
print(f"📍 Latitude: {latitude}, Longitude: {longitude}")
#update sqlite table
update_sqlite_config("latitude_raw", float(latitude))
update_sqlite_config("longitude_raw", float(longitude))
else:
print("❌ Failed to extract coordinates.")
time.sleep(1)
except Exception as e:
print("An error occurred:", e)
traceback.print_exc() # This prints the full traceback

View File

@@ -1,11 +1,23 @@
'''
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to see if the SARA-R410 is running
ex:
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 ATI 2
ex 1 (get SIM infos)
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+CCID? 2
ex 2 (turn on blue light):
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UGPIOC=16,2 2
ex 3 (reconnect network)
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+COPS=1,2,20801 20
ex 4 (get HTTP Profiles)
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UHTTP? 2
ex 5 (get IP addr)
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+CGPADDR=1 2
'''
@@ -20,22 +32,10 @@ port='/dev/'+parameter[0] # ex: ttyAMA2
command = parameter[1] # ex: AT+CCID?
timeout = float(parameter[2]) # ex:2
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
baudrate = 115200
try:
ser = serial.Serial(
port=port, #USB0 or ttyS0
@@ -46,6 +46,9 @@ ser = serial.Serial(
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
@@ -63,25 +66,33 @@ ser.write((command + '\r').encode('utf-8'))
#ser.write(b'AT+CMUX=?')
try:
# Read lines until a timeout occurs
response_lines = []
while True:
line = ser.readline().decode('utf-8').strip()
if not line:
break # Break the loop if an empty line is encountered
start_time = time.time()
while (time.time() - start_time) < timeout:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
response_lines.append(line)
# Check if we received any data
if not response_lines:
print(f"ERROR: No response received from {port} after sending command: {command}")
sys.exit(1)
# Print the response
for line in response_lines:
print(line)
except serial.SerialException as e:
print(f"Error: {e}")
print(f"ERROR: Serial communication error: {e}")
sys.exit(1)
except Exception as e:
print(f"ERROR: Unexpected error: {e}")
sys.exit(1)
finally:
if ser.is_open:
# Close the serial port if it's open
if 'ser' in locals() and ser.is_open:
ser.close()
#print("Serial closed")

63
SARA/sara_checkDNS.py Normal file
View File

@@ -0,0 +1,63 @@
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to resolve DNS (get IP from domain name) with AT+UDNSRN command
Ex:
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_checkDNS.py ttyAMA2 data.nebuleair.fr
To do: need to add profile id as parameter
'''
import serial
import time
import sys
import json
parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
port='/dev/'+parameter[0] # ex: ttyAMA2
url = parameter[1] # ex: data.mobileair.fr
baudrate = 115200
ser = serial.Serial(
port=port, #USB0 or ttyS0
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
)
command = f'AT+UDNSRN=0,"{url}"\r'
ser.write((command + '\r').encode('utf-8'))
print("****")
print("DNS check")
try:
# Read lines until a timeout occurs
response_lines = []
while True:
line = ser.readline().decode('utf-8').strip()
if not line:
break # Break the loop if an empty line is encountered
response_lines.append(line)
# Print the response
for line in response_lines:
print(line)
except serial.SerialException as e:
print(f"Error: {e}")
finally:
if ser.is_open:
ser.close()
print("****")
#print("Serial closed")

View File

@@ -1,11 +1,18 @@
'''
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to connect SARA-R410 to network SARA-R410
python3 /var/www/nebuleair_pro_4g/SARA/sara_connectNetwork.py ttyAMA2 20801 10
AT+COPS=1,2,20801
mode->1 pour manual
format->2 pour numeric
operator->20801 pour orange
operator->20801 pour orange, 20810 pour SFR
'''
import serial
@@ -19,22 +26,54 @@ networkID = parameter[1] # ex: 20801
timeout = float(parameter[2]) # ex:2
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None, debug=True):
'''
Fonction très importante !!!
Reads the complete response from a serial connection and waits for specific lines.
'''
if wait_for_lines is None:
wait_for_lines = [] # Default to an empty list if not provided
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for any target line
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
if debug:
print(f"[DEBUG] 🔎 Found target line: {target_line} (in {elapsed_time:.2f}s)")
return decoded_response # Return response immediately if a target line is found
elif time.time() > end_time:
if debug:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
if debug:
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
# Check if the elapsed time exceeded 10 seconds
if total_elapsed_time > 10 and debug:
print(f"[ALERT] 🚨 The operation took too long 🚨")
print(f'<span style="color: red;font-weight: bold;">[ALERT] ⚠️{total_elapsed_time:.2f}s⚠</span>')
return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
baudrate = 115200
ser = serial.Serial(
port=port, #USB0 or ttyS0
@@ -50,17 +89,11 @@ ser.write((command + '\r').encode('utf-8'))
try:
# Read lines until a timeout occurs
response_lines = []
while True:
line = ser.readline().decode('utf-8').strip()
if not line:
break # Break the loop if an empty line is encountered
response_lines.append(line)
response = read_complete_response(ser, wait_for_lines=["OK", "ERROR"],timeout=5, end_of_response_timeout=120, debug=True)
# Print the response
for line in response_lines:
print(line)
print('<p class="text-danger-emphasis">')
print(response)
print("</p>", end="")
except serial.SerialException as e:
print(f"Error: {e}")

View File

@@ -11,22 +11,7 @@ parameter = sys.argv[1:] # Exclude the script name
port='/dev/'+parameter[0] # ex: ttyAMA2
message = parameter[1] # ex: Hello
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
baudrate = 115200
ser = serial.Serial(
port=port, #USB0 or ttyS0

58
SARA/sara_google_ping.py Normal file
View File

@@ -0,0 +1,58 @@
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to set the URL for a HTTP request
Ex:
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_google_ping.py
'''
import serial
import time
import sys
import json
baudrate = 115200
ser = serial.Serial(
port='/dev/ttyAMA2',
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
)
url="www.google.com"
command = f'AT+UPING="{url}"\r'
ser.write((command + '\r').encode('utf-8'))
try:
# Read lines until a timeout occurs
response_lines = []
while True:
line = ser.readline().decode('utf-8').strip()
if not line:
break # Break the loop if an empty line is encountered
response_lines.append(line)
# Print the response
for line in response_lines:
print(line)
except serial.SerialException as e:
print(f"Error: {e}")
finally:
if ser.is_open:
ser.close()
print("****")
#print("Serial closed")

165
SARA/sara_ping.py Normal file
View File

@@ -0,0 +1,165 @@
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to do a ping request to data.nebuleair.fr/ping.php
python3 /var/www/nebuleair_pro_4g/SARA/sara_ping.py
'''
import serial
import time
import sys
import json
# SARA R4 UHTTPC profile IDs
aircarto_profile_id = 0
baudrate = 115200
ser_sara = serial.Serial(
port='/dev/ttyAMA2',
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None, debug=True):
'''
Fonction très importante !!!
Reads the complete response from a serial connection and waits for specific lines.
'''
if wait_for_lines is None:
wait_for_lines = [] # Default to an empty list if not provided
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting)
response.extend(data)
end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for any target line
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
if debug:
print(f"[DEBUG] 🔎 Found target line: {target_line} (in {elapsed_time:.2f}s)")
return decoded_response # Return response immediately if a target line is found
elif time.time() > end_time:
if debug:
print(f"[DEBUG] Timeout reached. No more data received.")
break
time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time
if debug:
print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
# Check if the elapsed time exceeded 10 seconds
if total_elapsed_time > 10 and debug:
print(f"[ALERT] 🚨 The operation took too long 🚨")
print(f'<span style="color: red;font-weight: bold;">[ALERT] ⚠️{total_elapsed_time:.2f}s⚠</span>')
return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
def extract_error_code(response):
"""
Extract just the error code from AT+UHTTPER response
"""
for line in response.split('\n'):
if '+UHTTPER' in line:
try:
# Split the line and get the third value (error code)
parts = line.split(':')[1].strip().split(',')
if len(parts) >= 3:
error_code = int(parts[2])
return error_code
except:
pass
# Return None if we couldn't find the error code
return None
try:
#3. Send to endpoint (with device ID)
print("Send data (GET REQUEST):")
command= f'AT+UHTTPC={aircarto_profile_id},1,"/ping.php","aircarto_server_response.txt"\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_3 = read_complete_response(ser_sara, timeout=5, end_of_response_timeout=20, wait_for_lines=["+UUHTTPCR", "+CME ERROR"], debug=True)
print(response_SARA_3)
# si on recoit la réponse UHTTPCR
if "+UUHTTPCR" in response_SARA_3:
print("✅ Received +UUHTTPCR response.")
# Split response into lines
lines = response_SARA_3.strip().splitlines()
# 1.Vérifier si la réponse contient un message d'erreur CME
if "+CME ERROR" in lines[-1]:
print("error ⛔")
else:
http_response = lines[-1] # "+UUHTTPCR: 0,4,0"
parts = http_response.split(',')
# 2.1 code 0 (HTTP failed) ⛔⛔⛔
if len(parts) == 3 and parts[-1] == '0': # The third value indicates success
print("⛔⛔ATTENTION: HTTP operation failed")
#get error code
print("Getting error code (11->Server connection error, 73->Secure socket connect error)")
command = f'AT+UHTTPER={aircarto_profile_id}\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_9 = read_complete_response(ser_sara, wait_for_lines=["OK"], debug=False)
print('<p class="text-danger-emphasis">')
print(response_SARA_9)
print("</p>", end="")
# Extract just the error code
error_code = extract_error_code(response_SARA_9)
if error_code is not None:
# Display interpretation based on error code
if error_code == 0:
print('<p class="text-success">No error detected</p>')
elif error_code == 4:
print('<p class="text-danger">Error 4: Invalid server Hostname</p>')
elif error_code == 11:
print('<p class="text-danger">Error 11: Server connection error</p>')
elif error_code == 22:
print('<p class="text-danger">Error 22: PSD or CSD connection not established</p>')
elif error_code == 73:
print('<p class="text-danger">Error 73: Secure socket connect error</p>')
else:
print(f'<p class="text-danger">Unknown error code: {error_code}</p>')
else:
print('<p class="text-danger">Could not extract error code from response</p>')
# 2.2 code 1 (HHTP succeded)
else:
# Si la commande HTTP a réussi
print("✅✅HTTP operation successful")
#4. Read reply from server
print("Reply from server:")
ser_sara.write(b'AT+URDFILE="aircarto_server_response.txt"\r')
response_SARA_4 = read_complete_response(ser_sara, wait_for_lines=["OK"], debug=False)
print(response_SARA_4)
except serial.SerialException as e:
print(f"Error: {e}")
finally:
if ser_sara.is_open:
ser_sara.close()
#print("Serial closed")

View File

@@ -11,22 +11,7 @@ parameter = sys.argv[1:] # Exclude the script name
port='/dev/'+parameter[0] # ex: ttyAMA2
message = parameter[1] # ex: Hello
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
baudrate = 115200
ser = serial.Serial(
port=port, #USB0 or ttyS0

View File

@@ -10,23 +10,9 @@ import json
parameter = sys.argv[1:] # Exclude the script name
port='/dev/'+parameter[0] # ex: ttyAMA2
endpoint = parameter[1] # ex: /pro_4G/notif_message.php
profile_id = parameter[2]
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
baudrate = 115200
ser = serial.Serial(
port=port, #USB0 or ttyS0
@@ -37,7 +23,7 @@ ser = serial.Serial(
timeout = 2
)
command= f'AT+UHTTPC=0,4,"{endpoint}","data.txt","sensordata.json",4\r'
command= f'AT+UHTTPC={profile_id},4,"{endpoint}","data.txt","sensordata.json",4\r'
ser.write((command + '\r').encode('utf-8'))
try:

View File

@@ -1,4 +1,10 @@
'''
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to connect SARA-R410 to APN
AT+CGDCONT=1,"IP","data.mono"
@@ -15,23 +21,7 @@ port='/dev/'+parameter[0] # ex: ttyAMA2
apn_address = parameter[1] # ex: data.mono
timeout = float(parameter[2]) # ex:2
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
baudrate = 115200
ser = serial.Serial(
port=port, #USB0 or ttyS0
@@ -43,6 +33,8 @@ ser = serial.Serial(
)
command = f'AT+CGDCONT=1,"IP","{apn_address}"\r'
#command = f'AT+CGDCONT=1,"IPV4V6","{apn_address}"\r'
#command = f'AT+CGDCONT=1,"IP","{apn_address}",0,0\r'
ser.write((command + '\r').encode('utf-8'))

View File

@@ -1,8 +1,13 @@
'''
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Script to set the URL for a HTTP request
Ex:
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setURL.py ttyAMA2 data.nebuleair.fr
To do: need to add profile id as parameter
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setURL.py ttyAMA2 data.nebuleair.fr 0
First profile id:
AT+UHTTP=0,1,"data.nebuleair.fr"
@@ -19,24 +24,10 @@ parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
port='/dev/'+parameter[0] # ex: ttyAMA2
url = parameter[1] # ex: data.mobileair.fr
profile_id = parameter[2] #ex: 0
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
baudrate = 115200
ser = serial.Serial(
port=port, #USB0 or ttyS0
@@ -47,7 +38,7 @@ ser = serial.Serial(
timeout = 2
)
command = f'AT+UHTTP=0,1,"{url}"\r'
command = f'AT+UHTTP={profile_id},1,"{url}"\r'
ser.write((command + '\r').encode('utf-8'))
print("****")

View File

@@ -40,22 +40,7 @@ def read_complete_response(serial_connection, timeout=2, end_of_response_timeout
return response.decode('utf-8', errors='replace')
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
baudrate = 115200
ser_sara = serial.Serial(
port=port, #USB0 or ttyS0

View File

@@ -12,21 +12,7 @@ port='/dev/'+parameter[0] # ex: ttyAMA2
message = parameter[1] # ex: Hello
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
baudrate = 115200
ser = serial.Serial(
port=port, #USB0 or ttyS0

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.6.0

View File

@@ -2,26 +2,81 @@
# Script to check if wifi is connected and start hotspot if not
# will also retreive unique RPi ID and store it to deviceID.txt
# script that starts at boot:
# @reboot /var/www/nebuleair_pro_4g/boot_hotspot.sh >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
OUTPUT_FILE="/var/www/nebuleair_pro_4g/wifi_list.csv"
JSON_FILE="/var/www/nebuleair_pro_4g/config.json"
echo "-------------------"
echo "-------------------"
echo "NebuleAir pro started at $(date)"
echo "getting SARA R4 serial number"
chmod -R 777 /var/www/nebuleair_pro_4g/
# Blink GPIO 23 and 24 five times (starts OFF, stays OFF at end)
#gpioset -c gpiochip0 -t 1s,1s,1s,1s,1s,1s,1s,1s,1s,1s,0 23=0 24=0
# Blink GPIO 23 and 24 five times (starts OFF, stays OFF at end)
python3 << 'EOF'
import RPi.GPIO as GPIO
import time
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(23, GPIO.OUT)
GPIO.setup(24, GPIO.OUT)
for _ in range(5):
GPIO.output(23, GPIO.HIGH)
GPIO.output(24, GPIO.HIGH)
time.sleep(1)
GPIO.output(23, GPIO.LOW)
GPIO.output(24, GPIO.LOW)
time.sleep(1)
GPIO.cleanup()
EOF
echo "getting RPI serial number"
# Get the last 8 characters of the serial number and write to text file
serial_number=$(cat /proc/cpuinfo | grep Serial | awk '{print substr($3, length($3) - 7)}')
# Define the JSON file path
# Use jq to update the "deviceID" in the JSON file
jq --arg serial_number "$serial_number" '.deviceID = $serial_number' "$JSON_FILE" > temp.json && mv temp.json "$JSON_FILE"
# update Sqlite database (only if not already set, i.e., still has default value 'XXXX')
echo "Updating SQLite database with device ID: $serial_number"
sqlite3 /var/www/nebuleair_pro_4g/sqlite/sensors.db "UPDATE config_table SET value='$serial_number' WHERE key='deviceID' AND value='XXXX';"
echo "id: $serial_number"
#get the SSH port for tunneling
SSH_TUNNEL_PORT=$(jq -r '.sshTunnel_port' "$JSON_FILE")
# Get deviceID from SQLite config_table (may be different from serial_number if manually configured)
DEVICE_ID=$(sqlite3 /var/www/nebuleair_pro_4g/sqlite/sensors.db "SELECT value FROM config_table WHERE key='deviceID'")
echo "Device ID from database: $DEVICE_ID"
# Get deviceName from SQLite config_table for use in hotspot SSID
DEVICE_NAME=$(sqlite3 /var/www/nebuleair_pro_4g/sqlite/sensors.db "SELECT value FROM config_table WHERE key='deviceName'")
echo "Device Name from database: $DEVICE_NAME"
# Get SSH tunnel port from SQLite config_table
SSH_TUNNEL_PORT=$(sqlite3 /var/www/nebuleair_pro_4g/sqlite/sensors.db "SELECT value FROM config_table WHERE key='sshTunnel_port'")
#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)
@@ -32,46 +87,43 @@ if [ "$STATE" == "30 (disconnected)" ]; then
# Perform a wifi scan and save its output to a csv file
# nmcli device wifi list
nmcli -f SSID,SIGNAL,SECURITY device wifi list | awk 'BEGIN { OFS=","; print "SSID,SIGNAL,SECURITY" } NR>1 { print $1,$2,$3 }' > "$OUTPUT_FILE"
# Start the hotspot
echo "Starting hotspot..."
sudo nmcli device wifi hotspot ifname wlan0 ssid nebuleair_pro password nebuleaircfg
# Update JSON to reflect hotspot mode
jq --arg status "hotspot" '.WIFI_status = $status' "$JSON_FILE" > temp.json && mv temp.json "$JSON_FILE"
# Start the hotspot with SSID based on deviceName
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'"
else
echo "Success: wlan0 is connected!"
echo "🛜Success: wlan0 is connected!🛜"
CONN_SSID=$(nmcli -g GENERAL.CONNECTION device show wlan0)
echo "Connection: $CONN_SSID"
#update config JSON file
jq --arg status "connected" '.WIFI_status = $status' "$JSON_FILE" > temp.json && mv temp.json "$JSON_FILE"
sudo chmod 777 "$JSON_FILE"
# Update SQLite to reflect hotspot mode
sqlite3 /var/www/nebuleair_pro_4g/sqlite/sensors.db "UPDATE config_table SET value='connected' WHERE key='WIFI_status'"
# Lancer le tunnel SSH
echo "Démarrage du tunnel SSH sur le port $SSH_TUNNEL_PORT..."
#echo "Démarrage du tunnel SSH sur le port $SSH_TUNNEL_PORT..."
# Start the SSH agent if it's not already running
eval "$(ssh-agent -s)"
#eval "$(ssh-agent -s)"
# Add your SSH private key
ssh-add /home/airlab/.ssh/id_rsa
#ssh-add /home/airlab/.ssh/id_rsa
#connections details
REMOTE_USER="airlab_server1" # Remplacez par votre nom d'utilisateur distant
REMOTE_SERVER="aircarto.fr" # Remplacez par l'adresse de votre serveur
LOCAL_PORT=22 # Port local à rediriger
MONITOR_PORT=0 # Désactive la surveillance de connexion autossh
#REMOTE_USER="airlab_server1" # Remplacez par votre nom d'utilisateur distant
#REMOTE_SERVER="aircarto.fr" # Remplacez par l'adresse de votre serveur
#LOCAL_PORT=22 # Port local à rediriger
#MONITOR_PORT=0 # Désactive la surveillance de connexion autossh
#autossh -M "$MONITOR_PORT" -f -N -R "$SSH_TUNNEL_PORT:localhost:$LOCAL_PORT" "$REMOTE_USER@$REMOTE_SERVER" -p 50221
# ssh -f -N -R 52221:localhost:22 -p 50221 airlab_server1@aircarto.fr
ssh -i /var/www/.ssh/id_rsa -f -N -R "$SSH_TUNNEL_PORT:localhost:$LOCAL_PORT" -p 50221 -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_SERVER"
#ssh -i /var/www/.ssh/id_rsa -f -N -R "$SSH_TUNNEL_PORT:localhost:$LOCAL_PORT" -p 50221 -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_SERVER"
#Check if the tunnel was created successfully
if [ $? -eq 0 ]; then
echo "Tunnel started successfully!"
else
echo "Error: Unable to start the tunnel!"
exit 1
fi
#if [ $? -eq 0 ]; then
# echo "Tunnel started successfully!"
#else
# echo "Error: Unable to start the tunnel!"
# exit 1
#fi
fi
echo "-------------------"

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

@@ -2,26 +2,49 @@
echo "-------"
echo "Start connexion 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"
#disable hotspot
echo "Disable Hotspot:"
sudo nmcli connection down Hotspot
sleep 10
# Find and disable any active hotspot connection
echo "Disable Hotspot..."
# Get all wireless connections that are currently active (excludes the target WiFi)
ACTIVE_HOTSPOT=$(nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep ':802-11-wireless:wlan0' | cut -d: -f1)
if [ -n "$ACTIVE_HOTSPOT" ]; then
echo "Disabling hotspot connection: $ACTIVE_HOTSPOT"
sudo nmcli connection down "$ACTIVE_HOTSPOT"
else
echo "No active hotspot found"
fi
sleep 5
echo "Start connection with:"
echo "SSID: $1"
echo "Password: $2"
echo "Password: [HIDDEN]"
sudo nmcli device wifi connect "$1" password "$2"
#check if connection is successfull
# Check if connection is successful
if [ $? -eq 0 ]; then
echo "Connection to $1 is successfull"
echo "Connection to $1 is successful"
# Update SQLite to reflect connected status
sqlite3 /var/www/nebuleair_pro_4g/sqlite/sensors.db "UPDATE config_table SET value='connected' WHERE key='WIFI_status'"
echo "Updated database: WIFI_status = connected"
else
echo "Connection to $1 failed"
echo "Restarting hotspot..."
#enable hotspot
sudo nmcli connection up Hotspot
# Recreate hotspot with current deviceName as SSID
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 restarted with SSID: $DEVICE_NAME"
fi
echo "End connexion shell script"
echo "-------"

View File

@@ -1,12 +1,13 @@
@reboot chmod 777 /dev/ttyAMA* /dev/i2c-1
@reboot sleep 10 && chmod 777 /dev/ttyAMA* /dev/i2c-1
@reboot /var/www/nebuleair_pro_4g/boot_hotspot.sh >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
@reboot sleep 30 && /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setURL.py ttyAMA2 data.nebuleair.fr >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
@reboot sleep 30 && /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/reboot/start.py >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
@reboot sleep 45 && /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/SSL/prepareUspotProfile.py ttyAMA2 api-prod.uspot.probesys.net >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
#0 0 * * * > /var/www/nebuleair_pro_4g/logs/master.log
#0 0 * * * > /var/www/nebuleair_pro_4g/logs/master_errors.log
#0 0 * * * > /var/www/nebuleair_pro_4g/logs/app.log
0 0 * * * find /var/www/nebuleair_pro_4g/logs -name "*.log" -type f -exec truncate -s 0 {} \;
* * * * * /usr/bin/python3 /var/www/nebuleair_pro_4g/loop/1_NPM/send_data.py >> /var/www/nebuleair_pro_4g/logs/loop.log 2>&1
0 0 */2 * * > /var/www/nebuleair_pro_4g/logs/loop.log

View File

@@ -1,6 +1,16 @@
"""
_____ _ ___ _______ _
| ____| \ | \ \ / / ____| / \
| _| | \| |\ \ / /| _| / _ \
| |___| |\ | \ V / | |___ / ___ \
|_____|_| \_| \_/ |_____/_/ \_\
Main loop to gather data from envea Sensors
/usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_value_loop.py
Need to run every minutes
* * * * * /usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_value_loop.py
Save data to .txt file inside /var/www/nebuleair_pro_4g/envea/data/
"""
import json
import serial

View File

@@ -0,0 +1,133 @@
"""
_____ _ ___ _______ _
| ____| \ | \ \ / / ____| / \
| _| | \| |\ \ / /| _| / _ \
| |___| |\ | \ V / | |___ / ___ \
|_____|_| \_| \_/ |_____/_/ \_\
Main loop to gather data from Envea Sensors
Runs every minute via cron:
* * * * * /usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_value_loop_json.py
Saves data as JSON inside: /var/www/nebuleair_pro_4g/envea/data/data.json
"""
import json
import serial
import time
import traceback
from datetime import datetime
# Function to load config data
def load_config(config_file):
try:
with open(config_file, 'r') as file:
return json.load(file)
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Function to save data to a JSON file
def save_data_to_json(filename, data):
try:
with open(filename, 'w') as file:
json.dump(data, file, indent=4)
print(f"Data saved to {filename}")
except Exception as e:
print(f"Error saving to file {filename}: {e}")
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load configuration data
config = load_config(config_file)
# Initialize sensors and serial connections
envea_sondes = config.get('envea_sondes', [])
connected_envea_sondes = [sonde for sonde in envea_sondes if sonde.get('connected', False)]
serial_connections = {}
if connected_envea_sondes:
for device in connected_envea_sondes:
port = device.get('port', 'Unknown')
name = device.get('name', 'Unknown')
try:
serial_connections[name] = serial.Serial(
port=f'/dev/{port}',
baudrate=9600,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1
)
except serial.SerialException as e:
print(f"Error opening serial port for {name}: {e}")
# Function to gather data from sensors
def gather_data():
global data_h2s, data_no2, data_o3
data_h2s = 0
data_no2 = 0
data_o3 = 0
try:
if connected_envea_sondes:
for device in connected_envea_sondes:
name = device.get('name', 'Unknown')
coefficient = device.get('coefficient', 1)
if name in serial_connections:
serial_connection = serial_connections[name]
try:
serial_connection.write(
b"\xFF\x02\x13\x30\x01\x02\x03\x04\x05\x06\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x12\xAF\x88\x03"
)
data_envea = serial_connection.readline()
if len(data_envea) >= 20:
byte_20 = data_envea[19] * coefficient
if name == "h2s":
data_h2s = byte_20
elif name == "no2":
data_no2 = byte_20
elif name == "o3":
data_o3 = byte_20
except serial.SerialException as e:
print(f"Error communicating with {name}: {e}")
except Exception as e:
print("An error occurred while gathering data:", e)
traceback.print_exc()
# Main loop
if __name__ == "__main__":
h2s_values = []
no2_values = []
o3_values = []
for cycle in range(6): # Run 6 times
gather_data()
h2s_values.append(data_h2s)
no2_values.append(data_no2)
o3_values.append(data_o3)
print(f"Cycle {cycle + 1}:")
print(f" H2S: {data_h2s}, NO2: {data_no2}, O3: {data_o3}")
time.sleep(9) # Wait 9 seconds
# Compute the mean values (as integers)
mean_h2s = int(sum(h2s_values) / len(h2s_values)) if h2s_values else 0
mean_no2 = int(sum(no2_values) / len(no2_values)) if no2_values else 0
mean_o3 = int(sum(o3_values) / len(o3_values)) if o3_values else 0
# Create JSON structure
data_json = {
"h2s": mean_h2s,
"no2": mean_no2,
"o3": mean_o3
}
# Define JSON file path
output_file = "/var/www/nebuleair_pro_4g/envea/data/data.json"
# Save to JSON file
save_data_to_json(output_file, data_json)

View File

@@ -1,6 +1,7 @@
import serial
import time
import sys
import re
parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
@@ -61,8 +62,46 @@ def read_cairsens(port, baudrate=9600, parity=serial.PARITY_NONE, stopbits=seria
# ASCII characters
ascii_data = ''.join(chr(b) if 0x20 <= b <= 0x7E else '.' for b in raw_bytes)
print(f"Valeurs converties en ASCII : {ascii_data}")
sensor_type = "Unknown" # ou None, selon ton besoin
sensor_measurement = "Unknown"
sensor_range = "Unknown"
letters = re.findall(r'[A-Za-z]', ascii_data)
if len(letters) >= 1:
#print(f"First letter found: {letters[0]}")
if letters[0] == "C":
sensor_type = "Cairclip"
if len(letters) >= 2:
#print(f"Second letter found: {letters[1]}")
if letters[1] == "A":
sensor_measurement = "Ammonia(NH3)"
if letters[1] == "C":
sensor_measurement = "O3 and NO2"
if letters[1] == "G":
sensor_measurement = "CH4"
if letters[1] == "H":
sensor_measurement = "H2S"
if letters[1] == "N":
sensor_measurement = "NO2"
if len(letters) >= 3:
#print(f"Thrisd letter found: {letters[2]}")
if letters[2] == "B":
sensor_range = "0-250 ppb"
if letters[2] == "M":
sensor_range = "0-1ppm"
if letters[2] == "V":
sensor_range = "0-20 ppm"
if letters[2] == "P":
sensor_range = "PACKET data block ?"
if len(letters) < 1:
print("No letter found in the ASCII data.")
print(f"Valeurs converties en ASCII : {sensor_type} {sensor_measurement} {sensor_range}")
#print(f"Sensor type: {sensor_type}")
#print(f"Sensor measurment: {sensor_measurement}")
#print(f"Sensor range: {sensor_range}")
# Numeric values
numeric_values = [b for b in raw_bytes]
print(f"Valeurs numériques : {numeric_values}")

224
envea/read_ref_v2.py Normal file
View File

@@ -0,0 +1,224 @@
"""
_____ _ ___ _______ _
| ____| \ | \ \ / / ____| / \
| _| | \| |\ \ / /| _| / _ \
| |___| |\ | \ V / | |___ / ___ \
|_____|_| \_| \_/ |_____/_/ \_\
Gather data from envea Sensors and store them to the SQlite table
Use the RTC time for the timestamp
This script is run by a service nebuleair-envea-data.service
/usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_ref_v2.py ttyAMA4
ATTENTION --> read_ref.py fonctionne mieux
"""
import serial
import time
import sys
parameter = sys.argv[1:] # Exclude the script name
port='/dev/'+parameter[0]
# Mapping dictionaries
COMPOUND_MAP = {
'A': 'Ammonia',
'B': 'Benzene',
'C': 'Carbon Monoxide',
'D': 'Hydrogen Sulfide',
'E': 'Ethylene',
'F': 'Formaldehyde',
'G': 'Gasoline',
'H': 'Hydrogen',
'I': 'Isobutylene',
'J': 'Jet Fuel',
'K': 'Kerosene',
'L': 'Liquified Petroleum Gas',
'M': 'Methane',
'N': 'Nitrogen Dioxide',
'O': 'Ozone',
'P': 'Propane',
'Q': 'Quinoline',
'R': 'Refrigerant',
'S': 'Sulfur Dioxide',
'T': 'Toluene',
'U': 'Uranium Hexafluoride',
'V': 'Vinyl Chloride',
'W': 'Water Vapor',
'X': 'Xylene',
'Y': 'Yttrium',
'Z': 'Zinc'
}
RANGE_MAP = {
'A': '0-10 ppm',
'B': '0-250 ppb',
'C': '0-1000 ppm',
'D': '0-50 ppm',
'E': '0-100 ppm',
'F': '0-5 ppm',
'G': '0-500 ppm',
'H': '0-2000 ppm',
'I': '0-200 ppm',
'J': '0-300 ppm',
'K': '0-400 ppm',
'L': '0-600 ppm',
'M': '0-800 ppm',
'N': '0-20 ppm',
'O': '0-1 ppm',
'P': '0-5000 ppm',
'Q': '0-150 ppm',
'R': '0-750 ppm',
'S': '0-25 ppm',
'T': '0-350 ppm',
'U': '0-450 ppm',
'V': '0-550 ppm',
'W': '0-650 ppm',
'X': '0-850 ppm',
'Y': '0-950 ppm',
'Z': '0-1500 ppm'
}
INTERFACE_MAP = {
0x01: 'USB',
0x02: 'UART',
0x03: 'I2C',
0x04: 'SPI'
}
def parse_cairsens_data(hex_data):
"""
Parse the extracted hex data from CAIRSENS sensor.
:param hex_data: Hexadecimal string of extracted data (indices 11-28)
:return: Dictionary with parsed information
"""
# Convert hex to bytes for easier processing
raw_bytes = bytes.fromhex(hex_data)
# Initialize result dictionary
result = {
'device_type': 'Unknown',
'compound': 'Unknown',
'range': 'Unknown',
'interface': 'Unknown',
'raw_data': hex_data
}
if len(raw_bytes) >= 4: # Ensure we have at least 4 bytes
# First byte: Device type check
first_char = chr(raw_bytes[0]) if 0x20 <= raw_bytes[0] <= 0x7E else '?'
if first_char == 'C':
result['device_type'] = 'CAIRCLIP'
else:
result['device_type'] = f'Unknown ({first_char})'
# Second byte: Compound mapping
second_char = chr(raw_bytes[1]) if 0x20 <= raw_bytes[1] <= 0x7E else '?'
result['compound'] = COMPOUND_MAP.get(second_char, f'Unknown ({second_char})')
# Third byte: Range mapping
third_char = chr(raw_bytes[2]) if 0x20 <= raw_bytes[2] <= 0x7E else '?'
result['range'] = RANGE_MAP.get(third_char, f'Unknown ({third_char})')
# Fourth byte: Interface (raw byte value)
interface_byte = raw_bytes[3]
result['interface'] = INTERFACE_MAP.get(interface_byte, f'Unknown (0x{interface_byte:02X})')
result['interface_raw'] = f'0x{interface_byte:02X}'
return result
def read_cairsens(port, baudrate=9600, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, databits=serial.EIGHTBITS, timeout=1):
"""
Lit les données de la sonde CAIRSENS via UART.
/usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_ref_v2.py ttyAMA4
:param port: Le port série utilisé (ex: 'COM1' ou '/dev/ttyAMA0').
:param baudrate: Le débit en bauds (ex: 9600).
:param parity: Le bit de parité (serial.PARITY_NONE, serial.PARITY_EVEN, serial.PARITY_ODD).
:param stopbits: Le nombre de bits de stop (serial.STOPBITS_ONE, serial.STOPBITS_TWO).
:param databits: Le nombre de bits de données (serial.FIVEBITS, serial.SIXBITS, serial.SEVENBITS, serial.EIGHTBITS).
:param timeout: Temps d'attente maximal pour la lecture (en secondes).
:return: Les données reçues sous forme de chaîne de caractères.
"""
try:
# Ouvrir la connexion série
ser = serial.Serial(
port=port,
baudrate=baudrate,
parity=parity,
stopbits=stopbits,
bytesize=databits,
timeout=timeout
)
print(f"Connexion ouverte sur {port} à {baudrate} bauds.")
# Attendre un instant pour stabiliser la connexion
time.sleep(2)
# Envoyer une commande à la sonde (si nécessaire)
# Adapter cette ligne selon la documentation de la sonde
#ser.write(b'\r\n')
ser.write(b'\xFF\x02\x13\x30\x01\x02\x03\x04\x05\x06\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x1C\xD1\x61\x03')
# Lire les données reçues
data = ser.readline()
print(f"Données reçues brutes : {data}")
# Convertir les données en hexadécimal
hex_data = data.hex() # Convertit en chaîne hexadécimale
formatted_hex = ' '.join(hex_data[i:i+2] for i in range(0, len(hex_data), 2)) # Formate avec des espaces
print(f"Données reçues en hexadécimal : {formatted_hex}")
# Extraire les valeurs de l'index 11 à 28 (indices 22 à 56 en hex string)
extracted_hex = hex_data[22:56] # Each byte is 2 hex chars, so 11*2=22 to 28*2=56
print(f"Valeurs hexadécimales extraites (11 à 28) : {extracted_hex}")
# Parse the extracted data
parsed_data = parse_cairsens_data(extracted_hex)
# Display parsed information
print("\n=== CAIRSENS SENSOR INFORMATION ===")
print(f"Device Type: {parsed_data['device_type']}")
print(f"Compound: {parsed_data['compound']}")
print(f"Range: {parsed_data['range']}")
print(f"Interface: {parsed_data['interface']} ({parsed_data.get('interface_raw', 'N/A')})")
print(f"Raw Data: {parsed_data['raw_data']}")
print("=====================================")
# Convertir en ASCII et en valeurs numériques (pour debug)
if extracted_hex:
raw_bytes = bytes.fromhex(extracted_hex)
ascii_data = ''.join(chr(b) if 0x20 <= b <= 0x7E else '.' for b in raw_bytes)
print(f"Valeurs converties en ASCII : {ascii_data}")
numeric_values = [b for b in raw_bytes]
print(f"Valeurs numériques : {numeric_values}")
# Fermer la connexion
ser.close()
print("Connexion fermée.")
return parsed_data
except serial.SerialException as e:
print(f"Erreur de connexion série : {e}")
return None
except Exception as e:
print(f"Erreur générale : {e}")
return None
# Exemple d'utilisation
if __name__ == "__main__":
port = port # Remplacez par votre port série (ex: /dev/ttyAMA0 sur Raspberry Pi)
baudrate = 9600 # Débit en bauds (à vérifier dans la documentation)
parity = serial.PARITY_NONE # Parité (NONE, EVEN, ODD)
stopbits = serial.STOPBITS_ONE # Bits de stop (ONE, TWO)
databits = serial.EIGHTBITS # Bits de données (FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS)
data = read_cairsens(port, baudrate, parity, stopbits, databits)
if data:
print(f"\nRésultat final : {data}")

View File

@@ -44,9 +44,9 @@ def read_cairsens(port, baudrate=9600, parity=serial.PARITY_NONE, stopbits=seria
# Lire les données reçues
#data = ser.read_until(b'\n') # Lire jusqu'à la fin de ligne ou un autre délimiteur
data = ser.read_until(b'\n') # Lire jusqu'à la fin de ligne ou un autre délimiteur
data = ser.readline()
#print(f"Données reçues brutes : {data}")
print(f"Données reçues brutes : {data}")
#print(f"Données reçues (utf-8) : {data.decode('utf-8').strip()}")
# Extraire le 20ème octet

213
envea/read_value_v2.py Executable file
View File

@@ -0,0 +1,213 @@
"""
_____ _ ___ _______ _
| ____| \ | \ \ / / ____| / \
| _| | \| |\ \ / /| _| / _ \
| |___| |\ | \ V / | |___ / ___ \
|_____|_| \_| \_/ |_____/_/ \_\
Gather data from envea Sensors and store them to the SQlite table
Use the RTC time for the timestamp
This script is run by a service nebuleair-envea-data.service
/usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_value_v2.py -d
"""
import json
import serial
import time
import traceback
import sqlite3
from datetime import datetime
import sys
# Set DEBUG to True to enable debug prints, False to disable
DEBUG = False # Change this to False to disable debug output
# You can also control debug via command line argument
if len(sys.argv) > 1 and sys.argv[1] in ['--debug', '-d']:
DEBUG = True
elif len(sys.argv) > 1 and sys.argv[1] in ['--quiet', '-q']:
DEBUG = False
def debug_print(message):
"""Print debug messages only if DEBUG is True"""
if DEBUG:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] {message}")
debug_print("=== ENVEA Sensor Reader Started ===")
# Connect to the SQLite database
try:
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
except Exception as e:
debug_print(f"✗ Failed to connect to database: {e}")
sys.exit(1)
# GET RTC TIME from SQlite
try:
cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone() # Get the first (and only) row
rtc_time_str = row[1] # '2025-02-07 12:30:45'
except Exception as e:
debug_print(f"✗ Failed to get RTC time: {e}")
rtc_time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
debug_print(f" Using system time instead: {rtc_time_str}")
# Fetch connected ENVEA sondes from SQLite config table
try:
cursor.execute("SELECT port, name, coefficient FROM envea_sondes_table WHERE connected = 1")
connected_envea_sondes = cursor.fetchall() # List of tuples (port, name, coefficient)
debug_print(f"✓ Found {len(connected_envea_sondes)} connected ENVEA sensors")
for port, name, coefficient in connected_envea_sondes:
debug_print(f" - {name}: port={port}, coefficient={coefficient}")
except Exception as e:
debug_print(f"✗ Failed to fetch connected sensors: {e}")
connected_envea_sondes = []
serial_connections = {}
if connected_envea_sondes:
debug_print("\n--- Opening Serial Connections ---")
for port, name, coefficient in connected_envea_sondes:
try:
serial_connections[name] = serial.Serial(
port=f'/dev/{port}',
baudrate=9600,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1
)
debug_print(f"✓ Opened serial port for {name} on /dev/{port}")
except serial.SerialException as e:
debug_print(f"✗ Error opening serial port for {name}: {e}")
else:
debug_print("! No connected ENVEA sensors found in configuration")
# Initialize sensor data variables
global data_h2s, data_no2, data_o3, data_co, data_nh3, data_so2
data_h2s = 0
data_no2 = 0
data_o3 = 0
data_co = 0
data_nh3 = 0
data_so2 = 0
try:
if connected_envea_sondes:
debug_print("\n--- Reading Sensor Data ---")
for port, name, coefficient in connected_envea_sondes:
if name in serial_connections:
serial_connection = serial_connections[name]
try:
debug_print(f"Reading from {name}...")
calculated_value = None
max_retries = 3
for attempt in range(max_retries):
# Flush input buffer to clear any stale data
serial_connection.reset_input_buffer()
# 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":
data_no2 = calculated_value
elif name == "o3":
data_o3 = calculated_value
elif name == "co":
data_co = calculated_value
elif name == "nh3":
data_nh3 = calculated_value
elif name == "so2":
data_so2 = calculated_value
debug_print(f"{name.upper()} = {calculated_value}")
else:
debug_print(f" ✗ Failed to read {name} after {max_retries} attempts")
except serial.SerialException as e:
debug_print(f"✗ Error communicating with {name}: {e}")
else:
debug_print(f"! No serial connection available for {name}")
except Exception as e:
debug_print(f"\n✗ An error occurred while gathering data: {e}")
traceback.print_exc()
# Display all collected data
debug_print(f"\n--- Collected Sensor Data ---")
debug_print(f"H2S: {data_h2s} ppb")
debug_print(f"NO2: {data_no2} ppb")
debug_print(f"O3: {data_o3} ppb")
debug_print(f"CO: {data_co} ppb")
debug_print(f"NH3: {data_nh3} ppb")
debug_print(f"SO2: {data_so2} ppb")
# Save to sqlite database
try:
cursor.execute('''
INSERT INTO data_envea (timestamp, h2s, no2, o3, co, nh3, so2) VALUES (?,?,?,?,?,?,?)'''
, (rtc_time_str, data_h2s, data_no2, data_o3, data_co, data_nh3, data_so2))
# Commit and close the connection
conn.commit()
except Exception as e:
debug_print(f"✗ Database error: {e}")
traceback.print_exc()
# Close serial connections
if serial_connections:
for name, connection in serial_connections.items():
try:
connection.close()
except:
pass
conn.close()
debug_print("\n=== ENVEA Sensor Reader Finished ===\n")

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

File diff suppressed because it is too large Load Diff

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

20
html/assets/js/chart.js Executable file

File diff suppressed because one or more lines are too long

129
html/assets/js/i18n.js Normal file
View File

@@ -0,0 +1,129 @@
/**
* NebuleAir i18n - Lightweight internationalization system
* Works offline with local JSON translation files
* Stores language preference in SQLite database
*/
const i18n = {
currentLang: 'fr', // Default language
translations: {},
/**
* Initialize i18n system
* Loads language preference from server and applies translations
*/
async init() {
try {
// Load language preference from server (SQLite database)
const response = await fetch('launcher.php?type=get_language');
const data = await response.json();
this.currentLang = data.language || 'fr';
} catch (error) {
console.warn('Could not load language preference, using default (fr):', error);
this.currentLang = 'fr';
}
// Load translations and apply
await this.loadTranslations(this.currentLang);
this.applyTranslations();
},
/**
* Load translation file for specified language
* @param {string} lang - Language code (fr, en)
*/
async loadTranslations(lang) {
try {
const response = await fetch(`lang/${lang}.json`);
this.translations = await response.json();
console.log(`Translations loaded for: ${lang}`);
} catch (error) {
console.error(`Failed to load translations for ${lang}:`, error);
}
},
/**
* Apply translations to all elements with data-i18n attribute
*/
applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
const translation = this.get(key);
if (translation) {
// Handle different element types
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
if (element.type === 'button' || element.type === 'submit') {
element.value = translation;
} else {
element.placeholder = translation;
}
} else {
element.textContent = translation;
}
} else {
console.warn(`Translation not found for key: ${key}`);
}
});
// Update HTML lang attribute
document.documentElement.lang = this.currentLang;
// Update language switcher dropdown
const languageSwitcher = document.getElementById('languageSwitcher');
if (languageSwitcher) {
languageSwitcher.value = this.currentLang;
}
},
/**
* Get translation by key (supports nested keys with dot notation)
* @param {string} key - Translation key (e.g., 'sensors.title')
* @returns {string} Translated string or key if not found
*/
get(key) {
const keys = key.split('.');
let value = this.translations;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return key; // Return key if translation not found
}
}
return value;
},
/**
* Change language and reload translations
* @param {string} lang - Language code (fr, en)
*/
async setLanguage(lang) {
if (lang === this.currentLang) return;
this.currentLang = lang;
// Save to server (SQLite database)
try {
await fetch(`launcher.php?type=set_language&language=${lang}`);
} catch (error) {
console.error('Failed to save language preference:', error);
}
// Reload translations and apply
await this.loadTranslations(lang);
this.applyTranslations();
// Emit custom event for other scripts to react to language change
document.dispatchEvent(new CustomEvent('languageChanged', { detail: { language: lang } }));
}
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => i18n.init());
} else {
i18n.init();
}

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';
}
}
});
}
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

14512
html/assets/leaflet/leaflet-src.js Executable file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

661
html/assets/leaflet/leaflet.css Executable file
View File

@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

6
html/assets/leaflet/leaflet.js Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

694
html/database.html Executable file
View File

@@ -0,0 +1,694 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NebuleAir</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;
}
/* Highlight most recent data row with light green background */
.table .most-recent-row td {
background-color: #d4edda !important;
}
.table-striped .most-recent-row td {
background-color: #d4edda !important;
}
</style>
</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-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4" data-i18n="database.title">Base de données</h1>
<p data-i18n="database.description">Le capteur enregistre en local les données de mesures. Vous pouvez ici les consulter et les télécharger.</p>
<div class="row mb-3">
<div class="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.viewDatabase">Consulter la base de donnée</h5>
<!-- Dropdown to select number of records -->
<div class="d-flex align-items-center mb-3">
<label for="records_limit" class="form-label me-2" data-i18n="database.numberOfMeasures">Nombre de mesures:</label>
<select id="records_limit" class="form-select w-auto">
<option value="10" selected data-i18n="database.last10">10 dernières</option>
<option value="20" data-i18n="database.last20">20 dernières</option>
<option value="30" data-i18n="database.last30">30 dernières</option>
</select>
</div>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_NPM',getSelectedLimit(),false)" data-i18n="database.pmMeasures">Mesures PM</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_BME280',getSelectedLimit(),false)" data-i18n="database.tempHumMeasures">Mesures Temp/Hum</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_NPM_5channels',getSelectedLimit(),false)" data-i18n="database.pm5Channels">Mesures PM (5 canaux)</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_envea',getSelectedLimit(),false)" data-i18n="database.cairsensProbe">Sonde Cairsens</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_NOISE',getSelectedLimit(),false)" data-i18n="database.noiseProbe">Sonde bruit</button>
<button class="btn btn-primary mb-2" onclick="get_data_sqlite('data_WIND',getSelectedLimit(),false)" data-i18n="database.windProbe">Sonde Vent</button>
<button class="btn btn-primary 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>
</div>
<div class="col-lg-4 col-md-6 mb-3">
<div class="card text-dark bg-light h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.downloadData">Télécharger les données</h5>
<!-- Date selection for download -->
<div class="d-flex align-items-center gap-3 mb-3">
<label for="start_date" class="form-label" data-i18n="database.startDate">Date de début:</label>
<input type="date" id="start_date" class="form-control w-auto">
<label for="end_date" class="form-label" data-i18n="database.endDate">Date de fin:</label>
<input type="date" id="end_date" class="form-control w-auto">
</div>
<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-6 mb-3">
<div class="card text-dark bg-light h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.downloadAll">Télécharger toute la table</h5>
<p class="text-muted small" data-i18n="database.downloadAllDesc">Télécharge l'intégralité des données sans filtre de date.</p>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_NPM')" data-i18n="database.pmMeasures">Mesures PM</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_BME280')" data-i18n="database.tempHumMeasures">Mesures Temp/Hum</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_NPM_5channels')" data-i18n="database.pm5Channels">Mesures PM (5 canaux)</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_envea')" data-i18n="database.cairsensProbe">Sonde Cairsens</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_NOISE')" data-i18n="database.noiseProbe">Sonde Bruit</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_MPPT')" data-i18n="database.battery">Batterie</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_MHZ19')">Mesures CO2</button>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-lg-4 col-md-6 mb-3">
<div class="card text-dark bg-light h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.statsTitle">Informations sur la base</h5>
<div id="db_stats_content">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2" data-i18n="common.loading">Chargement...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-lg-4 col-md-6 mb-3">
<div class="card text-white bg-danger h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.dangerZone">Zone dangereuse</h5>
<p class="card-text" data-i18n="database.dangerWarning">Attention: Cette action est irréversible!</p>
<button class="btn btn-dark btn-lg w-100 mb-2" onclick="emptySensorTables()" data-i18n="database.emptyAllTables">Vider toutes les tables de capteurs</button>
<small class="d-block mt-2" data-i18n="database.emptyTablesNote">Note: Les tables de configuration et horodatage seront préservées.</small>
</div>
</div>
</div>
</div>
<div class="row mt-2">
<div id="table_data"></div>
</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 () {
console.log("DOMContentLoaded");
const elementsToLoad = [
{ id: 'topbar', file: 'topbar.html' },
{ id: 'sidebar', file: 'sidebar.html' },
{ id: 'sidebar_mobile', file: 'sidebar.html' }
];
let loadedCount = 0;
const totalElements = elementsToLoad.length;
function applyTranslationsWhenReady() {
if (typeof i18n !== 'undefined' && i18n.translations && Object.keys(i18n.translations).length > 0) {
console.log("Applying translations to dynamically loaded content");
i18n.applyTranslations();
} else {
// Retry after a short delay if translations aren't loaded yet
setTimeout(applyTranslationsWhenReady, 100);
}
}
elementsToLoad.forEach(({ id, file }) => {
fetch(file)
.then(response => response.text())
.then(data => {
const element = document.getElementById(id);
if (element) {
element.innerHTML = data;
}
loadedCount++;
// Re-apply translations after all dynamic content is loaded
if (loadedCount === totalElements) {
applyTranslationsWhenReady();
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
});
// Also listen for language change events to re-apply translations
document.addEventListener('languageChanged', function() {
console.log("Language changed, re-applying translations");
i18n.applyTranslations();
});
});
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);
//get device Name (for the side bar)
const deviceName = response.deviceName;
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = deviceName;
});
//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
// Get database table stats
loadDbStats();
//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
}
// TABLE PM
function get_data_sqlite(table, limit, download , startDate = "", endDate = "") {
console.log(`Getting data for table: ${table}, limit: ${limit}, download: ${download}, start: ${startDate}, end: ${endDate}`);
// Construct URL parameters dynamically
let url = `launcher.php?type=table_mesure&table=${table}&limit=${limit}&download=${download}`;
// Add date parameters if downloading
if (download) {
url += `&start_date=${startDate}&end_date=${endDate}`;
}
console.log(url);
$.ajax({
url: url,
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);
// If download is true, generate and trigger CSV download
if (download) {
downloadCSV(response, table);
return; // Exit function after triggering download
}
let rows = response.trim().split("\n");
// Generate Bootstrap table
let tableHTML = `<table class="table table-striped table-bordered">
<thead class="table-dark"><tr>`;
// Define column headers dynamically based on the table type
if (table === "data_NPM") {
tableHTML += `
<th>Timestamp</th>
<th>PM1</th>
<th>PM2.5</th>
<th>PM10</th>
<th>Temperature (°C)</th>
<th>Humidity (%)</th>
<th>Status</th>
`;
} else if (table === "data_BME280") {
tableHTML += `
<th>Timestamp</th>
<th>Temperature (°C)</th>
<th>Humidity (%)</th>
<th>Pressure (hPa)</th>
`;
} else if (table === "data_NPM_5channels") {
tableHTML += `
<th>Timestamp</th>
<th>PM_ch1 (nb/L)</th>
<th>PM_ch2 (nb/L)</th>
<th>PM_ch3 (nb/L)</th>
<th>PM_ch4 (nb/L)</th>
<th>PM_ch5 (nb/L)</th>
`;
}else if (table === "data_envea") {
tableHTML += `
<th>Timestamp</th>
<th>NO2</th>
<th>H2S</th>
<th>NH3</th>
<th>CO</th>
<th>O3</th>
`;
}else if (table === "timestamp_table") {
tableHTML += `
<th>Timestamp</th>
`;
}else if (table === "data_WIND") {
tableHTML += `
<th>Timestamp</th>
<th>speed (km/h)</th>
<th>Direction (V)</th>
`;
}else if (table === "data_MPPT") {
tableHTML += `
<th>Timestamp</th>
<th>Battery Voltage</th>
<th>Battery Current</th>
<th> solar_voltage</th>
<th> solar_power</th>
<th> charger_status</th>
`;
}else if (table === "data_NOISE") {
tableHTML += `
<th>Timestamp</th>
<th>Curent LEQ</th>
<th>DB_A_value</th>
`;
}else if (table === "data_MHZ19") {
tableHTML += `
<th>Timestamp</th>
<th>CO2 (ppm)</th>
`;
}
tableHTML += `</tr></thead><tbody>`;
// Loop through rows and create table rows
rows.forEach((row, index) => {
let columns = row.replace(/[()]/g, "").split(", "); // Remove parentheses and split
// Add special class to first row (most recent data)
const rowClass = index === 0 ? ' class="most-recent-row"' : '';
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>
<td>${columns[2]}</td>
<td>${columns[3]}</td>
<td>${columns[4]}</td>
<td>${columns[5]}</td>
<td>${statusBadge}</td>
`;
} else if (table === "data_BME280") {
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
<td>${columns[2]}</td>
<td>${columns[3]}</td>
`;
}
else if (table === "data_NPM_5channels") {
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
<td>${columns[2]}</td>
<td>${columns[3]}</td>
<td>${columns[4]}</td>
<td>${columns[5]}</td>
`;
} else if (table === "data_envea") {
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
<td>${columns[2]}</td>
<td>${columns[3]}</td>
<td>${columns[4]}</td>
<td>${columns[5]}</td>
`;
}else if (table === "timestamp_table") {
tableHTML += `
<td>${columns[1]}</td>
`;
}else if (table === "data_WIND") {
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
<td>${columns[2]}</td>
`;
}else if (table === "data_MPPT") {
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
<td>${columns[2]}</td>
<td>${columns[3]}</td>
<td>${columns[4]}</td>
<td>${columns[5]}</td>
`;
}else if (table === "data_NOISE") {
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
<td>${columns[2]}</td>
`;
}else if (table === "data_MHZ19") {
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
`;
}
tableHTML += "</tr>";
});
tableHTML += `</tbody></table>`;
// Update the #table_data div with the generated table
document.getElementById("table_data").innerHTML = tableHTML;
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
}
function getSelectedLimit() {
return document.getElementById("records_limit").value;
}
function getStartDate() {
return document.getElementById("start_date").value;
}
function getEndDate() {
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) {
let rows = response.trim().split("\n");
let csvContent = "";
// Add headers based on table type
if (table === "data_NPM") {
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 => {
let columns = row.replace(/[()]/g, "").split(", ");
csvContent += columns.join(",") + "\n";
});
// Create a downloadable file
let blob = new Blob([csvContent], { type: "text/csv" });
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
a.href = url;
a.download = table + "_data.csv"; // File name
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// Table display names
const tableDisplayNames = {
'data_NPM': 'PM (NextPM)',
'data_NPM_5channels': 'PM 5 canaux',
'data_BME280': 'Temp/Hum (BME280)',
'data_envea': 'Gaz (Cairsens)',
'data_WIND': 'Vent',
'data_MPPT': 'Batterie (MPPT)',
'data_NOISE': 'Bruit'
};
function loadDbStats() {
$.ajax({
url: 'launcher.php?type=db_table_stats',
dataType: 'json',
method: 'GET',
success: function(response) {
if (!response.success) {
document.getElementById('db_stats_content').innerHTML =
'<div class="alert alert-danger mb-0">' + (response.error || 'Erreur') + '</div>';
return;
}
let html = '<p class="mb-2"><strong data-i18n="database.statsDbSize">Taille totale:</strong> ' + response.size_mb + ' MB</p>';
html += '<div class="table-responsive"><table class="table table-sm table-bordered mb-0">';
html += '<thead class="table-secondary"><tr>';
html += '<th data-i18n="database.statsTable">Table</th>';
html += '<th data-i18n="database.statsCount">Entrées</th>';
html += '<th data-i18n="database.statsOldest">Plus ancienne</th>';
html += '<th data-i18n="database.statsNewest">Plus récente</th>';
html += '<th data-i18n="database.statsDownload">CSV</th>';
html += '</tr></thead><tbody>';
response.tables.forEach(function(t) {
const displayName = tableDisplayNames[t.name] || t.name;
const oldest = t.oldest ? t.oldest.substring(0, 16) : '-';
const newest = t.newest ? t.newest.substring(0, 16) : '-';
const downloadBtn = t.count > 0
? '<a href="launcher.php?type=download_full_table&table=' + t.name + '" class="btn btn-outline-primary btn-sm py-0 px-1" title="Download CSV"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg></a>'
: '-';
html += '<tr>';
html += '<td>' + displayName + '</td>';
html += '<td>' + t.count.toLocaleString() + '</td>';
html += '<td><small>' + oldest + '</small></td>';
html += '<td><small>' + newest + '</small></td>';
html += '<td class="text-center">' + downloadBtn + '</td>';
html += '</tr>';
});
html += '</tbody></table></div>';
html += '<button class="btn btn-outline-secondary btn-sm mt-2" onclick="loadDbStats()" data-i18n="logs.refresh">Refresh</button>';
document.getElementById('db_stats_content').innerHTML = html;
// Re-apply translations if i18n is loaded
if (typeof i18n !== 'undefined' && i18n.translations && Object.keys(i18n.translations).length > 0) {
i18n.applyTranslations();
}
},
error: function(xhr, status, error) {
document.getElementById('db_stats_content').innerHTML =
'<div class="alert alert-danger mb-0">Erreur: ' + error + '</div>';
}
});
}
// Function to empty all sensor tables
function emptySensorTables() {
// Show confirmation dialog
const confirmed = confirm(
"WARNING: This will permanently delete ALL sensor data from the database!\n\n" +
"The following tables will be emptied:\n" +
"- data_NPM\n" +
"- data_NPM_5channels\n" +
"- data_BME280\n" +
"- data_envea\n" +
"- data_WIND\n" +
"- data_MPPT\n" +
"- data_NOISE\n\n" +
"Configuration and timestamp tables will be preserved.\n\n" +
"Are you absolutely sure you want to continue?"
);
if (!confirmed) {
console.log("Empty sensor tables operation cancelled by user");
return;
}
// Show loading message
const tableDataDiv = document.getElementById("table_data");
tableDataDiv.innerHTML = '<div class="alert alert-info">Emptying sensor tables... Please wait...</div>';
// Make AJAX request to empty tables
$.ajax({
url: 'launcher.php?type=empty_sensor_tables',
dataType: 'json',
method: 'GET',
success: function(response) {
console.log("Empty sensor tables response:", response);
if (response.success) {
// Show success message
let message = '<div class="alert alert-success">';
message += '<h5>Success!</h5>';
message += '<p>' + response.message + '</p>';
if (response.tables_processed && response.tables_processed.length > 0) {
message += '<p><strong>Tables emptied:</strong></p><ul>';
response.tables_processed.forEach(table => {
message += `<li>${table.name}: ${table.deleted} records deleted</li>`;
});
message += '</ul>';
}
message += '</div>';
tableDataDiv.innerHTML = message;
} else {
// Show error message
tableDataDiv.innerHTML = `<div class="alert alert-danger">
<h5>Error!</h5>
<p>${response.message || response.error || 'Unknown error occurred'}</p>
</div>`;
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
tableDataDiv.innerHTML = `<div class="alert alert-danger">
<h5>Error!</h5>
<p>Failed to empty sensor tables: ${error}</p>
</div>`;
}
});
}
</script>
</body>
</html>

View File

@@ -1,39 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NebuleAir</title>
<link rel="stylesheet" href="assets/css/bootstrap.min.css">
<script src="assets/js/chart.js"></script> <!-- Local Chart.js -->
<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 */
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 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>
@@ -49,27 +60,65 @@
</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">Votre capteur</h1>
<p>Bienvenue sur votre interface de configuration de votre capteur.</p>
<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">
<div class="col-sm-4">
<!-- Card NPM values -->
<div class="col-sm-4 mt-2">
<div class="card">
<div class="card-body">
<h5 class="card-title">Linux stats</h5>
<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>
<p class="card-text">Disk usage (total size <span id="disk_size"></span> Gb) </p>
<!-- Card Linux Stats -->
<div class="col-sm-4 mt-2">
<div class="card">
<div class="card-body">
<h5 class="card-title" data-i18n="home.linuxStats">Statistiques Linux</h5>
<p class="card-text"><span data-i18n="home.diskUsage">Utilisation du disque (taille totale</span> <span
id="disk_size"></span> Gb) </p>
<div id="disk_space"></div>
<p class="card-text">Memory usage (total size <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>
</div>
</div>
</div>
</div>
<!--
<div class="row mb-3">
<div class="col-sm-4 mt-2">
<div class="card">
<div class="card-body">
<h5 class="card-title">Mesures Temperature</h5>
<canvas id="sensorBME_temp" style="width: 100%; max-width: 600px; height: 200px;"></canvas>
</div>
</div>
</div>
</div>
-->
</main>
</div>
</div>
@@ -80,6 +129,10 @@
<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 () {
@@ -96,6 +149,10 @@
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));
@@ -107,6 +164,42 @@
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);
//get device Name (for the side bar)
const deviceName = response.deviceName;
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = deviceName;
});
//device name html page title
if (response.deviceName) {
document.title = response.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
}
},
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 => {
@@ -124,6 +217,11 @@ window.onload = function() {
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',
@@ -139,6 +237,34 @@ window.onload = function() {
}
});
//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',
@@ -210,8 +336,6 @@ window.onload = function() {
console.log(usedMemory);
console.log(percentageUsed);
// Create the outer div with class and attributes
const progressDiv = document.createElement('div');
progressDiv.className = 'progress mb-3';
@@ -240,12 +364,137 @@ window.onload = function() {
});
// 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();
}
}
})
.catch(error => console.error('Error loading config.json:', error));
}
</script>
</body>
</html>

247
html/lang/README.md Normal file
View File

@@ -0,0 +1,247 @@
# NebuleAir i18n System
Lightweight internationalization (i18n) system for NebuleAir web interface.
## Features
- **Offline-first**: Works completely offline with local JSON translation files
- **Database-backed**: Language preference stored in SQLite `config_table`
- **Automatic**: Translations apply on page load and when language changes
- **Simple API**: Easy-to-use data attributes and JavaScript API
## Quick Start
### 1. Include i18n.js in your HTML page
```html
<script src="assets/js/i18n.js"></script>
```
The i18n system will automatically initialize when the page loads.
### 2. Add translation keys to HTML elements
Use the `data-i18n` attribute to mark elements for translation:
```html
<h1 data-i18n="page.title">Titre en français</h1>
<p data-i18n="page.description">Description en français</p>
<button data-i18n="common.submit">Soumettre</button>
```
The text content serves as a fallback if translations aren't loaded.
### 3. Add translations to JSON files
Edit `lang/fr.json` and `lang/en.json`:
```json
{
"page": {
"title": "Mon Titre",
"description": "Ma description"
},
"common": {
"submit": "Soumettre"
}
}
```
Translation keys use dot notation for nested objects.
## Translation Files
- **`fr.json`**: French translations (default)
- **`en.json`**: English translations
### File Structure Example
```json
{
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer"
},
"navigation": {
"home": "Accueil",
"settings": "Paramètres"
},
"sensors": {
"title": "Capteurs",
"description": "Liste des capteurs"
}
}
```
## JavaScript API
### Get Current Language
```javascript
const currentLang = i18n.currentLang; // 'fr' or 'en'
```
### Change Language Programmatically
```javascript
await i18n.setLanguage('en'); // Switch to English
```
### Get Translation in JavaScript
```javascript
const translation = i18n.get('sensors.title'); // Returns translated string
```
### Manual Translation Application
If you dynamically create HTML elements, call `applyTranslations()` after adding them to the DOM:
```javascript
// Create new element
const div = document.createElement('div');
div.setAttribute('data-i18n', 'mypage.newElement');
div.textContent = 'Fallback text';
document.body.appendChild(div);
// Apply translations
i18n.applyTranslations();
```
### Listen for Language Changes
```javascript
document.addEventListener('languageChanged', (event) => {
console.log('Language changed to:', event.detail.language);
// Reload dynamic content, update charts, etc.
});
```
## Special Cases
### Input Placeholders
For input fields, the translation applies to the `placeholder` attribute:
```html
<input type="text" data-i18n="form.emailPlaceholder" placeholder="Email...">
```
### Button Values
For input buttons, the translation applies to the `value` attribute:
```html
<input type="submit" data-i18n="common.submit" value="Submit">
```
### Dynamic Content
For content created with JavaScript (like sensor cards), add `data-i18n` attributes to your template strings and call `i18n.applyTranslations()` after inserting into the DOM.
## Example: Migrating an Existing Page
### Before (French only):
```html
<!DOCTYPE html>
<html>
<head>
<title>Capteurs</title>
</head>
<body>
<h1>Liste des capteurs</h1>
<button onclick="getData()">Obtenir les données</button>
</body>
</html>
```
### After (Multilingual):
```html
<!DOCTYPE html>
<html>
<head>
<title data-i18n="sensors.pageTitle">Capteurs</title>
<script src="assets/js/i18n.js"></script>
</head>
<body>
<h1 data-i18n="sensors.title">Liste des capteurs</h1>
<button onclick="getData()" data-i18n="common.getData">Obtenir les données</button>
</body>
</html>
```
**Add to `lang/fr.json`:**
```json
{
"sensors": {
"pageTitle": "Capteurs",
"title": "Liste des capteurs"
},
"common": {
"getData": "Obtenir les données"
}
}
```
**Add to `lang/en.json`:**
```json
{
"sensors": {
"pageTitle": "Sensors",
"title": "Sensor List"
},
"common": {
"getData": "Get Data"
}
}
```
## Backend Integration
### Get Language Preference
```javascript
const response = await fetch('launcher.php?type=get_language');
const data = await response.json();
console.log(data.language); // 'fr' or 'en'
```
### Set Language Preference
```javascript
const response = await fetch('launcher.php?type=set_language&language=en');
const data = await response.json();
console.log(data.success); // true
```
Language preference is stored in SQLite `config_table` with key `language`.
## Completed Pages
-**sensors.html** - Fully translated with French/English support
## TODO: Pages to Migrate
- ⏳ index.html
- ⏳ admin.html
- ⏳ wifi.html
- ⏳ saraR4.html
- ⏳ map.html
## Tips
1. **Reuse common translations**: Put frequently used strings (buttons, actions, status messages) in the `common` section
2. **Keep keys descriptive**: Use `sensors.bme280.title` instead of `s1` for maintainability
3. **Test both languages**: Always verify that both French and English translations display correctly
4. **Fallback text**: Always provide fallback text in HTML for graceful degradation
## Support
For issues or questions about the i18n system, refer to the implementation in:
- `/html/assets/js/i18n.js` - Core translation library
- `/html/lang/fr.json` - French translations
- `/html/lang/en.json` - English translations
- `/html/sensors.html` - Example implementation

113
html/lang/en.json Normal file
View File

@@ -0,0 +1,113 @@
{
"common": {
"getData": "Get Data",
"loading": "Loading...",
"error": "Error",
"startRecording": "Start recording",
"stopRecording": "Stop recording"
},
"sensors": {
"title": "Measurement Sensors",
"description": "Your NebuleAir sensor is equipped with one or more probes that measure environmental variables. Measurements are automatic, but you can verify their operation here.",
"npm": {
"title": "NextPM",
"description": "Particulate matter sensor.",
"headerUart": "UART Port"
},
"bme280": {
"title": "BME280 Temp/Humidity Sensor",
"description": "Temperature and humidity sensor on I2C port.",
"headerI2c": "I2C Port",
"temp": "Temperature",
"hum": "Humidity",
"press": "Pressure"
},
"noise": {
"title": "NSRT MK4",
"description": "NSRT MK4 sound level meter on USB port.",
"headerUsb": "USB Port"
},
"envea": {
"title": "Envea Probe",
"description": "Gas sensor."
}
},
"wifi": {
"title": "WIFI Connection",
"description": "WIFI connection is not mandatory but it allows you to perform updates and enable remote control.",
"status": "Status",
"connected": "Connected",
"hotspot": "Hotspot",
"disconnected": "Disconnected",
"scan": "Scan",
"connect": "Connect",
"enterPassword": "Enter password for"
},
"admin": {
"title": "Administration",
"parameters": "Parameters (config)",
"deviceName": "Device Name",
"deviceID": "Device ID",
"modemVersion": "Modem Version"
},
"sidebar": {
"home": "Home",
"screen": "Screen",
"sensors": "Sensors",
"database": "Database",
"modem4g": "4G Modem",
"wifi": "WIFI",
"logs": "Logs",
"map": "Map",
"terminal": "Terminal",
"admin": "Admin"
},
"home": {
"title": "Your Sensor",
"welcome": "Welcome to your sensor configuration interface.",
"pmMeasures": "PM Measurements",
"linuxStats": "Linux Statistics",
"diskUsage": "Disk usage (total size",
"memoryUsage": "Memory usage (total size",
"databaseSize": "Database size:"
},
"database": {
"title": "Database",
"description": "The sensor records measurement data locally. You can view and download it here.",
"viewDatabase": "View Database",
"numberOfMeasures": "Number of measurements:",
"last10": "Last 10",
"last20": "Last 20",
"last30": "Last 30",
"pmMeasures": "PM Measurements",
"tempHumMeasures": "Temp/Hum Measurements",
"pm5Channels": "PM Measurements (5 channels)",
"cairsensProbe": "Cairsens Probe",
"noiseProbe": "Noise Probe",
"windProbe": "Wind Probe",
"battery": "Battery",
"timestampTable": "Timestamp Table",
"downloadData": "Download Data",
"startDate": "Start date:",
"endDate": "End date:",
"dangerZone": "Danger Zone",
"dangerWarning": "Warning: This action is irreversible!",
"emptyAllTables": "Empty all sensor tables",
"emptyTablesNote": "Note: Configuration and timestamp tables will be preserved.",
"statsTitle": "Database Information",
"statsDbSize": "Total size:",
"statsTable": "Table",
"statsCount": "Entries",
"statsOldest": "Oldest",
"statsNewest": "Newest",
"statsDownload": "CSV"
},
"logs": {
"title": "The Log",
"description": "The log allows you to know if the sensor processes are running correctly.",
"saraLogs": "Sara logs",
"bootLogs": "Boot logs",
"refresh": "Refresh",
"clear": "Clear"
}
}

113
html/lang/fr.json Normal file
View File

@@ -0,0 +1,113 @@
{
"common": {
"getData": "Obtenir les données",
"loading": "Chargement...",
"error": "Erreur",
"startRecording": "Démarrer l'enregistrement",
"stopRecording": "Arrêter l'enregistrement"
},
"sensors": {
"title": "Les sondes de mesure",
"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.",
"npm": {
"title": "NextPM",
"description": "Capteur particules fines.",
"headerUart": "Port UART"
},
"bme280": {
"title": "Capteur Temp/Humidité BME280",
"description": "Capteur température et humidité sur le port I2C.",
"headerI2c": "Port I2C",
"temp": "Température",
"hum": "Humidité",
"press": "Pression"
},
"noise": {
"title": "NSRT MK4",
"description": "Sonomètre NSRT MK4 sur port USB.",
"headerUsb": "Port USB"
},
"envea": {
"title": "Sonde Envea",
"description": "Capteur gaz."
}
},
"wifi": {
"title": "Connexion WIFI",
"description": "La connexion WIFI n'est pas obligatoire mais elle vous permet d'effectuer des mises à jour et d'activer le contrôle à distance.",
"status": "Statut",
"connected": "Connecté",
"hotspot": "Point d'accès",
"disconnected": "Déconnecté",
"scan": "Scanner",
"connect": "Se connecter",
"enterPassword": "Entrer le mot de passe pour"
},
"admin": {
"title": "Administration",
"parameters": "Paramètres (config)",
"deviceName": "Nom de l'appareil",
"deviceID": "ID de l'appareil",
"modemVersion": "Version du modem"
},
"sidebar": {
"home": "Accueil",
"screen": "Écran",
"sensors": "Capteurs",
"database": "Base de données",
"modem4g": "Modem 4G",
"wifi": "WIFI",
"logs": "Logs",
"map": "Carte",
"terminal": "Terminal",
"admin": "Admin"
},
"home": {
"title": "Votre capteur",
"welcome": "Bienvenue sur votre interface de configuration de votre capteur.",
"pmMeasures": "Mesures PM",
"linuxStats": "Statistiques Linux",
"diskUsage": "Utilisation du disque (taille totale",
"memoryUsage": "Utilisation de la mémoire (taille totale",
"databaseSize": "Taille de la base de données:"
},
"database": {
"title": "Base de données",
"description": "Le capteur enregistre en local les données de mesures. Vous pouvez ici les consulter et les télécharger.",
"viewDatabase": "Consulter la base de donnée",
"numberOfMeasures": "Nombre de mesures:",
"last10": "10 dernières",
"last20": "20 dernières",
"last30": "30 dernières",
"pmMeasures": "Mesures PM",
"tempHumMeasures": "Mesures Temp/Hum",
"pm5Channels": "Mesures PM (5 canaux)",
"cairsensProbe": "Sonde Cairsens",
"noiseProbe": "Sonde bruit",
"windProbe": "Sonde Vent",
"battery": "Batterie",
"timestampTable": "Timestamp Table",
"downloadData": "Télécharger les données",
"startDate": "Date de début:",
"endDate": "Date de fin:",
"dangerZone": "Zone dangereuse",
"dangerWarning": "Attention: Cette action est irréversible!",
"emptyAllTables": "Vider toutes les tables de capteurs",
"emptyTablesNote": "Note: Les tables de configuration et horodatage seront préservées.",
"statsTitle": "Informations sur la base",
"statsDbSize": "Taille totale:",
"statsTable": "Table",
"statsCount": "Entrées",
"statsOldest": "Plus ancienne",
"statsNewest": "Plus récente",
"statsDownload": "CSV"
},
"logs": {
"title": "Le journal",
"description": "Le journal des logs permet de savoir si les processus du capteur se déroulent correctement.",
"saraLogs": "Sara logs",
"bootLogs": "Boot logs",
"refresh": "Refresh",
"clear": "Clear"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -49,15 +49,18 @@
</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">Le journal</h1>
<p>Le journal des logs permet de savoir si les processus du capteur se déroulent correctement.</p>
<h1 class="mt-4" data-i18n="logs.title">Le journal</h1>
<p data-i18n="logs.description">Le journal des logs permet de savoir si les processus du capteur se déroulent correctement.</p>
<div class="row">
<!-- card 1 -->
<div class="col-lg-6 col-12">
<div class="card" style="height: 80vh;">
<div class="card-header">
Loop logs <button type="submit" class="btn btn-secondary btn-sm" onclick="clear_loopLogs()">Clear</button>
<span data-i18n="logs.saraLogs">Sara logs</span>
<button type="submit" class="btn btn-secondary btn-sm" id="refresh-master-log" data-i18n="logs.refresh">Refresh</button>
<button type="submit" class="btn btn-secondary btn-sm" onclick="clear_loopLogs()" data-i18n="logs.clear">Clear</button>
<span id="script_running"></span>
</div>
<div class="card-body overflow-auto" id="card_loop_content">
@@ -68,7 +71,8 @@
<div class="col-lg-6 col-12">
<div class="card" style="height: 80vh;">
<div class="card-header">
Boot logs
<span data-i18n="logs.bootLogs">Boot logs</span>
<button type="submit" class="btn btn-secondary btn-sm" id="refresh-boot-log" data-i18n="logs.refresh">Refresh</button>
</div>
<div class="card-body overflow-auto" id="card_boot_content">
@@ -86,6 +90,9 @@
<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 () {
@@ -110,77 +117,54 @@
const loop_card_content = document.getElementById('card_loop_content');
const boot_card_content = document.getElementById('card_boot_content');
fetch('../logs/loop.log')
.then((response) => {
if (!response.ok) {
throw new Error('Failed to fetch the log file.');
}
return response.text();
})
.then((data) => {
const lines = data.split('\n');
//Getting Master logs
console.log("Getting SARA logs");
displayLogFile('../logs/sara_service.log', loop_card_content, true, 1000);
// Format log content
const formattedLog = lines
.map((line) => line.trim()) // Remove extra whitespace
.filter((line) => line) // Remove empty lines
.join('<br>'); // Join formatted lines with line breaks
console.log("Getting app/boot logs");
displayLogFile('../logs/app.log', boot_card_content, true, 1000);
loop_card_content.innerHTML = `<pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0;">${formattedLog}</pre>`;
loop_card_content.scrollTop = loop_card_content.scrollHeight; // Scroll to the bottom
})
.catch((error) => {
console.error(error);
loop_card_content.textContent = 'Error loading log file.';
});
fetch('../logs/app.log')
.then((response) => {
if (!response.ok) {
throw new Error('Failed to fetch the log file.');
}
return response.text();
})
.then((data) => {
const lines = data.split('\n');
// Format log content
const formattedLog = lines
.map((line) => line.trim()) // Remove extra whitespace
.filter((line) => line) // Remove empty lines
.join('<br>'); // Join formatted lines with line breaks
boot_card_content.innerHTML = `<pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0;">${formattedLog}</pre>`;
boot_card_content.scrollTop = loop_card_content.scrollHeight; // Scroll to the bottom
})
.catch((error) => {
console.error(error);
boot_card_content.textContent = 'Error loading log file.';
});
// Setup master log with refresh button
setupLogRefreshButton('refresh-master-log', '../logs/sara_service.log', 'card_loop_content', 3000);
// Setup boot log with refresh button
setupLogRefreshButton('refresh-boot-log', '../logs/app.log', 'card_boot_content', 300);
});
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
const deviceName = data.deviceName;
getModem_busy_status();
setInterval(getModem_busy_status, 2000);
//NEW way to get config (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_side bar
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = deviceName;
element.innerText = response.deviceName;
});
//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
//get local RTC
$.ajax({
@@ -197,10 +181,78 @@ window.onload = function() {
}
});
}//end onload
function displayLogFile(logFilePath, containerElement, scrollToBottom = true, maxLines = 0) {
// Show loading indicator
containerElement.innerHTML = '<div class="text-center"><i>Loading log file...</i></div>';
return fetch(logFilePath)
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch the log file: ${response.status} ${response.statusText}`);
}
return response.text();
})
.catch(error => console.error('Error loading config.json:', error));
.then((data) => {
// Split the log into lines
let lines = data.split('\n');
// Apply max lines limit if specified
if (maxLines > 0 && lines.length > maxLines) {
lines = lines.slice(-maxLines); // Get only the last N lines
}
// Format log content
const formattedLog = lines
.map((line) => line.trim()) // Remove extra whitespace
.filter((line) => line) // Remove empty lines
.join('<br>'); // Join formatted lines with line breaks
// Display the formatted log
containerElement.innerHTML = `<pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0;">${formattedLog}</pre>`;
// Scroll to bottom if requested
if (scrollToBottom) {
containerElement.scrollTop = containerElement.scrollHeight;
}
return formattedLog; // Return the formatted log in case the caller needs it
})
.catch((error) => {
console.error(`Error loading log file ${logFilePath}:`, error);
containerElement.innerHTML = `<div class="text-danger">Error loading log file: ${error.message}</div>`;
throw error; // Re-throw the error for the caller to handle if needed
});
}
/**
* Set up a refresh button for a log file
* @param {string} buttonId - ID of the button element
* @param {string} logFilePath - Path to the log file
* @param {string} containerId - ID of the container to display the log in
* @param {number} maxLines - Maximum number of lines to display (0 for all)
*/
function setupLogRefreshButton(buttonId, logFilePath, containerId, maxLines = 0) {
console.log("Refreshing logs");
const button = document.getElementById(buttonId);
const container = document.getElementById(containerId);
if (!button || !container) {
console.error('Button or container element not found');
return;
}
// Initial load
displayLogFile(logFilePath, container, true, maxLines);
// Set up button click handler
button.addEventListener('click', () => {
displayLogFile(logFilePath, container, true, maxLines);
});
}
function clear_loopLogs(){
console.log("Clearing loop logs");
@@ -221,6 +273,37 @@ function clear_loopLogs(){
}
});
}
function getModem_busy_status() {
//console.log("Getting modem busy status");
const script_is_running = document.getElementById("script_running");
$.ajax({
url: 'launcher.php?type=getModem_busy',
dataType: 'json', // Expecting JSON response
method: 'GET',
success: function(response) {
//console.log(response);
if (response.running) {
// Script is running → Show the Bootstrap spinner
script_is_running.innerHTML = `
<div class="spinner-border spinner-border-sm text-danger" role="status">
<span class="visually-hidden">Modem is busy...</span>
</div>
`;
} else {
// Script is NOT running → Show a success message (no spinner)
script_is_running.innerHTML = ``;
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
script_is_running.innerHTML = `<span class="text-warning">Error checking status ⚠️</span>`;
}
});
}
</script>
</body>

266
html/map.html Executable file
View File

@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NebuleAir</title>
<link rel="stylesheet" href="assets/css/bootstrap.min.css">
<link rel="stylesheet" href="assets/leaflet/leaflet.css" />
<script src="assets/leaflet/leaflet.js"></script>
<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-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4">Localisation</h1>
<div class="row">
<div class="col-sm-6 mb-2">
<div class="card">
<div class="card-body">
<h3 class="mt-1">Zone localisation du capteur</h3>
<p class="card-text">Mis à jour automatiquement par le capteur. </p>
<div class="row">
<div class="col-6 mb-3">
<label for="device_name" class="form-label">Latitude</label>
<input type="text" class="form-control" id="device_latitude_raw" disabled>
</div>
<div class="col-6 mb-3">
<label for="device_name" class="form-label">Longitude</label>
<input type="text" class="form-control" id="device_longitude_raw" disabled>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 mb-2">
<div class="card">
<div class="card-body">
<h3 class="mt-1">Point précis</h3>
<p class="card-text">Mis à jour manuellement (sur aircarto.fr ou sur cette interface si le capteur est connecté au WIFI) </p>
<div class="row">
<div class="col-6 mb-3">
<label for="device_name" class="form-label">Latitude</label>
<input type="text" class="form-control" id="device_latitude_precision" onchange="update_config('latitude_precision', this.value)">
</div>
<div class="col-6 mb-3">
<label for="device_name" class="form-label">Longitude</label>
<input type="text" class="form-control" id="device_longitude_precision" onchange="update_config('longitude_precision', this.value)">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div id="map" style="height: 70vh;"></div>
</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>
<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;
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
});
});
let map;
let marker;
// Function to load and update map
function loadConfigAndUpdateMap() {
fetch('../config.json')
.then(response => response.json())
.then(data => {
console.log("Getting config file (update)");
// Get device details
const deviceID = data.deviceID.trim().toUpperCase();
const deviceName = data.deviceName;
const device_latitude_precision = parseFloat(data.latitude_precision);
const device_longitude_precision = parseFloat(data.longitude_precision);
const device_latitude_raw = parseFloat(data.latitude_raw);
const device_longitude_raw = parseFloat(data.longitude_raw);
console.log("Latitude (precision): " + device_latitude_precision);
console.log("Longitude (precision): " + device_longitude_precision);
// Update input fields
document.getElementById("device_latitude_precision").value = device_latitude_precision;
document.getElementById("device_longitude_precision").value = device_longitude_precision;
document.getElementById("device_latitude_raw").value = device_latitude_raw;
document.getElementById("device_longitude_raw").value = device_longitude_raw;
// If map is not initialized, create it
if (!map) {
map = L.map('map').setView([device_latitude_precision, device_longitude_precision], 15);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
// Add draggable marker (point precision)
marker = L.marker([device_latitude_precision, device_longitude_precision], { draggable: true }).addTo(map);
//add a circle
var circle = L.circle([device_latitude_raw, device_longitude_raw], {
color: 'blue',
fillColor: '#3399FF',
fillOpacity: 0.3,
radius: 500
}).addTo(map);
// Event listener when marker is moved
marker.on('dragend', function (event) {
let newLatLng = marker.getLatLng();
console.log("Marker moved to:", newLatLng.lat, newLatLng.lng);
// Update the input fields with new values
document.getElementById("device_latitude_precision").value = newLatLng.lat;
document.getElementById("device_longitude_precision").value = newLatLng.lng;
// Call update function to save new values
update_config('latitude_precision', newLatLng.lat);
setTimeout(() => { update_config('longitude_precision', newLatLng.lng); }, 750);
});
} else {
// If the map already exists, update position
map.setView([device_latitude, device_longitude], 9);
// Move marker
marker.setLatLng([device_latitude, device_longitude]);
}
// Update device name in sidebar
document.querySelectorAll('.sideBar_sensorName').forEach((element) => {
element.innerText = deviceName;
});
// Get local RTC time
$.ajax({
url: 'launcher.php?type=RTC_time',
dataType: 'text',
method: 'GET',
success: function(response) {
console.log("Local RTC: " + response);
document.getElementById("RTC_time").textContent = response;
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
})
.catch(error => console.error('Error loading config.json:', error));
}
// Function to update config and refresh the map
function update_config(param, value) {
console.log("Updating ", param, " : ", value);
$.ajax({
url: 'launcher.php?type=update_config&param=' + param + '&value=' + value,
dataType: 'text',
method: 'GET',
cache: false,
success: function(response) {
console.log(response);
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
}
// Load config and initialize map on page load
window.onload = function () {
loadConfigAndUpdateMap();
};
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

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;
}
#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 */
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 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>
@@ -49,10 +58,19 @@
</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">Les sondes de mesure</h1>
<p>Votre capteur NebuleAir est équipé de une ou plusieurs sondes qui permettent de mesurer certaines variables environnementales. La mesure
<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>
<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>
</div>
@@ -64,6 +82,10 @@
<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 () {
@@ -80,6 +102,10 @@
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));
@@ -91,32 +117,121 @@ function getNPM_values(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
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"];
// 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];
// PM values
const pmKeys = ["PM1", "PM25", "PM10"];
pmKeys.forEach(key => {
if (response[key] !== undefined) {
$("#data-table-body_" + port).append(`
<tr>
<td>${key}</td>
<td>${value} µg/m³</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 getNPM_firmware(port) {
console.log("Firmware version from NPM (port " + port + "):");
$("#loading_fw_" + port).show();
$.ajax({
url: 'launcher.php?type=npm_firmware&port=' + port,
dataType: 'json',
method: 'GET',
success: function (response) {
console.log(response);
$("#loading_fw_" + port).hide();
const fwSpan = document.getElementById("fw_version_" + port);
if (response.firmware_version !== undefined) {
fwSpan.innerHTML = '<span class="badge bg-success">Firmware: ' + response.firmware_version + '</span>';
} else {
fwSpan.innerHTML = '<span class="badge bg-danger">Error reading firmware</span>';
}
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_fw_" + port).hide();
const fwSpan = document.getElementById("fw_version_" + port);
fwSpan.innerHTML = '<span class="badge bg-danger">Error</span>';
}
});
}
@@ -127,20 +242,18 @@ function getENVEA_values(port, name){
$.ajax({
url: 'launcher.php?type=envea&port=' + port + '&name=' + name,
dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
dataType: 'json',
method: 'GET',
success: function (response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_envea" + name);
tableBody.innerHTML = "";
$("#loading_envea" + name).hide();
// Create an array of the desired keys
// Create an array of the desired keys
const keysToShow = [name];
// Add only the specified elements to the table
keysToShow.forEach(key => {
if (response !== undefined) { // Check if the key exists in the response
if (response !== undefined) {
const value = response;
$("#data-table-body_envea" + name).append(`
<tr>
@@ -153,50 +266,150 @@ function getENVEA_values(port, name){
},
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 = `
<tr>
<td colspan="2" class="text-danger">
❌ Error: unable to get data from sensor.<br>
<small>${status}: ${error}</small>
</td>
</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:");
console.log("Data from NSRT MK4 Noise Sensor:");
$("#loading_noise").show();
$.ajax({
url: 'launcher.php?type=noise',
method: 'GET', // Use GET or POST depending on your needs
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;
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>${key}</td>
<td>${value} DB</td>
<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 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',
method: 'GET', // Use GET or POST depending on your needs
success: function (response) {
console.log(response);
@@ -236,21 +449,207 @@ function getBME280_values(){
window.onload = function () {
fetch('../config.json') // Replace 'deviceID.txt' with 'config.json'
.then(response => response.json()) // Parse response as JSON
.then(data => {
//get device ID
const deviceID = data.deviceID.trim().toUpperCase();
//document.getElementById('pageTitle_plus_ID').innerText = 'token: ' + deviceID;
//get device Name
const deviceName = data.deviceName;
//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
// 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">
Port UART
</div>
<div class="card-body">
<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>
</div>
</div>
</div>`;
container.innerHTML += cardHTML; // Add the I2C card if condition is met
//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">
Port I2C
</div>
<div class="card-body">
<h5 class="card-title" data-i18n="sensors.bme280.title">BME280 Temp/Hum sensor</h5>
<p class="card-text" data-i18n="sensors.bme280.description">Capteur température et humidité sur le port I2C.</p>
<button class="btn btn-primary mb-1" onclick="getBME280_values()" data-i18n="common.getData">Get Data</button>
<br>
<div id="loading_BME280" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<table class="table table-striped-columns">
<tbody id="data-table-body_BME280"></tbody>
</table>
</div>
</div>
</div>`;
container.innerHTML += i2C_BME_HTML; // Add the I2C card if condition is met
}
//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.headerUsb">
Port USB
</div>
<div class="card-body">
<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>
<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>
</table>
</div>
</div>
</div>`;
container.innerHTML += noiseHTML;
}
//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>`;
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">
<strong>Sondes Envea (Debug)</strong>
</div>
<div class="card-body">
<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;
}
// 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({
@@ -268,112 +667,9 @@ window.onload = function() {
}
});
const container = document.getElementById('card-container'); // Conteneur des cartes
//creates NPM cards
const NPM_ports = data.NextPM_ports; // Récupère les ports
NPM_ports.forEach((port, index) => {
const cardHTML = `
<div class="col-sm-3">
<div class="card">
<div class="card-header">
Port UART ${port.replace('ttyAMA', '')}
</div>
<div class="card-body">
<h5 class="card-title">NextPM ${String.fromCharCode(65 + index)}</h5>
<p class="card-text">Capteur particules fines.</p>
<button class="btn btn-primary" onclick="getNPM_values('${port}')">Get Data</button>
<div id="loading_${port}" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<table class="table table-striped-columns">
<tbody id="data-table-body_${port}"></tbody>
</table>
</div>
</div>
</div>`;
container.innerHTML += cardHTML; // Ajouter la carte au conteneur
});
//creates ENVEA cards
const ENVEA_sensors = data.envea_sondes.filter(sonde => sonde.connected); // Filter only connected sondes
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">
<div class="card">
<div class="card-header">
Port UART ${port.replace('ttyAMA', '')}
</div>
<div class="card-body">
<h5 class="card-title">Sonde Envea ${name}</h5>
<p class="card-text">Capteur gas.</p>
<button class="btn btn-primary" onclick="getENVEA_values('${port}','${name}','${coefficient}')">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>
</div>
</div>
</div>`;
container.innerHTML += cardHTML; // Ajouter la carte au conteneur
});
//creates i2c BME280 card
if (data.i2c_BME) {
const i2C_BME_HTML = `
<div class="col-sm-3">
<div class="card">
<div class="card-header">
Port I2C
</div>
<div class="card-body">
<h5 class="card-title">BME280 Temp/Hum sensor</h5>
<p class="card-text">Capteur température et humidité sur le port I2C.</p>
<button class="btn btn-primary mb-1" onclick="getBME280_values()">Get Data</button>
<br>
<div id="loading_BME280" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<table class="table table-striped-columns">
<tbody id="data-table-body_BME280"></tbody>
</table>
</div>
</div>
</div>`;
container.innerHTML += i2C_BME_HTML; // Add the I2C card if condition is met
}
//creates i2c sound card
if (data.i2C_sound) {
const i2C_HTML = `
<div class="col-sm-3">
<div class="card">
<div class="card-header">
Port I2C
</div>
<div class="card-body">
<h5 class="card-title">Decibel Meter</h5>
<p class="card-text">Capteur bruit sur le port I2C.</p>
<button class="btn btn-primary mb-1" onclick="getNoise_values()">Get Data</button>
<br>
<button class="btn btn-success" onclick="startNoise()">Start recording</button>
<button class="btn btn-danger" onclick="stopNoise()">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>
</table>
</div>
</div>
</div>`;
container.innerHTML += i2C_HTML; // Add the I2C card if condition is met
}
})
.catch(error => console.error('Error loading config.json:', error));
}
} //end windows onload
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More