- 6th column is 'export to JSON' (Y/W/N), not a writable flag. - Add base functions hex2bin (bitfield string) and userdef (pass-through). - Rename 'scale' to 'equation' (expression in x, e.g. (x-32)*5/9 possible). - Update generic Python parser to handle all base functions and emit Y-style unit rows. - Remove resolved TODO on flag W semantics.
17 KiB
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. 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 |
Décode les octets comme ASCII (non null-terminé, padé à droite). |
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
hex2decmulti-octets sont big-endian (à vérifier au cas par cas avec Miotiq si un doute apparaît). - Les champs
stringsont 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 |
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) |
||
| 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 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":
val = chunk.decode("ascii", errors="replace").rstrip("\x00 ")
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 champequationest 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 ?). - 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). |