diff --git a/S88/write_data.py b/S88/write_data.py index 287492a..2349135 100644 --- a/S88/write_data.py +++ b/S88/write_data.py @@ -3,7 +3,14 @@ Script to get CO2 values from Senseair S88 sensor and write to database /usr/bin/python3 /var/www/nebuleair_pro_4g/S88/write_data.py Modbus RTU 9600 8N1. Reads IR1..IR4 in one frame to get status + CO2. -If status (IR1) is non-zero the reading is skipped (warm-up or error). + +A row is ALWAYS written each run, with a status byte (like data_NPM.npm_status +and data_NOISE.noise_status): + s88_status = 0 -> OK, CO2 valid + s88_status = 0xFF -> sensor not responding / read error (CO2 stored as 0) +This is essential: without it the table keeps the last good value forever and +loop/SARA_send_data_v2.py would keep transmitting a stale CO2 reading when the +sensor is actually dead. ''' import serial @@ -14,6 +21,9 @@ DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db" DEFAULT_PORT = "/dev/ttyAMA5" BAUDRATE = 9600 +STATUS_OK = 0x00 +STATUS_NO_RESPONSE = 0xFF + # Modbus slave address: 0xFE = "any address", any S88 responds regardless of # its configured individual address. Fine for single-sensor setups. SLAVE_ADDR = 0xFE @@ -80,20 +90,32 @@ def read_co2(ser): def main(): - conn = sqlite3.connect(DB_PATH) + conn = sqlite3.connect(DB_PATH, timeout=10) 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. + # Self-heal: ensure the table + status column exist 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_S88 ( timestamp TEXT, - CO2 INTEGER + CO2 INTEGER, + s88_status INTEGER DEFAULT 0 ) """) + try: + cursor.execute("ALTER TABLE data_S88 ADD COLUMN s88_status INTEGER DEFAULT 0") + except Exception: + pass # Column already exists + conn.commit() port = get_config(cursor, "S88_port", DEFAULT_PORT) + # Default to the error state; only a clean read flips it to OK. + co2_ppm = 0 + status = STATUS_NO_RESPONSE + + ser = None try: ser = serial.Serial( port=port, @@ -103,36 +125,40 @@ def main(): bytesize=serial.EIGHTBITS, timeout=1, ) - except Exception as e: - print(f"Error opening serial port {port}: {e}") - conn.close() - sys.exit(1) - - try: co2 = read_co2(ser) - if co2 is None: - print("Failed to read CO2 from S88.") - return - - co2_ppm = int(round(co2)) + if co2 is not None: + co2_ppm = int(round(co2)) + status = STATUS_OK + else: + print("S88 not responding -> writing error row (s88_status=0xFF)") + except Exception as e: + print(f"S88 serial/read error: {e} -> writing error row (s88_status=0xFF)") + finally: + if ser is not None: + try: + ser.close() + except Exception: + pass + # ALWAYS write a row so the send loop never reuses a stale value. + try: cursor.execute("SELECT last_updated FROM timestamp_table LIMIT 1") row = cursor.fetchone() - rtc_time_str = row[0] + rtc_time_str = row[0] if row else "not connected" cursor.execute( - "INSERT INTO data_S88 (timestamp, CO2) VALUES (?, ?)", - (rtc_time_str, co2_ppm), + "INSERT INTO data_S88 (timestamp, CO2, s88_status) VALUES (?, ?, ?)", + (rtc_time_str, co2_ppm, status), ) conn.commit() - print(f"CO2: {co2_ppm} ppm (saved at {rtc_time_str})") + + if status == STATUS_OK: + print(f"CO2: {co2_ppm} ppm (saved at {rtc_time_str})") + else: + print(f"S88 no data, s88_status=0x{status:02X} (saved at {rtc_time_str})") except Exception as e: - print(f"S88 error: {e}") + print(f"S88 DB write error: {e}") finally: - try: - ser.close() - except Exception: - pass conn.close() diff --git a/VERSION b/VERSION index 0eed1a2..f8f4f03 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.0 +1.12.1 diff --git a/changelog.json b/changelog.json index e658158..2193390 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,22 @@ { "versions": [ + { + "version": "1.12.1", + "date": "2026-06-02", + "changes": { + "features": [], + "improvements": [ + "S88: nouvelle colonne data_S88.s88_status (0 = OK, 0xFF = sonde ne répond pas), sur le modèle de npm_status/noise_status. write_data.py écrit DÉSORMAIS une ligne à CHAQUE cycle (avant: rien quand pas de réponse), avec s88_status=0xFF et CO2=0 en cas d'échec. Évite que la base garde indéfiniment la dernière valeur valide. Affichage badge ✅/❌ dans database.html, colonne ajoutée aux exports CSV. Connexion SQLite avec timeout=10 (anti 'database is locked' vu en prod avec le daemon CCS811 + multiples writers)." + ], + "fixes": [ + "Transmission: SARA_send_data_v2.py ne transmet plus une valeur CO2 périmée. Il lit s88_status sur la dernière ligne data_S88; si la sonde est down (0xFF), les octets 81-82 restent à 0xFFFF (= capteur CO2 absent dans la spec Miotiq) au lieu d'envoyer la dernière mesure valide. Garde compatible avec les bases non encore migrées (len(row)>2)." + ], + "compatibility": [ + "Migration DB: colonne s88_status ajoutée via create_db.py (ALTER idempotent) + set_config.py migrations + self-heal dans write_data.py. S'applique à la prochaine OTA." + ] + }, + "notes": "Découvert sur pro100: la sonde S88 ne répondait plus (0 octet Modbus sur les 3 ports UART, panne câblage/alim) mais la base gardait CO2=487 d'hier et la loop l'aurait transmis en boucle. Le même principe (toujours écrire + code d'état) reste à appliquer au CCS811 quand il sera transmis." + }, { "version": "1.12.0", "date": "2026-06-02", diff --git a/html/database.html b/html/database.html index 8464426..66cbcdb 100755 --- a/html/database.html +++ b/html/database.html @@ -323,7 +323,7 @@ function buildTableHeader(table) { data_MPPT: ['Timestamp','Battery Voltage','Battery Current','solar_voltage','solar_power','charger_status'], data_NOISE: ['Timestamp','Curent LEQ','DB_A_value','Status'], data_MHZ19: ['Timestamp','CO2 (ppm)'], - data_S88: ['Timestamp','CO2 (ppm)'], + data_S88: ['Timestamp','CO2 (ppm)','Status'], data_CCS811: ['Timestamp','eCO2 (ppm)','TVOC (ppb)'] }; return (headers[table] || ['Data']).map(h => `