1 Commits

Author SHA1 Message Date
Your Name
1e656d76a1 add config page and terminal page 2025-03-08 14:43:22 +01:00
132 changed files with 3315 additions and 20446 deletions

View File

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

View File

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

14
.gitignore vendored
View File

@@ -14,16 +14,4 @@ NPM/data/*.txt
NPM/data/*.json NPM/data/*.json
*.lock *.lock
sqlite/*.db sqlite/*.db
sqlite/*.sql tests/
tests/
# Secrets
.env
# Claude Code local settings
.claude/settings.local.json
# Python bytecode
__pycache__/
*.pyc

View File

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

View File

@@ -1,101 +0,0 @@
# CCS811 — Capteur qualité d'air (eCO2 / TVOC)
Capteur de gaz **MOX** (oxyde métallique) AMS CCS811. Connecté en **I2C**.
## ⚠ À lire avant de câbler
Le CCS811 **n'est pas** un capteur CO2 NDIR comme le Senseair S88. C'est un capteur
de COV (composés organiques volatils) qui mesure :
- **TVOC** (Total Volatile Organic Compounds) — en **ppb**. C'est la mesure réellement
utile / fiable du capteur, et celle qui nous intéresse ici.
- **eCO2** (CO2 *équivalent*) — en **ppm**, plage 4008192. Valeur *calculée* à partir
du TVOC par un algorithme interne, ce **n'est pas** une mesure directe du CO2. Pour
un vrai CO2, utiliser le S88. On stocke quand même l'eCO2 (gratuit, vient de la même
lecture) mais ne pas le confondre avec une mesure NDIR.
## ⚠ Clock-stretching I2C sur Raspberry Pi
Le CCS811 utilise massivement le **clock-stretching** I2C. Le contrôleur I2C matériel
du Raspberry Pi (BSC) gère **mal** le clock-stretching (bug matériel documenté). Sans
mitigation, les lectures échouent typiquement en `OSError` / `Remote I/O error`.
**Mitigation** : ralentir le bus I2C dans `/boot/firmware/config.txt` :
```
dtparam=i2c_arm_baudrate=10000
```
(10 kHz au lieu de 100 kHz par défaut.) Reboot ensuite.
**Confirmé nécessaire sur le terrain** (nebuleair-pro100, juin 2026) : à 100 kHz le
CCS811 renvoie des valeurs corrompues 0x8000+ (32768) par intermittence et finit en
état d'erreur. À 10 kHz c'est stable. Ce réglage n'est pas géré par le repo (fichier
hors `/var/www`), il doit être posé à la main sur chaque capteur équipé d'un CCS811.
Vérifier la présence du capteur :
```bash
sudo i2cdetect -y 1 # doit montrer 5a (ou 5b selon la broche ADDR)
```
## Adresse I2C
- **0x5A** : ADDR à GND — défaut des breakouts **Adafruit**. Valeur par défaut du firmware.
- **0x5B** : ADDR à VDD — défaut des breakouts **SparkFun** / modules génériques.
Configurable dans `admin.html` (clé config `CCS811_address`, dropdown 0x5A / 0x5B).
## Câblage I2C
| CCS811 | Raspberry Pi |
|---|---|
| VCC / VIN | 3.3V |
| GND | GND |
| SDA | SDA (GPIO2) |
| SCL | SCL (GPIO3) |
| WAK / nWAKE | GND (réveil permanent ; sinon laisser le module gérer) |
| ADDR | GND → 0x5A, VDD → 0x5B |
⚠ La plupart des breakouts CCS811 sont en **3.3V** logique. Ne pas alimenter en 5V
sans level-shifter sauf si le module embarque son propre régulateur + shifter.
## Burn-in / conditionnement
- **Burn-in initial** : ~48 h de fonctionnement continu avant des valeurs stables (1ère mise en service).
- **Warm-up** à chaque démarrage : ~20 min pour des valeurs fiables. Au démarrage le
capteur renvoie souvent eCO2=400 ppm / TVOC=0 ppb (valeurs de repos).
## Implémentation NebuleAir
**Architecture : daemon, PAS un timer oneshot** (contrairement aux autres capteurs).
Le CCS811 doit être initialisé **une seule fois** puis lu en continu :
- chaque (ré)init fait un reset + app_start, et les premiers échantillons juste après
sont du garbage (eCO2 = 0, ou valeurs 0x8000+ = 32768 dues au clock-stretching) ;
- un cycle reset toutes les 10 s empêche l'algorithme de baseline de se construire.
Composants :
- `CCS811/daemon.py` — service long-running (`nebuleair-ccs811-data.service`,
`Type=simple`, `Restart=always`). Init une fois, puis boucle : toutes les 10 s,
lit un échantillon **valide** (eCO2 ∈ [400, 8192], le reste est jeté) et l'écrit
dans `data_CCS811 (timestamp, eCO2, TVOC)`. Re-init automatique du capteur après
plusieurs erreurs I2C consécutives.
- `CCS811/get_data.py` — bouton "Get Data" du web. **Ne lit PAS le capteur** (ça
entrerait en collision I2C avec le daemon et corromprait la sonde) : renvoie la
**dernière ligne** de `data_CCS811`. Affiche `{"eCO2","TVOC","timestamp"}` ou
`{"error": "..."}`.
Librairie Python : `adafruit-circuitpython-ccs811` (dans `requirements.txt`,
installée par `installation_part1.sh` ET par `update_firmware.sh`). La table est
créée par `sqlite/create_db.py` et self-healée par `daemon.py`
(CREATE TABLE IF NOT EXISTS) — garder les deux schémas synchro.
Activation : `admin.html` → case "Send VOC sensor data (CCS811)".
### Pistes d'amélioration (non implémentées)
Le CCS811 supporte une compensation température/humidité (`SET_ENV_DATA`). Comme le
boîtier embarque déjà un BME280, on pourrait lui pousser temp/hum périodiquement
pour améliorer la précision. Non fait pour garder le daemon simple.

View File

@@ -1,143 +0,0 @@
'''
Long-running daemon for the AMS CCS811 air-quality sensor (TVOC + eCO2).
Run by systemd nebuleair-ccs811-data.service (Type=simple, Restart=always).
WHY a daemon and not a 10s oneshot timer like the other sensors:
the CCS811 must be initialised ONCE and then read continuously. Each driver
(re)init does a reset + app_start, and the first samples right after that are
garbage (eCO2 = 0 or 0x8000+ corruption). Resetting every 10s also prevents the
sensor's baseline algorithm from ever building up. So we init once here and loop.
Valid eCO2 range is [400, 8192] ppm. Out-of-range samples (notably 0x8000 = 32768,
an I2C clock-stretching corruption artifact on the Pi) are dropped.
TVOC is the primary measurement; eCO2 is *derived* from VOCs (not a true NDIR CO2).
The web "Get Data" button does NOT read the sensor (that would collide with this
daemon on the I2C bus and corrupt it) — it reads the last row from the DB instead.
See CCS811/get_data.py.
'''
import sqlite3
import sys
import time
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
DEFAULT_ADDRESS = 0x5A
READ_INTERVAL = 10 # seconds between stored samples
ECO2_MIN, ECO2_MAX = 400, 8192 # CCS811 physical eCO2 range
SAMPLE_TIMEOUT = 4 # max seconds to wait for a valid sample within a tick
REINIT_AFTER_ERRORS = 5 # consecutive I2C errors before re-initialising the sensor
INIT_RETRY_DELAY = 10 # seconds between init attempts
def get_config(cursor, key, default):
cursor.execute("SELECT value FROM config_table WHERE key = ?", (key,))
row = cursor.fetchone()
return row[0] if row else default
def ensure_table(cursor):
# Self-heal: duplicates the canonical schema from sqlite/create_db.py.
cursor.execute("""
CREATE TABLE IF NOT EXISTS data_CCS811 (
timestamp TEXT,
eCO2 INTEGER,
TVOC INTEGER
)
""")
def init_sensor(address):
import board
import busio
import adafruit_ccs811
i2c = busio.I2C(board.SCL, board.SDA)
return adafruit_ccs811.CCS811(i2c, address=address)
def init_with_retry(address):
while True:
try:
ccs = init_sensor(address)
print(f"CCS811: initialised at {hex(address)}", flush=True)
return ccs
except Exception as e:
print(f"CCS811: init failed at {hex(address)}: {e} (retry in {INIT_RETRY_DELAY}s)", flush=True)
time.sleep(INIT_RETRY_DELAY)
def read_valid_sample(ccs):
'''Poll up to SAMPLE_TIMEOUT for a data_ready sample in the valid eCO2 range.
Returns (eco2, tvoc) or None. Raises OSError on I2C failure.'''
end = time.monotonic() + SAMPLE_TIMEOUT
while time.monotonic() < end:
if ccs.data_ready:
eco2 = int(ccs.eco2)
tvoc = int(ccs.tvoc)
if ECO2_MIN <= eco2 <= ECO2_MAX:
return eco2, tvoc
# else: out-of-range (warm-up 0 or 0x8000 corruption) -> keep polling
time.sleep(0.5)
return None
def main():
conn = sqlite3.connect(DB_PATH, timeout=10)
cursor = conn.cursor()
ensure_table(cursor)
conn.commit()
addr_str = get_config(cursor, "CCS811_address", "0x5A")
try:
address = int(str(addr_str), 16)
except ValueError:
address = DEFAULT_ADDRESS
try:
import board # noqa: F401
import busio # noqa: F401
import adafruit_ccs811 # noqa: F401
except Exception as e:
print(f"CCS811: library import failed: {e}", flush=True)
conn.close()
sys.exit(1)
ccs = init_with_retry(address)
print("CCS811: discarding warm-up samples...", flush=True)
consecutive_errors = 0
while True:
try:
sample = read_valid_sample(ccs)
consecutive_errors = 0
if sample is None:
print("CCS811: no valid sample this tick (warming up or corrupted), skipping.", flush=True)
else:
eco2, tvoc = sample
cursor.execute("SELECT last_updated FROM timestamp_table LIMIT 1")
row = cursor.fetchone()
rtc_time_str = row[0] if row else "not connected"
cursor.execute(
"INSERT INTO data_CCS811 (timestamp, eCO2, TVOC) VALUES (?, ?, ?)",
(rtc_time_str, eco2, tvoc),
)
conn.commit()
print(f"eCO2: {eco2} ppm, TVOC: {tvoc} ppb (saved at {rtc_time_str})", flush=True)
except OSError as e:
consecutive_errors += 1
print(f"CCS811: I2C error: {e} (#{consecutive_errors})", flush=True)
if consecutive_errors >= REINIT_AFTER_ERRORS:
print("CCS811: too many I2C errors, re-initialising sensor...", flush=True)
ccs = init_with_retry(address)
consecutive_errors = 0
except Exception as e:
print(f"CCS811: unexpected error: {e}", flush=True)
time.sleep(READ_INTERVAL)
if __name__ == "__main__":
main()

View File

@@ -1,40 +0,0 @@
'''
Live value for the web "Get Data" button (CCS811 air-quality sensor).
Prints {"eCO2": <ppm>, "TVOC": <ppb>, "timestamp": <str>} or {"error": "<msg>"}.
IMPORTANT: this does NOT read the I2C sensor. The CCS811 is owned by the
long-running daemon (CCS811/daemon.py); opening the bus here would collide with
it and corrupt the sensor. Instead we return the most recent row the daemon
stored in data_CCS811. TVOC is the primary measurement.
Usage: /usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/get_data.py
'''
import json
import sqlite3
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
def main():
try:
conn = sqlite3.connect(DB_PATH, timeout=5)
cursor = conn.cursor()
cursor.execute(
"SELECT timestamp, eCO2, TVOC FROM data_CCS811 ORDER BY timestamp DESC LIMIT 1"
)
row = cursor.fetchone()
conn.close()
except Exception as e:
print(json.dumps({"error": f"DB read error: {e}"}))
return
if not row:
print(json.dumps({"error": "No CCS811 data yet (daemon warming up?)"}))
return
print(json.dumps({"timestamp": row[0], "eCO2": int(row[1]), "TVOC": int(row[2])}))
if __name__ == "__main__":
main()

304
CLAUDE.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,9 @@ import serial
import requests import requests
import json import json
import sys import sys
import time
parameter = sys.argv[1:] # Exclude the script name parameter = sys.argv[1:] # Exclude the script name
#print("Parameters received:")
port='/dev/'+parameter[0] port='/dev/'+parameter[0]
ser = serial.Serial( ser = serial.Serial(
@@ -34,93 +34,42 @@ ser.write(b'\x81\x11\x6E') #data10s
while True: while True:
try: try:
byte_data = ser.readline() byte_data = ser.readline()
#print(byte_data)
# Convert raw data to hex string for debugging
raw_hex = byte_data.hex() if byte_data else ""
# Check if we received data
if not byte_data or len(byte_data) < 15:
data = {
'PM1': 0.0,
'PM25': 0.0,
'PM10': 0.0,
'sleep': 0,
'degradedState': 0,
'notReady': 0,
'heatError': 0,
't_rhError': 0,
'fanError': 0,
'memoryError': 0,
'laserError': 0,
'raw': raw_hex,
'message': f"No data received or incomplete frame (length: {len(byte_data)})"
}
json_data = json.dumps(data)
print(json_data)
break
stateByte = int.from_bytes(byte_data[2:3], byteorder='big') stateByte = int.from_bytes(byte_data[2:3], byteorder='big')
Statebits = [int(bit) for bit in bin(stateByte)[2:].zfill(8)] Statebits = [int(bit) for bit in bin(stateByte)[2:].zfill(8)]
PM1 = int.from_bytes(byte_data[9:11], byteorder='big')/10 PM1 = int.from_bytes(byte_data[9:11], byteorder='big')/10
PM25 = int.from_bytes(byte_data[11:13], byteorder='big')/10 PM25 = int.from_bytes(byte_data[11:13], byteorder='big')/10
PM10 = int.from_bytes(byte_data[13:15], byteorder='big')/10 PM10 = int.from_bytes(byte_data[13:15], byteorder='big')/10
#print(f"State: {Statebits}")
# Create JSON with raw data and status message #print(f"PM1: {PM1}")
#print(f"PM25: {PM25}")
#print(f"PM10: {PM10}")
#create JSON
data = { data = {
'capteurID': 'nebuleairpro1',
'sondeID':'USB2',
'PM1': PM1, 'PM1': PM1,
'PM25': PM25, 'PM25': PM25,
'PM10': PM10, 'PM10': PM10,
'sleep': Statebits[0], 'sleep' : Statebits[0],
'degradedState': Statebits[1], 'degradedState' : Statebits[1],
'notReady': Statebits[2], 'notReady' : Statebits[2],
'heatError': Statebits[3], 'heatError' : Statebits[3],
't_rhError': Statebits[4], 't_rhError' : Statebits[4],
'fanError': Statebits[5], 'fanError' : Statebits[5],
'memoryError': Statebits[6], 'memoryError' : Statebits[6],
'laserError': Statebits[7], 'laserError' : Statebits[7]
'raw': raw_hex,
'message': 'OK' if sum(Statebits[1:]) == 0 else 'Sensor error detected'
} }
json_data = json.dumps(data) json_data = json.dumps(data)
print(json_data) print(json_data)
break break
except KeyboardInterrupt: except KeyboardInterrupt:
data = { print("User interrupt encountered. Exiting...")
'PM1': 0.0,
'PM25': 0.0,
'PM10': 0.0,
'sleep': 0,
'degradedState': 0,
'notReady': 0,
'heatError': 0,
't_rhError': 0,
'fanError': 0,
'memoryError': 0,
'laserError': 0,
'raw': '',
'message': 'User interrupt encountered'
}
print(json.dumps(data))
time.sleep(3) time.sleep(3)
exit() exit()
except:
except Exception as e: # for all other kinds of error, but not specifying which one
data = { print("Unknown error...")
'PM1': 0.0,
'PM25': 0.0,
'PM10': 0.0,
'sleep': 0,
'degradedState': 0,
'notReady': 0,
'heatError': 0,
't_rhError': 0,
'fanError': 0,
'memoryError': 0,
'laserError': 0,
'raw': '',
'message': f'Error: {str(e)}'
}
print(json.dumps(data))
time.sleep(3) time.sleep(3)
exit() exit()

View File

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

View File

@@ -29,8 +29,6 @@ Request
\x00\x55 Quantity of Registers (Requests to read x55 or 85 consecutive registers starting from address 56) \x00\x55 Quantity of Registers (Requests to read x55 or 85 consecutive registers starting from address 56)
\...\... Cyclic Redundancy Check (checksum ) \...\... Cyclic Redundancy Check (checksum )
MAJ 2026 --> renvoie des 0 si pas de réponse du NPM
''' '''
import serial import serial
import requests import requests
@@ -40,9 +38,6 @@ import crcmod
import sqlite3 import sqlite3
import time import time
# Dry-run mode: print JSON output without writing to database
dry_run = "--dry-run" in sys.argv
# Connect to the SQLite database # Connect to the SQLite database
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db") conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor() cursor = conn.cursor()
@@ -57,188 +52,128 @@ def load_config(config_file):
return {} return {}
# Load the configuration data # Load the configuration data
npm_solo_port = "/dev/ttyAMA5" #port du NPM solo config_file = '/var/www/nebuleair_pro_4g/config.json'
config = load_config(config_file)
npm_solo_port = config.get('NPM_solo_port', '') #port du NPM solo
#GET RTC TIME from SQlite #GET RTC TIME from SQlite
cursor.execute("SELECT * FROM timestamp_table LIMIT 1") cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
row = cursor.fetchone() # Get the first (and only) row row = cursor.fetchone() # Get the first (and only) row
rtc_time_str = row[1] # '2025-02-07 12:30:45' rtc_time_str = row[1] # '2025-02-07 12:30:45'
# Initialize default error values # Initialize serial port
pm1_10s = 0 ser = serial.Serial(
pm25_10s = 0 port=npm_solo_port,
pm10_10s = 0 baudrate=115200,
channel_1 = 0 parity=serial.PARITY_EVEN,
channel_2 = 0 stopbits=serial.STOPBITS_ONE,
channel_3 = 0 bytesize=serial.EIGHTBITS,
channel_4 = 0 timeout=2
channel_5 = 0 )
relative_humidity = 0
temperature = 0
npm_status = 0xFF # Default: all errors (will be overwritten if read succeeds)
try: # Define Modbus CRC-16 function
# Initialize serial port crc16 = crcmod.predefined.mkPredefinedCrcFun('modbus')
ser = serial.Serial(
port=npm_solo_port,
baudrate=115200,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=2
)
# Define Modbus CRC-16 function # Request frame without CRC
crc16 = crcmod.predefined.mkPredefinedCrcFun('modbus') data = b'\x01\x03\x00\x38\x00\x55'
# Request frame without CRC # Calculate and append CRC
data = b'\x01\x03\x00\x38\x00\x55' crc = crc16(data)
crc_low = crc & 0xFF
crc_high = (crc >> 8) & 0xFF
request = data + bytes([crc_low, crc_high])
# Calculate and append CRC # Clear serial buffer before sending
crc = crc16(data) ser.flushInput()
crc_low = crc & 0xFF
crc_high = (crc >> 8) & 0xFF
request = data + bytes([crc_low, crc_high])
# Clear serial buffer before sending # Send request
ser.flushInput() ser.write(request)
time.sleep(0.2) # Wait for sensor to respond
# Send request # Read response
ser.write(request) response_length = 2 + 1 + (2 * 85) + 2 # Address + Function + Data + CRC
time.sleep(0.2) # Wait for sensor to respond byte_data = ser.read(response_length)
# Read response # Validate response length
response_length = 2 + 1 + (2 * 85) + 2 # Address + Function + Data + CRC if len(byte_data) < response_length:
byte_data = ser.read(response_length) print("[ERROR] Incomplete response received:", byte_data.hex())
exit()
# Validate response length # Verify CRC
if len(byte_data) < response_length: received_crc = int.from_bytes(byte_data[-2:], byteorder='little')
if not dry_run: calculated_crc = crc16(byte_data[:-2])
print(f"[ERROR] Incomplete response received: {byte_data.hex()}")
raise Exception("Incomplete response")
# Verify CRC if received_crc != calculated_crc:
received_crc = int.from_bytes(byte_data[-2:], byteorder='little') print("[ERROR] CRC check failed! Corrupted data received.")
calculated_crc = crc16(byte_data[:-2]) exit()
if received_crc != calculated_crc: # Convert response to hex for debugging
if not dry_run: formatted = ''.join(f'\\x{byte:02x}' for byte in byte_data)
print("[ERROR] CRC check failed! Corrupted data received.") #print("Response:", formatted)
raise Exception("CRC check failed")
# Convert response to hex for debugging # Extract and print PM values
formatted = ''.join(f'\\x{byte:02x}' for byte in byte_data) def extract_value(byte_data, register, scale=1, single_register=False, round_to=None):
#print("Response:", formatted) REGISTER_START = 56
offset = (register - REGISTER_START) * 2 + 3
# Extract and print PM values if single_register:
def extract_value(byte_data, register, scale=1, single_register=False, round_to=None): value = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
REGISTER_START = 56
offset = (register - REGISTER_START) * 2 + 3
if single_register:
value = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
else:
lsw = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
msw = int.from_bytes(byte_data[offset+2:offset+4], byteorder='big')
value = (msw << 16) | lsw
value = value / scale
if round_to == 0:
return int(value)
elif round_to is not None:
return round(value, round_to)
else:
return value
pm1_10s = extract_value(byte_data, 56, 1000, round_to=1)
pm25_10s = extract_value(byte_data, 58, 1000, round_to=1)
pm10_10s = extract_value(byte_data, 60, 1000, round_to=1)
#print("10 sec concentration:")
#print(f"PM1: {pm1_10s}")
#print(f"PM2.5: {pm25_10s}")
#print(f"PM10: {pm10_10s}")
# Extract values for 5 channels
channel_1 = extract_value(byte_data, 128, round_to=0) # 0.2 - 0.5μm
channel_2 = extract_value(byte_data, 130, round_to=0) # 0.5 - 1.0μm
channel_3 = extract_value(byte_data, 132, round_to=0) # 1.0 - 2.5μm
channel_4 = extract_value(byte_data, 134, round_to=0) # 2.5 - 5.0μm
channel_5 = extract_value(byte_data, 136, round_to=0) # 5.0 - 10.0μm
#print(f"Channel 1 (0.2->0.5): {channel_1}")
#print(f"Channel 2 (0.5->1.0): {channel_2}")
#print(f"Channel 3 (1.0->2.5): {channel_3}")
#print(f"Channel 4 (2.5->5.0): {channel_4}")
#print(f"Channel 5 (5.0->10.): {channel_5}")
# Retrieve relative humidity from register 106 (0x6A)
relative_humidity = extract_value(byte_data, 106, 100, single_register=True)
# Retrieve temperature from register 106 (0x6A)
temperature = extract_value(byte_data, 107, 100, single_register=True)
#print(f"Internal Relative Humidity: {relative_humidity} %")
#print(f"Internal temperature: {temperature} °C")
# Read NPM status register (register 19 = 0x13, 1 register)
# Modbus request: slave=0x01, func=0x03, addr=0x0013, qty=0x0001
status_request = b'\x01\x03\x00\x13\x00\x01'
status_crc = crc16(status_request)
status_request += bytes([status_crc & 0xFF, (status_crc >> 8) & 0xFF])
ser.flushInput()
ser.write(status_request)
time.sleep(0.2)
# Response: addr(1) + func(1) + byte_count(1) + data(2) + crc(2) = 7 bytes
status_response = ser.read(7)
if len(status_response) == 7:
status_recv_crc = int.from_bytes(status_response[-2:], byteorder='little')
status_calc_crc = crc16(status_response[:-2])
if status_recv_crc == status_calc_crc:
npm_status = int.from_bytes(status_response[3:5], byteorder='big') & 0xFF
if not dry_run:
print(f"NPM status: 0x{npm_status:02X} ({npm_status})")
else:
if not dry_run:
print("[WARNING] NPM status CRC check failed, keeping default")
else: else:
if not dry_run: lsw = int.from_bytes(byte_data[offset:offset+2], byteorder='big')
print(f"[WARNING] NPM status incomplete response ({len(status_response)} bytes)") msw = int.from_bytes(byte_data[offset+2:offset+4], byteorder='big')
value = (msw << 16) | lsw
ser.close() value = value / scale
except Exception as e: if round_to == 0:
if not dry_run: return int(value)
print(f"[ERROR] Sensor communication failed: {e}") elif round_to is not None:
# Variables already set to -1 at the beginning return round(value, round_to)
finally:
if dry_run:
# Print JSON output without writing to database
result = {
"PM1": pm1_10s,
"PM25": pm25_10s,
"PM10": pm10_10s,
"temperature": temperature,
"humidity": relative_humidity,
"npm_status": npm_status,
"npm_status_hex": f"0x{npm_status:02X}"
}
print(json.dumps(result))
else: else:
# Always save data to database, even if all values are 0 return value
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(''' pm1_10s = extract_value(byte_data, 56, 1000, round_to=1)
INSERT INTO data_NPM (timestamp,PM1, PM25, PM10, temp_npm, hum_npm, npm_status) VALUES (?,?,?,?,?,?,?)''' pm25_10s = extract_value(byte_data, 58, 1000, round_to=1)
, (rtc_time_str, pm1_10s, pm25_10s, pm10_10s, temperature, relative_humidity, npm_status)) pm10_10s = extract_value(byte_data, 60, 1000, round_to=1)
# Commit and close the connection #print("10 sec concentration:")
conn.commit() #print(f"PM1: {pm1_10s}")
#print(f"PM2.5: {pm25_10s}")
#print(f"PM10: {pm10_10s}")
conn.close() # Extract values for 5 channels
channel_1 = extract_value(byte_data, 128, round_to=0) # 0.2 - 0.5μm
channel_2 = extract_value(byte_data, 130, round_to=0) # 0.5 - 1.0μm
channel_3 = extract_value(byte_data, 132, round_to=0) # 1.0 - 2.5μm
channel_4 = extract_value(byte_data, 134, round_to=0) # 2.5 - 5.0μm
channel_5 = extract_value(byte_data, 136, round_to=0) # 5.0 - 10.0μm
#print(f"Channel 1 (0.2->0.5): {channel_1}")
#print(f"Channel 2 (0.5->1.0): {channel_2}")
#print(f"Channel 3 (1.0->2.5): {channel_3}")
#print(f"Channel 4 (2.5->5.0): {channel_4}")
#print(f"Channel 5 (5.0->10.): {channel_5}")
# Retrieve relative humidity from register 106 (0x6A)
relative_humidity = extract_value(byte_data, 106, 100, single_register=True)
# Retrieve temperature from register 106 (0x6A)
temperature = extract_value(byte_data, 107, 100, single_register=True)
#print(f"Internal Relative Humidity: {relative_humidity} %")
#print(f"Internal temperature: {temperature} °C")
cursor.execute('''
INSERT INTO data_NPM_5channels (timestamp,PM_ch1, PM_ch2, PM_ch3, PM_ch4, PM_ch5) VALUES (?,?,?,?,?,?)'''
, (rtc_time_str,channel_1,channel_2,channel_3,channel_4,channel_5))
cursor.execute('''
INSERT INTO data_NPM (timestamp,PM1, PM25, PM10, temp_npm, hum_npm) VALUES (?,?,?,?,?,?)'''
, (rtc_time_str,pm1_10s,pm25_10s,pm10_10s,temperature,relative_humidity ))
# Commit and close the connection
conn.commit()
conn.close()

175
README.md
View File

@@ -28,19 +28,17 @@ Line by line installation.
``` ```
sudo apt update sudo apt update
sudo apt install git gh apache2 sqlite3 php php-sqlite3 python3 python3-pip jq autossh i2c-tools python3-smbus -y sudo apt install git gh apache2 php php-sqlite3 python3 python3-pip jq autossh i2c-tools python3-smbus -y
sudo pip3 install pyserial requests RPi.GPIO adafruit-circuitpython-bme280 crcmod psutil ntplib pytz gpiozero adafruit-circuitpython-ads1x15 numpy nsrt-mk3-dev --break-system-packages sudo pip3 install pyserial requests RPi.GPIO adafruit-circuitpython-bme280 crcmod psutil ntplib pytz --break-system-packages
sudo mkdir -p /var/www/.ssh sudo mkdir -p /var/www/.ssh
sudo ssh-keygen -t rsa -b 4096 -f /var/www/.ssh/id_rsa -N "" sudo ssh-keygen -t rsa -b 4096 -f /var/www/.ssh/id_rsa -N ""
sudo ssh-copy-id -i /var/www/.ssh/id_rsa.pub -p 50221 airlab_server1@aircarto.fr sudo ssh-copy-id -i /var/www/.ssh/id_rsa.pub -p 50221 airlab_server1@aircarto.fr
sudo git clone http://gitea.aircarto.fr/PaulVua/nebuleair_pro_4g.git /var/www/nebuleair_pro_4g sudo git clone http://gitea.aircarto.fr/PaulVua/nebuleair_pro_4g.git /var/www/nebuleair_pro_4g
sudo mkdir /var/www/nebuleair_pro_4g/logs sudo mkdir /var/www/nebuleair_pro_4g/logs
sudo touch /var/www/nebuleair_pro_4g/logs/app.log /var/www/nebuleair_pro_4g/logs/loop.log /var/www/nebuleair_pro_4g/wifi_list.csv sudo touch /var/www/nebuleair_pro_4g/logs/app.log /var/www/nebuleair_pro_4g/logs/loop.log /var/www/nebuleair_pro_4g/wifi_list.csv
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/create_db.py sudo cp /var/www/nebuleair_pro_4g/config.json.dist /var/www/nebuleair_pro_4g/config.json
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/set_config.py
sudo chmod -R 777 /var/www/nebuleair_pro_4g/ sudo chmod -R 777 /var/www/nebuleair_pro_4g/
git config --global core.fileMode false git config --global core.fileMode false
git -C /var/www/nebuleair_pro_4g config core.fileMode false
git config --global --add safe.directory /var/www/nebuleair_pro_4g git config --global --add safe.directory /var/www/nebuleair_pro_4g
sudo crontab /var/www/nebuleair_pro_4g/cron_jobs sudo crontab /var/www/nebuleair_pro_4g/cron_jobs
sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/create_db.py sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/create_db.py
@@ -59,9 +57,6 @@ ALL ALL=(ALL) NOPASSWD: /usr/bin/nmcli, /usr/sbin/reboot
www-data ALL=(ALL) NOPASSWD: /usr/bin/git pull www-data ALL=(ALL) NOPASSWD: /usr/bin/git pull
www-data ALL=(ALL) NOPASSWD: /usr/bin/ssh www-data ALL=(ALL) NOPASSWD: /usr/bin/ssh
www-data ALL=(ALL) NOPASSWD: /usr/bin/python3 * www-data ALL=(ALL) NOPASSWD: /usr/bin/python3 *
www-data ALL=(ALL) NOPASSWD: /bin/systemctl *
www-data ALL=(ALL) NOPASSWD: /usr/bin/pkill
www-data ALL=(ALL) NOPASSWD: /var/www/nebuleair_pro_4g/*
``` ```
## Serial ## Serial
@@ -181,146 +176,6 @@ 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 # Notes
## Wifi Hotspot (AP) ## Wifi Hotspot (AP)
@@ -353,28 +208,4 @@ This can be doned with script boot_hotspot.sh.
@reboot /var/www/nebuleair_pro_4g/boot_hotspot.sh @reboot /var/www/nebuleair_pro_4g/boot_hotspot.sh
``` ```
## Claude Code
Instructions to use claude code on the RPI.
### Install NPM
```
sudo apt install -y nodejs npm
node -v
npm -v
```
### Install Claude
```
sudo npm install -g @anthropic-ai/claude-code
```
### Run claude
```
claude
```

View File

@@ -1,15 +1,45 @@
''' '''
____ _____ ____ ____ _____ ____
| _ \_ _/ ___| | _ \_ _/ ___|
| |_) || || | | |_) || || |
| _ < | || |___ | _ < | || |___
|_| \_\|_| \____| |_| \_\|_| \____|
Script to read time from RTC module and save it to DB Script to read time from RTC module and save it to DB
I2C connection - Address 0x68 I2C connection
Address 0x68
/usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/save_to_db.py
This need to be run as a system service
--> sudo nano /etc/systemd/system/rtc_save_to_db.service
⬇️
[Unit]
Description=RTC Save to DB Script
After=network.target
[Service]
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/save_to_db.py
Restart=always
RestartSec=1
User=root
WorkingDirectory=/var/www/nebuleair_pro_4g
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/rtc_save_to_db.log
StandardError=append:/var/www/nebuleair_pro_4g/logs/rtc_save_to_db_errors.log
[Install]
WantedBy=multi-user.target
⬆️
sudo systemctl daemon-reload
sudo systemctl enable rtc_save_to_db.service
sudo systemctl start rtc_save_to_db.service
sudo systemctl status rtc_save_to_db.service
Runs as a long-running systemd service (rtc_save_to_db.service).
The service file is created by services/setup_services.sh — single source of truth.
''' '''
import smbus2 import smbus2
import time import time

View File

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

View File

@@ -1,11 +1,5 @@
""" """
____ _____ ____ Script to set the RTC using the browser time.
| _ \_ _/ ___|
| |_) || || |
| _ < | || |___
|_| \_\|_| \____|
Script to set the RTC using the browser time (script used by the web UI).
/usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_browserTime.py '2024-01-30 12:48:39' /usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_browserTime.py '2024-01-30 12:48:39'

View File

@@ -1,171 +0,0 @@
# Senseair S88 — Capteur CO2 NDIR
Notes essentielles extraites des datasheets Senseair (Product Specification PSP14279
rev 3, et "Modbus on Senseair S88" TDE14367 rev 5). Les PDF originaux ne sont pas
versionnés (trop lourds, pas utiles sur les capteurs).
## Modèle
- **Senseair S88 Residential** — Article No. 004-1-0100
- Capteur CO2 miniature NDIR (Non-Dispersive InfraRed)
- Dimensions : 33.9 × 19.6 × 9.7 mm — poids < 5 g
- Compatibilité registres Modbus avec le Senseair S8
## Caractéristiques mesure
| Paramètre | Valeur |
|---|---|
| Gaz mesuré | CO2 |
| Plage | 400 10 000 ppm |
| Intervalle de mesure | 2 s |
| Précision 4003000 ppm | ±25 ppm ±3% de la lecture |
| Précision 300010000 ppm | ±10% de la lecture |
| Temps de chauffe | ≤ 10 s |
| Temps de réponse t63% | ≤ 30 s |
| Conditions d'opération | 050 °C, 085 %RH (sans condensation, dew point ≤ 35 °C) |
| Dépendance pression | 1.6 % par kPa d'écart à la pression normale |
| Durée de vie | > 15 ans |
| Maintenance | Sans entretien (ABC : Automatic Baseline Correction activé par défaut) |
## Alimentation
- **Tension** : 4.5 5.25 V (le 5V du Pi convient)
- **Courant pic** : ≤ 300 mA (pendant la rampe de la lampe IR)
- **Courant moyen** : ≤ 30 mA
- Non protégée contre surtensions / inversion polarité — attention au câblage
## Pinout
```
G+ ●─┐ ┌─● DVCC_out (3.3V, sortie régulateur interne — ne PAS utiliser)
G0 ●─┤ ├─● UART_TxD (3.3V CMOS, sortie capteur)
Alarm_OC●─┤ ├─● UART_RxD (3.3V CMOS, entrée capteur)
PWM 1kHz●─┘ ├─● UART_R/T (direction RS-485, à laisser flottant en UART direct)
└─● bCAL_in (entrée calibration manuelle)
```
### Câblage vers la PCB NebuleAir Pro
Le S88 se branche sur un connecteur libre de la PCB custom (NPM1/NPM2/NPM3 — voir
mapping silkscreen ↔ ttyAMA dans le CLAUDE.md à la racine). Sélectionne ensuite le
port correspondant dans admin.html → "Send CO2 sensor data (Senseair S88)" →
dropdown "Port UART".
| S88 | Connecteur PCB |
|---|---|
| G+ | 5V |
| G0 | GND |
| UART_TxD | RxD du connecteur (crossover) |
| UART_RxD | TxD du connecteur (crossover) |
| UART_R/T | non connecté |
Les niveaux UART du S88 sont 3.3V CMOS — directement compatibles avec le Pi.
Pas besoin de level shifter, pas besoin de RS-485 transceiver.
## Protocole Modbus RTU
- **Mode** : RTU (seul mode supporté)
- **Baudrate** : 9600 par défaut (19200 aussi supporté)
- **Format** : 8 bits de données, **pas de parité**, 1 stop bit en réception / 2 stop bits en transmission (config par défaut)
- **Adresse esclave** : 1247 (configurable via HR). **0xFE = "any address"** — répondue par n'importe quel S88, utile quand on ne connaît pas l'adresse individuelle (à n'utiliser qu'en bench, pas en réseau multi-capteurs)
- **Adresse 0** : broadcast (commandes write seulement)
- **Réponse timeout** : ≤ 180 ms
### Fonctions supportées
| Code | Fonction |
|---|---|
| 0x03 | Read Holding Registers (config, plage 0x00000x0020) |
| 0x04 | Read Input Registers (mesures, plage 0x00000x001F) |
| 0x06 | Write Single Register |
| 0x10 | Write Multiple Registers |
| 0x2B / 0x0E | Read Device Identification (Vendor Name, ProductCode, MajorMinorRevision) |
### Input Registers (mesures, fonction 0x04)
| Reg | Offset | Nom | Description |
|---|---|---|---|
| **IR1** | 0x0000 | MeterStatus | Bits d'état (DI1=Fatal error, DI3=Algorithm error, DI4=Output error, DI5=Self-diagnostic error, DI6=Out of range, DI7=Memory error, DI8=Warm Up) |
| IR2 | 0x0001 | AlarmStatus | Réservé |
| IR3 | 0x0002 | OutputStatus | DI33=Alarm Output status, DI34=PWM Output status |
| **IR4** | **0x0003** | **Space CO2** | **Concentration CO2 en ppm (uint16)** ⚠ voir note scaling |
| IR5 | 0x0004 | Space Temp | Température capteur (au-dessus de l'ambiant à cause de l'auto-échauffement) |
| IR6 | 0x0005 | Synchro | Incrémenté chaque cycle de mesure |
| IR7 | 0x0006 | Vbb | Tension VBB pendant lamp ramp (LSB = 1 mV) |
| IR22 | 0x0015 | PWM Output | Valeur PWM (0x3FFF = 100%) |
| IR24+IR25 | 0x0017/18 | ETC | Elapsed Time Counter (heures), 4 octets |
| IR28 | 0x001B | Memory Map version | |
| IR29 | 0x001C | FW version | high byte = Main, low byte = Sub |
| IR30+IR31 | 0x001D/1E | Sensor Serial Number | 4 octets |
**Scaling CO2** : la plupart des S88 retournent la valeur directement en ppm.
**Certains futurs modèles** de la famille S88 divisent par 10 (400 ppm → renvoie 40).
À vérifier au bench. Le ProductCode (lu via fonction 0x2B/0x0E objet 0x01) permet
d'identifier le modèle — pour le S88 Residential 004-1-0100 c'est ppm directement.
### Exemple : lire CO2 seul (IR4)
**Requête maître** (adresse 0xFE, function 04, start 0x0003, qty 0x0001) :
```
FE 04 00 03 00 01 25 C5
└┬┘ └┬┘ └──┬──┘ └──┬──┘ └─┬─┘
addr fn start qty CRC (low byte first)
```
**Réponse esclave** (CO2 = 400 ppm = 0x0190) :
```
FE 04 02 01 90 AC DB
└┬┘ └┬┘ └┬┘ └──┬──┘ └─┬─┘
addr fn count value CRC
```
### Exemple : lire status + CO2 en une commande (IR1 à IR4)
**Requête maître** :
```
FE 04 00 00 00 04 E5 C6
```
**Réponse esclave** (status=0, CO2=400ppm) :
```
FE 04 08 00 00 00 00 00 00 01 90 16 E6
└┬┘ └┬┘ └┬┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └─┬─┘
addr fn cnt IR1=0 IR2=0 IR3=0 IR4=400 CRC
```
C'est la séquence recommandée pour le scraping périodique : un seul appel,
on récupère l'état + la valeur. Si IR1 (status) ≠ 0, ne pas écrire la mesure.
## Notes EEPROM
Les Holding Registers sont mappés en EEPROM (sauf HR1HR4 et HR22) :
- Limite EEPROM : **< 10 000 cycles d'écriture** sur la durée de vie
- Une écriture multi-registres compte pour 1 cycle
- Attendre **≥ 180 ms** après écriture d'un HR avant power-down/reset
⚠ Ne JAMAIS écrire les HR depuis une boucle qui tourne souvent — réservé à la
configuration initiale (changement de baudrate, d'adresse Modbus, etc.).
## Implémentation NebuleAir
Voir `S88/write_data.py` et `S88/get_data.py`. Le module Python `minimalmodbus`
ou `pymodbus` peut être utilisé, ou directement `pyserial` avec calcul CRC16 manuel.
Pour lecture périodique simple :
```python
# Pseudocode — voir write_data.py pour la vraie implémentation
request = b'\xFE\x04\x00\x00\x00\x04' + crc16(...) # IR1..IR4
ser.write(request)
response = ser.read(13) # FE 04 08 + 8 octets data + 2 CRC
if response[0] == 0xFE and response[1] == 0x04:
status = (response[3] << 8) | response[4]
co2_ppm = (response[9] << 8) | response[10]
if status == 0:
# OK, enregistrer co2_ppm
```

View File

@@ -1,112 +0,0 @@
'''
Live read of the Senseair S88 CO2 sensor (used by the web "Get Data" button).
Prints a JSON object: {"CO2": <int_ppm>} or {"error": "<message>"}.
Modbus RTU 9600 8N1, reads IR1..IR4 in one frame.
Usage: /usr/bin/python3 /var/www/nebuleair_pro_4g/S88/get_data.py [port]
If no port is given, the script reads S88_port from config_table.
'''
import json
import sqlite3
import sys
import serial
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
DEFAULT_PORT = "/dev/ttyAMA5"
BAUDRATE = 9600
SLAVE_ADDR = 0xFE
READ_REQUEST = bytes([SLAVE_ADDR, 0x04, 0x00, 0x00, 0x00, 0x04, 0xE5, 0xC6])
EXPECTED_RESPONSE_LEN = 13
def crc16_modbus(data):
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc
def get_port_from_config():
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT value FROM config_table WHERE key = ?", ("S88_port",))
row = cursor.fetchone()
conn.close()
return row[0] if row else DEFAULT_PORT
except Exception:
return DEFAULT_PORT
def read_co2(ser):
ser.reset_input_buffer()
ser.write(READ_REQUEST)
response = ser.read(EXPECTED_RESPONSE_LEN)
if len(response) < 5:
return None, f"short response ({len(response)} bytes)"
if response[1] & 0x80:
return None, f"Modbus exception code {response[2]:#04x}"
if len(response) != EXPECTED_RESPONSE_LEN:
return None, f"unexpected response length {len(response)}"
received_crc = response[-2] | (response[-1] << 8)
if crc16_modbus(response[:-2]) != received_crc:
return None, "CRC mismatch"
if response[0] != SLAVE_ADDR or response[1] != 0x04 or response[2] != 0x08:
return None, f"unexpected header {response[:3].hex()}"
status = (response[3] << 8) | response[4]
co2 = (response[9] << 8) | response[10]
if status != 0:
return None, f"sensor not ready (status={status:#06x})"
return co2, None
def main():
port = sys.argv[1] if len(sys.argv) > 1 else get_port_from_config()
try:
ser = serial.Serial(
port=port,
baudrate=BAUDRATE,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1,
)
except Exception as e:
print(json.dumps({"error": f"Cannot open {port}: {e}"}))
return
try:
co2, err = read_co2(ser)
if co2 is None:
print(json.dumps({"error": err or "No data from S88"}))
return
print(json.dumps({"CO2": int(round(co2))}))
except Exception as e:
print(json.dumps({"error": f"S88 read error: {e}"}))
finally:
try:
ser.close()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -1,166 +0,0 @@
'''
Script to get CO2 values from Senseair S88 sensor and write to database
/usr/bin/python3 /var/www/nebuleair_pro_4g/S88/write_data.py
Modbus RTU 9600 8N1. Reads IR1..IR4 in one frame to get status + CO2.
A row is ALWAYS written each run, with a status byte (like data_NPM.npm_status
and data_NOISE.noise_status):
s88_status = 0 -> OK, CO2 valid
s88_status = 0xFF -> sensor not responding / read error (CO2 stored as 0)
This is essential: without it the table keeps the last good value forever and
loop/SARA_send_data_v2.py would keep transmitting a stale CO2 reading when the
sensor is actually dead.
'''
import serial
import sqlite3
import sys
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
DEFAULT_PORT = "/dev/ttyAMA5"
BAUDRATE = 9600
STATUS_OK = 0x00
STATUS_NO_RESPONSE = 0xFF
# Modbus slave address: 0xFE = "any address", any S88 responds regardless of
# its configured individual address. Fine for single-sensor setups.
SLAVE_ADDR = 0xFE
# Read IR1..IR4 in one frame: function 0x04, start 0x0000, qty 0x0004
READ_REQUEST = bytes([SLAVE_ADDR, 0x04, 0x00, 0x00, 0x00, 0x04, 0xE5, 0xC6])
EXPECTED_RESPONSE_LEN = 13 # 1 addr + 1 fn + 1 count + 8 data + 2 CRC
def crc16_modbus(data):
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc
def get_config(cursor, key, default):
cursor.execute("SELECT value FROM config_table WHERE key = ?", (key,))
row = cursor.fetchone()
return row[0] if row else default
def read_co2(ser):
ser.reset_input_buffer()
ser.write(READ_REQUEST)
response = ser.read(EXPECTED_RESPONSE_LEN)
if len(response) < 5:
print(f"S88: short response ({len(response)} bytes)")
return None
# Modbus exception response: function code has high bit set (0x84 instead of 0x04)
if response[1] & 0x80:
print(f"S88 Modbus exception: code={response[2]:#04x}")
return None
if len(response) != EXPECTED_RESPONSE_LEN:
print(f"S88: unexpected response length {len(response)} (expected {EXPECTED_RESPONSE_LEN})")
return None
# Verify CRC (last two bytes, low byte first)
received_crc = response[-2] | (response[-1] << 8)
if crc16_modbus(response[:-2]) != received_crc:
print("S88: CRC mismatch")
return None
if response[0] != SLAVE_ADDR or response[1] != 0x04 or response[2] != 0x08:
print(f"S88: unexpected header {response[:3].hex()}")
return None
status = (response[3] << 8) | response[4]
co2 = (response[9] << 8) | response[10]
if status != 0:
# DI8 = Warm Up (bit 7 of low byte). Other bits = errors.
print(f"S88: sensor not ready, status={status:#06x}")
return None
return co2
def main():
conn = sqlite3.connect(DB_PATH, timeout=10)
cursor = conn.cursor()
# Self-heal: ensure the table + status column exist even if create_db.py was
# skipped during OTA. Duplicates the canonical schema from sqlite/create_db.py
# — keep them in sync.
cursor.execute("""
CREATE TABLE IF NOT EXISTS data_S88 (
timestamp TEXT,
CO2 INTEGER,
s88_status INTEGER DEFAULT 0
)
""")
try:
cursor.execute("ALTER TABLE data_S88 ADD COLUMN s88_status INTEGER DEFAULT 0")
except Exception:
pass # Column already exists
conn.commit()
port = get_config(cursor, "S88_port", DEFAULT_PORT)
# Default to the error state; only a clean read flips it to OK.
co2_ppm = 0
status = STATUS_NO_RESPONSE
ser = None
try:
ser = serial.Serial(
port=port,
baudrate=BAUDRATE,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1,
)
co2 = read_co2(ser)
if co2 is not None:
co2_ppm = int(round(co2))
status = STATUS_OK
else:
print("S88 not responding -> writing error row (s88_status=0xFF)")
except Exception as e:
print(f"S88 serial/read error: {e} -> writing error row (s88_status=0xFF)")
finally:
if ser is not None:
try:
ser.close()
except Exception:
pass
# ALWAYS write a row so the send loop never reuses a stale value.
try:
cursor.execute("SELECT last_updated FROM timestamp_table LIMIT 1")
row = cursor.fetchone()
rtc_time_str = row[0] if row else "not connected"
cursor.execute(
"INSERT INTO data_S88 (timestamp, CO2, s88_status) VALUES (?, ?, ?)",
(rtc_time_str, co2_ppm, status),
)
conn.commit()
if status == STATUS_OK:
print(f"CO2: {co2_ppm} ppm (saved at {rtc_time_str})")
else:
print(f"S88 no data, s88_status=0x{status:02X} (saved at {rtc_time_str})")
except Exception as e:
print(f"S88 DB write error: {e}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,72 +0,0 @@
'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
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,84 +1,73 @@
r''' '''
____ _ ____ _ ____ _ ____ _
/ ___| / \ | _ \ / \ / ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \ \___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \ ___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\ |____/_/ \_\_| \_\/_/ \_\
Script that starts at the boot of the RPI (with cron) 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 @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 /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 serial
import RPi.GPIO as GPIO
import time import time
import sys
import json
import re import re
import sqlite3
import traceback
#get data from config
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
#GPIO #Fonction pour mettre à jour le JSON de configuration
SARA_power_GPIO = 16 def update_json_key(file_path, key, value):
GPIO.setmode(GPIO.BCM) # Use BCM numbering
GPIO.setup(SARA_power_GPIO, GPIO.OUT)
# database connection
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
def update_sqlite_config(key, value):
""" """
Updates a specific key in the SQLite config_table with a new value. Updates a specific key in a JSON file with a new value.
:param file_path: Path to the JSON file.
:param key: The key to update in the JSON file.
:param value: The new value to assign to the key.
""" """
try: try:
cursor.execute("SELECT type FROM config_table WHERE key = ?", (key,)) # Load the existing data
result = cursor.fetchone() with open(file_path, "r") as file:
data = json.load(file)
if result is None:
print(f"Key '{key}' not found in the config_table.") # Check if the key exists in the JSON file
return if key in data:
data[key] = value # Update the key with the new value
value_type = result[0]
if value_type == 'bool':
if isinstance(value, bool):
str_value = '1' if value else '0'
else:
str_value = '1' if str(value).lower() in ('true', '1', 'yes', 'y') else '0'
elif value_type == 'int':
str_value = str(int(value))
elif value_type == 'float':
str_value = str(float(value))
else: else:
str_value = str(value) print(f"Key '{key}' not found in the JSON file.")
return
cursor.execute("UPDATE config_table SET value = ? WHERE key = ?", (str_value, key))
conn.commit() # Write the updated data back to the file
with open(file_path, "w") as file:
print(f"Updated '{key}' to '{value}' in database.") json.dump(data, file, indent=2) # Use indent for pretty printing
print(f"💾 updating '{key}' to '{value}'.")
except Exception as e: except Exception as e:
print(f"Error updating the SQLite database: {e}") print(f"Error updating the JSON file: {e}")
# Load baudrate from config # Define the config file path
cursor.execute("SELECT value FROM config_table WHERE key = 'SaraR4_baudrate'") config_file = '/var/www/nebuleair_pro_4g/config.json'
row = cursor.fetchone() # Load the configuration data
baudrate = int(row[0]) if row else 115200 config = load_config(config_file)
baudrate = config.get('SaraR4_baudrate', 115200) #baudrate du sara R4
device_id = config.get('deviceID', '').upper() #device ID en maj
ser_sara = serial.Serial( ser_sara = serial.Serial(
port='/dev/ttyAMA2', port='/dev/ttyAMA2',
baudrate=baudrate, baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE, stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS, bytesize=serial.EIGHTBITS,
timeout = 2 timeout = 2
@@ -86,10 +75,11 @@ ser_sara = serial.Serial(
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None, debug=True): 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. Reads the complete response from a serial connection and waits for specific lines.
''' '''
if wait_for_lines is None: if wait_for_lines is None:
wait_for_lines = [] wait_for_lines = [] # Default to an empty list if not provided
response = bytearray() response = bytearray()
serial_connection.timeout = timeout serial_connection.timeout = timeout
@@ -97,72 +87,104 @@ def read_complete_response(serial_connection, timeout=2, end_of_response_timeout
start_time = time.time() start_time = time.time()
while True: while True:
elapsed_time = time.time() - start_time elapsed_time = time.time() - start_time # Time since function start
if serial_connection.in_waiting > 0: if serial_connection.in_waiting > 0:
data = serial_connection.read(serial_connection.in_waiting) data = serial_connection.read(serial_connection.in_waiting)
response.extend(data) response.extend(data)
end_time = time.time() + end_of_response_timeout end_time = time.time() + end_of_response_timeout # Reset timeout on new data
# Decode and check for any target line
decoded_response = response.decode('utf-8', errors='replace') decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines: for target_line in wait_for_lines:
if target_line in decoded_response: if target_line in decoded_response:
if debug: if debug:
print(f"[DEBUG] Found target line: {target_line} (in {elapsed_time:.2f}s)") print(f"[DEBUG] 🔎 Found target line: {target_line} (in {elapsed_time:.2f}s)")
return decoded_response return decoded_response # Return response immediately if a target line is found
elif time.time() > end_time: elif time.time() > end_time:
if debug: if debug:
print(f"[DEBUG] Timeout reached. No more data received.") print(f"[DEBUG] Timeout reached. No more data received.")
break break
time.sleep(0.1) time.sleep(0.1) # Short sleep to prevent busy waiting
# Final response and debug output
total_elapsed_time = time.time() - start_time total_elapsed_time = time.time() - start_time
if debug: if debug:
print(f"[DEBUG] elapsed time: {total_elapsed_time:.2f}s.") print(f"[DEBUG] ⏱️ elapsed time: {total_elapsed_time:.2f}s. ⏱️")
# Check if the elapsed time exceeded 10 seconds
if total_elapsed_time > 10 and debug: if total_elapsed_time > 10 and debug:
print(f"[ALERT] The operation took too long ({total_elapsed_time:.2f}s)") print(f"[ALERT] 🚨 The operation took too long 🚨")
print(f'<span style="color: red;font-weight: bold;">[ALERT] ⚠️{total_elapsed_time:.2f}s⚠</span>')
return response.decode('utf-8', errors='replace') return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
try: try:
print('<h3>Start reboot python script</h3>') print('<h3>Start reboot python script</h3>')
# 1. Reset modem_config_mode at boot to prevent capteur from staying stuck in config mode #check modem status
cursor.execute("UPDATE config_table SET value = '0' WHERE key = 'modem_config_mode'") print("Check SARA Status")
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)
# 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' command = f'ATI\r'
ser_sara.write(command.encode('utf-8')) ser_sara.write(command.encode('utf-8'))
response_SARA_ATI = read_complete_response(ser_sara, wait_for_lines=["IMEI"]) response_SARA_ATI = read_complete_response(ser_sara, wait_for_lines=["IMEI"])
print(response_SARA_ATI) print(response_SARA_ATI)
match = re.search(r"Model:\s*(.+)", response_SARA_ATI)
model = match.group(1).strip() if match else "Unknown" # Strip unwanted characters
print(f" Model: {model}")
update_json_key(config_file, "modem_version", model)
time.sleep(1)
model = "Unknown" # 1. Set AIRCARTO URL
if "SARA-R410M" in response_SARA_ATI: print('Set aircarto URL')
model = "SARA-R410M" aircarto_profile_id = 0
print("Detected SARA R4 modem") aircarto_url="data.nebuleair.fr"
elif "SARA-R500" in response_SARA_ATI: command = f'AT+UHTTP={aircarto_profile_id},1,"{aircarto_url}"\r'
model = "SARA-R500" ser_sara.write(command.encode('utf-8'))
print("Detected SARA R5 modem") response_SARA_1 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_1)
time.sleep(1)
#2. Set uSpot URL
print('Set uSpot URL')
uSpot_profile_id = 1
uSpot_url="api-prod.uspot.probesys.net"
command = f'AT+UHTTP={uSpot_profile_id},1,"{uSpot_url}"\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_2 = read_complete_response(ser_sara, wait_for_lines=["OK"])
print(response_SARA_2)
time.sleep(1)
print("set port 81")
command = f'AT+UHTTP={uSpot_profile_id},5,81\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)
#3. Get localisation (CellLocate)
mode = 2
sensor = 2
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}")
else: else:
match = re.search(r"Model:\s*([A-Za-z0-9\-]+)", response_SARA_ATI) print("❌ Failed to extract coordinates.")
if match:
model = match.group(1).strip()
else:
print("Could not identify modem model")
print(f"Model: {model}") #update config.json
update_sqlite_config("modem_version", model) update_json_key(config_file, "latitude_raw", float(latitude))
update_json_key(config_file, "longitude_raw", float(longitude))
print('<h3>Boot script complete. Modem ready for main loop.</h3>') time.sleep(1)
except Exception as e: except Exception as e:
print("An error occurred:", e) print("An error occurred:", e)
traceback.print_exc() traceback.print_exc() # This prints the full traceback

View File

@@ -1,4 +1,4 @@
r''' '''
____ _ ____ _ ____ _ ____ _
/ ___| / \ | _ \ / \ / ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \ \___ \ / _ \ | |_) | / _ \
@@ -6,9 +6,7 @@ r'''
|____/_/ \_\_| \_\/_/ \_\ |____/_/ \_\_| \_\/_/ \_\
Script to see if the SARA-R410 is running Script to see if the SARA-R410 is running
ex: ex:
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 ATI 2
ex 1 (get SIM infos)
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+CCID? 2 python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+CCID? 2
ex 2 (turn on blue light): ex 2 (turn on blue light):
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UGPIOC=16,2 2 python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UGPIOC=16,2 2
@@ -16,8 +14,6 @@ ex 3 (reconnect network)
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+COPS=1,2,20801 20 python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+COPS=1,2,20801 20
ex 4 (get HTTP Profiles) ex 4 (get HTTP Profiles)
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UHTTP? 2 python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UHTTP? 2
ex 5 (get IP addr)
python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+CGPADDR=1 2
''' '''
@@ -32,67 +28,68 @@ port='/dev/'+parameter[0] # ex: ttyAMA2
command = parameter[1] # ex: AT+CCID? command = parameter[1] # ex: AT+CCID?
timeout = float(parameter[2]) # ex:2 timeout = float(parameter[2]) # ex:2
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables # Access the shared variables
baudrate = 115200 baudrate = config.get('SaraR4_baudrate', 115200)
ser = serial.Serial(
port=port, #USB0 or ttyS0
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = timeout
)
ser.write((command + '\r').encode('utf-8'))
#ser.write(b'ATI\r') #General Information
#ser.write(b'AT+CCID?\r') #SIM card number
#ser.write(b'AT+CPIN?\r') #Check the status of the SIM card
#ser.write(b'AT+CIND?\r') #Indication state (last number is SIM detection: 0 no SIM detection, 1 SIM detected, 2 not available)
#ser.write(b'AT+UGPIOR=?\r') #Reads the current value of the specified GPIO pin
#ser.write(b'AT+UGPIOC?\r') #GPIO select configuration
#ser.write(b'AT+COPS=?\r') #Check the network and cellular technology the modem is currently using
#ser.write(b'AT+COPS=1,2,20801') #connext to orange
#ser.write(b'AT+CFUN=?\r') #Selects/read the level of functionality
#ser.write(b'AT+URAT=?\r') #Radio Access Technology
#ser.write(b'AT+USIMSTAT?')
#ser.write(b'AT+IPR=115200') #Check/Define baud rate
#ser.write(b'AT+CMUX=?')
try: try:
ser = serial.Serial(
port=port, #USB0 or ttyS0
baudrate=baudrate, #115200 ou 9600
parity=serial.PARITY_NONE, #PARITY_NONE, PARITY_EVEN or PARITY_ODD
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout = timeout
)
# Flush any leftover data from previous commands or modem boot URCs
ser.reset_input_buffer()
ser.write((command + '\r').encode('utf-8'))
#ser.write(b'ATI\r') #General Information
#ser.write(b'AT+CCID?\r') #SIM card number
#ser.write(b'AT+CPIN?\r') #Check the status of the SIM card
#ser.write(b'AT+CIND?\r') #Indication state (last number is SIM detection: 0 no SIM detection, 1 SIM detected, 2 not available)
#ser.write(b'AT+UGPIOR=?\r') #Reads the current value of the specified GPIO pin
#ser.write(b'AT+UGPIOC?\r') #GPIO select configuration
#ser.write(b'AT+COPS=?\r') #Check the network and cellular technology the modem is currently using
#ser.write(b'AT+COPS=1,2,20801') #connext to orange
#ser.write(b'AT+CFUN=?\r') #Selects/read the level of functionality
#ser.write(b'AT+URAT=?\r') #Radio Access Technology
#ser.write(b'AT+USIMSTAT?')
#ser.write(b'AT+IPR=115200') #Check/Define baud rate
#ser.write(b'AT+CMUX=?')
# Read lines until a timeout occurs # Read lines until a timeout occurs
response_lines = [] response_lines = []
start_time = time.time() while True:
line = ser.readline().decode('utf-8').strip()
while (time.time() - start_time) < timeout: if not line:
line = ser.readline().decode('utf-8', errors='ignore').strip() break # Break the loop if an empty line is encountered
if line: response_lines.append(line)
response_lines.append(line)
# Check if we received any data
if not response_lines:
print(f"ERROR: No response received from {port} after sending command: {command}")
sys.exit(1)
# Print the response # Print the response
for line in response_lines: for line in response_lines:
print(line) print(line)
except serial.SerialException as e: except serial.SerialException as e:
print(f"ERROR: Serial communication error: {e}") print(f"Error: {e}")
sys.exit(1)
except Exception as e:
print(f"ERROR: Unexpected error: {e}")
sys.exit(1)
finally: finally:
# Close the serial port if it's open if ser.is_open:
if 'ser' in locals() and ser.is_open:
ser.close() ser.close()
#print("Serial closed")

View File

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

View File

@@ -1,170 +0,0 @@
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Check and setup PDP connection (user-friendly version for Miotiq page).
- Checks if PDP context is already active
- If yes: reports OK without touching anything
- If no: activates PDP context and PSD profile
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_check_pdp.py
'''
import serial
import time
import sys
import re
ser_sara = serial.Serial(
port='/dev/ttyAMA2',
baudrate=115200,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=2
)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None):
if wait_for_lines is None:
wait_for_lines = []
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
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
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
return decoded_response
elif time.time() > end_time:
break
time.sleep(0.1)
return response.decode('utf-8', errors='replace')
def send_at(command, wait_for=None, timeout=2):
"""Send AT command and return (response_text, success_bool)"""
if wait_for is None:
wait_for = ["OK", "+CME ERROR", "ERROR"]
ser_sara.reset_input_buffer()
ser_sara.write((command + '\r').encode('utf-8'))
resp = read_complete_response(ser_sara, timeout=timeout, end_of_response_timeout=timeout, wait_for_lines=wait_for)
success = "OK" in resp and "+CME ERROR" not in resp and "ERROR" not in resp.replace("OK", "")
return resp, success
# Collect raw logs for collapsible display
raw_logs = []
def log_raw(label, response):
raw_logs.append(f"[{label}]\n{response.strip()}")
try:
sys.stdout.reconfigure(line_buffering=True)
ser_sara.reset_input_buffer()
# Step 1: Check modem connectivity
resp, ok = send_at('ATI0')
log_raw('ATI0', resp)
if not ok:
print('❌ <strong>Modem non accessible</strong>')
print('<small class="text-muted">Pas de réponse du modem sur ttyAMA2</small>')
sys.exit(1)
print('✅ Modem connecté')
# Step 2: Check if PDP context is already active
resp, ok = send_at('AT+CGACT?')
log_raw('AT+CGACT?', resp)
pdp_already_active = '+CGACT: 1,1' in resp
if pdp_already_active:
print('✅ Contexte PDP déjà actif')
# Also check PSD profile status by trying to read IP
resp2, ok2 = send_at('AT+UPSND=0,0')
log_raw('AT+UPSND=0,0', resp2)
ip_match = re.search(r'\+UPSND:\s*0,0,"([^"]+)"', resp2)
if ip_match and ip_match.group(1) != "0.0.0.0":
ip_addr = ip_match.group(1)
print(f'✅ Profil PSD actif — IP: {ip_addr}')
print('<br><strong class="text-success">Connexion PDP OK — prêt pour les sockets UDP.</strong>')
else:
# PDP active but PSD profile not set up — need to configure it
print('⚠️ Contexte PDP actif mais profil PSD non configuré — activation en cours...')
setup_psd = True
else:
print('⚠️ Contexte PDP inactif — activation en cours...')
setup_psd = True
# Activate PDP context
resp, ok = send_at('AT+CGACT=1,1', timeout=5)
log_raw('AT+CGACT=1,1', resp)
if ok:
print('✅ Contexte PDP activé')
else:
print('❌ Échec activation contexte PDP')
sys.exit(1)
# Setup PSD profile if needed
if 'setup_psd' in dir() and setup_psd:
# Set PDP type to IPv4
resp, ok = send_at('AT+UPSD=0,0,0')
log_raw('AT+UPSD=0,0,0', resp)
# Map profile #0 to CID=1
resp, ok = send_at('AT+UPSD=0,100,1')
log_raw('AT+UPSD=0,100,1', resp)
# Activate PSD profile
resp, ok = send_at('AT+UPSDA=0,3', wait_for=["OK", "+UUPSDA", "+CME ERROR", "ERROR"], timeout=5)
log_raw('AT+UPSDA=0,3', resp)
if "OK" in resp or "+UUPSDA" in resp:
# Verify IP
resp2, ok2 = send_at('AT+UPSND=0,0')
log_raw('AT+UPSND=0,0', resp2)
ip_match = re.search(r'\+UPSND:\s*0,0,"([^"]+)"', resp2)
if ip_match and ip_match.group(1) != "0.0.0.0":
print(f'✅ Profil PSD activé — IP: {ip_match.group(1)}')
print('<br><strong class="text-success">Connexion PDP OK — prêt pour les sockets UDP.</strong>')
else:
print('✅ Profil PSD activé')
print('<br><strong class="text-success">Connexion PDP OK.</strong>')
else:
print('❌ Échec activation profil PSD')
print('<br><strong class="text-danger">La connexion PDP n\'a pas pu être établie.</strong>')
except serial.SerialException as e:
print(f'❌ Erreur série: {e}')
except Exception as e:
print(f'❌ Erreur: {e}')
finally:
# Print raw logs in a collapsible section
if raw_logs:
log_id = "pdp_raw_logs"
print(f'<br><button class="btn btn-sm btn-outline-secondary mt-2" type="button" data-bs-toggle="collapse" data-bs-target="#{log_id}"><small>Logs AT</small></button>')
print(f'<div class="collapse mt-1" id="{log_id}"><div class="card card-body bg-light"><small><code>')
for log in raw_logs:
print(log.replace('\n', '<br>'))
print('<br>')
print('</code></small></div></div>')
if ser_sara.is_open:
ser_sara.close()

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
r''' '''
____ _ ____ _ ____ _ ____ _
/ ___| / \ | _ \ / \ / ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \ \___ \ / _ \ | |_) | / _ \
@@ -18,7 +18,24 @@ import sys
import json import json
baudrate = 115200
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
ser = serial.Serial( ser = serial.Serial(
port='/dev/ttyAMA2', port='/dev/ttyAMA2',

View File

@@ -1,4 +1,4 @@
r''' '''
____ _ ____ _ ____ _ ____ _
/ ___| / \ | _ \ / \ / ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \ \___ \ / _ \ | |_) | / _ \
@@ -17,7 +17,23 @@ import json
# SARA R4 UHTTPC profile IDs # SARA R4 UHTTPC profile IDs
aircarto_profile_id = 0 aircarto_profile_id = 0
baudrate = 115200
#get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
ser_sara = serial.Serial( ser_sara = serial.Serial(
port='/dev/ttyAMA2', port='/dev/ttyAMA2',
@@ -73,31 +89,13 @@ def read_complete_response(serial_connection, timeout=2, end_of_response_timeout
return response.decode('utf-8', errors='replace') # Return the full response if no target line is found return response.decode('utf-8', errors='replace') # Return the full response if no target line is found
def extract_error_code(response):
"""
Extract just the error code from AT+UHTTPER response
"""
for line in response.split('\n'):
if '+UHTTPER' in line:
try:
# Split the line and get the third value (error code)
parts = line.split(':')[1].strip().split(',')
if len(parts) >= 3:
error_code = int(parts[2])
return error_code
except:
pass
# Return None if we couldn't find the error code
return None
try: try:
#3. Send to endpoint (with device ID) #3. Send to endpoint (with device ID)
print("Send data (GET REQUEST):") print("Send data (GET REQUEST):")
command= f'AT+UHTTPC={aircarto_profile_id},1,"/ping.php","aircarto_server_response.txt"\r' command= f'AT+UHTTPC={aircarto_profile_id},1,"/ping.php","aircarto_server_response.txt"\r'
ser_sara.write(command.encode('utf-8')) ser_sara.write(command.encode('utf-8'))
response_SARA_3 = read_complete_response(ser_sara, timeout=5, end_of_response_timeout=20, wait_for_lines=["+UUHTTPCR", "+CME ERROR"], debug=True) response_SARA_3 = read_complete_response(ser_sara, timeout=5, end_of_response_timeout=120, wait_for_lines=["+UUHTTPCR", "+CME ERROR"], debug=True)
print(response_SARA_3) print(response_SARA_3)
# si on recoit la réponse UHTTPCR # si on recoit la réponse UHTTPCR
@@ -113,36 +111,7 @@ try:
parts = http_response.split(',') parts = http_response.split(',')
# 2.1 code 0 (HTTP failed) ⛔⛔⛔ # 2.1 code 0 (HTTP failed) ⛔⛔⛔
if len(parts) == 3 and parts[-1] == '0': # The third value indicates success if len(parts) == 3 and parts[-1] == '0': # The third value indicates success
print("ATTENTION: HTTP operation failed") print("⛔ATTENTION: HTTP operation failed")
#get error code
print("Getting error code (11->Server connection error, 73->Secure socket connect error)")
command = f'AT+UHTTPER={aircarto_profile_id}\r'
ser_sara.write(command.encode('utf-8'))
response_SARA_9 = read_complete_response(ser_sara, wait_for_lines=["OK"], debug=False)
print('<p class="text-danger-emphasis">')
print(response_SARA_9)
print("</p>", end="")
# Extract just the error code
error_code = extract_error_code(response_SARA_9)
if error_code is not None:
# Display interpretation based on error code
if error_code == 0:
print('<p class="text-success">No error detected</p>')
elif error_code == 4:
print('<p class="text-danger">Error 4: Invalid server Hostname</p>')
elif error_code == 11:
print('<p class="text-danger">Error 11: Server connection error</p>')
elif error_code == 22:
print('<p class="text-danger">Error 22: PSD or CSD connection not established</p>')
elif error_code == 73:
print('<p class="text-danger">Error 73: Secure socket connect error</p>')
else:
print(f'<p class="text-danger">Unknown error code: {error_code}</p>')
else:
print('<p class="text-danger">Could not extract error code from response</p>')
# 2.2 code 1 (HHTP succeded) # 2.2 code 1 (HHTP succeded)
else: else:
# Si la commande HTTP a réussi # Si la commande HTTP a réussi

View File

@@ -1,235 +0,0 @@
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Test aller-retour Miotiq:
1. Construit un payload 100 bytes avec device_id + command=0x02 (ping)
2. Cree un socket UDP binde sur un port local fixe (LISTEN_PORT)
3. Envoie via AT+USOST vers 192.168.0.20:4242 (mode non-connecte)
4. Ecoute avec AT+USORF pour la reponse descendante Miotiq (~15s)
Le serveur doit envoyer le downlink via l'API Miotiq sendToDevice
avec dstPort = LISTEN_PORT (33333).
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_ping_miotiq.py
'''
import serial
import time
import sys
import re
import sqlite3
# --- Config ---
MIOTIQ_IP = "192.168.0.20"
MIOTIQ_PORT = 4242
LISTEN_PORT = 33333 # Port local sur lequel le capteur ecoute le downlink
PAYLOAD_SIZE = 100
COMMAND_PING = 0x02
LISTEN_TIMEOUT = 15 # seconds to wait for downlink response
# --- Load device_id from SQLite ---
def load_device_id():
try:
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
cursor = conn.cursor()
cursor.execute("SELECT value FROM config_table WHERE key='deviceID'")
row = cursor.fetchone()
conn.close()
if row:
return row[0].upper()
except Exception as e:
print(f'❌ Erreur lecture config SQLite: {e}')
return None
# --- Build ping payload ---
def build_ping_payload(device_id):
payload = bytearray(PAYLOAD_SIZE)
for i in range(PAYLOAD_SIZE):
payload[i] = 0xFF
# Bytes 0-7: device_id (ASCII, padded with 0x00)
device_id_bytes = device_id.encode('ascii')[:8].ljust(8, b'\x00')
payload[0:8] = device_id_bytes
# Byte 9: command = 0x02 (ping test)
payload[9] = COMMAND_PING
return bytes(payload)
# --- Serial ---
ser_sara = serial.Serial(
port='/dev/ttyAMA2',
baudrate=115200,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=2
)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None):
if wait_for_lines is None:
wait_for_lines = []
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
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
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
return decoded_response
elif time.time() > end_time:
break
time.sleep(0.1)
return response.decode('utf-8', errors='replace')
def send_at(command, wait_for=None, timeout=2):
if wait_for is None:
wait_for = ["OK", "+CME ERROR", "ERROR"]
ser_sara.reset_input_buffer()
ser_sara.write((command + '\r').encode('utf-8'))
resp = read_complete_response(ser_sara, timeout=timeout, end_of_response_timeout=timeout, wait_for_lines=wait_for)
has_error = "+CME ERROR" in resp or ("ERROR" in resp and "OK" not in resp)
return resp, not has_error
# --- Raw logs ---
raw_logs = []
def log_raw(label, response):
raw_logs.append(f"[{label}]\n{response.strip()}")
socket_id = None
try:
sys.stdout.reconfigure(line_buffering=True)
ser_sara.reset_input_buffer()
# Load device ID
device_id = load_device_id()
if not device_id:
print('❌ <strong>Impossible de lire le deviceID depuis la config</strong>')
sys.exit(1)
print(f'Device ID: <strong>{device_id}</strong>')
# Build ping payload
ping_payload = build_ping_payload(device_id)
print(f'Payload: {PAYLOAD_SIZE} bytes, command=0x{COMMAND_PING:02X} (ping)')
# Step 1: Create UDP socket bound to local port (for receiving downlink)
resp, ok = send_at(f'AT+USOCR=17,{LISTEN_PORT}')
log_raw(f'AT+USOCR=17,{LISTEN_PORT}', resp)
if not ok:
print('❌ Création socket UDP — erreur')
print('<small class="text-muted">La connexion PDP n\'est peut-être pas établie. Lancez "Vérifier connexion PDP" d\'abord.</small>')
sys.exit(1)
match = re.search(r'\+USOCR:\s*(\d+)', resp)
if not match:
print('❌ Impossible d\'extraire le socket ID')
sys.exit(1)
socket_id = match.group(1)
print(f'✅ Socket UDP créé (ID: {socket_id}, port local: {LISTEN_PORT})')
# Step 2: Send ping payload via AT+USOST (unconnected mode)
resp, ok = send_at(
f'AT+USOST={socket_id},"{MIOTIQ_IP}",{MIOTIQ_PORT},{len(ping_payload)}',
wait_for=["@", "OK", "+CME ERROR", "ERROR"]
)
log_raw(f'AT+USOST={socket_id}', resp)
if "@" not in resp:
print('❌ Le modem n\'a pas envoyé le prompt @ — envoi annulé')
sys.exit(1)
ser_sara.write(ping_payload)
resp = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"])
log_raw('PAYLOAD SEND', resp)
if "+CME ERROR" in resp or ("ERROR" in resp and "OK" not in resp):
print('❌ Erreur envoi du payload ping')
sys.exit(1)
print(f'✅ Payload ping envoyé vers {MIOTIQ_IP}:{MIOTIQ_PORT} ({PAYLOAD_SIZE} bytes)')
# Step 3: Listen for downlink response on port LISTEN_PORT
print(f'<br>⏳ <strong>Attente réponse descendante Miotiq sur port {LISTEN_PORT} ({LISTEN_TIMEOUT}s)...</strong>')
response_received = False
start_listen = time.time()
while time.time() - start_listen < LISTEN_TIMEOUT:
# Check for URC +UUSORF or poll with AT+USORF
ser_sara.reset_input_buffer()
resp, ok = send_at(f'AT+USORF={socket_id},512', timeout=2)
log_raw(f'AT+USORF={socket_id},512', resp)
# Parse response: +USORF: <socket_id>,"<ip>",<port>,<length>,"<data>"
usorf_match = re.search(r'\+USORF:\s*\d+,"([^"]*)",(\d+),(\d+),"([^"]*)"', resp)
if usorf_match:
src_ip = usorf_match.group(1)
src_port = usorf_match.group(2)
data_len = usorf_match.group(3)
received_data = usorf_match.group(4)
elapsed = time.time() - start_listen
print(f'<br>✅ <strong class="text-success">Réponse reçue en {elapsed:.1f}s !</strong>')
print(f'<small class="text-muted">Source: {src_ip}:{src_port}{data_len} bytes</small><br>')
print(f'<small class="text-muted">Data: {received_data}</small>')
response_received = True
break
# Also check for no-data response: +USORF: <socket_id>,0
# (means no data available yet, keep polling)
time.sleep(2)
if not response_received:
print(f'<br>⚠️ <strong class="text-warning">Aucune réponse reçue après {LISTEN_TIMEOUT}s</strong>')
print(f'<small class="text-muted">Le payload a été envoyé mais aucune donnée reçue sur le port {LISTEN_PORT}. Vérifiez que le serveur envoie le downlink sur dstPort={LISTEN_PORT}.</small>')
# Step 4: Close socket
resp, ok = send_at(f'AT+USOCL={socket_id}')
log_raw(f'AT+USOCL={socket_id}', resp)
socket_id = None
print('✅ Socket fermé')
# Summary
if response_received:
print('<br><strong class="text-success">Test aller-retour OK — la communication bidirectionnelle Miotiq fonctionne.</strong>')
else:
print('<br><strong class="text-warning">L\'envoi UDP fonctionne mais la réponse descendante n\'a pas été reçue.</strong>')
except serial.SerialException as e:
print(f'❌ Erreur série: {e}')
except SystemExit:
pass
except Exception as e:
print(f'❌ Erreur: {e}')
finally:
# Close socket if still open
if socket_id is not None:
try:
ser_sara.write(f'AT+USOCL={socket_id}\r'.encode('utf-8'))
read_complete_response(ser_sara, wait_for_lines=["OK", "ERROR"], timeout=1, end_of_response_timeout=1)
except:
pass
# Print raw logs
if raw_logs:
log_id = "ping_raw_logs"
print(f'<br><button class="btn btn-sm btn-outline-secondary mt-2" type="button" data-bs-toggle="collapse" data-bs-target="#{log_id}"><small>Logs AT</small></button>')
print(f'<div class="collapse mt-1" id="{log_id}"><div class="card card-body bg-light"><small><code>')
for log in raw_logs:
print(log.replace('\n', '<br>'))
print('<br>')
print('</code></small></div></div>')
if ser_sara.is_open:
ser_sara.close()

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
r''' '''
____ _ ____ _ ____ _ ____ _
/ ___| / \ | _ \ / \ / ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \ \___ \ / _ \ | |_) | / _ \
@@ -8,6 +8,7 @@ r'''
Script to set the URL for a HTTP request Script to set the URL for a HTTP request
Ex: Ex:
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setURL.py ttyAMA2 data.nebuleair.fr 0 /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setURL.py ttyAMA2 data.nebuleair.fr 0
To do: need to add profile id as parameter
First profile id: First profile id:
AT+UHTTP=0,1,"data.nebuleair.fr" AT+UHTTP=0,1,"data.nebuleair.fr"
@@ -27,7 +28,22 @@ url = parameter[1] # ex: data.mobileair.fr
profile_id = parameter[2] #ex: 0 profile_id = parameter[2] #ex: 0
baudrate = 115200 #get baudrate
def load_config(config_file):
try:
with open(config_file, 'r') as file:
config_data = json.load(file)
return config_data
except Exception as e:
print(f"Error loading config file: {e}")
return {}
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Access the shared variables
baudrate = config.get('SaraR4_baudrate', 115200)
ser = serial.Serial( ser = serial.Serial(
port=port, #USB0 or ttyS0 port=port, #USB0 or ttyS0

View File

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

View File

@@ -1,154 +0,0 @@
r'''
____ _ ____ _
/ ___| / \ | _ \ / \
\___ \ / _ \ | |_) | / _ \
___) / ___ \| _ < / ___ \
|____/_/ \_\_| \_\/_/ \_\
Test UDP socket connectivity to Miotiq private network (192.168.0.20:4242)
Creates a UDP socket, connects, writes a small test payload, and closes.
Each AT step is verified for errors.
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_test_udp.py
'''
import serial
import time
import sys
import re
ser_sara = serial.Serial(
port='/dev/ttyAMA2',
baudrate=115200,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=2
)
def read_complete_response(serial_connection, timeout=2, end_of_response_timeout=2, wait_for_lines=None, debug=False):
if wait_for_lines is None:
wait_for_lines = []
response = bytearray()
serial_connection.timeout = timeout
end_time = time.time() + end_of_response_timeout
start_time = time.time()
while True:
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
decoded_response = response.decode('utf-8', errors='replace')
for target_line in wait_for_lines:
if target_line in decoded_response:
return decoded_response
elif time.time() > end_time:
break
time.sleep(0.1)
return response.decode('utf-8', errors='replace')
# Result tracking
steps = []
def log_step(name, success, detail=""):
steps.append({"name": name, "success": success, "detail": detail})
icon = "" if success else ""
print(f'{icon} {name}')
if detail:
print(f'<small class="text-muted">{detail}</small>')
try:
sys.stdout.reconfigure(line_buffering=True)
ser_sara.reset_input_buffer()
# Step 1: Create UDP socket (protocol 17 = UDP)
command = 'AT+USOCR=17\r'
ser_sara.write(command.encode('utf-8'))
response = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"])
if "+CME ERROR" in response or "ERROR" in response:
log_step("Création socket UDP", False, "AT+USOCR=17 → erreur. La connexion PDP n'est peut-être pas établie.")
print('<br><strong>Suggestion :</strong> Lancez "Setup PSD connection" puis réessayez.')
sys.exit(1)
# Extract socket ID
match = re.search(r'\+USOCR:\s*(\d+)', response)
if not match:
log_step("Création socket UDP", False, "Impossible d'extraire le socket ID")
sys.exit(1)
socket_id = match.group(1)
log_step("Création socket UDP", True, f"Socket ID: {socket_id}")
# Step 2: Connect to Miotiq server
ser_sara.reset_input_buffer()
command = f'AT+USOCO={socket_id},"192.168.0.20",4242\r'
ser_sara.write(command.encode('utf-8'))
response = read_complete_response(ser_sara, timeout=5, end_of_response_timeout=5, wait_for_lines=["OK", "+CME ERROR", "ERROR"])
if "+CME ERROR" in response or "ERROR" in response:
log_step("Connexion 192.168.0.20:4242", False, f"Erreur connexion au serveur Miotiq")
# Close socket
ser_sara.write(f'AT+USOCL={socket_id}\r'.encode('utf-8'))
read_complete_response(ser_sara, wait_for_lines=["OK", "ERROR"], timeout=1, end_of_response_timeout=1)
sys.exit(1)
log_step("Connexion 192.168.0.20:4242", True)
# Step 3: Write test payload (4 bytes: "TEST")
test_payload = b'TEST'
ser_sara.reset_input_buffer()
command = f'AT+USOWR={socket_id},{len(test_payload)}\r'
ser_sara.write(command.encode('utf-8'))
response = read_complete_response(ser_sara, wait_for_lines=["@", "OK", "+CME ERROR", "ERROR"])
if "@" not in response:
log_step("Écriture payload test", False, "Le modem n'a pas envoyé le prompt @")
ser_sara.reset_input_buffer()
ser_sara.write(f'AT+USOCL={socket_id}\r'.encode('utf-8'))
read_complete_response(ser_sara, wait_for_lines=["OK", "ERROR"], timeout=1, end_of_response_timeout=1)
sys.exit(1)
# Send the actual bytes
ser_sara.write(test_payload)
response = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"])
if "+CME ERROR" in response or "ERROR" in response:
log_step("Écriture payload test", False, "Erreur lors de l'envoi des données")
ser_sara.write(f'AT+USOCL={socket_id}\r'.encode('utf-8'))
read_complete_response(ser_sara, wait_for_lines=["OK", "ERROR"], timeout=1, end_of_response_timeout=1)
sys.exit(1)
log_step("Écriture payload test", True, f"{len(test_payload)} bytes envoyés")
# Step 4: Close socket
ser_sara.reset_input_buffer()
command = f'AT+USOCL={socket_id}\r'
ser_sara.write(command.encode('utf-8'))
response = read_complete_response(ser_sara, wait_for_lines=["OK", "+CME ERROR", "ERROR"])
if "+CME ERROR" in response or "ERROR" in response:
log_step("Fermeture socket", False)
else:
log_step("Fermeture socket", True)
# Summary
all_ok = all(s["success"] for s in steps)
if all_ok:
print('<br><strong class="text-success">Toutes les étapes OK — le modem peut envoyer des données UDP vers Miotiq.</strong>')
else:
print('<br><strong class="text-danger">Certaines étapes ont échoué.</strong>')
except serial.SerialException as e:
print(f'❌ Erreur série: {e}')
except Exception as e:
print(f'❌ Erreur: {e}')
finally:
if ser_sara.is_open:
ser_sara.close()

View File

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

View File

@@ -1 +0,0 @@
1.12.3

View File

@@ -2,10 +2,9 @@
# Script to check if wifi is connected and start hotspot if not # Script to check if wifi is connected and start hotspot if not
# will also retreive unique RPi ID and store it to deviceID.txt # will also retreive unique RPi ID and store it to deviceID.txt
# script that starts at boot:
# @reboot /var/www/nebuleair_pro_4g/boot_hotspot.sh >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
OUTPUT_FILE="/var/www/nebuleair_pro_4g/wifi_list.csv" OUTPUT_FILE="/var/www/nebuleair_pro_4g/wifi_list.csv"
JSON_FILE="/var/www/nebuleair_pro_4g/config.json"
echo "-------------------" echo "-------------------"
@@ -13,78 +12,31 @@ echo "-------------------"
echo "NebuleAir pro started at $(date)" echo "NebuleAir pro started at $(date)"
chmod -R 777 /var/www/nebuleair_pro_4g/ # Blink GPIO 23 and 24 five times
for i in {1..5}; do
# Turn GPIO 23 and 24 ON
gpioset gpiochip0 23=1 24=1
#echo "LEDs ON"
sleep 1
# Turn GPIO 23 and 24 OFF
gpioset gpiochip0 23=0 24=0
#echo "LEDs OFF"
sleep 1
done
# Blink GPIO 23 and 24 five times (starts OFF, stays OFF at end) echo "getting SARA R4 serial number"
#gpioset -c gpiochip0 -t 1s,1s,1s,1s,1s,1s,1s,1s,1s,1s,0 23=0 24=0
# Blink GPIO 23 and 24 five times (starts OFF, stays OFF at end)
python3 << 'EOF'
import RPi.GPIO as GPIO
import time
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(23, GPIO.OUT)
GPIO.setup(24, GPIO.OUT)
for _ in range(5):
GPIO.output(23, GPIO.HIGH)
GPIO.output(24, GPIO.HIGH)
time.sleep(1)
GPIO.output(23, GPIO.LOW)
GPIO.output(24, GPIO.LOW)
time.sleep(1)
GPIO.cleanup()
EOF
echo "getting RPI serial number"
# Get the last 8 characters of the serial number and write to text file # Get the last 8 characters of the serial number and write to text file
serial_number=$(cat /proc/cpuinfo | grep Serial | awk '{print substr($3, length($3) - 7)}') serial_number=$(cat /proc/cpuinfo | grep Serial | awk '{print substr($3, length($3) - 7)}')
# Use jq to update the "deviceID" in the JSON file
# update Sqlite database (only if not already set, i.e., still has default value 'XXXX') jq --arg serial_number "$serial_number" '.deviceID = $serial_number' "$JSON_FILE" > temp.json && mv temp.json "$JSON_FILE"
# Use busy timeout to handle concurrent access from systemd timers at boot
echo "Updating SQLite database with device ID: $serial_number"
sqlite3 -cmd ".timeout 5000" /var/www/nebuleair_pro_4g/sqlite/sensors.db "UPDATE config_table SET value='$serial_number' WHERE key='deviceID' AND value='XXXX';"
echo "id: $serial_number" echo "id: $serial_number"
# Get deviceID from SQLite config_table (may be different from serial_number if manually configured) #get the SSH port for tunneling
DEVICE_ID=$(sqlite3 -cmd ".timeout 5000" /var/www/nebuleair_pro_4g/sqlite/sensors.db "SELECT value FROM config_table WHERE key='deviceID'") SSH_TUNNEL_PORT=$(jq -r '.sshTunnel_port' "$JSON_FILE")
echo "Device ID from database: $DEVICE_ID"
# Get deviceName from SQLite config_table for use in hotspot SSID
DEVICE_NAME=$(sqlite3 -cmd ".timeout 5000" /var/www/nebuleair_pro_4g/sqlite/sensors.db "SELECT value FROM config_table WHERE key='deviceName'")
echo "Device Name from database: $DEVICE_NAME"
# Fallback SSID if DB read failed (lock contention) or deviceName is empty:
# use a deterministic name derived from the RPi serial so hotspot still starts.
if [ -z "$DEVICE_NAME" ]; then
DEVICE_NAME="nebuleair-pro-$serial_number"
echo "WARN: deviceName empty in DB, using fallback SSID: $DEVICE_NAME"
fi
# Get SSH tunnel port from SQLite config_table
SSH_TUNNEL_PORT=$(sqlite3 -cmd ".timeout 5000" /var/www/nebuleair_pro_4g/sqlite/sensors.db "SELECT value FROM config_table WHERE key='sshTunnel_port'")
#need to wait for the network manager to be ready #need to wait for the network manager to be ready
sleep 20 sleep 20
# IMPORTANT: Always enable WiFi radio at boot (in case it was disabled by power save)
WIFI_RADIO_STATE=$(nmcli radio wifi)
echo "WiFi radio state: $WIFI_RADIO_STATE"
if [ "$WIFI_RADIO_STATE" == "disabled" ]; then
echo "WiFi radio is disabled, enabling it..."
nmcli radio wifi on
# Wait longer for NetworkManager to scan and reconnect to known networks
echo "Waiting 15 seconds for WiFi to reconnect to known networks..."
sleep 15
else
echo "WiFi radio is already enabled"
fi
# Get the connection state of wlan0 # Get the connection state of wlan0
STATE=$(nmcli -g GENERAL.STATE device show wlan0) STATE=$(nmcli -g GENERAL.STATE device show wlan0)
@@ -95,20 +47,23 @@ if [ "$STATE" == "30 (disconnected)" ]; then
# Perform a wifi scan and save its output to a csv file # Perform a wifi scan and save its output to a csv file
# nmcli device wifi list # nmcli device wifi list
nmcli -f SSID,SIGNAL,SECURITY device wifi list | awk 'BEGIN { OFS=","; print "SSID,SIGNAL,SECURITY" } NR>1 { print $1,$2,$3 }' > "$OUTPUT_FILE" nmcli -f SSID,SIGNAL,SECURITY device wifi list | awk 'BEGIN { OFS=","; print "SSID,SIGNAL,SECURITY" } NR>1 { print $1,$2,$3 }' > "$OUTPUT_FILE"
# Start the hotspot with SSID based on deviceName # Start the hotspot
echo "Starting hotspot with SSID: $DEVICE_NAME" echo "Starting hotspot..."
sudo nmcli device wifi hotspot ifname wlan0 ssid "$DEVICE_NAME" password nebuleaircfg sudo nmcli device wifi hotspot ifname wlan0 ssid nebuleair_pro password nebuleaircfg
# Update JSON to reflect hotspot mode
jq --arg status "hotspot" '.WIFI_status = $status' "$JSON_FILE" > temp.json && mv temp.json "$JSON_FILE"
# Update SQLite to reflect hotspot mode
sqlite3 -cmd ".timeout 5000" /var/www/nebuleair_pro_4g/sqlite/sensors.db "UPDATE config_table SET value='hotspot' WHERE key='WIFI_status'"
else else
echo "🛜Success: wlan0 is connected!🛜" echo "🛜Success: wlan0 is connected!🛜"
CONN_SSID=$(nmcli -g GENERAL.CONNECTION device show wlan0) CONN_SSID=$(nmcli -g GENERAL.CONNECTION device show wlan0)
echo "Connection: $CONN_SSID" echo "Connection: $CONN_SSID"
# Update SQLite to reflect hotspot mode #update config JSON file
sqlite3 -cmd ".timeout 5000" /var/www/nebuleair_pro_4g/sqlite/sensors.db "UPDATE config_table SET value='connected' WHERE key='WIFI_status'" jq --arg status "connected" '.WIFI_status = $status' "$JSON_FILE" > temp.json && mv temp.json "$JSON_FILE"
sudo chmod 777 "$JSON_FILE"
# Lancer le tunnel SSH # Lancer le tunnel SSH
#echo "Démarrage du tunnel SSH sur le port $SSH_TUNNEL_PORT..." #echo "Démarrage du tunnel SSH sur le port $SSH_TUNNEL_PORT..."

View File

@@ -1,983 +0,0 @@
{
"versions": [
{
"version": "1.12.3",
"date": "2026-06-02",
"changes": {
"features": [],
"improvements": [
"Signalement défaut CO2: adoption de l'option 1 (sentinelle ISO_17=0xFFFF seule, source de vérité, alignée avec le dev serveur). Retrait du bit 7 CO2_ERROR ajouté en v1.12.2 (le bit 7 est ambigu WIND/CO2 selon device_type). L'absence/défaut du S88 est signalée uniquement par bytes 81-82 = 0xFFFF, ce qui reste garanti correct grâce à l'écriture systématique + s88_status (v1.12.1). error_flags.md remis à jour."
],
"fixes": [],
"compatibility": []
},
"notes": "Décision conjointe avec le dev serveur Miotiq: la sentinelle ISO_17=0xFFFF est la seule source de vérité pour l'absence CO2; pas de bit error_flags. Annule la partie bit 7 de v1.12.2."
},
{
"version": "1.12.2",
"date": "2026-06-02",
"changes": {
"features": [],
"improvements": [
"Transmission: error_flags (byte 66) bit 7 (0x80) posé quand la sonde CO2 S88 est déconnectée (s88_status=0xFF ou aucune donnée). Bit 7 = double sens selon le produit (décodage serveur par device_type): WIND_ERROR sur NebuleAir classique, CO2_ERROR sur les unités équipées CO2 (confirmé côté serveur). Nouvelle constante ERR_CO2=0x80. error_flags.md mis à jour."
],
"fixes": [],
"compatibility": [
"⚠ Bit 7 partagé vent/CO2: le serveur interprète selon le device_type. Sur une box qui aurait À LA FOIS girouette ET S88, conflit — ne pas activer les deux sans arbitrage serveur."
]
},
"notes": "Complète v1.12.1: en plus du sentinel 0xFFFF dans le champ CO2 (byte 81-82), le bit CO2_ERROR remonte explicitement le défaut. Vérifié sur pro100 (S88 muet -> error_flags bit 7 posé)."
},
{
"version": "1.12.1",
"date": "2026-06-02",
"changes": {
"features": [],
"improvements": [
"S88: nouvelle colonne data_S88.s88_status (0 = OK, 0xFF = sonde ne répond pas), sur le modèle de npm_status/noise_status. write_data.py écrit DÉSORMAIS une ligne à CHAQUE cycle (avant: rien quand pas de réponse), avec s88_status=0xFF et CO2=0 en cas d'échec. Évite que la base garde indéfiniment la dernière valeur valide. Affichage badge ✅/❌ dans database.html, colonne ajoutée aux exports CSV. Connexion SQLite avec timeout=10 (anti 'database is locked' vu en prod avec le daemon CCS811 + multiples writers)."
],
"fixes": [
"Transmission: SARA_send_data_v2.py ne transmet plus une valeur CO2 périmée. Il lit s88_status sur la dernière ligne data_S88; si la sonde est down (0xFF), les octets 81-82 restent à 0xFFFF (= capteur CO2 absent dans la spec Miotiq) au lieu d'envoyer la dernière mesure valide. Garde compatible avec les bases non encore migrées (len(row)>2)."
],
"compatibility": [
"Migration DB: colonne s88_status ajoutée via create_db.py (ALTER idempotent) + set_config.py migrations + self-heal dans write_data.py. S'applique à la prochaine OTA."
]
},
"notes": "Découvert sur pro100: la sonde S88 ne répondait plus (0 octet Modbus sur les 3 ports UART, panne câblage/alim) mais la base gardait CO2=487 d'hier et la loop l'aurait transmis en boucle. Le même principe (toujours écrire + code d'état) reste à appliquer au CCS811 quand il sera transmis."
},
{
"version": "1.12.0",
"date": "2026-06-02",
"changes": {
"features": [
"Transmission du CO2 S88 dans le payload UDP Miotiq (débloque le WIP). Nouvelle méthode SensorPayload.set_co2() -> bytes 81-82 (uint16 ppm, champ ISO_17 de la spec Miotiq). SARA_send_data_v2.py lit data_S88 (dernière ligne) si config S88 active et empaquette le CO2. Défaut 0xFFFF = capteur absent (octets initialisés à 0xFF). Canal UDP Miotiq uniquement (pas CSV/JSON pour l'instant). error_flags.md: cartographie d'octets mise à jour (81-82 = CO2)."
],
"improvements": [],
"fixes": [],
"compatibility": [
"Source du champ CO2 (ISO_17) = Senseair S88 (vrai CO2 NDIR). Le CCS811 n'alimente PAS ce champ (son eCO2 est dérivé, pas un vrai CO2). Le CCS811 (TVOC + eCO2) n'a pas encore de champ dans la spec serveur Miotiq -> transmission CCS811 toujours en attente d'extension de la spec côté serveur."
]
},
"notes": "Spec Miotiq reçue: payload 83 octets, byte 81-82 = CO2 ISO_17 uint16 ppm. Packing vérifié (793 -> 0x0319). À faire ensuite pour le CCS811: demander à Miotiq d'ajouter un champ TVOC (code ISO COV) + eCO2."
},
{
"version": "1.11.0",
"date": "2026-06-02",
"changes": {
"features": [],
"improvements": [
"CCS811: passage d'un timer oneshot (10s) à un DAEMON long-running (nebuleair-ccs811-data.service, Type=simple, Restart=always). Le CCS811 doit être initialisé une seule fois puis lu en continu : chaque ré-init produit du garbage les premières secondes (eCO2=0 ou 0x8000=32768) et un reset toutes les 10s empêche la baseline de se construire. Nouveau CCS811/daemon.py (init 1x, boucle lecture/écriture 10s, re-init auto après erreurs I2C). write_data.py supprimé. setup_services.sh self-heal: supprime l'ancien timer .timer des capteurs en 1.10.x.",
"CCS811/get_data.py (bouton Get Data) ne lit plus le capteur mais la dernière ligne de data_CCS811 — sinon collision I2C avec le daemon (= corruption observée sur pro100)."
],
"fixes": [
"CCS811: filtrage corrigé. La plage valide est [400, 8192] ppm; l'ancien filtre eCO2<400 laissait passer les valeurs corrompues 32768 (clock-stretching). Désormais tout échantillon hors plage est jeté."
],
"compatibility": [
"⚠ MATÉRIEL: sur Raspberry Pi le CCS811 exige de ralentir le bus I2C à 10 kHz (dtparam=i2c_arm_baudrate=10000 dans /boot/firmware/config.txt + reboot). Confirmé indispensable sur pro100: à 100 kHz, valeurs corrompues 32768 intermittentes. Réglage hors repo, à poser manuellement sur chaque capteur équipé d'un CCS811. BME280/RTC tolèrent 10 kHz."
]
},
"notes": "Daemon vérifié sur nebuleair-pro100 après reboot avec I2C à 10 kHz. Rappel burn-in CCS811: ~20 min de warm-up, ~48h de conditionnement initial. get_data.py renvoie maintenant aussi un champ timestamp (ignoré par sensors.html)."
},
{
"version": "1.10.1",
"date": "2026-06-02",
"changes": {
"features": [],
"improvements": [
"OTA installe désormais les dépendances Python. Nouveau requirements.txt (source unique de vérité), installé par installation_part1.sh (install neuve, chemin relatif au script car le repo n'est pas encore cloné) ET par update_firmware.sh (nouvelle étape 2a, idempotent). Corrige le trou découvert sur nebuleair-pro100 : l'OTA faisait git pull sans réinstaller pip, donc la lib adafruit-circuitpython-ccs811 manquait et le timer CCS811 échouait en ModuleNotFoundError. Tous les capteurs récupéreront automatiquement les libs manquantes à la prochaine MAJ."
],
"fixes": [
"CCS811: filtrage des lectures parasites eCO2 < 400 ppm (plancher physique du capteur). Juste après l'init du driver, le CCS811 renvoie parfois un échantillon 0/0 avant sa 1ère mesure valide — ces lignes ne sont plus écrites en base (write_data.py) ni affichées (get_data.py), le tick suivant réessaie."
],
"compatibility": []
},
"notes": "Vérifié en SSH sur nebuleair-pro100 : capteur détecté en I2C à 0x5A, lib installée, données eCO2/TVOC qui remontent. Rappel: le CCS811 a besoin de ~20 min de warm-up et ~48h de burn-in initial pour des valeurs stables."
},
{
"version": "1.10.0",
"date": "2026-06-02",
"changes": {
"features": [
"Intégration du capteur de qualité d'air CCS811 (I2C). Mesure TVOC (ppb, mesure principale) + eCO2 (ppm, dérivé des COV — PAS un vrai CO2 NDIR comme le S88). Nouveau dossier CCS811/ (get_data.py lecture live + write_data.py timer). Table data_CCS811 (timestamp, eCO2, TVOC). Timer systemd toutes les 10 s. Activation + adresse I2C (0x5A Adafruit / 0x5B SparkFun, défaut 0x5A) configurables dans admin.html. Carte 'Get Data' dans sensors.html, consultation/export dans database.html. Lib adafruit-circuitpython-ccs811 ajoutée à installation_part1.sh."
],
"improvements": [],
"fixes": [],
"compatibility": []
},
"notes": "⚠ Matériel : le CCS811 utilise le clock-stretching I2C que le contrôleur du Pi gère mal (bug BSC). Prévoir 'dtparam=i2c_arm_baudrate=10000' dans config.txt si les lectures échouent en I/O error — à valider au bench. Voir CCS811/README.md. Schéma data_CCS811 dupliqué dans write_data.py (self-heal CREATE IF NOT EXISTS) et create_db.py — garder synchro. CCS811 pas encore intégré au payload de transmission (local-only, comme le S88)."
},
{
"version": "1.9.19",
"date": "2026-06-01",
"changes": {
"features": [],
"improvements": [
"S88/write_data.py: self-heal CREATE TABLE IF NOT EXISTS au démarrage. Protège contre le cas où l'OTA n'a pas appelé create_db.py (problème classique bash qui exécute l'ancien script chargé en mémoire avant le git pull). Le script crée la table data_S88 lui-même au 1er run si elle manque."
],
"fixes": [],
"compatibility": []
},
"notes": "Le schéma de la table est dupliqué dans write_data.py et create_db.py — garder synchro. C'est volontaire pour rendre le script self-contained et éviter les blocages OTA. Pattern à appliquer aux futurs nouveaux capteurs."
},
{
"version": "1.9.18",
"date": "2026-06-01",
"changes": {
"features": [],
"improvements": [],
"fixes": [
"OTA update: appel manquant à sqlite/create_db.py dans update_firmware.sh et update_firmware_from_file.sh. Conséquence: les MAJ qui ajoutaient une nouvelle table (data_S88, data_NOISE.noise_status, etc.) laissaient les timers tourner mais chaque écriture échouait silencieusement avec 'no such table'. Désormais create_db.py est appelé en step 2 juste avant set_config.py — idempotent (CREATE IF NOT EXISTS + ALTER ADD COLUMN in try/except), safe à chaque OTA."
],
"compatibility": []
},
"notes": "Fix self-bootstrap: dès qu'un capteur fait une OTA après cette version, create_db.py s'exécute et crée toutes les tables manquantes. Pour les capteurs déjà sur v1.9.13v1.9.17 qui ont activé S88 sans table data_S88, la prochaine OTA résoudra automatiquement."
},
{
"version": "1.9.17",
"date": "2026-06-01",
"changes": {
"features": [
"database.html: refonte de la consultation des mesures. Les boutons 'Consulter la base' ouvrent désormais un grand modal (modal-xl scrollable) avec pagination 20 lignes par page (boutons Précédent/Suivant + indicateur de plage). Le dropdown 'Nombre de mesures' est supprimé — par défaut 20 dernières, on navigue ensuite page par page.",
"database.html: ajout des boutons Senseair S88 dans les 3 cartes (Consulter / Télécharger par date / Télécharger toute la table), pointant sur data_S88. Le bouton MH-Z19 est renommé 'Mesures CO2 (MH-Z19)'."
],
"improvements": [
"sqlite/read.py + launcher.php endpoint table_mesure: support du paramètre OFFSET (utilisé par la pagination du modal)."
],
"fixes": [],
"compatibility": []
},
"notes": "Rétrocompatible: read.py accepte toujours les anciens appels à 2 arguments (offset par défaut = 0). L'endpoint table_mesure accepte un offset optionnel."
},
{
"version": "1.9.16",
"date": "2026-06-01",
"changes": {
"features": [],
"improvements": [
"Sélecteur S88_port: les options affichent désormais 'port NPM1 (/dev/ttyAMA5)' etc. pour matcher le silkscreen de la PCB et éviter les erreurs de branchement. ttyAMA0 (non exposé) et ttyAMA2 (occupé par SARA) sont retirés du dropdown."
],
"fixes": [],
"compatibility": []
},
"notes": "Aucun changement en base — la valeur stockée reste /dev/ttyAMAx, seul l'affichage change."
},
{
"version": "1.9.15",
"date": "2026-06-01",
"changes": {
"features": [
"Capteur CO2 Senseair S88: ajout d'un sélecteur de port UART (S88_port) sous la checkbox 'Send CO2 sensor data' dans admin.html. Permet de choisir parmi ttyAMA0/2/3/4/5 sans passer par la CLI."
],
"improvements": [],
"fixes": [],
"compatibility": []
},
"notes": "Aucun changement de schéma. La clé S88_port existe déjà en base depuis v1.9.13 — le sélecteur expose juste sa modification via l'UI."
},
{
"version": "1.9.14",
"date": "2026-06-01",
"changes": {
"features": [
"Capteur CO2 Senseair S88: implémentation de la lecture Modbus RTU. read_co2() lit IR1..IR4 en une trame (status + CO2) à 9600 8N1, adresse 0xFE 'any address', avec vérification CRC16-Modbus et rejet de la mesure si status non-nul (warm-up ou erreur). CRC requête/réponse validés contre les exemples du datasheet TDE14367. Doc protocole consolidée dans S88/README.md."
],
"improvements": [],
"fixes": [],
"compatibility": []
},
"notes": "Le capteur peut être utilisé tel quel branché sur le port configuré dans S88_port (défaut /dev/ttyAMA5). L'adresse Modbus 0xFE répond quelle que soit l'adresse individuelle du capteur — adapté pour un seul S88 sur le bus. Si plusieurs S88 sur le même UART, configurer une adresse individuelle via les HR (à faire une seule fois en bench)."
},
{
"version": "1.9.13",
"date": "2026-06-01",
"changes": {
"features": [
"Capteur CO2 Senseair S88: scaffolding complet (table SQLite data_S88, flag config S88 + port configurable S88_port par défaut /dev/ttyAMA5, service/timer systemd 10s, carte sensors.html, endpoint launcher.php ?type=s88, toggle admin.html 'Send CO2 sensor data'). La fonction read_co2() est un stub NotImplementedError en attente du datasheet du protocole — le service tourne mais log l'erreur sans planter."
],
"improvements": [],
"fixes": [],
"compatibility": []
},
"notes": "Ajout du capteur CO2 Senseair S88. Pour activer après MAJ: exécuter sqlite/create_db.py + sqlite/set_config.py (pour la migration table+config), puis services/setup_services.sh. La lecture sensor est désactivée tant que le datasheet n'est pas intégré."
},
{
"version": "1.9.12",
"date": "2026-05-28",
"changes": {
"features": [],
"improvements": [
"SARA loop: 2e test de comm avant hardware reboot. Quand AT+CSQ ne répond pas (hoquet série temporaire), le script retente le AT+CSQ jusqu'à 3 fois (0.5s d'intervalle) avant d'escalader. Si le modem répond, le flux normal reprend sans reboot — évite les coupures d'alim GPIO et l'usure du modem pour une simple absence de réponse ponctuelle."
],
"fixes": [],
"compatibility": []
},
"notes": "Ne touche pas aux autres causes de reboot (erreur Treck, etc.) ni au cas signal=99. Cible uniquement le 'No answer from SARA module' qui déclenchait un hardware reboot dès le premier AT+CSQ muet."
},
{
"version": "1.9.11",
"date": "2026-05-28",
"changes": {
"features": [
"Bouton 'Test Power Supply' à côté de 'Run Self Test' (pages Admin, Accueil, Capteurs, Modem) : lance uniquement le check sous-tension dans un petit modal dédié, sans dérouler tout le Self Test. Affiche le verdict (OK / Warning / Failed) + le détail des bits (sous-tension maintenant / depuis le boot, throttling)."
],
"improvements": [],
"fixes": [],
"compatibility": []
},
"notes": "Réutilise l'endpoint launcher.php?type=throttled ajouté en v1.9.10. Le modal est défini dans selftest-modal.html (déjà chargé sur toutes les pages)."
},
{
"version": "1.9.10",
"date": "2026-05-28",
"changes": {
"features": [
"Self Test: nouveau check 'Power Supply' qui lit 'vcgencmd get_throttled' et détecte la sous-tension du Pi. Passed = alim OK, Warning = sous-tension survenue depuis le boot, Failed = sous-tension active. Apparaît en tête des résultats et dans le rapport copiable. La sous-tension est une cause fréquente de capteurs USB instables, corruptions SD et reboots."
],
"improvements": [],
"fixes": [],
"compatibility": [
"Backend: nouvel endpoint launcher.php?type=throttled + script power/get_throttled.py (lancé via sudo python3, déjà whitelisté dans sudoers — aucun changement /etc/sudoers requis)."
]
},
"notes": "Complément de la v1.9.9 (retry sonde bruit): permet de diagnostiquer à distance la cause racine (alimentation 5V insuffisante / câble) depuis l'interface admin, sur n'importe quel boîtier de la flotte."
},
{
"version": "1.9.9",
"date": "2026-05-28",
"changes": {
"features": [],
"improvements": [
"Sonde bruit (NSRT MK4): le script de collecte retente jusqu'à 3 fois (1s d'intervalle) avant de marquer 'Déconnecté'. Absorbe les ré-énumérations USB ponctuelles de /dev/ttyACM0 (~1s) qui provoquaient des points isolés à 0.0. Si l'ouverture du port échoue on ré-ouvre, si une lecture échoue on garde le handle ouvert et on retente les lectures."
],
"fixes": [],
"compatibility": []
},
"notes": "Le retry est un palliatif: la cause racine observée sur le pro150 (Device 9565AA75) est une vraie ré-énumération USB du NSRT toutes les ~10s (dmesg: 'USB disconnect' / 'new full-speed USB device' en boucle, numéro de device qui monte). À traiter côté matériel: alimentation/sous-tension du Pi (vcgencmd get_throttled), câble et connecteur USB."
},
{
"version": "1.9.8",
"date": "2026-05-21",
"changes": {
"features": [],
"improvements": [
"Self Test: titre du modal renommé 'Modem Self Test' → 'Self Test' (plus juste, le test couvre aussi les capteurs et le RTC)",
"Self Test: ajout de la ligne 'Firmware Version' dans les logs et dans le rapport copiable (récupérée depuis le fichier VERSION via get_config_sqlite, pas d'AJAX supplémentaire)"
],
"fixes": [],
"compatibility": []
},
"notes": "Si le test Envea passe encore en 'Passed' alors qu'aucune sonde n'est branchée: forcer le rafraîchissement du navigateur (Ctrl+F5) — le fichier selftest.js v1.9.7 est probablement en cache. La nouvelle version doit afficher 'Envea ttyAMA3/4/5: detected=...' au lieu de '=== ENVEA Sensor Reader Started ==='."
},
{
"version": "1.9.7",
"date": "2026-05-21",
"changes": {
"features": [],
"improvements": [
"Modem Self Test: le test 'Envea (Gas Sensors)' vérifie maintenant la présence physique du device sur ttyAMA3/4/5 via detect_envea_device (read_ref.py), au lieu de se fier à la config envea_sondes_table.connected=1. Affiche les ports où un device Envea CAIRSENS est réellement détecté"
],
"fixes": [],
"compatibility": []
},
"notes": "Suite de la v1.9.6: le précédent fix parsait read_value_v2.py mais cette sortie reflète la config UI (sonde activée), pas la réponse physique du device. Le self-test utilise désormais la même logique que la page 'Envea Sondes Detection'."
},
{
"version": "1.9.6",
"date": "2026-05-21",
"changes": {
"features": [],
"improvements": [],
"fixes": [
"Modem Self Test: le test 'Envea (Gas Sensors)' passait à tort en 'Passed' même sans sonde physiquement branchée. L'ancien check vérifiait juste que la sortie debug de read_value_v2.py était non vide et ne contenait pas le mot 'error', ce qui était toujours vrai (le script imprime un en-tête '=== ENVEA Sensor Reader Started ===' et utilise 'Failed' pas 'error' pour les échecs). Le test parse maintenant les marqueurs explicites '✓ NAME = ' (trame valide reçue) et '✗ Failed to read NAME' pour décider Passed/Warning/Failed, et liste les sondes qui répondent vs celles qui ne répondent pas"
],
"compatibility": []
},
"notes": "Fix d'un faux positif du self-test Envea. Détecte aussi le cas 'aucune sonde marquée connected=1' dans envea_sondes_table (sortie '! No connected ENVEA sensors found')."
},
{
"version": "1.9.5",
"date": "2026-05-20",
"changes": {
"features": [],
"improvements": [
"logs.html (modal WiFi connect logs): message clair quand le fichier n'existe pas encore (au lieu du 404 d'Apache qui faisait croire à un bug). Échappement HTML du contenu et limitation aux 1000 dernières lignes pour la performance"
],
"fixes": [],
"compatibility": []
},
"notes": "Petit ajustement UX du modal introduit en v1.9.4: si aucune tentative WiFi n'a eu lieu depuis v1.9.3, on indique simplement qu'il faut en déclencher une plutôt que d'afficher une erreur HTTP."
},
{
"version": "1.9.4",
"date": "2026-05-20",
"changes": {
"features": [
"logs.html: bouton 'WiFi connect logs' qui ouvre un modal affichant le contenu de logs/wifi_connect.log (introduit en v1.9.3), avec bouton Refresh. Permet de récupérer la chronologie complète d'une tentative de connexion WiFi directement depuis l'UI admin, sans passer en SSH"
],
"improvements": [],
"fixes": [],
"compatibility": []
},
"notes": "Complète la v1.9.3 en rendant accessible le nouveau log dédié depuis l'interface admin (le fichier était écrit mais pas affiché dans la page Journal)."
},
{
"version": "1.9.3",
"date": "2026-05-20",
"changes": {
"features": [
"Nouveau log dédié logs/wifi_connect.log (timestamps stricts) tracant chaque étape d'une tentative de connexion WiFi depuis le mode hotspot: réception de la requête PHP, état NetworkManager avant/après, code de retour et sortie complète de nmcli, fallback hotspot si échec"
],
"improvements": [
"connexion.sh: réécrit avec fonction log_wc() horodatée, snapshots NM avant/après, capture stdout+stderr de nmcli, validation des arguments d'entrée, busy timeout SQLite, fallback SSID dérivé du serial RPi"
],
"fixes": [
"Correction critique du flow wifi_connect: launcher.php n'échappait pas les arguments SSID/PASS passés en shell (pas d'escapeshellarg). Les SSID avec espaces ou les mots de passe contenant $, &, ;, espaces, etc. étaient corrompus avant d'atteindre nmcli, faisant échouer silencieusement la connexion (retour client: 'ça bloque au moment de cliquer sur se connecter')",
"wifi.html: ajout de encodeURIComponent() sur SSID et PASS dans l'URL de wifi_connect (caractères &, +, =, # cassaient la requête côté serveur)"
],
"compatibility": []
},
"notes": "Corrige le bug le plus probable derrière les retours clients de connexion WiFi qui échoue sans message d'erreur. Le nouveau log wifi_connect.log permet désormais de diagnostiquer précisément un échec (à fournir lors de tout futur retour client). Log tronqué automatiquement chaque nuit comme les autres logs."
},
{
"version": "1.9.2",
"date": "2026-05-20",
"changes": {
"features": [],
"improvements": [
"boot_hotspot.sh: ajout d'un busy timeout de 5s sur toutes les requêtes SQLite pour gérer la contention avec les timers systemd au boot",
"boot_hotspot.sh: SSID de hotspot dérivé du serial RPi (nebuleair-pro-<serial>) en fallback si deviceName est vide dans la DB"
],
"fixes": [
"Correction d'un bug critique: le hotspot ne démarrait pas si la SQLite était lockée au boot (les requêtes échouaient silencieusement, $DEVICE_NAME restait vide, nmcli refusait de créer un hotspot sans SSID). Visible dans les logs par 'Error: in prepare, database is locked (5)' suivi de 'Failed to setup a Wi-Fi hotspot: A wireless setting with a valid SSID is required'"
],
"compatibility": []
},
"notes": "Garantit que le hotspot de configuration démarre dans tous les cas où wlan0 est déconnecté, même en cas de race condition avec les autres services au boot."
},
{
"version": "1.9.1",
"date": "2026-05-19",
"changes": {
"features": [
"Admin: nouvelle section 'Réseau Tailscale' affichant statut de connexion, IP tailnet, hostname et serveur Headscale",
"Admin: bouton 'Actualiser' pour rafraîchir les infos Tailscale, et bloc déroulant pour consulter les 50 dernières lignes du log bootstrap",
"launcher.php: nouvelles actions get_tailscale_info et get_tailscale_log"
],
"improvements": [
"Vérification visuelle immédiate de l'état d'enrôlement Tailscale sans avoir à passer en SSH"
],
"fixes": [],
"compatibility": [
"Aucun impact sur les capteurs sans Tailscale: la section affiche 'Non installé' avec un message d'invite à mettre à jour"
]
},
"notes": "Complète la v1.9.0 (enrôlement automatique) avec la visibilité UI nécessaire pour valider/diagnostiquer la connexion Tailscale sur chaque capteur depuis l'admin web."
},
{
"version": "1.9.0",
"date": "2026-05-19",
"changes": {
"features": [
"Enrôlement automatique des capteurs sur le tailnet AirCarto (Headscale) pour accès SSH distant via Tailscale",
"installation_part1.sh: installation du paquet Tailscale (curl|sh) + règle sudoers /usr/bin/tailscale *",
"services/tailscale_bootstrap.sh: script idempotent d'enrôlement au boot (hostname dérivé du deviceID: nebuleair-pro-<id>)",
"services/setup_services.sh: nouveau service systemd nebuleair-tailscale-bootstrap (one-shot at boot, After=network-online+tailscaled)",
"update_firmware.sh: nouvelle étape 3d 'Bootstrap Tailscale' — self-heal install + fetch authkey depuis data.nebuleair.fr/pro_4G/get_tailscale_key.php + enrôlement"
],
"improvements": [
"update_firmware.sh: chmod 755 désormais appliqué aussi aux services/*.sh (cohérence avec le pattern existant des dossiers Python)",
"Auth de l'endpoint serveur basée sur le deviceID (déjà connu côté AirCarto, pas de nouveau secret à provisionner sur les capteurs)",
"Fallback HTTPS → HTTP pour le fetch authkey tant que data.nebuleair.fr n'a pas de certificat TLS (à retirer une fois le cert en place)"
],
"fixes": [],
"compatibility": [
"Capteurs déployés pré-v1.9.0: le bootstrap Tailscale s'auto-installe au prochain update OTA (self-heal binary + sudoers via /etc/sudoers.d/nebuleair-tailscale)",
"Capteurs sans connectivité au moment de l'update: l'enrôlement échoue silencieusement et sera retenté au prochain boot via le service systemd",
"Aucun secret committé dans le repo: la preauth key est fetchée à la volée depuis le serveur AirCarto"
]
},
"notes": "Permet l'accès SSH distant aux 200 capteurs déployés une fois leur client a cliqué sur 'Update' dans l'admin web — utile pour le support et le debug à distance sans avoir à demander au client d'intervenir. Côté serveur AirCarto, un nouvel endpoint data.nebuleair.fr/pro_4G/get_tailscale_key.php doit être déployé en parallèle (retourne la preauth key Headscale pour les deviceID valides, avec rate-limiting et audit log recommandés)."
},
{
"version": "1.8.3",
"date": "2026-05-13",
"changes": {
"features": [
"Sidebar: affichage de la version firmware juste sous le nom du capteur (visible sur toutes les pages)"
],
"improvements": [
"launcher.php: get_config_sqlite renvoie maintenant firmware_version (lu depuis le fichier VERSION) — pas de nouveau endpoint, pas de nouveau fetch côté UI",
"topbar-logo.js: peuple .sideBar_firmwareVersion via le fetch config existant + MutationObserver (compatible avec le chargement asynchrone de sidebar.html)"
],
"fixes": [],
"compatibility": [
"Aucun risque sur les pages existantes: les handlers AJAX par page restent inchangés, seul launcher.php ajoute un champ dans la réponse JSON"
]
},
"notes": "Permet d'identifier d'un coup d'oeil la version firmware d'un capteur depuis n'importe quelle page de l'UI — utile pour decider du chemin de mise a jour (online git pull >= v0.x, offline ZIP upload >= v1.4.0) sans avoir a fouiller dans admin.html ou en SSH."
},
{
"version": "1.8.2",
"date": "2026-05-12",
"changes": {
"features": [
"Pre-flight check sudoers avant lancement de l'update (online et offline): détecte les capteurs sans règle NOPASSWD et affiche une alerte claire avec la commande de fix",
"Bouton 'Copier le contenu' pour le bloc sudoers à coller (presse-papier)"
],
"improvements": [
"Détection précoce: l'erreur sudo apparaît immédiatement (en < 1s) au lieu d'attendre l'échec du script en background",
"Message d'erreur user-friendly avec étapes numérotées au lieu de l'erreur cryptique de sudo"
],
"fixes": [],
"compatibility": [
"Aucun impact sur les capteurs sains: si sudo NOPASSWD est correctement configuré, le pre-flight passe en <100ms"
]
},
"notes": "Sur les anciens capteurs installés avant l'ajout de la règle sudoers /var/www/nebuleair_pro_4g/* dans installation_part1.sh, l'update via web UI était silencieusement cassé. Désormais l'UI explique exactement quoi faire en SSH pour réparer."
},
{
"version": "1.8.1",
"date": "2026-05-12",
"changes": {
"features": [
"Upload offline (ZIP): même UX live que l'update online (progress bar dynamique, label étape, timer, logs techniques repliables)",
"Upload offline: self-heal via Step 4c qui appelle setup_services.sh (alignement complet avec l'online)"
],
"improvements": [
"Frontend: mapping des étapes spécifique au mode (UPDATE_STEPS_ONLINE / UPDATE_STEPS_OFFLINE) selon le script lancé",
"Frontend: interpolation sub-step gère désormais Step 3c (online) et Step 4c (offline) de la même façon",
"Backend: route upload_firmware lance maintenant le script en background et réutilise le même mécanisme de log/done que l'update online",
"Détection de fin: substring 'completed successfully!' (matche les deux scripts qui ont des markers finaux légèrement différents)"
],
"fixes": [
"Le bouton 'Upload & Install' restait bloqué sur 'Installing...' après succès — resetUpdateButton remet maintenant les deux boutons à zéro"
],
"compatibility": [
"Aucun risque sur les capteurs existants: même logique, juste l'UX qui change côté UI"
]
},
"notes": "Suite v1.8.0: alignement de l'upload offline sur le flow online. Les deux chemins partagent maintenant le même mécanisme de polling, la même progress bar, et le même self-heal Step 3c/4c. Plus de divergence entre les deux modes."
},
{
"version": "1.8.0",
"date": "2026-05-12",
"changes": {
"features": [
"Update firmware: nouvelle UX avec progress bar dynamique, label de l'étape en cours, et timer mm:ss / estimation",
"Update firmware: streaming live des logs (polling 700ms) — plus de fenêtre 'qui bloque' pendant 90s",
"Update firmware: bloc de statut final (succès vert / échec rouge) avec message explicite",
"Update firmware: logs techniques masqués par défaut dans une section repliable, ouverts automatiquement en cas d'échec"
],
"improvements": [
"Backend: 2 nouvelles routes launcher.php — update_firmware_start (lance en background, retour immédiat) et update_firmware_progress (polling incrémental avec offset)",
"Sous-étape Step 3c (setup_services.sh, le plus long): interpolation de la progression via comptage des 'Started X' (npm, envea, sara, etc.)",
"Sous-étape Step 4 (restart services): interpolation via comptage des 'Restarting enabled service:'"
],
"fixes": [],
"compatibility": [
"L'ancienne route update_firmware (synchronous) est conservée pour rétrocompatibilité"
]
},
"notes": "Refonte UX du process de mise à jour: l'utilisateur voit maintenant en temps réel où on en est (étape, %, temps écoulé) au lieu de fixer un spinner pendant 1-2 minutes. Les logs bruts restent accessibles pour debug pro via une section repliable."
},
{
"version": "1.7.7",
"date": "2026-05-12",
"changes": {
"features": [],
"improvements": [],
"fixes": [
"Page database (Informations sur la base): la colonne 'Plus récente' affichait 'not connected' a cause d'un tri lexicographique mixant des chaines 'not connected' et des dates ISO. Les requetes MIN/MAX excluent maintenant 'not connected'."
],
"compatibility": [
"Aucun impact sur les donnees: les lignes 'not connected' restent en base, elles sont juste ignorees pour le calcul des bornes temporelles affichees"
]
},
"notes": "Effet de bord du bug RTC corrige en v1.7.4: les capteurs deployes avec un rtc_save_to_db.service inactif ont accumule des lignes avec timestamp='not connected'. En tri ASCII, 'n' > '2' donc MAX(timestamp) renvoyait 'not connected' au lieu de la vraie date la plus recente."
},
{
"version": "1.7.6",
"date": "2026-05-12",
"changes": {
"features": [],
"improvements": [
"Page admin (tableau SystemD Services): ajout de 3 services manquants — rtc_save_to_db, nebuleair-wifi-powersave.timer, nebuleair-cpu-power.service",
"Page admin: support d'un display_name explicite par service (sinon auto-cleanup) — affichage propre pour les services .service en plus des .timer",
"Page admin: nebuleair-noise-data.timer ajoute aux listes allowed pour restart/toggle (etait dans get_systemd_services mais pas dans les 2 autres)"
],
"fixes": [],
"compatibility": []
},
"notes": "Le tableau SystemD Services affiche maintenant la liste complete des 11 services NebuleAir. Cohérence des 3 listes hardcodees (display, restart, toggle)."
},
{
"version": "1.7.5",
"date": "2026-05-12",
"changes": {
"features": [],
"improvements": [],
"fixes": [
"update_firmware.sh: chmod +x avant d'executer setup_services.sh (le bit executable est strippé par git config core.fileMode=false, donc le check -x echouait systematiquement et Step 3c logguait 'not found or not executable, skipping')"
],
"compatibility": []
},
"notes": "Suite v1.7.4: le self-heal Step 3c n'etait pas reellement actif a cause d'un check -x trop strict. Cette version corrige le probleme en chmod +x avant l'execution, comme le fait deja installation_part2.sh."
},
{
"version": "1.7.4",
"date": "2026-05-12",
"changes": {
"features": [],
"improvements": [
"Services systemd: source de verite unique dans services/setup_services.sh (le service rtc_save_to_db etait auparavant cree inline dans installation_part2.sh)",
"update_firmware.sh: appelle maintenant setup_services.sh apres git pull (self-healing des services manquants/masques sur les capteurs deja deployes)",
"setup_services.sh: systemctl unmask defensif sur rtc_save_to_db avant creation du fichier (evite l'ecriture dans /dev/null si le service avait ete masque)"
],
"fixes": [
"Bug observe sur capteur deployé: rtc_save_to_db.service masque -> timestamp_table reste a 'not connected' -> RTC affiche comme non connecte dans les logs SARA alors que le materiel fonctionne. L'update firmware ne reparait pas cette situation. Avec v1.7.4, un simple update firmware repare automatiquement."
],
"compatibility": [
"Aucun risque sur les capteurs sains: les fichiers .service sont reecrits avec le meme contenu, comportement inchange",
"Capteurs avec services manquants/masques: seront repares automatiquement au prochain update firmware",
"Aucune migration manuelle requise"
]
},
"notes": "Reorganisation interne du provisionnement des services systemd. installation_part2.sh ne contient plus la definition inline du service RTC (deduplique). save_to_db.py ne contient plus les instructions systemd en commentaire (deduplique). update_firmware.sh devient self-healing pour les services."
},
{
"version": "1.7.3",
"date": "2026-05-12",
"changes": {
"features": [],
"improvements": [],
"fixes": [
"Revert v1.7.2: retrait du check SIM sur la branche CSQ=99 (overhead inutile a chaque coupure signal temporaire, le check sur la branche USOCR/PDP suffit avec un delai de 1-2 cycles maximum)"
],
"compatibility": []
},
"notes": "Apres tests: la SIM est detectee tres vite via la branche PDP meme sans le check sur CSQ=99 (le modem alterne CSQ=99 et CSQ>0 sans SIM). Le check sur CSQ=99 ajoutait du cout AT a chaque coupure reseau temporaire pour un gain marginal. On garde uniquement le check v1.7.1 dans la branche USOCR/PDP."
},
{
"version": "1.7.2",
"date": "2026-05-12",
"changes": {
"features": [],
"improvements": [
"Loop SARA: detection SIM injoignable etendue au cas 'signal CSQ=99' (sans SIM, le modem ne peut pas s'enregistrer donc CSQ=99 systematique)",
"Distinction claire entre 'SIM absente' (alerte rouge + notification) et 'coupure reseau temporaire' (message vert, retry au prochain cycle)"
],
"fixes": [
"v1.7.1 ne detectait pas la SIM absente quand CSQ=99 (le script sortait avant d'atteindre le check SIM dans la branche USOCR)"
],
"compatibility": []
},
"notes": "Complement v1.7.1: le check SIM est maintenant appele dans les deux branches d'echec (CSQ=99 et USOCR ERROR). Couvre tous les cas de SIM injoignable."
},
{
"version": "1.7.1",
"date": "2026-05-12",
"changes": {
"features": [],
"improvements": [
"Loop SARA: detection SIM injoignable avant escalade hardware reboot (evite les reboots inutiles en boucle quand la SIM est absente/mal inseree)",
"Logs HTML: bloc rouge tres visible quand la SIM n'est pas detectee (bordure, fond rose, message d'action clair)",
"Notification WiFi dediee: 'SIM NOT DETECTED -> physical check required' au lieu du generique 'UDP socket creation failed'"
],
"fixes": [
"Plus de hardware reboots repetes a chaque cycle de 60s quand la SIM est physiquement injoignable (economie courant + usure transistor GPIO 16)"
],
"compatibility": []
},
"notes": "Quand la sequence PDP echoue, le script verifie maintenant AT+CPIN? et AT+CCID pour diagnostiquer si le probleme vient de la SIM ou du reseau. Si SIM absente: notification claire + arret propre, pas de reboot. Si SIM presente: comportement actuel (reboot hardware). Le check ne s'execute qu'en cas d'erreur, zero impact sur le happy path."
},
{
"version": "1.7.0",
"date": "2026-04-27",
"changes": {
"features": [
"Page modem: section Tests Miotiq (UDP) visible uniquement avec carte SIM Miotiq",
"Test 1 — Verification connexion PDP: detecte si PDP deja actif, active automatiquement si besoin",
"Test 2 — Test socket UDP: cree/connecte/envoie/ferme un socket vers 192.168.0.20:4242",
"Test 3 — Ping aller-retour Miotiq: envoie payload ping (command=0x02), ecoute reponse descendante via API Miotiq sendToDevice"
],
"improvements": [
"Sections Test HTTP et Send message masquees automatiquement en mode Miotiq (pas d'acces internet)",
"Script check PDP user-friendly: affiche IP si deja actif, pas d'erreurs inutiles",
"Logs AT bruts accessibles via bouton collapse sur tous les tests Miotiq",
"Payload UDP: byte 9 recycle de protocol_version (redondant avec bytes 69-71) en champ command",
"Ping Miotiq: socket binde sur port fixe 33333 + mode non-connecte (AT+USOST/USORF) pour recevoir le downlink"
],
"fixes": [],
"compatibility": [
"Necessite mise a jour du parser Miotiq: renommer version en command (byte 9)",
"Byte 9: 0x00=data (nouveaux capteurs), 0x01=data (anciens capteurs, retrocompat), 0x02=ping test",
"Necessite configuration serveur: reponse descendante Miotiq sur dstPort=33333"
]
},
"notes": "La page modem s'adapte au type de carte SIM. En mode Miotiq, les tests HTTP sont remplaces par des tests UDP dedies avec 3 niveaux: connexion PDP, socket UDP, et ping aller-retour bidirectionnel via l'API descendante Miotiq (valide en ~2s)."
},
{
"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",
"changes": {
"features": [
"Page WiFi: bouton Oublier le reseau pour passer en mode hotspot sans reboot",
"Page WiFi: badge Mode Hotspot visible dans la sidebar (lien vers page WiFi)",
"Page WiFi: scan des reseaux WiFi en mode hotspot via cache CSV (scan au demarrage)"
],
"improvements": [
"Page WiFi: refonte UI avec cards contextuelles (infos connexion detaillees si connecte, scan si hotspot)",
"Page WiFi: affichage SSID, signal, IP, passerelle, hostname, frequence, securite",
"Page WiFi: scan WiFi masque quand deja connecte, scan avec colonnes signal et securite",
"Page WiFi: migration de config.json vers get_config_sqlite",
"Endpoint internet enrichi: SSID, signal, frequence, securite, passerelle, hostname",
"Scan WiFi en mode hotspot: lecture du fichier wifi_list.csv avec notice explicative",
"forget_wifi.sh: scan WiFi avec rescan explicite et delai avant lancement hotspot"
],
"fixes": [
"Correction VERSION 1.4.3 -> 1.4.4",
"Fix IP hotspot: 192.168.43.1 -> 10.42.0.1 (defaut NetworkManager)",
"Fix forget_wifi.sh: appel bash explicite + disconnect wlan0 avant delete"
],
"compatibility": []
},
"notes": "Le bouton Oublier le reseau supprime la connexion WiFi sauvegardee, scanne les reseaux disponibles, puis demarre le hotspot (pas de reboot necessaire). En mode hotspot, la page WiFi affiche les reseaux scannes au demarrage via un cache CSV. Adresse hotspot: http://10.42.0.1/html/"
},
{
"version": "1.4.4",
"date": "2026-03-16",
"changes": {
"features": [
"Bouton Self Test disponible sur les pages Accueil, Capteurs et Admin (en plus de Modem 4G)",
"Test du module RTC DS3231 integre dans le self-test (connexion + synchronisation horloge)"
],
"improvements": [
"Refactoring self-test : code JS et HTML des modals extraits dans des fichiers partages (selftest.js, selftest-modal.html)",
"Le modal self-test est charge dynamiquement via fetch, plus besoin de dupliquer le HTML"
],
"fixes": [],
"compatibility": []
},
"notes": "Le self-test est maintenant accessible depuis toutes les pages principales. Le test RTC verifie la connexion du module et l'ecart avec l'heure systeme."
},
{
"version": "1.4.3",
"date": "2026-03-16",
"changes": {
"features": [
"Page database: bouton telecharger toute la table (bypass filtre dates)",
"Page database: validation obligatoire des dates avant telechargement par periode"
],
"improvements": [
"Payload UDP bruit: bytes 22-23 = noise_cur_leq, 24-25 = noise_cur_level, 26-27 = max_noise (reserve)",
"Envoi des deux valeurs bruit (cur_LEQ + DB_A_value) en UDP Miotiq au lieu d'une seule"
],
"fixes": [],
"compatibility": [
"Necessite mise a jour du parser Miotiq pour decoder les nouveaux champs noise_cur_leq et noise_cur_level"
]
},
"notes": "Mise a jour structure UDP bruit pour alignement avec parser Miotiq et ameliorations page database."
},
{
"version": "1.4.2",
"date": "2026-03-14",
"changes": {
"features": [],
"improvements": [],
"fixes": [
"Fix envoi UDP Miotiq: desynchronisation serie causant l'envoi de la commande AT+USOWR comme payload au lieu des donnees capteurs",
"Ajout flush buffer serie (reset_input_buffer) avant chaque etape UDP critique",
"Verification du prompt @ du modem avant envoi des donnees binaires",
"Abort propre de l'envoi UDP si creation socket, connexion ou prompt @ echoue",
"Retry creation socket apres reset PDP reussi"
],
"compatibility": []
},
"notes": "Corrige un bug ou le modem SARA envoyait la commande AT+USOWR comme donnees UDP, causant des erreurs UNKNOWN_DEVICE sur le parser Miotiq."
},
{
"version": "1.4.1",
"date": "2026-03-12",
"changes": {
"features": [],
"improvements": [
"Migration capteur bruit de l'ancien systeme I2C vers le sonometre NSRT MK4 en USB",
"Nouveau script sound_meter/read.py pour lecture a la demande (retour JSON)",
"Page capteurs: carte USB avec affichage LEQ et dB(A) au lieu de l'ancien format texte",
"Self-test modem: parsing JSON du NSRT MK4 au lieu de texte brut"
],
"fixes": [
"Correction du self-test bruit qui affichait 'Unexpected value' avec le nouveau capteur"
],
"compatibility": []
},
"notes": "Mise a jour necessaire si le sonometre NSRT MK4 est connecte en USB. L'ancien capteur I2C n'est plus supporte sur la page capteurs."
},
{
"version": "1.4.0",
"date": "2026-03-10",
"changes": {
"features": [
"Mise a jour firmware hors-ligne par upload de fichier ZIP via l'interface web admin",
"Barre de progression pour suivre l'upload du fichier",
"Fichier .update-exclude versionne pour gerer les exclusions rsync de maniere evolutive"
],
"improvements": [
"Vidage du buffer serie avant chaque commande AT dans sara.py (evite les URCs residuelles au demarrage)"
],
"fixes": [],
"compatibility": [
"Necessite l'ajout de update_firmware_from_file.sh dans les permissions sudo de www-data",
"Necessite Apache mod_rewrite pour html/.htaccess (upload 50MB)"
]
},
"notes": "Permet la mise a jour du firmware sans connexion internet : telecharger le .zip depuis Gitea, se connecter au hotspot du capteur, et uploader via admin.html."
},
{
"version": "1.3.0",
"date": "2026-02-17",
"changes": {
"features": [
"Onglet 'Ecran' pour le controle de l'affichage HDMI (ModuleAir Pro uniquement)",
"Demarrage et arret du script d'affichage via l'interface web",
"Verification automatique du type d'appareil pour afficher l'onglet"
],
"improvements": [
"Ajout de logs console pour le debougage des commandes web",
"Traduction de l'element de menu 'Ecran'"
],
"fixes": [
"Correction des permissions d'execution des scripts python via web (sudo)",
"Correction de la visibilite des onglets du menu lateral (doublons ID)"
],
"compatibility": [
"Necessite python3-kivy installe",
"Necessite l'ajout de permissions sudo pour www-data (voir documentation)"
]
},
"notes": "Ajout de la fonctionnalite de controle d'ecran pour les demonstrations."
},
{
"version": "1.2.0",
"date": "2026-02-17",
"changes": {
"features": [
"Integration capteur CO2 MH-Z19 (scripts, base de donnees, service systemd, interface web)",
"Carte test CO2 sur la page capteurs",
"Checkbox activation CO2 sur la page admin",
"Consultation et telechargement des mesures CO2 sur la page base de donnees"
],
"improvements": [],
"fixes": [
"Logo ModuleAir Pro ne s'affichait pas (script dans innerHTML non execute)"
],
"compatibility": [
"Necessite re-execution de create_db.py, set_config.py et setup_services.sh apres mise a jour"
]
},
"notes": "Ajout du support capteur CO2 MH-Z19 pour le ModuleAir Pro. La transmission SARA sera integree dans une version ulterieure."
},
{
"version": "1.1.0",
"date": "2026-02-16",
"changes": {
"features": [
"Card informations base de donnees (taille, nombre d'entrees, dates min/max par table)",
"Telechargement CSV complet par table depuis la page base de donnees",
"Bouton version firmware NextPM sur la page capteurs",
"Tests capteurs integres dans l'auto-test modem",
"Logo dynamique selon le type d'appareil (NebuleAir/ModuleAir)"
],
"improvements": [
"Reordonnancement de l'auto-test : capteurs avant communication"
],
"fixes": [],
"compatibility": []
},
"notes": "Ameliorations de l'interface web : meilleure visibilite sur l'etat de la base de donnees et des capteurs."
},
{
"version": "1.0.0",
"date": "2026-02-11",
"changes": {
"features": [
"Support multi-device : NebuleAir Pro / ModuleAir Pro",
"Systeme de versioning firmware",
"Changelog viewer dans l'interface web"
],
"improvements": [],
"fixes": [],
"compatibility": [
"Les capteurs existants sont automatiquement configures en 'nebuleair_pro'"
]
},
"notes": "Premiere version tracee. Les capteurs anterieurs recevront device_type=nebuleair_pro par defaut lors de la mise a jour."
}
]
}

5
old/config.json.dist → config.json.dist Normal file → Executable file
View File

@@ -5,11 +5,8 @@
"RTC/save_to_db.py": true, "RTC/save_to_db.py": true,
"BME280/get_data_v2.py": true, "BME280/get_data_v2.py": true,
"envea/read_value_v2.py": false, "envea/read_value_v2.py": false,
"MPPT/read.py": false,
"windMeter/read.py": false,
"sqlite/flush_old_data.py": true, "sqlite/flush_old_data.py": true,
"deviceID": "XXXX", "deviceID": "XXXX",
"npm_5channel": false,
"latitude_raw": 0, "latitude_raw": 0,
"longitude_raw":0, "longitude_raw":0,
"latitude_precision": 0, "latitude_precision": 0,
@@ -28,7 +25,7 @@
"SARA_R4_general_status": "connected", "SARA_R4_general_status": "connected",
"SARA_R4_SIM_status": "connected", "SARA_R4_SIM_status": "connected",
"SARA_R4_network_status": "connected", "SARA_R4_network_status": "connected",
"SARA_R4_neworkID": 20810, "SARA_R4_neworkID": 0,
"WIFI_status": "connected", "WIFI_status": "connected",
"MQTT_GUI": false, "MQTT_GUI": false,
"send_aircarto": true, "send_aircarto": true,

View File

@@ -1,92 +1,28 @@
#!/bin/bash #!/bin/bash
# Connect wlan0 to a client WiFi network, replacing the local hotspot. echo "-------"
# Called by launcher.php (wifi_connect action) with: $1=SSID $2=PASSWORD echo "Start connexion shell script at $(date)"
# All output is appended to logs/wifi_connect.log by the caller's redirect,
# but we also write timestamped lines here to make the log self-contained.
WIFI_LOG="/var/www/nebuleair_pro_4g/logs/wifi_connect.log"
DB="/var/www/nebuleair_pro_4g/sqlite/sensors.db"
log_wc() { #disable hotspot
local ts echo "Disable Hotspot:"
ts=$(date '+%Y-%m-%d %H:%M:%S') sudo nmcli connection down Hotspot
echo "[$ts] [connexion.sh] $*" sleep 10
}
log_wc "================================================================" echo "Start connection with:"
log_wc "=== connexion.sh started ===" echo "SSID: $1"
log_wc "PID=$$ user=$(whoami)" echo "Password: $2"
log_wc "SSID='$1' (len=${#1})" sudo nmcli device wifi connect "$1" password "$2"
log_wc "PASS=[HIDDEN] (len=${#2})"
if [ -z "$1" ]; then #check if connection is successfull
log_wc "ERROR: empty SSID, aborting" if [ $? -eq 0 ]; then
exit 1 echo "Connection to $1 is successfull"
fi
if [ -z "$2" ]; then
log_wc "ERROR: empty PASSWORD, aborting"
exit 1
fi
# Get deviceName for the hotspot fallback SSID (with busy timeout against lock)
DEVICE_NAME=$(sqlite3 -cmd ".timeout 5000" "$DB" "SELECT value FROM config_table WHERE key='deviceName'")
log_wc "deviceName from DB: '$DEVICE_NAME'"
# Fallback SSID derived from RPi serial if DB read failed or value is empty
if [ -z "$DEVICE_NAME" ]; then
serial_number=$(cat /proc/cpuinfo | grep Serial | awk '{print substr($3, length($3) - 7)}')
DEVICE_NAME="nebuleair-pro-$serial_number"
log_wc "WARN: deviceName empty in DB, fallback to '$DEVICE_NAME'"
fi
# Snapshot NM state BEFORE we touch anything (helps debug)
log_wc "--- NetworkManager state BEFORE ---"
log_wc "wlan0 state: $(nmcli -g GENERAL.STATE device show wlan0 2>&1)"
log_wc "wlan0 connection: $(nmcli -g GENERAL.CONNECTION device show wlan0 2>&1)"
log_wc "active connections:"
nmcli -t -f NAME,TYPE,DEVICE connection show --active 2>&1 | while read -r line; do log_wc " $line"; done
# Find and bring down any active wireless connection on wlan0 (the hotspot)
ACTIVE_HOTSPOT=$(nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep ':802-11-wireless:wlan0' | cut -d: -f1)
if [ -n "$ACTIVE_HOTSPOT" ]; then
log_wc "Disabling active wireless connection on wlan0: '$ACTIVE_HOTSPOT'"
DOWN_OUT=$(sudo nmcli connection down "$ACTIVE_HOTSPOT" 2>&1)
DOWN_RC=$?
log_wc "nmcli connection down rc=$DOWN_RC out: $DOWN_OUT"
else else
log_wc "No active wireless connection on wlan0 (nothing to bring down)" echo "Connection to $1 failed"
echo "Restarting hotspot..."
#enable hotspot
sudo nmcli connection up Hotspot
fi fi
echo "End connexion shell script"
echo "-------"
log_wc "Sleeping 5s to let NM release wlan0..."
sleep 5
log_wc "--- Attempting nmcli wifi connect ---"
log_wc "Command: sudo nmcli device wifi connect '$1' password [HIDDEN]"
NMCLI_OUT=$(sudo nmcli device wifi connect "$1" password "$2" 2>&1)
NMCLI_RC=$?
log_wc "nmcli exit code: $NMCLI_RC"
log_wc "nmcli output: $NMCLI_OUT"
# Snapshot NM state AFTER attempt
log_wc "--- NetworkManager state AFTER ---"
log_wc "wlan0 state: $(nmcli -g GENERAL.STATE device show wlan0 2>&1)"
log_wc "wlan0 connection: $(nmcli -g GENERAL.CONNECTION device show wlan0 2>&1)"
if [ $NMCLI_RC -eq 0 ]; then
log_wc "SUCCESS: connected to '$1'"
sqlite3 -cmd ".timeout 5000" "$DB" "UPDATE config_table SET value='connected' WHERE key='WIFI_status'"
log_wc "DB updated: WIFI_status = connected"
else
log_wc "FAILURE: connection to '$1' failed (rc=$NMCLI_RC), restarting hotspot..."
# Recreate hotspot with deviceName (or fallback) as SSID
HS_OUT=$(sudo nmcli device wifi hotspot ifname wlan0 ssid "$DEVICE_NAME" password nebuleaircfg 2>&1)
HS_RC=$?
log_wc "nmcli hotspot rc=$HS_RC out: $HS_OUT"
sqlite3 -cmd ".timeout 5000" "$DB" "UPDATE config_table SET value='hotspot' WHERE key='WIFI_status'"
log_wc "DB updated: WIFI_status = hotspot"
log_wc "Hotspot restarted with SSID: '$DEVICE_NAME'"
fi
log_wc "=== connexion.sh end ==="

View File

@@ -4,10 +4,4 @@
@reboot sleep 30 && /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/reboot/start.py >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1 @reboot sleep 30 && /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/reboot/start.py >> /var/www/nebuleair_pro_4g/logs/app.log 2>&1
#0 0 * * * > /var/www/nebuleair_pro_4g/logs/master.log 0 0 * * * > /var/www/nebuleair_pro_4g/logs/master.log
#0 0 * * * > /var/www/nebuleair_pro_4g/logs/master_errors.log
#0 0 * * * > /var/www/nebuleair_pro_4g/logs/app.log
0 0 * * * find /var/www/nebuleair_pro_4g/logs -name "*.log" -type f -exec truncate -s 0 {} \;

View File

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

View File

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

View File

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

View File

View File

View File

@@ -8,9 +8,7 @@
Gather data from envea Sensors and store them to the SQlite table Gather data from envea Sensors and store them to the SQlite table
Use the RTC time for the timestamp Use the RTC time for the timestamp
This script is run by a service nebuleair-envea-data.service /usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_value_v2.py
/usr/bin/python3 /var/www/nebuleair_pro_4g/envea/read_value_v2.py -d
""" """
@@ -20,59 +18,41 @@ import time
import traceback import traceback
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
import sys
# Set DEBUG to True to enable debug prints, False to disable
DEBUG = False # Change this to False to disable debug output
# You can also control debug via command line argument
if len(sys.argv) > 1 and sys.argv[1] in ['--debug', '-d']:
DEBUG = True
elif len(sys.argv) > 1 and sys.argv[1] in ['--quiet', '-q']:
DEBUG = False
def debug_print(message):
"""Print debug messages only if DEBUG is True"""
if DEBUG:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] {message}")
debug_print("=== ENVEA Sensor Reader Started ===")
# Connect to the SQLite database # Connect to the SQLite database
try: conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db")
conn = sqlite3.connect("/var/www/nebuleair_pro_4g/sqlite/sensors.db") cursor = conn.cursor()
cursor = conn.cursor()
except Exception as e:
debug_print(f"✗ Failed to connect to database: {e}")
sys.exit(1)
# GET RTC TIME from SQlite #GET RTC TIME from SQlite
try: cursor.execute("SELECT * FROM timestamp_table LIMIT 1")
cursor.execute("SELECT * FROM timestamp_table LIMIT 1") row = cursor.fetchone() # Get the first (and only) row
row = cursor.fetchone() # Get the first (and only) row rtc_time_str = row[1] # '2025-02-07 12:30:45'
rtc_time_str = row[1] # '2025-02-07 12:30:45'
except Exception as e:
debug_print(f"✗ Failed to get RTC time: {e}")
rtc_time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
debug_print(f" Using system time instead: {rtc_time_str}")
# Fetch connected ENVEA sondes from SQLite config table # Function to load config data
try: def load_config(config_file):
cursor.execute("SELECT port, name, coefficient FROM envea_sondes_table WHERE connected = 1") try:
connected_envea_sondes = cursor.fetchall() # List of tuples (port, name, coefficient) with open(config_file, 'r') as file:
debug_print(f"✓ Found {len(connected_envea_sondes)} connected ENVEA sensors") config_data = json.load(file)
for port, name, coefficient in connected_envea_sondes: return config_data
debug_print(f" - {name}: port={port}, coefficient={coefficient}") except Exception as e:
except Exception as e: print(f"Error loading config file: {e}")
debug_print(f"✗ Failed to fetch connected sensors: {e}") return {}
connected_envea_sondes = []
# Define the config file path
config_file = '/var/www/nebuleair_pro_4g/config.json'
# Load the configuration data
config = load_config(config_file)
# Initialize sensors and serial connections
envea_sondes = config.get('envea_sondes', [])
connected_envea_sondes = [sonde for sonde in envea_sondes if sonde.get('connected', False)]
serial_connections = {} serial_connections = {}
if connected_envea_sondes: if connected_envea_sondes:
debug_print("\n--- Opening Serial Connections ---") for device in connected_envea_sondes:
for port, name, coefficient in connected_envea_sondes: port = device.get('port', 'Unknown')
name = device.get('name', 'Unknown')
try: try:
serial_connections[name] = serial.Serial( serial_connections[name] = serial.Serial(
port=f'/dev/{port}', port=f'/dev/{port}',
@@ -82,132 +62,60 @@ if connected_envea_sondes:
bytesize=serial.EIGHTBITS, bytesize=serial.EIGHTBITS,
timeout=1 timeout=1
) )
debug_print(f"✓ Opened serial port for {name} on /dev/{port}")
except serial.SerialException as e: except serial.SerialException as e:
debug_print(f"Error opening serial port for {name}: {e}") print(f"Error opening serial port for {name}: {e}")
else:
debug_print("! No connected ENVEA sensors found in configuration")
# Initialize sensor data variables global data_h2s, data_no2, data_o3
global data_h2s, data_no2, data_o3, data_co, data_nh3, data_so2
data_h2s = 0 data_h2s = 0
data_no2 = 0 data_no2 = 0
data_o3 = 0 data_o3 = 0
data_co = 0 data_co = 0
data_nh3 = 0 data_nh3 = 0
data_so2 = 0
try: try:
if connected_envea_sondes: if connected_envea_sondes:
debug_print("\n--- Reading Sensor Data ---") for device in connected_envea_sondes:
for port, name, coefficient in connected_envea_sondes: name = device.get('name', 'Unknown')
coefficient = device.get('coefficient', 1)
if name in serial_connections: if name in serial_connections:
serial_connection = serial_connections[name] serial_connection = serial_connections[name]
try: try:
debug_print(f"Reading from {name}...") serial_connection.write(
b"\xFF\x02\x13\x30\x01\x02\x03\x04\x05\x06\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x12\xAF\x88\x03"
calculated_value = None )
max_retries = 3 data_envea = serial_connection.readline()
if len(data_envea) >= 20:
for attempt in range(max_retries): byte_20 = data_envea[19] * coefficient
# Flush input buffer to clear any stale data
serial_connection.reset_input_buffer()
# Send command to sensor
command = b"\xFF\x02\x13\x30\x01\x02\x03\x04\x05\x06\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x12\xAF\x88\x03"
serial_connection.write(command)
if attempt == 0:
debug_print(f" → Sent command: {command.hex()}")
# Wait for sensor response
time.sleep(0.8)
# Read all available data from buffer
bytes_available = serial_connection.in_waiting
debug_print(f" ← Attempt {attempt + 1}: {bytes_available} bytes available")
if bytes_available > 0:
data_envea = serial_connection.read(bytes_available)
else:
data_envea = serial_connection.read(32)
if len(data_envea) > 0:
debug_print(f" ← Received {len(data_envea)} bytes: {data_envea.hex()}")
# Find frame start (0xFF 0x02) in received data
frame_start = -1
for i in range(len(data_envea) - 1):
if data_envea[i] == 0xFF and data_envea[i + 1] == 0x02:
frame_start = i
break
if frame_start >= 0:
frame_data = data_envea[frame_start:]
if len(frame_data) >= 20:
byte_20 = frame_data[19]
calculated_value = byte_20 * coefficient
debug_print(f" → Found valid frame at position {frame_start}")
debug_print(f" → Byte 20 = {byte_20} × {coefficient} = {calculated_value}")
break # Success, exit retry loop
debug_print(f" ✗ Attempt {attempt + 1} failed, {'retrying...' if attempt < max_retries - 1 else 'giving up'}")
time.sleep(0.2)
if calculated_value is not None:
if name == "h2s": if name == "h2s":
data_h2s = calculated_value data_h2s = byte_20
elif name == "no2": elif name == "no2":
data_no2 = calculated_value data_no2 = byte_20
elif name == "o3": elif name == "o3":
data_o3 = calculated_value data_o3 = byte_20
elif name == "co":
data_co = calculated_value
elif name == "nh3":
data_nh3 = calculated_value
elif name == "so2":
data_so2 = calculated_value
debug_print(f"{name.upper()} = {calculated_value}")
else:
debug_print(f" ✗ Failed to read {name} after {max_retries} attempts")
except serial.SerialException as e: except serial.SerialException as e:
debug_print(f"Error communicating with {name}: {e}") print(f"Error communicating with {name}: {e}")
else:
debug_print(f"! No serial connection available for {name}")
except Exception as e: except Exception as e:
debug_print(f"\nAn error occurred while gathering data: {e}") print("An error occurred while gathering data:", e)
traceback.print_exc() traceback.print_exc()
# Display all collected data
debug_print(f"\n--- Collected Sensor Data ---")
debug_print(f"H2S: {data_h2s} ppb")
debug_print(f"NO2: {data_no2} ppb")
debug_print(f"O3: {data_o3} ppb")
debug_print(f"CO: {data_co} ppb")
debug_print(f"NH3: {data_nh3} ppb")
debug_print(f"SO2: {data_so2} ppb")
# Save to sqlite database #print(f" H2S: {data_h2s}, NO2: {data_no2}, O3: {data_o3}")
#save to sqlite database
try: try:
cursor.execute(''' cursor.execute('''
INSERT INTO data_envea (timestamp, h2s, no2, o3, co, nh3, so2) VALUES (?,?,?,?,?,?,?)''' INSERT INTO data_envea (timestamp,h2s, no2, o3, co, nh3) VALUES (?,?,?,?,?,?)'''
, (rtc_time_str, data_h2s, data_no2, data_o3, data_co, data_nh3, data_so2)) , (rtc_time_str,data_h2s,data_no2,data_o3,data_co,data_nh3 ))
# Commit and close the connection # Commit and close the connection
conn.commit() conn.commit()
except Exception as e:
debug_print(f"✗ Database error: {e}")
traceback.print_exc()
# Close serial connections #print("Sensor data saved successfully!")
if serial_connections:
for name, connection in serial_connections.items(): except Exception as e:
try: print(f"Database error: {e}")
connection.close()
except:
pass
conn.close() conn.close()
debug_print("\n=== ENVEA Sensor Reader Finished ===\n")

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

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

344
html/config.html Normal file
View File

@@ -0,0 +1,344 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NebuleAir - Config Editor</title>
<link rel="stylesheet" href="assets/css/bootstrap.min.css">
<style>
body {
overflow-x: hidden;
}
#sidebar a.nav-link {
position: relative;
display: flex;
align-items: center;
}
#sidebar a.nav-link:hover {
background-color: rgba(0, 0, 0, 0.5);
}
#sidebar a.nav-link svg {
margin-right: 8px; /* Add spacing between icons and text */
}
#sidebar {
transition: transform 0.3s ease-in-out;
}
.offcanvas-backdrop {
z-index: 1040;
}
#jsonEditor {
width: 100%;
min-height: 400px;
font-family: monospace;
font-size: 14px;
border: 1px solid #ccc;
padding: 10px;
white-space: pre;
}
.password-popup {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2000;
justify-content: center;
align-items: center;
}
.password-container {
background-color: white;
padding: 20px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
width: 300px;
}
</style>
</head>
<body>
<!-- Topbar -->
<span id="topbar"></span>
<!-- Sidebar Offcanvas for Mobile -->
<div class="offcanvas offcanvas-start text-white bg-dark" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="sidebarOffcanvasLabel">NebuleAir</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body" id="sidebar_mobile">
</div>
</div>
<div class="container-fluid mt-5">
<div class="row">
<!-- Side bar -->
<aside class="col-md-2 col-lg-1 d-none d-md-block vh-100 position-fixed bg-dark text-white" id="sidebar">
</aside>
<!-- Main content -->
<main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4">Configuration Editor</h1>
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-warning">
<strong>Warning:</strong> Editing the configuration file directly can affect system functionality.
Make changes carefully and ensure valid JSON format.
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">config.json</h5>
<div>
<button id="editBtn" class="btn btn-primary me-2">Edit</button>
<button id="saveBtn" class="btn btn-success me-2" disabled>Save</button>
<button id="cancelBtn" class="btn btn-secondary" disabled>Cancel</button>
</div>
</div>
<div class="card-body">
<div id="jsonEditor" class="mb-3" readonly></div>
<div id="errorMsg" class="alert alert-danger" style="display:none;"></div>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Password Modal -->
<div class="password-popup" id="passwordModal">
<div class="password-container">
<h5>Authentication Required</h5>
<p>Please enter the admin password to edit configuration:</p>
<div class="mb-3">
<input type="password" class="form-control" id="adminPassword" placeholder="Password">
</div>
<div class="mb-3 d-flex justify-content-between">
<button class="btn btn-secondary" id="cancelPasswordBtn">Cancel</button>
<button class="btn btn-primary" id="submitPasswordBtn">Submit</button>
</div>
<div id="passwordError" class="text-danger mt-2" style="display:none;"></div>
</div>
</div>
<!-- JAVASCRIPT -->
<!-- Link Ajax locally -->
<script src="assets/jquery/jquery-3.7.1.min.js"></script>
<!-- Link Bootstrap JS and Popper.js locally -->
<script src="assets/js/bootstrap.bundle.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const elementsToLoad = [
{ id: 'topbar', file: 'topbar.html' },
{ id: 'sidebar', file: 'sidebar.html' },
{ id: 'sidebar_mobile', file: 'sidebar.html' }
];
elementsToLoad.forEach(({ id, file }) => {
fetch(file)
.then(response => response.text())
.then(data => {
const element = document.getElementById(id);
if (element) {
element.innerHTML = data;
}
})
.catch(error => console.error(`Error loading ${file}:`, error));
});
});
// Add admin password (should be changed to something more secure)
const ADMIN_PASSWORD = "nebuleair123";
// Global variables for editor
let originalConfig = '';
let jsonEditor;
let editBtn;
let saveBtn;
let cancelBtn;
let passwordModal;
let adminPassword;
let submitPasswordBtn;
let cancelPasswordBtn;
let passwordError;
let errorMsg;
// Initialize DOM references after document is loaded
function initializeElements() {
jsonEditor = document.getElementById('jsonEditor');
editBtn = document.getElementById('editBtn');
saveBtn = document.getElementById('saveBtn');
cancelBtn = document.getElementById('cancelBtn');
passwordModal = document.getElementById('passwordModal');
adminPassword = document.getElementById('adminPassword');
submitPasswordBtn = document.getElementById('submitPasswordBtn');
cancelPasswordBtn = document.getElementById('cancelPasswordBtn');
passwordError = document.getElementById('passwordError');
errorMsg = document.getElementById('errorMsg');
}
// Load config file
function loadConfigFile() {
fetch('../config.json')
.then(response => response.text())
.then(data => {
originalConfig = data;
// Format JSON for display with proper indentation
try {
const jsonObj = JSON.parse(data);
const formattedJSON = JSON.stringify(jsonObj, null, 2);
jsonEditor.textContent = formattedJSON;
} catch (e) {
jsonEditor.textContent = data;
console.error("Error parsing JSON:", e);
}
})
.catch(error => {
console.error('Error loading config.json:', error);
jsonEditor.textContent = "Error loading configuration file.";
});
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
// Initialize DOM elements
initializeElements();
// Load config file
loadConfigFile();
// Edit button
editBtn.addEventListener('click', function() {
passwordModal.style.display = 'flex';
adminPassword.value = ''; // Clear password field
passwordError.style.display = 'none';
adminPassword.focus();
});
// Password submit button
submitPasswordBtn.addEventListener('click', function() {
if (adminPassword.value === ADMIN_PASSWORD) {
passwordModal.style.display = 'none';
enableEditing();
} else {
passwordError.textContent = 'Invalid password';
passwordError.style.display = 'block';
}
});
// Enter key for password
adminPassword.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
submitPasswordBtn.click();
}
});
// Cancel password button
cancelPasswordBtn.addEventListener('click', function() {
passwordModal.style.display = 'none';
});
// Save button
saveBtn.addEventListener('click', function() {
saveConfig();
});
// Cancel button
cancelBtn.addEventListener('click', function() {
cancelEditing();
});
});
// Enable editing mode
function enableEditing() {
jsonEditor.setAttribute('contenteditable', 'true');
jsonEditor.focus();
jsonEditor.classList.add('border-primary');
editBtn.disabled = true;
saveBtn.disabled = false;
cancelBtn.disabled = false;
}
// Cancel editing
function cancelEditing() {
jsonEditor.setAttribute('contenteditable', 'false');
jsonEditor.classList.remove('border-primary');
jsonEditor.textContent = originalConfig;
// Reformat JSON
try {
const jsonObj = JSON.parse(originalConfig);
const formattedJSON = JSON.stringify(jsonObj, null, 2);
jsonEditor.textContent = formattedJSON;
} catch (e) {
jsonEditor.textContent = originalConfig;
}
editBtn.disabled = false;
saveBtn.disabled = true;
cancelBtn.disabled = true;
errorMsg.style.display = 'none';
}
// Save config
function saveConfig() {
const newConfig = jsonEditor.textContent;
// Validate JSON
try {
JSON.parse(newConfig);
// Send to server
$.ajax({
url: 'launcher.php',
method: 'POST',
data: {
type: 'save_config',
config: newConfig
},
dataType: 'json',
success: function(response) {
if (response.success) {
originalConfig = newConfig;
jsonEditor.setAttribute('contenteditable', 'false');
jsonEditor.classList.remove('border-primary');
editBtn.disabled = false;
saveBtn.disabled = true;
cancelBtn.disabled = true;
// Show success message
errorMsg.textContent = 'Configuration saved successfully!';
errorMsg.classList.remove('alert-danger');
errorMsg.classList.add('alert-success');
errorMsg.style.display = 'block';
// Hide success message after 3 seconds
setTimeout(() => {
errorMsg.style.display = 'none';
}, 3000);
} else {
errorMsg.textContent = 'Error saving configuration: ' + response.message;
errorMsg.classList.remove('alert-success');
errorMsg.classList.add('alert-danger');
errorMsg.style.display = 'block';
}
},
error: function(xhr, status, error) {
errorMsg.textContent = 'Error saving configuration: ' + error;
errorMsg.classList.remove('alert-success');
errorMsg.classList.add('alert-danger');
errorMsg.style.display = 'block';
}
});
} catch (e) {
errorMsg.textContent = 'Invalid JSON format: ' + e.message;
errorMsg.classList.remove('alert-success');
errorMsg.classList.add('alert-danger');
errorMsg.style.display = 'block';
}
}
</script>
</body>
</html>

View File

@@ -26,13 +26,6 @@
.offcanvas-backdrop { .offcanvas-backdrop {
z-index: 1040; z-index: 1040;
} }
/* Highlight most recent data row with light green background */
.table .most-recent-row td {
background-color: #d4edda !important;
}
.table-striped .most-recent-row td {
background-color: #d4edda !important;
}
</style> </style>
</head> </head>
<body> <body>
@@ -56,151 +49,73 @@
</aside> </aside>
<!-- Main content --> <!-- Main content -->
<main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4"> <main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4" data-i18n="database.title">Base de données</h1> <h1 class="mt-4">Base de données</h1>
<p data-i18n="database.description">Le capteur enregistre en local les données de mesures. Vous pouvez ici les consulter et les télécharger.</p> <p>Le capteur enregistre en local les données de mesures. Vous pouvez ici les consulter et les télécharger.</p>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-lg-4 col-md-6 mb-3"> <div class="col-sm-5">
<div class="card text-dark bg-light h-100"> <div class="card text-dark bg-light">
<div class="card-body"> <div class="card-body">
<h5 class="card-title" data-i18n="database.viewDatabase">Consulter la base de donnée</h5> <h5 class="card-title">Consulter la base de donnée</h5>
<p class="text-muted small">Ouvre les 20 dernières mesures, navigation page par page.</p> <!-- Dropdown to select number of records -->
<button class="btn btn-primary mb-2" onclick="openTableModal('data_NPM','Mesures PM')" data-i18n="database.pmMeasures">Mesures PM</button> <div class="d-flex align-items-center mb-3">
<button class="btn btn-primary mb-2" onclick="openTableModal('data_BME280','Mesures Temp/Hum')" data-i18n="database.tempHumMeasures">Mesures Temp/Hum</button> <label for="records_limit" class="form-label me-2">Nombre de mesures:</label>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_NPM_5channels','Mesures PM (5 canaux)')" data-i18n="database.pm5Channels">Mesures PM (5 canaux)</button> <select id="records_limit" class="form-select w-auto">
<button class="btn btn-primary mb-2" onclick="openTableModal('data_envea','Sonde Cairsens')" data-i18n="database.cairsensProbe">Sonde Cairsens</button> <option value="10" selected>10 dernières</option>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_NOISE','Sonde bruit')" data-i18n="database.noiseProbe">Sonde bruit</button> <option value="20">20 dernières</option>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_WIND','Sonde Vent')" data-i18n="database.windProbe">Sonde Vent</button> <option value="30">30 dernières</option>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_MPPT','Batterie')" data-i18n="database.battery">Batterie</button> </select>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_MHZ19','Mesures CO2 (MH-Z19)')">Mesures CO2 (MH-Z19)</button> </div>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_S88','Mesures CO2 (Senseair S88)')">Mesures CO2 (Senseair S88)</button> <button class="btn btn-primary" onclick="get_data_sqlite('data_NPM',getSelectedLimit(),false)">Mesures PM</button>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_CCS811','Mesures TVOC/eCO2 (CCS811)')">Mesures TVOC/eCO2 (CCS811)</button> <button class="btn btn-primary" onclick="get_data_sqlite('data_BME280',getSelectedLimit(),false)">Mesures Temp/Hum</button>
<button class="btn btn-warning mb-2" onclick="openTableModal('timestamp_table','Timestamp Table')" data-i18n="database.timestampTable">Timestamp Table</button> <button class="btn btn-primary" onclick="get_data_sqlite('data_NPM_5channels',getSelectedLimit(),false)">Mesures PM (5 canaux)</button>
<button class="btn btn-primary" onclick="get_data_sqlite('data_envea',getSelectedLimit(),false)">Sonde Cairsens</button>
<button class="btn btn-warning" onclick="get_data_sqlite('timestamp_table',getSelectedLimit(),false)">Timestamp Table</button>
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-4 col-md-6 mb-3"> <div class="col-sm-5">
<div class="card text-dark bg-light h-100"> <div class="card text-dark bg-light">
<div class="card-body"> <div class="card-body">
<h5 class="card-title" data-i18n="database.downloadData">Télécharger les données</h5> <h5 class="card-title">Télécharger les données</h5>
<!-- Date selection for download --> <!-- Date selection for download -->
<div class="d-flex align-items-center gap-3 mb-3"> <div class="d-flex align-items-center gap-3 mb-3">
<label for="start_date" class="form-label" data-i18n="database.startDate">Date de début:</label> <label for="start_date" class="form-label">Date de début:</label>
<input type="date" id="start_date" class="form-control w-auto"> <input type="date" id="start_date" class="form-control w-auto">
<label for="end_date" class="form-label" data-i18n="database.endDate">Date de fin:</label> <label for="end_date" class="form-label">Date de fin:</label>
<input type="date" id="end_date" class="form-control w-auto"> <input type="date" id="end_date" class="form-control w-auto">
</div> </div>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_NPM')" data-i18n="database.pmMeasures">Mesures PM</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_BME280')" data-i18n="database.tempHumMeasures">Mesures Temp/Hum</button> <button class="btn btn-primary" onclick="get_data_sqlite('data_NPM',10,true, getStartDate(), getEndDate())">Mesures PM</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_NPM_5channels')" data-i18n="database.pm5Channels">Mesures PM (5 canaux)</button> <button class="btn btn-primary" onclick="get_data_sqlite('data_BME280',10,true, getStartDate(), getEndDate())">Mesures Temp/Hum</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_envea')" data-i18n="database.cairsensProbe">Sonde Cairsens</button> <button class="btn btn-primary" onclick="get_data_sqlite('data_NPM_5channels',10,true, getStartDate(), getEndDate())">Mesures PM (5 canaux)</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_NOISE')" data-i18n="database.noiseProbe">Sonde Bruit</button> <button class="btn btn-primary" onclick="get_data_sqlite('data_envea',10,true, getStartDate(), getEndDate())">Sonde Cairsens</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_mppt')" data-i18n="database.battery">Batterie</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_MHZ19')">Mesures CO2 (MH-Z19)</button> </table>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_S88')">Mesures CO2 (Senseair S88)</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_CCS811')">Mesures TVOC/eCO2 (CCS811)</button>
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-4 col-md-6 mb-3"> <div>
<div class="card text-dark bg-light h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.downloadAll">Télécharger toute la table</h5>
<p class="text-muted small" data-i18n="database.downloadAllDesc">Télécharge l'intégralité des données sans filtre de date.</p>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_NPM')" data-i18n="database.pmMeasures">Mesures PM</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_BME280')" data-i18n="database.tempHumMeasures">Mesures Temp/Hum</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_NPM_5channels')" data-i18n="database.pm5Channels">Mesures PM (5 canaux)</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_envea')" data-i18n="database.cairsensProbe">Sonde Cairsens</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_NOISE')" data-i18n="database.noiseProbe">Sonde Bruit</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_MPPT')" data-i18n="database.battery">Batterie</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_MHZ19')">Mesures CO2 (MH-Z19)</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_S88')">Mesures CO2 (Senseair S88)</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_CCS811')">Mesures TVOC/eCO2 (CCS811)</button>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-lg-4 col-md-6 mb-3">
<div class="card text-dark bg-light h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.statsTitle">Informations sur la base</h5>
<div id="db_stats_content">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2" data-i18n="common.loading">Chargement...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-lg-4 col-md-6 mb-3">
<div class="card text-white bg-danger h-100">
<div class="card-body">
<h5 class="card-title" data-i18n="database.dangerZone">Zone dangereuse</h5>
<p class="card-text" data-i18n="database.dangerWarning">Attention: Cette action est irréversible!</p>
<button class="btn btn-dark btn-lg w-100 mb-2" onclick="emptySensorTables()" data-i18n="database.emptyAllTables">Vider toutes les tables de capteurs</button>
<small class="d-block mt-2" data-i18n="database.emptyTablesNote">Note: Les tables de configuration et horodatage seront préservées.</small>
</div>
</div>
</div>
</div>
<div class="row mt-2"> <div class="row mt-2">
<div id="table_data"></div> <div id="table_data"></div>
</div> </div>
</main> </main>
</div> </div>
</div> </div>
<!-- Modal pour consultation des mesures avec pagination -->
<div class="modal fade" id="tableModal" tabindex="-1" aria-labelledby="tableModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="tableModalLabel">Mesures</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="tableModalContent">
<div class="text-center py-3">
<div class="spinner-border" role="status"></div>
<span class="ms-2">Chargement...</span>
</div>
</div>
</div>
<div class="modal-footer justify-content-between">
<div class="text-muted small" id="tableModalRange"></div>
<div class="btn-group">
<button type="button" class="btn btn-outline-primary" id="tableModalPrev" onclick="tableModalChangePage(-1)" disabled>← Précédent</button>
<button type="button" class="btn btn-outline-primary" id="tableModalNext" onclick="tableModalChangePage(1)">Suivant →</button>
</div>
</div>
</div>
</div>
</div>
<!-- JAVASCRIPT --> <!-- JAVASCRIPT -->
<!-- Link Ajax locally --> <!-- Link Ajax locally -->
<script src="assets/jquery/jquery-3.7.1.min.js"></script> <script src="assets/jquery/jquery-3.7.1.min.js"></script>
<!-- Link Bootstrap JS and Popper.js locally --> <!-- Link Bootstrap JS and Popper.js locally -->
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system -->
<script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
@@ -212,19 +127,6 @@
{ id: 'sidebar_mobile', file: 'sidebar.html' } { id: 'sidebar_mobile', file: 'sidebar.html' }
]; ];
let loadedCount = 0;
const totalElements = elementsToLoad.length;
function applyTranslationsWhenReady() {
if (typeof i18n !== 'undefined' && i18n.translations && Object.keys(i18n.translations).length > 0) {
console.log("Applying translations to dynamically loaded content");
i18n.applyTranslations();
} else {
// Retry after a short delay if translations aren't loaded yet
setTimeout(applyTranslationsWhenReady, 100);
}
}
elementsToLoad.forEach(({ id, file }) => { elementsToLoad.forEach(({ id, file }) => {
fetch(file) fetch(file)
.then(response => response.text()) .then(response => response.text())
@@ -233,22 +135,11 @@
if (element) { if (element) {
element.innerHTML = data; element.innerHTML = data;
} }
loadedCount++;
// Re-apply translations after all dynamic content is loaded
if (loadedCount === totalElements) {
applyTranslationsWhenReady();
}
}) })
.catch(error => console.error(`Error loading ${file}:`, error)); .catch(error => console.error(`Error loading ${file}:`, error));
}); });
// Also listen for language change events to re-apply translations
document.addEventListener('languageChanged', function() {
console.log("Language changed, re-applying translations");
i18n.applyTranslations();
});
}); });
@@ -256,219 +147,198 @@
window.onload = function() { window.onload = function() {
fetch('../config.json') // Replace 'deviceID.txt' with 'config.json'
.then(response => response.json()) // Parse response as JSON
.then(data => {
console.log("Getting config file (onload)");
//get device ID
const deviceID = data.deviceID.trim().toUpperCase();
//document.getElementById('pageTitle_plus_ID').innerText = 'token: ' + deviceID;
//get device Name
const deviceName = data.deviceName;
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = deviceName;
});
//NEW way to get data from SQLITE //get local RTC
$.ajax({ $.ajax({
url: 'launcher.php?type=get_config_sqlite', url: 'launcher.php?type=RTC_time',
dataType:'json', dataType: 'text', // Specify that you expect a JSON response
//dataType: 'json', // Specify that you expect a JSON response method: 'GET', // Use GET or POST depending on your needs
method: 'GET', // Use GET or POST depending on your needs success: function(response) {
success: function(response) { console.log("Local RTC: " + response);
console.log("Getting SQLite config table:"); const RTC_Element = document.getElementById("RTC_time");
console.log(response); RTC_Element.textContent = response;
},
//get device Name (for the side bar) error: function(xhr, status, error) {
const deviceName = response.deviceName; console.error('AJAX request failed:', status, error);
const elements = document.querySelectorAll('.sideBar_sensorName'); }
elements.forEach((element) => {
element.innerText = deviceName;
}); });
//device name html page title })
if (response.deviceName) { .catch(error => console.error('Error loading config.json:', error));
document.title = response.deviceName; }
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
}); //end ajax
// Get database table stats
loadDbStats();
//get local RTC // TABLE PM
function get_data_sqlite(table, limit, download , startDate = "", endDate = "") {
console.log(`Getting data for table: ${table}, limit: ${limit}, download: ${download}, start: ${startDate}, end: ${endDate}`);
// Construct URL parameters dynamically
let url = `launcher.php?type=table_mesure&table=${table}&limit=${limit}&download=${download}`;
// Add date parameters if downloading
if (download) {
url += `&start_date=${startDate}&end_date=${endDate}`;
}
console.log(url);
$.ajax({ $.ajax({
url: 'launcher.php?type=RTC_time', url: url,
dataType: 'text', // Specify that you expect a JSON response dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs method: 'GET', // Use GET or POST depending on your needs
success: function(response) { success: function(response) {
console.log("Local RTC: " + response); console.log(response);
const RTC_Element = document.getElementById("RTC_time");
RTC_Element.textContent = response;
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
}); //end AJAX
// If download is true, generate and trigger CSV download
if (download) {
downloadCSV(response, table);
return; // Exit function after triggering download
}
let rows = response.trim().split("\n");
// Generate Bootstrap table
} let tableHTML = `<table class="table table-striped table-bordered">
<thead class="table-dark"><tr>`;
// Define column headers dynamically based on the table type
if (table === "data_NPM") {
tableHTML += `
<th>Timestamp</th>
<th>PM1</th>
<th>PM2.5</th>
<th>PM10</th>
<th>Temperature (°C)</th>
<th>Humidity (%)</th>
`;
} else if (table === "data_BME280") {
tableHTML += `
<th>Timestamp</th>
<th>Temperature (°C)</th>
<th>Humidity (%)</th>
<th>Pressure (hPa)</th>
`;
} else if (table === "data_NPM_5channels") {
tableHTML += `
<th>Timestamp</th>
<th>PM_ch1 (nb/L)</th>
<th>PM_ch2 (nb/L)</th>
<th>PM_ch3 (nb/L)</th>
<th>PM_ch4 (nb/L)</th>
<th>PM_ch5 (nb/L)</th>
`;
}else if (table === "data_envea") {
tableHTML += `
<th>Timestamp</th>
<th>NO2</th>
<th>H2S</th>
<th>NH3</th>
<th>CO</th>
<th>O3</th>
// Build the <th> header cells for a given table `;
function buildTableHeader(table) { }else if (table === "timestamp_table") {
const headers = { tableHTML += `
data_NPM: ['Timestamp','PM1','PM2.5','PM10','Temperature (°C)','Humidity (%)','Status'], <th>Timestamp</th>
data_BME280: ['Timestamp','Temperature (°C)','Humidity (%)','Pressure (hPa)'], `;
data_NPM_5channels: ['Timestamp','PM_ch1 (nb/L)','PM_ch2 (nb/L)','PM_ch3 (nb/L)','PM_ch4 (nb/L)','PM_ch5 (nb/L)'], }
data_envea: ['Timestamp','NO2','H2S','NH3','CO','O3'],
timestamp_table: ['Timestamp'],
data_WIND: ['Timestamp','speed (km/h)','Direction (V)'],
data_MPPT: ['Timestamp','Battery Voltage','Battery Current','solar_voltage','solar_power','charger_status'],
data_NOISE: ['Timestamp','Curent LEQ','DB_A_value','Status'],
data_MHZ19: ['Timestamp','CO2 (ppm)'],
data_S88: ['Timestamp','CO2 (ppm)','Status'],
data_CCS811: ['Timestamp','eCO2 (ppm)','TVOC (ppb)']
};
return (headers[table] || ['Data']).map(h => `<th>${h}</th>`).join('');
}
// Build the <td> cells for one row of a given table tableHTML += `</tr></thead><tbody>`;
function buildTableRow(table, columns) {
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>`;
return `<td>${columns[0]}</td><td>${columns[1]}</td><td>${columns[2]}</td><td>${columns[3]}</td><td>${columns[4]}</td><td>${columns[5]}</td><td>${statusBadge}</td>`;
}
if (table === "data_NOISE") {
const nStatus = parseInt(columns[3]) || 0;
const nStatusLabel = nStatus === 255 ? '❌ Déconnecté' : '✅ OK';
return `<td>${columns[0]}</td><td>${columns[1]}</td><td>${columns[2]}</td><td>${nStatusLabel}</td>`;
}
if (table === "data_S88") {
const sStatus = parseInt(columns[2]) || 0;
const sStatusLabel = sStatus === 255 ? '❌ Déconnecté' : '✅ OK';
return `<td>${columns[0]}</td><td>${columns[1]}</td><td>${sStatusLabel}</td>`;
}
if (table === "timestamp_table") {
return `<td>${columns[1]}</td>`;
}
// Default: render all available columns
const colCount = { data_BME280: 4, data_NPM_5channels: 6, data_envea: 6, data_WIND: 3, data_MPPT: 6, data_MHZ19: 2, data_S88: 3, data_CCS811: 3 };
const n = colCount[table] || columns.length;
return columns.slice(0, n).map(c => `<td>${c}</td>`).join('');
}
// Loop through rows and create table rows
rows.forEach(row => {
let columns = row.replace(/[()]/g, "").split(", "); // Remove parentheses and split
tableHTML += "<tr>";
// Modal pagination state if (table === "data_NPM") {
const TABLE_MODAL_PAGE_SIZE = 20; tableHTML += `
let tableModalState = { table: null, title: null, page: 0 }; <td>${columns[0]}</td>
<td>${columns[1]}</td>
function openTableModal(table, title) { <td>${columns[2]}</td>
tableModalState = { table, title, page: 0 }; <td>${columns[3]}</td>
document.getElementById('tableModalLabel').textContent = title; <td>${columns[4]}</td>
const modalEl = document.getElementById('tableModal'); <td>${columns[5]}</td>
const modal = bootstrap.Modal.getOrCreateInstance(modalEl); `;
modal.show(); } else if (table === "data_BME280") {
loadTableModalPage(); tableHTML += `
} <td>${columns[0]}</td>
<td>${columns[1]}</td>
function tableModalChangePage(delta) { <td>${columns[2]}</td>
tableModalState.page = Math.max(0, tableModalState.page + delta); <td>${columns[3]}</td>
loadTableModalPage(); `;
} }
else if (table === "data_NPM_5channels") {
function loadTableModalPage() { tableHTML += `
const { table, page } = tableModalState; <td>${columns[0]}</td>
const offset = page * TABLE_MODAL_PAGE_SIZE; <td>${columns[1]}</td>
const url = `launcher.php?type=table_mesure&table=${table}&limit=${TABLE_MODAL_PAGE_SIZE}&offset=${offset}&download=false`; <td>${columns[2]}</td>
<td>${columns[3]}</td>
document.getElementById('tableModalContent').innerHTML = <td>${columns[4]}</td>
'<div class="text-center py-3"><div class="spinner-border" role="status"></div><span class="ms-2">Chargement…</span></div>'; <td>${columns[5]}</td>
document.getElementById('tableModalPrev').disabled = true;
document.getElementById('tableModalNext').disabled = true; `;
} else if (table === "data_envea") {
$.ajax({ tableHTML += `
url: url, <td>${columns[0]}</td>
dataType: 'text', <td>${columns[1]}</td>
method: 'GET', <td>${columns[2]}</td>
success: function(response) { <td>${columns[3]}</td>
const lines = response.trim().split('\n').filter(l => l.length > 0); <td>${columns[4]}</td>
<td>${columns[5]}</td>
if (lines.length === 0) {
if (page === 0) { `;
document.getElementById('tableModalContent').innerHTML = }else if (table === "timestamp_table") {
'<div class="text-center py-4 text-muted">Aucune donnée disponible dans cette table.</div>'; tableHTML += `
document.getElementById('tableModalRange').textContent = '—'; <td>${columns[1]}</td>
} else { `;
// Past the end — step back
tableModalState.page--;
loadTableModalPage();
} }
return;
}
let html = `<table class="table table-striped table-bordered mb-0"><thead class="table-dark sticky-top"><tr>${buildTableHeader(table)}</tr></thead><tbody>`; tableHTML += "</tr>";
lines.forEach((line, idx) => {
const columns = line.replace(/[()]/g, '').split(', ');
const rowClass = (idx === 0 && page === 0) ? ' class="most-recent-row"' : '';
html += `<tr${rowClass}>${buildTableRow(table, columns)}</tr>`;
}); });
html += '</tbody></table>';
document.getElementById('tableModalContent').innerHTML = html; tableHTML += `</tbody></table>`;
document.getElementById('tableModalRange').textContent = `Lignes ${offset + 1} ${offset + lines.length}`;
document.getElementById('tableModalPrev').disabled = page === 0; // Update the #table_data div with the generated table
// If we got fewer rows than the page size, we hit the end of the table document.getElementById("table_data").innerHTML = tableHTML;
document.getElementById('tableModalNext').disabled = lines.length < TABLE_MODAL_PAGE_SIZE; },
}, error: function(xhr, status, error) {
error: function(xhr, status, error) { console.error('AJAX request failed:', status, error);
document.getElementById('tableModalContent').innerHTML = }
`<div class="text-danger">Erreur de chargement: ${error}</div>`;
}
}); });
} }
// Legacy: still used by downloadByDate() to fetch CSV via the same endpoint function getSelectedLimit() {
function get_data_sqlite(table, limit, download, startDate = "", endDate = "") { return document.getElementById("records_limit").value;
let url = `launcher.php?type=table_mesure&table=${table}&limit=${limit}&download=${download}`;
if (download) {
url += `&start_date=${startDate}&end_date=${endDate}`;
}
$.ajax({
url: url,
dataType: 'text',
method: 'GET',
success: function(response) {
if (download) {
downloadCSV(response, table);
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});
} }
function getStartDate() { function getStartDate() {
return document.getElementById("start_date").value; return document.getElementById("start_date").value || "2025-01-01"; // Default to a safe date
} }
function getEndDate() { function getEndDate() {
return document.getElementById("end_date").value; return document.getElementById("end_date").value || "2025-12-31"; // Default to a safe date
}
function downloadByDate(table) {
const startDate = getStartDate();
const endDate = getEndDate();
if (!startDate || !endDate) {
alert("Veuillez sélectionner une date de début et une date de fin.");
return;
}
get_data_sqlite(table, 10, true, startDate, endDate);
}
function downloadFullTable(table) {
window.location.href = 'launcher.php?type=download_full_table&table=' + encodeURIComponent(table);
} }
function downloadCSV(response, table) { function downloadCSV(response, table) {
@@ -478,25 +348,13 @@ function downloadCSV(response, table) {
// Add headers based on table type // Add headers based on table type
if (table === "data_NPM") { if (table === "data_NPM") {
csvContent += "TimestampUTC,PM1,PM2.5,PM10,Temperature_sensor,Humidity_sensor,npm_status\n"; csvContent += "TimestampUTC,PM1,PM2.5,PM10,Temperature_sensor,Humidity_sensor\n";
} else if (table === "data_BME280") { } else if (table === "data_BME280") {
csvContent += "TimestampUTC,Temperature (°C),Humidity (%),Pressure (hPa)\n"; csvContent += "TimestampUTC,Temperature (°C),Humidity (%),Pressure (hPa)\n";
} }
else if (table === "data_NPM_5channels") { else if (table === "data_NPM_5channels") {
csvContent += "TimestampUTC,PM_ch1,PM_ch2,PM_ch3,PM_ch4,PM_ch5\n"; csvContent += "TimestampUTC,PM_ch1,PM_ch2,PM_ch3,PM_ch4,PM_ch5\n";
} }
else if (table === "data_NOISE") {
csvContent += "TimestampUTC,Current_LEQ,DB_A_value,noise_status\n";
}
else if (table === "data_MHZ19") {
csvContent += "TimestampUTC,CO2_ppm\n";
}
else if (table === "data_S88") {
csvContent += "TimestampUTC,CO2_ppm,s88_status\n";
}
else if (table === "data_CCS811") {
csvContent += "TimestampUTC,eCO2_ppm,TVOC_ppb\n";
}
// Format rows as CSV // Format rows as CSV
rows.forEach(row => { rows.forEach(row => {
@@ -515,143 +373,6 @@ function downloadCSV(response, table) {
document.body.removeChild(a); document.body.removeChild(a);
} }
// Table display names
const tableDisplayNames = {
'data_NPM': 'PM (NextPM)',
'data_NPM_5channels': 'PM 5 canaux',
'data_BME280': 'Temp/Hum (BME280)',
'data_envea': 'Gaz (Cairsens)',
'data_WIND': 'Vent',
'data_MPPT': 'Batterie (MPPT)',
'data_NOISE': 'Bruit',
'data_MHZ19': 'CO2 (MH-Z19)',
'data_S88': 'CO2 (Senseair S88)',
'data_CCS811': 'TVOC/eCO2 (CCS811)'
};
function loadDbStats() {
$.ajax({
url: 'launcher.php?type=db_table_stats',
dataType: 'json',
method: 'GET',
success: function(response) {
if (!response.success) {
document.getElementById('db_stats_content').innerHTML =
'<div class="alert alert-danger mb-0">' + (response.error || 'Erreur') + '</div>';
return;
}
let html = '<p class="mb-2"><strong data-i18n="database.statsDbSize">Taille totale:</strong> ' + response.size_mb + ' MB</p>';
html += '<div class="table-responsive"><table class="table table-sm table-bordered mb-0">';
html += '<thead class="table-secondary"><tr>';
html += '<th data-i18n="database.statsTable">Table</th>';
html += '<th data-i18n="database.statsCount">Entrées</th>';
html += '<th data-i18n="database.statsOldest">Plus ancienne</th>';
html += '<th data-i18n="database.statsNewest">Plus récente</th>';
html += '<th data-i18n="database.statsDownload">CSV</th>';
html += '</tr></thead><tbody>';
response.tables.forEach(function(t) {
const displayName = tableDisplayNames[t.name] || t.name;
const oldest = t.oldest ? t.oldest.substring(0, 16) : '-';
const newest = t.newest ? t.newest.substring(0, 16) : '-';
const downloadBtn = t.count > 0
? '<a href="launcher.php?type=download_full_table&table=' + t.name + '" class="btn btn-outline-primary btn-sm py-0 px-1" title="Download CSV"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg></a>'
: '-';
html += '<tr>';
html += '<td>' + displayName + '</td>';
html += '<td>' + t.count.toLocaleString() + '</td>';
html += '<td><small>' + oldest + '</small></td>';
html += '<td><small>' + newest + '</small></td>';
html += '<td class="text-center">' + downloadBtn + '</td>';
html += '</tr>';
});
html += '</tbody></table></div>';
html += '<button class="btn btn-outline-secondary btn-sm mt-2" onclick="loadDbStats()" data-i18n="logs.refresh">Refresh</button>';
document.getElementById('db_stats_content').innerHTML = html;
// Re-apply translations if i18n is loaded
if (typeof i18n !== 'undefined' && i18n.translations && Object.keys(i18n.translations).length > 0) {
i18n.applyTranslations();
}
},
error: function(xhr, status, error) {
document.getElementById('db_stats_content').innerHTML =
'<div class="alert alert-danger mb-0">Erreur: ' + error + '</div>';
}
});
}
// Function to empty all sensor tables
function emptySensorTables() {
// Show confirmation dialog
const confirmed = confirm(
"WARNING: This will permanently delete ALL sensor data from the database!\n\n" +
"The following tables will be emptied:\n" +
"- data_NPM\n" +
"- data_NPM_5channels\n" +
"- data_BME280\n" +
"- data_envea\n" +
"- data_WIND\n" +
"- data_MPPT\n" +
"- data_NOISE\n\n" +
"Configuration and timestamp tables will be preserved.\n\n" +
"Are you absolutely sure you want to continue?"
);
if (!confirmed) {
console.log("Empty sensor tables operation cancelled by user");
return;
}
// Show loading message
const tableDataDiv = document.getElementById("table_data");
tableDataDiv.innerHTML = '<div class="alert alert-info">Emptying sensor tables... Please wait...</div>';
// Make AJAX request to empty tables
$.ajax({
url: 'launcher.php?type=empty_sensor_tables',
dataType: 'json',
method: 'GET',
success: function(response) {
console.log("Empty sensor tables response:", response);
if (response.success) {
// Show success message
let message = '<div class="alert alert-success">';
message += '<h5>Success!</h5>';
message += '<p>' + response.message + '</p>';
if (response.tables_processed && response.tables_processed.length > 0) {
message += '<p><strong>Tables emptied:</strong></p><ul>';
response.tables_processed.forEach(table => {
message += `<li>${table.name}: ${table.deleted} records deleted</li>`;
});
message += '</ul>';
}
message += '</div>';
tableDataDiv.innerHTML = message;
} else {
// Show error message
tableDataDiv.innerHTML = `<div class="alert alert-danger">
<h5>Error!</h5>
<p>${response.message || response.error || 'Unknown error occurred'}</p>
</div>`;
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
tableDataDiv.innerHTML = `<div class="alert alert-danger">
<h5>Error!</h5>
<p>Failed to empty sensor tables: ${error}</p>
</div>`;
}
});
}
</script> </script>

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -49,38 +49,26 @@
</aside> </aside>
<!-- Main content --> <!-- Main content -->
<main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4"> <main class="col-md-10 ms-sm-auto col-lg-11 offset-md-2 offset-lg-1 px-md-4">
<h1 class="mt-4" data-i18n="logs.title">Le journal</h1> <h1 class="mt-4">Le journal</h1>
<p data-i18n="logs.description">Le journal des logs permet de savoir si les processus du capteur se déroulent correctement.</p> <p>Le journal des logs permet de savoir si les processus du capteur se déroulent correctement.</p>
<div class="mb-3">
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#wifiLogModal" onclick="loadWifiLog()">
WiFi connect logs
</button>
</div>
<div class="row"> <div class="row">
<!-- card 1 --> <!-- card 1 -->
<div class="col-lg-6 col-12"> <div class="col-lg-6 col-12">
<div class="card" style="height: 80vh;"> <div class="card" style="height: 80vh;">
<div class="card-header"> <div class="card-header">
<span data-i18n="logs.saraLogs">Sara logs</span> Master logs <button type="submit" class="btn btn-secondary btn-sm" onclick="clear_loopLogs()">Clear</button>
<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> <span id="script_running"></span>
</div> </div>
<div class="card-body overflow-auto" id="card_loop_content"> <div class="card-body overflow-auto" id="card_loop_content">
</div> </div>
</div> </div>
</div> </div>
<!-- card 2 --> <!-- card 2 -->
<div class="col-lg-6 col-12"> <div class="col-lg-6 col-12">
<div class="card" style="height: 80vh;"> <div class="card" style="height: 80vh;">
<div class="card-header"> <div class="card-header">
<span data-i18n="logs.bootLogs">Boot logs</span> Boot logs
<button type="submit" class="btn btn-secondary btn-sm" id="refresh-boot-log" data-i18n="logs.refresh">Refresh</button>
</div> </div>
<div class="card-body overflow-auto" id="card_boot_content"> <div class="card-body overflow-auto" id="card_boot_content">
@@ -88,21 +76,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- WiFi connect log modal -->
<div class="modal fade" id="wifiLogModal" tabindex="-1" aria-labelledby="wifiLogModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="wifiLogModalLabel">WiFi connect logs</h5>
<button type="button" class="btn btn-secondary btn-sm ms-auto me-2" onclick="loadWifiLog()">Refresh</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body overflow-auto" id="card_wifi_content" style="max-height: 70vh;">
</div>
</div>
</div>
</div>
</main> </main>
</div> </div>
</div> </div>
@@ -113,9 +86,6 @@
<script src="assets/jquery/jquery-3.7.1.min.js"></script> <script src="assets/jquery/jquery-3.7.1.min.js"></script>
<!-- Link Bootstrap JS and Popper.js locally --> <!-- Link Bootstrap JS and Popper.js locally -->
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<!-- i18n translation system -->
<script src="assets/js/i18n.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
@@ -141,17 +111,65 @@
const boot_card_content = document.getElementById('card_boot_content'); const boot_card_content = document.getElementById('card_boot_content');
//Getting Master logs //Getting Master logs
console.log("Getting SARA logs"); console.log("Getting master logs");
displayLogFile('../logs/sara_service.log', loop_card_content, true, 1000);
console.log("Getting app/boot logs");
displayLogFile('../logs/app.log', boot_card_content, true, 1000);
// Setup master log with refresh button fetch('../logs/master.log')
setupLogRefreshButton('refresh-master-log', '../logs/sara_service.log', 'card_loop_content', 3000); .then((response) => {
console.log("OK");
// Setup boot log with refresh button
setupLogRefreshButton('refresh-boot-log', '../logs/app.log', 'card_boot_content', 300); if (!response.ok) {
throw new Error('Failed to fetch the log file.');
}
return response.text();
})
.then((data) => {
const lines = data.split('\n');
// Format log content
const formattedLog = lines
.map((line) => line.trim()) // Remove extra whitespace
.filter((line) => line) // Remove empty lines
.join('<br>'); // Join formatted lines with line breaks
loop_card_content.innerHTML = `<pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0;">${formattedLog}</pre>`;
loop_card_content.scrollTop = loop_card_content.scrollHeight; // Scroll to the bottom
})
.catch((error) => {
console.error(error);
loop_card_content.textContent = 'Error loading log file.';
});
console.log("Getting app/boot logs");
//Getting App logs
fetch('../logs/app.log')
.then((response) => {
console.log("OK");
if (!response.ok) {
throw new Error('Failed to fetch the log file.');
}
return response.text();
})
.then((data) => {
const lines = data.split('\n');
// Format log content
const formattedLog = lines
.map((line) => line.trim()) // Remove extra whitespace
.filter((line) => line) // Remove empty lines
.join('<br>'); // Join formatted lines with line breaks
boot_card_content.innerHTML = `<pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0;">${formattedLog}</pre>`;
boot_card_content.scrollTop = loop_card_content.scrollHeight; // Scroll to the bottom
})
.catch((error) => {
console.error(error);
boot_card_content.textContent = 'Error loading log file.';
});
}); });
@@ -161,152 +179,41 @@ window.onload = function() {
getModem_busy_status(); getModem_busy_status();
setInterval(getModem_busy_status, 2000); setInterval(getModem_busy_status, 2000);
//NEW way to get config (SQLite) fetch('../config.json') // Replace 'deviceID.txt' with 'config.json'
$.ajax({ .then(response => response.json()) // Parse response as JSON
url: 'launcher.php?type=get_config_sqlite', .then(data => {
dataType:'json', console.log("Getting config file (onload)");
//dataType: 'json', // Specify that you expect a JSON response //get device ID
method: 'GET', // Use GET or POST depending on your needs const deviceID = data.deviceID.trim().toUpperCase();
success: function(response) { // document.getElementById('pageTitle_plus_ID').innerText = 'token: ' + deviceID;
console.log("Getting SQLite config table:"); //get device Name
console.log(response); const deviceName = data.deviceName;
//device name_side bar const elements = document.querySelectorAll('.sideBar_sensorName');
const elements = document.querySelectorAll('.sideBar_sensorName'); elements.forEach((element) => {
elements.forEach((element) => { element.innerText = deviceName;
element.innerText = response.deviceName; });
});
//device name html page title
if (response.deviceName) {
document.title = response.deviceName;
}
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});//end AJAX
//get local RTC //get local RTC
$.ajax({ $.ajax({
url: 'launcher.php?type=RTC_time', url: 'launcher.php?type=RTC_time',
dataType: 'text', // Specify that you expect a JSON response dataType: 'text', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs method: 'GET', // Use GET or POST depending on your needs
success: function(response) { success: function(response) {
console.log("Local RTC: " + response); console.log("Local RTC: " + response);
const RTC_Element = document.getElementById("RTC_time"); const RTC_Element = document.getElementById("RTC_time");
RTC_Element.textContent = response; RTC_Element.textContent = response;
}, },
error: function(xhr, status, error) { error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error); console.error('AJAX request failed:', status, error);
} }
}); });
})
.catch(error => console.error('Error loading config.json:', error));
}
}//end onload
function loadWifiLog() {
const container = document.getElementById('card_wifi_content');
if (!container) return;
container.innerHTML = '<div class="text-center"><i>Loading log file...</i></div>';
fetch('../logs/wifi_connect.log')
.then(response => {
if (response.status === 404) {
container.innerHTML = '<div class="alert alert-info mb-0">'
+ 'Aucun log WiFi pour le moment. '
+ 'Ce journal est créé lors de la première tentative de connexion WiFi depuis le mode hotspot. '
+ 'Reviens ici après avoir cliqué sur "Se connecter" depuis la page WiFi.'
+ '</div>';
return null;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
return response.text();
})
.then(text => {
if (text === null) return;
const lines = text.split('\n');
const tail = lines.slice(-1000).join('\n');
container.innerHTML = `<pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0;">${tail.replace(/</g, '&lt;')}</pre>`;
container.scrollTop = container.scrollHeight;
})
.catch(err => {
container.innerHTML = `<div class="text-danger">Error loading log file: ${err.message}</div>`;
});
}
function displayLogFile(logFilePath, containerElement, scrollToBottom = true, maxLines = 0) {
// Show loading indicator
containerElement.innerHTML = '<div class="text-center"><i>Loading log file...</i></div>';
return fetch(logFilePath)
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch the log file: ${response.status} ${response.statusText}`);
}
return response.text();
})
.then((data) => {
// Split the log into lines
let lines = data.split('\n');
// Apply max lines limit if specified
if (maxLines > 0 && lines.length > maxLines) {
lines = lines.slice(-maxLines); // Get only the last N lines
}
// Format log content
const formattedLog = lines
.map((line) => line.trim()) // Remove extra whitespace
.filter((line) => line) // Remove empty lines
.join('<br>'); // Join formatted lines with line breaks
// Display the formatted log
containerElement.innerHTML = `<pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0;">${formattedLog}</pre>`;
// Scroll to bottom if requested
if (scrollToBottom) {
containerElement.scrollTop = containerElement.scrollHeight;
}
return formattedLog; // Return the formatted log in case the caller needs it
})
.catch((error) => {
console.error(`Error loading log file ${logFilePath}:`, error);
containerElement.innerHTML = `<div class="text-danger">Error loading log file: ${error.message}</div>`;
throw error; // Re-throw the error for the caller to handle if needed
});
}
/**
* Set up a refresh button for a log file
* @param {string} buttonId - ID of the button element
* @param {string} logFilePath - Path to the log file
* @param {string} containerId - ID of the container to display the log in
* @param {number} maxLines - Maximum number of lines to display (0 for all)
*/
function setupLogRefreshButton(buttonId, logFilePath, containerId, maxLines = 0) {
console.log("Refreshing logs");
const button = document.getElementById(buttonId);
const container = document.getElementById(containerId);
if (!button || !container) {
console.error('Button or container element not found');
return;
}
// Initial load
displayLogFile(logFilePath, container, true, maxLines);
// Set up button click handler
button.addEventListener('click', () => {
displayLogFile(logFilePath, container, true, maxLines);
});
}
function clear_loopLogs(){ function clear_loopLogs(){
console.log("Clearing loop logs"); console.log("Clearing loop logs");
@@ -328,32 +235,6 @@ 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() { function getModem_busy_status() {
//console.log("Getting modem busy status"); //console.log("Getting modem busy status");

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -141,8 +141,8 @@
<div class="card-body p-0"> <div class="card-body p-0">
<div class="command-container" id="commandContainer"> <div class="command-container" id="commandContainer">
<div id="terminal">Welcome to NebuleAir Terminal Console <div id="terminal">Welcome to NebuleAir Terminal Console
Type your commands below. Type 'help' for a list of commands. Type your commands below. Type 'help' for a list of commands.
</div> </div>
<input type="text" id="cmdLine" placeholder="Enter command..." disabled> <input type="text" id="cmdLine" placeholder="Enter command..." disabled>
</div> </div>
<div id="errorMsg" class="alert alert-danger m-3" style="display:none;"></div> <div id="errorMsg" class="alert alert-danger m-3" style="display:none;"></div>
@@ -175,7 +175,6 @@
<script src="assets/jquery/jquery-3.7.1.min.js"></script> <script src="assets/jquery/jquery-3.7.1.min.js"></script>
<!-- Link Bootstrap JS and Popper.js locally --> <!-- Link Bootstrap JS and Popper.js locally -->
<script src="assets/js/bootstrap.bundle.js"></script> <script src="assets/js/bootstrap.bundle.js"></script>
<script src="assets/js/topbar-logo.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
@@ -201,34 +200,8 @@
initializeElements(); initializeElements();
}); });
window.onload = function() {
//NEW way to get config (SQLite)
$.ajax({
url: 'launcher.php?type=get_config_sqlite',
dataType:'json',
//dataType: 'json', // Specify that you expect a JSON response
method: 'GET', // Use GET or POST depending on your needs
success: function(response) {
console.log("Getting SQLite config table:");
console.log(response);
//device name_side bar
const elements = document.querySelectorAll('.sideBar_sensorName');
elements.forEach((element) => {
element.innerText = response.deviceName;
});
},
error: function(xhr, status, error) {
console.error('AJAX request failed:', status, error);
}
});//end AJAX
}
// Add admin password (should be changed to something more secure) // Add admin password (should be changed to something more secure)
const ADMIN_PASSWORD = "123plouf"; const ADMIN_PASSWORD = "nebuleair123";
// Global variables // Global variables
let terminal; let terminal;
@@ -365,6 +338,23 @@
return; return;
} }
if (command === 'help') {
terminal.innerHTML += `
Available commands:
help - Show this help message
clear - Clear the terminal
ls [options] - List directory contents
df -h - Show disk usage
free -h - Show memory usage
cat [file] - Display file contents
systemctl - Control system services
ifconfig - Show network configuration
reboot - Reboot the system (use with caution)
[Any other Linux command]\n`;
terminal.scrollTop = terminal.scrollHeight;
return;
}
// Filter dangerous commands // Filter dangerous commands
const dangerousCommands = [ const dangerousCommands = [
@@ -385,24 +375,21 @@
// Execute the command via AJAX // Execute the command via AJAX
$.ajax({ $.ajax({
url: 'launcher.php?type=execute_command', url: 'launcher.php',
method: 'POST', method: 'POST',
dataType:'json',
data: { data: {
type: 'execute_command', type: 'execute_command',
command: command command: command
}, },
success: function(response) { success: function(response) {
console.log(response); if (response.success) {
// Add command output to terminal
if (response.success) { terminal.innerHTML += `<span style="color: #00ff00;">${response.output}</span>\n`;
// Add command output to terminal } else {
terminal.innerHTML += `<span style="color: #00ff00;">${response.output}</span>\n`; terminal.innerHTML += `<span style="color: red;">Error: ${response.message}</span>\n`;
} else { }
terminal.innerHTML += `<span style="color: red;">Error: ${response.message}</span>\n`; terminal.scrollTop = terminal.scrollHeight;
} },
terminal.scrollTop = terminal.scrollHeight;
},
error: function(xhr, status, error) { error: function(xhr, status, error) {
terminal.innerHTML += `<span style="color: red;">Error executing command: ${error}</span>\n`; terminal.innerHTML += `<span style="color: red;">Error executing command: ${error}</span>\n`;
terminal.scrollTop = terminal.scrollHeight; terminal.scrollTop = terminal.scrollHeight;

View File

@@ -2,22 +2,16 @@
<nav class="navbar navbar-dark fixed-top" style="background-color: #8d8d8f;" id="topbar"> <nav class="navbar navbar-dark fixed-top" style="background-color: #8d8d8f;" id="topbar">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="#"> <a class="navbar-brand" href="#">
<img src="assets/img/LogoNebuleAir.png" alt="Logo" height="30" class="d-inline-block align-text-top" id="topbar-logo"> <img src="assets/img/LogoNebuleAir.png" alt="Logo" height="30" class="d-inline-block align-text-top">
</a> </a>
<div class="d-flex"> <div class="d-flex">
<button class="btn btn-outline-light d-md-none me-2" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebarOffcanvas" aria-controls="sidebarOffcanvas" aria-label="Toggle Sidebar"></button> <button class="btn btn-outline-light d-md-none me-2" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebarOffcanvas" aria-controls="sidebarOffcanvas" aria-label="Toggle Sidebar"></button>
<!-- Centered text --> <!-- Centered text -->
<!-- <!--
<span id="pageTitle_plus_ID" class="position-absolute top-50 start-50 translate-middle">Texte au milieu</span> <span id="pageTitle_plus_ID" class="position-absolute top-50 start-50 translate-middle">Texte au milieu</span>
--> -->
<!-- Language Switcher -->
<select class="form-select form-select-sm me-2" id="languageSwitcher" style="width: auto; background-color: #6c757d; color: white; border-color: white;" onchange="i18n.setLanguage(this.value)">
<option value="fr" style="background-color: #6c757d; color: white;">🇫🇷 FR</option>
<option value="en" style="background-color: #6c757d; color: white;">🇬🇧 EN</option>
</select>
<button type="button" class="btn btn-outline-light" id="RTC_time">loading...</button> <button type="button" class="btn btn-outline-light" id="RTC_time">loading...</button>
</div> </div>
</div> </div>

View File

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

0
old/install_software.yaml → install_software.yaml Normal file → Executable file
View File

View File

@@ -23,38 +23,40 @@ fi
# Update and install necessary packages # Update and install necessary packages
info "Updating package list and installing necessary packages..." info "Updating package list and installing necessary packages..."
sudo apt update && sudo apt install -y git gh apache2 sqlite3 php php-sqlite3 python3 python3-pip jq autossh i2c-tools python3-smbus python3-rpi.gpio || error "Failed to install required packages." sudo apt update && sudo apt install -y git gh apache2 php php-sqlite3 python3 python3-pip jq autossh i2c-tools python3-smbus || error "Failed to install required packages."
# Install Python libraries # Install Python libraries
# requirements.txt lives next to this script (the repo isn't cloned to
# /var/www yet at this point), so resolve it relative to the script location.
info "Installing Python libraries..." info "Installing Python libraries..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" sudo pip3 install pyserial requests RPi.GPIO adafruit-circuitpython-bme280 crcmod psutil ntplib pytz --break-system-packages || error "Failed to install Python libraries."
sudo pip3 install -r "$SCRIPT_DIR/requirements.txt" --break-system-packages || error "Failed to install Python libraries."
# Install Tailscale (for remote SSH access via Headscale tailnet) # Ask user if they want to set up SSH keys
info "Installing Tailscale..." read -p "Do you want to set up an SSH key for /var/www? (y/n): " answer
if ! command -v tailscale >/dev/null 2>&1; then answer=${answer,,} # Convert to lowercase
curl -fsSL https://tailscale.com/install.sh | sh || warning "Tailscale install failed. Remote access via tailnet will be unavailable."
if [[ "$answer" == "y" ]]; then
info "Setting up SSH keys..."
sudo mkdir -p /var/www/.ssh
sudo chmod 700 /var/www/.ssh
if [[ ! -f /var/www/.ssh/id_rsa ]]; then
sudo ssh-keygen -t rsa -b 4096 -f /var/www/.ssh/id_rsa -N ""
success "SSH key generated successfully."
else
warning "SSH key already exists. Skipping key generation."
fi
sudo ssh-copy-id -i /var/www/.ssh/id_rsa.pub -p 50221 airlab_server1@aircarto.fr || warning "Failed to copy SSH key. Please check the server connection."
success "SSH setup complete!"
else else
warning "Tailscale already installed. Skipping." warning "Skipping SSH key setup."
fi fi
# Clone the repository (check if it exists first) # Clone the repository (check if it exists first)
REPO_DIR="/var/www/nebuleair_pro_4g" REPO_DIR="/var/www/nebuleair_pro_4g"
if [[ -d "$REPO_DIR" ]]; then if [[ -d "$REPO_DIR" ]]; then
warning "Repository already exists. Will update instead of clone." warning "Repository already exists. Skipping clone."
# Save current directory
local current_dir=$(pwd)
# Navigate to repository directory
cd "$REPO_DIR"
# Stash any local changes
sudo git stash || warning "Failed to stash local changes"
# Pull latest changes
sudo git pull || error "Failed to pull latest changes"
# Return to original directory
cd "$current_dir"
success "Repository updated successfully!"
else else
info "Cloning the NebuleAir Pro 4G repository..." info "Cloning the NebuleAir Pro 4G repository..."
sudo git clone http://gitea.aircarto.fr/PaulVua/nebuleair_pro_4g.git "$REPO_DIR" || error "Failed to clone repository." sudo git clone http://gitea.aircarto.fr/PaulVua/nebuleair_pro_4g.git "$REPO_DIR" || error "Failed to clone repository."
@@ -64,6 +66,7 @@ fi
info "Setting up repository files and permissions..." info "Setting up repository files and permissions..."
sudo mkdir -p "$REPO_DIR/logs" sudo mkdir -p "$REPO_DIR/logs"
sudo touch "$REPO_DIR/logs/app.log" "$REPO_DIR/logs/master.log" "$REPO_DIR/logs/master_errors.log" "$REPO_DIR/wifi_list.csv" sudo touch "$REPO_DIR/logs/app.log" "$REPO_DIR/logs/master.log" "$REPO_DIR/logs/master_errors.log" "$REPO_DIR/wifi_list.csv"
sudo cp "$REPO_DIR/config.json.dist" "$REPO_DIR/config.json"
sudo chmod -R 755 "$REPO_DIR/" sudo chmod -R 755 "$REPO_DIR/"
sudo chown -R www-data:www-data "$REPO_DIR/" sudo chown -R www-data:www-data "$REPO_DIR/"
sudo git config --global core.fileMode false sudo git config --global core.fileMode false
@@ -88,97 +91,31 @@ else
warning "Database creation script not found." warning "Database creation script not found."
fi fi
# Set config
info "Set config..."
if [[ -f "$REPO_DIR/sqlite/set_config.py" ]]; then
sudo /usr/bin/python3 "$REPO_DIR/sqlite/set_config.py" || error "Failed to set config."
success "Databases set configuration successfully."
else
warning "Database set configuration script not found."
fi
# Configure Apache # Configure Apache
info "Configuring Apache..." info "Configuring Apache..."
APACHE_CONF="/etc/apache2/sites-available/000-default.conf" APACHE_CONF="/etc/apache2/sites-available/000-default.conf"
if grep -q "DocumentRoot /var/www/nebuleair_pro_4g" "$APACHE_CONF"; then if grep -q "DocumentRoot /var/www/nebuleair_pro_4g" "$APACHE_CONF"; then
warning "Apache DocumentRoot already set. Skipping." warning "Apache configuration already set. Skipping."
else else
sudo sed -i 's|DocumentRoot /var/www/html|DocumentRoot /var/www/nebuleair_pro_4g|' "$APACHE_CONF" sudo sed -i 's|DocumentRoot /var/www/html|DocumentRoot /var/www/nebuleair_pro_4g|' "$APACHE_CONF"
success "Apache DocumentRoot updated." sudo systemctl reload apache2
success "Apache configuration updated and reloaded."
fi 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) # Add sudo authorization (prevent duplicate entries)
info "Setting up sudo authorization..." info "Setting up sudo authorization..."
SUDOERS_FILE="/etc/sudoers" if ! sudo grep -q "/usr/bin/nmcli" /etc/sudoers; then
echo -e "ALL ALL=(ALL) NOPASSWD: /usr/bin/nmcli, /usr/sbin/reboot\nwww-data ALL=(ALL) NOPASSWD: /usr/bin/git pull\nwww-data ALL=(ALL) NOPASSWD: /usr/bin/ssh\nwww-data ALL=(ALL) NOPASSWD: /usr/bin/python3 *" | sudo tee -a /etc/sudoers > /dev/null
# First, fix any existing syntax errors success "Sudo authorization added."
if sudo visudo -c 2>&1 | grep -q "syntax error"; then
warning "Syntax error detected in sudoers file. Attempting to fix..."
# Remove the problematic line if it exists
sudo sed -i '/www-data ALL=(ALL) NOPASSWD: \/usr\/bin\/python3 \* www-data/d' "$SUDOERS_FILE"
fi
# Add proper sudo rules (each on a separate line)
if ! sudo grep -q "/usr/bin/nmcli" "$SUDOERS_FILE"; then
# Create a temporary file with the new rules
cat <<EOF | sudo tee /tmp/sudoers_additions > /dev/null
# NebuleAir Pro 4G sudo rules
ALL ALL=(ALL) NOPASSWD: /usr/bin/nmcli, /usr/sbin/reboot
www-data ALL=(ALL) NOPASSWD: /usr/bin/git pull
www-data ALL=(ALL) NOPASSWD: /usr/bin/ssh
www-data ALL=(ALL) NOPASSWD: /usr/bin/python3 *
www-data ALL=(ALL) NOPASSWD: /bin/systemctl *
www-data ALL=(ALL) NOPASSWD: /usr/bin/pkill *
www-data ALL=(ALL) NOPASSWD: /var/www/nebuleair_pro_4g/*
www-data ALL=(ALL) NOPASSWD: /usr/bin/tailscale *
EOF
# Validate the temporary file
if sudo visudo -c -f /tmp/sudoers_additions; then
# Append to sudoers if valid
sudo cat /tmp/sudoers_additions >> "$SUDOERS_FILE"
success "Sudo authorization added."
else
error "Failed to add sudo rules - syntax validation failed."
fi
# Clean up
sudo rm -f /tmp/sudoers_additions
else else
warning "Sudo authorization already set. Skipping." warning "Sudo authorization already set. Skipping."
fi fi
# Validate sudoers file after changes # Open all UART serial ports (avoid duplication)
if ! sudo visudo -c; then info "Configuring UART serial ports..."
error "Sudoers file has syntax errors! Please fix manually with 'sudo visudo'"
fi
# Open all UART serial ports and disable HDMI + Bluetooth (avoid duplication)
info "Configuring UART serial ports and disabling Bluetooth to save power..."
if ! grep -q "enable_uart=1" /boot/firmware/config.txt; then if ! grep -q "enable_uart=1" /boot/firmware/config.txt; then
echo -e "\nenable_uart=1\ndtoverlay=uart0\ndtoverlay=uart1\ndtoverlay=uart2\ndtoverlay=uart3\ndtoverlay=uart4\ndtoverlay=uart5\n\n# Disable Bluetooth to save power (~20-30mA)\ndtoverlay=disable-bt" | sudo tee -a /boot/firmware/config.txt > /dev/null echo -e "\nenable_uart=1\ndtoverlay=uart0\ndtoverlay=uart1\ndtoverlay=uart2\ndtoverlay=uart3\ndtoverlay=uart4\ndtoverlay=uart5" | sudo tee -a /boot/firmware/config.txt > /dev/null
success "UART configuration, HDMI and Bluetooth disable added." success "UART configuration added."
else else
warning "UART configuration already set. Skipping." warning "UART configuration already set. Skipping."
fi fi
@@ -192,12 +129,10 @@ info "Enabling I2C ports..."
sudo raspi-config nonint do_i2c 0 sudo raspi-config nonint do_i2c 0
success "I2C ports enabled." success "I2C ports enabled."
# Final sudoers check #creates databases
if sudo visudo -c; then info "Creates sqlites databases..."
success "Sudoers file is valid." /usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/create_db.py
else
error "Sudoers file has errors! System may not function correctly."
fi
# Completion message # Completion message
success "Setup completed successfully!" success "Setup completed successfully!"

View File

@@ -22,89 +22,55 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
if [[ "$EUID" -ne 0 ]]; then if [[ "$EUID" -ne 0 ]]; then
error "This script must be run as root. Use 'sudo ./installation.sh'" error "This script must be run as root. Use 'sudo ./installation.sh'"
fi fi
REPO_DIR="/var/www/nebuleair_pro_4g"
#set up the RTC #set up the RTC
info "Set up the RTC" info "Set up the RTC"
/usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_NTP.py /usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_NTP.py
#Wake up SARA
info "Wake Up SARA"
pinctrl set 16 op
pinctrl set 16 dh
info "Waiting for SARA to wake up..."
for i in {1..8}; do
echo -ne " $i/8 seconds\r"
sleep 1
done
echo -e "\n"
#Check SARA connection (ATI)
info "Check SARA connection (ATI)"
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 ATI 2 || warning "SARA not detected (ATI). Continuing..."
sleep 1
#set up SARA R4 APN #set up SARA R4 APN
#info "Set up Monogoto APN" info "Set up Monogoto APN"
#/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setAPN.py ttyAMA2 data.mono 2 /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara_setAPN.py ttyAMA2 data.mono 2
#activate blue network led on the SARA R4 #activate blue network led on the SARA R4
info "Activate blue LED" info "Activate blue LED"
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UGPIOC=16,2 2 || warning "SARA LED activation failed. Continuing..." /usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+UGPIOC=16,2 2
sleep 1
#get SIM card CCID
info "Get SIM card CCID"
/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+CCID? 2 || warning "SARA CCID read failed. Continuing..."
sleep 1
#get SIM card IMSI
info "Get SIM card IMSI"
imsi_output=$(/usr/bin/python3 /var/www/nebuleair_pro_4g/SARA/sara.py ttyAMA2 AT+CIMI 2 2>&1) || warning "SARA IMSI read failed. Continuing..."
echo "$imsi_output"
# Extract IMSI (15-digit numeric string)
imsi_number=$(echo "$imsi_output" | grep -oP '^\d{15}$' || echo "N/A")
#Connect to network #Connect to network
#info "Connect SARA R4 to network" info "Connect SARA R4 to network"
#python3 /var/www/nebuleair_pro_4g/SARA/sara_connectNetwork.py ttyAMA2 20810 60 python3 /var/www/nebuleair_pro_4g/SARA/sara_connectNetwork.py ttyAMA2 20810 60
# Set up all systemd services (SARA, NPM, BME280, RTC save_to_db, etc.) #Add master_nebuleair.service
# Single source of truth: services/setup_services.sh SERVICE_FILE="/etc/systemd/system/master_nebuleair.service"
info "Setting up systemd services..." info "Setting up systemd service for master_nebuleair..."
if [[ -f "$REPO_DIR/services/setup_services.sh" ]]; then # Create the systemd service file (overwrite if necessary)
sudo chmod +x "$REPO_DIR/services/setup_services.sh" sudo bash -c "cat > $SERVICE_FILE" <<EOF
sudo "$REPO_DIR/services/setup_services.sh" || warning "Failed to set up systemd services" [Unit]
success "Systemd services set up successfully." Description=Master manager for the Python loop scripts
else After=network.target
warning "Systemd services setup script not found."
fi
# Display device information [Service]
echo "" ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/master.py
echo "==========================================" Restart=always
echo -e "${GREEN} Installation Complete!${NC}" User=root
echo "==========================================" WorkingDirectory=/var/www/nebuleair_pro_4g
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/master.log
StandardError=append:/var/www/nebuleair_pro_4g/logs/master_errors.log
# Get Raspberry Pi serial number (last 8 characters) [Install]
serial_number=$(cat /proc/cpuinfo | grep Serial | awk '{print substr($3, length($3) - 7)}' | tr '[:lower:]' '[:upper:]') WantedBy=multi-user.target
echo -e "${BLUE}Device ID (Serial):${NC} $serial_number" EOF
# Display IMSI success "Systemd service file created: $SERVICE_FILE"
echo -e "${BLUE}IMSI:${NC} ${imsi_number:-N/A}"
# Get IP address and make it a clickable link # Reload systemd to recognize the new service
ip_wlan0=$(ip -4 addr show wlan0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' || echo "Not connected") info "Reloading systemd daemon..."
sudo systemctl daemon-reload
if [[ "$ip_wlan0" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then # Enable the service to start on boot
admin_url="http://${ip_wlan0}/html/admin.html" info "Enabling the service to start on boot..."
echo -e "${BLUE}IP Address (wlan0):${NC} ${admin_url}" sudo systemctl enable master_nebuleair.service
else
echo -e "${BLUE}IP Address (wlan0):${NC} $ip_wlan0"
fi
echo "==========================================" # Start the service immediately
info "Starting the service..."
sudo systemctl start master_nebuleair.service

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,333 +0,0 @@
# 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
```
Byte 9 : command (0x00 = data normal, 0x01 = legacy/ancien protocol_version, 0x02 = ping test)
Bytes 0-8 : device_id (8 bytes) + signal_quality (1 byte)
Bytes 10-65 : donnees capteurs
Byte 66 : error_flags (erreurs systeme)
Byte 67 : npm_status (status NextPM)
Byte 68 : device_status (etat general du boitier)
Bytes 69-71 : firmware version (major.minor.patch)
Bytes 72-75 : latitude (uint32, x/1000000-90)
Bytes 76-79 : longitude (uint32, x/1000000-180)
Byte 80 : misc (contexte de mesure)
Bytes 81-82 : CO2 (ISO_17, uint16 ppm — Senseair S88, NDIR ; 0xFFFF = absent, 0 = non mesure)
Bytes 83-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 |
Note: l'absence/defaut du capteur CO2 (S88) n'est PAS signalee par un bit error_flags
(le bit 7 est ambigu vent/CO2). Elle l'est uniquement par la sentinelle `ISO_17 = 0xFFFF`
dans le champ CO2 (bytes 81-82) — voir `udp-miotiq.md`.
### 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).

100
master.py Executable file
View File

@@ -0,0 +1,100 @@
'''
__ __ _
| \/ | __ _ ___| |_ ___ _ __
| |\/| |/ _` / __| __/ _ \ '__|
| | | | (_| \__ \ || __/ |
|_| |_|\__,_|___/\__\___|_|
Master Python script that will trigger other scripts at every chosen time pace
This script is triggered as a systemd service used as an alternative to cronjobs
Attention:
to do -> prevent SARA R4 Script to run again if it's taking more than 60 secs to finish (using a lock file ??)
First time: need to create the service file
--> sudo nano /etc/systemd/system/master_nebuleair.service
⬇️
[Unit]
Description=Master manager for the Python loop scripts
After=network.target
[Service]
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/master.py
Restart=always
User=root
WorkingDirectory=/var/www/nebuleair_pro_4g
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/master.log
StandardError=append:/var/www/nebuleair_pro_4g/logs/master_errors.log
[Install]
WantedBy=multi-user.target
⬆️
Reload systemd (first time after creating the service):
sudo systemctl daemon-reload
Enable (once), start (once and after stopping) and restart (after modification)systemd:
sudo systemctl enable master_nebuleair.service
sudo systemctl start master_nebuleair.service
sudo systemctl restart master_nebuleair.service
Check the service status:
sudo systemctl status master_nebuleair.service
Specific scripts can be disabled with config.json
Exemple: stop gathering data from NPM
Exemple: stop sending data with SARA R4
'''
import time
import threading
import subprocess
import json
import os
# Base directory where scripts are stored
SCRIPT_DIR = "/var/www/nebuleair_pro_4g/"
CONFIG_FILE = "/var/www/nebuleair_pro_4g/config.json"
def load_config():
"""Load the configuration file to determine which scripts to run."""
with open(CONFIG_FILE, "r") as f:
return json.load(f)
def run_script(script_name, interval, delay=0):
"""Run a script in a synchronized loop with an optional start delay."""
script_path = os.path.join(SCRIPT_DIR, script_name) # Build full path
next_run = time.monotonic() + delay # Apply the initial delay
while True:
config = load_config()
if config.get(script_name, True): # Default to True if not found
subprocess.run(["python3", script_path])
# Wait until the next exact interval
next_run += interval
sleep_time = max(0, next_run - time.monotonic()) # Prevent negative sleep times
time.sleep(sleep_time)
# Define scripts and their execution intervals (seconds)
SCRIPTS = [
#("RTC/save_to_db.py", 1, 0), # SAVE RTC time every 1 second, no delay
("NPM/get_data_modbus_v3.py", 10, 0), # Get NPM data (modbus 5 channels) every 10s, with 2s delay
("envea/read_value_v2.py", 10, 0), # Get NPM data (modbus 5 channels) every 10s, with 2s delay
("loop/SARA_send_data_v2.py", 60, 1), # Send data every 60 seconds, with 2s delay
("BME280/get_data_v2.py", 120, 0), # Get BME280 data every 120 seconds, no delay
("sqlite/flush_old_data.py", 86400, 0) # flush old data inside db every day ()
]
# Start threads for enabled scripts
for script_name, interval, delay in SCRIPTS:
thread = threading.Thread(target=run_script, args=(script_name, interval, delay), daemon=True)
thread.start()
# Keep the main script running
while True:
time.sleep(1)

View File

@@ -1,166 +0,0 @@
'''
__ __ _
| \/ | __ _ ___| |_ ___ _ __
| |\/| |/ _` / __| __/ _ \ '__|
| | | | (_| \__ \ || __/ |
|_| |_|\__,_|___/\__\___|_|
Master Python script that will trigger other scripts at every chosen time pace
This script is triggered as a systemd service used as an alternative to cronjobs
Attention:
to do -> prevent SARA R4 Script to run again if it's taking more than 60 secs to finish (using a lock file ??)
First time: need to create the service file
--> sudo nano /etc/systemd/system/master_nebuleair.service
⬇️
[Unit]
Description=Master manager for the Python loop scripts
After=network.target
[Service]
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/master.py
Restart=always
User=root
WorkingDirectory=/var/www/nebuleair_pro_4g
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/master.log
StandardError=append:/var/www/nebuleair_pro_4g/logs/master_errors.log
[Install]
WantedBy=multi-user.target
⬆️
Reload systemd (first time after creating the service):
sudo systemctl daemon-reload
Enable (once), start (once and after stopping) and restart (after modification)systemd:
sudo systemctl enable master_nebuleair.service
sudo systemctl start master_nebuleair.service
sudo systemctl restart master_nebuleair.service
Check the service status:
sudo systemctl status master_nebuleair.service
Specific scripts can be disabled with config.json
Exemple: stop gathering data from NPM
Exemple: stop sending data with SARA R4
'''
import time
import threading
import subprocess
import os
import sqlite3
# Base directory where scripts are stored
SCRIPT_DIR = "/var/www/nebuleair_pro_4g/"
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
# Lock file path for SARA script
SARA_LOCK_FILE = "/var/www/nebuleair_pro_4g/sara_script.lock"
def is_script_enabled(script_name):
"""Check if a script is enabled in the database."""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"SELECT enabled FROM config_scripts_table WHERE script_path = ?",
(script_name,)
)
result = cursor.fetchone()
conn.close()
if result is None:
return True # Default to enabled if not found in database
return bool(result[0])
except Exception:
# If any database error occurs, default to enabled
return True
def create_lock_file():
"""Create a lock file for the SARA script."""
with open(SARA_LOCK_FILE, 'w') as f:
f.write(str(int(time.time())))
def remove_lock_file():
"""Remove the SARA script lock file."""
if os.path.exists(SARA_LOCK_FILE):
os.remove(SARA_LOCK_FILE)
def is_script_locked():
"""Check if the SARA script is currently locked."""
if not os.path.exists(SARA_LOCK_FILE):
return False
# Check if lock is older than 60 seconds (stale)
with open(SARA_LOCK_FILE, 'r') as f:
try:
lock_time = int(f.read().strip())
if time.time() - lock_time > 60:
# Lock is stale, remove it
remove_lock_file()
return False
except ValueError:
# Invalid lock file content
remove_lock_file()
return False
return True
def run_script(script_name, interval, delay=0):
"""Run a script in a synchronized loop with an optional start delay."""
script_path = os.path.join(SCRIPT_DIR, script_name) # Build full path
next_run = time.monotonic() + delay # Apply the initial delay
while True:
if is_script_enabled(script_name):
# Special handling for SARA script to prevent concurrent runs
if script_name == "loop/SARA_send_data_v2.py":
if not is_script_locked():
create_lock_file()
try:
subprocess.run(["python3", script_path], timeout=200)
finally:
remove_lock_file()
else:
# Run other scripts normally
subprocess.run(["python3", script_path])
# Wait until the next exact interval
next_run += interval
sleep_time = max(0, next_run - time.monotonic()) # Prevent negative sleep times
time.sleep(sleep_time)
# Define scripts and their execution intervals (seconds)
SCRIPTS = [
# Format: (script_name, interval_in_seconds, start_delay_in_seconds)
("NPM/get_data_modbus_v3.py", 10, 0), # Get NPM data (modbus 5 channels) every 10s
("envea/read_value_v2.py", 10, 0), # Get Envea data every 10s
("loop/SARA_send_data_v2.py", 60, 1), # Send data every 60 seconds, with 1s delay
("BME280/get_data_v2.py", 120, 0), # Get BME280 data every 120 seconds
("MPPT/read.py", 120, 0), # Get MPPT data every 120 seconds
("sqlite/flush_old_data.py", 86400, 0) # Flush old data inside db every day
]
# Start threads for scripts
for script_name, interval, delay in SCRIPTS:
thread = threading.Thread(target=run_script, args=(script_name, interval, delay), daemon=True)
thread.start()
# Keep the main script running
while True:
time.sleep(1)

View File

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

View File

@@ -1,82 +0,0 @@
#!/usr/bin/env python3
"""
python3 /var/www/nebuleair_pro_4g/power/get_throttled.py
Lit l'état d'alimentation du Raspberry Pi via `vcgencmd get_throttled` et
renvoie un JSON décodé. Utilisé par le Self Test (launcher.php?type=throttled)
pour détecter une sous-tension (cause fréquente de capteurs USB instables,
corruptions SD, reboots).
Doit tourner en root (vcgencmd a besoin de /dev/vcio) : appelé via
`sudo /usr/bin/python3 ...` depuis launcher.php.
Bits de get_throttled (cf. doc Raspberry Pi) :
0 : sous-tension active maintenant
1 : freq ARM bridée maintenant
2 : throttling actif maintenant
3 : limite temperature douce active maintenant
16 : sous-tension survenue depuis le boot
17 : bridage freq ARM survenu depuis le boot
18 : throttling survenu depuis le boot
19 : limite temperature douce survenue depuis le boot
"""
import json
import subprocess
def main():
try:
raw = subprocess.check_output(
['/usr/bin/vcgencmd', 'get_throttled'],
stderr=subprocess.STDOUT,
timeout=5,
).decode('utf-8', errors='ignore').strip()
except FileNotFoundError:
print(json.dumps({"available": False, "error": "vcgencmd introuvable (pas un Raspberry Pi ?)"}))
return
except Exception as e:
print(json.dumps({"available": False, "error": str(e)}))
return
# Sortie attendue : "throttled=0x50000"
if '=' not in raw:
print(json.dumps({"available": False, "error": f"sortie inattendue: {raw}"}))
return
hex_str = raw.split('=', 1)[1].strip()
try:
value = int(hex_str, 16)
except ValueError:
print(json.dumps({"available": False, "error": f"valeur illisible: {raw}"}))
return
flags = {
"under_voltage_now": bool(value & 0x1),
"arm_freq_capped_now": bool(value & 0x2),
"throttled_now": bool(value & 0x4),
"soft_temp_limit_now": bool(value & 0x8),
"under_voltage_occurred": bool(value & 0x10000),
"arm_freq_capped_occurred": bool(value & 0x20000),
"throttling_occurred": bool(value & 0x40000),
"soft_temp_limit_occurred": bool(value & 0x80000),
}
# Niveau de gravite pour le Self Test
if flags["under_voltage_now"] or flags["throttled_now"]:
status = "critical"
message = "Sous-tension ACTIVE — alimentation 5V insuffisante (alim/cable a remplacer)"
elif flags["under_voltage_occurred"] or flags["throttling_occurred"]:
status = "warning"
message = "Sous-tension survenue depuis le demarrage — verifier alim 5V / cable USB"
else:
status = "ok"
message = "Alimentation OK"
result = {"available": True, "raw": hex_str, "value": value, "status": status, "message": message}
result.update(flags)
print(json.dumps(result))
if __name__ == "__main__":
main()

View File

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

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