35 Commits
v1.4.5 ... main

Author SHA1 Message Date
PaulVua
a68af89612 Page modem: retour visuel OK/erreur sur boutons LED status PCB
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:42:23 +02:00
PaulVua
7045adc7a6 v1.6.4: Page modem — progression reset hardware + boutons LED status PCB
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:29:02 +02:00
PaulVua
c062263b24 v1.6.3: Logs SARA en temps reel — auto-refresh + python3 -u unbuffered
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:36:54 +02:00
PaulVua
9f76e3b2de v1.6.2: Simplification script boot SARA — config modem deleguee au script principal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:15:24 +01:00
PaulVua
0ed18dd5c1 Fix: bandeau mode configuration affiche a tort (d-flex !important ecrasait display:none)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:03:14 +01:00
PaulVua
cf10d20db5 Page modem: reset hardware GPIO 16 + alerte mode configuration + reset au boot
- Bouton Reset Hardware (GPIO 16) avec verification ATI apres redemarrage
- Bandeau d'alerte rouge quand mode configuration actif (transmission desactivee)
- Reset automatique de modem_config_mode a 0 au boot (SARA/reboot/start.py)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:43:45 +01:00
PaulVua
3f7d0c0816 Page database: affichage noise_status dans tableau et export CSV
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:45:01 +01:00
PaulVua
47d76be5df v1.6.1: NSRT MK4 deconnexion — noise_status en base + ERR_NOISE dans error_flags
Meme modele que NPM: ecriture en base avec valeurs a 0 et noise_status=0xFF
si capteur deconnecte, flag ERR_NOISE (bit 5) dans byte 66 UDP, messages
explicites sur page capteurs et self-test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:42:10 +01:00
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
24 changed files with 1422 additions and 412 deletions

3
.gitignore vendored
View File

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

View File

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

140
README.md
View File

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

View File

@@ -0,0 +1,72 @@
'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/reboot/hardware_reboot.py
Hardware reboot of the SARA R5 modem using GPIO 16 (GND control via transistor).
Cuts power for 3 seconds, then verifies modem is responsive with ATI command.
Returns JSON result for web interface.
'''
import RPi.GPIO as GPIO
import serial
import time
import json
import sqlite3
SARA_GND_GPIO = 16
# Load baudrate from config
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
cursor.execute("SELECT value FROM config_table WHERE key='SaraR4_baudrate'")
row = cursor.fetchone()
baudrate = int(row[0]) if row else 115200
conn.close()
result = {
"reboot": False,
"modem_response": None,
"error": None
}
try:
# Step 1: Cut GND (modem off)
GPIO.setmode(GPIO.BCM)
GPIO.setup(SARA_GND_GPIO, GPIO.OUT)
GPIO.output(SARA_GND_GPIO, GPIO.LOW)
time.sleep(3)
# Step 2: Restore GND (modem on)
GPIO.output(SARA_GND_GPIO, GPIO.HIGH)
time.sleep(5) # wait for modem boot
# Step 3: Check modem with ATI
ser = serial.Serial('/dev/ttyAMA2', baudrate=baudrate, timeout=3)
ser.reset_input_buffer()
for attempt in range(5):
ser.write(b'ATI\r')
time.sleep(1)
response = ser.read(ser.in_waiting or 1).decode('utf-8', errors='replace')
if "OK" in response:
result["reboot"] = True
result["modem_response"] = response.strip()
break
time.sleep(2)
else:
result["error"] = "Modem ne repond pas apres le redemarrage"
ser.close()
except Exception as e:
result["error"] = str(e)
finally:
GPIO.cleanup(SARA_GND_GPIO)
print(json.dumps(result))

View File

@@ -1,95 +1,57 @@
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
Roles:
1. Reset modem_config_mode to 0 (boot safety)
2. Power on SARA modem via GPIO 16
3. Detect modem model (SARA R4 or R5) and save to SQLite
All other configuration (AirCarto URL, uSpot HTTPS, PDP setup, geolocation)
is handled by the main loop script: loop/SARA_send_data_v2.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
SARA_power_GPIO = 16
GPIO.setmode(GPIO.BCM) # Use BCM numbering
GPIO.setup(SARA_power_GPIO, GPIO.OUT) # Set GPIO17 as an output
GPIO.setup(SARA_power_GPIO, GPIO.OUT)
# 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:
@@ -100,29 +62,23 @@ def update_sqlite_config(key, value):
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.")
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
# Load baudrate from config
cursor.execute("SELECT value FROM config_table WHERE key = 'SaraR4_baudrate'")
row = cursor.fetchone()
baudrate = int(row[0]) if row else 115200
ser_sara = serial.Serial(
port='/dev/ttyAMA2',
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
baudrate=baudrate,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = 2
@@ -130,11 +86,10 @@ ser_sara = serial.Serial(
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
wait_for_lines = []
response = bytearray()
serial_connection.timeout = timeout
@@ -142,259 +97,72 @@ def read_complete_response(serial_connection, timeout=2, end_of_response_timeout
start_time = time.time()
while True:
elapsed_time = time.time() - start_time # Time since function start
elapsed_time = time.time() - start_time
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
end_time = time.time() + end_of_response_timeout
# 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
print(f"[DEBUG] Found target line: {target_line} (in {elapsed_time:.2f}s)")
return decoded_response
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
time.sleep(0.1)
# 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
print(f"[DEBUG] elapsed time: {total_elapsed_time:.2f}s.")
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>')
print(f"[ALERT] The operation took too long ({total_elapsed_time:.2f}s)")
return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
return response.decode('utf-8', errors='replace')
try:
print('<h3>Start reboot python script</h3>')
#First we need to power on the module (if connected to mosfet via gpio16)
# 1. Reset modem_config_mode at boot to prevent capteur from staying stuck in config mode
cursor.execute("UPDATE config_table SET value = '0' WHERE key = 'modem_config_mode'")
conn.commit()
print("modem_config_mode reset to 0 (boot safety)")
# 2. Power on the module (MOSFET via GPIO 16)
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")
# 3. Detect modem model
# 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")
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
print("Detected SARA R5 modem")
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("Could not identify modem model")
print(f"🔍 Model: {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)
print('<h3>Boot script complete. Modem ready for main loop.</h3>')
except Exception as e:
print("An error occurred:", e)
traceback.print_exc() # This prints the full traceback
traceback.print_exc()

View File

@@ -1 +1 @@
1.4.5
1.6.4

View File

@@ -1,5 +1,170 @@
{
"versions": [
{
"version": "1.6.4",
"date": "2026-04-02",
"changes": {
"features": [
"Page modem: boutons Activer/Desactiver LED status connexion PCB (AT+UGPIOC=16,2 / AT+UGPIOC=16,255)"
],
"improvements": [
"Page modem: messages de progression en 3 etapes pendant le reset hardware (coupure, redemarrage, test connexion)",
"Page modem: bouton reset hardware desactive pendant l'operation pour eviter les doubles clics"
],
"fixes": [],
"compatibility": []
},
"notes": "Le reset hardware affiche maintenant les etapes en temps reel (~20s). Deux nouveaux boutons permettent de controler la LED bleue du PCB qui indique l'etat de la connexion reseau du modem."
},
{
"version": "1.6.3",
"date": "2026-04-01",
"changes": {
"features": [
"Page logs: bouton Auto-refresh pour suivre les logs SARA en temps reel (polling 3s)"
],
"improvements": [
"Service SARA: ajout flag python3 -u (unbuffered) pour ecriture immediate des logs dans le fichier"
],
"fixes": [],
"compatibility": [
"Necessite re-execution de setup_services.sh pour activer le mode unbuffered (optionnel, pas d'impact si non fait)"
]
},
"notes": "Les logs SARA sont maintenant visibles en temps reel sur la page logs grace au mode unbuffered Python et au rafraichissement automatique. Aucun impact sur les anciennes installations qui ne relancent pas setup_services.sh."
},
{
"version": "1.6.2",
"date": "2026-03-27",
"changes": {
"features": [],
"improvements": [
"Simplification du script de boot SARA (start.py): suppression config AirCarto, uSpot/SSL, PDP et geolocalisation",
"La configuration modem est desormais entierement geree par le script principal (SARA_send_data_v2.py)"
],
"fixes": [],
"compatibility": []
},
"notes": "Le script de boot ne fait plus que 3 choses: reset modem_config_mode, alimentation modem GPIO 16, detection modele R4/R5. Toute la configuration (URLs, certificats, PDP, geolocalisation) est deja geree par le script principal qui tourne chaque minute avec gestion d'erreur et retry."
},
{
"version": "1.6.1",
"date": "2026-03-19",
"changes": {
"features": [
"Sonometre NSRT MK4: detection deconnexion avec message explicite (page capteurs + self-test)",
"Colonne noise_status dans data_NOISE (0x00=OK, 0xFF=deconnecte)",
"ERR_NOISE (bit 5, byte 66) dans error_flags UDP quand sonometre deconnecte"
],
"improvements": [
"Script NSRT_mk4_get_data.py ecrit en base meme si capteur deconnecte (valeurs a 0, noise_status=0xFF)",
"Script read.py: message d'erreur clair au lieu de l'exception Python brute",
"Self-test: affiche 'Capteur deconnecte — verifiez le cablage USB' au lieu de l'erreur technique"
],
"fixes": [],
"compatibility": [
"Migration automatique: colonne noise_status ajoutee via set_config.py lors du firmware update"
]
},
"notes": "Gestion de la deconnexion du sonometre NSRT MK4 alignee sur le modele NPM: ecriture en base avec status d'erreur, flag ERR_NOISE dans la payload UDP, et messages utilisateur explicites sur l'interface web."
},
{
"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",

View File

@@ -211,24 +211,38 @@
<h3 class="mt-4">Clock</h3>
<div class="mb-3">
<label for="sys_local_time" class="form-label">System time (local)</label>
<input type="text" class="form-control" id="sys_local_time" disabled>
<label for="RTC_utc_time" class="form-label fw-bold fs-5">RTC time (UTC)</label>
<input type="text" class="form-control form-control-lg border-primary" id="RTC_utc_time" disabled>
<small class="text-muted">Module DS3231 avec pile de sauvegarde. Garde l'heure meme hors tension. Horloge de reference du capteur.</small>
</div>
<div class="mb-3">
<label for="sys_UTC_time" class="form-label">System time (UTC)</label>
<input type="text" class="form-control" id="sys_UTC_time" disabled>
<label for="browser_utc_time" class="form-label">Browser time (UTC)</label>
<input type="text" class="form-control" id="browser_utc_time" disabled>
<small class="text-muted">Heure de votre appareil (PC/Mac/tablette). Reference pour verifier le RTC.</small>
</div>
<div class="mb-3">
<label for="RTC_utc_time" class="form-label">RTC time (UTC)</label>
<input type="text" class="form-control" id="RTC_utc_time" disabled>
</div>
<hr>
<details class="mb-3">
<summary class="text-muted" style="cursor:pointer;">System time (non utilise par le capteur)</summary>
<div class="mt-2">
<div class="mb-3">
<label for="sys_local_time" class="form-label">System time (local)</label>
<input type="text" class="form-control form-control-sm" id="sys_local_time" disabled>
</div>
<div class="mb-3">
<label for="sys_UTC_time" class="form-label">System time (UTC)</label>
<input type="text" class="form-control form-control-sm" id="sys_UTC_time" disabled>
</div>
<small class="text-muted">Horloge Linux du Raspberry Pi. Se synchronise via internet (NTP). Non utilisee par le capteur.</small>
</div>
</details>
<div id="alert_container"></div>
<h5 class="mt-4">Set RTC</h5>
<h5 class="mt-4">Synchroniser le RTC</h5>
<small class="text-muted d-block mb-2">Met a jour l'horloge RTC pour qu'elle reste precise sans internet.</small>
<button type="submit" class="btn btn-primary mb-1" onclick="set_RTC_withNTP()">WiFi (NTP) </button>
<button type="submit" class="btn btn-primary mb-1" onclick="set_RTC_withBrowser()">Browser time </button>
@@ -585,29 +599,54 @@ window.onload = function() {
document.getElementById("sys_UTC_time").value = response.system_utc_time;
document.getElementById("RTC_utc_time").value = response.rtc_module_time;
// Get the time difference
const timeDiff = response.time_difference_seconds;
// Display browser time in UTC
const browserDate = new Date();
const browserUTC = browserDate.getUTCFullYear() + '-' +
String(browserDate.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(browserDate.getUTCDate()).padStart(2, '0') + ' ' +
String(browserDate.getUTCHours()).padStart(2, '0') + ':' +
String(browserDate.getUTCMinutes()).padStart(2, '0') + ':' +
String(browserDate.getUTCSeconds()).padStart(2, '0');
document.getElementById("browser_utc_time").value = browserUTC;
// Reference to the alert container
// Compare RTC time with browser time
const alertContainer = document.getElementById("alert_container");
// Remove any previous alert
alertContainer.innerHTML = "";
const rtcInput = document.getElementById("RTC_utc_time");
// Add an alert based on time difference
if (typeof timeDiff === "number") {
if (timeDiff >= 0 && timeDiff <= 10) {
if (response.rtc_module_time === 'not connected' || !response.rtc_module_time) {
// RTC module disconnected
rtcInput.classList.add('border-danger', 'text-danger');
rtcInput.classList.remove('border-primary');
alertContainer.innerHTML = `
<div class="alert alert-danger d-flex align-items-center" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-exclamation-triangle-fill me-2 flex-shrink-0" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.436-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div>
<strong>Module RTC deconnecte !</strong><br>
Verifiez la pile du module DS3231 et les cables I2C.
</div>
</div>`;
} else {
const rtcDate = new Date(response.rtc_module_time + ' UTC');
const timeDiff = Math.abs(Math.round((browserDate - rtcDate) / 1000));
if (timeDiff <= 30) {
alertContainer.innerHTML = `
<div class="alert alert-success" role="alert">
RTC and system time are in sync (Difference: ${timeDiff} sec).
RTC synchronise avec l'heure du navigateur (ecart: ${timeDiff} sec).
</div>`;
} else if (timeDiff > 10) {
} else {
const minutes = Math.floor(timeDiff / 60);
const label = minutes > 0 ? `${minutes} min ${timeDiff % 60} sec` : `${timeDiff} sec`;
alertContainer.innerHTML = `
<div class="alert alert-danger" role="alert">
RTC time is out of sync! (Difference: ${timeDiff} sec).
RTC desynchronise ! Ecart avec le navigateur: ${label}.
Utilisez "Synchroniser le RTC" ci-dessous.
</div>`;
}
}
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);

View File

@@ -246,7 +246,7 @@ async function selfTestSequence() {
addSelfTestLog(`Device ID: ${selfTestReport.deviceId}`);
addSelfTestLog(`Device Name: ${selfTestReport.deviceName}`);
addSelfTestLog(`Modem Version: ${selfTestReport.modemVersion}`);
addSelfTestLog(`System Time (RTC): ${selfTestReport.systemTime}`);
addSelfTestLog(`RTC Time: ${selfTestReport.systemTime}`);
addSelfTestLog(`Browser Time: ${new Date().toLocaleString()}`);
addSelfTestLog(`GPS: ${selfTestReport.latitude}, ${selfTestReport.longitude}`);
addSelfTestLog('────────────────────────────────────────────────────────');
@@ -320,10 +320,10 @@ async function selfTestSequence() {
try {
if (sensor.type === 'npm') {
// NPM sensor test
// NPM sensor test (uses get_data_modbus_v3.py --dry-run)
const npmResult = await new Promise((resolve, reject) => {
$.ajax({
url: 'launcher.php?type=npm&port=' + sensor.port,
url: 'launcher.php?type=npm',
dataType: 'json',
method: 'GET',
timeout: 15000,
@@ -333,22 +333,42 @@ async function selfTestSequence() {
});
selfTestReport.rawResponses['NPM Sensor'] = JSON.stringify(npmResult, null, 2);
addSelfTestLog(`NPM response: PM1=${npmResult.PM1}, PM2.5=${npmResult.PM25}, PM10=${npmResult.PM10}`);
addSelfTestLog(`NPM response: PM1=${npmResult.PM1}, PM2.5=${npmResult.PM25}, PM10=${npmResult.PM10}, status=${npmResult.npm_status_hex}`);
// Check for errors
const npmErrors = ['notReady', 'fanError', 'laserError', 'heatError', 't_rhError', 'memoryError', 'degradedState'];
const activeErrors = npmErrors.filter(e => npmResult[e] === 1);
// 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', `Errors: ${activeErrors.join(', ')}`, 'bg-warning');
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} ug/m3`, 'bg-success');
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
@@ -392,7 +412,10 @@ async function selfTestSequence() {
addSelfTestLog(`Noise response: ${JSON.stringify(noiseResult)}`);
if (noiseResult.error) {
updateTestStatus(sensor.id, 'Failed', noiseResult.error, 'bg-danger');
const noiseMsg = noiseResult.disconnected
? 'Capteur déconnecté — vérifiez le câblage USB'
: noiseResult.error;
updateTestStatus(sensor.id, 'Failed', noiseMsg, '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');
@@ -449,16 +472,19 @@ async function selfTestSequence() {
updateTestStatus(sensor.id, 'Failed', 'RTC module not connected', 'bg-danger');
testsFailed++;
} else if (rtcResult.rtc_module_time) {
const timeDiff = rtcResult.time_difference_seconds;
if (typeof timeDiff === 'number' && Math.abs(timeDiff) <= 60) {
updateTestStatus(sensor.id, 'Passed', `${rtcResult.rtc_module_time} (sync OK, diff: ${timeDiff}s)`, 'bg-success');
// 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 if (typeof timeDiff === 'number') {
updateTestStatus(sensor.id, 'Warning', `${rtcResult.rtc_module_time} (out of sync: ${timeDiff}s)`, 'bg-warning');
testsFailed++;
} else {
updateTestStatus(sensor.id, 'Passed', `${rtcResult.rtc_module_time}`, 'bg-success');
testsPassed++;
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');

View File

@@ -327,6 +327,7 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<th>PM10</th>
<th>Temperature (°C)</th>
<th>Humidity (%)</th>
<th>Status</th>
`;
} else if (table === "data_BME280") {
tableHTML += `
@@ -380,6 +381,7 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<th>Timestamp</th>
<th>Curent LEQ</th>
<th>DB_A_value</th>
<th>Status</th>
`;
}else if (table === "data_MHZ19") {
@@ -400,6 +402,10 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
tableHTML += `<tr${rowClass}>`;
if (table === "data_NPM") {
const statusVal = parseInt(columns[6]) || 0;
const statusBadge = statusVal === 0
? '<span class="badge text-bg-success">OK</span>'
: `<span class="badge text-bg-warning">0x${statusVal.toString(16).toUpperCase().padStart(2,'0')}</span>`;
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
@@ -407,6 +413,7 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<td>${columns[3]}</td>
<td>${columns[4]}</td>
<td>${columns[5]}</td>
<td>${statusBadge}</td>
`;
} else if (table === "data_BME280") {
tableHTML += `
@@ -456,10 +463,13 @@ function get_data_sqlite(table, limit, download , startDate = "", endDate = "")
<td>${columns[5]}</td>
`;
}else if (table === "data_NOISE") {
const nStatus = parseInt(columns[3]) || 0;
const nStatusLabel = nStatus === 255 ? '❌ Déconnecté' : '✅ OK';
tableHTML += `
<td>${columns[0]}</td>
<td>${columns[1]}</td>
<td>${columns[2]}</td>
<td>${nStatusLabel}</td>
`;
}else if (table === "data_MHZ19") {
@@ -519,13 +529,16 @@ function downloadCSV(response, table) {
// Add headers based on table type
if (table === "data_NPM") {
csvContent += "TimestampUTC,PM1,PM2.5,PM10,Temperature_sensor,Humidity_sensor\n";
csvContent += "TimestampUTC,PM1,PM2.5,PM10,Temperature_sensor,Humidity_sensor,npm_status\n";
} else if (table === "data_BME280") {
csvContent += "TimestampUTC,Temperature (°C),Humidity (%),Pressure (hPa)\n";
}
else if (table === "data_NPM_5channels") {
csvContent += "TimestampUTC,PM_ch1,PM_ch2,PM_ch3,PM_ch4,PM_ch5\n";
}
else if (table === "data_NOISE") {
csvContent += "TimestampUTC,Current_LEQ,DB_A_value,noise_status\n";
}
else if (table === "data_MHZ19") {
csvContent += "TimestampUTC,CO2_ppm\n";
}

View File

@@ -419,8 +419,9 @@ if ($type == "upload_firmware") {
// Check file upload
if (!isset($_FILES['firmware_file']) || $_FILES['firmware_file']['error'] !== UPLOAD_ERR_OK) {
$max_upload = ini_get('upload_max_filesize');
$upload_errors = [
UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit',
UPLOAD_ERR_INI_SIZE => "Le fichier depasse la limite serveur (actuellement $max_upload). Effectuez d'abord une mise a jour via WiFi (bouton Update firmware) pour debloquer l'upload hors-ligne.",
UPLOAD_ERR_FORM_SIZE => 'File exceeds form upload limit',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
UPLOAD_ERR_NO_FILE => 'No file was uploaded',
@@ -804,8 +805,7 @@ if ($type == "reboot") {
}
if ($type == "npm") {
$port=$_GET['port'];
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data.py ' . $port;
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/NPM/get_data_modbus_v3.py --dry-run';
$output = shell_exec($command);
echo $output;
}
@@ -880,6 +880,13 @@ if ($type == "sara") {
echo $output;
}
# SARA HARDWARE REBOOT (GPIO 16)
if ($type == "sara_hardware_reboot") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/reboot/hardware_reboot.py';
$output = shell_exec($command);
echo $output;
}
# SARA R4 COMMANDS (MQTT)
if ($type == "sara_getMQTT_config") {
$port=$_GET['port'];
@@ -1108,7 +1115,7 @@ if ($type == "wifi_connect") {
$SSID=$_GET['SSID'];
$PASS=$_GET['pass'];
// Get device name from database for instructions
// Get device name and hostname for instructions
try {
$db = new PDO("sqlite:$database_path");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
@@ -1120,6 +1127,7 @@ if ($type == "wifi_connect") {
} catch (PDOException $e) {
$deviceName = 'NebuleAir';
}
$hostname = trim(shell_exec('hostname 2>/dev/null')) ?: 'aircarto';
// Launch connection script in background
$script_path = '/var/www/nebuleair_pro_4g/connexion.sh';
@@ -1132,6 +1140,7 @@ if ($type == "wifi_connect") {
'success' => true,
'ssid' => $SSID,
'deviceName' => $deviceName,
'hostname' => $hostname,
'message' => 'Connection attempt started',
'instructions' => [
'fr' => [
@@ -1139,7 +1148,7 @@ if ($type == "wifi_connect") {
'step1' => "Le capteur tente de se connecter au réseau « $SSID »",
'step2' => "Vous allez être déconnecté du hotspot dans quelques secondes",
'step3' => "Reconnectez-vous au WiFi « $SSID » sur votre appareil",
'step4' => "Accédez au capteur via http://$deviceName.local ou cherchez son IP dans votre routeur",
'step4' => "Accédez au capteur via http://$hostname.local/html/ ou cherchez son IP dans votre routeur",
'warning' => "Si la connexion échoue, le capteur recréera automatiquement le hotspot"
],
'en' => [
@@ -1147,7 +1156,7 @@ if ($type == "wifi_connect") {
'step1' => "The sensor is attempting to connect to network « $SSID »",
'step2' => "You will be disconnected from the hotspot in a few seconds",
'step3' => "Reconnect your device to WiFi « $SSID »",
'step4' => "Access the sensor via http://$deviceName.local or find its IP in your router",
'step4' => "Access the sensor via http://$hostname.local/html/ or find its IP in your router",
'warning' => "If connection fails, the sensor will automatically recreate the hotspot"
]
]

View File

@@ -58,6 +58,9 @@
<div class="card-header">
<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="button" class="btn btn-sm" id="auto-refresh-toggle" onclick="toggleAutoRefresh()">
<span id="auto-refresh-icon"></span> Auto
</button>
<button type="submit" class="btn btn-secondary btn-sm" onclick="clear_loopLogs()" data-i18n="logs.clear">Clear</button>
<span id="script_running"></span>
@@ -274,6 +277,32 @@ function clear_loopLogs(){
});
}
// Auto-refresh for SARA logs
let autoRefreshInterval = null;
const AUTO_REFRESH_MS = 3000;
function toggleAutoRefresh() {
const btn = document.getElementById('auto-refresh-toggle');
const icon = document.getElementById('auto-refresh-icon');
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
icon.textContent = '▶';
btn.classList.remove('btn-success');
btn.classList.add('btn-secondary');
} else {
// Refresh immediately, then every N seconds
displayLogFile('../logs/sara_service.log', document.getElementById('card_loop_content'), true, 1000);
autoRefreshInterval = setInterval(() => {
displayLogFile('../logs/sara_service.log', document.getElementById('card_loop_content'), true, 1000);
}, AUTO_REFRESH_MS);
icon.textContent = '⏸';
btn.classList.remove('btn-secondary');
btn.classList.add('btn-success');
}
}
function getModem_busy_status() {
//console.log("Getting modem busy status");

View File

@@ -58,6 +58,16 @@
<label class="form-check-label" for="check_modem_configMode">Mode configuration</label>
</div>
<div id="configmode_alert" class="alert alert-danger align-items-center py-3 mb-3" role="alert" style="display:none;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-exclamation-triangle-fill me-3 flex-shrink-0" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
</svg>
<div>
<strong>Mode configuration actif — le capteur n'envoie aucune donnée !</strong><br>
<small>Le script de transmission (SARA) est désactivé tant que ce mode est actif. Pensez à le désactiver une fois la configuration terminée. Ce mode sera automatiquement désactivé au prochain redémarrage du capteur.</small>
</div>
</div>
<button class="btn btn-success mb-3 btn_selfTest" onclick="runSelfTest()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2-circle me-1" viewBox="0 0 16 16">
<path d="M2.5 8a5.5 5.5 0 1 1 11 0 5.5 5.5 0 0 1-11 0z"/>
@@ -151,14 +161,35 @@
</div>
</div>
<div class="col-sm-2">
<div class="col-sm-3">
<div class="card">
<div class="card-body">
<p class="card-text">Modem Reset </p>
<button class="btn btn-danger" onclick="getData_saraR4('ttyAMA2', 'AT+CFUN=15', 1)">Reset</button>
<p class="card-text"><strong>Modem Reset</strong></p>
<p class="text-muted small mb-2">Reset software (AT+CFUN=15) : redémarre le firmware du modem.</p>
<button class="btn btn-warning mb-2" onclick="getData_saraR4('ttyAMA2', 'AT+CFUN=15', 1)">Reset Software</button>
<div id="loading_ttyAMA2_AT_CFUN_15" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="response_ttyAMA2_AT_CFUN_15"></div>
</table>
<hr>
<p class="text-muted small mb-2">Reset hardware (GPIO 16) : coupe et rétablit l'alimentation du modem, puis teste la connexion avec le modem (~20 secondes).</p>
<button class="btn btn-danger mb-2" id="btn_hw_reboot" onclick="hardwareRebootSara()">Reset Hardware</button>
<div id="loading_hw_reboot" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="response_hw_reboot"></div>
</div>
</div>
</div>
<div class="col-sm-3">
<div class="card">
<div class="card-body">
<p class="card-text"><strong>LED status connexion (PCB)</strong></p>
<p class="text-muted small mb-2">Active la LED bleue du PCB qui indique l'état de la connexion réseau (GPIO 16 du modem).</p>
<button class="btn btn-primary mb-2" onclick="sendLedCommand('AT+UGPIOC=16,2', 'Activer')">Activer LED</button>
<button class="btn btn-secondary mb-2" onclick="sendLedCommand('AT+UGPIOC=16,255', 'Désactiver')">Désactiver LED</button>
<div id="loading_led" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<div id="response_led"></div>
</div>
</div>
</div>
@@ -446,6 +477,9 @@ document.addEventListener('DOMContentLoaded', function () {
if (check_modem_configMode) {
check_modem_configMode.checked = response.modem_config_mode;
console.log("Modem configuration: " + response.modem_config_mode);
// Show/hide config mode alert banner
const alertEl = document.getElementById("configmode_alert");
if (alertEl) alertEl.style.display = response.modem_config_mode ? "flex" : "none";
} else {
console.error("Checkbox element not found");
}
@@ -1024,6 +1058,103 @@ function getSignalInfo(port, timeout) {
});
}
function hardwareRebootSara() {
if (!confirm("Couper l'alimentation du modem SARA via GPIO 16 ?\nLe modem sera éteint pendant ~3 secondes puis redémarré.\nLa connexion avec le modem sera ensuite testée (~20 secondes au total).")) return;
console.log("Hardware reboot SARA via GPIO 16");
$("#btn_hw_reboot").prop("disabled", true);
$("#loading_hw_reboot").show();
$("#response_hw_reboot").html(`
<div class="alert alert-info py-2 mt-2" id="hw_reboot_progress">
<small><strong>Étape 1/3</strong> — Coupure alimentation du modem...</small>
</div>`);
// Progress messages
var step2Timeout = setTimeout(function() {
$("#hw_reboot_progress small").html('<strong>Étape 2/3</strong> — Attente du redémarrage du modem...');
}, 4000);
var step3Timeout = setTimeout(function() {
$("#hw_reboot_progress small").html('<strong>Étape 3/3</strong> — Test de la connexion avec le modem (ATI)...');
}, 9000);
$.ajax({
url: 'launcher.php?type=sara_hardware_reboot',
dataType: 'json',
method: 'GET',
timeout: 45000,
success: function(response) {
console.log(response);
clearTimeout(step2Timeout);
clearTimeout(step3Timeout);
$("#loading_hw_reboot").hide();
$("#btn_hw_reboot").prop("disabled", false);
if (response.reboot) {
$("#response_hw_reboot").html(`
<div class="alert alert-success py-2 mt-2">
<strong>Modem redémarré et connexion vérifiée</strong><br>
<small><code>${response.modem_response}</code></small>
</div>`);
} else {
$("#response_hw_reboot").html(`
<div class="alert alert-danger py-2 mt-2">
<strong>Echec</strong><br>
<small>${response.error || 'Modem ne répond pas après le redémarrage'}</small>
</div>`);
}
},
error: function(xhr, status, error) {
console.error('Hardware reboot failed:', status, error);
clearTimeout(step2Timeout);
clearTimeout(step3Timeout);
$("#loading_hw_reboot").hide();
$("#btn_hw_reboot").prop("disabled", false);
$("#response_hw_reboot").html(`
<div class="alert alert-danger py-2 mt-2">
<strong>Erreur de communication</strong><br>
<small>${error}</small>
</div>`);
}
});
}
function sendLedCommand(command, label) {
$("#loading_led").show();
$("#response_led").empty();
$.ajax({
url: 'launcher.php?type=sara&port=ttyAMA2&command=' + encodeURIComponent(command) + '&timeout=5',
dataType: 'text',
method: 'GET',
timeout: 10000,
success: function(response) {
$("#loading_led").hide();
if (response.indexOf("OK") !== -1) {
$("#response_led").html(`
<div class="alert alert-success py-2 mt-2">
<strong>${label} — OK</strong><br>
<small>Commande <code>${command}</code> exécutée avec succès.</small>
</div>`);
} else {
$("#response_led").html(`
<div class="alert alert-warning py-2 mt-2">
<strong>${label} — Réponse inattendue</strong><br>
<small><code>${response}</code></small>
</div>`);
}
},
error: function(xhr, status, error) {
$("#loading_led").hide();
$("#response_led").html(`
<div class="alert alert-danger py-2 mt-2">
<strong>Erreur de connexion avec le modem</strong><br>
<small>${error || 'Pas de réponse'}</small>
</div>`);
}
});
}
function getData_saraR4(port, command, timeout){
console.log("Data from SaraR4");
console.log("Port: " + port );
@@ -1429,6 +1560,10 @@ function update_modem_configMode(param, checked){
const toastBody = toastLiveExample.querySelector('.toast-body');
console.log("updating modem config mode to :" + checked);
// Toggle alert banner immediately
const alertEl = document.getElementById("configmode_alert");
if (alertEl) alertEl.style.display = checked ? "flex" : "none";
$.ajax({
url: 'launcher.php?type=update_config_sqlite&param='+param+'&value='+checked,
dataType: 'json', // Specify that you expect a JSON response

View File

@@ -117,54 +117,94 @@
$("#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", "message"];
// Error messages mapping
const errorMessages = {
"notReady": "Sensor is not ready",
"fanError": "Fan malfunction detected",
"laserError": "Laser malfunction detected",
"heatError": "Heating system error",
"t_rhError": "Temperature/Humidity sensor error",
"memoryError": "Memory failure detected",
"degradedState": "Sensor in degraded state"
};
// Add only the specified elements to the table
keysToShow.forEach(key => {
if (response[key] !== undefined) { // Check if the key exists in the response
const value = response[key];
// 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>
</tr>
`);
<tr>
<td>${key}</td>
<td>${response[key]} µg/m³</td>
</tr>
`);
}
});
// Check for errors and add them to the table
Object.keys(errorMessages).forEach(errorKey => {
if (response[errorKey] === 1) {
// 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 class="error-row">
<td><b>${errorKey}</b></td>
<td style="color: red;">⚠ ${errorMessages[errorKey]}</td>
</tr>
`);
<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();
}
});
}

View File

@@ -90,13 +90,33 @@ fi
info "Configuring Apache..."
APACHE_CONF="/etc/apache2/sites-available/000-default.conf"
if grep -q "DocumentRoot /var/www/nebuleair_pro_4g" "$APACHE_CONF"; then
warning "Apache configuration already set. Skipping."
warning "Apache DocumentRoot already set. Skipping."
else
sudo sed -i 's|DocumentRoot /var/www/html|DocumentRoot /var/www/nebuleair_pro_4g|' "$APACHE_CONF"
sudo systemctl reload apache2
success "Apache configuration updated and reloaded."
success "Apache DocumentRoot updated."
fi
# Enable AllowOverride for .htaccess (needed for PHP upload limits)
APACHE_MAIN_CONF="/etc/apache2/apache2.conf"
if grep -q "AllowOverride All" "$APACHE_MAIN_CONF"; then
warning "AllowOverride already configured. Skipping."
else
# Replace AllowOverride None with AllowOverride All for /var/www/
sudo sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' "$APACHE_MAIN_CONF"
success "AllowOverride All enabled for /var/www/."
fi
# Also increase PHP limits directly in php.ini as fallback
PHP_INI=$(php -r "echo php_ini_loaded_file();" 2>/dev/null)
if [[ -n "$PHP_INI" ]]; then
sudo sed -i 's/upload_max_filesize = .*/upload_max_filesize = 50M/' "$PHP_INI"
sudo sed -i 's/post_max_size = .*/post_max_size = 55M/' "$PHP_INI"
success "PHP upload limits set to 50M in $PHP_INI"
fi
sudo systemctl reload apache2
success "Apache configuration updated and reloaded."
# Add sudo authorization (prevent duplicate entries)
info "Setting up sudo authorization..."
SUDOERS_FILE="/etc/sudoers"

View File

@@ -151,6 +151,16 @@ payload_json = {
aircarto_profile_id = 0
uSpot_profile_id = 1
# Error flags constants (byte 66)
ERR_RTC_DISCONNECTED = 0x01
ERR_RTC_RESET = 0x02
ERR_BME280 = 0x04
ERR_NPM = 0x08
ERR_ENVEA = 0x10
ERR_NOISE = 0x20
ERR_MPPT = 0x40
ERR_WIND = 0x80
# database connection
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
@@ -271,7 +281,13 @@ class SensorPayload:
#device_id_bytes = bytes.fromhex(device_id)[:8].ljust(8, b'\x00')
self.payload[0:8] = device_id_bytes
# Status/error bytes default to 0x00 (no error)
# 0xFF = "no data" for sensor values, but for status bytes
# 0x00 = "no error/no flag" is the safe default
self.payload[66] = 0x00 # error_flags
self.payload[67] = 0x00 # npm_status
self.payload[68] = 0x00 # device_status
# Set protocol version (byte 9)
self.payload[9] = 0x01
@@ -358,6 +374,28 @@ class SensorPayload:
if direction is not None:
self.payload[64:66] = struct.pack('>H', int(direction))
def set_error_flags(self, flags):
"""Set system error flags (byte 66)"""
self.payload[66] = flags & 0xFF
def set_npm_status(self, status):
"""Set NextPM status register (byte 67)"""
self.payload[67] = status & 0xFF
def set_device_status(self, status):
"""Set device status flags (byte 68)"""
self.payload[68] = status & 0xFF
def set_firmware_version(self, version_str):
"""Set firmware version bytes 69-71 (major.minor.patch)"""
try:
parts = version_str.strip().split('.')
self.payload[69] = int(parts[0]) & 0xFF
self.payload[70] = int(parts[1]) & 0xFF
self.payload[71] = int(parts[2]) & 0xFF
except (IndexError, ValueError):
pass # leave as 0xFF if VERSION file is malformed
def get_bytes(self):
"""Get the complete 100-byte payload"""
return bytes(self.payload)
@@ -808,6 +846,19 @@ try:
payload_csv[18] = npm_temp
payload_csv[19] = npm_hum
# npm_status: last value only (no average), use rowid (not timestamp)
npm_status_value = rows[0][7] if rows and rows[0][7] is not None else 0xFF
npm_disconnected = False
if npm_status_value == 0xFF:
# 0xFF = NPM disconnected/no response → will set ERR_NPM in error_flags
npm_disconnected = True
print("NPM status: 0xFF (disconnected)")
else:
# Valid status from NPM → send as byte 67
payload.set_npm_status(npm_status_value)
print(f"NPM status: 0x{npm_status_value:02X}")
#add data to payload UDP
payload.set_npm_core(PM1, PM25, PM10)
payload.set_npm_internal(npm_temp, npm_hum)
@@ -964,18 +1015,27 @@ try:
print("No data available in the database.")
# NOISE sensor
noise_disconnected = False
if NOISE_sensor:
print("Getting NOISE sensor values")
cursor.execute("SELECT * FROM data_NOISE ORDER BY rowid DESC LIMIT 1")
last_row = cursor.fetchone()
if last_row:
print("SQLite DB last available row:", last_row)
cur_LEQ = last_row[1]
cur_LEQ = last_row[1]
cur_level = last_row[2]
# noise_status: 0xFF = disconnected (column index 3)
noise_status_value = last_row[3] if len(last_row) > 3 and last_row[3] is not None else 0x00
if noise_status_value == 0xFF:
noise_disconnected = True
print("Noise status: 0xFF (disconnected)")
else:
print(f"Noise status: 0x{noise_status_value:02X}")
#Add data to payload CSV
payload_csv[6] = cur_level
#Add data to payload UDP
payload.set_noise(
cur_leq=cur_LEQ, # current LEQ (dBA)
@@ -1090,6 +1150,25 @@ try:
'''
# ---- Build error_flags (byte 66) ----
error_flags = 0x00
if rtc_status == "disconnected":
error_flags |= ERR_RTC_DISCONNECTED
if rtc_status == "reset":
error_flags |= ERR_RTC_RESET
if npm_disconnected:
error_flags |= ERR_NPM
if noise_disconnected:
error_flags |= ERR_NOISE
payload.set_error_flags(error_flags)
# ---- Firmware version (bytes 69-71) ----
try:
with open("/var/www/nebuleair_pro_4g/VERSION", "r") as f:
payload.set_firmware_version(f.read())
except FileNotFoundError:
pass
if send_miotiq:
print('<p class="fw-bold">➡SEND TO MIOTIQ</p>', end="")
@@ -1125,9 +1204,20 @@ try:
print(response_SARA_1)
else:
print("⛔There were issues with the modem CSD PSD reinitialize process")
print("🔄 PDP reset failed → escalating to hardware reboot")
# Clignotement LED rouge en cas d'erreur
led_thread = Thread(target=blink_led, args=(24, 5, 0.5))
led_thread.start()
#Send notification (WIFI)
send_error_notification(device_id, "UDP socket creation failed + PDP reset failed -> hardware reboot")
#Hardware Reboot
hardware_reboot_success = modem_hardware_reboot()
if hardware_reboot_success:
print("✅Modem successfully rebooted and reinitialized")
else:
print("⛔There were issues with the modem reboot/reinitialize process")
#end loop
sys.exit()
#Retreive Socket ID
socket_id = None

322
loop/error_flags.md Normal file
View File

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

View File

@@ -82,7 +82,7 @@ After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/loop/SARA_send_data_v2.py
ExecStart=/usr/bin/python3 -u /var/www/nebuleair_pro_4g/loop/SARA_send_data_v2.py
User=root
WorkingDirectory=/var/www/nebuleair_pro_4g
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/sara_service.log

View File

@@ -28,7 +28,31 @@ write_fs(frequency: int) -> sampling freq
import nsrt_mk3_dev
import sqlite3
nsrt = nsrt_mk3_dev.NsrtMk3Dev('/dev/ttyACM0')
# noise_status: 0x00 = OK, 0xFF = disconnected
noise_status = 0x00
leq_level_rounded = 0
weighted_level_rounded = 0
try:
nsrt = nsrt_mk3_dev.NsrtMk3Dev('/dev/ttyACM0')
freq_level = nsrt.read_fs() #current sampling frequency
time_constant = nsrt.read_tau() #reads the current time constant
leq_level = nsrt.read_leq() #current running LEQ and starts the integration of a new LEQ.
weighting = nsrt.read_weighting() #weighting curve that is currently selected ( A ou C)
weighted_level = nsrt.read_level() #current running level in dB.
leq_level_rounded = round(leq_level, 2)
weighted_level_rounded = round(weighted_level, 2)
except Exception as e:
err_msg = str(e)
if "No such file or directory" in err_msg or "could not open port" in err_msg:
print(f"NSRT MK4 disconnected: {err_msg}")
noise_status = 0xFF
else:
print(f"NSRT MK4 error: {err_msg}")
noise_status = 0xFF
# Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
@@ -39,34 +63,15 @@ 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'
freq_level = nsrt.read_fs() #current sampling frequency
time_constant = nsrt.read_tau() #reads the current time constant
leq_level = nsrt.read_leq() #current running LEQ and starts the integration of a new LEQ.
weighting = nsrt.read_weighting() #weighting curve that is currently selected ( A ou C)
weighted_level = nsrt.read_level() #current running level in dB.
#print(f'current sampling freq : {freq_level} Hz')
#print(f'current time constant : {time_constant} s')
#print(f'current LEQ level: {leq_level:0.2f} dB')
#print(f'{weighting} value: {weighted_level:0.2f} dBA')
# Round values to 2 decimal places before saving
leq_level_rounded = round(leq_level, 2)
weighted_level_rounded = round(weighted_level, 2)
#save to db
#save to sqlite database
try:
cursor.execute('''
INSERT INTO data_NOISE (timestamp,current_LEQ, DB_A_value) VALUES (?,?,?)'''
, (rtc_time_str,leq_level_rounded,weighted_level_rounded))
INSERT INTO data_NOISE (timestamp, current_LEQ, DB_A_value, noise_status) VALUES (?,?,?,?)'''
, (rtc_time_str, leq_level_rounded, weighted_level_rounded, noise_status))
# Commit and close the connection
conn.commit()
#print("Sensor data saved successfully!")
except Exception as e:
print(f"Database error: {e}")
print(f"Database error: {e}")
conn.close()

View File

@@ -31,4 +31,10 @@ try:
print(json.dumps(data))
except Exception as e:
print(json.dumps({"error": str(e)}))
err_msg = str(e)
if "No such file or directory" in err_msg or "could not open port" in err_msg:
print(json.dumps({"error": "Capteur déconnecté — vérifiez le câblage USB du sonomètre NSRT MK4 (/dev/ttyACM0)", "disconnected": True}))
elif "Permission denied" in err_msg:
print(json.dumps({"error": "Permission refusée sur /dev/ttyACM0 — exécutez: sudo chmod 777 /dev/ttyACM0", "disconnected": False}))
else:
print(json.dumps({"error": err_msg}))

View File

@@ -67,10 +67,18 @@ CREATE TABLE IF NOT EXISTS data_NPM (
PM25 REAL,
PM10 REAL,
temp_npm REAL,
hum_npm REAL
hum_npm REAL,
npm_status INTEGER DEFAULT 0
)
""")
# Add npm_status column to existing databases (migration)
try:
cursor.execute("ALTER TABLE data_NPM ADD COLUMN npm_status INTEGER DEFAULT 0")
print("Added npm_status column to data_NPM")
except:
pass # Column already exists
# Create a table BME280
cursor.execute("""
CREATE TABLE IF NOT EXISTS data_BME280 (
@@ -132,10 +140,18 @@ cursor.execute("""
CREATE TABLE IF NOT EXISTS data_NOISE (
timestamp TEXT,
current_LEQ REAL,
DB_A_value REAL
DB_A_value REAL,
noise_status INTEGER DEFAULT 0
)
""")
# Add noise_status column to existing databases (migration)
try:
cursor.execute("ALTER TABLE data_NOISE ADD COLUMN noise_status INTEGER DEFAULT 0")
print("Added noise_status column to data_NOISE")
except:
pass # Column already exists
# Create a table MHZ19 (CO2 sensor)
cursor.execute("""
CREATE TABLE IF NOT EXISTS data_MHZ19 (

View File

@@ -107,6 +107,19 @@ for connected, port, name, coefficient in envea_sondes:
print(f"Envea sonde '{name}' already exists, skipping")
# Database migrations (add columns to existing tables)
migrations = [
("data_NPM", "npm_status", "INTEGER DEFAULT 0"),
("data_NOISE", "noise_status", "INTEGER DEFAULT 0"),
]
for table, column, col_type in migrations:
try:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
print(f"Migration: added column '{column}' to {table}")
except:
pass # Column already exists
# Commit and close the connection
conn.commit()
conn.close()

View File

@@ -81,6 +81,32 @@ sudo chmod 755 /var/www/nebuleair_pro_4g/MPPT/*.py 2>/dev/null
sudo chmod 755 /var/www/nebuleair_pro_4g/wifi/*.py 2>/dev/null
check_status "File permissions update"
# Step 3b: Ensure Apache/PHP config allows file uploads
APACHE_MAIN_CONF="/etc/apache2/apache2.conf"
if grep -q '<Directory /var/www/>' "$APACHE_MAIN_CONF"; then
if ! sed -n '/<Directory \/var\/www\/>/,/<\/Directory>/p' "$APACHE_MAIN_CONF" | grep -q "AllowOverride All"; then
sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' "$APACHE_MAIN_CONF"
print_status "✓ AllowOverride All enabled for Apache"
APACHE_CHANGED=true
fi
fi
PHP_INI=$(php -r "echo php_ini_loaded_file();" 2>/dev/null)
if [ -n "$PHP_INI" ]; then
CURRENT_UPLOAD=$(grep -oP 'upload_max_filesize = \K\d+' "$PHP_INI" 2>/dev/null)
if [ -n "$CURRENT_UPLOAD" ] && [ "$CURRENT_UPLOAD" -lt 50 ]; then
sed -i 's/upload_max_filesize = .*/upload_max_filesize = 50M/' "$PHP_INI"
sed -i 's/post_max_size = .*/post_max_size = 55M/' "$PHP_INI"
print_status "✓ PHP upload limits set to 50M"
APACHE_CHANGED=true
fi
fi
if [ "${APACHE_CHANGED:-false}" = true ]; then
systemctl reload apache2 2>/dev/null
print_status "✓ Apache reloaded"
fi
# Step 4: Restart critical services if they exist
print_status ""
print_status "Step 4: Managing system services..."

View File

@@ -118,6 +118,32 @@ chmod 755 "$TARGET_DIR/MPPT/"*.py 2>/dev/null
chmod 755 "$TARGET_DIR/wifi/"*.py 2>/dev/null
check_status "File permissions update"
# Step 4b: Ensure Apache/PHP config allows file uploads (.htaccess + php.ini)
APACHE_MAIN_CONF="/etc/apache2/apache2.conf"
if grep -q '<Directory /var/www/>' "$APACHE_MAIN_CONF"; then
if ! sed -n '/<Directory \/var\/www\/>/,/<\/Directory>/p' "$APACHE_MAIN_CONF" | grep -q "AllowOverride All"; then
sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' "$APACHE_MAIN_CONF"
print_status "✓ AllowOverride All enabled for Apache"
APACHE_CHANGED=true
fi
fi
PHP_INI=$(php -r "echo php_ini_loaded_file();" 2>/dev/null)
if [ -n "$PHP_INI" ]; then
CURRENT_UPLOAD=$(grep -oP 'upload_max_filesize = \K\d+' "$PHP_INI" 2>/dev/null)
if [ -n "$CURRENT_UPLOAD" ] && [ "$CURRENT_UPLOAD" -lt 50 ]; then
sed -i 's/upload_max_filesize = .*/upload_max_filesize = 50M/' "$PHP_INI"
sed -i 's/post_max_size = .*/post_max_size = 55M/' "$PHP_INI"
print_status "✓ PHP upload limits set to 50M"
APACHE_CHANGED=true
fi
fi
if [ "${APACHE_CHANGED:-false}" = true ]; then
systemctl reload apache2 2>/dev/null
print_status "✓ Apache reloaded"
fi
# Step 5: Restart critical services
print_status ""
print_status "Step 5: Managing system services..."