v1.10.0: intégration capteur CCS811 (TVOC/eCO2, I2C)
Nouveau capteur de qualité d'air CCS811 sur le bus I2C, calqué sur le pattern S88 (local-only, pas encore dans le payload de transmission). - CCS811/get_data.py (lecture live) + write_data.py (timer 10s, self-heal table) - table data_CCS811 (timestamp, eCO2, TVOC) dans create_db.py - config CCS811 (bool) + CCS811_address (0x5A/0x5B, défaut 0x5A) dans set_config.py - service+timer systemd nebuleair-ccs811-data (10s) + ajout boucle d'activation - admin.html: case d'activation + dropdown adresse I2C - sensors.html: carte Get Data (TVOC + eCO2) - database.html + launcher.php: consultation/export/stats data_CCS811 - lib adafruit-circuitpython-ccs811 dans installation_part1.sh - CCS811/README.md: câblage, adresses, warning clock-stretching I2C sur Pi - CLAUDE.md + changelog mis à jour Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
83
CCS811/README.md
Normal file
83
CCS811/README.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 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 400–8192. 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. À valider au bench — sur
|
||||||
|
certains modules/CM4 ça passe à 100 kHz, sur d'autres non.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- `CCS811/get_data.py` — lecture live (bouton "Get Data" du web). Affiche
|
||||||
|
`{"eCO2": <ppm>, "TVOC": <ppb>}` ou `{"error": "..."}`.
|
||||||
|
- `CCS811/write_data.py` — lecture périodique (timer systemd, toutes les 10 s),
|
||||||
|
écrit dans la table `data_CCS811 (timestamp, eCO2, TVOC)`.
|
||||||
|
|
||||||
|
Librairie Python : `adafruit-circuitpython-ccs811` (installée par
|
||||||
|
`installation_part1.sh`). La table est créée par `sqlite/create_db.py` et
|
||||||
|
self-healée par `write_data.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 à chaque lecture
|
||||||
|
pour améliorer la précision. Non fait en v1 pour garder le script simple et autonome.
|
||||||
87
CCS811/get_data.py
Normal file
87
CCS811/get_data.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'''
|
||||||
|
Live read of the AMS CCS811 air-quality sensor (used by the web "Get Data" button).
|
||||||
|
Prints a JSON object: {"eCO2": <int_ppm>, "TVOC": <int_ppb>} or {"error": "<message>"}.
|
||||||
|
|
||||||
|
CCS811 is a MOX gas sensor: it outputs an equivalent CO2 (eCO2, derived from VOCs)
|
||||||
|
and a Total VOC (TVOC). It is NOT an NDIR CO2 sensor like the S88. TVOC is the
|
||||||
|
primary measurement of interest here.
|
||||||
|
|
||||||
|
I2C, library adafruit-circuitpython-ccs811. Address read from config_table
|
||||||
|
(key CCS811_address, e.g. "0x5A" Adafruit / "0x5B" SparkFun), default 0x5A.
|
||||||
|
|
||||||
|
Usage: /usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/get_data.py [address]
|
||||||
|
'''
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
|
||||||
|
DEFAULT_ADDRESS = 0x5A
|
||||||
|
# CCS811 produces a fresh sample every 1 s in drive mode 1. Poll data_ready a few
|
||||||
|
# times to cover the case where the driver was just (re)initialised.
|
||||||
|
DATA_READY_RETRIES = 30
|
||||||
|
DATA_READY_DELAY = 0.2 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def get_address_from_config():
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT value FROM config_table WHERE key = ?", ("CCS811_address",))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row and row[0]:
|
||||||
|
return int(str(row[0]), 16)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return DEFAULT_ADDRESS
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
try:
|
||||||
|
address = int(sys.argv[1], 16)
|
||||||
|
except ValueError:
|
||||||
|
print(json.dumps({"error": f"invalid address {sys.argv[1]}"}))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
address = get_address_from_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import board
|
||||||
|
import busio
|
||||||
|
import adafruit_ccs811
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": f"library import failed: {e}"}))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
i2c = busio.I2C(board.SCL, board.SDA)
|
||||||
|
ccs811 = adafruit_ccs811.CCS811(i2c, address=address)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": f"cannot init CCS811 at {hex(address)}: {e}"}))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
ready = False
|
||||||
|
for _ in range(DATA_READY_RETRIES):
|
||||||
|
if ccs811.data_ready:
|
||||||
|
ready = True
|
||||||
|
break
|
||||||
|
time.sleep(DATA_READY_DELAY)
|
||||||
|
|
||||||
|
if not ready:
|
||||||
|
print(json.dumps({"error": "CCS811 data not ready (warming up?)"}))
|
||||||
|
return
|
||||||
|
|
||||||
|
eco2 = ccs811.eco2
|
||||||
|
tvoc = ccs811.tvoc
|
||||||
|
print(json.dumps({"eCO2": int(eco2), "TVOC": int(tvoc)}))
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": f"CCS811 read error: {e}"}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
98
CCS811/write_data.py
Normal file
98
CCS811/write_data.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'''
|
||||||
|
Script to get air-quality values from the AMS CCS811 sensor and write to database.
|
||||||
|
/usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/write_data.py
|
||||||
|
|
||||||
|
CCS811 is a MOX gas sensor: eCO2 (equivalent CO2 in ppm, derived from VOCs) and
|
||||||
|
TVOC (Total VOC in ppb). TVOC is the primary measurement of interest.
|
||||||
|
|
||||||
|
I2C, library adafruit-circuitpython-ccs811. Address from config_table
|
||||||
|
(key CCS811_address, e.g. "0x5A" / "0x5B"), default 0x5A.
|
||||||
|
'''
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
|
||||||
|
DEFAULT_ADDRESS = 0x5A
|
||||||
|
DATA_READY_RETRIES = 30
|
||||||
|
DATA_READY_DELAY = 0.2 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
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 main():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Self-heal: ensure the table exists 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_CCS811 (
|
||||||
|
timestamp TEXT,
|
||||||
|
eCO2 INTEGER,
|
||||||
|
TVOC INTEGER
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
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
|
||||||
|
import busio
|
||||||
|
import adafruit_ccs811
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CCS811: library import failed: {e}")
|
||||||
|
conn.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
i2c = busio.I2C(board.SCL, board.SDA)
|
||||||
|
ccs811 = adafruit_ccs811.CCS811(i2c, address=address)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CCS811: cannot init at {hex(address)}: {e}")
|
||||||
|
conn.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ready = False
|
||||||
|
for _ in range(DATA_READY_RETRIES):
|
||||||
|
if ccs811.data_ready:
|
||||||
|
ready = True
|
||||||
|
break
|
||||||
|
time.sleep(DATA_READY_DELAY)
|
||||||
|
|
||||||
|
if not ready:
|
||||||
|
print("CCS811: data not ready (warming up?), skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
eco2 = int(ccs811.eco2)
|
||||||
|
tvoc = int(ccs811.tvoc)
|
||||||
|
|
||||||
|
cursor.execute("SELECT last_updated FROM timestamp_table LIMIT 1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
rtc_time_str = row[0]
|
||||||
|
|
||||||
|
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})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CCS811 error: {e}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -23,6 +23,7 @@ NebuleAir Pro 4G is an environmental monitoring system running on Raspberry Pi 4
|
|||||||
- NSRT MK4: Noise sensor via I2C (0x48)
|
- NSRT MK4: Noise sensor via I2C (0x48)
|
||||||
- SARA R4/R5: 4G cellular modem (ttyAMA2)
|
- SARA R4/R5: 4G cellular modem (ttyAMA2)
|
||||||
- Senseair S88: CO2 sensor via Modbus RTU (any free ttyAMA — port configurable, see admin.html)
|
- 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
|
- Wind meter: via ADS1115 ADC
|
||||||
- MPPT: Solar charger monitoring
|
- MPPT: Solar charger monitoring
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ When adding a new UART sensor (e.g. S88), it goes on one of the free NPM connect
|
|||||||
- `NPM/`: NextPM sensor scripts
|
- `NPM/`: NextPM sensor scripts
|
||||||
- `envea/`: Envea sensor scripts
|
- `envea/`: Envea sensor scripts
|
||||||
- `BME280/`: BME280 sensor scripts
|
- `BME280/`: BME280 sensor scripts
|
||||||
|
- `CCS811/`: CCS811 air-quality sensor scripts (TVOC/eCO2, I2C)
|
||||||
- `sound_meter/`: Noise sensor code (C program)
|
- `sound_meter/`: Noise sensor code (C program)
|
||||||
- `SARA/`: 4G modem communication (AT commands)
|
- `SARA/`: 4G modem communication (AT commands)
|
||||||
- `windMeter/`: Wind sensor scripts
|
- `windMeter/`: Wind sensor scripts
|
||||||
@@ -102,6 +104,7 @@ sudo /var/www/nebuleair_pro_4g/services/setup_services.sh
|
|||||||
- `nebuleair-envea-data.timer`: Every 10 seconds (Envea sensors)
|
- `nebuleair-envea-data.timer`: Every 10 seconds (Envea sensors)
|
||||||
- `nebuleair-sara-data.timer`: Every 60 seconds (4G data transmission)
|
- `nebuleair-sara-data.timer`: Every 60 seconds (4G data transmission)
|
||||||
- `nebuleair-bme280-data.timer`: Every 120 seconds (BME280 sensor)
|
- `nebuleair-bme280-data.timer`: Every 120 seconds (BME280 sensor)
|
||||||
|
- `nebuleair-ccs811-data.timer`: Every 10 seconds (CCS811 TVOC/eCO2 sensor)
|
||||||
- `nebuleair-mppt-data.timer`: Every 120 seconds (MPPT charger)
|
- `nebuleair-mppt-data.timer`: Every 120 seconds (MPPT charger)
|
||||||
- `nebuleair-noise-data.timer`: Every 60 seconds (Noise sensor)
|
- `nebuleair-noise-data.timer`: Every 60 seconds (Noise sensor)
|
||||||
- `nebuleair-db-cleanup-data.timer`: Daily (database cleanup)
|
- `nebuleair-db-cleanup-data.timer`: Daily (database cleanup)
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"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",
|
"version": "1.9.19",
|
||||||
"date": "2026-06-01",
|
"date": "2026-06-01",
|
||||||
|
|||||||
@@ -154,6 +154,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" value="" id="check_ccs811" onchange="update_config_sqlite('CCS811', this.checked)">
|
||||||
|
<label class="form-check-label" for="check_ccs811">
|
||||||
|
Send VOC sensor data (CCS811)
|
||||||
|
</label>
|
||||||
|
<div class="mt-2 ms-4" style="max-width: 250px;">
|
||||||
|
<label for="ccs811_address" class="form-label small mb-1">Adresse I2C du capteur CCS811</label>
|
||||||
|
<select class="form-select form-select-sm" id="ccs811_address" onchange="update_config_sqlite('CCS811_address', this.value)">
|
||||||
|
<option value="0x5A">0x5A (Adafruit, ADDR=GND)</option>
|
||||||
|
<option value="0x5B">0x5B (SparkFun, ADDR=VDD)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-check mb-3">
|
<div class="form-check mb-3">
|
||||||
<input class="form-check-input" type="checkbox" value="" id="check_wifi_power_saving" onchange="update_config_sqlite('wifi_power_saving', this.checked)">
|
<input class="form-check-input" type="checkbox" value="" id="check_wifi_power_saving" onchange="update_config_sqlite('wifi_power_saving', this.checked)">
|
||||||
<label class="form-check-label" for="check_wifi_power_saving">
|
<label class="form-check-label" for="check_wifi_power_saving">
|
||||||
@@ -596,6 +610,7 @@ window.onload = function() {
|
|||||||
const checkbox_noise = document.getElementById("check_NOISE");
|
const checkbox_noise = document.getElementById("check_NOISE");
|
||||||
const checkbox_mhz19 = document.getElementById("check_mhz19");
|
const checkbox_mhz19 = document.getElementById("check_mhz19");
|
||||||
const checkbox_s88 = document.getElementById("check_s88");
|
const checkbox_s88 = document.getElementById("check_s88");
|
||||||
|
const checkbox_ccs811 = document.getElementById("check_ccs811");
|
||||||
const checkbox_wifi_power_saving = document.getElementById("check_wifi_power_saving");
|
const checkbox_wifi_power_saving = document.getElementById("check_wifi_power_saving");
|
||||||
|
|
||||||
checkbox_bme.checked = response["BME280"];
|
checkbox_bme.checked = response["BME280"];
|
||||||
@@ -609,6 +624,10 @@ window.onload = function() {
|
|||||||
if (response["S88_port"]) {
|
if (response["S88_port"]) {
|
||||||
document.getElementById("s88_port").value = response["S88_port"];
|
document.getElementById("s88_port").value = response["S88_port"];
|
||||||
}
|
}
|
||||||
|
checkbox_ccs811.checked = response["CCS811"];
|
||||||
|
if (response["CCS811_address"]) {
|
||||||
|
document.getElementById("ccs811_address").value = response["CCS811_address"];
|
||||||
|
}
|
||||||
checkbox_wifi_power_saving.checked = response["wifi_power_saving"];
|
checkbox_wifi_power_saving.checked = response["wifi_power_saving"];
|
||||||
|
|
||||||
checkbox_uSpot.checked = response["send_uSpot"];
|
checkbox_uSpot.checked = response["send_uSpot"];
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
<button class="btn btn-primary mb-2" onclick="openTableModal('data_MPPT','Batterie')" data-i18n="database.battery">Batterie</button>
|
<button class="btn btn-primary mb-2" onclick="openTableModal('data_MPPT','Batterie')" data-i18n="database.battery">Batterie</button>
|
||||||
<button class="btn btn-primary mb-2" onclick="openTableModal('data_MHZ19','Mesures CO2 (MH-Z19)')">Mesures CO2 (MH-Z19)</button>
|
<button class="btn btn-primary mb-2" onclick="openTableModal('data_MHZ19','Mesures CO2 (MH-Z19)')">Mesures CO2 (MH-Z19)</button>
|
||||||
<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 mb-2" onclick="openTableModal('data_S88','Mesures CO2 (Senseair S88)')">Mesures CO2 (Senseair S88)</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-warning mb-2" onclick="openTableModal('timestamp_table','Timestamp Table')" data-i18n="database.timestampTable">Timestamp Table</button>
|
<button class="btn btn-warning mb-2" onclick="openTableModal('timestamp_table','Timestamp Table')" data-i18n="database.timestampTable">Timestamp Table</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,6 +101,7 @@
|
|||||||
<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_mppt')" data-i18n="database.battery">Batterie</button>
|
||||||
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_MHZ19')">Mesures CO2 (MH-Z19)</button>
|
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_MHZ19')">Mesures CO2 (MH-Z19)</button>
|
||||||
<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_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>
|
||||||
@@ -117,6 +119,7 @@
|
|||||||
<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_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_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_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>
|
</div>
|
||||||
@@ -320,7 +323,8 @@ function buildTableHeader(table) {
|
|||||||
data_MPPT: ['Timestamp','Battery Voltage','Battery Current','solar_voltage','solar_power','charger_status'],
|
data_MPPT: ['Timestamp','Battery Voltage','Battery Current','solar_voltage','solar_power','charger_status'],
|
||||||
data_NOISE: ['Timestamp','Curent LEQ','DB_A_value','Status'],
|
data_NOISE: ['Timestamp','Curent LEQ','DB_A_value','Status'],
|
||||||
data_MHZ19: ['Timestamp','CO2 (ppm)'],
|
data_MHZ19: ['Timestamp','CO2 (ppm)'],
|
||||||
data_S88: ['Timestamp','CO2 (ppm)']
|
data_S88: ['Timestamp','CO2 (ppm)'],
|
||||||
|
data_CCS811: ['Timestamp','eCO2 (ppm)','TVOC (ppb)']
|
||||||
};
|
};
|
||||||
return (headers[table] || ['Data']).map(h => `<th>${h}</th>`).join('');
|
return (headers[table] || ['Data']).map(h => `<th>${h}</th>`).join('');
|
||||||
}
|
}
|
||||||
@@ -343,7 +347,7 @@ function buildTableRow(table, columns) {
|
|||||||
return `<td>${columns[1]}</td>`;
|
return `<td>${columns[1]}</td>`;
|
||||||
}
|
}
|
||||||
// Default: render all available columns
|
// 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: 2 };
|
const colCount = { data_BME280: 4, data_NPM_5channels: 6, data_envea: 6, data_WIND: 3, data_MPPT: 6, data_MHZ19: 2, data_S88: 2, data_CCS811: 3 };
|
||||||
const n = colCount[table] || columns.length;
|
const n = colCount[table] || columns.length;
|
||||||
return columns.slice(0, n).map(c => `<td>${c}</td>`).join('');
|
return columns.slice(0, n).map(c => `<td>${c}</td>`).join('');
|
||||||
}
|
}
|
||||||
@@ -485,6 +489,9 @@ function downloadCSV(response, table) {
|
|||||||
else if (table === "data_S88") {
|
else if (table === "data_S88") {
|
||||||
csvContent += "TimestampUTC,CO2_ppm\n";
|
csvContent += "TimestampUTC,CO2_ppm\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 => {
|
||||||
@@ -513,7 +520,8 @@ const tableDisplayNames = {
|
|||||||
'data_MPPT': 'Batterie (MPPT)',
|
'data_MPPT': 'Batterie (MPPT)',
|
||||||
'data_NOISE': 'Bruit',
|
'data_NOISE': 'Bruit',
|
||||||
'data_MHZ19': 'CO2 (MH-Z19)',
|
'data_MHZ19': 'CO2 (MH-Z19)',
|
||||||
'data_S88': 'CO2 (Senseair S88)'
|
'data_S88': 'CO2 (Senseair S88)',
|
||||||
|
'data_CCS811': 'TVOC/eCO2 (CCS811)'
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadDbStats() {
|
function loadDbStats() {
|
||||||
|
|||||||
@@ -794,7 +794,7 @@ if ($type == "db_table_stats") {
|
|||||||
$fileSizeMB = round($fileSizeBytes / (1024 * 1024), 2);
|
$fileSizeMB = round($fileSizeBytes / (1024 * 1024), 2);
|
||||||
|
|
||||||
// Sensor data tables to inspect
|
// Sensor data tables to inspect
|
||||||
$tables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88'];
|
$tables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88', 'data_CCS811'];
|
||||||
|
|
||||||
$tableStats = [];
|
$tableStats = [];
|
||||||
foreach ($tables as $tableName) {
|
foreach ($tables as $tableName) {
|
||||||
@@ -844,7 +844,7 @@ if ($type == "download_full_table") {
|
|||||||
$table = $_GET['table'] ?? '';
|
$table = $_GET['table'] ?? '';
|
||||||
|
|
||||||
// Whitelist of allowed tables
|
// Whitelist of allowed tables
|
||||||
$allowedTables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88'];
|
$allowedTables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88', 'data_CCS811'];
|
||||||
|
|
||||||
if (!in_array($table, $allowedTables)) {
|
if (!in_array($table, $allowedTables)) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -862,7 +862,8 @@ if ($type == "download_full_table") {
|
|||||||
'data_MPPT' => 'TimestampUTC,Battery_voltage,Battery_current,Solar_voltage,Solar_power,Charger_status',
|
'data_MPPT' => 'TimestampUTC,Battery_voltage,Battery_current,Solar_voltage,Solar_power,Charger_status',
|
||||||
'data_NOISE' => 'TimestampUTC,Current_LEQ,DB_A_value',
|
'data_NOISE' => 'TimestampUTC,Current_LEQ,DB_A_value',
|
||||||
'data_MHZ19' => 'TimestampUTC,CO2_ppm',
|
'data_MHZ19' => 'TimestampUTC,CO2_ppm',
|
||||||
'data_S88' => 'TimestampUTC,CO2_ppm'
|
'data_S88' => 'TimestampUTC,CO2_ppm',
|
||||||
|
'data_CCS811' => 'TimestampUTC,eCO2_ppm,TVOC_ppb'
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1016,6 +1017,12 @@ if ($type == "s88") {
|
|||||||
echo $output;
|
echo $output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($type == "ccs811") {
|
||||||
|
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/get_data.py';
|
||||||
|
$output = shell_exec($command);
|
||||||
|
echo $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if ($type == "table_mesure") {
|
if ($type == "table_mesure") {
|
||||||
$table=$_GET['table'];
|
$table=$_GET['table'];
|
||||||
@@ -1674,6 +1681,10 @@ if ($type == "get_systemd_services") {
|
|||||||
'description' => 'Reads CO2 concentration from MH-Z19 sensor',
|
'description' => 'Reads CO2 concentration from MH-Z19 sensor',
|
||||||
'frequency' => 'Every 2 minutes'
|
'frequency' => 'Every 2 minutes'
|
||||||
],
|
],
|
||||||
|
'nebuleair-ccs811-data.timer' => [
|
||||||
|
'description' => 'Reads eCO2/TVOC from CCS811 air-quality sensor',
|
||||||
|
'frequency' => 'Every 10 seconds'
|
||||||
|
],
|
||||||
'nebuleair-s88-data.timer' => [
|
'nebuleair-s88-data.timer' => [
|
||||||
'description' => 'Reads CO2 concentration from Senseair S88 sensor',
|
'description' => 'Reads CO2 concentration from Senseair S88 sensor',
|
||||||
'frequency' => 'Every 10 seconds'
|
'frequency' => 'Every 10 seconds'
|
||||||
@@ -1765,6 +1776,7 @@ if ($type == "restart_systemd_service") {
|
|||||||
'nebuleair-noise-data.timer',
|
'nebuleair-noise-data.timer',
|
||||||
'nebuleair-mhz19-data.timer',
|
'nebuleair-mhz19-data.timer',
|
||||||
'nebuleair-s88-data.timer',
|
'nebuleair-s88-data.timer',
|
||||||
|
'nebuleair-ccs811-data.timer',
|
||||||
'nebuleair-db-cleanup-data.timer',
|
'nebuleair-db-cleanup-data.timer',
|
||||||
'nebuleair-wifi-powersave.timer',
|
'nebuleair-wifi-powersave.timer',
|
||||||
'nebuleair-cpu-power.service',
|
'nebuleair-cpu-power.service',
|
||||||
@@ -1831,6 +1843,7 @@ if ($type == "toggle_systemd_service") {
|
|||||||
'nebuleair-noise-data.timer',
|
'nebuleair-noise-data.timer',
|
||||||
'nebuleair-mhz19-data.timer',
|
'nebuleair-mhz19-data.timer',
|
||||||
'nebuleair-s88-data.timer',
|
'nebuleair-s88-data.timer',
|
||||||
|
'nebuleair-ccs811-data.timer',
|
||||||
'nebuleair-db-cleanup-data.timer',
|
'nebuleair-db-cleanup-data.timer',
|
||||||
'nebuleair-wifi-powersave.timer',
|
'nebuleair-wifi-powersave.timer',
|
||||||
'nebuleair-cpu-power.service',
|
'nebuleair-cpu-power.service',
|
||||||
|
|||||||
@@ -409,6 +409,62 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCCS811_values() {
|
||||||
|
console.log("Data from CCS811 air-quality sensor:");
|
||||||
|
$("#loading_ccs811").show();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: 'launcher.php?type=ccs811',
|
||||||
|
dataType: 'json',
|
||||||
|
method: 'GET',
|
||||||
|
success: function (response) {
|
||||||
|
console.log(response);
|
||||||
|
const tableBody = document.getElementById("data-table-body_ccs811");
|
||||||
|
tableBody.innerHTML = "";
|
||||||
|
$("#loading_ccs811").hide();
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
$("#data-table-body_ccs811").append(`
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-danger">
|
||||||
|
⚠ ${response.error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
if (response.TVOC !== undefined) {
|
||||||
|
$("#data-table-body_ccs811").append(`
|
||||||
|
<tr>
|
||||||
|
<td>TVOC</td>
|
||||||
|
<td>${response.TVOC} ppb</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
if (response.eCO2 !== undefined) {
|
||||||
|
$("#data-table-body_ccs811").append(`
|
||||||
|
<tr>
|
||||||
|
<td>eCO2</td>
|
||||||
|
<td>${response.eCO2} ppm</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function (xhr, status, error) {
|
||||||
|
console.error('AJAX request failed:', status, error);
|
||||||
|
$("#loading_ccs811").hide();
|
||||||
|
const tableBody = document.getElementById("data-table-body_ccs811");
|
||||||
|
tableBody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-danger">
|
||||||
|
⚠ Erreur de communication avec le capteur
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getMHZ19_values() {
|
function getMHZ19_values() {
|
||||||
console.log("Data from MH-Z19 CO2 sensor:");
|
console.log("Data from MH-Z19 CO2 sensor:");
|
||||||
$("#loading_mhz19").show();
|
$("#loading_mhz19").show();
|
||||||
@@ -675,6 +731,29 @@
|
|||||||
container.innerHTML += S88_HTML;
|
container.innerHTML += S88_HTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//creates CCS811 air-quality (eCO2/TVOC) card
|
||||||
|
if (config.CCS811) {
|
||||||
|
const CCS811_HTML = `
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
I2C
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">CCS811 (TVOC / eCO2)</h5>
|
||||||
|
<p class="card-text">Capteur de composés organiques volatils.</p>
|
||||||
|
<button class="btn btn-primary mb-1" onclick="getCCS811_values()" data-i18n="common.getData">Get Data</button>
|
||||||
|
<div id="loading_ccs811" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
|
||||||
|
<table class="table table-striped-columns">
|
||||||
|
<tbody id="data-table-body_ccs811"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
container.innerHTML += CCS811_HTML;
|
||||||
|
}
|
||||||
|
|
||||||
//Si on a des SONDES ENVEA connectée il faut faire un deuxième call dans la table envea_sondes_table
|
//Si on a des SONDES ENVEA connectée il faut faire un deuxième call dans la table envea_sondes_table
|
||||||
//creates ENVEA debug card
|
//creates ENVEA debug card
|
||||||
if (config.envea) {
|
if (config.envea) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ sudo apt update && sudo apt install -y git gh apache2 sqlite3 php php-sqlite3 py
|
|||||||
|
|
||||||
# Install Python libraries
|
# Install Python libraries
|
||||||
info "Installing Python libraries..."
|
info "Installing Python libraries..."
|
||||||
sudo pip3 install pyserial requests adafruit-circuitpython-bme280 crcmod psutil gpiozero ntplib adafruit-circuitpython-ads1x15 nsrt-mk3-dev pytz --break-system-packages || error "Failed to install Python libraries."
|
sudo pip3 install pyserial requests adafruit-circuitpython-bme280 adafruit-circuitpython-ccs811 crcmod psutil gpiozero ntplib adafruit-circuitpython-ads1x15 nsrt-mk3-dev pytz --break-system-packages || error "Failed to install Python libraries."
|
||||||
|
|
||||||
# Install Tailscale (for remote SSH access via Headscale tailnet)
|
# Install Tailscale (for remote SSH access via Headscale tailnet)
|
||||||
info "Installing Tailscale..."
|
info "Installing Tailscale..."
|
||||||
|
|||||||
@@ -269,6 +269,38 @@ AccuracySec=1s
|
|||||||
WantedBy=timers.target
|
WantedBy=timers.target
|
||||||
EOL
|
EOL
|
||||||
|
|
||||||
|
# Create service and timer files for CCS811 air-quality (eCO2/TVOC) Data
|
||||||
|
cat > /etc/systemd/system/nebuleair-ccs811-data.service << 'EOL'
|
||||||
|
[Unit]
|
||||||
|
Description=NebuleAir CCS811 Air-Quality (eCO2/TVOC) Data Collection Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/write_data.py
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/var/www/nebuleair_pro_4g
|
||||||
|
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/ccs811_service.log
|
||||||
|
StandardError=append:/var/www/nebuleair_pro_4g/logs/ccs811_service_errors.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOL
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/nebuleair-ccs811-data.timer << 'EOL'
|
||||||
|
[Unit]
|
||||||
|
Description=Run NebuleAir CCS811 Air-Quality Data Collection every 10 seconds
|
||||||
|
Requires=nebuleair-ccs811-data.service
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=10s
|
||||||
|
OnUnitActiveSec=10s
|
||||||
|
AccuracySec=1s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOL
|
||||||
|
|
||||||
# Create service and timer files for Database Cleanup
|
# Create service and timer files for Database Cleanup
|
||||||
cat > /etc/systemd/system/nebuleair-db-cleanup-data.service << 'EOL'
|
cat > /etc/systemd/system/nebuleair-db-cleanup-data.service << 'EOL'
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -402,7 +434,7 @@ systemctl daemon-reload
|
|||||||
|
|
||||||
# Enable and start all timers
|
# Enable and start all timers
|
||||||
echo "Enabling and starting all services..."
|
echo "Enabling and starting all services..."
|
||||||
for service in npm envea sara bme280 mppt mhz19 s88 db-cleanup noise; do
|
for service in npm envea sara bme280 mppt mhz19 s88 ccs811 db-cleanup noise; do
|
||||||
systemctl enable nebuleair-$service-data.timer
|
systemctl enable nebuleair-$service-data.timer
|
||||||
systemctl start nebuleair-$service-data.timer
|
systemctl start nebuleair-$service-data.timer
|
||||||
echo "Started nebuleair-$service-data timer"
|
echo "Started nebuleair-$service-data timer"
|
||||||
|
|||||||
@@ -168,6 +168,15 @@ CREATE TABLE IF NOT EXISTS data_S88 (
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Create a table CCS811 (AMS CCS811 air-quality sensor: eCO2 + TVOC)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS data_CCS811 (
|
||||||
|
timestamp TEXT,
|
||||||
|
eCO2 INTEGER,
|
||||||
|
TVOC INTEGER
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# Commit and close the connection
|
# Commit and close the connection
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ config_entries = [
|
|||||||
("MHZ19", "0", "bool"),
|
("MHZ19", "0", "bool"),
|
||||||
("S88", "0", "bool"),
|
("S88", "0", "bool"),
|
||||||
("S88_port", "/dev/ttyAMA5", "str"),
|
("S88_port", "/dev/ttyAMA5", "str"),
|
||||||
|
("CCS811", "0", "bool"),
|
||||||
|
("CCS811_address", "0x5A", "str"),
|
||||||
("modem_version", "XXX", "str"),
|
("modem_version", "XXX", "str"),
|
||||||
("device_type", "nebuleair_pro", "str"),
|
("device_type", "nebuleair_pro", "str"),
|
||||||
("language", "fr", "str"),
|
("language", "fr", "str"),
|
||||||
|
|||||||
Reference in New Issue
Block a user