# 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 ») : ``` ||||| ``` | 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|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|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 | `command` | | | Type de trame — `0x00` = données mesure, `0x01` = ping test (déclenché par le firmware pour vérifier le lien capteur → Miotiq → backend). 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 | | 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 ``. - Webhook à paramétrer côté Miotiq par capteur : `https://data..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=` — état d'un device par IMSI. - `POST https://app.miotiq.com/api/device/update?api_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). |