Files
aircarto-protocols/parsers/udp-miotiq.md
Paul Vuarambon 7f8d6a21e9 docs(miotiq): Miotiq descriptor format as canonical parser + NebuleAir Pro 4G 83B descriptor
- Refactor parsers/udp-miotiq.md around the pipe-separated descriptor format used on Miotiq side.
- Document the full 83-byte NebuleAir Pro 4G descriptor (PM, gases, noise, weather, power, status).
- Keep legacy MobileAir 17B binary format for reference.
- Add formats/iso-pollutant-codes.md placeholder for AirCarto ISO 7168 code mapping.
- Open TODOs: flag W semantics, endianness, signed battery_current, MobileAir migration.
2026-04-23 00:46:35 +02:00

14 KiB
Raw Blame History

Parser UDP Miotiq

Miotiq 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 :

{
  "payload": "<base64 du datagramme UDP d'origine>",
  "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 :

<nb_chars_hex>|<nom>|<decodeur>|<unite>|<scale>|<flags>
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.
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 0359, 0 = Nord
66 1 error_flags Bitfield d'erreur, cf. firmware
67 1 npm_status Copie du STATE NextPM (voir 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 :

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 <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

  • 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).