''' 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()