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).
|
||||
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]
|
||||
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user