Files
aircarto-protocols/parsers/udp-miotiq.md
Paul Vuarambon efd1aa438a docs: align with AirCarto 2026 JSON template + fix ISO mapping
formats/json-payload.md: full rewrite around the actual server-side template
  (endpoint api.aircarto.com/receive_data?device_type=<model>, flat schema,
  _unit suffix companions, -1 and 255 sentinel semantics, full bitfield
  tables for error_flags/npm_status/device_status, misc context codes).

formats/iso-pollutant-codes.md: fill in the LCSQA mapping. Fixes my earlier
  inversion — ISO_39=PM2.5 and ISO_24=PM10 (not the other way). Add gases:
  ISO_03=NO2, ISO_04=CO, ISO_05=H2S, ISO_08=O3, ISO_21=NH3.

parsers/udp-miotiq.md:
  - string base function outputs hex (not ASCII) — update description and
    generic Python parser accordingly.
  - Fix ISO_39/ISO_24 labels in NebuleAir Pro 4G byte layout.
  - Name the 5 gases by offset, cross-link bitfield docs and JSON canonical.
  - New TODO: origin of latitude/longitude/misc in final JSON (not in 83B
    descriptor).

README.md: reflect the new file layout and data flow summary.
2026-04-23 00:55:25 +02:00

18 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──> 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 produit le JSON canonique AirCarto (voir formats/json-payload.md) qui est posté sur le backend.

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. 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.
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|||
22|reserved|skip|||

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 0359, 0 = Nord
66 1 error_flags Bitfield erreurs système, détail dans formats/json-payload.md. 0xFF = firmware ancien.
67 1 npm_status Bitfield statut NextPM (copie du STATE UART, voir sensors/nextpm.md et json-payload.md). 0xFF = firmware ancien.
68 1 device_status Bitfield état boîtier, détail dans formats/json-payload.md. 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 11 reserved À ignorer (évolution future du descripteur)
83 total

Les champs latitude, longitude, misc présents dans le JSON final (voir json-payload.md) ne sont pas dans ce descripteur 83B — à documenter : ajoutés par Miotiq depuis les métadonnées device, ou transmis via un autre canal.

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 complet (6 colonnes) et décode n'importe quelle trame. Utile si ton backend veut refaire le décodage côté serveur plutôt que s'appuyer uniquement sur le JSON pré-décodé de Miotiq (utile en cas de doute, ou pour rejouer des trames brutes).

import base64
from dataclasses import dataclass

@dataclass
class Field:
    size: int           # octets
    name: str
    fn: str             # string | hex2dec | hex2bin | userdef | skip
    unit: str
    equation: str       # ex. "x/10", "(x-32)*5/9", "" = identité
    export: str         # Y (défaut) | W | N

def parse_descriptor(text: str) -> list[Field]:
    fields = []
    for line in text.strip().splitlines():
        parts = line.split("|")
        while len(parts) < 6:
            parts.append("")
        length, name, fn, unit, eq, export = parts[:6]
        fields.append(Field(int(length) // 2, name, fn, unit, eq, export or "Y"))
    return fields

def _apply_equation(x, equation: str):
    if not equation:
        return x
    # équation est une expression Miotiq en x ; on l'évalue avec x comme seul nom autorisé.
    return eval(equation, {"__builtins__": {}}, {"x": x})

def decode(payload: bytes, fields: list[Field], emit_units: bool = True) -> dict:
    out, off = {}, 0
    for f in fields:
        chunk = payload[off:off + f.size]
        off += f.size
        if f.fn == "skip" or f.export == "N":
            continue
        if len(chunk) != f.size:
            raise ValueError(f"payload trop court au champ {f.name}")

        if f.fn == "string":
            # Miotiq: garde la représentation hex des octets, pas de décodage ASCII.
            val = chunk.hex()
        elif f.fn == "hex2dec":
            val = _apply_equation(int.from_bytes(chunk, "big", signed=False), f.equation)
        elif f.fn == "hex2bin":
            val = "".join(f"{b:08b}" for b in chunk)
        elif f.fn == "userdef":
            val = chunk.hex()
        else:
            raise ValueError(f"base function inconnue: {f.fn}")

        out[f.name] = val
        # export=Y : ajouter la ligne d'unité comme le fait Miotiq
        if emit_units and f.export == "Y" and f.unit:
            out[f"{f.name}_unit"] = f"{val} {f.unit}"
    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),
    }

Attention : eval() sur le champ equation est acceptable tant que le descripteur vient d'une source de confiance (ta conf Miotiq). Si un jour un descripteur peut être injecté par un tiers, remplacer par un parseur d'expressions arithmétiques restreint.

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 l'endianness des champs multi-octets (big-endian supposé).
  • Confirmer le caractère signé/non-signé de battery_current (décharge = négatif ?).
  • D'où viennent latitude / longitude / misc dans le JSON final ? (pas dans le descripteur 83B ; métadonnées Miotiq ? trame séparée ?)
  • 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.