From 96260120fc536b6962e6e1ac29a44bb6a614384f Mon Sep 17 00:00:00 2001 From: Paul Vuarambon Date: Thu, 23 Apr 2026 00:49:22 +0200 Subject: [PATCH] docs(miotiq): align descriptor spec with official Miotiq docs - 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. --- parsers/udp-miotiq.md | 98 ++++++++++++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 30 deletions(-) diff --git a/parsers/udp-miotiq.md b/parsers/udp-miotiq.md index b6ed9d1..fc92163 100644 --- a/parsers/udp-miotiq.md +++ b/parsers/udp-miotiq.md @@ -34,26 +34,44 @@ Le corps `application/json` reçu par le script PHP contient : ## Format descripteur Miotiq -Un descripteur est une suite de lignes, une par champ, au format : +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 | -|----------------|------------------------------------------------------------------------------------------| -| `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). | +| 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](../formats/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` | 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 `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. +- La taille totale du payload = somme des `length / 2`. Toute trame de taille différente doit être rejetée. ## Descripteurs actuels @@ -176,44 +194,62 @@ Valeurs manquantes codées `-1` (sentinelle legacy). À **ne pas reproduire** po ## Parser serveur — squelette générique -Un parser générique qui consomme un descripteur Miotiq et décode n'importe quelle trame : +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). ```python -import base64, struct +import base64 from dataclasses import dataclass @dataclass class Field: - size: int # octets + size: int # octets name: str - decoder: str # string | hex2dec | skip + fn: str # string | hex2dec | hex2bin | userdef | skip unit: str - scale: str # "x/10" | "x/100" | "" + 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("|") - 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)) + 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 decode(payload: bytes, fields: list[Field]) -> dict: +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.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 + + 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 @@ -230,6 +266,8 @@ def on_miotiq_webhook(body: dict, descriptor: str) -> dict: } ``` +> 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 @@ -243,7 +281,6 @@ Côté PHP (cf. implémentations existantes `udp_miotiq_byte.php` / `udp_miotiq_ ## À 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. @@ -255,3 +292,4 @@ Côté PHP (cf. implémentations existantes `udp_miotiq_byte.php` / `udp_miotiq_ |------------|----------|-----------------------------------------------------------------------------| | 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). |