Files
L'électron rare f55093d6fe
ESP-IDF CI / Host Tests (Unity) (push) Successful in 1m8s
CI / firmware-native (push) Successful in 2m57s
Rust Protection Tests / Cargo test (host) (push) Failing after 3m21s
ESP-IDF CI / ESP-IDF Build (v5.4) (push) Failing after 6m55s
ESP-IDF CI / Memory Budget Gate (push) Has been skipped
qa-cicd-environments / qa-kxkm-s3-build (push) Successful in 8m53s
qa-cicd-environments / qa-sim-host (push) Successful in 2m2s
qa-cicd-environments / qa-kxkm-s3-memory-budget (push) Successful in 11m17s
chore: import KXKM Batterie Parallelator
Context: the project archive (KXKM_Batterie_Parallelator-main) had
no git history locally; a fresh repository is needed to host it on
git.saillant.cc (electron/KXKM_Batterie_Parallelator).

Approach: initialize a new repo on branch main, stage the archive
content, and harden .gitignore before the first commit.

Changes:
- Import the full project tree: firmware/, firmware-idf/,
  firmware-rs/, iosApp/, kxkm-bmu-app/, kxkm-api/, hardware/,
  docs/, specs/, scripts/, models/, tests/
- Keep project dotfiles tracked despite the trailing '.*' ignore
  rule: .github/, .claude/, .superpowers/, .gitattributes,
  .markdownlint.json
- Extend .gitignore: firmware/src/credentials.h (local secrets,
  template kept), kxkm-bmu-app/**/build/ (66 MB compiled iOS
  framework), .remember/ (session data)

Impact: the project can now be maintained on the self-hosted Gitea
forge with a clean, secret-free initial history.
2026-07-04 12:32:28 +02:00

340 lines
13 KiB
C++

/**
* @file bmu_ble.cpp
* @brief NimBLE BLE init, GAP advertising, pairing/bonding — Phase 9.
*
* Initialise le stack NimBLE, configure 3 services GATT (Battery, System, Control),
* demarre l'advertising BLE connectable avec bonding Secure Connections.
*/
#include "sdkconfig.h"
#if CONFIG_BMU_BLE_ENABLED
#include "bmu_ble.h"
#include "bmu_ble_internal.h"
#include "esp_log.h"
#include "esp_nimble_hci.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/util/util.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "store/config/ble_store_config.h"
#include "bmu_ble_victron_gatt.h"
#include "esp_timer.h"
#include <atomic>
static const char *TAG = "BLE";
/* ── Contexte global (accessible par les fichiers service) ───────── */
static bmu_protection_ctx_t *s_prot = NULL;
static bmu_battery_manager_t *s_mgr = NULL;
static uint8_t s_nb_ina = 0;
static std::atomic<int> s_connected_count{0};
/* Fenêtre d'autorisation de re-pairing (audit sécu) : 0 = fermée.
* Ouverte par l'écran via bmu_ble_allow_repair() ; un REPEAT_PAIRING n'est
* accepté que pendant cette fenêtre. */
static int64_t s_repair_allowed_until_ms = 0;
void bmu_ble_allow_repair(uint32_t window_ms)
{
s_repair_allowed_until_ms = (esp_timer_get_time() / 1000) + (int64_t)window_ms;
ESP_LOGW(TAG, "Fenêtre de réappairage ouverte %u ms", (unsigned)window_ms);
}
bmu_protection_ctx_t *bmu_ble_get_prot(void) { return s_prot; }
bmu_battery_manager_t *bmu_ble_get_mgr(void) { return s_mgr; }
uint8_t bmu_ble_get_nb_ina(void) { return s_nb_ina; }
void bmu_ble_set_nb_ina(uint8_t n) { s_nb_ina = n; }
/* ── Forward declarations ────────────────────────────────────────── */
static void start_advertising(void);
static void on_sync(void);
static void on_reset(int reason);
static int ble_gap_event_handler(struct ble_gap_event *event, void *arg);
static void nimble_host_task(void *param);
/* ── Advertising ─────────────────────────────────────────────────── */
static void start_advertising(void)
{
/* Advertise uniquement si on n'a pas atteint le max de connexions */
if (s_connected_count.load() >= CONFIG_BMU_BLE_MAX_CONNECTIONS) {
ESP_LOGI(TAG, "Max connections atteint (%d), advertising stoppe",
CONFIG_BMU_BLE_MAX_CONNECTIONS);
return;
}
struct ble_gap_adv_params adv_params = {};
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND; /* Connectable undirected */
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN; /* General discoverable */
adv_params.itvl_min = BLE_GAP_ADV_ITVL_MS(50); /* 50 ms */
adv_params.itvl_max = BLE_GAP_ADV_ITVL_MS(100);/* 100 ms */
/* Advertising data : flags + nom (inclut le device name pour multi-BMU) */
static char ble_name[32];
snprintf(ble_name, sizeof(ble_name), "KXKM-BMU-%s", CONFIG_BMU_DEVICE_NAME);
struct ble_hs_adv_fields fields = {};
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
fields.name = (const uint8_t *)ble_name;
fields.name_len = strlen(ble_name);
fields.name_is_complete = 1;
int rc = ble_gap_adv_set_fields(&fields);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gap_adv_set_fields: rc=%d", rc);
return;
}
/* Scan response : UUID 128-bit du service Battery */
struct ble_hs_adv_fields rsp_fields = {};
static ble_uuid128_t batt_svc_uuid = BMU_BLE_UUID128_DECLARE(0x01, 0x00);
rsp_fields.uuids128 = &batt_svc_uuid;
rsp_fields.num_uuids128 = 1;
rsp_fields.uuids128_is_complete = 1;
rc = ble_gap_adv_rsp_set_fields(&rsp_fields);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gap_adv_rsp_set_fields: rc=%d", rc);
/* Pas fatal — on continue sans scan response */
}
rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
&adv_params, ble_gap_event_handler, NULL);
if (rc != 0 && rc != BLE_HS_EALREADY) {
ESP_LOGE(TAG, "ble_gap_adv_start: rc=%d", rc);
} else {
ESP_LOGI(TAG, "Advertising demarre: '%s'", CONFIG_BMU_BLE_DEVICE_NAME);
}
}
/* ── GAP Event Handler ───────────────────────────────────────────── */
static int ble_gap_event_handler(struct ble_gap_event *event, void *arg)
{
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
s_connected_count.fetch_add(1);
ESP_LOGI(TAG, "Client connecte (conn_handle=%d, total=%d)",
event->connect.conn_handle, s_connected_count.load());
/* Demarrer les timers de notification si c'est le premier client */
if (s_connected_count.load() == 1) {
bmu_ble_battery_notify_start();
bmu_ble_system_notify_start();
bmu_ble_wifi_notify_start();
bmu_ble_victron_gatt_notify_start();
}
/* Demander connexion securisee */
ble_gap_security_initiate(event->connect.conn_handle);
/* Reprendre l'advertising si places restantes */
start_advertising();
} else {
ESP_LOGW(TAG, "Connexion echouee: status=%d", event->connect.status);
start_advertising();
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
s_connected_count.fetch_sub(1);
ESP_LOGI(TAG, "Client deconnecte (reason=0x%02x, total=%d)",
event->disconnect.reason, s_connected_count.load());
/* Stopper les timers si plus aucun client */
if (s_connected_count.load() <= 0) {
s_connected_count.store(0);
bmu_ble_battery_notify_stop();
bmu_ble_system_notify_stop();
bmu_ble_wifi_notify_stop();
bmu_ble_victron_gatt_notify_stop();
}
start_advertising();
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
ESP_LOGD(TAG, "Advertising termine — redemarrage");
start_advertising();
return 0;
case BLE_GAP_EVENT_ENC_CHANGE:
ESP_LOGI(TAG, "Encryption changee: conn_handle=%d status=%d",
event->enc_change.conn_handle, event->enc_change.status);
return 0;
case BLE_GAP_EVENT_REPEAT_PAIRING: {
/* Confirmation à l'écran (audit sécu) : écraser un bond existant exige
* une autorisation physique préalable sur le BMU. L'opérateur ouvre une
* fenêtre via l'écran (bouton « Autoriser réappairage » →
* bmu_ble_allow_repair). Hors fenêtre : on REJETTE (IGNORE) et on garde
* le bond légitime → un attaquant ne peut plus l'écraser sans accès
* physique. */
int64_t now_ms = esp_timer_get_time() / 1000;
if (now_ms >= s_repair_allowed_until_ms) {
ESP_LOGW(TAG, "Re-pairing REJETÉ conn=%d — non autorisé "
"(ouvrir la fenêtre sur l'écran du BMU)",
event->repeat_pairing.conn_handle);
return BLE_GAP_REPEAT_PAIRING_IGNORE;
}
/* Fenêtre ouverte : autoriser une fois, puis refermer. */
s_repair_allowed_until_ms = 0;
struct ble_gap_conn_desc desc;
ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc);
ble_store_util_delete_peer(&desc.peer_id_addr);
ESP_LOGW(TAG, "Re-pairing AUTORISÉ (bond écrasé) conn=%d",
event->repeat_pairing.conn_handle);
return BLE_GAP_REPEAT_PAIRING_RETRY;
}
case BLE_GAP_EVENT_SUBSCRIBE:
ESP_LOGD(TAG, "Subscribe: conn=%d attr=%d cur_notify=%d",
event->subscribe.conn_handle,
event->subscribe.attr_handle,
event->subscribe.cur_notify);
return 0;
default:
return 0;
}
}
/* ── Callbacks host sync/reset ───────────────────────────────────── */
static void on_sync(void)
{
/* S'assurer qu'on a une adresse valide */
int rc = ble_hs_util_ensure_addr(0);
if (rc != 0) {
ESP_LOGE(TAG, "ble_hs_util_ensure_addr: rc=%d", rc);
return;
}
uint8_t addr[6] = {};
uint8_t addr_type = 0;
rc = ble_hs_id_infer_auto(0, &addr_type);
if (rc == 0) {
ble_hs_id_copy_addr(addr_type, addr, NULL);
ESP_LOGI(TAG, "Adresse BLE: %02x:%02x:%02x:%02x:%02x:%02x (type=%d)",
addr[5], addr[4], addr[3], addr[2], addr[1], addr[0], addr_type);
}
start_advertising();
}
static void on_reset(int reason)
{
ESP_LOGW(TAG, "NimBLE host reset: reason=%d", reason);
}
/* ── NimBLE host task ────────────────────────────────────────────── */
static void nimble_host_task(void *param)
{
ESP_LOGI(TAG, "NimBLE host task demarree");
nimble_port_run(); /* Bloque jusqu'a arret */
nimble_port_freertos_deinit();
}
/* ══════════════════════════════════════════════════════════════════ */
/* ── API publique ─────────────────────────────────────────────────── */
/* ══════════════════════════════════════════════════════════════════ */
esp_err_t bmu_ble_init(bmu_protection_ctx_t *prot,
bmu_battery_manager_t *mgr,
uint8_t nb_ina)
{
if (!prot || !mgr) {
return ESP_ERR_INVALID_ARG;
}
s_prot = prot;
s_mgr = mgr;
s_nb_ina = nb_ina;
ESP_LOGI(TAG, "Init NimBLE BLE — %d batteries, max %d connexions",
nb_ina, CONFIG_BMU_BLE_MAX_CONNECTIONS);
/* 1. Initialiser le stack NimBLE */
int rc = nimble_port_init();
if (rc != ESP_OK) {
ESP_LOGE(TAG, "nimble_port_init: rc=%d", rc);
return ESP_FAIL;
}
/* 2. Configurer le host */
ble_hs_cfg.reset_cb = on_reset;
ble_hs_cfg.sync_cb = on_sync;
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
/* 3. Security Manager — bonding + Secure Connections */
ble_hs_cfg.sm_io_cap = BLE_SM_IO_CAP_NO_IO;
ble_hs_cfg.sm_bonding = 1;
ble_hs_cfg.sm_sc = 1; /* Secure Connections */
ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
/* 4. Configurer le nom GAP */
ble_svc_gap_init();
ble_svc_gatt_init();
{
char gap_name[32];
snprintf(gap_name, sizeof(gap_name), "KXKM-BMU-%s", CONFIG_BMU_DEVICE_NAME);
ble_svc_gap_device_name_set(gap_name);
}
/* 5. Construire et enregistrer la table GATT unifiee */
/* NimBLE attend un tableau termine par un element {0} */
static struct ble_gatt_svc_def gatt_svcs[5]; /* 4 services max + terminateur */
int svc_idx = 0;
const struct ble_gatt_svc_def *batt_svc = bmu_ble_battery_svc_defs();
const struct ble_gatt_svc_def *sys_svc = bmu_ble_system_svc_defs();
const struct ble_gatt_svc_def *ctrl_svc = bmu_ble_control_svc_defs();
gatt_svcs[svc_idx++] = batt_svc[0];
gatt_svcs[svc_idx++] = sys_svc[0];
gatt_svcs[svc_idx++] = ctrl_svc[0];
#ifdef CONFIG_BMU_VICTRON_GATT_ENABLED
const struct ble_gatt_svc_def *vic_svc = bmu_ble_victron_gatt_svc_defs();
if (vic_svc != NULL) {
gatt_svcs[svc_idx++] = vic_svc[0];
}
#endif
memset(&gatt_svcs[svc_idx], 0, sizeof(struct ble_gatt_svc_def));
rc = ble_gatts_count_cfg(gatt_svcs);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gatts_count_cfg: rc=%d", rc);
return ESP_FAIL;
}
rc = ble_gatts_add_svcs(gatt_svcs);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gatts_add_svcs: rc=%d", rc);
return ESP_FAIL;
}
/* 6. Demarrer le host NimBLE dans une tache FreeRTOS */
nimble_port_freertos_init(nimble_host_task);
ESP_LOGI(TAG, "NimBLE initialise — services GATT enregistres");
return ESP_OK;
}
bool bmu_ble_is_connected(void)
{
return s_connected_count.load() > 0;
}
int bmu_ble_connected_count(void)
{
return s_connected_count.load();
}
#endif /* CONFIG_BMU_BLE_ENABLED */