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>
144 lines
4.9 KiB
Python
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()
|