Files
nebuleair_pro_4g/S88/write_data.py
PaulVua d554f03195 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>
2026-06-02 17:10:32 +02:00

167 lines
5.1 KiB
Python

'''
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.
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 sqlite3
import sys
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
# Read IR1..IR4 in one frame: function 0x04, start 0x0000, qty 0x0004
READ_REQUEST = bytes([SLAVE_ADDR, 0x04, 0x00, 0x00, 0x00, 0x04, 0xE5, 0xC6])
EXPECTED_RESPONSE_LEN = 13 # 1 addr + 1 fn + 1 count + 8 data + 2 CRC
def crc16_modbus(data):
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc
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 read_co2(ser):
ser.reset_input_buffer()
ser.write(READ_REQUEST)
response = ser.read(EXPECTED_RESPONSE_LEN)
if len(response) < 5:
print(f"S88: short response ({len(response)} bytes)")
return None
# Modbus exception response: function code has high bit set (0x84 instead of 0x04)
if response[1] & 0x80:
print(f"S88 Modbus exception: code={response[2]:#04x}")
return None
if len(response) != EXPECTED_RESPONSE_LEN:
print(f"S88: unexpected response length {len(response)} (expected {EXPECTED_RESPONSE_LEN})")
return None
# Verify CRC (last two bytes, low byte first)
received_crc = response[-2] | (response[-1] << 8)
if crc16_modbus(response[:-2]) != received_crc:
print("S88: CRC mismatch")
return None
if response[0] != SLAVE_ADDR or response[1] != 0x04 or response[2] != 0x08:
print(f"S88: unexpected header {response[:3].hex()}")
return None
status = (response[3] << 8) | response[4]
co2 = (response[9] << 8) | response[10]
if status != 0:
# DI8 = Warm Up (bit 7 of low byte). Other bits = errors.
print(f"S88: sensor not ready, status={status:#06x}")
return None
return co2
def main():
conn = sqlite3.connect(DB_PATH, timeout=10)
cursor = conn.cursor()
# 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,
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,
baudrate=BAUDRATE,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1,
)
co2 = read_co2(ser)
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] if row else "not connected"
cursor.execute(
"INSERT INTO data_S88 (timestamp, CO2, s88_status) VALUES (?, ?, ?)",
(rtc_time_str, co2_ppm, status),
)
conn.commit()
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 DB write error: {e}")
finally:
conn.close()
if __name__ == "__main__":
main()