ESP32 — configuration JSON des pièces
Connectivité et appairage du runtime sécurisé
Le runtime générique initialise Wi-Fi STA et Bluetooth Classic SPP, ainsi que
le PHY LAN8720 sur WT32-ETH01. Ethernet applique l'adresse statique
192.168.0.240/24 et la passerelle 192.168.0.1 utilisées par la configuration
du contrôleur. Un ESP32 sans PHY Ethernet peut utiliser le Wi-Fi ; l'échec du
PHY ne désactive pas les autres transports.
Mode Ethernet temporaire
Le runtime est actuellement compilé avec LEUKOS_WIFI_ENABLED = false pour
continuer les essais par Ethernet après un brownout reproduit au démarrage de
la radio Wi-Fi. Le code Wi-Fi et ses identifiants NVS sont conservés, mais le
démarrage STA et les reconnexions sont ignorés tant que ce paramètre reste
désactivé. Le provisionnement Wi-Fi conserve les paramètres pour une future
réactivation ; il n'allume pas la radio dans ce mode. Le PHY LAN8720 reste
initialisé explicitement, et le canal TCP continue d'exiger WireGuard et HMAC.
Cette désactivation ne constitue pas une réparation de l'alimentation.
L'appairage radio Bluetooth et l'activation applicative sont distincts. Le nom
annoncé est LeukOS-<device_id> (suffixe matériel avant provisionnement).
Le node doit disposer d'un contrôleur Bluetooth opérationnel, puis appairer le
MCU et ouvrir RFCOMM. Chaque commande et réponse reste signée avec HMAC,
nonce et timestamp, y compris sur Bluetooth. Les clés sont provisionnées par
le mécanisme série PROVISION1 existant ; SECURITY expose uniquement leur
état de validation. Un MCU non provisionné refuse les commandes applicatives.
Les identifiants Wi-Fi sont configurés sur la liaison série physique à 115200
bauds, avec une ligne NETWORKJSON suivie d'un objet JSON contenant ssid
et password. Ne pas placer un vrai mot de passe dans un journal, une commande
historisée ou un fichier partagé. Le SSID accepte au plus 32 octets et le mot
de passe 8 à 63 octets (vide pour un réseau ouvert explicitement choisi).
Un SSID vide désactive les tentatives de connexion. Sur une liaison déjà
appairée, le même contenu peut être transmis dans une enveloppe HMAC AUTH1.
Le MCU répond OK NETWORK après persistance en NVS, puis applique les nouveaux
paramètres. La NVS Wi-Fi est séparée de la transaction de sécurité HMAC/WireGuard.
Un flash sans effacement conserve les deux ; aucun AP ouvert n'est créé.
La commande série NETWORK, également disponible dans une requête signée,
indique les adresses, l'état Wi-Fi, l'activation Bluetooth, la mémoire libre
et l'initialisation du tunnel, sans révéler de mot de passe ni de clé.
tunnel_initialized=1 signifie que l'interface WireGuard est créée, pas qu'un
handshake ou qu'une commande authentifiée a réussi.
Ethernet est prioritaire ; le Wi-Fi prend le relais lorsqu'il a une adresse et qu'Ethernet est indisponible. Un changement d'interface ou d'adresse ferme les anciennes sockets et recrée le tunnel sur la nouvelle interface. Les tentatives Wi-Fi sont espacées de 15 secondes, celles d'initialisation Bluetooth de 30 secondes et celles de WireGuard de 5 secondes. Les callbacks réseau signalent les changements ; la boucle principale gère les sockets et WireGuard.
La validation complète après flash doit couvrir SECURITY READY, NETWORK,
la découverte/appairage Bluetooth, une commande HMAC avec réponse vérifiée,
un handshake WireGuard et la perte/reprise des liens. Les ports en clair OTA
3232 et CONFIG 3233 restent fermés. Les exemples historiques de transfert
direct ci-dessous décrivent le protocole initial, pas le chemin sécurisé actif :
la configuration de pièce passe désormais par CONFIGJSON signé via le node.
Le firmware ESP32 de leukOS est générique. Il connaît les mécanismes possibles — relais, mesure analogique, température et présence — mais ne connaît aucune pièce ni aucun module à la compilation. Une configuration de pièce est un document JSON canonique partagé par l'IHM Qt, SQLite et l'ESP32.
La compilation est un contrôle d'intégrité du runtime. Dans le parcours réseau, le bouton Envoyer JSON ne reflashe pas le firmware : seul le document de la pièce est transféré.
Document canonique attendu
Voici une pièce Bureau contenant une prise et un capteur de présence :
{
"schema": "leukos.domotics.room",
"version": 1,
"id": "room_f43df77cf8b5",
"name": "Bureau",
"icon": "sofa",
"controller": {
"board": "esp32-eth01-v1.4",
"port": "tcp://192.168.0.240:23"
},
"modules": {
"m_595ad682c46d": {
"name": "Prise-1",
"type": "socket",
"kind": "relay",
"pin": "IO2",
"activeLow": false,
"referenceVoltage": 5,
"target": "m_595ad682c46d",
"icon": "socket",
"unit": "",
"min": 0,
"max": 1
},
"m_a90c7d4e82f1": {
"name": "Présence bureau",
"type": "presence",
"kind": "sensor",
"pin": "IO35",
"activeLow": false,
"referenceVoltage": 3.3,
"target": "m_a90c7d4e82f1",
"icon": "motion-sensor",
"unit": "",
"min": 0,
"max": 1
}
}
}
Les clés de modules sont les identifiants stables. Le firmware les reprend
dans STATE et SET ; le nom visible peut donc changer sans casser les
automatisations.
Champs de la pièce
| Chemin | Obligatoire | Règle |
|---|---|---|
schema | oui | valeur exacte leukos.domotics.room |
version | oui | entier 1 |
id | oui | 1 à 80 caractères parmi lettres, chiffres, _ et - |
name | oui | nom affiché, 1 à 80 caractères |
icon | non | identifiant d'icône de l'IHM |
controller.board | oui | esp32-eth01-v1.4 pour cette carte |
controller.port | oui | URL tcp://adresse:port, généralement port 23 |
modules | oui | objet contenant de 1 à 16 modules |
Chaque identifiant de module suit m_ puis 12 caractères hexadécimaux, par
exemple m_595ad682c46d. Deux modules d'une même pièce ne peuvent pas utiliser
le même GPIO.
Le schéma JSON Schema v1 peut être téléchargé pour valider un fichier dans un éditeur, une application mobile ou une chaîne d'intégration avant son envoi au node.
Champs d'un module
| Champ | Obligatoire | Description |
|---|---|---|
name | oui | libellé destiné à l'utilisateur |
type | oui | choisit le comportement du firmware |
kind | oui | relay ou sensor, information sémantique pour leukOS |
pin | oui | GPIO au format IO2, IO35, etc. |
activeLow | non | inverse la logique électrique ; défaut false |
referenceVoltage | non | référence analogique entre 1,0 et 5,5 V ; défaut 3,3 V |
target | non | identifiant fonctionnel ciblé par l'IHM |
icon, unit, min, max | non | métadonnées d'affichage et de plage |
Types et mécanismes
type | Mode interne | Fonction |
|---|---|---|
lamp, ceiling, fan, socket, washer, valve | relais | sortie numérique commandable par SET |
temperature | température | conversion analogique de type LM35 en °C |
analog | analogique | valeur ADC brute de 0 à 4095 |
presence | présence | entrée numérique filtrée, avec inversion optionnelle |
Sur WT32-ETH01, les broches numériques proposées par l'IHM sont IO0, IO39,
IO36, IO15, IO14, IO12, IO35, IO4 et IO2. Les entrées analogiques
proposées sont IO39, IO36 et IO35. Certaines broches ont aussi un rôle au
démarrage ou pour Ethernet : le câblage réel doit respecter le schéma de la
carte.
Comment le firmware interprète le JSON
1. Choisir le comportement à partir de type
Le code ne contient pas d'identifiant de prise ou de pièce. Il associe seulement un type fonctionnel à un mode générique :
enum ModuleMode : uint8_t {
MODE_RELAY,
MODE_TEMPERATURE,
MODE_ANALOG,
MODE_PRESENCE
};
bool moduleModeForType(const char* type, ModuleMode& mode) {
if (!strcmp(type, "lamp") || !strcmp(type, "ceiling") ||
!strcmp(type, "fan") || !strcmp(type, "socket") ||
!strcmp(type, "washer") || !strcmp(type, "valve"))
mode = MODE_RELAY;
else if (!strcmp(type, "temperature")) mode = MODE_TEMPERATURE;
else if (!strcmp(type, "analog")) mode = MODE_ANALOG;
else if (!strcmp(type, "presence")) mode = MODE_PRESENCE;
else return false;
return true;
}
Ajouter un nouveau type implique donc deux décisions : son schéma JSON et son mécanisme matériel dans ce répartiteur.
2. Construire les modules en mémoire
Chaque propriété de l'objet modules devient une structure interne :
struct Module {
char id[32];
char type[24];
uint8_t pin;
ModuleMode mode;
bool activeLow;
float referenceVoltage;
bool on;
bool stableDigital;
uint8_t candidateCount;
};
Module modules[16];
uint8_t moduleCount = 0;
Le parseur cJSON vérifie la version, l'identifiant de pièce, le nombre de
modules, le type, le GPIO et l'absence de broche dupliquée avant de toucher aux
sorties. La configuration candidate est préparée à part ; elle ne remplace
l'ancienne que si tout le document est valide.
cJSON* root = cJSON_ParseWithLength(json.c_str(), json.length());
cJSON* version = cJSON_GetObjectItemCaseSensitive(root, "version");
cJSON* id = cJSON_GetObjectItemCaseSensitive(root, "id");
cJSON* configured = cJSON_GetObjectItemCaseSensitive(root, "modules");
if (!cJSON_IsNumber(version) || version->valueint != 1 ||
!cJSON_IsString(id) || !cJSON_IsObject(configured)) {
error = "invalid_schema";
return false;
}
3. Configurer les GPIO sans état dangereux
Avant de remplacer une configuration, les anciennes sorties sont arrêtées. Les
nouvelles sorties relais démarrent à l'état inactif en tenant compte de
activeLow :
if (module.mode == MODE_RELAY) {
digitalWrite(module.pin, module.activeLow ? HIGH : LOW);
pinMode(module.pin, OUTPUT);
} else {
pinMode(module.pin,
module.mode == MODE_PRESENCE && module.activeLow
? INPUT_PULLUP
: INPUT);
}
Écrire le niveau inactif avant pinMode(..., OUTPUT) limite l'impulsion parasite
au démarrage d'un relais.
4. Produire les états
STATE renvoie une paire id=valeur par module :
if (module.mode == MODE_RELAY)
out.print(module.on ? 1 : 0);
else if (module.mode == MODE_TEMPERATURE)
out.print(analogRead(module.pin) *
module.referenceVoltage * 100.0f / 4095.0f, 1);
else if (module.mode == MODE_ANALOG)
out.print(analogRead(module.pin));
else
out.print(readDigitalStable(module) ? 1 : 0);
Exemple de réponse :
m_595ad682c46d=1 m_a90c7d4e82f1=0
La formule de température correspond à un LM35 (10 mV/°C). Un autre capteur analogique nécessite un nouveau type ou une règle de conversion explicite.
5. Appliquer une commande
Seuls les modules en mode relais acceptent SET :
SET m_595ad682c46d 1
SET m_595ad682c46d 0
Le runtime recherche l'identifiant, applique l'inversion activeLow, puis
retourne l'état complet. Un identifiant inconnu ou un capteur produit
ERR MODULE.
Protocole de transfert sur TCP 3233
Le flux contient une ligne d'en-tête puis exactement le nombre d'octets UTF-8 annoncé :
CONFIG 989\n
{...989 octets de JSON...}
Le firmware impose une taille maximale de 8192 octets et répond :
OK CONFIG 3
Le nombre final est la révision conservée. En cas d'échec, la réponse indique
la famille de l'erreur, par exemple ERR CONFIG invalid_json,
invalid_schema, invalid_module, duplicate_pin ou storage.
Envoyer un fichier depuis un ordinateur
Le calcul doit utiliser le nombre d'octets, pas le nombre de caractères :
#!/usr/bin/env python3
import json
import socket
from pathlib import Path
document = json.loads(Path("bureau.json").read_text(encoding="utf-8"))
payload = json.dumps(document, ensure_ascii=False, separators=(",", ":")).encode()
if len(payload) > 8192:
raise SystemExit("JSON supérieur à 8192 octets")
with socket.create_connection(("192.168.0.240", 3233), timeout=5) as sock:
sock.sendall(f"CONFIG {len(payload)}\n".encode("ascii") + payload)
print(sock.recv(256).decode().strip())
Le JSON peut venir d'un formulaire Qt, d'un ordinateur ou d'une application mobile. Le producteur importe ou construit le même document canonique ; il ne génère pas une variante spécifique au transport.
Cohérence avec SQLite
L'API locale conserve le document dans la table domotics_room_config :
curl -X POST \
-H 'Content-Type: application/json' \
--data-binary @bureau.json \
http://127.0.0.1:8080/domotics/config/room_f43df77cf8b5
Relire la valeur persistée :
curl http://127.0.0.1:8080/domotics/config/room_f43df77cf8b5
Un POST valide et stocke le JSON mais ne contacte pas, à lui seul, l'ESP32.
L'IHM Qt exécute la séquence complète : enregistrement SQLite, connexion au port
3233, envoi du même payload, puis attente de OK CONFIG.
La configuration sauvegardée dans /etc/leukos/domotics.json, le document
SQLite et le payload envoyé à la carte doivent rester sémantiquement identiques.
Il ne doit exister qu'une seule représentation métier de la pièce.
Persistance dans l'ESP32
Après validation, le runtime écrit le JSON et sa révision dans la NVS :
Preferences preferences;
preferences.begin("leukos", false);
size_t written = preferences.putString("room_json", json);
preferences.putUInt("cfg_rev", configRevision + 1);
preferences.end();
Au redémarrage, room_json est relu, revalidé et les GPIO sont reconstruits. Une
mise à jour OTA normale préserve cette NVS. Un effacement total de la flash la
supprime et la pièce redevient unconfigured=1.
Contrôles avant mise en service
- valider le JSON et vérifier que sa taille ne dépasse pas 8192 octets ;
- vérifier que chaque identifiant de module est stable et unique ;
- vérifier qu'aucun GPIO n'est utilisé deux fois ;
- contrôler
activeLowrelais débranché de la charge ; - envoyer le JSON et exiger
OK CONFIG <révision>; - lire
INFOsur TCP23et vérifierroometconfig_version; - lire
STATE, puis tester chaque relais avecSET; - redémarrer l'ESP32 et confirmer que la configuration est restaurée.
Pour installer le runtime avant ce premier transfert, voir ESP32 Ethernet — initialisation, premier flash et OTA.