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 => `${h}`).join(''); @@ -343,11 +343,16 @@ function buildTableRow(table, columns) { const nStatusLabel = nStatus === 255 ? '❌ Déconnecté' : '✅ OK'; return `${columns[0]}${columns[1]}${columns[2]}${nStatusLabel}`; } + if (table === "data_S88") { + const sStatus = parseInt(columns[2]) || 0; + const sStatusLabel = sStatus === 255 ? '❌ Déconnecté' : '✅ OK'; + return `${columns[0]}${columns[1]}${sStatusLabel}`; + } if (table === "timestamp_table") { return `${columns[1]}`; } // Default: render all available columns - const colCount = { data_BME280: 4, data_NPM_5channels: 6, data_envea: 6, data_WIND: 3, data_MPPT: 6, data_MHZ19: 2, data_S88: 2, data_CCS811: 3 }; + const colCount = { data_BME280: 4, data_NPM_5channels: 6, data_envea: 6, data_WIND: 3, data_MPPT: 6, data_MHZ19: 2, data_S88: 3, data_CCS811: 3 }; const n = colCount[table] || columns.length; return columns.slice(0, n).map(c => `${c}`).join(''); } @@ -487,7 +492,7 @@ function downloadCSV(response, table) { csvContent += "TimestampUTC,CO2_ppm\n"; } else if (table === "data_S88") { - csvContent += "TimestampUTC,CO2_ppm\n"; + csvContent += "TimestampUTC,CO2_ppm,s88_status\n"; } else if (table === "data_CCS811") { csvContent += "TimestampUTC,eCO2_ppm,TVOC_ppb\n"; diff --git a/html/launcher.php b/html/launcher.php index e02a464..b029c16 100755 --- a/html/launcher.php +++ b/html/launcher.php @@ -862,7 +862,7 @@ if ($type == "download_full_table") { 'data_MPPT' => 'TimestampUTC,Battery_voltage,Battery_current,Solar_voltage,Solar_power,Charger_status', 'data_NOISE' => 'TimestampUTC,Current_LEQ,DB_A_value', 'data_MHZ19' => 'TimestampUTC,CO2_ppm', - 'data_S88' => 'TimestampUTC,CO2_ppm', + 'data_S88' => 'TimestampUTC,CO2_ppm,s88_status', 'data_CCS811' => 'TimestampUTC,eCO2_ppm,TVOC_ppb' ]; diff --git a/loop/SARA_send_data_v2.py b/loop/SARA_send_data_v2.py index 06d5d47..fdf80a4 100755 --- a/loop/SARA_send_data_v2.py +++ b/loop/SARA_send_data_v2.py @@ -1084,14 +1084,23 @@ try: ) # S88 CO2 sensor (Senseair S88, NDIR) -> byte 81-82 (ISO_17, uint16 ppm) + # Only transmit when the LAST reading is valid (s88_status == 0). If the + # sensor is down (s88_status == 0xFF), leave bytes 81-82 at 0xFFFF (= "CO2 + # sensor absent" in the Miotiq spec) instead of sending a stale value. + # NB: byte 66 (error_flags) is already full, so absence is signalled solely + # by the 0xFFFF sentinel in the CO2 field. if s88_sensor: print("➡️Getting S88 CO2 value") cursor.execute("SELECT * FROM data_S88 ORDER BY rowid DESC LIMIT 1") last_row = cursor.fetchone() - if last_row and last_row[1] is not None: + if last_row: co2_ppm = last_row[1] - print(f"CO2 (S88): {co2_ppm} ppm") - payload.set_co2(co2_ppm) + s88_status_value = last_row[2] if len(last_row) > 2 and last_row[2] is not None else 0x00 + if s88_status_value == 0x00 and co2_ppm is not None: + print(f"CO2 (S88): {co2_ppm} ppm") + payload.set_co2(co2_ppm) + else: + print(f"S88 last reading invalid (s88_status=0x{s88_status_value:02X}) -> CO2 marked absent (0xFFFF)") else: print("No S88 data available in the database.") diff --git a/sqlite/create_db.py b/sqlite/create_db.py index 60fb44c..f5778ac 100755 --- a/sqlite/create_db.py +++ b/sqlite/create_db.py @@ -161,13 +161,22 @@ CREATE TABLE IF NOT EXISTS data_MHZ19 ( """) # Create a table S88 (Senseair S88 CO2 sensor) +# s88_status: 0 = OK, 0xFF (255) = sensor not responding (no Modbus reply) cursor.execute(""" CREATE TABLE IF NOT EXISTS data_S88 ( timestamp TEXT, - CO2 INTEGER + CO2 INTEGER, + s88_status INTEGER DEFAULT 0 ) """) +# Add s88_status column to existing databases (migration) +try: + cursor.execute("ALTER TABLE data_S88 ADD COLUMN s88_status INTEGER DEFAULT 0") + print("Added s88_status column to data_S88") +except: + pass # Column already exists + # Create a table CCS811 (AMS CCS811 air-quality sensor: eCO2 + TVOC) cursor.execute(""" CREATE TABLE IF NOT EXISTS data_CCS811 ( diff --git a/sqlite/set_config.py b/sqlite/set_config.py index 432bec1..58c3fe5 100644 --- a/sqlite/set_config.py +++ b/sqlite/set_config.py @@ -115,6 +115,7 @@ for connected, port, name, coefficient in envea_sondes: migrations = [ ("data_NPM", "npm_status", "INTEGER DEFAULT 0"), ("data_NOISE", "noise_status", "INTEGER DEFAULT 0"), + ("data_S88", "s88_status", "INTEGER DEFAULT 0"), ] for table, column, col_type in migrations: