From 13c266d694e5f0f4a501307d3b816fbd43f3abe8 Mon Sep 17 00:00:00 2001 From: PaulVua Date: Tue, 2 Jun 2026 16:08:03 +0200 Subject: [PATCH] v1.11.0: CCS811 en daemon + fix filtrage + I2C 10kHz requis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CCS811/README.md | 42 +++++++---- CCS811/daemon.py | 143 +++++++++++++++++++++++++++++++++++++ CCS811/get_data.py | 87 +++++----------------- CCS811/write_data.py | 105 --------------------------- CLAUDE.md | 2 +- VERSION | 2 +- changelog.json | 18 +++++ html/launcher.php | 11 +-- services/setup_services.sh | 40 ++++++----- update_firmware.sh | 1 + 10 files changed, 240 insertions(+), 211 deletions(-) create mode 100644 CCS811/daemon.py delete mode 100644 CCS811/write_data.py diff --git a/CCS811/README.md b/CCS811/README.md index cf2f827..f9295be 100644 --- a/CCS811/README.md +++ b/CCS811/README.md @@ -26,8 +26,12 @@ mitigation, les lectures échouent typiquement en `OSError` / `Remote I/O error` 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. +(10 kHz au lieu de 100 kHz par défaut.) Reboot ensuite. + +**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 : @@ -64,20 +68,34 @@ sans level-shifter sauf si le module embarque son propre régulateur + shifter. ## Implémentation NebuleAir -- `CCS811/get_data.py` — lecture live (bouton "Get Data" du web). Affiche - `{"eCO2": , "TVOC": }` ou `{"error": "..."}`. -- `CCS811/write_data.py` — lecture périodique (timer systemd, toutes les 10 s), - écrit dans la table `data_CCS811 (timestamp, eCO2, TVOC)`. +⚠ **Architecture : daemon, PAS un timer oneshot** (contrairement aux autres capteurs). +Le CCS811 doit être initialisé **une seule fois** puis lu en continu : -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. +- chaque (ré)init fait un reset + app_start, et les premiers échantillons juste après + sont du garbage (eCO2 = 0, ou valeurs 0x8000+ = 32768 dues au clock-stretching) ; +- un cycle reset toutes les 10 s empêche l'algorithme de baseline de se construire. + +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)". ### 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. +boîtier embarque déjà un BME280, on pourrait lui pousser temp/hum périodiquement +pour améliorer la précision. Non fait pour garder le daemon simple. diff --git a/CCS811/daemon.py b/CCS811/daemon.py new file mode 100644 index 0000000..31a01f9 --- /dev/null +++ b/CCS811/daemon.py @@ -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() diff --git a/CCS811/get_data.py b/CCS811/get_data.py index 7219c6d..c863da6 100644 --- a/CCS811/get_data.py +++ b/CCS811/get_data.py @@ -1,90 +1,39 @@ ''' -Live read of the AMS CCS811 air-quality sensor (used by the web "Get Data" button). -Prints a JSON object: {"eCO2": , "TVOC": } or {"error": ""}. +Live value for the web "Get Data" button (CCS811 air-quality sensor). +Prints {"eCO2": , "TVOC": , "timestamp": } or {"error": ""}. -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. +IMPORTANT: this does NOT read the I2C sensor. The CCS811 is owned by the +long-running daemon (CCS811/daemon.py); opening the bus here would collide with +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 -(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] +Usage: /usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/get_data.py ''' 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 + conn = sqlite3.connect(DB_PATH, timeout=5) + cursor = conn.cursor() + cursor.execute( + "SELECT timestamp, eCO2, TVOC FROM data_CCS811 ORDER BY timestamp DESC LIMIT 1" + ) + row = cursor.fetchone() + conn.close() except Exception as e: - print(json.dumps({"error": f"library import failed: {e}"})) + print(json.dumps({"error": f"DB read error: {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}"})) + if not row: + print(json.dumps({"error": "No CCS811 data yet (daemon warming up?)"})) 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 = 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}"})) + print(json.dumps({"timestamp": row[0], "eCO2": int(row[1]), "TVOC": int(row[2])})) if __name__ == "__main__": diff --git a/CCS811/write_data.py b/CCS811/write_data.py deleted file mode 100644 index a8a394d..0000000 --- a/CCS811/write_data.py +++ /dev/null @@ -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() diff --git a/CLAUDE.md b/CLAUDE.md index 47aa916..660cdae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ sudo /var/www/nebuleair_pro_4g/services/setup_services.sh - `nebuleair-envea-data.timer`: Every 10 seconds (Envea sensors) - `nebuleair-sara-data.timer`: Every 60 seconds (4G data transmission) - `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-noise-data.timer`: Every 60 seconds (Noise sensor) - `nebuleair-db-cleanup-data.timer`: Daily (database cleanup) diff --git a/VERSION b/VERSION index 4dae298..1cac385 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.10.1 +1.11.0 diff --git a/changelog.json b/changelog.json index 1a9c7e6..612d85f 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,23 @@ { "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", "date": "2026-06-02", diff --git a/html/launcher.php b/html/launcher.php index 4c9935f..e02a464 100755 --- a/html/launcher.php +++ b/html/launcher.php @@ -1681,9 +1681,10 @@ if ($type == "get_systemd_services") { 'description' => 'Reads CO2 concentration from MH-Z19 sensor', 'frequency' => 'Every 2 minutes' ], - 'nebuleair-ccs811-data.timer' => [ - 'description' => 'Reads eCO2/TVOC from CCS811 air-quality sensor', - 'frequency' => 'Every 10 seconds' + 'nebuleair-ccs811-data.service' => [ + 'display_name' => 'CCS811', + 'description' => 'Reads TVOC/eCO2 from CCS811 air-quality sensor (daemon)', + 'frequency' => 'Continuous (daemon)' ], 'nebuleair-s88-data.timer' => [ 'description' => 'Reads CO2 concentration from Senseair S88 sensor', @@ -1776,7 +1777,7 @@ if ($type == "restart_systemd_service") { 'nebuleair-noise-data.timer', 'nebuleair-mhz19-data.timer', 'nebuleair-s88-data.timer', - 'nebuleair-ccs811-data.timer', + 'nebuleair-ccs811-data.service', 'nebuleair-db-cleanup-data.timer', 'nebuleair-wifi-powersave.timer', 'nebuleair-cpu-power.service', @@ -1843,7 +1844,7 @@ if ($type == "toggle_systemd_service") { 'nebuleair-noise-data.timer', 'nebuleair-mhz19-data.timer', 'nebuleair-s88-data.timer', - 'nebuleair-ccs811-data.timer', + 'nebuleair-ccs811-data.service', 'nebuleair-db-cleanup-data.timer', 'nebuleair-wifi-powersave.timer', 'nebuleair-cpu-power.service', diff --git a/services/setup_services.sh b/services/setup_services.sh index 59d0394..ae51438 100644 --- a/services/setup_services.sh +++ b/services/setup_services.sh @@ -269,15 +269,19 @@ AccuracySec=1s WantedBy=timers.target 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' [Unit] -Description=NebuleAir CCS811 Air-Quality (eCO2/TVOC) Data Collection Service +Description=NebuleAir CCS811 Air-Quality (eCO2/TVOC) Daemon After=network.target [Service] -Type=oneshot -ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/write_data.py +Type=simple +ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/daemon.py +Restart=always +RestartSec=10 User=root WorkingDirectory=/var/www/nebuleair_pro_4g 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 EOL -cat > /etc/systemd/system/nebuleair-ccs811-data.timer << 'EOL' -[Unit] -Description=Run NebuleAir CCS811 Air-Quality Data Collection every 10 seconds -Requires=nebuleair-ccs811-data.service - -[Timer] -OnBootSec=10s -OnUnitActiveSec=10s -AccuracySec=1s - -[Install] -WantedBy=timers.target -EOL +# Self-heal: remove the obsolete oneshot timer from CCS811 v1.10.x (now a daemon). +if [ -f /etc/systemd/system/nebuleair-ccs811-data.timer ]; then + systemctl stop nebuleair-ccs811-data.timer 2>/dev/null + systemctl disable nebuleair-ccs811-data.timer 2>/dev/null + rm -f /etc/systemd/system/nebuleair-ccs811-data.timer + echo "Removed obsolete nebuleair-ccs811-data.timer (CCS811 is now a daemon)" +fi # Create service and timer files for Database Cleanup cat > /etc/systemd/system/nebuleair-db-cleanup-data.service << 'EOL' @@ -434,12 +432,18 @@ systemctl daemon-reload # Enable and start all timers 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 start nebuleair-$service-data.timer echo "Started nebuleair-$service-data timer" 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) systemctl enable nebuleair-wifi-powersave.timer systemctl start nebuleair-wifi-powersave.timer diff --git a/update_firmware.sh b/update_firmware.sh index 6da18f4..62a7f75 100755 --- a/update_firmware.sh +++ b/update_firmware.sh @@ -232,6 +232,7 @@ services=( "nebuleair-mppt-data.timer" "nebuleair-noise-data.timer" "nebuleair-wifi-powersave.timer" + "nebuleair-ccs811-data.service" ) for service in "${services[@]}"; do