From 278775e7e8ec8bdb2406c8fbea20ef256b292b39 Mon Sep 17 00:00:00 2001 From: Paul Vuarambon Date: Thu, 23 Apr 2026 00:36:19 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20initial=20skeleton=20=E2=80=94=20NextP?= =?UTF-8?q?M=20sensor,=20JSON=20format,=20Miotiq=20UDP=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++ CONVENTIONS.md | 39 +++++++++ README.md | 44 ++++++++++ formats/json-payload.md | 83 ++++++++++++++++++ formats/mqtt.md | 38 +++++++++ parsers/udp-miotiq.md | 167 ++++++++++++++++++++++++++++++++++++ sensors/_TEMPLATE.md | 90 ++++++++++++++++++++ sensors/nextpm.md | 183 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 649 insertions(+) create mode 100644 .gitignore create mode 100644 CONVENTIONS.md create mode 100644 README.md create mode 100644 formats/json-payload.md create mode 100644 formats/mqtt.md create mode 100644 parsers/udp-miotiq.md create mode 100644 sensors/_TEMPLATE.md create mode 100644 sensors/nextpm.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f978af --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +.idea/ +.vscode/ +*.swp +*~ diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..b515a48 --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,39 @@ +# Conventions + +## Nommage des fichiers + +- Un capteur = un fichier `sensors/.md` en minuscules, sans tiret dans le nom s'il n'est pas dans la marque (`nextpm.md`, `sps30.md`, `bme280.md`). +- Un parser = un fichier `parsers/-.md` (`udp-miotiq.md`, `mqtt-tb.md`). +- Un format = un fichier `formats/.md` décrivant le schéma et les exemples. + +## Style Markdown + +- Titre de niveau 1 unique en haut du fichier. +- Tables pour les registres, commandes, champs de payload. +- Blocs de code annotés du langage (` ```c `, ` ```python `, ` ```json `). +- Unités explicites dans chaque champ : `µg/m³`, `°C`, `%HR`, `ms`. +- Endianness toujours précisée pour les champs multi-octets. + +## Versioning + +- Ce repo suit la branche `main`. Pas de tag de version pour l'instant. +- Si un protocole évolue de façon **incompatible**, créer une section « Historique » en bas du fichier capteur/format, dater le changement et décrire le breaking change. Les firmwares référencent la révision du fichier qu'ils implémentent. +- Pour les breaking changes majeurs (nouvelle structure payload, renommage de champ), ouvrir une PR et taguer le repo `vX` après merge. + +## Code d'exemple + +- Les snippets dans cette doc sont **illustratifs**. Le code de production vit dans les repos firmware/backend. +- Toujours lier vers le fichier source réel quand c'est possible (ex : `server/sites/data.mobileair.fr/udp_miotiq_byte.php`). +- Éviter les exemples qui dépendent de constantes secrètes (clés API, tokens) — remplacer par ``. + +## Unités et types de données canoniques + +| Grandeur | Unité | Type JSON | Notes | +|------------------------|---------|-----------|-------------------------------------| +| PM1 / PM2.5 / PM10 | µg/m³ | number | 1 décimale suffit | +| Température | °C | number | 1 décimale | +| Humidité relative | % | number | 0–100 | +| Pression | hPa | number | | +| Latitude / Longitude | degrés | number | WGS84, 6 décimales | +| Timestamp | secondes UTC | integer | Unix epoch, UTC toujours | +| Signal cellulaire | dBm ou % | integer | préciser selon capteur | diff --git a/README.md b/README.md new file mode 100644 index 0000000..552d4f1 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# aircarto-protocols + +Documentation de référence pour tous les capteurs AirCarto : protocoles de communication (UART, I2C, UDP, HTTP, MQTT…), formats de données, parsers et conventions. + +**Public visé** — chaque projet firmware ou backend AirCarto consomme cette doc pour intégrer la logique commune (dialogue capteur, format d'envoi, parsing côté serveur) sans réinventer ni diverger. + +## Structure + +``` +aircarto-protocols/ +├── CONVENTIONS.md Nommage, versioning, style doc +├── formats/ Formats d'échange de données +│ ├── json-payload.md Format JSON canonique des mesures +│ └── mqtt.md Topics et conventions MQTT +├── sensors/ Un fichier par capteur +│ ├── _TEMPLATE.md Gabarit à copier pour tout nouveau capteur +│ └── nextpm.md NextPM (Tera Sensor) — UART +└── parsers/ Parsers côté serveur / passerelle + └── udp-miotiq.md Webhook Miotiq (UDP → HTTPS JSON) +``` + +## Index des capteurs + +| Capteur | Interface | Doc | État | +|-------------|-----------|-----------------------------------|------------| +| NextPM | UART | [sensors/nextpm.md](sensors/nextpm.md) | Complet | + +## Index des parsers + +| Nom | Transport | Doc | État | +|-----------------|------------------|-----------------------------------------------|---------| +| UDP Miotiq | UDP → HTTPS JSON | [parsers/udp-miotiq.md](parsers/udp-miotiq.md) | Complet | + +## Comment ajouter une entrée + +- **Nouveau capteur** : copier `sensors/_TEMPLATE.md` vers `sensors/.md`, remplir les sections, mettre à jour l'index ci-dessus. +- **Nouveau format / parser** : créer le fichier sous `formats/` ou `parsers/`, mettre à jour l'index. +- Voir [CONVENTIONS.md](CONVENTIONS.md) pour le style et le nommage. + +## Pourquoi ce repo + +Avant : chaque firmware AirCarto (NebuleAir, ModuleAir, MobileAir…) redéfinissait ses trames, ses topics et son format JSON dans son coin. Les parsers côté serveur (`data.mobileair.fr/udp_miotiq_*.php`, `gestion.aircarto.fr`) devaient suivre. Résultat : dérives silencieuses entre capteurs, bugs d'intégration. + +Ici on centralise la **spécification**. Le code de référence reste dans les repos des projets (firmwares, backends) ; ce repo décrit ce qui est attendu sur le fil. diff --git a/formats/json-payload.md b/formats/json-payload.md new file mode 100644 index 0000000..3b206df --- /dev/null +++ b/formats/json-payload.md @@ -0,0 +1,83 @@ +# Format JSON canonique — mesures capteurs + +Format recommandé pour tout nouvel envoi de mesures d'un capteur AirCarto vers un backend (HTTP POST, MQTT publish, webhook…). + +> Les chemins d'envoi existants (Miotiq binaire 17 octets, CSV) restent documentés dans [`parsers/udp-miotiq.md`](../parsers/udp-miotiq.md) pour compatibilité. **Ce JSON est la cible** pour les nouveaux développements. + +## Schéma + +```json +{ + "token": "string", + "ts": 1713830400, + "type_conn": "LTE-M", + "measurements": { + "pm1": 0.0, + "pm25": 0.0, + "pm10": 0.0, + "temperature": 0.0, + "humidity": 0.0 + }, + "gps": { + "lat": 43.605, + "lon": 1.444, + "sats": 8 + }, + "link": { + "signal": -78, + "imsi": "208xxxxxxxxxxx" + }, + "fw": "1.4.2" +} +``` + +## Champs + +| Champ | Type | Obligatoire | Description | +|-------------------------|---------|-------------|--------------------------------------------------------------------| +| `token` | string | oui | Identifiant unique du capteur (= clé en base `capteurs.capteurs`). | +| `ts` | integer | oui | Timestamp Unix UTC en secondes de la mesure (pas de la réception). | +| `type_conn` | string | oui | Un des : `WiFi`, `LTE-M`, `LTE-BIN`, `NB-IoT`, `LoRa`, `Ethernet`. | +| `measurements.pm1` | number | si mesuré | µg/m³ | +| `measurements.pm25` | number | si mesuré | µg/m³ | +| `measurements.pm10` | number | si mesuré | µg/m³ | +| `measurements.temperature` | number | si mesuré | °C | +| `measurements.humidity` | number | si mesuré | % HR | +| `gps.lat` | number | si GPS | Degrés décimaux WGS84, 6 décimales. | +| `gps.lon` | number | si GPS | Idem. | +| `gps.sats` | integer | si GPS | Nombre de satellites vus. | +| `link.signal` | integer | non | RSSI en dBm (négatif) **ou** % de qualité ; préciser dans `type_conn`. | +| `link.imsi` | string | non | IMSI de la SIM (modem cellulaire uniquement). | +| `fw` | string | non | Version firmware du capteur, ex. `1.4.2`. | + +## Règles + +- Omettre un champ si non mesuré. Ne pas envoyer `null` ni `-1` comme valeur sentinelle (hérité du legacy CSV Miotiq). +- `ts` côté capteur si dispo (GPS / NTP), sinon côté serveur à la réception — documenter au cas par cas dans la doc du capteur. +- Toutes les valeurs numériques utilisent le point `.` comme séparateur décimal. +- Un seul capteur par message. Pas de batch (à revoir si besoin). + +## Réponse attendue du serveur + +```json +{ "ok": true } +``` + +Ou en cas d'erreur : + +```json +{ "ok": false, "error": "token inconnu" } +``` + +HTTP 200 dans les deux cas pour ne pas déclencher de retry cellulaire côté modem ; le `ok: false` indique à la supervision qu'il y a un problème applicatif. + +## Exemple minimal + +```json +{ + "token": "001", + "ts": 1713830400, + "type_conn": "WiFi", + "measurements": { "pm25": 12.3 } +} +``` diff --git a/formats/mqtt.md b/formats/mqtt.md new file mode 100644 index 0000000..67dda6f --- /dev/null +++ b/formats/mqtt.md @@ -0,0 +1,38 @@ +# MQTT — conventions + +> **Statut** : squelette. À compléter au premier déploiement MQTT AirCarto. + +## Broker + +- À définir : URL, port (1883 / 8883 TLS), credentials. + +## Topics + +Convention proposée : + +``` +aircarto/// +``` + +- `projet` : `nebuleair`, `moduleair`, `mobileair`, … +- `token` : identifiant unique du capteur. +- `canal` : + - `telemetry` : mesures périodiques (publish capteur → broker), payload JSON conforme à [`json-payload.md`](json-payload.md). + - `status` : en ligne / hors ligne, version firmware (retain, LWT). + - `cmd` : commandes broker → capteur (ex. reboot, changer la fréquence). + - `ack` : accusés capteur → broker. + +Exemple : `aircarto/nebuleair/001/telemetry`. + +## QoS et retain + +- `telemetry` : **QoS 0** (fire-and-forget, InfluxDB tolère les pertes occasionnelles). +- `status` : **QoS 1** + **retain = true** + LWT pour détecter les déconnexions. +- `cmd` : **QoS 1**, pas de retain. + +## À faire + +- [ ] Nommer le broker de référence (Mosquitto ? HiveMQ ? EMQX ?). +- [ ] Documenter l'authentification (username+password, certificats ?). +- [ ] Schéma exact des payloads `status` et `cmd`. +- [ ] Exemple de wildcard subscription côté backend. diff --git a/parsers/udp-miotiq.md b/parsers/udp-miotiq.md new file mode 100644 index 0000000..82edcad --- /dev/null +++ b/parsers/udp-miotiq.md @@ -0,0 +1,167 @@ +# Parser UDP Miotiq + +[Miotiq](https://app.miotiq.com/) est la plateforme IoT cellulaire utilisée par AirCarto pour la connectivité LTE-M / NB-IoT (MobileAir, NebuleAir Pro 4G). Les capteurs envoient des **datagrammes UDP** vers un endpoint Miotiq ; Miotiq forwarde ces datagrammes à un webhook HTTPS AirCarto sous forme de **POST JSON**. + +Chemin de données : + +``` +Capteur ──UDP──> Miotiq ──HTTPS POST JSON──> data.mobileair.fr/udp_miotiq_*.php ──> PostgreSQL + InfluxDB +``` + +## Enveloppe JSON reçue du webhook + +Le corps `application/json` reçu par le script PHP contient : + +```json +{ + "payload": "", + "customerId": "string", + "rcvTime": 1713830400, + "srcIP": "10.x.x.x", + "srcImsi": "208xxxxxxxxxxx" +} +``` + +| Champ | Type | Description | +|--------------|---------|--------------------------------------------------------------------| +| `payload` | string | **Base64** des octets UDP envoyés par le capteur (charge utile brute). | +| `customerId` | string | Identifiant client Miotiq. | +| `rcvTime` | integer | Timestamp Unix UTC de la réception Miotiq, en secondes. | +| `srcIP` | string | IP source du modem cellulaire (côté opérateur). | +| `srcImsi` | string | IMSI de la SIM, sert à rattacher la mesure à un capteur en DB. | + +## Formats de payload interne + +Après décodage base64, le contenu est soit **binaire**, soit **CSV**, selon le firmware du capteur. + +### Format binaire (17 octets) — MobileAir + +Format packé big-endian, 17 octets. Parser de référence : [`data.mobileair.fr/udp_miotiq_byte.php`](https://gitea.aircarto.fr/PaulVua) (endpoint `/udp_miotiq_byte.php`). + +| Offset | Taille | Champ | Type | Décodage | +|--------|--------|------------------|--------|---------------------------------| +| 0 | 1 | `device_id` | uint8 | `str(val).zfill(3)` → token `"001"` | +| 1 | 2 | `pm1_x10` | uint16 BE | `raw / 10.0` → µg/m³ | +| 3 | 2 | `pm25_x10` | uint16 BE | `raw / 10.0` → µg/m³ | +| 5 | 2 | `pm10_x10` | uint16 BE | `raw / 10.0` → µg/m³ | +| 7 | 2 | `lat_x10000` | uint16 BE | `raw / 10000.0` → degrés (0 si pas de fix) | +| 9 | 2 | `lon_x10000` | uint16 BE | `raw / 10000.0` → degrés (0 si pas de fix) | +| 11 | 1 | `num_sats` | uint8 | nombre de satellites | +| 12 | 1 | `signal_quality` | uint8 | % qualité modem | +| 13 | 1 | `moving_type` | uint8 | énumération déplacement | + +Format C sur capteur (pseudo, big-endian packé) : + +```c +struct __attribute__((packed)) mobileair_udp_t { + uint8_t device_id; + uint16_t pm1_x10; // htons avant envoi + uint16_t pm25_x10; + uint16_t pm10_x10; + uint16_t lat_x10000; + uint16_t lon_x10000; + uint8_t num_sats; + uint8_t signal_quality; + uint8_t moving_type; +}; // sizeof = 14 — attention : le format sur le fil fait 17 octets +``` + +> Un ancien format **15 octets** existe (sans `lat`/`lon`/`moving_type`) — le parser PHP le gère en fallback. Ne plus l'utiliser pour un nouveau firmware. + +Python équivalent pour lire / écrire : + +```python +import struct, base64 + +FMT = ">B HHHHH BBB" # 17 octets + +def pack(device_id, pm1, pm25, pm10, lat, lon, sats, sig, moving): + return struct.pack(FMT, device_id, + int(pm1*10), int(pm25*10), int(pm10*10), + int(lat*10000), int(lon*10000), + sats, sig, moving) + +def unpack(data: bytes): + if len(data) != 17: + raise ValueError(f"payload {len(data)} octets, attendu 17") + dev, pm1, pm25, pm10, lat, lon, sats, sig, moving = struct.unpack(FMT, data) + return { + "device_id": f"{dev:03d}", + "pm1": pm1 / 10.0, + "pm25": pm25 / 10.0, + "pm10": pm10 / 10.0, + "lat": lat / 10000.0, + "lon": lon / 10000.0, + "sats": sats, + "signal": sig, + "moving_type": moving, + } + +# Côté webhook +body = {"payload": base64.b64encode(pack(1, 12.3, 18.5, 22.1, 43.605, 1.444, 8, 80, 1)).decode(), + "customerId": "aircarto", "rcvTime": 1713830400, + "srcIP": "10.0.0.1", "srcImsi": "208010000000001"} +``` + +### Format CSV — MobileAir (legacy) + +Parser de référence : endpoint `/udp_miotiq_csv.php`. + +Le payload base64-décodé est une chaîne ASCII : + +``` +{device_id},{pm1},{pm25},{pm10},{lat},{lon},{num_sats},{signal_quality},{moving_type} +``` + +Exemple : `001,12.3,18.5,22.1,43.605000,1.444000,8,80,1` + +- Séparateur : virgule `,`. +- Décimales : point `.`. +- Valeurs manquantes : `-1` (sentinelle legacy — à **ne pas reproduire** pour les nouveaux formats, voir [`formats/json-payload.md`](../formats/json-payload.md)). + +## Côté serveur — squelette du webhook PHP + +```php +` — état d'un device par IMSI. + - `POST https://app.miotiq.com/api/device/update?api_key=` — renommer / associer un device (voir `miotiq-update` dans `routes/sensors.js`). + +## Historique + +| Date | Révision | Changement | +|------------|----------|-------------------------------------------------------| +| 2026-04-23 | v1 | Création à partir des parsers PHP en prod. | diff --git a/sensors/_TEMPLATE.md b/sensors/_TEMPLATE.md new file mode 100644 index 0000000..6e3146b --- /dev/null +++ b/sensors/_TEMPLATE.md @@ -0,0 +1,90 @@ +# <Nom du capteur> + +Remplace ce bloc par une ligne de résumé : fabricant, grandeurs mesurées, interface principale. + +> Exemple : « Tera Sensor NextPM — capteur de particules PM1/PM2.5/PM10, interface UART. » + +## Caractéristiques + +| Paramètre | Valeur | +|--------------------------|-------------------------------| +| Fabricant / modèle | | +| Grandeurs mesurées | PM1, PM2.5, PM10 / T / HR / … | +| Plage | | +| Précision | | +| Alimentation | 3V3 / 5V, mA typique | +| Interface | UART / I2C / SPI / analogique | +| Datasheet | URL | + +## Câblage + +Lister **chaque fil** avec sa couleur, le pin côté capteur et le pin côté MCU de référence. + +| Fil capteur | Pin capteur | Fonction | Pin MCU (exemple nRF9151) | +|-------------|-------------|----------|---------------------------| +| Rouge | 1 | VCC | 3V3 | +| Noir | 2 | GND | GND | +| … | | | | + +## Protocole + +- Transport : UART 115200 8N1 / I2C 100 kHz / … +- Adresse (si I2C) : +- Polarité, endianness : + +### Format de trame + +Décrire exactement la structure : header, champs, checksum, taille. + +``` ++------+-----+------+------+-----+ +| HDR | CMD | LEN | DATA | CS | ++------+-----+------+------+-----+ + 1B 1B 1B Nb 1B +``` + +Checksum : algorithme exact (`256 - sum(prev) mod 256`, CRC8, CRC16 poly…). + +### Commandes + +| Commande | Code | Requête | Réponse (taille, champs) | +|----------|------|------------------------|-----------------------------------| +| Read X | 0x11 | `81 11 …` | `81 11 state cs` | +| Sleep | 0x15 | | | + +### Décodage des champs + +Pour chaque donnée retournée, précise : +- Position, taille, endianness. +- Facteur d'échelle (ex. `val / 10.0` pour obtenir des µg/m³). +- Unité finale. + +## Exemple de code + +### Lecture (pseudo-C) + +```c +// TODO +``` + +### Lecture (Python) + +```python +# TODO +``` + +## Spécificités connues / pièges + +- … + +## Références + +- Datasheet : URL +- Code de référence utilisé en prod : `/src/drivers/` +- Note d'application constructeur : URL + +## Historique + +| Date | Révision | Changement | +|------------|----------|-----------------------------------------------| +| YYYY-MM-DD | v1 | Création de la doc. | diff --git a/sensors/nextpm.md b/sensors/nextpm.md new file mode 100644 index 0000000..045e664 --- /dev/null +++ b/sensors/nextpm.md @@ -0,0 +1,183 @@ +# NextPM (Tera Sensor) + +Capteur de particules **Tera Sensor NextPM** : mesure PM1, PM2.5, PM10 en masse (µg/m³) et en nombre (#/cm³), intègre un capteur de température et d'humidité embarqué. Communication **UART**. + +## Caractéristiques + +| Paramètre | Valeur | +|------------------------|--------------------------------------------------| +| Fabricant / modèle | Tera Sensor — NextPM | +| Grandeurs | PM1, PM2.5, PM10 (µg/m³ et #/cm³), T (°C), HR (%) | +| Plage PM | 0 – 1000 µg/m³ | +| Alimentation | 5 V, ~100 mA pic, ~35 mA en veille | +| Interface | UART 3V3 (logique CMOS 3.3 V tolérante 5 V TX) | +| Vitesse UART par défaut | 115200 baud, 8N1 | +| Datasheet | https://www.tera-sensor.com/ | + +## Câblage + +Connecteur JST-PH 4 broches du NextPM (vue côté capteur) : + +| Pin capteur | Fil usuel | Fonction | MCU (exemple) | +|-------------|-----------|----------------|------------------------| +| 1 | Rouge | VCC 5 V | 5V | +| 2 | Noir | GND | GND | +| 3 | Blanc | RX capteur (← TX MCU) | UART TX du MCU | +| 4 | Vert | TX capteur (→ RX MCU) | UART RX du MCU | + +> Le NextPM est en 3V3 côté logique : si le MCU est en 3V3 (nRF9151, ESP32…), connecter directement. Pour un MCU 5 V, prévoir un level shifter sur TX→RX. + +## Protocole UART + +- 115200 bauds, 8 bits, pas de parité, 1 stop. +- Toutes les trames commencent par **`0x81`** (préambule). +- Checksum = `(256 - somme(octets précédents)) mod 256`, placé en dernier octet. +- Délai de traitement côté capteur : 15–30 ms typique ; prévoir timeout de lecture de 200 ms. + +### Structure des trames + +**Requête MCU → capteur** (sans données) : + +``` ++------+------+------+ +| 0x81 | CMD | CS | ++------+------+------+ +``` + +**Requête MCU → capteur** (avec données) : + +``` ++------+------+----------+------+ +| 0x81 | CMD | DATA... | CS | ++------+------+----------+------+ +``` + +**Réponse capteur → MCU** : + +``` ++------+------+-------+------------+------+ +| 0x81 | CMD | STATE | DATA... | CS | ++------+------+-------+------------+------+ +``` + +`STATE` (1 octet) est un bitfield d'état : + +| Bit | Signification | +|-----|-------------------------------------------------| +| 0 | Fan default (1 = vitesse dégradée / anormale) | +| 1 | Memory error | +| 2 | Sensor laser default | +| 3 | T/RH sensor default | +| 4 | Sleep mode | +| 5–7 | Réservés | + +Un `STATE == 0x00` indique un fonctionnement nominal. + +### Commandes principales + +| Commande | Code | Taille requête | Taille réponse | Notes | +|-------------------------------|-------|----------------|----------------|----------------------------------------------| +| Read concentrations 10 s | 0x11 | 3 | 16 | Moyenne glissante 10 s | +| Read concentrations 60 s | 0x12 | 3 | 16 | Moyenne glissante 60 s (plus stable) | +| Read concentrations 900 s | 0x13 | 3 | 16 | Moyenne glissante 15 min | +| Read T/RH | 0x14 | 3 | 10 | Température et humidité | +| Sleep (fan off) | 0x15 | 3 | 4 | Passe en veille, ventilateur coupé | +| Wake / fan on | 0x16 | 3 | 4 | Sort de veille | +| Set fan speed | 0x17 | 4 | 4 | 1 octet supplémentaire (% vitesse) | +| Set clock | 0x21 | 9 | 4 | Horodatage interne | +| Read firmware version | 0x41 | 3 | variable | Retourne une chaîne ASCII | + +> Les codes et tailles ci-dessus sont issus de l'intégration de référence. **Toujours recroiser avec la datasheet Tera Sensor la plus récente** avant d'implémenter une nouvelle version firmware — certains registres ont changé entre révisions hardware. + +### Format de la réponse `0x11` / `0x12` / `0x13` + +16 octets de DATA, big-endian : + +| Offset | Taille | Champ | Unité | Décodage | +|--------|--------|-----------------------|---------------|-------------------------| +| 0 | 2 | PM1 number | #/cm³ | valeur brute | +| 2 | 2 | PM2.5 number | #/cm³ | valeur brute | +| 4 | 2 | PM10 number | #/cm³ | valeur brute | +| 6 | 2 | PM1 mass | µg/m³ × 10 | `raw / 10.0` | +| 8 | 2 | PM2.5 mass | µg/m³ × 10 | `raw / 10.0` | +| 10 | 2 | PM10 mass | µg/m³ × 10 | `raw / 10.0` | + +### Format de la réponse `0x14` + +| Offset | Taille | Champ | Unité | Décodage | +|--------|--------|---------------|--------------|---------------------------| +| 0 | 2 | Température | °C × 100 | `raw / 100.0` (signed) | +| 2 | 2 | Humidité | %HR × 100 | `raw / 100.0` | + +## Exemple de code + +### Construction de la requête (C) + +```c +#include +#include + +static uint8_t nextpm_checksum(const uint8_t *buf, size_t len) { + uint32_t sum = 0; + for (size_t i = 0; i < len; i++) sum += buf[i]; + return (uint8_t)(256 - (sum & 0xFF)); +} + +// Envoie une commande sans données (0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x41) +size_t nextpm_build_cmd(uint8_t cmd, uint8_t *out) { + out[0] = 0x81; + out[1] = cmd; + out[2] = nextpm_checksum(out, 2); + return 3; +} +``` + +### Décodage de la réponse 0x11 / 0x12 / 0x13 (Python) + +```python +import struct + +def decode_pm(frame: bytes) -> dict: + if len(frame) != 16 or frame[0] != 0x81: + raise ValueError("trame NextPM invalide") + cmd, state = frame[1], frame[2] + data = frame[3:15] + cs_expected = (256 - sum(frame[:15])) & 0xFF + if cs_expected != frame[15]: + raise ValueError("checksum NextPM incorrect") + + pm1_n, pm25_n, pm10_n, pm1_m, pm25_m, pm10_m = struct.unpack(">HHHHHH", data) + return { + "state": state, + "pm1_num": pm1_n, + "pm25_num": pm25_n, + "pm10_num": pm10_n, + "pm1": pm1_m / 10.0, + "pm25": pm25_m / 10.0, + "pm10": pm10_m / 10.0, + } +``` + +## Mise en œuvre recommandée + +1. Au boot : `0x16` (wake) puis laisser 30 s de stabilisation du flux avant de lire. +2. Utiliser `0x12` (moyenne 60 s) pour l'envoi réseau standard — meilleur compromis bruit/latence. +3. Loguer `STATE` à chaque lecture ; remonter au backend si `STATE != 0`. +4. En cas de cycle veille/mesure (ex. applications sur batterie) : `0x15` (sleep), attendre la prochaine fenêtre, `0x16`, 30 s stabilisation, lire, renvoyer en sleep. + +## Pièges connus + +- Les **2 premières minutes** après le wake sont à ignorer (le ventilateur monte en régime, concentrations sous-estimées). +- Si le checksum est faux une fois sur deux : vérifier la masse commune (GND) entre MCU et NextPM — flottement du GND observé sur certains câbles longs. +- Le NextPM renvoie parfois un octet `0x00` avant le préambule si la ligne UART n'était pas propre au démarrage : implémenter une resynchronisation sur `0x81` côté parser MCU. + +## Références + +- Datasheet : https://www.tera-sensor.com/ (demander la dernière révision PDF) +- Code firmware de référence AirCarto : `nebuleair_pro_4g` et `esp32_NPM_only` sur [gitea.aircarto.fr](https://gitea.aircarto.fr/PaulVua). + +## Historique + +| Date | Révision | Changement | +|------------|----------|---------------------------------| +| 2026-04-23 | v1 | Création de la doc. |