Files
nebuleair_pro_4g/CCS811/daemon.py
PaulVua 13c266d694 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>
2026-06-02 16:08:03 +02:00

144 lines
4.9 KiB
Python

'''
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()