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:
166
S88/README.md
Normal file
166
S88/README.md
Normal 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 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
|
||||||
|
```
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user