v1.12.1: S88 ecrit toujours + code d'etat, plus de CO2 perime transmis
Probleme vu sur pro100: sonde S88 muette (panne cablage) mais write_data.py n'ecrivait rien -> la base gardait la derniere valeur (487 ppm d'hier) et la loop d'envoi la transmettait en boucle. - data_S88: nouvelle colonne s88_status (0=OK, 0xFF=sonde muette), comme npm_status/noise_status. Migration via create_db.py + set_config.py + self-heal. - S88/write_data.py: ecrit DESORMAIS une ligne a chaque cycle (CO2=0 + 0xFF si pas de reponse). Connexion SQLite timeout=10 (anti database-is-locked). - SARA_send_data_v2.py: lit s88_status; si 0xFF -> bytes 81-82 restent 0xFFFF (CO2 absent) au lieu d'envoyer une valeur perimee. Compatible bases non migrees. - database.html + launcher.php: badge statut + colonne dans les exports CSV. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
/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.
|
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
|
import serial
|
||||||
@@ -14,6 +21,9 @@ DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
|
|||||||
DEFAULT_PORT = "/dev/ttyAMA5"
|
DEFAULT_PORT = "/dev/ttyAMA5"
|
||||||
BAUDRATE = 9600
|
BAUDRATE = 9600
|
||||||
|
|
||||||
|
STATUS_OK = 0x00
|
||||||
|
STATUS_NO_RESPONSE = 0xFF
|
||||||
|
|
||||||
# Modbus slave address: 0xFE = "any address", any S88 responds regardless of
|
# Modbus slave address: 0xFE = "any address", any S88 responds regardless of
|
||||||
# its configured individual address. Fine for single-sensor setups.
|
# its configured individual address. Fine for single-sensor setups.
|
||||||
SLAVE_ADDR = 0xFE
|
SLAVE_ADDR = 0xFE
|
||||||
@@ -80,20 +90,32 @@ def read_co2(ser):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Self-heal: ensure the table exists even if create_db.py was skipped during OTA.
|
# Self-heal: ensure the table + status column exist even if create_db.py was
|
||||||
# Duplicates the canonical schema from sqlite/create_db.py — keep them in sync.
|
# skipped during OTA. Duplicates the canonical schema from sqlite/create_db.py
|
||||||
|
# — keep them in sync.
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS data_S88 (
|
CREATE TABLE IF NOT EXISTS data_S88 (
|
||||||
timestamp TEXT,
|
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)
|
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:
|
try:
|
||||||
ser = serial.Serial(
|
ser = serial.Serial(
|
||||||
port=port,
|
port=port,
|
||||||
@@ -103,36 +125,40 @@ def main():
|
|||||||
bytesize=serial.EIGHTBITS,
|
bytesize=serial.EIGHTBITS,
|
||||||
timeout=1,
|
timeout=1,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
print(f"Error opening serial port {port}: {e}")
|
|
||||||
conn.close()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
co2 = read_co2(ser)
|
co2 = read_co2(ser)
|
||||||
if co2 is None:
|
if co2 is not None:
|
||||||
print("Failed to read CO2 from S88.")
|
co2_ppm = int(round(co2))
|
||||||
return
|
status = STATUS_OK
|
||||||
|
else:
|
||||||
co2_ppm = int(round(co2))
|
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")
|
cursor.execute("SELECT last_updated FROM timestamp_table LIMIT 1")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
rtc_time_str = row[0]
|
rtc_time_str = row[0] if row else "not connected"
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"INSERT INTO data_S88 (timestamp, CO2) VALUES (?, ?)",
|
"INSERT INTO data_S88 (timestamp, CO2, s88_status) VALUES (?, ?, ?)",
|
||||||
(rtc_time_str, co2_ppm),
|
(rtc_time_str, co2_ppm, status),
|
||||||
)
|
)
|
||||||
conn.commit()
|
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:
|
except Exception as e:
|
||||||
print(f"S88 error: {e}")
|
print(f"S88 DB write error: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
|
||||||
ser.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"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",
|
"version": "1.12.0",
|
||||||
"date": "2026-06-02",
|
"date": "2026-06-02",
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ function buildTableHeader(table) {
|
|||||||
data_MPPT: ['Timestamp','Battery Voltage','Battery Current','solar_voltage','solar_power','charger_status'],
|
data_MPPT: ['Timestamp','Battery Voltage','Battery Current','solar_voltage','solar_power','charger_status'],
|
||||||
data_NOISE: ['Timestamp','Curent LEQ','DB_A_value','Status'],
|
data_NOISE: ['Timestamp','Curent LEQ','DB_A_value','Status'],
|
||||||
data_MHZ19: ['Timestamp','CO2 (ppm)'],
|
data_MHZ19: ['Timestamp','CO2 (ppm)'],
|
||||||
data_S88: ['Timestamp','CO2 (ppm)'],
|
data_S88: ['Timestamp','CO2 (ppm)','Status'],
|
||||||
data_CCS811: ['Timestamp','eCO2 (ppm)','TVOC (ppb)']
|
data_CCS811: ['Timestamp','eCO2 (ppm)','TVOC (ppb)']
|
||||||
};
|
};
|
||||||
return (headers[table] || ['Data']).map(h => `<th>${h}</th>`).join('');
|
return (headers[table] || ['Data']).map(h => `<th>${h}</th>`).join('');
|
||||||
@@ -343,11 +343,16 @@ function buildTableRow(table, columns) {
|
|||||||
const nStatusLabel = nStatus === 255 ? '❌ Déconnecté' : '✅ OK';
|
const nStatusLabel = nStatus === 255 ? '❌ Déconnecté' : '✅ OK';
|
||||||
return `<td>${columns[0]}</td><td>${columns[1]}</td><td>${columns[2]}</td><td>${nStatusLabel}</td>`;
|
return `<td>${columns[0]}</td><td>${columns[1]}</td><td>${columns[2]}</td><td>${nStatusLabel}</td>`;
|
||||||
}
|
}
|
||||||
|
if (table === "data_S88") {
|
||||||
|
const sStatus = parseInt(columns[2]) || 0;
|
||||||
|
const sStatusLabel = sStatus === 255 ? '❌ Déconnecté' : '✅ OK';
|
||||||
|
return `<td>${columns[0]}</td><td>${columns[1]}</td><td>${sStatusLabel}</td>`;
|
||||||
|
}
|
||||||
if (table === "timestamp_table") {
|
if (table === "timestamp_table") {
|
||||||
return `<td>${columns[1]}</td>`;
|
return `<td>${columns[1]}</td>`;
|
||||||
}
|
}
|
||||||
// Default: render all available columns
|
// 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;
|
const n = colCount[table] || columns.length;
|
||||||
return columns.slice(0, n).map(c => `<td>${c}</td>`).join('');
|
return columns.slice(0, n).map(c => `<td>${c}</td>`).join('');
|
||||||
}
|
}
|
||||||
@@ -487,7 +492,7 @@ function downloadCSV(response, table) {
|
|||||||
csvContent += "TimestampUTC,CO2_ppm\n";
|
csvContent += "TimestampUTC,CO2_ppm\n";
|
||||||
}
|
}
|
||||||
else if (table === "data_S88") {
|
else if (table === "data_S88") {
|
||||||
csvContent += "TimestampUTC,CO2_ppm\n";
|
csvContent += "TimestampUTC,CO2_ppm,s88_status\n";
|
||||||
}
|
}
|
||||||
else if (table === "data_CCS811") {
|
else if (table === "data_CCS811") {
|
||||||
csvContent += "TimestampUTC,eCO2_ppm,TVOC_ppb\n";
|
csvContent += "TimestampUTC,eCO2_ppm,TVOC_ppb\n";
|
||||||
|
|||||||
@@ -862,7 +862,7 @@ if ($type == "download_full_table") {
|
|||||||
'data_MPPT' => 'TimestampUTC,Battery_voltage,Battery_current,Solar_voltage,Solar_power,Charger_status',
|
'data_MPPT' => 'TimestampUTC,Battery_voltage,Battery_current,Solar_voltage,Solar_power,Charger_status',
|
||||||
'data_NOISE' => 'TimestampUTC,Current_LEQ,DB_A_value',
|
'data_NOISE' => 'TimestampUTC,Current_LEQ,DB_A_value',
|
||||||
'data_MHZ19' => 'TimestampUTC,CO2_ppm',
|
'data_MHZ19' => 'TimestampUTC,CO2_ppm',
|
||||||
'data_S88' => 'TimestampUTC,CO2_ppm',
|
'data_S88' => 'TimestampUTC,CO2_ppm,s88_status',
|
||||||
'data_CCS811' => 'TimestampUTC,eCO2_ppm,TVOC_ppb'
|
'data_CCS811' => 'TimestampUTC,eCO2_ppm,TVOC_ppb'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1084,14 +1084,23 @@ try:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# S88 CO2 sensor (Senseair S88, NDIR) -> byte 81-82 (ISO_17, uint16 ppm)
|
# 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:
|
if s88_sensor:
|
||||||
print("➡️Getting S88 CO2 value")
|
print("➡️Getting S88 CO2 value")
|
||||||
cursor.execute("SELECT * FROM data_S88 ORDER BY rowid DESC LIMIT 1")
|
cursor.execute("SELECT * FROM data_S88 ORDER BY rowid DESC LIMIT 1")
|
||||||
last_row = cursor.fetchone()
|
last_row = cursor.fetchone()
|
||||||
if last_row and last_row[1] is not None:
|
if last_row:
|
||||||
co2_ppm = last_row[1]
|
co2_ppm = last_row[1]
|
||||||
print(f"CO2 (S88): {co2_ppm} ppm")
|
s88_status_value = last_row[2] if len(last_row) > 2 and last_row[2] is not None else 0x00
|
||||||
payload.set_co2(co2_ppm)
|
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:
|
else:
|
||||||
print("No S88 data available in the database.")
|
print("No S88 data available in the database.")
|
||||||
|
|
||||||
|
|||||||
@@ -161,13 +161,22 @@ CREATE TABLE IF NOT EXISTS data_MHZ19 (
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
# Create a table S88 (Senseair S88 CO2 sensor)
|
# Create a table S88 (Senseair S88 CO2 sensor)
|
||||||
|
# s88_status: 0 = OK, 0xFF (255) = sensor not responding (no Modbus reply)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS data_S88 (
|
CREATE TABLE IF NOT EXISTS data_S88 (
|
||||||
timestamp TEXT,
|
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)
|
# Create a table CCS811 (AMS CCS811 air-quality sensor: eCO2 + TVOC)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS data_CCS811 (
|
CREATE TABLE IF NOT EXISTS data_CCS811 (
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ for connected, port, name, coefficient in envea_sondes:
|
|||||||
migrations = [
|
migrations = [
|
||||||
("data_NPM", "npm_status", "INTEGER DEFAULT 0"),
|
("data_NPM", "npm_status", "INTEGER DEFAULT 0"),
|
||||||
("data_NOISE", "noise_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:
|
for table, column, col_type in migrations:
|
||||||
|
|||||||
Reference in New Issue
Block a user