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