v1.11.0: CCS811 en daemon + fix filtrage + I2C 10kHz requis
Vérif terrain sur pro100 : à 100 kHz le CCS811 renvoie des valeurs corrompues 0x8000 (32768) par clock-stretching, et le modèle oneshot-reset-toutes-les-10s ne donne que le 1er échantillon post-init (garbage). Refonte : - CCS811/daemon.py: service long-running (Type=simple, Restart=always). Init 1x, boucle lecture/écriture 10s, filtre eCO2 dans [400,8192], re-init auto sur erreurs I2C répétées. Remplace write_data.py (supprimé). - CCS811/get_data.py: lit la dernière ligne data_CCS811 au lieu du capteur (évite la collision I2C avec le daemon -> corruption observée). - setup_services.sh: service daemon + self-heal suppression de l'ancien .timer; activation hors boucle timers. - launcher.php: .timer -> .service (map statut + allowedServices x2). - update_firmware.sh: redémarre le daemon à l'OTA. - doc: README (archi daemon + I2C 10kHz confirmé requis), CLAUDE.md, changelog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,12 @@ mitigation, les lectures échouent typiquement en `OSError` / `Remote I/O error`
|
|||||||
dtparam=i2c_arm_baudrate=10000
|
dtparam=i2c_arm_baudrate=10000
|
||||||
```
|
```
|
||||||
|
|
||||||
(10 kHz au lieu de 100 kHz par défaut.) Reboot ensuite. À valider au bench — sur
|
(10 kHz au lieu de 100 kHz par défaut.) Reboot ensuite.
|
||||||
certains modules/CM4 ça passe à 100 kHz, sur d'autres non.
|
|
||||||
|
**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 :
|
Vérifier la présence du capteur :
|
||||||
|
|
||||||
@@ -64,20 +68,34 @@ sans level-shifter sauf si le module embarque son propre régulateur + shifter.
|
|||||||
|
|
||||||
## Implémentation NebuleAir
|
## Implémentation NebuleAir
|
||||||
|
|
||||||
- `CCS811/get_data.py` — lecture live (bouton "Get Data" du web). Affiche
|
⚠ **Architecture : daemon, PAS un timer oneshot** (contrairement aux autres capteurs).
|
||||||
`{"eCO2": <ppm>, "TVOC": <ppb>}` ou `{"error": "..."}`.
|
Le CCS811 doit être initialisé **une seule fois** puis lu en continu :
|
||||||
- `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
|
- chaque (ré)init fait un reset + app_start, et les premiers échantillons juste après
|
||||||
`installation_part1.sh`). La table est créée par `sqlite/create_db.py` et
|
sont du garbage (eCO2 = 0, ou valeurs 0x8000+ = 32768 dues au clock-stretching) ;
|
||||||
self-healée par `write_data.py` (CREATE TABLE IF NOT EXISTS) — garder les deux
|
- un cycle reset toutes les 10 s empêche l'algorithme de baseline de se construire.
|
||||||
schémas synchro.
|
|
||||||
|
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)".
|
Activation : `admin.html` → case "Send VOC sensor data (CCS811)".
|
||||||
|
|
||||||
### Pistes d'amélioration (non implémentées)
|
### Pistes d'amélioration (non implémentées)
|
||||||
|
|
||||||
Le CCS811 supporte une compensation température/humidité (`SET_ENV_DATA`). Comme le
|
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
|
boîtier embarque déjà un BME280, on pourrait lui pousser temp/hum périodiquement
|
||||||
pour améliorer la précision. Non fait en v1 pour garder le script simple et autonome.
|
pour améliorer la précision. Non fait pour garder le daemon simple.
|
||||||
|
|||||||
143
CCS811/daemon.py
Normal file
143
CCS811/daemon.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
'''
|
||||||
|
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()
|
||||||
@@ -1,90 +1,39 @@
|
|||||||
'''
|
'''
|
||||||
Live read of the AMS CCS811 air-quality sensor (used by the web "Get Data" button).
|
Live value for the web "Get Data" button (CCS811 air-quality sensor).
|
||||||
Prints a JSON object: {"eCO2": <int_ppm>, "TVOC": <int_ppb>} or {"error": "<message>"}.
|
Prints {"eCO2": <ppm>, "TVOC": <ppb>, "timestamp": <str>} or {"error": "<msg>"}.
|
||||||
|
|
||||||
CCS811 is a MOX gas sensor: it outputs an equivalent CO2 (eCO2, derived from VOCs)
|
IMPORTANT: this does NOT read the I2C sensor. The CCS811 is owned by the
|
||||||
and a Total VOC (TVOC). It is NOT an NDIR CO2 sensor like the S88. TVOC is the
|
long-running daemon (CCS811/daemon.py); opening the bus here would collide with
|
||||||
primary measurement of interest here.
|
it and corrupt the sensor. Instead we return the most recent row the daemon
|
||||||
|
stored in data_CCS811. TVOC is the primary measurement.
|
||||||
|
|
||||||
I2C, library adafruit-circuitpython-ccs811. Address read from config_table
|
Usage: /usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/get_data.py
|
||||||
(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 json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
|
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():
|
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:
|
try:
|
||||||
import board
|
conn = sqlite3.connect(DB_PATH, timeout=5)
|
||||||
import busio
|
cursor = conn.cursor()
|
||||||
import adafruit_ccs811
|
cursor.execute(
|
||||||
|
"SELECT timestamp, eCO2, TVOC FROM data_CCS811 ORDER BY timestamp DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(json.dumps({"error": f"library import failed: {e}"}))
|
print(json.dumps({"error": f"DB read error: {e}"}))
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
if not row:
|
||||||
i2c = busio.I2C(board.SCL, board.SDA)
|
print(json.dumps({"error": "No CCS811 data yet (daemon warming up?)"}))
|
||||||
ccs811 = adafruit_ccs811.CCS811(i2c, address=address)
|
|
||||||
except Exception as e:
|
|
||||||
print(json.dumps({"error": f"cannot init CCS811 at {hex(address)}: {e}"}))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
print(json.dumps({"timestamp": row[0], "eCO2": int(row[1]), "TVOC": int(row[2])}))
|
||||||
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 = int(ccs811.eco2)
|
|
||||||
tvoc = int(ccs811.tvoc)
|
|
||||||
# eCO2 floor is 400 ppm; a sub-400 value is a not-yet-settled sample.
|
|
||||||
if eco2 < 400:
|
|
||||||
print(json.dumps({"error": "CCS811 reading not settled (warming up?)"}))
|
|
||||||
return
|
|
||||||
print(json.dumps({"eCO2": eco2, "TVOC": tvoc}))
|
|
||||||
except Exception as e:
|
|
||||||
print(json.dumps({"error": f"CCS811 read error: {e}"}))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
'''
|
|
||||||
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)
|
|
||||||
|
|
||||||
# eCO2 has a physical floor of 400 ppm. Just after the driver (re)inits,
|
|
||||||
# the CCS811 can return a 0/0 sample before its first valid measurement is
|
|
||||||
# ready — those are spurious, drop them (next 10 s tick will retry).
|
|
||||||
if eco2 < 400:
|
|
||||||
print(f"CCS811: reading not settled (eCO2={eco2}), skipping.")
|
|
||||||
return
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -104,7 +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-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-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,23 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"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",
|
"version": "1.10.1",
|
||||||
"date": "2026-06-02",
|
"date": "2026-06-02",
|
||||||
|
|||||||
@@ -1681,9 +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' => [
|
'nebuleair-ccs811-data.service' => [
|
||||||
'description' => 'Reads eCO2/TVOC from CCS811 air-quality sensor',
|
'display_name' => 'CCS811',
|
||||||
'frequency' => 'Every 10 seconds'
|
'description' => 'Reads TVOC/eCO2 from CCS811 air-quality sensor (daemon)',
|
||||||
|
'frequency' => 'Continuous (daemon)'
|
||||||
],
|
],
|
||||||
'nebuleair-s88-data.timer' => [
|
'nebuleair-s88-data.timer' => [
|
||||||
'description' => 'Reads CO2 concentration from Senseair S88 sensor',
|
'description' => 'Reads CO2 concentration from Senseair S88 sensor',
|
||||||
@@ -1776,7 +1777,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-ccs811-data.service',
|
||||||
'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',
|
||||||
@@ -1843,7 +1844,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-ccs811-data.service',
|
||||||
'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',
|
||||||
|
|||||||
@@ -269,15 +269,19 @@ AccuracySec=1s
|
|||||||
WantedBy=timers.target
|
WantedBy=timers.target
|
||||||
EOL
|
EOL
|
||||||
|
|
||||||
# Create service and timer files for CCS811 air-quality (eCO2/TVOC) Data
|
# CCS811 air-quality (eCO2/TVOC) — long-running DAEMON, not a oneshot timer.
|
||||||
|
# The CCS811 must be initialised once and read continuously (a 10s reset cycle
|
||||||
|
# yields garbage right after each init and never lets its baseline build up).
|
||||||
cat > /etc/systemd/system/nebuleair-ccs811-data.service << 'EOL'
|
cat > /etc/systemd/system/nebuleair-ccs811-data.service << 'EOL'
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=NebuleAir CCS811 Air-Quality (eCO2/TVOC) Data Collection Service
|
Description=NebuleAir CCS811 Air-Quality (eCO2/TVOC) Daemon
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=simple
|
||||||
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/write_data.py
|
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/daemon.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=/var/www/nebuleair_pro_4g
|
WorkingDirectory=/var/www/nebuleair_pro_4g
|
||||||
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/ccs811_service.log
|
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/ccs811_service.log
|
||||||
@@ -287,19 +291,13 @@ StandardError=append:/var/www/nebuleair_pro_4g/logs/ccs811_service_errors.log
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOL
|
EOL
|
||||||
|
|
||||||
cat > /etc/systemd/system/nebuleair-ccs811-data.timer << 'EOL'
|
# Self-heal: remove the obsolete oneshot timer from CCS811 v1.10.x (now a daemon).
|
||||||
[Unit]
|
if [ -f /etc/systemd/system/nebuleair-ccs811-data.timer ]; then
|
||||||
Description=Run NebuleAir CCS811 Air-Quality Data Collection every 10 seconds
|
systemctl stop nebuleair-ccs811-data.timer 2>/dev/null
|
||||||
Requires=nebuleair-ccs811-data.service
|
systemctl disable nebuleair-ccs811-data.timer 2>/dev/null
|
||||||
|
rm -f /etc/systemd/system/nebuleair-ccs811-data.timer
|
||||||
[Timer]
|
echo "Removed obsolete nebuleair-ccs811-data.timer (CCS811 is now a daemon)"
|
||||||
OnBootSec=10s
|
fi
|
||||||
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'
|
||||||
@@ -434,12 +432,18 @@ 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 ccs811 db-cleanup noise; do
|
for service in npm envea sara bme280 mppt mhz19 s88 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"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# CCS811 is a long-running daemon (service, not timer)
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable nebuleair-ccs811-data.service
|
||||||
|
systemctl restart nebuleair-ccs811-data.service
|
||||||
|
echo "Started nebuleair-ccs811-data service (daemon)"
|
||||||
|
|
||||||
# Enable and start WiFi power save timer (separate naming convention)
|
# Enable and start WiFi power save timer (separate naming convention)
|
||||||
systemctl enable nebuleair-wifi-powersave.timer
|
systemctl enable nebuleair-wifi-powersave.timer
|
||||||
systemctl start nebuleair-wifi-powersave.timer
|
systemctl start nebuleair-wifi-powersave.timer
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ services=(
|
|||||||
"nebuleair-mppt-data.timer"
|
"nebuleair-mppt-data.timer"
|
||||||
"nebuleair-noise-data.timer"
|
"nebuleair-noise-data.timer"
|
||||||
"nebuleair-wifi-powersave.timer"
|
"nebuleair-wifi-powersave.timer"
|
||||||
|
"nebuleair-ccs811-data.service"
|
||||||
)
|
)
|
||||||
|
|
||||||
for service in "${services[@]}"; do
|
for service in "${services[@]}"; do
|
||||||
|
|||||||
Reference in New Issue
Block a user