# 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──> data.*.aircarto.fr/udp_miotiq_*.php ──> 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 c'est aussi lui qui sert de **contrat** pour le firmware capteur et le parser serveur. ## 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. | | `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. | ## Format descripteur Miotiq Un descripteur est une suite de lignes, une par champ, au format : ``` ||||| ``` | Colonne | Description | |----------------|------------------------------------------------------------------------------------------| | `nb_chars_hex` | Taille du champ en **caractères hexadécimaux** (2 chars = 1 octet). | | `nom` | Identifiant logique du champ. Les codes polluants suivent [ISO 7168 AirCarto](../formats/iso-pollutant-codes.md). | | `decodeur` | `string` (ASCII), `hex2dec` (entier non-signé big-endian), `skip` (ignorer N octets). | | `unite` | Unité physique finale (`ugm3`, `degC`, `%`, `hPa`, `ppb`, `dB`, `V`, `A`, `W`, `m/s`, `degrees`, `count`). Peut être vide pour les champs d'état. | | `scale` | Facteur à appliquer après `hex2dec`. `x/10`, `x/100`… Vide = pas de division. | | `flags` | Optionnel. `W` observé en fin de certaines lignes — **signification à confirmer** (hypothèse : champ writable / configurable via downlink Miotiq). | **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 `nb_chars_hex / 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||| 22|reserved|skip||| ``` Layout octet par octet : | Offset | Taille | Champ | Unité | Scale | Notes | |--------|--------|-------------------|---------|--------|------------------------------------------| | 0 | 8 | `device_id` | | | ASCII, identifiant capteur | | 8 | 1 | `signal_quality` | dB | | Signal cellulaire | | 9 | 1 | `version` | | | Version encodée (complète en v_major/minor/patch plus bas) | | 10 | 2 | `ISO_68` | µg/m³ | /10 | PM1 | | 12 | 2 | `ISO_39` | µg/m³ | /10 | PM10 | | 14 | 2 | `ISO_24` | µg/m³ | /10 | PM2.5 | | 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 | | Gaz — voir `formats/iso-pollutant-codes.md` | | 30 | 2 | `ISO_05` | ppb | | Gaz | | 32 | 2 | `ISO_21` | ppb | | Gaz | | 34 | 2 | `ISO_04` | ppb | | Gaz | | 36 | 2 | `ISO_08` | ppb | | Gaz | | 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 d'erreur, cf. firmware | | 67 | 1 | `npm_status` | | | Copie du `STATE` NextPM (voir [sensors/nextpm.md](../sensors/nextpm.md)) | | 68 | 1 | `device_status` | | | Bitfield global | | 69 | 1 | `version_major` | | | Version firmware `X.y.z` | | 70 | 1 | `version_minor` | | | `x.Y.z` | | 71 | 1 | `version_patch` | | | `x.y.Z` | | 72 | 11 | `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. ## Parser serveur — squelette générique Un parser générique qui consomme un descripteur Miotiq et décode n'importe quelle trame : ```python import base64, struct from dataclasses import dataclass @dataclass class Field: size: int # octets name: str decoder: str # string | hex2dec | skip unit: str scale: str # "x/10" | "x/100" | "" def parse_descriptor(text: str) -> list[Field]: fields = [] for line in text.strip().splitlines(): parts = line.split("|") nb_chars, name, decoder, unit, scale = parts[0], parts[1], parts[2], parts[3], parts[4] fields.append(Field(int(nb_chars) // 2, name, decoder, unit, scale)) return fields def decode(payload: bytes, fields: list[Field]) -> dict: out, off = {}, 0 for f in fields: chunk = payload[off:off + f.size] if len(chunk) != f.size: raise ValueError(f"payload trop court au champ {f.name}") if f.decoder == "string": out[f.name] = chunk.decode("ascii", errors="replace").rstrip("\x00 ") elif f.decoder == "hex2dec": raw = int.from_bytes(chunk, "big", signed=False) if f.scale == "x/10": out[f.name] = raw / 10.0 elif f.scale == "x/100": out[f.name] = raw / 100.0 else: out[f.name] = raw elif f.decoder == "skip": pass off += f.size return out # Webhook Miotiq def on_miotiq_webhook(body: dict, descriptor: str) -> dict: raw = base64.b64decode(body["payload"]) fields = parse_descriptor(descriptor) total = sum(f.size for f in fields) if len(raw) != total: raise ValueError(f"payload {len(raw)} octets, descripteur attend {total}") return { "imsi": body.get("srcImsi"), "rcvTime": body.get("rcvTime"), "data": decode(raw, fields), } ``` Côté PHP (cf. implémentations existantes `udp_miotiq_byte.php` / `udp_miotiq_csv.php`), la logique sera à réécrire autour de ce même principe de descripteur au fur et à mesure de la migration. ## 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 - [ ] Confirmer la signification du flag `W` dans le descripteur (hypothèse : writable via downlink). - [ ] 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). |