Files
aircarto-protocols/formats/udp-miotiq.md
Your Name ace447c933 docs(co2): ajouter ISO_17 (CO2, ppm) au protocole NebuleAir Pro 4G
- iso-pollutant-codes.md: code ISO_17 = CO2 (ppm), sentinelle 0xFFFF
- udp-miotiq.md: ISO_17 consomme les 2 octets reserved (offset 81),
  trame inchangee a 83 octets, retrocompat (0 = anciens fw)
- json-payload.md: tableau polluants, note sentinelle, exemple

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:44:25 +02:00

227 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Parser UDP Miotiq
[Miotiq](https://app.miotiq.com/) est la plateforme IoT cellulaire utilisée par AirCarto pour la connectivité LTE-M / NB-IoT (NebuleAir Pro 4G, MobileAir, ModuleAir 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──> api.aircarto.com/receive_data ──> PostgreSQL + InfluxDB
```
**Principe** : chaque capteur a un **descripteur Miotiq** (format texte pipe-séparé) qui décrit l'ordonnancement et le décodage de sa charge utile. Miotiq applique ce descripteur à l'ingestion et poste sur le backend le **JSON canonique AirCarto** spécifié dans [`json-payload.md`](json-payload.md) — c'est ce JSON-là que consomment les scripts PHP `receive_data`.
Ce document couvre uniquement le **format du descripteur** et le **layout binaire** par capteur. Le schéma du JSON de sortie (champs, unités, bitfields, exemple complet) vit dans [`json-payload.md`](json-payload.md).
Métadonnées transport ajoutées par Miotiq en marge du JSON décodé (utiles pour l'audit / le rattachement device) : `srcImsi` (IMSI de la SIM — clé de correspondance device en DB), `rcvTime` (timestamp Unix UTC réception Miotiq), `srcIP`, `customerId`. En mode webhook « raw » (sans descripteur), Miotiq peut aussi poster le datagramme brut sous `payload` encodé base64 — utile si le backend veut re-décoder côté serveur (parser serveur à documenter séparément).
## Format descripteur Miotiq
Un descripteur est une suite de lignes, une par champ. **Format officiel Miotiq** (cf. doc plateforme, section « How to create a Parsing table ») :
```
<length>|<variable name>|<base function>|<units>|<equation>|<export to JSON>
```
| Colonne | Description |
|----------------------|--------------------------------------------------------------------------------------------------|
| `length` | Taille du champ en **caractères hexadécimaux** (2 chars = 1 octet). |
| `variable name` | Identifiant logique du champ. Les codes polluants suivent [ISO 7168 AirCarto](iso-pollutant-codes.md). |
| `base function` | Fonction de décodage appliquée aux octets bruts. Voir tableau ci-dessous. |
| `units` | Unité physique finale (`ugm3`, `degC`, `%`, `hPa`, `ppb`, `ppm`, `dB`, `V`, `A`, `W`, `m/s`, `degrees`, `count`). Vide pour les champs d'état / versions. |
| `equation` | Expression de transformation appliquée à la valeur décodée, où `x` est la valeur. Ex. `x/10`, `x/100`, `(x-32)*5/9`. Vide = pas de transformation. |
| `export to JSON` | Contrôle la sortie JSON côté Miotiq (voir ci-dessous). Valeurs : `Y` (défaut), `W`, `N`. |
### Base functions
| Valeur | Effet |
|------------|-------------------------------------------------------------------------------------------------|
| `string` | Sort la **représentation hexadécimale** des octets tels quels (n'effectue *pas* de décodage ASCII). Ex. `device_id` 8 octets `0x44 30 35 32 34 31 39 38` sort `"4430353234313938"` ; le client convertit en ASCII si besoin (`bytes.fromhex(v).decode("ascii")``"D0524198"`). |
| `hex2dec` | Convertit les octets en entier non-signé **big-endian**. |
| `hex2bin` | Convertit les octets en chaîne binaire (bitfield lisible, utile pour les registres d'état). |
| `userdef` | Pas de transformation — les octets bruts sont passés tels quels comme valeur. |
| `skip` | Observé dans nos descripteurs (`reserved`) pour ignorer N octets. Les octets sautés ne sortent pas dans le JSON. |
### Export to JSON
| Valeur | Comportement |
|-----------|---------------------------------------------------------------------------------------------------------|
| `Y` (défaut) | Exporte la valeur **et** une ligne d'unité : `"battery_voltage": 3600, "battery_voltage_unit": "3600 mV"`. |
| `W` | Exporte la valeur seule, sans ligne d'unité : `"battery_voltage": 3600`. |
| `N` | N'exporte rien dans le JSON transmis au webhook. |
**Conventions implicites** :
- Les champs `hex2dec` multi-octets sont **big-endian** (à vérifier au cas par cas avec Miotiq si un doute apparaît).
- Les champs `string` sont ASCII, non null-terminés, padés à droite.
- L'ordre des lignes du descripteur est l'ordre des octets sur le fil : pas de séparateur, pas d'alignement.
- La taille totale du payload = somme des `length / 2`. Toute trame de taille différente doit être rejetée.
## Descripteurs actuels
### NebuleAir Pro 4G (83 octets = 166 chars hex)
Descripteur de référence :
```
16|device_id|string|||W
2|signal_quality|hex2dec|dB||
2|command|hex2dec|||W
4|ISO_68|hex2dec|ugm3|x/10|
4|ISO_39|hex2dec|ugm3|x/10|
4|ISO_24|hex2dec|ugm3|x/10|
4|ISO_54|hex2dec|degC|x/100|
4|ISO_55|hex2dec|%|x/100|
4|ISO_53|hex2dec|hPa||
4|noise_cur_leq|hex2dec|dB|x/10|
4|noise_cur_level|hex2dec|dB|x/10|
4|max_noise|hex2dec|dB|x/10|
4|ISO_03|hex2dec|ppb||
4|ISO_05|hex2dec|ppb||
4|ISO_21|hex2dec|ppb||
4|ISO_04|hex2dec|ppb||
4|ISO_08|hex2dec|ppb||
4|npm_ch1|hex2dec|count||
4|npm_ch2|hex2dec|count||
4|npm_ch3|hex2dec|count||
4|npm_ch4|hex2dec|count||
4|npm_ch5|hex2dec|count||
4|npm_temp|hex2dec|°C|x/10|
4|npm_humidity|hex2dec|%|x/10|
4|battery_voltage|hex2dec|V|x/100|
4|battery_current|hex2dec|A|x/100|
4|solar_voltage|hex2dec|V|x/100|
4|solar_power|hex2dec|W||
4|charger_status|hex2dec|||
4|wind_speed|hex2dec|m/s|x/10|
4|wind_direction|hex2dec|degrees||
2|error_flags|hex2dec|||
2|npm_status|hex2dec|||
2|device_status|hex2dec|||
2|version_major|hex2dec|||
2|version_minor|hex2dec|||
2|version_patch|hex2dec|||
8|latitude|hex2dec|degrees|x/1000000-90|
8|longitude|hex2dec|degrees|x/1000000-180|
2|misc|hex2dec|||
4|ISO_17|hex2dec|ppm||
```
**Encodage `latitude` / `longitude`**`hex2dec` est **non-signé** côté Miotiq. Pour transmettre des coordonnées négatives sans ambiguïté, le firmware encode avec un offset fixe :
- `raw_lat = round((lat_deg + 90) * 1_000_000)` — range attendue `[0, 180_000_000]`, tient dans uint32.
- `raw_lon = round((lon_deg + 180) * 1_000_000)` — range `[0, 360_000_000]`, tient dans uint32.
Miotiq applique l'équation inverse (`x/1000000-90`, `x/1000000-180`) et exporte directement des degrés WGS84 signés dans le JSON. Précision ~11 cm (6 décimales), conforme à [`CONVENTIONS.md`](../CONVENTIONS.md).
**Pas de sentinelle numérique pour « no fix »** : quand le GPS n'a pas de fix, le firmware positionne le bit `GPS_NO_FIX` dans `device_status` (voir [`json-payload.md`](json-payload.md#device_status--bitfield-boîtier-1-octet)). Le backend ignore `latitude`/`longitude` quand ce bit est levé, indépendamment de leur valeur brute.
Les firmwares antérieurs à cette extension envoyaient déjà 83 octets (bloc `reserved` = 11 zéros), qui décodent désormais comme `lat=-90, lon=-180, misc=0`. Sur ces firmwares `device_status = 0xFF` (= champ non supporté — cf. [`json-payload.md`](json-payload.md#error_flags--bitfield-système-1-octet)) : un backend prudent traite donc la combinaison `device_status == 0xFF && (lat, lon) == (-90, -180)` comme « coords non disponibles ».
**CO₂ (`ISO_17`, offset 81)** — ce champ réutilise les 2 octets jadis `reserved` : la trame reste à **83 octets** (aucune trame déployée n'est rejetée). Le CO₂ est encodé en **ppm bruts** (`uint16`, pas d'équation, ambiant ≈ 420 ppm). Comme `reserved` était émis à `0x0000`, les firmwares sans capteur CO₂ décodent `ISO_17 = 0` ; les firmwares CO₂ utilisent la sentinelle `0xFFFF` (65535) quand le capteur est absent ou en défaut. Le backend traite donc `ISO_17 ∈ {0, 0xFFFF}` comme « CO₂ non disponible ».
Layout octet par octet :
| Offset | Taille | Champ | Unité | Scale | Notes |
|--------|--------|-------------------|---------|--------|------------------------------------------|
| 0 | 8 | `device_id` | | | Hex 16 chars, converti en ASCII côté client (ex. `"D0524198"`) |
| 8 | 1 | `signal_quality` | dB | | Signal cellulaire |
| 9 | 1 | `command` | | | Type de trame — `0x00` / `0x01` = données mesure, `0x02` = ping test (déclenché par le firmware pour vérifier le lien capteur → Miotiq → backend). `0x01` est conservé comme synonyme de mesure normale par rétrocompatibilité (firmwares existants hardcodés `0x01` quand l'octet portait encore le nom `version`). Voir [`json-payload.md`](json-payload.md#commande--type-de-trame). |
| 10 | 2 | `ISO_68` | µg/m³ | /10 | PM1 |
| 12 | 2 | `ISO_39` | µg/m³ | /10 | PM2.5 |
| 14 | 2 | `ISO_24` | µg/m³ | /10 | PM10 |
| 16 | 2 | `ISO_54` | °C | /100 | Température |
| 18 | 2 | `ISO_55` | % HR | /100 | Humidité |
| 20 | 2 | `ISO_53` | hPa | | Pression |
| 22 | 2 | `noise_cur_leq` | dB | /10 | Leq instantané |
| 24 | 2 | `noise_cur_level` | dB | /10 | Niveau sonore courant |
| 26 | 2 | `max_noise` | dB | /10 | Crête |
| 28 | 2 | `ISO_03` | ppb | | NO₂ |
| 30 | 2 | `ISO_05` | ppb | | H₂S |
| 32 | 2 | `ISO_21` | ppb | | NH₃ |
| 34 | 2 | `ISO_04` | ppb | | CO |
| 36 | 2 | `ISO_08` | ppb | | O₃ |
| 38 | 2 | `npm_ch1` | count | | NextPM — nombre de particules canal 1 |
| 40 | 2 | `npm_ch2` | count | | NextPM canal 2 |
| 42 | 2 | `npm_ch3` | count | | NextPM canal 3 |
| 44 | 2 | `npm_ch4` | count | | NextPM canal 4 |
| 46 | 2 | `npm_ch5` | count | | NextPM canal 5 |
| 48 | 2 | `npm_temp` | °C | /10 | NextPM T interne |
| 50 | 2 | `npm_humidity` | % | /10 | NextPM HR interne |
| 52 | 2 | `battery_voltage` | V | /100 | |
| 54 | 2 | `battery_current` | A | /100 | Signé ? à confirmer (décharge = négatif ?) |
| 56 | 2 | `solar_voltage` | V | /100 | |
| 58 | 2 | `solar_power` | W | | |
| 60 | 2 | `charger_status` | | | Bitfield, cf. firmware |
| 62 | 2 | `wind_speed` | m/s | /10 | |
| 64 | 2 | `wind_direction` | degrés | | 0359, 0 = Nord |
| 66 | 1 | `error_flags` | | | Bitfield erreurs système, détail dans [`json-payload.md`](json-payload.md#error_flags--bitfield-système-1-octet). `0xFF` = firmware ancien. |
| 67 | 1 | `npm_status` | | | Bitfield statut NextPM (copie du `STATE` UART, voir [`sensors/nextpm.md`](../sensors/nextpm.md) et [`json-payload.md`](json-payload.md#npm_status--bitfield-nextpm-1-octet)). `0xFF` = firmware ancien. |
| 68 | 1 | `device_status` | | | Bitfield état boîtier, détail dans [`json-payload.md`](json-payload.md#device_status--bitfield-boîtier-1-octet). `0xFF` = firmware ancien. |
| 69 | 1 | `version_major` | | | Version firmware `X.y.z` |
| 70 | 1 | `version_minor` | | | `x.Y.z` |
| 71 | 1 | `version_patch` | | | `x.y.Z` |
| 72 | 4 | `latitude` | degrés | /1e6 90 | WGS84, offset unsigned. Voir encodage ci-dessus. Ignoré si `device_status.GPS_NO_FIX`. |
| 76 | 4 | `longitude` | degrés | /1e6 180 | WGS84, offset unsigned. Idem. |
| 80 | 1 | `misc` | | | Contexte de mesure 06 (voir [`json-payload.md`](json-payload.md#géolocalisation--contexte)) |
| 81 | 2 | `ISO_17` | ppm | | CO₂. `uint16` ppm (pas d'équation). `0xFFFF` = capteur CO₂ absent ; `0` (anciens fw, ex-`reserved`) = non mesuré. |
| **83** | total | | | | |
### MobileAir (17 octets — legacy, pré-descripteur)
> Ce capteur envoie encore un format binaire packé **sans descripteur Miotiq formel**. Migration prévue vers la même approche descripteur que NebuleAir Pro 4G.
Format packé big-endian, 17 octets. Parsers de référence en prod : `server/sites/data.mobileair.fr/udp_miotiq_byte.php` (binaire) et `udp_miotiq_csv.php` (CSV).
| Offset | Taille | Champ | Unité | Décodage |
|--------|--------|------------------|-----------|----------------------------------|
| 0 | 1 | `device_id` | | `str(val).zfill(3)` |
| 1 | 2 | `pm1_x10` | µg/m³ × 10 | `/10.0` |
| 3 | 2 | `pm25_x10` | µg/m³ × 10 | `/10.0` |
| 5 | 2 | `pm10_x10` | µg/m³ × 10 | `/10.0` |
| 7 | 2 | `lat_x10000` | deg × 1e4 | `/10000.0` (0 si pas de fix) |
| 9 | 2 | `lon_x10000` | deg × 1e4 | `/10000.0` |
| 11 | 1 | `num_sats` | | nombre de satellites |
| 12 | 1 | `signal_quality` | % | |
| 13 | 1 | `moving_type` | | énumération déplacement |
Un ancien format **15 octets** existe aussi (sans `lat`/`lon`/`moving_type`) — le parser PHP le gère en fallback. À ne plus utiliser pour du nouveau firmware.
Format CSV (base64-décodé = chaîne ASCII) :
```
{device_id},{pm1},{pm25},{pm10},{lat},{lon},{num_sats},{signal_quality},{moving_type}
```
Valeurs manquantes codées `-1` (sentinelle legacy). À **ne pas reproduire** pour les nouveaux formats.
## Configuration Miotiq
- Clé API serveur : stockée dans le code backend (`server/sites/gestion.aircarto.fr/server/routes/sensors.js`), référencée comme `<API_KEY>`.
- Webhook à paramétrer côté Miotiq par capteur : `https://data.<projet>.aircarto.fr/udp_miotiq.php`.
- Descripteur à coller dans la fiche device Miotiq (ou par endpoint côté projet).
- API utile :
- `POST https://app.miotiq.com/api/device/detail?api_key=<KEY>` — état d'un device par IMSI.
- `POST https://app.miotiq.com/api/device/update?api_key=<KEY>` — renommer / associer.
## À faire
- [ ] **Côté firmware NebuleAir Pro 4G** : implémenter l'encodage offset de `latitude`/`longitude` (`raw = round((deg + 90|180) * 1_000_000)`) et positionner `device_status.GPS_NO_FIX` quand il n'y a pas de fix.
- [ ] **Valider en test réel** que l'équation Miotiq `x/1000000-90` est acceptée telle quelle dans la colonne equation (soustraction littérale). Fallback si refusée : firmware envoie `raw / 1000` (millidegrés + 90000 pour lat, + 180000 pour lon) et équation devient `x/1000-90`. À tester une fois, valable à vie.
- [ ] Confirmer l'endianness des champs multi-octets (big-endian supposé).
- [ ] Confirmer le caractère signé/non-signé de `battery_current` (décharge = négatif ?).
- [ ] Migrer MobileAir du format binaire 17B vers un descripteur Miotiq formel.
- [ ] Ajouter un descripteur ModuleAir Pro 4G quand dispo.
## Historique
| Date | Révision | Changement |
|------------|----------|-----------------------------------------------------------------------------|
| 2026-04-23 | v1 | Création — MobileAir 17B + CSV à partir des parsers PHP en prod. |
| 2026-04-23 | v2 | Refonte autour du format descripteur Miotiq, ajout NebuleAir Pro 4G (83B). |
| 2026-04-23 | v3 | Format descripteur aligné sur doc officielle Miotiq : 6e colonne = export JSON (W/Y/N), ajout base functions `hex2bin` et `userdef`, colonne `equation` (expression en x). |
| 2026-04-23 | v4 | Correction : `string` produit du hex (pas ASCII). Correction ISO_39=PM2.5 et ISO_24=PM10 (inversion). Gaz confirmés (NO₂/CO/H₂S/NH₃/O₃). Lien vers JSON canonique AirCarto 2026. |
| 2026-04-23 | v5 | Extension prévue du descripteur NebuleAir Pro 4G avec `latitude`, `longitude`, `misc` dans le bloc `reserved` — proposition d'encodage dans la section À faire. |
| 2026-04-24 | v6 | Intégration effective de `latitude` (4B), `longitude` (4B), `misc` (1B) dans le descripteur NebuleAir Pro 4G. Encodage offset unsigned (`raw = (deg + 90|180) * 1e6`, équation `x/1000000-90|180`) pour contourner l'absence de signed sur `hex2dec` Miotiq. « No fix » géré par le bit `GPS_NO_FIX` de `device_status`. Reste 2B `reserved`. |
| 2026-04-27 | v7 | Octet 9 renommé `version``command`. Ce champ était hardcodé `0x01` côté firmware (jamais une vraie version de protocole). Réaffecté à un type de trame : `0x00` = données mesure, `0x01` = ping test. Permet au firmware de déclencher une trame de bout en bout (capteur → Miotiq → backend) sans envoyer de mesures réelles. Versioning protocole assuré par `version_major/minor/patch` (offsets 69-71). |
| 2026-04-27 | v8 | Rétrocompatibilité : ping test déplacé de `0x01` vers `0x02`. Les firmwares déployés émettent déjà `0x01` (héritage du champ `version` hardcodé) ; les traiter comme ping aurait fait passer toutes leurs trames pour des diagnostics. `0x00` et `0x01` restent donc des trames de mesure normales, `0x02` devient le déclencheur explicite du ping. |
| 2026-06-01 | v9 | Ajout du CO₂ (`ISO_17`, ppm, `uint16`) sur NebuleAir Pro 4G, en consommant les 2 octets `reserved` (offset 81). Trame inchangée à 83 octets — aucune trame déployée rejetée. Plus de bloc `reserved`. Sentinelle `0xFFFF` = capteur absent ; `0` (ex-`reserved` des anciens fw) = non mesuré. `ISO_17` ajouté à [`iso-pollutant-codes.md`](iso-pollutant-codes.md). |