From 5b6dcda8f0be91189390606620661cd540b6f62a Mon Sep 17 00:00:00 2001 From: Konkzor Date: Thu, 6 Aug 2026 22:05:25 +0200 Subject: [PATCH 01/18] ci: enhance firmware build process with matrix strategy and debug configuration --- .github/workflows/ci.yml | 19 ++++++++++++++++--- .gitignore | 2 ++ Firmware/main/main.c | 4 ++-- Firmware/sdkconfig.ci-debug.defaults | 1 + 4 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 Firmware/sdkconfig.ci-debug.defaults diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36b5af3..89d39ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,21 +39,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 }}) diff --git a/.gitignore b/.gitignore index bf2f2c9..ad8ea78 100644 --- a/.gitignore +++ b/.gitignore @@ -14,10 +14,12 @@ PCB/MCAD files/*.FCBak # Firmware Firmware/build/ +Firmware/build-debug/ Firmware/build-qemu/ Firmware/build-qemu-*/ Firmware/sdkconfig* !Firmware/sdkconfig.defaults +!Firmware/sdkconfig.ci-debug.defaults Firmware/.cache/ Firmware/test/host/build/ qemu.log diff --git a/Firmware/main/main.c b/Firmware/main/main.c index 38c0617..f1f689e 100644 --- a/Firmware/main/main.c +++ b/Firmware/main/main.c @@ -260,7 +260,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 +289,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(); 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 From d2bca573772efada84b6bffe1f363b25d5787fd3 Mon Sep 17 00:00:00 2001 From: Konkzor Date: Thu, 6 Aug 2026 23:37:39 +0200 Subject: [PATCH 02/18] wifi: load station credentials from NVS Enable ESP WiFi NVS storage so provisioned station credentials are loaded by the driver. Stop starting WiFi during wifi_init(); keep radio startup in wifi_start() so the FSM controls power use. Add helpers to detect stored credentials and synchronize state when another component starts WiFi. --- Firmware/main/wifi_manager.c | 70 ++++++++++++++++++++---------------- Firmware/main/wifi_manager.h | 19 ++++++++++ 2 files changed, 59 insertions(+), 30 deletions(-) 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 * From 7d15951d649547a92c798ca6e51e0318c6d816df Mon Sep 17 00:00:00 2001 From: Konkzor Date: Thu, 6 Aug 2026 23:37:48 +0200 Subject: [PATCH 03/18] build: enable BLE provisioning dependencies Enable ESP32 BLE/Bluedroid support and the larger single-app partition defaults needed for provisioning. Add the qrcode managed component and lockfile so debug builds can print provisioning QR codes. Ignore managed_components because the component manager regenerates it from the lockfile. --- .gitignore | 1 + Firmware/dependencies.lock | 17 +++++++++++++++++ Firmware/main/idf_component.yml | 2 ++ Firmware/sdkconfig.defaults | 13 +++++++++++++ 4 files changed, 33 insertions(+) create mode 100644 Firmware/dependencies.lock create mode 100644 Firmware/main/idf_component.yml diff --git a/.gitignore b/.gitignore index ad8ea78..b805490 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ Firmware/build/ Firmware/build-debug/ Firmware/build-qemu/ Firmware/build-qemu-*/ +Firmware/managed_components/ Firmware/sdkconfig* !Firmware/sdkconfig.defaults !Firmware/sdkconfig.ci-debug.defaults diff --git a/Firmware/dependencies.lock b/Firmware/dependencies.lock new file mode 100644 index 0000000..4520eda --- /dev/null +++ b/Firmware/dependencies.lock @@ -0,0 +1,17 @@ +dependencies: + 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/qrcode +manifest_hash: 83219de2f187e31c82482008626ea7d97fbf805633ba6af8a24538822cb99161 +target: esp32 +version: 2.0.0 diff --git a/Firmware/main/idf_component.yml b/Firmware/main/idf_component.yml new file mode 100644 index 0000000..15aa094 --- /dev/null +++ b/Firmware/main/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + qrcode: "^0.1.0" diff --git a/Firmware/sdkconfig.defaults b/Firmware/sdkconfig.defaults index 58b930f..1f405c7 100644 --- a/Firmware/sdkconfig.defaults +++ b/Firmware/sdkconfig.defaults @@ -15,6 +15,19 @@ 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_SINGLE_APP_LARGE=y + +# 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 From 896aac4e7715736fda62a98093a4d1d863f2e2a6 Mon Sep 17 00:00:00 2001 From: Konkzor Date: Thu, 6 Aug 2026 23:38:01 +0200 Subject: [PATCH 04/18] provisioning: add low-power BLE WiFi setup Add a BLE provisioning FSM state that starts when no WiFi credentials are stored. Store credentials through the standard Espressif provisioning app without running immediate WiFi validation. Replace WiFi scan requests with an empty low-power scan endpoint because scanning is not supportable on the power budget. Print the provisioning URL and emit the terminal QR code only in debug builds. --- Firmware/main/CMakeLists.txt | 10 +- Firmware/main/Kconfig.projbuild | 19 +- Firmware/main/fsm.c | 3 +- Firmware/main/fsm.h | 1 + Firmware/main/main.c | 46 +++- Firmware/main/provisioning_manager.c | 315 +++++++++++++++++++++++++++ Firmware/main/provisioning_manager.h | 29 +++ 7 files changed, 407 insertions(+), 16 deletions(-) create mode 100644 Firmware/main/provisioning_manager.c create mode 100644 Firmware/main/provisioning_manager.h diff --git a/Firmware/main/CMakeLists.txt b/Firmware/main/CMakeLists.txt index 039b36f..c2dfd0f 100644 --- a/Firmware/main/CMakeLists.txt +++ b/Firmware/main/CMakeLists.txt @@ -1,7 +1,13 @@ +set(linkey_requires nvs_flash esp_wifi mqtt wifi_provisioning bt HULP) + +if(CONFIG_LINKEY_DEBUG_LOGS) + list(APPEND linkey_requires qrcode) +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 "main.c" "ulp_linky.cpp" "tic_parser.c" "voltage_manager.c" "voltage_state.c" "wifi_manager.c" "mqtt_manager.c" "fsm.c" "provisioning_manager.c" INCLUDE_DIRS "." - REQUIRES nvs_flash esp_wifi mqtt HULP + REQUIRES ${linkey_requires} ) if(LINKEY_QEMU_EMULATION) diff --git a/Firmware/main/Kconfig.projbuild b/Firmware/main/Kconfig.projbuild index 83299ba..283f8ba 100644 --- a/Firmware/main/Kconfig.projbuild +++ b/Firmware/main/Kconfig.projbuild @@ -6,18 +6,6 @@ menu "Linkey Monitor Configuration" 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. - config LINKEY_MQTT_BROKER_URI string "MQTT Broker URI" default "mqtt://192.168.1.100" @@ -43,6 +31,13 @@ menu "Linkey Monitor Configuration" help Prefix for MQTT topics. Topics will be: /state, /status + config LINKEY_PROV_POP + string "Provisioning Proof of Possession" + default "linkey-pop" + help + Proof of possession used by the Espressif provisioning app. + This should be unique per device in production. + config LINKEY_USE_STATIC_IP bool "Use Static IP (faster connection)" default n diff --git a/Firmware/main/fsm.c b/Firmware/main/fsm.c index 01384ab..497fd23 100644 --- a/Firmware/main/fsm.c +++ b/Firmware/main/fsm.c @@ -66,7 +66,8 @@ app_state_t fsm_voltage_watchdog_next(app_state_t current, bool voltage_low) 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..b12de20 100644 --- a/Firmware/main/fsm.h +++ b/Firmware/main/fsm.h @@ -25,6 +25,7 @@ extern "C" { typedef enum { STATE_INIT, STATE_WAIT_VOLTAGE, + STATE_BLE_PROVISION, STATE_WIFI_CONNECT, STATE_MQTT_CONNECT, STATE_WAIT_ULP_DATA, diff --git a/Firmware/main/main.c b/Firmware/main/main.c index f1f689e..eadf45f 100644 --- a/Firmware/main/main.c +++ b/Firmware/main/main.c @@ -16,6 +16,7 @@ #include "voltage_manager.h" #include "wifi_manager.h" #include "mqtt_manager.h" +#include "provisioning_manager.h" #include "debug.h" #include "tic_types.h" @@ -40,6 +41,7 @@ static const char *TAG = "LINKY_MAIN"; // 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 @@ -67,6 +69,7 @@ static app_state_t current_state = STATE_INIT; 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, @@ -166,6 +169,7 @@ 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); @@ -207,13 +211,50 @@ 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..."); + conn_result_t result = provisioning_start_ble(VOLTAGE_FALLBACK_MIN_MV, + POLL_INTERVAL_MS); + 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; + } + + DEBUG_LOGW(TAG, "BLE provisioning failed - retrying"); + stop_all_connections(); + return STATE_BLE_PROVISION; +} + // STATE_WIFI_CONNECT: Initialize WiFi (once) and connect static app_state_t handle_state_wifi_connect(void) { @@ -392,6 +433,9 @@ void app_main(void) 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/provisioning_manager.c b/Firmware/main/provisioning_manager.c new file mode 100644 index 0000000..617efa4 --- /dev/null +++ b/Firmware/main/provisioning_manager.c @@ -0,0 +1,315 @@ +#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" + +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) +{ + char payload[150] = {0}; + snprintf(payload, sizeof(payload), + "{\"ver\":\"%s\",\"name\":\"%s\",\"pop\":\"%s\",\"transport\":\"%s\"}", + PROV_QR_VERSION, service_name, CONFIG_LINKEY_PROV_POP, + PROV_TRANSPORT_BLE); + +#ifdef CONFIG_LINKEY_DEBUG_LOGS + 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); +#endif + ESP_LOGI(TAG, "Provisioning URL:\n%s?data=%s", QRCODE_BASE_URL, payload); +} + +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 wifi_prov_security1_params_t *sec_params = CONFIG_LINKEY_PROV_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 pop=%s transport=ble security=1", + service_name, CONFIG_LINKEY_PROV_POP); + print_provisioning_qr(service_name); + + 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 From 61c3d8a651638a9205ac10af0228b0326d846ebb Mon Sep 17 00:00:00 2001 From: Konkzor Date: Thu, 6 Aug 2026 23:43:51 +0200 Subject: [PATCH 05/18] docs: document BLE WiFi provisioning Describe the BLE provisioning flow, including manual SSID entry, disabled WiFi scans, and deferred WiFi validation. Update menuconfig setup instructions now that WiFi credentials are provisioned instead of built in. Document debug QR code output and the qrcode component dependency. --- Firmware/README.md | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/Firmware/README.md b/Firmware/README.md index 06d1ace..951e1ed 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -9,6 +9,7 @@ Ce firmware utilise le coprocesseur ULP (*Ultra Low Power*) de l'ESP32 pour surv │ CPU principal (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,6 +31,10 @@ 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 @@ -60,7 +65,9 @@ stateDiagram-v2 direction LR [*] --> INIT INIT --> WAIT_VOLTAGE - WAIT_VOLTAGE --> ACTIVE: V ≥ 2,5 V + 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) state ACTIVE { @@ -81,6 +88,7 @@ stateDiagram-v2 |------|------|----------------------| | `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,6 +96,21 @@ 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. +### 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. + +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. + +Le *Proof of Possession* est configuré dans `menuconfig` via **Provisioning Proof of Possession**. Par défaut : `linkey-pop`. + +Quand **Enable debug logging** est activé, le firmware imprime aussi un QR code compatible avec l'application Espressif dans le moniteur série. Le lien de provisioning est toujours imprimé pour faciliter le diagnostic. + ### Topics MQTT Avec le préfixe par défaut `linkey` : @@ -237,15 +260,14 @@ 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`) +- **Provisioning Proof of Possession** : code demandé par l'application Espressif #### Paramètres optionnels : - **MQTT Username/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,6 +281,15 @@ 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. + ## 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`. @@ -281,7 +312,7 @@ Temps d'exécution attendu : < 1 s pour les 13 cas actuels. 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 @@ -310,3 +341,4 @@ 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 From 7456958347507442e4c73f0b646ee65e60a7eb5d Mon Sep 17 00:00:00 2001 From: Konkzor Date: Thu, 6 Aug 2026 23:50:34 +0200 Subject: [PATCH 06/18] provisioning: add LED indication during BLE WiFi provisioning process. Before only a unique white flash was done before entering provisoning. Now, a 10ms/0,5Hz flash is emitted. --- Firmware/main/main.c | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Firmware/main/main.c b/Firmware/main/main.c index eadf45f..15cf2fb 100644 --- a/Firmware/main/main.c +++ b/Firmware/main/main.c @@ -87,6 +87,8 @@ 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; // ULP wake ISR: notify main task when a new frame is received static void IRAM_ATTR ulp_isr(void *arg) @@ -131,6 +133,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) { @@ -237,8 +271,10 @@ static app_state_t handle_state_ble_provision(void) } 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(); From 35fcac4baf15a292b7bb5ad57893e8f8f86d0588 Mon Sep 17 00:00:00 2001 From: Konkzor Date: Fri, 7 Aug 2026 19:58:37 +0200 Subject: [PATCH 07/18] Add GPIO0 manual WiFi provisioning trigger Poll the BOOT button from the existing FSM loop so long-press detection does not add an always-running task or extra wakeups. Enter BLE provisioning after GPIO0 is held for at least two seconds, and require release before accepting another trigger. Keep existing WiFi credentials when manual provisioning fails so stored credentials are only replaced after the provisioning app submits new ones. --- Firmware/README.md | 7 ++++ Firmware/main/main.c | 93 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/Firmware/README.md b/Firmware/README.md index 951e1ed..56c785d 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -53,6 +53,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 @@ -69,6 +70,7 @@ stateDiagram-v2 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 @@ -100,12 +102,15 @@ stateDiagram-v2 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 configuré dans `menuconfig` via **Provisioning Proof of Possession**. Par défaut : `linkey-pop`. @@ -290,6 +295,8 @@ Au premier démarrage, si aucun identifiant WiFi n'est stocké : 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`. diff --git a/Firmware/main/main.c b/Firmware/main/main.c index 15cf2fb..bee4b1a 100644 --- a/Firmware/main/main.c +++ b/Firmware/main/main.c @@ -23,6 +23,7 @@ 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 @@ -50,6 +51,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) @@ -89,6 +91,7 @@ static bool ulp_initialized = false; 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) @@ -98,6 +101,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 = { @@ -117,6 +122,26 @@ 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_INIT + && 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) { @@ -222,6 +247,7 @@ static app_state_t handle_state_init(void) // Initialize RGB LEDs rgb_led_init(); + boot_button_init(); // Initialize voltage monitoring voltage_init(); @@ -286,11 +312,76 @@ static app_state_t handle_state_ble_provision(void) 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) { @@ -455,6 +546,8 @@ void app_main(void) } } + handle_manual_provisioning_request(); + // Blink LED for current state (10ms) rgb_led_blink(state_colors[current_state], 10); From 2ac8ebdd65e9e0fddf5ae807f019729ca8c6effc Mon Sep 17 00:00:00 2001 From: Konkzor Date: Fri, 7 Aug 2026 20:06:10 +0200 Subject: [PATCH 08/18] Move initialization out of the runtime FSM Run NVS, LED, ADC, and power-management initialization once before entering the main state loop so INIT no longer participates in state LED blinking. Remove STATE_INIT from the pure FSM model and update voltage watchdog tests to reflect that WAIT_VOLTAGE is now the first runtime state. Update the firmware README diagram, state table, and test description to match the init-plus-FSM boot flow. --- Firmware/README.md | 13 +++++++------ Firmware/main/fsm.c | 2 +- Firmware/main/fsm.h | 5 ++--- Firmware/main/main.c | 19 +++++++------------ Firmware/test/host/test_main_fsm.c | 10 ---------- 5 files changed, 17 insertions(+), 32 deletions(-) diff --git a/Firmware/README.md b/Firmware/README.md index 56c785d..3f6c69e 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -6,7 +6,8 @@ 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 │ @@ -64,8 +65,7 @@ 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 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é @@ -88,7 +88,6 @@ 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 | @@ -98,6 +97,8 @@ 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. @@ -299,7 +300,7 @@ Pour reprovisionner un appareil déjà configuré, maintenir BOOT/GPIO0 plus de ## 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 @@ -312,7 +313,7 @@ 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) diff --git a/Firmware/main/fsm.c b/Firmware/main/fsm.c index 497fd23..1a2db3a 100644 --- a/Firmware/main/fsm.c +++ b/Firmware/main/fsm.c @@ -60,7 +60,7 @@ 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; } diff --git a/Firmware/main/fsm.h b/Firmware/main/fsm.h index b12de20..849b707 100644 --- a/Firmware/main/fsm.h +++ b/Firmware/main/fsm.h @@ -23,7 +23,6 @@ extern "C" { * @brief Application FSM states. */ typedef enum { - STATE_INIT, STATE_WAIT_VOLTAGE, STATE_BLE_PROVISION, STATE_WIFI_CONNECT, @@ -57,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/main.c b/Firmware/main/main.c index bee4b1a..94639f1 100644 --- a/Firmware/main/main.c +++ b/Firmware/main/main.c @@ -40,7 +40,6 @@ 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 @@ -65,11 +64,10 @@ 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, @@ -137,8 +135,7 @@ static void boot_button_init(void) static bool boot_button_force_allowed(app_state_t state) { - return state != STATE_INIT - && state != STATE_WAIT_VOLTAGE + return state != STATE_WAIT_VOLTAGE && state != STATE_BLE_PROVISION; } @@ -234,7 +231,7 @@ 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) @@ -521,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 @@ -532,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); @@ -553,9 +551,6 @@ void app_main(void) // 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; 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); From 11786016e7cc49254c6f1b9759a9202c4b3ff5ac Mon Sep 17 00:00:00 2001 From: Konkzor Date: Fri, 7 Aug 2026 20:24:51 +0200 Subject: [PATCH 09/18] Fix QEMU provisioning trigger emulation Keep normal QEMU runs out of the manual provisioning path by wrapping GPIO0 as released by default and by mocking wifi_has_config() instead of calling the real WiFi driver state API. Exclude the real BLE provisioning implementation from QEMU builds and provide an emulation stub so manual provisioning requests can be observed without pulling in unsupported BLE runtime behavior. Add a manual provisioning QEMU mode, build-only support in the runner, a CI job, and a log assertion that verifies the simulated BOOT long press reaches the provisioning stub without panic/assert markers. --- .github/workflows/ci.yml | 49 +++++++++++++++++++ .gitignore | 2 +- Firmware/emulation/CMakeLists.txt | 6 +++ Firmware/emulation/linkey_qemu_mocks.c | 40 +++++++++++++++ Firmware/main/CMakeLists.txt | 21 +++++++- Firmware/run_qemu_linkey.sh | 27 +++++++++- .../test/qemu/assert_manual_provisioning.py | 41 ++++++++++++++++ 7 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 Firmware/test/qemu/assert_manual_provisioning.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89d39ff..7d50d42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,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 b805490..57db739 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ Firmware/sdkconfig* !Firmware/sdkconfig.ci-debug.defaults Firmware/.cache/ Firmware/test/host/build/ -qemu.log +*.log __pycache__/ *.pyc 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/main/CMakeLists.txt b/Firmware/main/CMakeLists.txt index c2dfd0f..ab7fe80 100644 --- a/Firmware/main/CMakeLists.txt +++ b/Firmware/main/CMakeLists.txt @@ -1,11 +1,26 @@ -set(linkey_requires nvs_flash esp_wifi mqtt wifi_provisioning bt HULP) +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" + "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() 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" "provisioning_manager.c" + SRCS ${linkey_sources} INCLUDE_DIRS "." REQUIRES ${linkey_requires} ) @@ -13,6 +28,7 @@ idf_component_register( 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 @@ -26,6 +42,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/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/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()) From 48ed644c6c7e012b8aa388d4eb1ac7e16cc9ce50 Mon Sep 17 00:00:00 2001 From: Konkzor Date: Sat, 8 Aug 2026 00:36:50 +0200 Subject: [PATCH 10/18] Add MQTT autodiscovery and device identity - MQTT discovery: discover Home Assistant via _home-assistant._tcp.local, build mqtt://:1883, and fall back to mqtt://homeassistant.local:1883. - MQTT topics: remove the configurable topic prefix and derive runtime topics as linkey//state, status, and debug paths. - MQTT username: use linkey_ by default when MQTT Username is left empty, while keeping an explicit Kconfig username override. - Add the espressif/mdns managed dependency and disable mDNS autodiscovery in QEMU defaults. - Update QEMU MQTT assertions, CI probes, and documentation for MAC-derived identity and topics. --- .github/workflows/ci.yml | 9 +- Firmware/README.md | 44 +++++- Firmware/dependencies.lock | 13 +- Firmware/emulation/sdkconfig.qemu.defaults | 1 + Firmware/main/CMakeLists.txt | 4 + Firmware/main/Kconfig.projbuild | 22 +-- Firmware/main/idf_component.yml | 3 +- Firmware/main/mqtt_manager.c | 155 ++++++++++++++++++--- Firmware/main/mqtt_manager.h | 2 +- Firmware/test/qemu/assert_mqtt.py | 67 +++++---- README.md | 6 +- 11 files changed, 259 insertions(+), 67 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d50d42..3486185 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,12 +142,13 @@ jobs: ./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 @@ -162,11 +163,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 -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 diff --git a/Firmware/README.md b/Firmware/README.md index 3f6c69e..eb31345 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -41,6 +41,7 @@ Ce firmware utilise le coprocesseur ULP (*Ultra Low Power*) de l'ESP32 pour surv - *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é @@ -117,14 +118,42 @@ Le *Proof of Possession* est configuré dans `menuconfig` via **Provisioning Pro Quand **Enable debug logging** est activé, le firmware imprime aussi un QR code compatible avec l'application Espressif dans le moniteur série. Le lien de provisioning est toujours imprimé pour faciliter le diagnostic. +### 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**. **MQTT Password** reste à configurer si le broker exige un mot de passe. + ### 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. @@ -266,12 +295,13 @@ Naviguer dans **« Linkey Monitor Configuration »** et configurer : #### Paramètres requis : - **Device Name** : nom affiché dans Home Assistant (par défaut : `Linkey`) -- **MQTT Broker URI** : ex. `mqtt://192.168.1.100` -- **MQTT Topic Prefix** : par défaut `linkey` (topics : `linkey/state`, `linkey/status`) - **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** : logs verbeux et QR code de provisioning dans le moniteur série @@ -336,6 +366,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 @@ -350,3 +381,4 @@ 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 index 4520eda..39b4667 100644 --- a/Firmware/dependencies.lock +++ b/Firmware/dependencies.lock @@ -1,4 +1,14 @@ 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: [] @@ -11,7 +21,8 @@ dependencies: type: idf version: 5.4.1 direct_dependencies: +- espressif/mdns - espressif/qrcode -manifest_hash: 83219de2f187e31c82482008626ea7d97fbf805633ba6af8a24538822cb99161 +manifest_hash: de01ba84c68e47ef62ae6d9a1a8e146f3265abf91364a8d60da88cd5a462e2a9 target: esp32 version: 2.0.0 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 ab7fe80..7286ce8 100644 --- a/Firmware/main/CMakeLists.txt +++ b/Firmware/main/CMakeLists.txt @@ -19,6 +19,10 @@ 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 ${linkey_sources} INCLUDE_DIRS "." diff --git a/Firmware/main/Kconfig.projbuild b/Firmware/main/Kconfig.projbuild index 283f8ba..f2d0662 100644 --- a/Firmware/main/Kconfig.projbuild +++ b/Firmware/main/Kconfig.projbuild @@ -8,16 +8,26 @@ menu "Linkey Monitor Configuration" 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" @@ -25,12 +35,6 @@ menu "Linkey Monitor Configuration" help Password for MQTT broker authentication (leave empty if not required). - config LINKEY_MQTT_TOPIC_PREFIX - string "MQTT Topic Prefix" - default "linkey" - help - Prefix for MQTT topics. Topics will be: /state, /status - config LINKEY_PROV_POP string "Provisioning Proof of Possession" default "linkey-pop" diff --git a/Firmware/main/idf_component.yml b/Firmware/main/idf_component.yml index 15aa094..0a5b3d7 100644 --- a/Firmware/main/idf_component.yml +++ b/Firmware/main/idf_component.yml @@ -1,2 +1,3 @@ dependencies: - qrcode: "^0.1.0" + qrcode: ^0.1.0 + espressif/mdns: ^1.11.3 diff --git a/Firmware/main/mqtt_manager.c b/Firmware/main/mqtt_manager.c index 1d2b51e..1c80f42 100644 --- a/Firmware/main/mqtt_manager.c +++ b/Firmware/main/mqtt_manager.c @@ -4,9 +4,14 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_mac.h" +#include "esp_log.h" #include "debug.h" #include "types.h" +#if CONFIG_LINKEY_MQTT_AUTODISCOVERY +#include "mdns.h" +#endif + static const char *TAG = "MQTT_MGR"; // MQTT credentials from Kconfig @@ -14,12 +19,6 @@ static const char *TAG = "MQTT_MGR"; #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,95 @@ 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 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 +143,43 @@ 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 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; @@ -321,7 +441,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 +453,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 +496,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 +518,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,9 +537,7 @@ void mqtt_init(mqtt_state_t *state) .buffer.out_size = 4096, }; - if (strlen(MQTT_USERNAME) > 0) { - mqtt_cfg.credentials.username = MQTT_USERNAME; - } + mqtt_cfg.credentials.username = mqtt_username(); if (strlen(MQTT_PASSWORD) > 0) { mqtt_cfg.credentials.authentication.password = MQTT_PASSWORD; } @@ -629,7 +750,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 +766,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/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/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. From 0b43b033914262e53792111df174884efda630d8 Mon Sep 17 00:00:00 2001 From: Konkzor Date: Sun, 9 Aug 2026 12:53:24 +0200 Subject: [PATCH 11/18] Add factory NVS secret support Add a factory_data NVS partition and a shared factory_config_schema.h that owns the factory partition label, namespace, NVS key names, and development fallback secrets. Read MQTT password and BLE provisioning PoP from the factory partition when available, with empty Kconfig values acting only as development overrides instead of owning defaults. Keep normal firmware logs from printing provisioning credentials; QR/provisioning URL output remains debug-log only. --- Firmware/main/CMakeLists.txt | 1 + Firmware/main/Kconfig.projbuild | 11 ++-- Firmware/main/factory_config.c | 95 +++++++++++++++++++++++++++ Firmware/main/factory_config.h | 4 ++ Firmware/main/factory_config_schema.h | 8 +++ Firmware/main/mqtt_manager.c | 9 +-- Firmware/main/provisioning_manager.c | 21 +++--- Firmware/partitions.csv | 5 ++ Firmware/sdkconfig.defaults | 4 +- 9 files changed, 141 insertions(+), 17 deletions(-) create mode 100644 Firmware/main/factory_config.c create mode 100644 Firmware/main/factory_config.h create mode 100644 Firmware/main/factory_config_schema.h create mode 100644 Firmware/partitions.csv diff --git a/Firmware/main/CMakeLists.txt b/Firmware/main/CMakeLists.txt index 7286ce8..ad527b4 100644 --- a/Firmware/main/CMakeLists.txt +++ b/Firmware/main/CMakeLists.txt @@ -7,6 +7,7 @@ set(linkey_sources "voltage_state.c" "wifi_manager.c" "mqtt_manager.c" + "factory_config.c" "fsm.c" ) diff --git a/Firmware/main/Kconfig.projbuild b/Firmware/main/Kconfig.projbuild index f2d0662..1999c49 100644 --- a/Firmware/main/Kconfig.projbuild +++ b/Firmware/main/Kconfig.projbuild @@ -33,14 +33,17 @@ menu "Linkey Monitor Configuration" 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_PROV_POP string "Provisioning Proof of Possession" - default "linkey-pop" + default "" help - Proof of possession used by the Espressif provisioning app. - This should be unique per device in production. + 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/mqtt_manager.c b/Firmware/main/mqtt_manager.c index 1c80f42..649b654 100644 --- a/Firmware/main/mqtt_manager.c +++ b/Firmware/main/mqtt_manager.c @@ -6,6 +6,7 @@ #include "esp_mac.h" #include "esp_log.h" #include "debug.h" +#include "factory_config.h" #include "types.h" #if CONFIG_LINKEY_MQTT_AUTODISCOVERY @@ -14,10 +15,9 @@ 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 // Home Assistant discovery prefix #define HA_DISCOVERY_PREFIX "homeassistant" @@ -538,8 +538,9 @@ void mqtt_init(mqtt_state_t *state) }; mqtt_cfg.credentials.username = mqtt_username(); - if (strlen(MQTT_PASSWORD) > 0) { - mqtt_cfg.credentials.authentication.password = MQTT_PASSWORD; + 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); diff --git a/Firmware/main/provisioning_manager.c b/Firmware/main/provisioning_manager.c index 617efa4..78db623 100644 --- a/Firmware/main/provisioning_manager.c +++ b/Firmware/main/provisioning_manager.c @@ -17,6 +17,7 @@ #include "wifi_provisioning/wifi_scan.h" #include "voltage_manager.h" #include "debug.h" +#include "factory_config.h" static const char *TAG = "PROV_MGR"; @@ -168,20 +169,23 @@ static void get_service_name(char *service_name, size_t service_name_size) mac[3], mac[4], mac[5]); } -static void print_provisioning_qr(const char *service_name) +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, CONFIG_LINKEY_PROV_POP, + PROV_QR_VERSION, service_name, ble_pop, PROV_TRANSPORT_BLE); -#ifdef CONFIG_LINKEY_DEBUG_LOGS 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); -#endif 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, @@ -247,7 +251,8 @@ conn_result_t provisioning_start_ble(uint16_t voltage_threshold, ESP_ERROR_CHECK(wifi_prov_mgr_init(config)); - const wifi_prov_security1_params_t *sec_params = CONFIG_LINKEY_PROV_POP; + 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, @@ -267,9 +272,9 @@ conn_result_t provisioning_start_ble(uint16_t voltage_threshold, &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 pop=%s transport=ble security=1", - service_name, CONFIG_LINKEY_PROV_POP); - print_provisioning_qr(service_name); + 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) { 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/sdkconfig.defaults b/Firmware/sdkconfig.defaults index 1f405c7..b1851b5 100644 --- a/Firmware/sdkconfig.defaults +++ b/Firmware/sdkconfig.defaults @@ -19,7 +19,9 @@ CONFIG_ESP_WIFI_IRAM_OPT=n CONFIG_ESP_WIFI_RX_IRAM_OPT=n # Partition table -CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y +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 From e1fedaea1f73a9dcc18f6ce8eda32039ea83c526 Mon Sep 17 00:00:00 2001 From: Konkzor Date: Sun, 9 Aug 2026 12:54:01 +0200 Subject: [PATCH 12/18] Add factory flash setup tool Add a Python factory flashing tool that reads the ESP32 MAC, generates or reuses per-device MQTT and BLE provisioning secrets, creates the factory NVS image, flashes factory_data, and prints install information for Home Assistant/provisioning. Read the factory schema from factory_config_schema.h so the Python tool does not duplicate partition, namespace, key, or fallback definitions. Keep the ESP32 in ROM bootloader after MAC read and flash factory_data before the final firmware flash, avoiding a second manual BOOT/reset cycle on boards without reliable auto-boot. Ignore generated per-device factory files and the isolated build-factory directory. --- .gitignore | 2 + Firmware/tools/factory_flash.py | 322 ++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100755 Firmware/tools/factory_flash.py diff --git a/.gitignore b/.gitignore index 57db739..e31733b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ PCB/MCAD files/*.FCBak # Firmware Firmware/build/ Firmware/build-debug/ +Firmware/build-factory/ Firmware/build-qemu/ Firmware/build-qemu-*/ Firmware/managed_components/ @@ -23,6 +24,7 @@ Firmware/sdkconfig* !Firmware/sdkconfig.ci-debug.defaults Firmware/.cache/ Firmware/test/host/build/ +Firmware/factory/devices/ *.log __pycache__/ *.pyc diff --git a/Firmware/tools/factory_flash.py b/Firmware/tools/factory_flash.py new file mode 100755 index 0000000..d1d9e19 --- /dev/null +++ b/Firmware/tools/factory_flash.py @@ -0,0 +1,322 @@ +#!/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 string +import subprocess +import sys +from pathlib import Path +from urllib.parse import quote + + +REPO_FIRMWARE_DIR = Path(__file__).resolve().parents[1] +PARTITION_CSV = REPO_FIRMWARE_DIR / "partitions.csv" +SCHEMA_HEADER = REPO_FIRMWARE_DIR / "main" / "factory_config_schema.h" +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", + } + + +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(24) + 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]) -> 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") + + +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.") + 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) + write_config_files(config, schema) + 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()) From f35e480b683e1827d51ff3872bae9a2748321443 Mon Sep 17 00:00:00 2001 From: Konkzor Date: Sun, 9 Aug 2026 12:54:46 +0200 Subject: [PATCH 13/18] Document factory provisioning workflow Describe the factory_data NVS partition, the per-device BLE PoP and MQTT password keys, and how tools/factory_flash.py generates, flashes, and prints the installation information. Clarify that Kconfig password and PoP values are optional development overrides, while factory_config_schema.h owns the fallback values and factory_data overrides them in production. --- Firmware/README.md | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/Firmware/README.md b/Firmware/README.md index eb31345..d152729 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -114,9 +114,40 @@ Le provisioning utilise le protocole standard de l'application Espressif, avec l - 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 configuré dans `menuconfig` via **Provisioning Proof of Possession**. Par défaut : `linkey-pop`. +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. Le lien de provisioning est toujours imprimé pour faciliter le diagnostic. +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 @@ -133,7 +164,7 @@ Le firmware ne se base pas sur `_mqtt._tcp.local` : ce service générique peut 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**. **MQTT Password** reste à configurer si le broker exige un mot de passe. +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 From 64c0be4d2a4b709dda49be2a0e281d2b94453757 Mon Sep 17 00:00:00 2001 From: Konkzor Date: Sun, 9 Aug 2026 12:56:00 +0200 Subject: [PATCH 14/18] Require MQTT credentials in QEMU tests Configure CI Mosquitto to reject anonymous clients and require the deterministic QEMU credentials linkey_ddeeff / mqtt-password, matching the mocked MAC suffix and factory schema fallback. Authenticate CI probe, subscription capture, and debug request publishing so the smoke test fails if firmware MQTT auth regresses. Align the local Docker Mosquitto setup and emulation docs with the same credential-gated broker behavior. Add a lightweight CI validation step for factory_flash.py schema parsing so schema/header changes cannot silently break the factory tool. --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++---- Firmware/EMULATION.md | 4 ++-- Firmware/docker-compose.yml | 9 +++++++-- Firmware/mosquitto_qemu.conf | 3 ++- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3486185..9e815c2 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 @@ -103,9 +125,14 @@ 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" + 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 @@ -125,7 +152,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 @@ -137,7 +164,7 @@ 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 & @@ -163,7 +190,7 @@ jobs: sleep 2 done - mosquitto_pub -h localhost -p 1884 -t "${QEMU_TOPIC_PREFIX}/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 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/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/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 From cea07429328d2b5fc2fde995e198904f935e0b4f Mon Sep 17 00:00:00 2001 From: Konkzor Date: Sun, 9 Aug 2026 13:17:57 +0200 Subject: [PATCH 15/18] Fix CI Mosquitto password file permissions Make the generated Mosquitto password file world-readable so the broker can open it after dropping privileges to its runtime user. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e815c2..343418d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,7 @@ jobs: 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 From b23b025c51d5dce4ed40af303d8c88338b429810 Mon Sep 17 00:00:00 2001 From: Konkzor Date: Sun, 9 Aug 2026 14:19:20 +0200 Subject: [PATCH 16/18] Use underscore in Linkey device names - Rename BLE provisioning service names from Linkey-XXXXXX to Linkey_XXXXXX in firmware and newly generated factory setup data. - Make the Home Assistant MQTT discovery device name default to Linkey_ when no explicit menuconfig override is set. - Document the BLE and MQTT/Home Assistant naming behavior so multiple Linkey devices get distinct names. --- Firmware/README.md | 8 ++++---- Firmware/main/Kconfig.projbuild | 5 +++-- Firmware/main/mqtt_manager.c | 18 +++++++++++++++++- Firmware/main/provisioning_manager.c | 2 +- Firmware/tools/factory_flash.py | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/Firmware/README.md b/Firmware/README.md index d152729..e863e29 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -102,7 +102,7 @@ L'initialisation NVS, LED, ADC et gestion d'énergie est effectuée une seule fo ### 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. +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`. @@ -134,7 +134,7 @@ 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` +- nom BLE `Linkey_XXXXXX` - contenu du QR code de provisioning et URL Espressif - utilisateur MQTT `linkey_` - mot de passe MQTT @@ -325,7 +325,7 @@ idf.py menuconfig Naviguer dans **« Linkey Monitor Configuration »** et configurer : #### Paramètres requis : -- **Device Name** : nom affiché dans Home Assistant (par défaut : `Linkey`) +- **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 : @@ -353,7 +353,7 @@ idf.py -p /dev/ttyUSB0 flash monitor 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`. +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. diff --git a/Firmware/main/Kconfig.projbuild b/Firmware/main/Kconfig.projbuild index 1999c49..0f04e49 100644 --- a/Firmware/main/Kconfig.projbuild +++ b/Firmware/main/Kconfig.projbuild @@ -2,9 +2,10 @@ menu "Linkey Monitor Configuration" config LINKEY_DEVICE_NAME string "Device Name" - default "Linkey" + default "" help - Device name shown in Home Assistant. + Optional Home Assistant device name override. + Leave empty to use Linkey_. config LINKEY_MQTT_BROKER_URI string "MQTT Broker URI" diff --git a/Firmware/main/mqtt_manager.c b/Firmware/main/mqtt_manager.c index 649b654..96eb7e0 100644 --- a/Firmware/main/mqtt_manager.c +++ b/Firmware/main/mqtt_manager.c @@ -42,6 +42,7 @@ 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 @@ -158,6 +159,21 @@ static const char *mqtt_username(void) 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) { @@ -201,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 diff --git a/Firmware/main/provisioning_manager.c b/Firmware/main/provisioning_manager.c index 78db623..1cc64ec 100644 --- a/Firmware/main/provisioning_manager.c +++ b/Firmware/main/provisioning_manager.c @@ -165,7 +165,7 @@ 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", + snprintf(service_name, service_name_size, "Linkey_%02X%02X%02X", mac[3], mac[4], mac[5]); } diff --git a/Firmware/tools/factory_flash.py b/Firmware/tools/factory_flash.py index d1d9e19..feb07dd 100755 --- a/Firmware/tools/factory_flash.py +++ b/Firmware/tools/factory_flash.py @@ -131,7 +131,7 @@ def load_or_create_config(mac: str, schema: dict[str, str]) -> dict: with paths["json"].open("r", encoding="utf-8") as f: return json.load(f) - ble_name = f"Linkey-{suffix.upper()}" + ble_name = f"Linkey_{suffix.upper()}" mqtt_username = f"linkey_{suffix}" ble_pop = random_secret(12) mqtt_password = random_secret(24) From e1693c5ecc4cd0d78b0f96cd18da92ff28f5adeb Mon Sep 17 00:00:00 2001 From: Konkzor Date: Sun, 9 Aug 2026 14:20:46 +0200 Subject: [PATCH 17/18] Reduce MQTT password length in factory configuration --- Firmware/tools/factory_flash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Firmware/tools/factory_flash.py b/Firmware/tools/factory_flash.py index feb07dd..b04d898 100755 --- a/Firmware/tools/factory_flash.py +++ b/Firmware/tools/factory_flash.py @@ -134,7 +134,7 @@ def load_or_create_config(mac: str, schema: dict[str, str]) -> dict: ble_name = f"Linkey_{suffix.upper()}" mqtt_username = f"linkey_{suffix}" ble_pop = random_secret(12) - mqtt_password = random_secret(24) + mqtt_password = random_secret(12) qr_payload = json.dumps( { "ver": PROV_QR_VERSION, From 7d22ed6ecc7dcdb8f2b59735d0efb52a328d764f Mon Sep 17 00:00:00 2001 From: Konkzor Date: Sun, 9 Aug 2026 15:18:23 +0200 Subject: [PATCH 18/18] Generate quick start guide during factory flash - Add a two-sided quick start guide HTML template with Linkey branding, provisioning QR placement, and MQTT credential placeholders. - Extend factory_flash.py to generate per-device quick_start.html and quick_start.pdf next to the existing setup text file. - Resolve logo and app badge assets to local file URIs before PDF rendering so headless Chrome embeds them reliably. - Include local Google Play and App Store badge assets and add --chrome / --no-guide-pdf controls for the generator. --- Firmware/tools/factory_flash.py | 95 +++- .../images/app-store-badge.svg" | 50 ++ .../images/google-play-badge.png" | Bin 0 -> 18061 bytes .../linkey_quick_start_guide_template.html" | 479 ++++++++++++++++++ 4 files changed, 622 insertions(+), 2 deletions(-) create mode 100644 "Templates/Guide d\303\251marrage rapide/images/app-store-badge.svg" create mode 100644 "Templates/Guide d\303\251marrage rapide/images/google-play-badge.png" create mode 100644 "Templates/Guide d\303\251marrage rapide/linkey_quick_start_guide_template.html" diff --git a/Firmware/tools/factory_flash.py b/Firmware/tools/factory_flash.py index b04d898..f93c150 100755 --- a/Firmware/tools/factory_flash.py +++ b/Firmware/tools/factory_flash.py @@ -9,16 +9,20 @@ 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" @@ -120,6 +124,8 @@ def device_paths(mac: str) -> dict[str, Path]: "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", } @@ -166,7 +172,7 @@ def load_or_create_config(mac: str, schema: dict[str, str]) -> dict: } -def write_config_files(config: dict, schema: dict[str, str]) -> None: +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) @@ -192,6 +198,88 @@ def write_config_files(config: dict, schema: dict[str, str]) -> None: ]) 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: @@ -281,13 +369,16 @@ def main() -> int: 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) - write_config_files(config, 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: 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 0000000000000000000000000000000000000000..a266c32b951c0eea3f6f599d97bad14567e9f1d0 GIT binary patch literal 18061 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 literal 0 HcmV?d00001 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 +
+ +
+ +
+
+ + +