diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36b5af3..343418d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,28 @@ jobs: - name: Install build tools run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake build-essential gcovr + - name: Validate factory flash tool + working-directory: Firmware + run: | + python3 -m py_compile tools/factory_flash.py + python3 - <<'PY' + import importlib.util + from pathlib import Path + + script = Path("tools/factory_flash.py") + spec = importlib.util.spec_from_file_location("factory_flash", script) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + schema = module.read_schema() + assert schema["LINKEY_FACTORY_PARTITION_LABEL"] == "factory_data" + assert schema["LINKEY_FACTORY_NAMESPACE"] == "linkey" + assert schema["LINKEY_FACTORY_KEY_MQTT_PASSWORD"] == "mqtt-password" + assert schema["LINKEY_FACTORY_KEY_BLE_POP"] == "ble-pop" + assert schema["LINKEY_FACTORY_FALLBACK_MQTT_PASSWORD"] == "mqtt-password" + assert schema["LINKEY_FACTORY_FALLBACK_BLE_POP"] == "linkey-pop" + PY + - name: Configure run: cmake -S Firmware/test/host -B Firmware/test/host/build -DENABLE_COVERAGE=ON @@ -39,21 +61,34 @@ jobs: fail_ci_if_error: false firmware-build: - name: ESP-IDF firmware build + name: ESP-IDF firmware build (${{ matrix.variant }}) runs-on: ubuntu-latest container: espressif/idf:release-v5.4 + strategy: + fail-fast: false + matrix: + include: + - variant: default + build_dir: build + sdkconfig_defaults: sdkconfig.defaults + - variant: debug-logs + build_dir: build-debug + sdkconfig_defaults: sdkconfig.defaults;sdkconfig.ci-debug.defaults steps: - name: Checkout uses: actions/checkout@v5 with: submodules: recursive - - name: Build firmware + - name: Build firmware (${{ matrix.variant }}) shell: bash working-directory: Firmware run: | . $IDF_PATH/export.sh - idf.py build + idf.py \ + -B "${{ matrix.build_dir }}" \ + -DSDKCONFIG_DEFAULTS="${{ matrix.sdkconfig_defaults }}" \ + build qemu-emulation: name: QEMU emulation (${{ matrix.tariff }}) @@ -90,9 +125,15 @@ jobs: . $IDF_PATH/export.sh eval "$(python "$IDF_PATH/tools/idf_tools.py" export)" + MQTT_USERNAME="linkey_ddeeff" + MQTT_PASSWORD="mqtt-password" + mosquitto_passwd -b -c mosquitto-ci.passwd "$MQTT_USERNAME" "$MQTT_PASSWORD" + chmod 0644 mosquitto-ci.passwd + cat > mosquitto-ci.conf <<'EOF' per_listener_settings false - allow_anonymous true + allow_anonymous false + password_file mosquitto-ci.passwd listener 1884 0.0.0.0 protocol mqtt log_dest stdout @@ -112,7 +153,7 @@ jobs: BROKER_READY=0 for _ in $(seq 1 30); do - if mosquitto_pub -h localhost -p 1884 -t ci/probe -m ready >/dev/null 2>&1; then + if mosquitto_pub -h localhost -p 1884 -u "$MQTT_USERNAME" -P "$MQTT_PASSWORD" -t ci/probe -m ready >/dev/null 2>&1; then BROKER_READY=1 break fi @@ -124,17 +165,18 @@ jobs: exit 1 fi - stdbuf -oL mosquitto_sub -h localhost -p 1884 -t '#' -v > "mqtt-${TARIFF_OPTION}.log" 2>&1 & + stdbuf -oL mosquitto_sub -h localhost -p 1884 -u "$MQTT_USERNAME" -P "$MQTT_PASSWORD" -t '#' -v > "mqtt-${TARIFF_OPTION}.log" 2>&1 & SUB_PID=$! ./run_qemu_linkey.sh --tariff_option "$TARIFF_OPTION" --build > "qemu-${TARIFF_OPTION}.log" 2>&1 & QEMU_PID=$! + QEMU_TOPIC_PREFIX="linkey/ddeeff" deadline=$((SECONDS + 300)) while [ "$SECONDS" -lt "$deadline" ]; do - if grep -Fxq "linkey/status online" "mqtt-${TARIFF_OPTION}.log" \ + if grep -Fxq "${QEMU_TOPIC_PREFIX}/status online" "mqtt-${TARIFF_OPTION}.log" \ && grep -Eq '^homeassistant/device/linkey_[0-9a-f]{12}/config ' "mqtt-${TARIFF_OPTION}.log" \ - && grep -Eq '^linkey/state ' "mqtt-${TARIFF_OPTION}.log"; then + && grep -Eq "^${QEMU_TOPIC_PREFIX}/state " "mqtt-${TARIFF_OPTION}.log"; then break fi if ! kill -0 "$QEMU_PID" 2>/dev/null; then @@ -149,11 +191,11 @@ jobs: sleep 2 done - mosquitto_pub -h localhost -p 1884 -t linkey/debug/request -m GET_TIC_FRAME + mosquitto_pub -h localhost -p 1884 -u "$MQTT_USERNAME" -P "$MQTT_PASSWORD" -t "${QEMU_TOPIC_PREFIX}/debug/request" -m GET_TIC_FRAME deadline=$((SECONDS + 60)) while [ "$SECONDS" -lt "$deadline" ]; do - if grep -Eq '^linkey/debug/tic_frame ' "mqtt-${TARIFF_OPTION}.log"; then + if grep -Eq "^${QEMU_TOPIC_PREFIX}/debug/tic_frame " "mqtt-${TARIFF_OPTION}.log"; then break fi if ! kill -0 "$QEMU_PID" 2>/dev/null; then @@ -199,6 +241,55 @@ jobs: Firmware/mqtt-${{ matrix.tariff }}.log Firmware/mosquitto-${{ matrix.tariff }}.log + qemu-manual-provisioning: + name: QEMU manual provisioning trigger + runs-on: ubuntu-latest + container: espressif/idf:release-v5.4 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Install QEMU (xtensa) + shell: bash + run: | + . $IDF_PATH/export.sh + python "$IDF_PATH/tools/idf_tools.py" install qemu-xtensa + + - name: Run manual provisioning trigger emulation + shell: bash + working-directory: Firmware + run: | + . $IDF_PATH/export.sh + eval "$(python "$IDF_PATH/tools/idf_tools.py" export)" + + ./run_qemu_linkey.sh \ + --tariff_option base \ + --manual-provision-test \ + --build-only + + timeout 45s ./run_qemu_linkey.sh \ + --tariff_option base \ + --manual-provision-test > qemu-manual-provisioning.log 2>&1 || true + + echo "---- qemu-manual-provisioning.log ----" + cat qemu-manual-provisioning.log + + - name: Assert manual provisioning trigger + shell: bash + working-directory: Firmware + run: | + python3 test/qemu/assert_manual_provisioning.py \ + --qemu-log qemu-manual-provisioning.log + + - name: Upload manual provisioning QEMU log + if: always() + uses: actions/upload-artifact@v6 + with: + name: qemu-manual-provisioning-log + path: Firmware/qemu-manual-provisioning.log + pcb-drc: name: KiCad DRC runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index bf2f2c9..e31733b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,13 +14,18 @@ PCB/MCAD files/*.FCBak # Firmware Firmware/build/ +Firmware/build-debug/ +Firmware/build-factory/ Firmware/build-qemu/ Firmware/build-qemu-*/ +Firmware/managed_components/ Firmware/sdkconfig* !Firmware/sdkconfig.defaults +!Firmware/sdkconfig.ci-debug.defaults Firmware/.cache/ Firmware/test/host/build/ -qemu.log +Firmware/factory/devices/ +*.log __pycache__/ *.pyc diff --git a/Firmware/EMULATION.md b/Firmware/EMULATION.md index e87d5ba..16fa022 100644 --- a/Firmware/EMULATION.md +++ b/Firmware/EMULATION.md @@ -118,10 +118,10 @@ docker compose up -d mosquitto Pour observer les messages publiés par le firmware émulé : ```bash -mosquitto_sub -h localhost -p 1884 -t "linkey/#" -v +mosquitto_sub -h localhost -p 1884 -u linkey_ddeeff -P mqtt-password -t "linkey/#" -v ``` -Cette commande se connecte au broker local exposé sur le port `1884`, s'abonne à tous les topics sous `linkey/` et affiche chaque message reçu avec son topic. +Cette commande se connecte au broker local exposé sur le port `1884`, s'abonne à tous les topics sous `linkey/` et affiche chaque message reçu avec son topic. Le broker QEMU refuse les connexions anonymes et attend les identifiants dérivés de la MAC mockée : `linkey_ddeeff` / `mqtt-password`. L'URI du broker QEMU est définie dans `emulation/sdkconfig.qemu.defaults`. diff --git a/Firmware/README.md b/Firmware/README.md index 06d1ace..e863e29 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -6,9 +6,11 @@ Ce firmware utilise le coprocesseur ULP (*Ultra Low Power*) de l'ESP32 pour surv ``` ┌─────────────────────────────────────────────┐ -│ CPU principal (FSM) (réveil périodique) │ +│ CPU principal (init + FSM) │ +│ Réveil périodique │ │ • Surveillance tension avec seuils de │ │ fallback par état │ +│ • Provisioning WiFi BLE si nécessaire │ │ • Gestion des connexions WiFi/MQTT │ │ • Auto-découverte HA à la connexion MQTT │ │ • Publication JSON des données capteurs │ @@ -30,11 +32,16 @@ Ce firmware utilise le coprocesseur ULP (*Ultra Low Power*) de l'ESP32 pour surv - **Vrai support UART 7E1** : gestion correcte des 7 bits de données + parité paire (ignorée) - **Mode *light sleep*** : WiFi maintenu connecté, réveil rapide (à mesurer) - **WiFi** : + - Identifiants provisionnés en BLE via l'application Espressif + - Stockage des identifiants dans la NVS WiFi ESP-IDF + - Validation WiFi différée : la FSM reprend la main après le provisioning + - Scan WiFi désactivé pendant le provisioning pour limiter le courant - Mise en cache du BSSID/canal pour reconnexion instantanée - *Fast scan* - *Modem sleep* WiFi pour économie d'énergie - IP statique optionnelle (court-circuite le DHCP) - **MQTT** : + - Découverte automatique du broker Home Assistant via mDNS (`_home-assistant._tcp.local`). Port imposé 1883. - QoS 1 pour une livraison fiable et une meilleure détection des coupures WiFi - Délais d'expiration courts - *Last Will and Testament* (LWT) pour le suivi de disponibilité @@ -48,6 +55,7 @@ Ce firmware utilise le coprocesseur ULP (*Ultra Low Power*) de l'ESP32 pour surv - **Réception trame par trame** : l'ULP reçoit une trame TIC complète (terminée par ETX `0x03`) en double buffer RTC ping-pong, puis réveille le CPU - **Validation de checksum** : vérifie le checksum TIC Linky `(sum & 0x3F) + 0x20` - **Statut LED RGB** : flash bref (10 ms) à chaque itération de la FSM, couleur dépendant de l'état courant — la LED reste éteinte le reste du temps pour économiser l'énergie +- **Provisioning manuel** : un appui long de plus de 2 s sur BOOT/GPIO0 force l'entrée en provisioning BLE sans effacer les identifiants WiFi existants - **Surveillance tension supercondensateur** : lecture de la tension du condensateur - **Logs de debug** : journalisation verbeuse configurable pour le diagnostic @@ -58,10 +66,12 @@ Ce firmware utilise le coprocesseur ULP (*Ultra Low Power*) de l'ESP32 pour surv ```mermaid stateDiagram-v2 direction LR - [*] --> INIT - INIT --> WAIT_VOLTAGE - WAIT_VOLTAGE --> ACTIVE: V ≥ 2,5 V + [*] --> WAIT_VOLTAGE + WAIT_VOLTAGE --> BLE_PROVISION: V ≥ 2,5 V et WiFi non provisionné + BLE_PROVISION --> WAIT_VOLTAGE: identifiants stockés / V faible + WAIT_VOLTAGE --> ACTIVE: V ≥ 2,5 V et WiFi provisionné ACTIVE --> WAIT_VOLTAGE: V faible (fallback) + ACTIVE --> BLE_PROVISION: BOOT/GPIO0 maintenu > 2 s state ACTIVE { direction LR @@ -79,8 +89,8 @@ stateDiagram-v2 | État | Rôle | Couleur du flash LED | |------|------|----------------------| -| `INIT` | Init NVS, LEDs, ADC, gestion d'énergie | Cyan | | `WAIT_VOLTAGE` | Attend que le supercondensateur soit chargé (≥ 2,5 V) | Rouge | +| `BLE_PROVISION` | Provisioning BLE des identifiants WiFi, sans scan ni validation WiFi immédiate | Blanc | | `WIFI_CONNECT` | Connexion WiFi (avec cache BSSID/canal), retry interne en cas d'échec | Bleu | | `MQTT_CONNECT` | Connexion au broker MQTT + auto-découverte HA, retry interne si broker indisponible | Magenta | | `WAIT_ULP_DATA` | Init ULP + attente d'une trame TIC complète, CPU en *light sleep* | Jaune | @@ -88,14 +98,93 @@ stateDiagram-v2 > **Fallback tension (`ACTIVE → WAIT_VOLTAGE`)** : dans tous les états regroupés sous `ACTIVE`, la FSM retombe vers `WAIT_VOLTAGE` si la tension descend sous `VOLTAGE_FALLBACK_MIN_MV` (1,5 V) **ou** chute de plus de 200 mV par rapport au pic atteint depuis l'entrée dans l'état (seuil dynamique). Les connexions WiFi/MQTT sont alors arrêtées. +L'initialisation NVS, LED, ADC et gestion d'énergie est effectuée une seule fois au démarrage, avant l'entrée dans la FSM. Elle n'a donc pas de flash LED dédié. + +### Provisioning WiFi BLE + +Si aucun identifiant WiFi n'est présent dans la NVS WiFi ESP-IDF, la FSM entre dans l'état `BLE_PROVISION` dès que la tension du supercondensateur est suffisante. Le service BLE apparaît sous le nom `Linkey_XXXXXX`, dérivé de l'adresse MAC. + +Il est aussi possible de forcer le provisioning depuis les états actifs en maintenant BOOT/GPIO0 plus de 2 s. Cette demande arrête WiFi/MQTT et entre en `BLE_PROVISION`, mais ne supprime pas les identifiants WiFi déjà stockés. Si le provisioning manuel échoue alors que des identifiants existent encore, le firmware les conserve et retourne à `WAIT_VOLTAGE`. + +Le provisioning utilise le protocole standard de l'application Espressif, avec les contraintes basse consommation suivantes : + +- Le scan WiFi demandé par l'application est remplacé par une réponse vide. Il faut donc saisir le SSID manuellement. +- Les identifiants reçus sont enregistrés dans la NVS WiFi avec `esp_wifi_set_config()`. +- Le firmware répond ensuite à l'application comme si la connexion WiFi était validée, mais ne lance pas de connexion WiFi pendant le provisioning. +- Après succès, BLE/WiFi sont arrêtés et la FSM retourne à `WAIT_VOLTAGE`. La connexion WiFi réelle est effectuée plus tard par `WIFI_CONNECT`, sous contrôle des seuils de tension. +- Les anciens identifiants ne sont remplacés que lorsque l'application soumet de nouveaux identifiants. Ils ne sont pas effacés au moment d'entrer en provisioning manuel. + +Le *Proof of Possession* est lu depuis la partition NVS `factory_data` quand elle est présente. Sinon, le firmware utilise le repli défini dans `factory_config_schema.h`, sauf override explicite via `menuconfig`. + +Quand **Enable debug logging** est activé, le firmware imprime aussi un QR code compatible avec l'application Espressif dans le moniteur série. En usage normal, les informations de provisioning doivent venir de l'outil de flash factory, pas des logs firmware. + +### Flash factory et informations d'installation + +Le firmware contient une partition NVS dédiée `factory_data` pour les secrets propres à chaque appareil : + +- `ble-pop` : PoP utilisé par l'application Espressif pour le provisioning WiFi BLE +- `mqtt-password` : mot de passe MQTT de l'utilisateur propre à l'appareil + +Ces secrets sont générés et flashés par l'outil Python : + +```bash +cd Firmware +python3 tools/factory_flash.py -p /dev/ttyUSB0 +``` + +L'outil lit l'adresse MAC de l'ESP32, crée le dossier `factory/devices//` si nécessaire, puis réutilise les mêmes secrets aux exécutions suivantes. Il génère la partition NVS factory, lance `idf.py build`, flashe le firmware, flashe `factory_data`, puis affiche les informations à recopier dans Home Assistant et dans un guide de démarrage rapide : + +- nom BLE `Linkey_XXXXXX` +- contenu du QR code de provisioning et URL Espressif +- utilisateur MQTT `linkey_` +- mot de passe MQTT +- préfixe de topic `linkey/` + +Pour préparer uniquement les fichiers sans flasher : + +```bash +cd Firmware +python3 tools/factory_flash.py -p /dev/ttyUSB0 --no-flash +``` + +Les replis de développement et QEMU sont définis dans `factory_config_schema.h` : `linkey-pop` pour le PoP BLE et `mqtt-password` pour MQTT. Les champs `menuconfig` **Provisioning Proof of Possession** et **MQTT Password** sont seulement des overrides optionnels ; laissez-les vides pour utiliser ces replis ou la partition `factory_data` quand elle est flashée. + +### Découverte du broker MQTT + +Après la connexion WiFi, le firmware tente de découvrir Home Assistant via mDNS en interrogeant le service `_home-assistant._tcp.local`. Home Assistant annonce son service HTTP sur ce nom ; le firmware récupère l'adresse IP annoncée puis construit l'URI MQTT avec le port standard `1883` : + +```text +_home-assistant._tcp.local -> 192.168.1.16 +mqtt://192.168.1.16:1883 +``` + +Si cette découverte échoue, le firmware utilise **MQTT Broker URI** comme repli. La valeur par défaut est `mqtt://homeassistant.local:1883`. + +Le firmware ne se base pas sur `_mqtt._tcp.local` : ce service générique peut être annoncé par d'autres équipements du réseau (box, routeur, services internes) et ne désigne pas nécessairement le broker MQTT utilisé par Home Assistant. + +La découverte ne fournit pas les identifiants MQTT. Si **MQTT Username** est laissé vide, le firmware utilise automatiquement `linkey_`, où `` correspond aux 6 derniers caractères hexadécimaux de l'adresse MAC WiFi. Exemple : `linkey_8ebde0`. + +Si le broker exige une authentification, il faut donc créer cet utilisateur côté Home Assistant/Mosquitto, ou renseigner un autre nom dans **MQTT Username**. Le mot de passe MQTT est lu depuis la partition `factory_data` quand elle est présente ; sinon le firmware utilise le repli défini dans `factory_config_schema.h`, sauf override explicite par **MQTT Password**. + ### Topics MQTT -Avec le préfixe par défaut `linkey` : -- `linkey/state` — payload JSON contenant les données capteurs (seules les valeurs valides sont incluses) : +Les topics ne sont pas configurables dans `menuconfig`. Ils sont dérivés automatiquement des 6 derniers caractères hexadécimaux de l'adresse MAC WiFi : + +```text +linkey/ +``` + +Cela permet de connecter plusieurs clés linkey sur un même réseau sans configuration. + +Exemple pour un suffixe `8ebde0` : + +- `linkey/8ebde0/state` — payload JSON contenant les données capteurs (seules les valeurs valides sont incluses) : ```json {"iinst":3,"base":12345678,"papp":690,"adps":30,"vcap":2850,"uptime":3600} ``` -- `linkey/status` — disponibilité de l'appareil (`online`/`offline` via LWT) +- `linkey/8ebde0/status` — disponibilité de l'appareil (`online`/`offline` via LWT) +- `linkey/8ebde0/debug/request` — demande de publication de trame TIC brute +- `linkey/8ebde0/debug/tic_frame` — trame TIC brute publiée à la demande **Note** : seules les valeurs Linky issues d'un groupe d'information avec un checksum valide **et qui ont changé depuis la dernière publication** sont incluses dans le JSON. `VCAP` et `uptime` sont toujours présents. Les index spécifiques à un tarif (HCHC/HCHP, EJPHN/EJPHPM, BBRH*) n'apparaissent que si le compteur est configuré avec le contrat correspondant. @@ -236,16 +325,16 @@ idf.py menuconfig Naviguer dans **« Linkey Monitor Configuration »** et configurer : #### Paramètres requis : -- **Device Name** : nom affiché dans Home Assistant (par défaut : `Linkey`) -- **WiFi SSID** : nom de votre réseau WiFi -- **WiFi Password** : mot de passe WiFi -- **MQTT Broker URI** : ex. `mqtt://192.168.1.100` -- **MQTT Topic Prefix** : par défaut `linkey` (topics : `linkey/state`, `linkey/status`) +- **Device Name** : override optionnel du nom affiché dans Home Assistant. Si vide, le firmware utilise `Linkey_`, par exemple `Linkey_8ebde0`. +- **Provisioning Proof of Possession** : code demandé par l'application Espressif #### Paramètres optionnels : -- **MQTT Username/Password** : si votre broker requiert une authentification +- **Auto-discover MQTT broker with mDNS** : activé par défaut ; découvre Home Assistant via `_home-assistant._tcp.local` et utilise le port MQTT `1883` +- **MQTT Broker URI** : URI de repli si la découverte mDNS échoue (par défaut : `mqtt://homeassistant.local:1883`) +- **MQTT Username** : optionnel ; si vide, le firmware utilise `linkey_<6 derniers caractères de la MAC WiFi>` +- **MQTT Password** : si votre broker requiert une authentification - **Use Static IP** : à activer pour une connexion initiale plus rapide -- **Enable debug logging** : pour le diagnostic +- **Enable debug logging** : logs verbeux et QR code de provisioning dans le moniteur série ### 4. Compiler @@ -259,9 +348,20 @@ idf.py build idf.py -p /dev/ttyUSB0 flash monitor ``` +### 6. Provisionner le WiFi + +Au premier démarrage, si aucun identifiant WiFi n'est stocké : + +1. Ouvrir l'application Espressif de provisioning. +2. Scanner le QR code affiché dans le moniteur série si les logs de debug sont activés, ou sélectionner manuellement le périphérique BLE `Linkey_XXXXXX`, puis entrer le *Proof of Possession* configuré dans `menuconfig`. +3. Saisir le SSID et le mot de passe WiFi manuellement (la recherche de réseaux par l'ESP32 est volontairement désactivée). +4. L'application doit terminer avec succès. Le firmware arrête ensuite le provisioning et attend à nouveau une tension suffisante avant de se connecter au WiFi. + +Pour reprovisionner un appareil déjà configuré, maintenir BOOT/GPIO0 plus de 2 s pendant que l'appareil est dans un état actif. Les identifiants existants restent présents tant que l'application ne soumet pas de nouveaux identifiants. + ## Tests -Les tests unitaires *host* couvrent la logique pure extraite des modules — pour le moment uniquement `voltage_state.c` (seuils et suivi du pic dynamique). Le framework Unity est récupéré à la configuration via `FetchContent`. +Les tests unitaires *host* couvrent la logique pure extraite des modules, notamment `voltage_state.c`, `fsm.c` et `tic_parser.c`. Le framework Unity est récupéré à la configuration via `FetchContent`. ### Exécuter localement @@ -274,14 +374,14 @@ cmake --build build ctest --test-dir build --output-on-failure ``` -Temps d'exécution attendu : < 1 s pour les 13 cas actuels. +Temps d'exécution attendu : < 1 s pour les tests actuels. ### Intégration continue (CI) Le workflow GitHub Actions `.github/workflows/ci.yml` lance à chaque *push* et *pull request* : 1. **`host-tests`** — `ubuntu-latest`, build CMake + Unity + ctest. -2. **`firmware-build`** — image Docker `espressif/idf:release-v5.4`, `idf.py build` complet pour valider que les refactorings ne cassent pas la compilation du firmware. +2. **`firmware-build`** — image Docker `espressif/idf:release-v5.4`, `idf.py build` complet pour valider que les refactorings ne cassent pas la compilation du firmware. La matrice couvre aussi une configuration avec `CONFIG_LINKEY_DEBUG_LOGS=y`, qui active notamment le QR code de provisioning. ### Ajouter un nouveau test @@ -297,6 +397,7 @@ L'approche actuelle teste uniquement la logique pure extraite. Étendre la couve - [Documentation ULP ESP32](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/ulp.html) - [Documentation TIC Linky](../Doc/Enedis-MOP-CPT_002E.pdf) — spécification TIC Linky (incluse dans `Doc/`) - [Gestion d'énergie ESP32](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/power_management.html) +- [Découverte d'instance Home Assistant](https://developers.home-assistant.io/docs/api/instance_discovery/) - [Découverte MQTT HA](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) ## Licence @@ -310,3 +411,5 @@ Le code du firmware (`Firmware/main/`) est distribué sous **licence MIT**. Voir - **HULP** (`Firmware/HULP/`, sous-module Git) — [MIT](HULP/LICENSE), © 2019 Matt - **ESP-IDF** — [Apache 2.0](https://github.com/espressif/esp-idf/blob/master/LICENSE) +- **espressif/qrcode** (`Firmware/main/idf_component.yml`) — composant ESP-IDF utilisé uniquement par les builds avec logs de debug pour afficher le QR code de provisioning +- **espressif/mdns** (`Firmware/main/idf_component.yml`) — composant ESP-IDF utilisé pour découvrir l'instance Home Assistant sur le réseau local diff --git a/Firmware/dependencies.lock b/Firmware/dependencies.lock new file mode 100644 index 0000000..39b4667 --- /dev/null +++ b/Firmware/dependencies.lock @@ -0,0 +1,28 @@ +dependencies: + espressif/mdns: + component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.11.3 + espressif/qrcode: + component_hash: 3b493771bc5d6ad30cbf87c25bf784aada8a08c941504355b55d6b75518ed7bc + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 0.1.0~2 + idf: + source: + type: idf + version: 5.4.1 +direct_dependencies: +- espressif/mdns +- espressif/qrcode +manifest_hash: de01ba84c68e47ef62ae6d9a1a8e146f3265abf91364a8d60da88cd5a462e2a9 +target: esp32 +version: 2.0.0 diff --git a/Firmware/docker-compose.yml b/Firmware/docker-compose.yml index cc533b7..8feb4cd 100644 --- a/Firmware/docker-compose.yml +++ b/Firmware/docker-compose.yml @@ -8,13 +8,18 @@ services: - ./mosquitto_qemu.conf:/tmp/mosquitto.conf:ro - mosquitto-data:/mosquitto/data - mosquitto-logs:/mosquitto/log - command: ["mosquitto", "-c", "/tmp/mosquitto.conf"] + command: + - sh + - -c + - | + mosquitto_passwd -b -c /tmp/mosquitto.passwd linkey_ddeeff mqtt-password + mosquitto -c /tmp/mosquitto.conf environment: - TZ=UTC networks: - linkey-test-network healthcheck: - test: ["CMD", "mosquitto_sub", "-h", "localhost", "-C", "1", "-t", "$$SYS/broker/clients/total"] + test: ["CMD", "mosquitto_sub", "-h", "localhost", "-u", "linkey_ddeeff", "-P", "mqtt-password", "-C", "1", "-t", "$$SYS/broker/clients/total"] interval: 10s timeout: 5s retries: 3 diff --git a/Firmware/emulation/CMakeLists.txt b/Firmware/emulation/CMakeLists.txt index 10fae34..91d9e19 100644 --- a/Firmware/emulation/CMakeLists.txt +++ b/Firmware/emulation/CMakeLists.txt @@ -17,3 +17,9 @@ endif() target_compile_definitions(${COMPONENT_LIB} PRIVATE LINKEY_QEMU_TARIFF_OPTION_${LINKEY_QEMU_TARIFF_OPTION_UPPER}=1 ) + +if(LINKEY_QEMU_MANUAL_PROVISION_TEST) + target_compile_definitions(${COMPONENT_LIB} PRIVATE + LINKEY_QEMU_MANUAL_PROVISION_TEST=1 + ) +endif() diff --git a/Firmware/emulation/linkey_qemu_mocks.c b/Firmware/emulation/linkey_qemu_mocks.c index 88ef665..8cf3d6a 100644 --- a/Firmware/emulation/linkey_qemu_mocks.c +++ b/Firmware/emulation/linkey_qemu_mocks.c @@ -8,10 +8,13 @@ #include "esp_mac.h" #include "esp_netif.h" #include "esp_pm.h" +#include "esp_timer.h" +#include "driver/gpio.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "hulp.h" #include "qemu_test_data.h" +#include "provisioning_manager.h" #include "ulp_linky.h" #include "voltage_manager.h" #include "wifi_manager.h" @@ -25,6 +28,9 @@ static esp_eth_handle_t qemu_eth_handle; static esp_eth_netif_glue_handle_t qemu_eth_glue; static bool qemu_eth_has_ip; static const uint8_t qemu_wifi_sta_mac[6] = {0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}; +#if defined(LINKEY_QEMU_MANUAL_PROVISION_TEST) +static bool qemu_boot_press_logged; +#endif static const char *qemu_selected_frame(int *len) { @@ -141,6 +147,26 @@ esp_err_t __wrap_esp_pm_configure(const esp_pm_config_t *config) return ESP_OK; } +int __real_gpio_get_level(gpio_num_t gpio_num); + +int __wrap_gpio_get_level(gpio_num_t gpio_num) +{ + if (gpio_num == GPIO_NUM_0) { +#if defined(LINKEY_QEMU_MANUAL_PROVISION_TEST) + int64_t now_ms = esp_timer_get_time() / 1000; + bool pressed = now_ms >= 5000 && now_ms < 12000; + if (pressed && !qemu_boot_press_logged) { + ESP_LOGI(TAG, "QEMU manual BOOT/GPIO0 press active"); + qemu_boot_press_logged = true; + } + return pressed ? 0 : 1; +#else + return 1; +#endif + } + return __real_gpio_get_level(gpio_num); +} + esp_err_t __real_esp_read_mac(uint8_t *mac, esp_mac_type_t type); esp_err_t __wrap_esp_read_mac(uint8_t *mac, esp_mac_type_t type) @@ -297,7 +323,21 @@ conn_result_t __wrap_wifi_connect(wifi_state_t *state, return CONN_FAILED; } +bool __wrap_wifi_has_config(void) +{ + return true; +} + bool __wrap_wifi_is_connected(void) { return qemu_eth_has_ip; } + +conn_result_t provisioning_start_ble(uint16_t voltage_threshold, + uint32_t poll_interval_ms) +{ + (void)voltage_threshold; + (void)poll_interval_ms; + ESP_LOGE(TAG, "BLE provisioning is not supported in QEMU emulation"); + return CONN_FAILED; +} diff --git a/Firmware/emulation/sdkconfig.qemu.defaults b/Firmware/emulation/sdkconfig.qemu.defaults index 893dd57..5dedcb4 100644 --- a/Firmware/emulation/sdkconfig.qemu.defaults +++ b/Firmware/emulation/sdkconfig.qemu.defaults @@ -1,3 +1,4 @@ # QEMU-only settings. Use with -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;emulation/sdkconfig.qemu.defaults". CONFIG_ETH_USE_OPENETH=y CONFIG_LINKEY_MQTT_BROKER_URI="mqtt://10.0.2.2:1884" +CONFIG_LINKEY_MQTT_AUTODISCOVERY=n diff --git a/Firmware/main/CMakeLists.txt b/Firmware/main/CMakeLists.txt index 039b36f..ad527b4 100644 --- a/Firmware/main/CMakeLists.txt +++ b/Firmware/main/CMakeLists.txt @@ -1,12 +1,39 @@ +set(linkey_requires nvs_flash esp_wifi mqtt HULP) +set(linkey_sources + "main.c" + "ulp_linky.cpp" + "tic_parser.c" + "voltage_manager.c" + "voltage_state.c" + "wifi_manager.c" + "mqtt_manager.c" + "factory_config.c" + "fsm.c" +) + +if(NOT LINKEY_QEMU_EMULATION) + list(APPEND linkey_requires wifi_provisioning bt) + list(APPEND linkey_sources "provisioning_manager.c") +endif() + +if(CONFIG_LINKEY_DEBUG_LOGS) + list(APPEND linkey_requires qrcode) +endif() + +if(CONFIG_LINKEY_MQTT_AUTODISCOVERY) + list(APPEND linkey_requires mdns) +endif() + idf_component_register( - SRCS "main.c" "ulp_linky.cpp" "tic_parser.c" "voltage_manager.c" "voltage_state.c" "wifi_manager.c" "mqtt_manager.c" "fsm.c" + SRCS ${linkey_sources} INCLUDE_DIRS "." - REQUIRES nvs_flash esp_wifi mqtt HULP + REQUIRES ${linkey_requires} ) if(LINKEY_QEMU_EMULATION) target_link_options(${COMPONENT_LIB} INTERFACE -Wl,--wrap=esp_pm_configure + -Wl,--wrap=gpio_get_level -Wl,--wrap=esp_read_mac -Wl,--wrap=hulp_ulp_isr_register -Wl,--wrap=hulp_ulp_interrupt_en @@ -20,6 +47,7 @@ if(LINKEY_QEMU_EMULATION) -Wl,--wrap=wifi_start -Wl,--wrap=wifi_stop -Wl,--wrap=wifi_connect + -Wl,--wrap=wifi_has_config -Wl,--wrap=wifi_is_connected ) endif() diff --git a/Firmware/main/Kconfig.projbuild b/Firmware/main/Kconfig.projbuild index 83299ba..0f04e49 100644 --- a/Firmware/main/Kconfig.projbuild +++ b/Firmware/main/Kconfig.projbuild @@ -2,46 +2,49 @@ menu "Linkey Monitor Configuration" config LINKEY_DEVICE_NAME string "Device Name" - default "Linkey" - help - Device name shown in Home Assistant. - - config LINKEY_WIFI_SSID - string "WiFi SSID" - default "" - help - SSID (network name) for the example to connect to. - - config LINKEY_WIFI_PASSWORD - string "WiFi Password" default "" help - WiFi password (WPA or WPA2) for the example to use. + Optional Home Assistant device name override. + Leave empty to use Linkey_. config LINKEY_MQTT_BROKER_URI string "MQTT Broker URI" - default "mqtt://192.168.1.100" + default "mqtt://homeassistant.local:1883" help - URI of the MQTT broker to connect to. + Fallback URI of the MQTT broker to connect to. Example: mqtt://192.168.1.100 or mqtts://mqtt.example.com + config LINKEY_MQTT_AUTODISCOVERY + bool "Auto-discover MQTT broker with mDNS" + default y + help + Query Home Assistant _home-assistant._tcp.local and use port 1883 + on the discovered host. + If discovery fails, the firmware falls back to LINKEY_MQTT_BROKER_URI. + config LINKEY_MQTT_USERNAME string "MQTT Username" default "" help - Username for MQTT broker authentication (leave empty if not required). + Username for MQTT broker authentication. + Leave empty to use the runtime default linkey_, for example linkey_8ebde0. config LINKEY_MQTT_PASSWORD string "MQTT Password" default "" help - Password for MQTT broker authentication (leave empty if not required). + Optional development override for the MQTT password. + Leave empty to use the fallback defined in factory_config_schema.h, + or the per-device value from the factory_data partition when present. - config LINKEY_MQTT_TOPIC_PREFIX - string "MQTT Topic Prefix" - default "linkey" + config LINKEY_PROV_POP + string "Provisioning Proof of Possession" + default "" help - Prefix for MQTT topics. Topics will be: /state, /status + Optional development override for the Espressif provisioning PoP. + Leave empty to use the fallback defined in factory_config_schema.h, + or the per-device value from the factory_data partition when present. config LINKEY_USE_STATIC_IP bool "Use Static IP (faster connection)" diff --git a/Firmware/main/factory_config.c b/Firmware/main/factory_config.c new file mode 100644 index 0000000..54b4f12 --- /dev/null +++ b/Firmware/main/factory_config.c @@ -0,0 +1,95 @@ +#include +#include + +#include "factory_config.h" +#include "factory_config_schema.h" +#include "esp_err.h" +#include "esp_log.h" +#include "nvs.h" +#include "nvs_flash.h" + +static const char *TAG = "FACTORY_CFG"; + +#define FACTORY_SECRET_MAX_LEN 64 + +static bool factory_config_initialized; +static char factory_mqtt_password[FACTORY_SECRET_MAX_LEN + 1]; +static char factory_ble_pop[FACTORY_SECRET_MAX_LEN + 1]; + +static void copy_default(char *dst, size_t dst_size, const char *value, + const char *fallback) +{ + if (value && value[0]) { + strlcpy(dst, value, dst_size); + } else { + strlcpy(dst, fallback, dst_size); + } +} + +static void read_factory_string(nvs_handle_t nvs, const char *key, + char *dst, size_t dst_size) +{ + size_t required_size = dst_size; + esp_err_t err = nvs_get_str(nvs, key, dst, &required_size); + if (err == ESP_ERR_NVS_NOT_FOUND) { + return; + } + + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to read factory key %s (%s)", + key, esp_err_to_name(err)); + } +} + +static void factory_config_init(void) +{ + if (factory_config_initialized) { + return; + } + + copy_default(factory_mqtt_password, sizeof(factory_mqtt_password), + CONFIG_LINKEY_MQTT_PASSWORD, + LINKEY_FACTORY_FALLBACK_MQTT_PASSWORD); + copy_default(factory_ble_pop, sizeof(factory_ble_pop), + CONFIG_LINKEY_PROV_POP, + LINKEY_FACTORY_FALLBACK_BLE_POP); + + esp_err_t err = nvs_flash_init_partition(LINKEY_FACTORY_PARTITION_LABEL); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Factory partition unavailable, using build defaults (%s)", + esp_err_to_name(err)); + factory_config_initialized = true; + return; + } + + nvs_handle_t nvs; + err = nvs_open_from_partition(LINKEY_FACTORY_PARTITION_LABEL, + LINKEY_FACTORY_NAMESPACE, + NVS_READONLY, &nvs); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Factory config unavailable, using build defaults (%s)", + esp_err_to_name(err)); + factory_config_initialized = true; + return; + } + + read_factory_string(nvs, LINKEY_FACTORY_KEY_MQTT_PASSWORD, + factory_mqtt_password, sizeof(factory_mqtt_password)); + read_factory_string(nvs, LINKEY_FACTORY_KEY_BLE_POP, + factory_ble_pop, sizeof(factory_ble_pop)); + nvs_close(nvs); + + factory_config_initialized = true; +} + +const char *factory_config_get_mqtt_password(void) +{ + factory_config_init(); + return factory_mqtt_password; +} + +const char *factory_config_get_ble_pop(void) +{ + factory_config_init(); + return factory_ble_pop; +} diff --git a/Firmware/main/factory_config.h b/Firmware/main/factory_config.h new file mode 100644 index 0000000..a4348d4 --- /dev/null +++ b/Firmware/main/factory_config.h @@ -0,0 +1,4 @@ +#pragma once + +const char *factory_config_get_mqtt_password(void); +const char *factory_config_get_ble_pop(void); diff --git a/Firmware/main/factory_config_schema.h b/Firmware/main/factory_config_schema.h new file mode 100644 index 0000000..dfcabf0 --- /dev/null +++ b/Firmware/main/factory_config_schema.h @@ -0,0 +1,8 @@ +#pragma once + +#define LINKEY_FACTORY_PARTITION_LABEL "factory_data" +#define LINKEY_FACTORY_NAMESPACE "linkey" +#define LINKEY_FACTORY_KEY_MQTT_PASSWORD "mqtt-password" +#define LINKEY_FACTORY_KEY_BLE_POP "ble-pop" +#define LINKEY_FACTORY_FALLBACK_MQTT_PASSWORD LINKEY_FACTORY_KEY_MQTT_PASSWORD +#define LINKEY_FACTORY_FALLBACK_BLE_POP "linkey-pop" diff --git a/Firmware/main/fsm.c b/Firmware/main/fsm.c index 01384ab..1a2db3a 100644 --- a/Firmware/main/fsm.c +++ b/Firmware/main/fsm.c @@ -60,13 +60,14 @@ app_state_t fsm_after_publish_data(bool voltage_low, app_state_t fsm_voltage_watchdog_next(app_state_t current, bool voltage_low) { - if (current == STATE_INIT || current == STATE_WAIT_VOLTAGE) return current; + if (current == STATE_WAIT_VOLTAGE) return current; return voltage_low ? STATE_WAIT_VOLTAGE : current; } bool fsm_should_reset_peak_on_enter(app_state_t state) { - return state == STATE_MQTT_CONNECT + return state == STATE_BLE_PROVISION + || state == STATE_MQTT_CONNECT || state == STATE_WAIT_ULP_DATA || state == STATE_PUBLISH_DATA; } diff --git a/Firmware/main/fsm.h b/Firmware/main/fsm.h index 68e51bd..849b707 100644 --- a/Firmware/main/fsm.h +++ b/Firmware/main/fsm.h @@ -23,8 +23,8 @@ extern "C" { * @brief Application FSM states. */ typedef enum { - STATE_INIT, STATE_WAIT_VOLTAGE, + STATE_BLE_PROVISION, STATE_WIFI_CONNECT, STATE_MQTT_CONNECT, STATE_WAIT_ULP_DATA, @@ -56,8 +56,8 @@ app_state_t fsm_after_publish_data(bool voltage_low, /** * @brief Voltage watchdog: if @p voltage_low is true and the current state * runs after init, return STATE_WAIT_VOLTAGE; otherwise return - * @p current unchanged. STATE_INIT and STATE_WAIT_VOLTAGE pass - * through (their own logic owns the voltage check). + * @p current unchanged. STATE_WAIT_VOLTAGE passes through because its + * own logic owns the voltage check. */ app_state_t fsm_voltage_watchdog_next(app_state_t current, bool voltage_low); diff --git a/Firmware/main/idf_component.yml b/Firmware/main/idf_component.yml new file mode 100644 index 0000000..0a5b3d7 --- /dev/null +++ b/Firmware/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + qrcode: ^0.1.0 + espressif/mdns: ^1.11.3 diff --git a/Firmware/main/main.c b/Firmware/main/main.c index 38c0617..94639f1 100644 --- a/Firmware/main/main.c +++ b/Firmware/main/main.c @@ -16,12 +16,14 @@ #include "voltage_manager.h" #include "wifi_manager.h" #include "mqtt_manager.h" +#include "provisioning_manager.h" #include "debug.h" #include "tic_types.h" static const char *TAG = "LINKY_MAIN"; // RGB LED pin definitions +#define BOOT_BUTTON_PIN GPIO_NUM_0 #define RGB_LED_RED_PIN GPIO_NUM_13 #define RGB_LED_GREEN_PIN GPIO_NUM_15 #define RGB_LED_BLUE_PIN GPIO_NUM_2 @@ -38,8 +40,8 @@ static const char *TAG = "LINKY_MAIN"; #define RGB_WHITE RGB_COLOR(1, 1, 1) // State LED color configuration (easily customizable) -#define LED_COLOR_INIT RGB_CYAN #define LED_COLOR_WAIT_VOLTAGE RGB_RED +#define LED_COLOR_BLE_PROVISION RGB_WHITE #define LED_COLOR_WIFI_CONNECT RGB_BLUE #define LED_COLOR_MQTT_CONNECT RGB_MAGENTA #define LED_COLOR_WAIT_ULP_DATA RGB_YELLOW @@ -48,6 +50,7 @@ static const char *TAG = "LINKY_MAIN"; // Timeouts (milliseconds) #define WIFI_CONNECT_TIMEOUT_MS 6000 // WiFi connection timeout #define MQTT_CONNECT_TIMEOUT_MS 1000 // MQTT connection timeout +#define BOOT_BUTTON_LONG_PRESS_MS 2000 // Hold BOOT/GPIO0 to reprovision // Timeout waiting for ULP data: frame-level reception needs at least 2 frame periods // (partial frame + first complete frame) before valid data is available #if defined(CONFIG_LINKEY_TARIFF_TEMPO) @@ -61,12 +64,12 @@ static const char *TAG = "LINKY_MAIN"; #endif #define POLL_INTERVAL_MS 100 // Polling interval in connect/wait loops -static app_state_t current_state = STATE_INIT; +static app_state_t current_state = STATE_WAIT_VOLTAGE; // State LED colors lookup table static const uint8_t state_colors[] = { - LED_COLOR_INIT, LED_COLOR_WAIT_VOLTAGE, + LED_COLOR_BLE_PROVISION, LED_COLOR_WIFI_CONNECT, LED_COLOR_MQTT_CONNECT, LED_COLOR_WAIT_ULP_DATA, @@ -84,6 +87,9 @@ static bool ulp_initialized = false; // Task handle for ULP wake notification static TaskHandle_t main_task_handle; +static TaskHandle_t ble_provision_led_task_handle = NULL; +static volatile bool ble_provision_led_blinking = false; +static volatile bool manual_provisioning_requested = false; // ULP wake ISR: notify main task when a new frame is received static void IRAM_ATTR ulp_isr(void *arg) @@ -93,6 +99,8 @@ static void IRAM_ATTR ulp_isr(void *arg) portYIELD_FROM_ISR(woken); } +static bool boot_button_force_allowed(app_state_t state); + static void rgb_led_init(void) { gpio_config_t io_conf = { @@ -112,6 +120,25 @@ static void rgb_led_init(void) DEBUG_LOG(TAG, "RGB LEDs initialized (R:%d, G:%d, B:%d)", RGB_LED_RED_PIN, RGB_LED_GREEN_PIN, RGB_LED_BLUE_PIN); } +static void boot_button_init(void) +{ + gpio_config_t io_conf = { + .pin_bit_mask = (1ULL << BOOT_BUTTON_PIN), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + ESP_ERROR_CHECK(gpio_config(&io_conf)); + DEBUG_LOG(TAG, "BOOT button initialized (GPIO%d)", BOOT_BUTTON_PIN); +} + +static bool boot_button_force_allowed(app_state_t state) +{ + return state != STATE_WAIT_VOLTAGE + && state != STATE_BLE_PROVISION; +} + // Set LED to a color (from RGB_COLOR macro) static void rgb_led_set(uint8_t color) { @@ -128,6 +155,38 @@ static void rgb_led_blink(uint8_t color, int duration_ms) rgb_led_set(RGB_OFF); } +static void ble_provision_led_task(void *arg) +{ + (void)arg; + + while (ble_provision_led_blinking) { + rgb_led_blink(LED_COLOR_BLE_PROVISION, 10); + vTaskDelay(pdMS_TO_TICKS(1990)); + } + + rgb_led_set(RGB_OFF); + ble_provision_led_task_handle = NULL; + vTaskDelete(NULL); +} + +static void ble_provision_led_start(void) +{ + if (ble_provision_led_task_handle) { + return; + } + + ble_provision_led_blinking = true; + ESP_ERROR_CHECK(xTaskCreate(ble_provision_led_task, "ble_prov_led", 2048, + NULL, 1, &ble_provision_led_task_handle) == pdPASS + ? ESP_OK + : ESP_ERR_NO_MEM); +} + +static void ble_provision_led_stop(void) +{ + ble_provision_led_blinking = false; +} + // Stop WiFi and MQTT (WiFi first to cut radio power immediately) static void stop_all_connections(void) { @@ -166,12 +225,13 @@ static conn_result_t ulp_wait_data(void) // Forward declarations for state handlers static app_state_t handle_state_init(void); static app_state_t handle_state_wait_voltage(void); +static app_state_t handle_state_ble_provision(void); static app_state_t handle_state_wifi_connect(void); static app_state_t handle_state_mqtt_connect(void); static app_state_t handle_state_wait_ulp_data(void); static app_state_t handle_state_publish_data(void); -// STATE_INIT: Initialize LED, ADC, pm_config +// Initialize LED, ADC, pm_config static app_state_t handle_state_init(void) { // Initialize NVS (used for WiFi) @@ -184,6 +244,7 @@ static app_state_t handle_state_init(void) // Initialize RGB LEDs rgb_led_init(); + boot_button_init(); // Initialize voltage monitoring voltage_init(); @@ -207,13 +268,117 @@ static app_state_t handle_state_wait_voltage(void) DEBUG_LOG(TAG, "Supercap voltage: %d mV", voltage_mv); if (voltage_mv >= VOLTAGE_START_MV) { - DEBUG_LOG(TAG, "Voltage sufficient - proceeding to WiFi init"); + if (!wifi_state.initialized) { + DEBUG_LOG(TAG, "Initializing WiFi..."); + wifi_init(&wifi_state); + } + + if (!wifi_has_config()) { + DEBUG_LOG(TAG, "Voltage sufficient but WiFi is not provisioned"); + return STATE_BLE_PROVISION; + } + + DEBUG_LOG(TAG, "Voltage sufficient - proceeding to WiFi connect"); return STATE_WIFI_CONNECT; } return STATE_WAIT_VOLTAGE; } +// STATE_BLE_PROVISION: Provision WiFi credentials over BLE +static app_state_t handle_state_ble_provision(void) +{ + if (!wifi_state.initialized) { + DEBUG_LOG(TAG, "Initializing WiFi for provisioning..."); + wifi_init(&wifi_state); + } + + DEBUG_LOG(TAG, "Starting BLE WiFi provisioning..."); + ble_provision_led_start(); + conn_result_t result = provisioning_start_ble(VOLTAGE_FALLBACK_MIN_MV, + POLL_INTERVAL_MS); + ble_provision_led_stop(); + if (result == CONN_OK) { + DEBUG_LOG(TAG, "WiFi credentials stored - returning to WAIT_VOLTAGE"); + stop_all_connections(); + return STATE_WAIT_VOLTAGE; + } + if (result == CONN_VOLTAGE_LOW) { + DEBUG_LOGW(TAG, "Voltage too low - returning to WAIT_VOLTAGE"); + stop_all_connections(); + return STATE_WAIT_VOLTAGE; + } + + if (wifi_has_config()) { + DEBUG_LOGW(TAG, "BLE provisioning failed - keeping previous credentials"); + stop_all_connections(); + return STATE_WAIT_VOLTAGE; + } + + DEBUG_LOGW(TAG, "BLE provisioning failed - retrying"); + stop_all_connections(); + return STATE_BLE_PROVISION; +} + +static void poll_boot_button_for_reprovision(void) +{ + static bool was_pressed = false; + static bool must_release = false; + static uint32_t pressed_ms = 0; + static TickType_t last_poll_tick = 0; + TickType_t now = xTaskGetTickCount(); + uint32_t elapsed_ms = (last_poll_tick == 0) + ? 0 + : pdTICKS_TO_MS(now - last_poll_tick); + bool pressed = gpio_get_level(BOOT_BUTTON_PIN) == 0; + + last_poll_tick = now; + + if (!boot_button_force_allowed(current_state)) { + was_pressed = false; + must_release = false; + pressed_ms = 0; + return; + } + + if (must_release) { + // Wait for a release before allowing another long-press request + if (!pressed) { + must_release = false; + pressed_ms = 0; + } + return; + } else if (pressed) { + // Start timing on a fresh press + if (!was_pressed) { + pressed_ms = 0; + } else { + pressed_ms += elapsed_ms; + } + // If the button has been pressed long enough: + // - request manual provisioning + // - and block further requests until the button is released + if (pressed_ms >= BOOT_BUTTON_LONG_PRESS_MS) { + manual_provisioning_requested = true; + must_release = true; + } + } + + was_pressed = pressed; +} + +static void handle_manual_provisioning_request(void) +{ + poll_boot_button_for_reprovision(); + + if (manual_provisioning_requested) { + manual_provisioning_requested = false; + DEBUG_LOGW(TAG, "BOOT button long pressed - requesting WiFi provisioning"); + stop_all_connections(); + current_state = STATE_BLE_PROVISION; + } +} + // STATE_WIFI_CONNECT: Initialize WiFi (once) and connect static app_state_t handle_state_wifi_connect(void) { @@ -260,7 +425,7 @@ static app_state_t handle_state_mqtt_connect(void) VOLTAGE_FALLBACK_MIN_MV, wifi_is_connected, MQTT_CONNECT_TIMEOUT_MS, POLL_INTERVAL_MS); if (result == CONN_VOLTAGE_LOW || result == CONN_WIFI_LOST) { - DEBUG_LOGW(TAG, result == CONN_VOLTAGE_LOW + DEBUG_LOGW(TAG, "%s", result == CONN_VOLTAGE_LOW ? "Voltage too low - stopping WiFi and returning to WAIT_VOLTAGE" : "WiFi lost during MQTT connect - returning to WIFI_CONNECT"); stop_all_connections(); @@ -289,7 +454,7 @@ static app_state_t handle_state_wait_ulp_data(void) conn_result_t result = ulp_wait_data(); if (result == CONN_VOLTAGE_LOW || result == CONN_WIFI_LOST) { - DEBUG_LOGW(TAG, result == CONN_VOLTAGE_LOW + DEBUG_LOGW(TAG, "%s", result == CONN_VOLTAGE_LOW ? "Voltage too low - stopping WiFi and returning to WAIT_VOLTAGE" : "WiFi or MQTT lost during ULP wait - returning to WIFI_CONNECT"); stop_all_connections(); @@ -353,7 +518,9 @@ static app_state_t handle_state_publish_data(void) void app_main(void) { DEBUG_LOG(TAG, "Starting FSM..."); - app_state_t previous_state = STATE_INIT; + + current_state = handle_state_init(); + app_state_t previous_state = current_state; while (1) { // Reset dynamic peak tracker on state transitions into dynamic states @@ -364,10 +531,9 @@ void app_main(void) previous_state = current_state; } - // Voltage check only possible after INIT (ADC must be initialized first). // STATE_WIFI_CONNECT uses the fixed floor (peak hasn't been built up yet); // every other connected state uses the dynamic drop-from-peak threshold. - if (current_state != STATE_INIT && current_state != STATE_WAIT_VOLTAGE) { + if (current_state != STATE_WAIT_VOLTAGE) { bool low = (current_state == STATE_WIFI_CONNECT) ? voltage_is_low(VOLTAGE_FALLBACK_MIN_MV) : voltage_is_low_dynamic(VOLTAGE_FALLBACK_MIN_MV); @@ -378,20 +544,22 @@ void app_main(void) } } + handle_manual_provisioning_request(); + // Blink LED for current state (10ms) rgb_led_blink(state_colors[current_state], 10); // Handle current state switch (current_state) { - case STATE_INIT: - current_state = handle_state_init(); - break; case STATE_WAIT_VOLTAGE: current_state = handle_state_wait_voltage(); break; case STATE_WIFI_CONNECT: current_state = handle_state_wifi_connect(); break; + case STATE_BLE_PROVISION: + current_state = handle_state_ble_provision(); + break; case STATE_MQTT_CONNECT: current_state = handle_state_mqtt_connect(); break; diff --git a/Firmware/main/mqtt_manager.c b/Firmware/main/mqtt_manager.c index 1d2b51e..96eb7e0 100644 --- a/Firmware/main/mqtt_manager.c +++ b/Firmware/main/mqtt_manager.c @@ -4,21 +4,20 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_mac.h" +#include "esp_log.h" #include "debug.h" +#include "factory_config.h" #include "types.h" +#if CONFIG_LINKEY_MQTT_AUTODISCOVERY +#include "mdns.h" +#endif + static const char *TAG = "MQTT_MGR"; -// MQTT credentials from Kconfig +// MQTT credentials from Kconfig/factory data #define MQTT_BROKER_URI CONFIG_LINKEY_MQTT_BROKER_URI #define MQTT_USERNAME CONFIG_LINKEY_MQTT_USERNAME -#define MQTT_PASSWORD CONFIG_LINKEY_MQTT_PASSWORD - -// MQTT topics -#define MQTT_TOPIC_STATE CONFIG_LINKEY_MQTT_TOPIC_PREFIX "/state" -#define MQTT_TOPIC_STATUS CONFIG_LINKEY_MQTT_TOPIC_PREFIX "/status" -#define MQTT_TOPIC_DEBUG_REQ CONFIG_LINKEY_MQTT_TOPIC_PREFIX "/debug/request" -#define MQTT_TOPIC_DEBUG_FRAME CONFIG_LINKEY_MQTT_TOPIC_PREFIX "/debug/tic_frame" // Home Assistant discovery prefix #define HA_DISCOVERY_PREFIX "homeassistant" @@ -42,6 +41,96 @@ static const char *TAG = "MQTT_MGR"; // Cached MAC address string static char device_mac_str[13] = {0}; // 12 hex chars + null +static char default_mqtt_username[14] = {0}; // "linkey_" + 6 hex chars + null +static char device_display_name[32] = {0}; +static char mqtt_topic_state[20]; // "linkey/" + 6 hex chars + "/state" + null +static char mqtt_topic_status[21]; // "linkey/" + 6 hex chars + "/status" + null +static char mqtt_topic_debug_req[28]; // "linkey/" + 6 hex chars + "/debug/request" + null +static char mqtt_topic_debug_frame[31]; // "linkey/" + 6 hex chars + "/debug/tic_frame" + null + +#if CONFIG_LINKEY_MQTT_AUTODISCOVERY +#define MQTT_DISCOVERY_TIMEOUT_MS 3000 +#define MQTT_DISCOVERY_MAX_RESULTS 2 + +static bool mdns_started; +static char discovered_mqtt_uri[64]; + +static bool mqtt_uri_from_result(const mdns_result_t *result, uint16_t broker_port, + bool use_result_port, char *uri, size_t uri_len) +{ + for (const mdns_result_t *r = result; r; r = r->next) { + uint16_t port = use_result_port && r->port ? r->port : broker_port; + for (const mdns_ip_addr_t *addr = r->addr; addr; addr = addr->next) { + if (addr->addr.type == ESP_IPADDR_TYPE_V4) { + int written = snprintf(uri, uri_len, "mqtt://" IPSTR ":%u", + IP2STR(&addr->addr.u_addr.ip4), port); + return written > 0 && (size_t)written < uri_len; + } + } + + if (r->hostname && r->hostname[0]) { + int written = snprintf(uri, uri_len, "mqtt://%s.local:%u", + r->hostname, port); + return written > 0 && (size_t)written < uri_len; + } + } + + return false; +} + +static bool mqtt_discover_service(const char *service, uint16_t broker_port, + bool use_result_port, char *uri, size_t uri_len) +{ + mdns_result_t *results = NULL; + esp_err_t err = mdns_query_ptr(service, "_tcp", MQTT_DISCOVERY_TIMEOUT_MS, + MQTT_DISCOVERY_MAX_RESULTS, &results); + bool found = false; + + if (err == ESP_OK && results) { + found = mqtt_uri_from_result(results, broker_port, use_result_port, + uri, uri_len); + } + + if (results) { + mdns_query_results_free(results); + } + + return found; +} + +static const char *mqtt_broker_uri(void) +{ + if (discovered_mqtt_uri[0]) { + return discovered_mqtt_uri; + } + + if (!mdns_started) { + esp_err_t err = mdns_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "mDNS init failed (%s), using MQTT fallback URI", + esp_err_to_name(err)); + return MQTT_BROKER_URI; + } + mdns_started = true; + } + + if (mqtt_discover_service("_home-assistant", 1883, false, discovered_mqtt_uri, + sizeof(discovered_mqtt_uri))) { + ESP_LOGI(TAG, "Home Assistant discovered via mDNS, using MQTT URI: %s", + discovered_mqtt_uri); + return discovered_mqtt_uri; + } + + ESP_LOGI(TAG, "No MQTT broker discovered with mDNS, using fallback URI: %s", + MQTT_BROKER_URI); + return MQTT_BROKER_URI; +} +#else +static const char *mqtt_broker_uri(void) +{ + return MQTT_BROKER_URI; +} +#endif // Get device MAC address as lowercase hex string static const char* get_device_mac_str(void) @@ -55,11 +144,58 @@ static const char* get_device_mac_str(void) return device_mac_str; } +static const char *mqtt_username(void) +{ + if (strlen(MQTT_USERNAME) > 0) { + return MQTT_USERNAME; + } + + if (default_mqtt_username[0] == 0) { + const char *mac = get_device_mac_str(); + snprintf(default_mqtt_username, sizeof(default_mqtt_username), + "linkey_%s", mac + 6); + } + + return default_mqtt_username; +} + +static const char *ha_device_name(void) +{ + if (strlen(DEVICE_NAME) > 0) { + return DEVICE_NAME; + } + + if (device_display_name[0] == 0) { + const char *mac = get_device_mac_str(); + snprintf(device_display_name, sizeof(device_display_name), + "Linkey_%s", mac + 6); + } + + return device_display_name; +} + +static void mqtt_topics_init(void) +{ + if (mqtt_topic_state[0] != 0) { + return; + } + + const char *mac = get_device_mac_str(); + const char *suffix = mac + 6; + snprintf(mqtt_topic_state, sizeof(mqtt_topic_state), "linkey/%s/state", suffix); + snprintf(mqtt_topic_status, sizeof(mqtt_topic_status), "linkey/%s/status", suffix); + snprintf(mqtt_topic_debug_req, sizeof(mqtt_topic_debug_req), + "linkey/%s/debug/request", suffix); + snprintf(mqtt_topic_debug_frame, sizeof(mqtt_topic_debug_frame), + "linkey/%s/debug/tic_frame", suffix); +} + // Publish Home Assistant device discovery config (single payload for all sensors) // Topic: homeassistant/device/linkey_/config static void mqtt_publish_ha_discovery(esp_mqtt_client_handle_t client) { const char *mac = get_device_mac_str(); + mqtt_topics_init(); char topic[128]; char payload[4096]; int offset = 0; @@ -81,7 +217,7 @@ static void mqtt_publish_ha_discovery(esp_mqtt_client_handle_t client) "\"hw\":\"%s\"," "\"sn\":\"%s\"" "},", - mac, DEVICE_NAME, DEVICE_MANUFACTURER, DEVICE_MODEL, + mac, ha_device_name(), DEVICE_MANUFACTURER, DEVICE_MODEL, FW_VERSION, HW_VERSION, mac); // Origin info @@ -321,7 +457,7 @@ static void mqtt_publish_ha_discovery(esp_mqtt_client_handle_t client) "\"stat_t\":\"%s\"," "\"avty_t\":\"%s\"" "}", - MQTT_TOPIC_STATE, MQTT_TOPIC_STATUS); + mqtt_topic_state, mqtt_topic_status); DEBUG_LOG(TAG, "HA discovery payload size: %d bytes", offset); @@ -333,14 +469,14 @@ static void mqtt_publish_ha_discovery(esp_mqtt_client_handle_t client) // Subscribe to debug request topic static void mqtt_subscribe_debug_topic(esp_mqtt_client_handle_t client) { - esp_mqtt_client_subscribe(client, MQTT_TOPIC_DEBUG_REQ, 1); + esp_mqtt_client_subscribe(client, mqtt_topic_debug_req, 1); DEBUG_LOG(TAG, "Subscribed to debug request topic"); } // Publish online status to availability topic static void mqtt_publish_online(esp_mqtt_client_handle_t client) { - esp_mqtt_client_publish(client, MQTT_TOPIC_STATUS, "online", 0, 1, 1); + esp_mqtt_client_publish(client, mqtt_topic_status, "online", 0, 1, 1); DEBUG_LOG(TAG, "Published: online"); } @@ -376,7 +512,8 @@ static void mqtt_event_handler(void *handler_args, esp_event_base_t base, esp_mqtt_event_handle_t event = (esp_mqtt_event_handle_t)event_data; if (event->topic_len > 0 && event->data_len > 0) { // Check if this is a debug request message - if (strncmp(event->topic, MQTT_TOPIC_DEBUG_REQ, event->topic_len) == 0) { + if (event->topic_len == strlen(mqtt_topic_debug_req) + && strncmp(event->topic, mqtt_topic_debug_req, event->topic_len) == 0) { // Check for magic word "GET_TIC_FRAME" if (event->data_len >= 12 && strncmp(event->data, "GET_TIC_FRAME", 13) == 0) { DEBUG_LOG(TAG, "Debug request received: GET_TIC_FRAME"); @@ -397,12 +534,14 @@ void mqtt_init(mqtt_state_t *state) return; } + const char *broker_uri = mqtt_broker_uri(); + mqtt_topics_init(); esp_mqtt_client_config_t mqtt_cfg = { - .broker.address.uri = MQTT_BROKER_URI, + .broker.address.uri = broker_uri, .session.keepalive = 5, // Reduced from 30s for faster disconnect detection .session.disable_clean_session = 0, .session.last_will = { - .topic = MQTT_TOPIC_STATUS, + .topic = mqtt_topic_status, .msg = "offline", .msg_len = 7, .qos = 1, @@ -414,11 +553,10 @@ void mqtt_init(mqtt_state_t *state) .buffer.out_size = 4096, }; - if (strlen(MQTT_USERNAME) > 0) { - mqtt_cfg.credentials.username = MQTT_USERNAME; - } - if (strlen(MQTT_PASSWORD) > 0) { - mqtt_cfg.credentials.authentication.password = MQTT_PASSWORD; + mqtt_cfg.credentials.username = mqtt_username(); + const char *mqtt_password = factory_config_get_mqtt_password(); + if (strlen(mqtt_password) > 0) { + mqtt_cfg.credentials.authentication.password = mqtt_password; } state->client = esp_mqtt_client_init(&mqtt_cfg); @@ -629,7 +767,7 @@ bool mqtt_publish_linky_data(mqtt_state_t *state, linky_data_t *data) snprintf(payload + offset, sizeof(payload) - offset, "}"); // Publish single JSON to state topic (QoS 1) - int ret = esp_mqtt_client_publish(state->client, MQTT_TOPIC_STATE, payload, 0, 1, 0); + int ret = esp_mqtt_client_publish(state->client, mqtt_topic_state, payload, 0, 1, 0); if (ret < 0) { DEBUG_LOGW(TAG, "Failed to publish state"); return false; @@ -645,7 +783,7 @@ bool mqtt_publish_tic_frame_debug(mqtt_state_t *state, const char *frame, int fr return false; } - int ret = esp_mqtt_client_publish(state->client, MQTT_TOPIC_DEBUG_FRAME, frame, frame_len, 1, 0); + int ret = esp_mqtt_client_publish(state->client, mqtt_topic_debug_frame, frame, frame_len, 1, 0); if (ret < 0) { DEBUG_LOGW(TAG, "Failed to publish debug frame"); return false; diff --git a/Firmware/main/mqtt_manager.h b/Firmware/main/mqtt_manager.h index a6549bf..e81fd83 100644 --- a/Firmware/main/mqtt_manager.h +++ b/Firmware/main/mqtt_manager.h @@ -89,7 +89,7 @@ bool mqtt_is_connected(mqtt_state_t *state); bool mqtt_publish_linky_data(mqtt_state_t *state, linky_data_t *data); /** - * @brief Publish raw TIC frame to debug topic (linkey/debug/tic_frame) + * @brief Publish raw TIC frame to debug topic (linkey//debug/tic_frame) * * @param[in] state MQTT state structure * @param[in] frame Raw TIC frame buffer (including STX/ETX) diff --git a/Firmware/main/provisioning_manager.c b/Firmware/main/provisioning_manager.c new file mode 100644 index 0000000..1cc64ec --- /dev/null +++ b/Firmware/main/provisioning_manager.c @@ -0,0 +1,320 @@ +#include +#include +#include "provisioning_manager.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/task.h" +#include "esp_event.h" +#include "esp_mac.h" +#include "esp_log.h" +#include "esp_wifi.h" +#ifdef CONFIG_LINKEY_DEBUG_LOGS +#include "qrcode.h" +#endif +#include "wifi_provisioning/manager.h" +#include "wifi_provisioning/scheme_ble.h" +#include "wifi_provisioning/wifi_config.h" +#include "wifi_provisioning/wifi_scan.h" +#include "voltage_manager.h" +#include "debug.h" +#include "factory_config.h" + +static const char *TAG = "PROV_MGR"; + +#define PROV_DONE_BIT BIT0 +#define PROV_FAIL_BIT BIT1 +#define PROV_QR_VERSION "v1" +#define PROV_TRANSPORT_BLE "ble" +#define QRCODE_BASE_URL "https://espressif.github.io/esp-jumpstart/qrcode.html" + +static EventGroupHandle_t prov_event_group; +static wifi_config_t deferred_wifi_config; +static bool deferred_wifi_config_set; +static bool deferred_wifi_config_applied; + +static esp_err_t deferred_config_get_status(wifi_prov_config_get_data_t *resp_data, + wifi_prov_ctx_t **ctx) +{ + (void)ctx; + memset(resp_data, 0, sizeof(*resp_data)); + + if (!deferred_wifi_config_applied) { + resp_data->wifi_state = WIFI_PROV_STA_CONNECTING; + resp_data->connecting_info.attempts_remaining = 1; + return ESP_OK; + } + + resp_data->wifi_state = WIFI_PROV_STA_CONNECTED; + strlcpy(resp_data->conn_info.ip_addr, "0.0.0.0", + sizeof(resp_data->conn_info.ip_addr)); + strlcpy(resp_data->conn_info.ssid, (const char *)deferred_wifi_config.sta.ssid, + sizeof(resp_data->conn_info.ssid)); + memset(resp_data->conn_info.bssid, 0x02, sizeof(resp_data->conn_info.bssid)); + resp_data->conn_info.channel = deferred_wifi_config.sta.channel; + resp_data->conn_info.auth_mode = WIFI_AUTH_WPA2_PSK; + + if (prov_event_group) { + xEventGroupSetBits(prov_event_group, PROV_DONE_BIT); + } + DEBUG_LOG(TAG, "Reported deferred WiFi provisioning success to app"); + return ESP_OK; +} + +static esp_err_t deferred_config_set(const wifi_prov_config_set_data_t *req_data, + wifi_prov_ctx_t **ctx) +{ + (void)ctx; + memset(&deferred_wifi_config, 0, sizeof(deferred_wifi_config)); + + size_t ssid_len = strnlen(req_data->ssid, sizeof(deferred_wifi_config.sta.ssid)); + memcpy(deferred_wifi_config.sta.ssid, req_data->ssid, ssid_len); + strlcpy((char *)deferred_wifi_config.sta.password, req_data->password, + sizeof(deferred_wifi_config.sta.password)); + + if (req_data->channel != 0) { + deferred_wifi_config.sta.channel = req_data->channel; + } + if (memcmp(req_data->bssid, "\0\0\0\0\0\0", 6) != 0) { + memcpy(deferred_wifi_config.sta.bssid, req_data->bssid, + sizeof(deferred_wifi_config.sta.bssid)); + deferred_wifi_config.sta.bssid_set = 1; + } + + deferred_wifi_config.sta.scan_method = WIFI_FAST_SCAN; + deferred_wifi_config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL; + deferred_wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + deferred_wifi_config.sta.listen_interval = 10; + + deferred_wifi_config_set = true; + deferred_wifi_config_applied = false; + DEBUG_LOG(TAG, "Stored deferred WiFi credentials for SSID %s", req_data->ssid); + return ESP_OK; +} + +static esp_err_t deferred_config_apply(wifi_prov_ctx_t **ctx) +{ + (void)ctx; + if (!deferred_wifi_config_set) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t ret = esp_wifi_set_config(WIFI_IF_STA, &deferred_wifi_config); + if (ret != ESP_OK) { + DEBUG_LOGE(TAG, "Failed to store deferred WiFi credentials: %s", + esp_err_to_name(ret)); + return ret; + } + + deferred_wifi_config_applied = true; + DEBUG_LOG(TAG, "Deferred WiFi credentials saved; skipping immediate WiFi validation"); + return ESP_OK; +} + +static wifi_prov_config_handlers_t deferred_config_handlers = { + .get_status_handler = deferred_config_get_status, + .set_config_handler = deferred_config_set, + .apply_config_handler = deferred_config_apply, + .ctx = NULL, +}; + +static esp_err_t low_power_scan_start(bool blocking, bool passive, + uint8_t group_channels, uint32_t period_ms, + wifi_prov_scan_ctx_t **ctx) +{ + (void)blocking; + (void)passive; + (void)group_channels; + (void)period_ms; + (void)ctx; + DEBUG_LOG(TAG, "Ignoring WiFi scan request for low-power provisioning"); + return ESP_OK; +} + +static esp_err_t low_power_scan_status(bool *scan_finished, + uint16_t *result_count, + wifi_prov_scan_ctx_t **ctx) +{ + (void)ctx; + if (scan_finished) { + *scan_finished = true; + } + if (result_count) { + *result_count = 0; + } + return ESP_OK; +} + +static esp_err_t low_power_scan_result(uint16_t result_index, + wifi_prov_scan_result_t *result, + wifi_prov_scan_ctx_t **ctx) +{ + (void)result_index; + (void)result; + (void)ctx; + return ESP_ERR_NOT_FOUND; +} + +static wifi_prov_scan_handlers_t low_power_scan_handlers = { + .scan_start = low_power_scan_start, + .scan_status = low_power_scan_status, + .scan_result = low_power_scan_result, + .ctx = NULL, +}; + +static void get_service_name(char *service_name, size_t service_name_size) +{ + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + snprintf(service_name, service_name_size, "Linkey_%02X%02X%02X", + mac[3], mac[4], mac[5]); +} + +static void print_provisioning_qr(const char *service_name, const char *ble_pop) +{ +#ifdef CONFIG_LINKEY_DEBUG_LOGS + char payload[150] = {0}; + snprintf(payload, sizeof(payload), + "{\"ver\":\"%s\",\"name\":\"%s\",\"pop\":\"%s\",\"transport\":\"%s\"}", + PROV_QR_VERSION, service_name, ble_pop, + PROV_TRANSPORT_BLE); + + ESP_LOGI(TAG, "Scan this QR code from the ESP provisioning app."); + esp_qrcode_config_t cfg = ESP_QRCODE_CONFIG_DEFAULT(); + esp_qrcode_generate(&cfg, payload); + ESP_LOGI(TAG, "Provisioning URL:\n%s?data=%s", QRCODE_BASE_URL, payload); +#else + (void)service_name; + (void)ble_pop; +#endif +} + +static void provisioning_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) +{ + (void)arg; + + if (event_base == WIFI_PROV_EVENT) { + switch (event_id) { + case WIFI_PROV_START: + DEBUG_LOG(TAG, "BLE provisioning started"); + break; + case WIFI_PROV_CRED_RECV: + { + const wifi_sta_config_t *wifi_sta_cfg = (const wifi_sta_config_t *)event_data; + (void)wifi_sta_cfg; + DEBUG_LOG(TAG, "Received WiFi credentials for SSID %s", + (const char *)wifi_sta_cfg->ssid); + break; + } + case WIFI_PROV_CRED_FAIL: + DEBUG_LOGW(TAG, "WiFi provisioning credentials failed"); + if (prov_event_group) { + xEventGroupSetBits(prov_event_group, PROV_FAIL_BIT); + } + break; + case WIFI_PROV_CRED_SUCCESS: + DEBUG_LOG(TAG, "WiFi provisioning credentials accepted"); + break; + case WIFI_PROV_END: + DEBUG_LOG(TAG, "BLE provisioning ended"); + break; + default: + break; + } + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + if (prov_event_group) { + xEventGroupSetBits(prov_event_group, PROV_DONE_BIT); + } + } +} + +conn_result_t provisioning_start_ble(uint16_t voltage_threshold, + uint32_t poll_interval_ms) +{ + char service_name[32]; + get_service_name(service_name, sizeof(service_name)); + + prov_event_group = xEventGroupCreate(); + if (!prov_event_group) { + return CONN_FAILED; + } + + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_PROV_EVENT, ESP_EVENT_ANY_ID, + provisioning_event_handler, NULL)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + provisioning_event_handler, NULL)); + + wifi_prov_mgr_config_t config = { + .scheme = wifi_prov_scheme_ble, + .scheme_event_handler = WIFI_PROV_EVENT_HANDLER_NONE, + }; + + ESP_ERROR_CHECK(wifi_prov_mgr_init(config)); + + const char *ble_pop = factory_config_get_ble_pop(); + const wifi_prov_security1_params_t *sec_params = ble_pop; + ESP_ERROR_CHECK(wifi_prov_mgr_start_provisioning(WIFI_PROV_SECURITY_1, + sec_params, + service_name, + NULL)); + + deferred_wifi_config_set = false; + deferred_wifi_config_applied = false; + wifi_prov_mgr_endpoint_unregister("prov-config"); + ESP_ERROR_CHECK(wifi_prov_mgr_endpoint_register("prov-config", + wifi_prov_config_data_handler, + &deferred_config_handlers)); + DEBUG_LOG(TAG, "WiFi config endpoint replaced with deferred validation"); + + wifi_prov_mgr_endpoint_unregister("prov-scan"); + ESP_ERROR_CHECK(wifi_prov_mgr_endpoint_register("prov-scan", + wifi_prov_scan_handler, + &low_power_scan_handlers)); + DEBUG_LOG(TAG, "WiFi scan endpoint replaced with low-power empty scan"); + + DEBUG_LOG(TAG, "Provision with ESP app: name=%s transport=ble security=1", + service_name); + print_provisioning_qr(service_name, ble_pop); + + conn_result_t result = CONN_FAILED; + while (1) { + if (voltage_is_low_dynamic(voltage_threshold)) { + DEBUG_LOGW(TAG, "Voltage too low during BLE provisioning"); + wifi_prov_mgr_stop_provisioning(); + result = CONN_VOLTAGE_LOW; + break; + } + + EventBits_t bits = xEventGroupWaitBits(prov_event_group, + PROV_DONE_BIT | PROV_FAIL_BIT, + pdTRUE, pdFALSE, + pdMS_TO_TICKS(poll_interval_ms)); + if (bits & PROV_DONE_BIT) { + result = CONN_OK; + break; + } + if (bits & PROV_FAIL_BIT) { + result = CONN_FAILED; + break; + } + } + + wifi_prov_mgr_deinit(); + esp_err_t stop_ret = esp_wifi_stop(); + if (stop_ret != ESP_OK && stop_ret != ESP_ERR_WIFI_NOT_STARTED) { + DEBUG_LOGW(TAG, "Failed to stop WiFi after provisioning: %s", + esp_err_to_name(stop_ret)); + } + if (result == CONN_OK) { + wifi_prov_scheme_ble_event_cb_free_btdm(NULL, WIFI_PROV_DEINIT, NULL); + } + + esp_event_handler_unregister(WIFI_PROV_EVENT, ESP_EVENT_ANY_ID, + provisioning_event_handler); + esp_event_handler_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, + provisioning_event_handler); + vEventGroupDelete(prov_event_group); + prov_event_group = NULL; + + return result; +} diff --git a/Firmware/main/provisioning_manager.h b/Firmware/main/provisioning_manager.h new file mode 100644 index 0000000..965c31d --- /dev/null +++ b/Firmware/main/provisioning_manager.h @@ -0,0 +1,29 @@ +/** + * @file provisioning_manager.h + * @brief BLE WiFi provisioning wrapper with voltage-aware exit. + */ + +#ifndef PROVISIONING_MANAGER_H +#define PROVISIONING_MANAGER_H + +#include +#include "types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Start BLE provisioning and wait for WiFi credentials. + * + * The call returns when provisioning succeeds, fails, or voltage drops below + * the supplied threshold. + */ +conn_result_t provisioning_start_ble(uint16_t voltage_threshold, + uint32_t poll_interval_ms); + +#ifdef __cplusplus +} +#endif + +#endif // PROVISIONING_MANAGER_H diff --git a/Firmware/main/wifi_manager.c b/Firmware/main/wifi_manager.c index 6634468..31ed1c0 100644 --- a/Firmware/main/wifi_manager.c +++ b/Firmware/main/wifi_manager.c @@ -12,10 +12,6 @@ RTC_DATA_ATTR static uint8_t rtc_bssid[6] = {0}; RTC_DATA_ATTR static uint8_t rtc_channel = 0; RTC_DATA_ATTR static bool rtc_bssid_valid = false; -// WiFi credentials from Kconfig -#define WIFI_SSID CONFIG_LINKEY_WIFI_SSID -#define WIFI_PASS CONFIG_LINKEY_WIFI_PASSWORD - static void wifi_event_handler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { @@ -26,11 +22,13 @@ static void wifi_event_handler(void* arg, esp_event_base_t event_base, DEBUG_LOG(TAG, "WiFi STA started"); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { wifi_event_sta_disconnected_t* event = (wifi_event_sta_disconnected_t*) event_data; + (void)event; DEBUG_LOG(TAG, "WiFi disconnected, reason: %d", event->reason); xEventGroupClearBits(state->event_group, WIFI_CONNECTED_BIT); xEventGroupSetBits(state->event_group, WIFI_FAIL_BIT); } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data; + (void)event; DEBUG_LOG(TAG, "WiFi connected - IP: " IPSTR, IP2STR(&event->ip_info.ip)); // Cache BSSID and channel for faster reconnection next time @@ -71,7 +69,7 @@ void wifi_init(wifi_state_t *state) #endif wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - cfg.nvs_enable = 0; + cfg.nvs_enable = 1; ESP_ERROR_CHECK(esp_wifi_init(&cfg)); esp_event_handler_instance_t instance_any_id; @@ -87,36 +85,30 @@ void wifi_init(wifi_state_t *state) state, &instance_got_ip)); - wifi_config_t wifi_config = { - .sta = { - .ssid = WIFI_SSID, - .password = WIFI_PASS, - .threshold.authmode = WIFI_AUTH_WPA2_PSK, - .scan_method = WIFI_FAST_SCAN, - .sort_method = WIFI_CONNECT_AP_BY_SIGNAL, - .bssid_set = 0, - .listen_interval = 10, - }, - }; - - if (rtc_bssid_valid) { - memcpy(wifi_config.sta.bssid, rtc_bssid, 6); - wifi_config.sta.bssid_set = 1; - wifi_config.sta.channel = rtc_channel; - DEBUG_LOG(TAG, "Using cached AP (Ch %d)", rtc_channel); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + + wifi_config_t wifi_config = {0}; + ESP_ERROR_CHECK(esp_wifi_get_config(WIFI_IF_STA, &wifi_config)); + + if (wifi_config.sta.ssid[0] != '\0') { + wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + wifi_config.sta.scan_method = WIFI_FAST_SCAN; + wifi_config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL; + wifi_config.sta.listen_interval = 10; + + if (rtc_bssid_valid) { + memcpy(wifi_config.sta.bssid, rtc_bssid, 6); + wifi_config.sta.bssid_set = 1; + wifi_config.sta.channel = rtc_channel; + DEBUG_LOG(TAG, "Using cached AP (Ch %d)", rtc_channel); + } + + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); } - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MAX_MODEM)); DEBUG_LOG(TAG, "WiFi modem sleep enabled"); - ESP_ERROR_CHECK(esp_wifi_start()); - state->started = true; - - // Set shorter beacon timeout for faster disconnect detection (minimum 3s for STA) - ESP_ERROR_CHECK(esp_wifi_set_inactive_time(WIFI_IF_STA, 3)); - state->initialized = true; DEBUG_LOG(TAG, "WiFi initialized"); } @@ -140,10 +132,28 @@ void wifi_start(wifi_state_t *state) if (state->initialized && !state->started) { DEBUG_LOG(TAG, "Starting WiFi..."); ESP_ERROR_CHECK(esp_wifi_start()); + // Set shorter beacon timeout after WiFi is started (minimum 3s for STA). + ESP_ERROR_CHECK(esp_wifi_set_inactive_time(WIFI_IF_STA, 3)); state->started = true; } } +bool wifi_has_config(void) +{ + wifi_config_t wifi_config = {0}; + if (esp_wifi_get_config(WIFI_IF_STA, &wifi_config) != ESP_OK) { + return false; + } + return wifi_config.sta.ssid[0] != '\0'; +} + +void wifi_mark_started(wifi_state_t *state) +{ + if (!state) return; + ESP_ERROR_CHECK(esp_wifi_set_inactive_time(WIFI_IF_STA, 3)); + state->started = true; +} + conn_result_t wifi_connect(wifi_state_t *state, voltage_check_fn_t voltage_check, uint16_t voltage_threshold, uint32_t timeout_ms, uint32_t poll_interval_ms) diff --git a/Firmware/main/wifi_manager.h b/Firmware/main/wifi_manager.h index 51a1b36..4665d2f 100644 --- a/Firmware/main/wifi_manager.h +++ b/Firmware/main/wifi_manager.h @@ -55,6 +55,25 @@ void wifi_stop(wifi_state_t *state); */ void wifi_start(wifi_state_t *state); +/** + * @brief Check whether a non-empty STA SSID is configured in the WiFi driver. + * + * With WiFi NVS enabled, this reflects credentials provisioned by ESP-IDF's + * provisioning manager after esp_wifi_init() has loaded stored config. + * + * @return true if STA credentials are present, false otherwise + */ +bool wifi_has_config(void); + +/** + * @brief Synchronize state after another component starts WiFi. + * + * The provisioning manager owns esp_wifi_start() while provisioning. Call this + * after provisioning succeeds so the normal WiFi manager state matches the + * driver state. + */ +void wifi_mark_started(wifi_state_t *state); + /** * @brief Connect to WiFi AP with voltage monitoring * diff --git a/Firmware/mosquitto_qemu.conf b/Firmware/mosquitto_qemu.conf index afa4138..d1638cc 100644 --- a/Firmware/mosquitto_qemu.conf +++ b/Firmware/mosquitto_qemu.conf @@ -1,7 +1,8 @@ # Mosquitto MQTT broker config for LinKey QEMU tests. per_listener_settings false -allow_anonymous true +allow_anonymous false +password_file /tmp/mosquitto.passwd listener 1883 0.0.0.0 protocol mqtt diff --git a/Firmware/partitions.csv b/Firmware/partitions.csv new file mode 100644 index 0000000..e815dcf --- /dev/null +++ b/Firmware/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 24K, +phy_init, data, phy, 0xf000, 4K, +factory, app, factory, 0x10000, 1500K, +factory_data, data, nvs, 0x187000, 16K, diff --git a/Firmware/run_qemu_linkey.sh b/Firmware/run_qemu_linkey.sh index 8428ff8..cf035b0 100755 --- a/Firmware/run_qemu_linkey.sh +++ b/Firmware/run_qemu_linkey.sh @@ -21,9 +21,11 @@ fi DEBUG_MODE=0 USE_MONITOR=0 BUILD_FIRST=0 +BUILD_ONLY=0 +MANUAL_PROVISION_TEST=0 usage() { - echo "Usage: $0 [--build] [--debug] [--monitor] [--tariff_option base|hphc|ejp|tempo]" + echo "Usage: $0 [--build] [--build-only] [--debug] [--monitor] [--manual-provision-test] [--tariff_option base|hphc|ejp|tempo]" } while [ $# -gt 0 ]; do @@ -32,6 +34,11 @@ while [ $# -gt 0 ]; do BUILD_FIRST=1 shift ;; + --build-only) + BUILD_FIRST=1 + BUILD_ONLY=1 + shift + ;; --debug) DEBUG_MODE=1 shift @@ -40,6 +47,10 @@ while [ $# -gt 0 ]; do USE_MONITOR=1 shift ;; + --manual-provision-test) + MANUAL_PROVISION_TEST=1 + shift + ;; --tariff_option) if [ $# -lt 2 ]; then echo "ERROR: --tariff_option requires a value" @@ -89,7 +100,12 @@ case "$TARIFF_OPTION" in ;; esac -BUILD_DIR="${LINKEY_QEMU_BUILD_DIR:-${SCRIPT_DIR}/build-qemu-${TARIFF_OPTION}}" +BUILD_DIR_SUFFIX="${TARIFF_OPTION}" +if [ "$MANUAL_PROVISION_TEST" -eq 1 ]; then + BUILD_DIR_SUFFIX="${BUILD_DIR_SUFFIX}-manual-provision" +fi + +BUILD_DIR="${LINKEY_QEMU_BUILD_DIR:-${SCRIPT_DIR}/build-qemu-${BUILD_DIR_SUFFIX}}" QEMU_FLASH="${BUILD_DIR}/qemu_flash.bin" QEMU_EFUSE="${BUILD_DIR}/qemu_efuse.bin" BUILD_APP_BIN="${BUILD_DIR}/linky-ulp-monitor.bin" @@ -107,6 +123,7 @@ BUILD_CMD=( "-DSDKCONFIG_DEFAULTS=sdkconfig.defaults;emulation/sdkconfig.qemu.defaults;${TARIFF_DEFAULTS}" -DLINKEY_QEMU_EMULATION=ON -DLINKEY_QEMU_TARIFF_OPTION="$TARIFF_CMAKE" + -DLINKEY_QEMU_MANUAL_PROVISION_TEST="$MANUAL_PROVISION_TEST" build ) @@ -139,6 +156,11 @@ if [ ! -f "$QEMU_FLASH" ] || [ "$QEMU_FLASH_SIZE" -ne 2097152 ] || [ "$BUILD_APP esptool.py --chip esp32 merge_bin --fill-flash-size 2MB -o "$QEMU_FLASH" @flash_args fi +if [ "$BUILD_ONLY" -eq 1 ]; then + echo "QEMU build artifacts ready in ${BUILD_DIR}" + exit 0 +fi + if [ ! -f "$QEMU_EFUSE" ]; then truncate -s 124 "$QEMU_EFUSE" fi @@ -184,6 +206,7 @@ echo "eFuse: $QEMU_EFUSE" echo "QEMU: $QEMU_CMD" echo "Build: LINKEY_QEMU_EMULATION=ON" echo "Tariff: $TARIFF_OPTION" +echo "Manual provisioning test: $MANUAL_PROVISION_TEST" echo "==============================================" exec "$QEMU_CMD" "${QEMU_ARGS[@]}" diff --git a/Firmware/sdkconfig.ci-debug.defaults b/Firmware/sdkconfig.ci-debug.defaults new file mode 100644 index 0000000..bd32f47 --- /dev/null +++ b/Firmware/sdkconfig.ci-debug.defaults @@ -0,0 +1 @@ +CONFIG_LINKEY_DEBUG_LOGS=y diff --git a/Firmware/sdkconfig.defaults b/Firmware/sdkconfig.defaults index 58b930f..b1851b5 100644 --- a/Firmware/sdkconfig.defaults +++ b/Firmware/sdkconfig.defaults @@ -15,6 +15,21 @@ CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=4 CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=8 CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=8 +CONFIG_ESP_WIFI_IRAM_OPT=n +CONFIG_ESP_WIFI_RX_IRAM_OPT=n + +# Partition table +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" + +# BLE provisioning support +CONFIG_BT_ENABLED=y +CONFIG_BT_BLE_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=y +CONFIG_BT_CLASSIC_ENABLED=n +CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y +CONFIG_BTDM_CTRL_MODE_BTDM=n # MQTT settings CONFIG_MQTT_PROTOCOL_311=y diff --git a/Firmware/test/host/test_main_fsm.c b/Firmware/test/host/test_main_fsm.c index 0a4ebdf..3353244 100644 --- a/Firmware/test/host/test_main_fsm.c +++ b/Firmware/test/host/test_main_fsm.c @@ -120,13 +120,6 @@ static void test_publish_happy_path_stays(void) // --- fsm_voltage_watchdog_next ------------------------------------------- -static void test_watchdog_init_passes_through_low(void) -{ - // STATE_INIT owns its own voltage handling (ADC not initialized yet); - // the watchdog must not pre-empt it. - TEST_ASSERT_EQUAL_INT(STATE_INIT, fsm_voltage_watchdog_next(STATE_INIT, true)); -} - static void test_watchdog_wait_voltage_passes_through_low(void) { // STATE_WAIT_VOLTAGE already checks voltage; double-trigger is harmless @@ -162,7 +155,6 @@ static void test_watchdog_drops_publish_to_wait_voltage(void) static void test_watchdog_voltage_ok_keeps_state(void) { // For every state, voltage_low=false must be a no-op. - TEST_ASSERT_EQUAL_INT(STATE_INIT, fsm_voltage_watchdog_next(STATE_INIT, false)); TEST_ASSERT_EQUAL_INT(STATE_WAIT_VOLTAGE, fsm_voltage_watchdog_next(STATE_WAIT_VOLTAGE, false)); TEST_ASSERT_EQUAL_INT(STATE_WIFI_CONNECT, fsm_voltage_watchdog_next(STATE_WIFI_CONNECT, false)); TEST_ASSERT_EQUAL_INT(STATE_MQTT_CONNECT, fsm_voltage_watchdog_next(STATE_MQTT_CONNECT, false)); @@ -181,7 +173,6 @@ static void test_reset_peak_true_for_dynamic_states(void) static void test_reset_peak_false_for_other_states(void) { - TEST_ASSERT_FALSE(fsm_should_reset_peak_on_enter(STATE_INIT)); TEST_ASSERT_FALSE(fsm_should_reset_peak_on_enter(STATE_WAIT_VOLTAGE)); TEST_ASSERT_FALSE(fsm_should_reset_peak_on_enter(STATE_WIFI_CONNECT)); } @@ -212,7 +203,6 @@ int main(void) RUN_TEST(test_publish_publish_failed_to_mqtt_connect); RUN_TEST(test_publish_happy_path_stays); - RUN_TEST(test_watchdog_init_passes_through_low); RUN_TEST(test_watchdog_wait_voltage_passes_through_low); RUN_TEST(test_watchdog_drops_wifi_connect_to_wait_voltage); RUN_TEST(test_watchdog_drops_mqtt_connect_to_wait_voltage); diff --git a/Firmware/test/qemu/assert_manual_provisioning.py b/Firmware/test/qemu/assert_manual_provisioning.py new file mode 100644 index 0000000..9a35fbc --- /dev/null +++ b/Firmware/test/qemu/assert_manual_provisioning.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Validate the QEMU manual WiFi provisioning trigger smoke test.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + + +PANIC_RE = re.compile(r"Guru Meditation Error|abort\(\) was called|assert failed:|CORRUPT HEAP") + + +def fail(message: str) -> None: + print(f"::error::{message}", file=sys.stderr) + raise SystemExit(1) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--qemu-log", type=Path, required=True) + args = parser.parse_args() + + log = args.qemu_log.read_text(errors="replace") + + if "main_task: Calling app_main()" not in log: + fail("QEMU log does not show app_main() entry") + if PANIC_RE.search(log): + fail("QEMU log contains a panic/assert/heap corruption marker") + if "QEMU manual BOOT/GPIO0 press active" not in log: + fail("QEMU did not simulate the BOOT/GPIO0 long press") + if "BLE provisioning is not supported in QEMU emulation" not in log: + fail("QEMU provisioning stub was not called after the manual request") + + print("QEMU manual provisioning assertions OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Firmware/test/qemu/assert_mqtt.py b/Firmware/test/qemu/assert_mqtt.py index 59aecbc..be77803 100644 --- a/Firmware/test/qemu/assert_mqtt.py +++ b/Firmware/test/qemu/assert_mqtt.py @@ -28,10 +28,7 @@ COMMON_STATE_FIELDS = {"iinst", "papp", "vcap", "uptime"} QEMU_WIFI_STA_MAC = "aabbccddeeff" -DEBUG_REQUEST_TOPIC = "linkey/debug/request" DEBUG_REQUEST_PAYLOAD = "GET_TIC_FRAME" -DEBUG_FRAME_TOPIC = "linkey/debug/tic_frame" -DEBUG_FRAME_TOPIC_BYTES = b"linkey/debug/tic_frame " EXPECTED_DEBUG_FRAMES = { "base": ( @@ -143,15 +140,25 @@ def assert_qemu_log(path: Path) -> None: fail("QEMU log contains a panic/assert/heap corruption marker") -def assert_status(messages: list[tuple[str, str]]) -> None: - if ("linkey/status", "online") not in messages: - fail("Missing exact MQTT status message: linkey/status online") +def topics_for_mac(mac: str) -> dict[str, str]: + prefix = f"linkey/{mac[-6:]}" + return { + "state": f"{prefix}/state", + "status": f"{prefix}/status", + "debug_request": f"{prefix}/debug/request", + "debug_frame": f"{prefix}/debug/tic_frame", + } + + +def assert_status(messages: list[tuple[str, str]], topics: dict[str, str]) -> None: + if (topics["status"], "online") not in messages: + fail(f"Missing exact MQTT status message: {topics['status']} online") def assert_discovery( messages: list[tuple[str, str]], tariff_option: str, -) -> None: +) -> str: discovery_messages = [ (topic, payload, DISCOVERY_TOPIC_RE.match(topic)) for topic, payload in messages @@ -182,10 +189,11 @@ def assert_discovery( fail(f"Discovery dev.ids mismatch: expected {expected_device_id!r}") if dev.get("sn") != topic_mac: fail(f"Discovery dev.sn mismatch: expected {topic_mac!r}") - if config.get("stat_t") != "linkey/state": - fail("Discovery stat_t must be exactly linkey/state") - if config.get("avty_t") != "linkey/status": - fail("Discovery avty_t must be exactly linkey/status") + topics = topics_for_mac(topic_mac) + if config.get("stat_t") != topics["state"]: + fail(f"Discovery stat_t must be exactly {topics['state']}") + if config.get("avty_t") != topics["status"]: + fail(f"Discovery avty_t must be exactly {topics['status']}") components = config.get("cmps") if not isinstance(components, dict): @@ -201,31 +209,35 @@ def assert_discovery( if not isinstance(uniq_id, str) or not uniq_id.startswith(f"linkey_{topic_mac}_"): fail(f"Discovery component {component} has invalid uniq_id: {uniq_id!r}") + return topic_mac + -def assert_state(messages: list[tuple[str, str]]) -> None: - state_payloads = [payload for topic, payload in messages if topic == "linkey/state"] +def assert_state(messages: list[tuple[str, str]], topics: dict[str, str]) -> None: + state_payloads = [payload for topic, payload in messages if topic == topics["state"]] if not state_payloads: - fail("Missing exact MQTT state topic: linkey/state") + fail(f"Missing exact MQTT state topic: {topics['state']}") - state = parse_json("linkey/state", state_payloads[-1]) + state = parse_json(topics["state"], state_payloads[-1]) missing = sorted(COMMON_STATE_FIELDS - set(state)) if missing: fail(f"State payload missing common fields: {', '.join(missing)}") -def assert_debug_frame(messages: list[tuple[str, str]], mqtt_log: Path, tariff_option: str) -> None: - if (DEBUG_REQUEST_TOPIC, DEBUG_REQUEST_PAYLOAD) not in messages: - fail(f"Missing exact debug request echo: {DEBUG_REQUEST_TOPIC} {DEBUG_REQUEST_PAYLOAD}") +def assert_debug_frame(messages: list[tuple[str, str]], mqtt_log: Path, tariff_option: str, + topics: dict[str, str]) -> None: + if (topics["debug_request"], DEBUG_REQUEST_PAYLOAD) not in messages: + fail(f"Missing exact debug request echo: {topics['debug_request']} {DEBUG_REQUEST_PAYLOAD}") - if not any(topic == DEBUG_FRAME_TOPIC for topic, _ in messages): - fail(f"Missing exact MQTT debug TIC frame topic: {DEBUG_FRAME_TOPIC}") + if not any(topic == topics["debug_frame"] for topic, _ in messages): + fail(f"Missing exact MQTT debug TIC frame topic: {topics['debug_frame']}") log_bytes = mqtt_log.read_bytes() - frame_start = log_bytes.rfind(DEBUG_FRAME_TOPIC_BYTES) + debug_frame_topic_bytes = f"{topics['debug_frame']} ".encode() + frame_start = log_bytes.rfind(debug_frame_topic_bytes) if frame_start < 0: - fail(f"Missing debug TIC frame marker in MQTT log: {DEBUG_FRAME_TOPIC}") + fail(f"Missing debug TIC frame marker in MQTT log: {topics['debug_frame']}") - frame_bytes = log_bytes[frame_start + len(DEBUG_FRAME_TOPIC_BYTES):] + frame_bytes = log_bytes[frame_start + len(debug_frame_topic_bytes):] expected_frame = EXPECTED_DEBUG_FRAMES[tariff_option] if not frame_bytes.startswith(expected_frame): fail(f"Debug TIC frame payload does not exactly match mocked {tariff_option} frame") @@ -247,10 +259,11 @@ def main() -> int: fail("MQTT log is empty") assert_qemu_log(args.qemu_log) - assert_status(messages) - assert_discovery(messages, args.tariff_option) - assert_state(messages) - assert_debug_frame(messages, args.mqtt_log, args.tariff_option) + topic_mac = assert_discovery(messages, args.tariff_option) + topics = topics_for_mac(topic_mac) + assert_status(messages, topics) + assert_state(messages, topics) + assert_debug_frame(messages, args.mqtt_log, args.tariff_option, topics) print(f"MQTT QEMU assertions OK for tariff_option={args.tariff_option}") return 0 diff --git a/Firmware/tools/factory_flash.py b/Firmware/tools/factory_flash.py new file mode 100755 index 0000000..f93c150 --- /dev/null +++ b/Firmware/tools/factory_flash.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +"""Generate and flash per-device Linkey factory configuration.""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import re +import secrets +import shutil +import string +import subprocess +import sys +import tempfile +from pathlib import Path +from urllib.parse import quote + + +REPO_FIRMWARE_DIR = Path(__file__).resolve().parents[1] +REPO_ROOT = REPO_FIRMWARE_DIR.parent +PARTITION_CSV = REPO_FIRMWARE_DIR / "partitions.csv" +SCHEMA_HEADER = REPO_FIRMWARE_DIR / "main" / "factory_config_schema.h" +QUICK_START_TEMPLATE = REPO_ROOT / "Templates" / "Guide démarrage rapide" / "linkey_quick_start_guide_template.html" +FACTORY_ROOT = REPO_FIRMWARE_DIR / "factory" / "devices" +DEFAULT_BUILD_DIR = REPO_FIRMWARE_DIR / "build-factory" +DEFAULT_SDKCONFIG = REPO_FIRMWARE_DIR / "sdkconfig.factory" +PROV_QR_VERSION = "v1" +PROV_TRANSPORT = "ble" +QRCODE_BASE_URL = "https://espressif.github.io/esp-jumpstart/qrcode.html" +SECRET_ALPHABET = string.ascii_letters + string.digits + + +def read_schema() -> dict[str, str]: + values: dict[str, str] = {} + aliases: dict[str, str] = {} + quoted_pattern = re.compile(r'#define\s+(LINKEY_FACTORY_[A-Z0-9_]+)\s+"([^"]+)"') + alias_pattern = re.compile(r"#define\s+(LINKEY_FACTORY_[A-Z0-9_]+)\s+(LINKEY_FACTORY_[A-Z0-9_]+)") + with SCHEMA_HEADER.open("r", encoding="utf-8") as f: + for line in f: + stripped = line.strip() + match = quoted_pattern.match(stripped) + if match: + values[match.group(1)] = match.group(2) + continue + + match = alias_pattern.match(stripped) + if match: + aliases[match.group(1)] = match.group(2) + + while aliases: + resolved = False + for name, target in list(aliases.items()): + if target in values: + values[name] = values[target] + del aliases[name] + resolved = True + if not resolved: + break + + required = { + "LINKEY_FACTORY_PARTITION_LABEL", + "LINKEY_FACTORY_NAMESPACE", + "LINKEY_FACTORY_KEY_MQTT_PASSWORD", + "LINKEY_FACTORY_KEY_BLE_POP", + "LINKEY_FACTORY_FALLBACK_MQTT_PASSWORD", + "LINKEY_FACTORY_FALLBACK_BLE_POP", + } + missing = sorted(required - values.keys()) + if missing: + raise SystemExit(f"Factory schema is missing: {', '.join(missing)}") + return values + + +def run(cmd: list[str], cwd: Path | None = None, capture: bool = False) -> str: + print("+ " + " ".join(cmd)) + result = subprocess.run( + cmd, + cwd=cwd, + check=True, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None, + ) + return result.stdout if capture and result.stdout else "" + + +def find_idf_path() -> Path: + idf_path = os.environ.get("IDF_PATH") + if not idf_path: + raise SystemExit("IDF_PATH is not set. Source ESP-IDF export.sh before running this tool.") + return Path(idf_path) + + +def read_mac(port: str, esptool: str) -> str: + output = run([ + esptool, + "--chip", + "esp32", + "-p", + port, + "--before", + "default_reset", + "--after", + "no_reset", + "read_mac", + ], capture=True) + match = re.search(r"MAC:\s*([0-9a-fA-F:]{12,17})", output) + if not match: + raise SystemExit("Could not parse ESP32 MAC address from esptool output.") + return match.group(1).replace(":", "").lower() + + +def random_secret(length: int) -> str: + return "".join(secrets.choice(SECRET_ALPHABET) for _ in range(length)) + + +def device_paths(mac: str) -> dict[str, Path]: + root = FACTORY_ROOT / mac + return { + "root": root, + "json": root / "factory_config.json", + "csv": root / "factory_config.csv", + "bin": root / "factory_config.bin", + "quick_start": root / "quick_start.txt", + "quick_start_html": root / "quick_start.html", + "quick_start_pdf": root / "quick_start.pdf", + } + + +def load_or_create_config(mac: str, schema: dict[str, str]) -> dict: + paths = device_paths(mac) + suffix = mac[-6:] + + if paths["json"].exists(): + with paths["json"].open("r", encoding="utf-8") as f: + return json.load(f) + + ble_name = f"Linkey_{suffix.upper()}" + mqtt_username = f"linkey_{suffix}" + ble_pop = random_secret(12) + mqtt_password = random_secret(12) + qr_payload = json.dumps( + { + "ver": PROV_QR_VERSION, + "name": ble_name, + "pop": ble_pop, + "transport": PROV_TRANSPORT, + }, + separators=(",", ":"), + ) + + return { + "mac": mac, + "device_id": f"linkey_{suffix}", + "ble_name": ble_name, + schema["LINKEY_FACTORY_KEY_BLE_POP"]: ble_pop, + "mqtt_username": mqtt_username, + schema["LINKEY_FACTORY_KEY_MQTT_PASSWORD"]: mqtt_password, + "mqtt_topic_prefix": f"linkey/{suffix}", + "mqtt_topics": { + "state": f"linkey/{suffix}/state", + "status": f"linkey/{suffix}/status", + "debug_request": f"linkey/{suffix}/debug/request", + "debug_tic_frame": f"linkey/{suffix}/debug/tic_frame", + }, + "provisioning": { + "qr_payload": qr_payload, + "url": f"{QRCODE_BASE_URL}?data={quote(qr_payload, safe='')}", + }, + } + + +def write_config_files(config: dict, schema: dict[str, str], chrome: str | None) -> None: + paths = device_paths(config["mac"]) + paths["root"].mkdir(parents=True, exist_ok=True) + + with paths["json"].open("w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + f.write("\n") + + with paths["csv"].open("w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow(["key", "type", "encoding", "value"]) + writer.writerow([schema["LINKEY_FACTORY_NAMESPACE"], "namespace", "", ""]) + writer.writerow([ + schema["LINKEY_FACTORY_KEY_MQTT_PASSWORD"], + "data", + "string", + config[schema["LINKEY_FACTORY_KEY_MQTT_PASSWORD"]], + ]) + writer.writerow([ + schema["LINKEY_FACTORY_KEY_BLE_POP"], + "data", + "string", + config[schema["LINKEY_FACTORY_KEY_BLE_POP"]], + ]) + + paths["quick_start"].write_text(quick_start_text(config, schema), encoding="utf-8") + write_quick_start_guide(config, schema, paths, chrome) + + +def write_quick_start_guide(config: dict, schema: dict[str, str], + paths: dict[str, Path], chrome: str | None) -> None: + if not QUICK_START_TEMPLATE.exists(): + print(f"Warning: quick start template not found: {QUICK_START_TEMPLATE}") + return + + template = QUICK_START_TEMPLATE.read_text(encoding="utf-8") + provisioning_qr_url = ( + "https://api.qrserver.com/v1/create-qr-code/" + f"?size=512x512&margin=12&data={quote(config['provisioning']['url'], safe='')}" + ) + logo_path = (REPO_ROOT / "Doc" / "images" / "logo_linkey.png").resolve().as_uri() + template_image_dir = QUICK_START_TEMPLATE.parent / "images" + google_play_badge = (template_image_dir / "google-play-badge.png").resolve().as_uri() + app_store_badge = (template_image_dir / "app-store-badge.svg").resolve().as_uri() + + html = template + html = html.replace("../../Doc/images/logo_linkey.png", logo_path) + html = html.replace("images/google-play-badge.png", google_play_badge) + html = html.replace("images/app-store-badge.svg", app_store_badge) + html = html.replace("{{mqtt_username}}", config["mqtt_username"]) + html = html.replace("{{mqtt_user}}", config["mqtt_username"]) + html = html.replace( + "{{mqtt_password}}", + config[schema["LINKEY_FACTORY_KEY_MQTT_PASSWORD"]], + ) + html = html.replace( + "{{mqtt_pass}}", + config[schema["LINKEY_FACTORY_KEY_MQTT_PASSWORD"]], + ) + html = html.replace("Linkey-<MAC>", config["ble_name"]) + html = html.replace("Linkey_<MAC>", config["ble_name"]) + html = html.replace("Linkey_MAC", config["ble_name"]) + html = html.replace( + '
QR CODE
PROVISIONING
', + ( + '
' + f'QR code de provisioning Linkey' + '
' + ), + ) + + paths["quick_start_html"].write_text(html, encoding="utf-8") + if chrome: + try: + generate_quick_start_pdf(paths["quick_start_html"], paths["quick_start_pdf"], chrome) + except subprocess.CalledProcessError as exc: + print(f"Warning: quick start PDF generation failed ({exc}); HTML guide was generated.") + else: + print("Warning: Chrome/Chromium not found; quick start PDF was not generated.") + + +def find_chrome(explicit_path: str | None) -> str | None: + if explicit_path: + return explicit_path + + for name in ("google-chrome", "chromium", "chromium-browser"): + path = shutil.which(name) + if path: + return path + return None + + +def generate_quick_start_pdf(html_path: Path, pdf_path: Path, chrome: str) -> None: + with tempfile.TemporaryDirectory(prefix="linkey-chrome-") as user_data_dir: + run([ + chrome, + "--headless", + "--disable-gpu", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-crash-reporter", + "--disable-crashpad", + "--allow-file-access-from-files", + f"--user-data-dir={user_data_dir}", + f"--print-to-pdf={pdf_path}", + "--print-to-pdf-no-header", + str(html_path.resolve().as_uri()), + ]) + + +def quick_start_text(config: dict, schema: dict[str, str]) -> str: + topics = config["mqtt_topics"] + return "\n".join( + [ + "Linkey setup information", + "", + f"MAC: {config['mac']}", + f"BLE service name: {config['ble_name']}", + f"BLE Proof of Possession: {config[schema['LINKEY_FACTORY_KEY_BLE_POP']]}", + f"Provisioning QR content: {config['provisioning']['qr_payload']}", + f"Provisioning URL: {config['provisioning']['url']}", + "", + "Home Assistant / MQTT", + f"MQTT username: {config['mqtt_username']}", + f"MQTT password: {config[schema['LINKEY_FACTORY_KEY_MQTT_PASSWORD']]}", + f"MQTT topic prefix: {config['mqtt_topic_prefix']}", + f"State topic: {topics['state']}", + f"Availability topic: {topics['status']}", + "", + ] + ) + + +def partition_info(schema: dict[str, str]) -> tuple[int, int]: + with PARTITION_CSV.open("r", encoding="utf-8", newline="") as f: + for row in csv.reader(f): + if not row or row[0].strip().startswith("#"): + continue + name = row[0].strip() + if name == schema["LINKEY_FACTORY_PARTITION_LABEL"]: + offset = int(row[3].strip(), 0) + size = parse_size(row[4].strip()) + return offset, size + raise SystemExit( + f"Partition {schema['LINKEY_FACTORY_PARTITION_LABEL']} not found in {PARTITION_CSV}." + ) + + +def parse_size(value: str) -> int: + value = value.strip() + lower = value.lower() + if lower.endswith("k"): + return int(lower[:-1], 0) * 1024 + if lower.endswith("m"): + return int(lower[:-1], 0) * 1024 * 1024 + return int(value, 0) + + +def generate_nvs_binary(config: dict, schema: dict[str, str], idf_path: Path) -> Path: + paths = device_paths(config["mac"]) + nvs_gen = idf_path / "components" / "nvs_flash" / "nvs_partition_generator" / "nvs_partition_gen.py" + if not nvs_gen.exists(): + raise SystemExit(f"nvs_partition_gen.py not found at {nvs_gen}") + + _offset, size = partition_info(schema) + run([sys.executable, str(nvs_gen), "generate", str(paths["csv"]), str(paths["bin"]), hex(size)]) + return paths["bin"] + + +def idf_project_args(args: argparse.Namespace) -> list[str]: + return [ + args.idf, + "-B", + str(args.build_dir), + "-D", + f"SDKCONFIG={args.sdkconfig}", + ] + + +def print_setup_info(config: dict, schema: dict[str, str], quick_start_path: Path) -> None: + print("") + print(quick_start_text(config, schema), end="") + print(f"Saved setup sheet: {quick_start_path}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-p", "--port", required=True, help="Serial port of the ESP32.") + parser.add_argument("--idf", default="idf.py", help="idf.py command to use.") + parser.add_argument("--esptool", default="esptool.py", help="esptool.py command to use.") + parser.add_argument("--build-dir", type=Path, default=DEFAULT_BUILD_DIR, + help="Factory build directory to use.") + parser.add_argument("--sdkconfig", type=Path, default=DEFAULT_SDKCONFIG, + help="Factory sdkconfig file to use.") + parser.add_argument("--no-build", action="store_true", help="Skip idf.py build.") + parser.add_argument("--no-flash", action="store_true", help="Generate files and print setup info only.") + parser.add_argument("--factory-only", action="store_true", help="Flash only the factory_data partition.") + parser.add_argument("--chrome", help="Chrome/Chromium executable used to render the quick start PDF.") + parser.add_argument("--no-guide-pdf", action="store_true", help="Generate quick start HTML but skip PDF rendering.") + args = parser.parse_args() + + idf_path = find_idf_path() + schema = read_schema() + mac = read_mac(args.port, args.esptool) + config = load_or_create_config(mac, schema) + chrome = None if args.no_guide_pdf else find_chrome(args.chrome) + write_config_files(config, schema, chrome) + factory_bin = generate_nvs_binary(config, schema, idf_path) + + if not args.no_build: + run(idf_project_args(args) + ["build"], cwd=REPO_FIRMWARE_DIR) + + if not args.no_flash: + offset, _size = partition_info(schema) + factory_flash_cmd = [ + args.esptool, + "--chip", + "esp32", + "-p", + args.port, + "--before", + "no_reset", + "--after", + "hard_reset" if args.factory_only else "no_reset", + "write_flash", + hex(offset), + str(factory_bin), + ] + run(factory_flash_cmd) + + if not args.factory_only: + run(idf_project_args(args) + ["-p", args.port, "flash"], cwd=REPO_FIRMWARE_DIR) + + print_setup_info(config, schema, device_paths(mac)["quick_start"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/README.md b/README.md index 3dc2b75..34b403e 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,14 @@ Démarrage rapide : git clone --recurse-submodules https://github.com/Konkzor/LinKey.git cd LinKey/Firmware source ~/esp/v5.4.1/esp-idf/export.sh -idf.py menuconfig # Configurer vos credentials WiFi et MQTT +idf.py menuconfig # Configurer l'appareil et les options MQTT idf.py -p /dev/ttyUSB0 flash monitor ``` +Les identifiants WiFi sont provisionnés ensuite en BLE via l'application Espressif. Le firmware tente de découvrir Home Assistant en mDNS pour construire automatiquement l'URI MQTT (`mqtt://:1883`), puis utilise `mqtt://homeassistant.local:1883` comme solution de repli. + +L'identité MQTT par défaut est dérivée de l'adresse MAC WiFi : utilisateur `linkey_` et topics `linkey//...`, par exemple `linkey/8ebde0/state`. + ## PCB Le PCB a été réalisé sous KiCad 9. diff --git "a/Templates/Guide d\303\251marrage rapide/images/app-store-badge.svg" "b/Templates/Guide d\303\251marrage rapide/images/app-store-badge.svg" new file mode 100644 index 0000000..329e8dc --- /dev/null +++ "b/Templates/Guide d\303\251marrage rapide/images/app-store-badge.svg" @@ -0,0 +1,50 @@ + + Download_on_the_App_Store_Badge_FR_RGB_blk_100517 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/Templates/Guide d\303\251marrage rapide/images/google-play-badge.png" "b/Templates/Guide d\303\251marrage rapide/images/google-play-badge.png" new file mode 100644 index 0000000..a266c32 Binary files /dev/null and "b/Templates/Guide d\303\251marrage rapide/images/google-play-badge.png" differ diff --git "a/Templates/Guide d\303\251marrage rapide/linkey_quick_start_guide_template.html" "b/Templates/Guide d\303\251marrage rapide/linkey_quick_start_guide_template.html" new file mode 100644 index 0000000..9fa4387 --- /dev/null +++ "b/Templates/Guide d\303\251marrage rapide/linkey_quick_start_guide_template.html" @@ -0,0 +1,479 @@ + + + + + +Linkey — Guide de démarrage + + + + + + +
+
+ +
+
+
Guide de démarrage
+
Linkey_MAC
+
+ +
+ +
+ +
+ +
+
1
+
+

Brancher votre clé

+

Insérez votre clé sur le port TIC du compteur Linky. Attendre que la LED clignote en BLANC.

+
+
+ +
+
2
+
+

Configurer le Wi-Fi *

+

Ouvrez l'app Espressif, scannez le QR code ci-contre, puis saisissez vos identifiants Wi-Fi.

+
+
+ +
+
3
+
+

Configurer MQTT

+

Créez l'utilisateur ci-contre dans Home Assistant.

+
+
+ +
+ + + +
+ +
+
*
+
+ Pas encore l'application Espressif ? + Téléchargement au verso. +
+
+ +
+
+ +
+
+ +
+
Application
+
+ +

Télécharger l'application Espressif

+

+ Scannez le QR code correspondant à votre téléphone. +

+ +
+ +
+
+ QR code Google Play vers ESP BLE Provisioning +
+ Disponible sur Google Play +
+ +
+
+ QR code App Store vers ESP BLE Provisioning +
+ Télécharger dans l'App Store +
+ +
+ +
+
+ + +