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()
|
||||
Reference in New Issue
Block a user