Will be moved to its own file later. Kept only the descriptor format spec and per-sensor binary layout in this doc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
17 KiB
Markdown
222 lines
17 KiB
Markdown
# 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`, `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|version|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|reserved|skip|||
|
||
```
|
||
|
||
**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 ».
|
||
|
||
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 | `version` | | | Version du protocole de communication |
|
||
| 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 | | 0–359, 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 0–6 (voir [`json-payload.md`](json-payload.md#géolocalisation--contexte)) |
|
||
| 81 | 2 | `reserved` | | | À ignorer (évolution future du descripteur) |
|
||
| **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`. |
|