From 7681578f2233bd71d215bd7659f9292a9f89f395 Mon Sep 17 00:00:00 2001 From: PaulVua Date: Mon, 1 Jun 2026 16:24:44 +0200 Subject: [PATCH] =?UTF-8?q?v1.9.14:=20Senseair=20S88=20-=20impl=C3=A9menta?= =?UTF-8?q?tion=20lecture=20Modbus=20RTU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_co2() lit IR1..IR4 en une trame (status + CO2) à 9600 8N1, adresse 0xFE 'any address', avec vérification CRC16-Modbus et rejet de la mesure si status non-nul (warm-up ou erreur). CRC requête/réponse validés contre les exemples du datasheet TDE14367. Doc protocole consolidée dans S88/README.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- S88/README.md | 166 ++++++++++++++++++++++++++++++++++++++++++++++ S88/get_data.py | 54 +++++++++++++-- S88/write_data.py | 64 ++++++++++++++++-- VERSION | 2 +- changelog.json | 13 ++++ 5 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 S88/README.md diff --git a/S88/README.md b/S88/README.md new file mode 100644 index 0000000..3eaafbb --- /dev/null +++ b/S88/README.md @@ -0,0 +1,166 @@ +# Senseair S88 — Capteur CO2 NDIR + +Notes essentielles extraites des datasheets Senseair (Product Specification PSP14279 +rev 3, et "Modbus on Senseair S88" TDE14367 rev 5). Les PDF originaux ne sont pas +versionnés (trop lourds, pas utiles sur les capteurs). + +## Modèle + +- **Senseair S88 Residential** — Article No. 004-1-0100 +- Capteur CO2 miniature NDIR (Non-Dispersive InfraRed) +- Dimensions : 33.9 × 19.6 × 9.7 mm — poids < 5 g +- Compatibilité registres Modbus avec le Senseair S8 + +## Caractéristiques mesure + +| Paramètre | Valeur | +|---|---| +| Gaz mesuré | CO2 | +| Plage | 400 – 10 000 ppm | +| Intervalle de mesure | 2 s | +| Précision 400–3000 ppm | ±25 ppm ±3% de la lecture | +| Précision 3000–10000 ppm | ±10% de la lecture | +| Temps de chauffe | ≤ 10 s | +| Temps de réponse t63% | ≤ 30 s | +| Conditions d'opération | 0–50 °C, 0–85 %RH (sans condensation, dew point ≤ 35 °C) | +| Dépendance pression | 1.6 % par kPa d'écart à la pression normale | +| Durée de vie | > 15 ans | +| Maintenance | Sans entretien (ABC : Automatic Baseline Correction activé par défaut) | + +## Alimentation + +- **Tension** : 4.5 – 5.25 V (le 5V du Pi convient) +- **Courant pic** : ≤ 300 mA (pendant la rampe de la lampe IR) +- **Courant moyen** : ≤ 30 mA +- Non protégée contre surtensions / inversion polarité — attention au câblage + +## Pinout + +``` + G+ ●─┐ ┌─● DVCC_out (3.3V, sortie régulateur interne — ne PAS utiliser) + G0 ●─┤ ├─● UART_TxD (3.3V CMOS, sortie capteur) +Alarm_OC●─┤ ├─● UART_RxD (3.3V CMOS, entrée capteur) +PWM 1kHz●─┘ ├─● UART_R/T (direction RS-485, à laisser flottant en UART direct) + └─● bCAL_in (entrée calibration manuelle) +``` + +### Câblage vers Raspberry Pi (UART 3.3V direct) + +| S88 | Raspberry Pi CM4 | +|---|---| +| G+ | 5V | +| G0 | GND | +| UART_TxD | RxD du ttyAMAx (ex. GPIO15 pour ttyAMA0) | +| UART_RxD | TxD du ttyAMAx (ex. GPIO14 pour ttyAMA0) | +| UART_R/T | non connecté | + +Les niveaux UART du S88 sont 3.3V CMOS — directement compatibles avec le Pi. +Pas besoin de level shifter, pas besoin de RS-485 transceiver. + +## Protocole Modbus RTU + +- **Mode** : RTU (seul mode supporté) +- **Baudrate** : 9600 par défaut (19200 aussi supporté) +- **Format** : 8 bits de données, **pas de parité**, 1 stop bit en réception / 2 stop bits en transmission (config par défaut) +- **Adresse esclave** : 1–247 (configurable via HR). **0xFE = "any address"** — répondue par n'importe quel S88, utile quand on ne connaît pas l'adresse individuelle (à n'utiliser qu'en bench, pas en réseau multi-capteurs) +- **Adresse 0** : broadcast (commandes write seulement) +- **Réponse timeout** : ≤ 180 ms + +### Fonctions supportées + +| Code | Fonction | +|---|---| +| 0x03 | Read Holding Registers (config, plage 0x0000–0x0020) | +| 0x04 | Read Input Registers (mesures, plage 0x0000–0x001F) | +| 0x06 | Write Single Register | +| 0x10 | Write Multiple Registers | +| 0x2B / 0x0E | Read Device Identification (Vendor Name, ProductCode, MajorMinorRevision) | + +### Input Registers (mesures, fonction 0x04) + +| Reg | Offset | Nom | Description | +|---|---|---|---| +| **IR1** | 0x0000 | MeterStatus | Bits d'état (DI1=Fatal error, DI3=Algorithm error, DI4=Output error, DI5=Self-diagnostic error, DI6=Out of range, DI7=Memory error, DI8=Warm Up) | +| IR2 | 0x0001 | AlarmStatus | Réservé | +| IR3 | 0x0002 | OutputStatus | DI33=Alarm Output status, DI34=PWM Output status | +| **IR4** | **0x0003** | **Space CO2** | **Concentration CO2 en ppm (uint16)** ⚠ voir note scaling | +| IR5 | 0x0004 | Space Temp | Température capteur (au-dessus de l'ambiant à cause de l'auto-échauffement) | +| IR6 | 0x0005 | Synchro | Incrémenté chaque cycle de mesure | +| IR7 | 0x0006 | Vbb | Tension VBB pendant lamp ramp (LSB = 1 mV) | +| IR22 | 0x0015 | PWM Output | Valeur PWM (0x3FFF = 100%) | +| IR24+IR25 | 0x0017/18 | ETC | Elapsed Time Counter (heures), 4 octets | +| IR28 | 0x001B | Memory Map version | | +| IR29 | 0x001C | FW version | high byte = Main, low byte = Sub | +| IR30+IR31 | 0x001D/1E | Sensor Serial Number | 4 octets | + +⚠ **Scaling CO2** : la plupart des S88 retournent la valeur directement en ppm. +**Certains futurs modèles** de la famille S88 divisent par 10 (400 ppm → renvoie 40). +À vérifier au bench. Le ProductCode (lu via fonction 0x2B/0x0E objet 0x01) permet +d'identifier le modèle — pour le S88 Residential 004-1-0100 c'est ppm directement. + +### Exemple : lire CO2 seul (IR4) + +**Requête maître** (adresse 0xFE, function 04, start 0x0003, qty 0x0001) : + +``` +FE 04 00 03 00 01 25 C5 +└┬┘ └┬┘ └──┬──┘ └──┬──┘ └─┬─┘ +addr fn start qty CRC (low byte first) +``` + +**Réponse esclave** (CO2 = 400 ppm = 0x0190) : + +``` +FE 04 02 01 90 AC DB +└┬┘ └┬┘ └┬┘ └──┬──┘ └─┬─┘ +addr fn count value CRC +``` + +### Exemple : lire status + CO2 en une commande (IR1 à IR4) + +**Requête maître** : + +``` +FE 04 00 00 00 04 E5 C6 +``` + +**Réponse esclave** (status=0, CO2=400ppm) : + +``` +FE 04 08 00 00 00 00 00 00 01 90 16 E6 +└┬┘ └┬┘ └┬┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └─┬─┘ +addr fn cnt IR1=0 IR2=0 IR3=0 IR4=400 CRC +``` + +C'est la séquence recommandée pour le scraping périodique : un seul appel, +on récupère l'état + la valeur. Si IR1 (status) ≠ 0, ne pas écrire la mesure. + +## Notes EEPROM + +Les Holding Registers sont mappés en EEPROM (sauf HR1–HR4 et HR22) : + +- Limite EEPROM : **< 10 000 cycles d'écriture** sur la durée de vie +- Une écriture multi-registres compte pour 1 cycle +- Attendre **≥ 180 ms** après écriture d'un HR avant power-down/reset + +⚠ Ne JAMAIS écrire les HR depuis une boucle qui tourne souvent — réservé à la +configuration initiale (changement de baudrate, d'adresse Modbus, etc.). + +## Implémentation NebuleAir + +Voir `S88/write_data.py` et `S88/get_data.py`. Le module Python `minimalmodbus` +ou `pymodbus` peut être utilisé, ou directement `pyserial` avec calcul CRC16 manuel. + +Pour lecture périodique simple : + +```python +# Pseudocode — voir write_data.py pour la vraie implémentation +request = b'\xFE\x04\x00\x00\x00\x04' + crc16(...) # IR1..IR4 +ser.write(request) +response = ser.read(13) # FE 04 08 + 8 octets data + 2 CRC +if response[0] == 0xFE and response[1] == 0x04: + status = (response[3] << 8) | response[4] + co2_ppm = (response[9] << 8) | response[10] + if status == 0: + # OK, enregistrer co2_ppm +``` diff --git a/S88/get_data.py b/S88/get_data.py index bd67e0c..7507cd7 100644 --- a/S88/get_data.py +++ b/S88/get_data.py @@ -2,6 +2,8 @@ Live read of the Senseair S88 CO2 sensor (used by the web "Get Data" button). Prints a JSON object: {"CO2": } or {"error": ""}. +Modbus RTU 9600 8N1, reads IR1..IR4 in one frame. + Usage: /usr/bin/python3 /var/www/nebuleair_pro_4g/S88/get_data.py [port] If no port is given, the script reads S88_port from config_table. ''' @@ -16,6 +18,22 @@ DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db" DEFAULT_PORT = "/dev/ttyAMA5" BAUDRATE = 9600 +SLAVE_ADDR = 0xFE +READ_REQUEST = bytes([SLAVE_ADDR, 0x04, 0x00, 0x00, 0x00, 0x04, 0xE5, 0xC6]) +EXPECTED_RESPONSE_LEN = 13 + + +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_port_from_config(): try: @@ -30,9 +48,33 @@ def get_port_from_config(): def read_co2(ser): - # TODO: implement the Senseair S88 read protocol once the datasheet is provided. - # Expected return: integer CO2 concentration in ppm, or None on failure. - raise NotImplementedError("Senseair S88 read protocol not implemented yet") + ser.reset_input_buffer() + ser.write(READ_REQUEST) + response = ser.read(EXPECTED_RESPONSE_LEN) + + if len(response) < 5: + return None, f"short response ({len(response)} bytes)" + + if response[1] & 0x80: + return None, f"Modbus exception code {response[2]:#04x}" + + if len(response) != EXPECTED_RESPONSE_LEN: + return None, f"unexpected response length {len(response)}" + + received_crc = response[-2] | (response[-1] << 8) + if crc16_modbus(response[:-2]) != received_crc: + return None, "CRC mismatch" + + if response[0] != SLAVE_ADDR or response[1] != 0x04 or response[2] != 0x08: + return None, f"unexpected header {response[:3].hex()}" + + status = (response[3] << 8) | response[4] + co2 = (response[9] << 8) | response[10] + + if status != 0: + return None, f"sensor not ready (status={status:#06x})" + + return co2, None def main(): @@ -52,13 +94,11 @@ def main(): return try: - co2 = read_co2(ser) + co2, err = read_co2(ser) if co2 is None: - print(json.dumps({"error": "No data from S88"})) + print(json.dumps({"error": err or "No data from S88"})) return print(json.dumps({"CO2": int(round(co2))})) - except NotImplementedError as e: - print(json.dumps({"error": str(e)})) except Exception as e: print(json.dumps({"error": f"S88 read error: {e}"})) finally: diff --git a/S88/write_data.py b/S88/write_data.py index 10cdd16..a6cec79 100644 --- a/S88/write_data.py +++ b/S88/write_data.py @@ -2,8 +2,8 @@ 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 -Port and protocol details come from config_table (key S88_port). -The actual sensor read protocol is implemented in read_co2() below. +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). ''' import serial @@ -14,6 +14,25 @@ DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db" DEFAULT_PORT = "/dev/ttyAMA5" BAUDRATE = 9600 +# 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,)) @@ -22,9 +41,42 @@ def get_config(cursor, key, default): def read_co2(ser): - # TODO: implement the Senseair S88 read protocol once the datasheet is provided. - # Expected return: integer CO2 concentration in ppm, or None on failure. - raise NotImplementedError("Senseair S88 read protocol not implemented yet") + 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(): @@ -65,8 +117,6 @@ def main(): ) conn.commit() print(f"CO2: {co2_ppm} ppm (saved at {rtc_time_str})") - except NotImplementedError as e: - print(f"S88 not ready: {e}") except Exception as e: print(f"S88 error: {e}") finally: diff --git a/VERSION b/VERSION index 2a72e6f..e5f8fef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.13 +1.9.14 diff --git a/changelog.json b/changelog.json index df0036c..6ab5952 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,18 @@ { "versions": [ + { + "version": "1.9.14", + "date": "2026-06-01", + "changes": { + "features": [ + "Capteur CO2 Senseair S88: implémentation de la lecture Modbus RTU. read_co2() lit IR1..IR4 en une trame (status + CO2) à 9600 8N1, adresse 0xFE 'any address', avec vérification CRC16-Modbus et rejet de la mesure si status non-nul (warm-up ou erreur). CRC requête/réponse validés contre les exemples du datasheet TDE14367. Doc protocole consolidée dans S88/README.md." + ], + "improvements": [], + "fixes": [], + "compatibility": [] + }, + "notes": "Le capteur peut être utilisé tel quel branché sur le port configuré dans S88_port (défaut /dev/ttyAMA5). L'adresse Modbus 0xFE répond quelle que soit l'adresse individuelle du capteur — adapté pour un seul S88 sur le bus. Si plusieurs S88 sur le même UART, configurer une adresse individuelle via les HR (à faire une seule fois en bench)." + }, { "version": "1.9.13", "date": "2026-06-01",