v1.9.14: Senseair S88 - implémentation 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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PaulVua
2026-06-01 16:24:44 +02:00
parent 239bdfea69
commit 7681578f22
5 changed files with 284 additions and 15 deletions

166
S88/README.md Normal file
View File

@@ -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 4003000 ppm | ±25 ppm ±3% de la lecture |
| Précision 300010000 ppm | ±10% de la lecture |
| Temps de chauffe | ≤ 10 s |
| Temps de réponse t63% | ≤ 30 s |
| Conditions d'opération | 050 °C, 085 %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** : 1247 (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 0x00000x0020) |
| 0x04 | Read Input Registers (mesures, plage 0x00000x001F) |
| 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 HR1HR4 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
```

View File

@@ -2,6 +2,8 @@
Live read of the Senseair S88 CO2 sensor (used by the web "Get Data" button). Live read of the Senseair S88 CO2 sensor (used by the web "Get Data" button).
Prints a JSON object: {"CO2": <int_ppm>} or {"error": "<message>"}. Prints a JSON object: {"CO2": <int_ppm>} or {"error": "<message>"}.
Modbus RTU 9600 8N1, reads IR1..IR4 in one frame.
Usage: /usr/bin/python3 /var/www/nebuleair_pro_4g/S88/get_data.py [port] 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. 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" DEFAULT_PORT = "/dev/ttyAMA5"
BAUDRATE = 9600 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(): def get_port_from_config():
try: try:
@@ -30,9 +48,33 @@ def get_port_from_config():
def read_co2(ser): def read_co2(ser):
# TODO: implement the Senseair S88 read protocol once the datasheet is provided. ser.reset_input_buffer()
# Expected return: integer CO2 concentration in ppm, or None on failure. ser.write(READ_REQUEST)
raise NotImplementedError("Senseair S88 read protocol not implemented yet") 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(): def main():
@@ -52,13 +94,11 @@ def main():
return return
try: try:
co2 = read_co2(ser) co2, err = read_co2(ser)
if co2 is None: if co2 is None:
print(json.dumps({"error": "No data from S88"})) print(json.dumps({"error": err or "No data from S88"}))
return return
print(json.dumps({"CO2": int(round(co2))})) print(json.dumps({"CO2": int(round(co2))}))
except NotImplementedError as e:
print(json.dumps({"error": str(e)}))
except Exception as e: except Exception as e:
print(json.dumps({"error": f"S88 read error: {e}"})) print(json.dumps({"error": f"S88 read error: {e}"}))
finally: finally:

View File

@@ -2,8 +2,8 @@
Script to get CO2 values from Senseair S88 sensor and write to database 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
Port and protocol details come from config_table (key S88_port). Modbus RTU 9600 8N1. Reads IR1..IR4 in one frame to get status + CO2.
The actual sensor read protocol is implemented in read_co2() below. If status (IR1) is non-zero the reading is skipped (warm-up or error).
''' '''
import serial import serial
@@ -14,6 +14,25 @@ DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
DEFAULT_PORT = "/dev/ttyAMA5" DEFAULT_PORT = "/dev/ttyAMA5"
BAUDRATE = 9600 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): def get_config(cursor, key, default):
cursor.execute("SELECT value FROM config_table WHERE key = ?", (key,)) cursor.execute("SELECT value FROM config_table WHERE key = ?", (key,))
@@ -22,9 +41,42 @@ def get_config(cursor, key, default):
def read_co2(ser): def read_co2(ser):
# TODO: implement the Senseair S88 read protocol once the datasheet is provided. ser.reset_input_buffer()
# Expected return: integer CO2 concentration in ppm, or None on failure. ser.write(READ_REQUEST)
raise NotImplementedError("Senseair S88 read protocol not implemented yet") 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(): def main():
@@ -65,8 +117,6 @@ def main():
) )
conn.commit() conn.commit()
print(f"CO2: {co2_ppm} ppm (saved at {rtc_time_str})") 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: except Exception as e:
print(f"S88 error: {e}") print(f"S88 error: {e}")
finally: finally:

View File

@@ -1 +1 @@
1.9.13 1.9.14

View File

@@ -1,5 +1,18 @@
{ {
"versions": [ "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", "version": "1.9.13",
"date": "2026-06-01", "date": "2026-06-01",