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

505 lines
17 KiB
C++

/**
* @file bmu_rint.cpp
* @brief Mesure de résistance interne (R_int) des batteries BMU.
*
* Séquence de mesure active :
* 1. Lecture V1/I1 sous charge
* 2. Switch OFF → attente PULSE_FAST_MS → lecture V2 (R ohmique)
* 3. Attente (PULSE_TOTAL_MS - PULSE_FAST_MS) → lecture V3 (R totale)
* 4. Switch ON → calcul R_ohmic / R_total → cache → routage sortie
*
* Calcul :
* R_ohmic = (V2 - V1) / |I1| [mΩ, V en mV, I en A]
* R_total = (V3 - V1) / |I1| [mΩ]
*
* Le contexte protection est passé via bmu_rint_set_ctx() avant toute mesure.
* Cette fonction est appelée par main lors de l'intégration du composant.
*/
#include "bmu_rint.h"
#include "bmu_protection.h"
#include "bmu_ina237.h"
#include "bmu_tca9535.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include <cmath>
#include <cstring>
static const char *TAG = "RINT";
/* ── Milliseconds depuis le boot ──────────────────────────────────────── */
static int64_t now_ms(void) { return esp_timer_get_time() / 1000; }
/* ── Déclaration forward du routeur de sortie (implémenté dans Task 4) ── */
extern "C" void rint_output_route(uint8_t idx, bmu_rint_trigger_t trigger,
const bmu_rint_result_t *res);
/* ── État statique du module ──────────────────────────────────────────── */
static bmu_protection_ctx_t *s_prot = NULL;
static bmu_rint_result_t s_cache[BMU_MAX_BATTERIES];
static SemaphoreHandle_t s_mutex = NULL; /* protection cache */
static SemaphoreHandle_t s_measure_mutex = NULL; /* exclusion mesure active */
static volatile bool s_measuring = false;
static TaskHandle_t s_task_handle = NULL;
/**
* @brief Injecte le contexte protection utilisé par toutes les mesures.
*
* Doit être appelée par main.cpp après bmu_protection_init() et avant
* bmu_rint_measure() ou bmu_rint_start_periodic().
*
* Note : non déclarée dans le .h public car interne à l'intégration main.
*/
extern "C" void bmu_rint_set_ctx(bmu_protection_ctx_t *ctx)
{
s_prot = ctx;
}
/* ═══════════════════════════════════════════════════════════════════════
* Helpers internes
* ═══════════════════════════════════════════════════════════════════════ */
/* Nombre de batteries en état CONNECTED */
static int count_connected(void)
{
if (s_prot == NULL) return 0;
int count = 0;
for (int i = 0; i < s_prot->nb_ina; i++) {
if (bmu_protection_get_state(s_prot, i) == BMU_STATE_CONNECTED) {
count++;
}
}
return count;
}
/* Vérifie si au moins une batterie est en ERROR ou LOCKED */
static bool has_error_or_locked(void)
{
if (s_prot == NULL) return true;
for (int i = 0; i < s_prot->nb_ina; i++) {
bmu_battery_state_t st = bmu_protection_get_state(s_prot, i);
if (st == BMU_STATE_ERROR || st == BMU_STATE_LOCKED) {
return true;
}
}
return false;
}
/**
* @brief Vérifie toutes les préconditions de sécurité avant mesure.
*
* @return ESP_OK si la mesure peut commencer, code d'erreur sinon.
*/
static esp_err_t guard_check(uint8_t idx)
{
/* Contexte disponible */
if (s_prot == NULL) {
ESP_LOGW(TAG, "guard: contexte protection non initialisé");
return ESP_ERR_INVALID_STATE;
}
/* Index valide */
if (idx >= s_prot->nb_ina) {
ESP_LOGW(TAG, "guard: idx %d hors plage (nb_ina=%d)", idx, s_prot->nb_ina);
return ESP_ERR_INVALID_ARG;
}
/* Mesure déjà en cours */
if (s_measuring) {
ESP_LOGD(TAG, "guard: mesure déjà en cours");
return ESP_ERR_INVALID_STATE;
}
/* Minimum 2 batteries connectées (une doit rester en ligne pendant le OFF) */
int nb_conn = count_connected();
if (nb_conn < 2) {
ESP_LOGW(TAG, "guard: seulement %d batterie(s) connectée(s)", nb_conn);
return ESP_ERR_INVALID_STATE;
}
/* Aucune batterie en erreur ou verrouillée */
if (has_error_or_locked()) {
ESP_LOGW(TAG, "guard: batterie en erreur ou verrouillée");
return ESP_ERR_INVALID_STATE;
}
/* La batterie cible doit être CONNECTED */
bmu_battery_state_t state = bmu_protection_get_state(s_prot, idx);
if (state != BMU_STATE_CONNECTED) {
ESP_LOGD(TAG, "guard: batterie %d non connectée (état=%d)", idx, (int)state);
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
/**
* @brief Calcule R_ohmic et R_total à partir des mesures et valide le résultat.
*/
static bmu_rint_result_t compute_result(float v_load, float i_load,
float v_fast, float v_stable,
int64_t timestamp)
{
bmu_rint_result_t res = {};
res.v_load_mv = v_load;
res.i_load_a = i_load;
res.v_ocv_fast_mv = v_fast;
res.v_ocv_stable_mv = v_stable;
res.timestamp_ms = timestamp;
res.valid = false;
float i_abs = fabsf(i_load);
/* Courant minimum */
const float i_min_a = CONFIG_BMU_RINT_MIN_CURRENT_MA / 1000.0f;
if (i_abs < i_min_a) {
ESP_LOGD(TAG, "compute: courant insuffisant %.3f A (min=%.3f)", i_abs, i_min_a);
return res;
}
/* dV doit être positif (tension rebondit après déconnexion) */
float dv_fast = v_fast - v_load;
float dv_stable = v_stable - v_load;
if (dv_fast <= 0.0f) {
ESP_LOGD(TAG, "compute: dV_fast négatif (%.1f mV)", dv_fast);
return res;
}
if (dv_stable <= 0.0f) {
ESP_LOGD(TAG, "compute: dV_stable négatif (%.1f mV)", dv_stable);
return res;
}
/* R = dV [mV] / I [A] → résultat en mΩ */
float r_ohmic = dv_fast / i_abs;
float r_total = dv_stable / i_abs;
/* Validation plage */
if (r_ohmic > (float)CONFIG_BMU_RINT_R_MAX_MOHM) {
ESP_LOGW(TAG, "compute: R_ohmic %.1f mΩ > max %d mΩ",
r_ohmic, CONFIG_BMU_RINT_R_MAX_MOHM);
return res;
}
/* R_total doit être >= R_ohmic */
if (r_total < r_ohmic) {
ESP_LOGD(TAG, "compute: R_total (%.1f) < R_ohmic (%.1f)", r_total, r_ohmic);
return res;
}
res.r_ohmic_mohm = r_ohmic;
res.r_total_mohm = r_total;
res.valid = true;
ESP_LOGI(TAG, "R_ohmic=%.1f mΩ R_total=%.1f mΩ I=%.3f A",
r_ohmic, r_total, i_load);
return res;
}
/* ═══════════════════════════════════════════════════════════════════════
* API publique
* ═══════════════════════════════════════════════════════════════════════ */
esp_err_t bmu_rint_init(void)
{
if (s_mutex != NULL) {
return ESP_OK; /* Déjà initialisé */
}
s_mutex = xSemaphoreCreateMutex();
if (s_mutex == NULL) {
ESP_LOGE(TAG, "Échec création mutex cache");
return ESP_ERR_NO_MEM;
}
s_measure_mutex = xSemaphoreCreateMutex();
if (s_measure_mutex == NULL) {
ESP_LOGE(TAG, "Échec création mutex mesure");
vSemaphoreDelete(s_mutex);
s_mutex = NULL;
return ESP_ERR_NO_MEM;
}
memset(s_cache, 0, sizeof(s_cache));
s_measuring = false;
s_task_handle = NULL;
ESP_LOGI(TAG, "R_int init OK");
return ESP_OK;
}
esp_err_t bmu_rint_measure(uint8_t battery_idx, bmu_rint_trigger_t trigger)
{
/* Préconditions de sécurité (lecture rapide, avant acquisition mutex) */
esp_err_t ret = guard_check(battery_idx);
if (ret != ESP_OK) {
return ret;
}
/* Acquisition non-bloquante : garantit exclusion mutuelle de la mesure */
if (s_measure_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_measure_mutex, 0) != pdTRUE) {
ESP_LOGD(TAG, "Mesure deja en cours — skip");
return ESP_ERR_INVALID_STATE;
}
s_measuring = true;
uint8_t tca_idx = battery_idx / 4;
uint8_t channel = battery_idx % 4;
bool switched_off = false;
esp_err_t result_err = ESP_OK;
float v1 = 0.0f, i1 = 0.0f;
float v2 = 0.0f, v3 = 0.0f, dummy = 0.0f;
int64_t ts = 0;
bmu_rint_result_t result = {};
ESP_LOGI(TAG, "Mesure R_int batterie %d (trigger=%d)", battery_idx, (int)trigger);
/* ── Étape 1 : lecture V1/I1 sous charge ─────────────────────────── */
ret = bmu_ina237_read_voltage_current(&s_prot->ina_devices[battery_idx], &v1, &i1);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Bat %d : erreur lecture V1/I1 (%s)",
battery_idx, esp_err_to_name(ret));
result_err = ret;
goto cleanup;
}
ts = now_ms();
/* ── Étape 2 : switch OFF ─────────────────────────────────────────── */
ret = bmu_tca9535_switch_battery(&s_prot->tca_devices[tca_idx], channel, false);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Bat %d : erreur switch OFF (%s)",
battery_idx, esp_err_to_name(ret));
result_err = ret;
goto cleanup;
}
switched_off = true;
/* ── Attente PULSE_FAST_MS → lecture V2 (ohmique) ────────────────── */
vTaskDelay(pdMS_TO_TICKS(CONFIG_BMU_RINT_PULSE_FAST_MS));
/* Vérification d'erreur intercalée avant V2 */
if (has_error_or_locked()) {
ESP_LOGW(TAG, "Bat %d : erreur/lock détectée pendant pulse — abandon", battery_idx);
result_err = ESP_FAIL;
goto cleanup;
}
ret = bmu_ina237_read_voltage_current(&s_prot->ina_devices[battery_idx], &v2, &dummy);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Bat %d : erreur lecture V2 (%s)",
battery_idx, esp_err_to_name(ret));
result_err = ret;
goto cleanup;
}
/* ── Attente complémentaire → lecture V3 (totale) ────────────────── */
{
int rest_ms = CONFIG_BMU_RINT_PULSE_TOTAL_MS - CONFIG_BMU_RINT_PULSE_FAST_MS;
if (rest_ms > 0) {
vTaskDelay(pdMS_TO_TICKS(rest_ms));
}
}
/* Vérification d'erreur intercalée avant V3 */
if (has_error_or_locked()) {
ESP_LOGW(TAG, "Bat %d : erreur/lock avant V3 — abandon", battery_idx);
result_err = ESP_FAIL;
goto cleanup;
}
ret = bmu_ina237_read_voltage_current(&s_prot->ina_devices[battery_idx], &v3, &dummy);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Bat %d : erreur lecture V3 (%s)",
battery_idx, esp_err_to_name(ret));
result_err = ret;
goto cleanup;
}
/* ── Étape 5 : switch ON (chemin nominal) ───────────────────────── */
bmu_tca9535_switch_battery(&s_prot->tca_devices[tca_idx], channel, true);
switched_off = false;
/* ── Calcul et mise en cache ─────────────────────────────────────── */
result = compute_result(v1, i1, v2, v3, ts);
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
s_cache[battery_idx] = result;
xSemaphoreGive(s_mutex);
}
/* ── Routage sortie (MQTT, InfluxDB, Display) ────────────────────── */
rint_output_route(battery_idx, trigger, &result);
result_err = result.valid ? ESP_OK : ESP_ERR_INVALID_RESPONSE;
cleanup:
/* Remet la batterie en ligne si elle a été déconnectée pendant la mesure */
if (switched_off) {
bmu_tca9535_switch_battery(&s_prot->tca_devices[tca_idx], channel, true);
}
s_measuring = false;
xSemaphoreGive(s_measure_mutex);
return result_err;
}
esp_err_t bmu_rint_measure_all(bmu_rint_trigger_t trigger)
{
if (s_prot == NULL || s_prot->nb_ina == 0) {
return ESP_ERR_INVALID_STATE;
}
ESP_LOGI(TAG, "Mesure R_int de toutes les batteries connectées");
bool at_least_one_ok = false;
for (int i = 0; i < s_prot->nb_ina; i++) {
if (bmu_protection_get_state(s_prot, i) != BMU_STATE_CONNECTED) {
continue;
}
esp_err_t ret = bmu_rint_measure((uint8_t)i, trigger);
if (ret == ESP_OK) {
at_least_one_ok = true;
} else {
ESP_LOGD(TAG, "Bat %d : mesure échouée (%s)", i, esp_err_to_name(ret));
}
/* Pause entre mesures pour laisser les batteries se stabiliser */
vTaskDelay(pdMS_TO_TICKS(500));
}
return at_least_one_ok ? ESP_OK : ESP_FAIL;
}
bmu_rint_result_t bmu_rint_get_cached(uint8_t battery_idx)
{
bmu_rint_result_t result = {};
if (battery_idx >= BMU_MAX_BATTERIES || s_mutex == NULL) {
return result;
}
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
result = s_cache[battery_idx];
xSemaphoreGive(s_mutex);
}
return result;
}
void bmu_rint_on_disconnect(uint8_t battery_idx, float v_before_mv, float i_before_a)
{
/* Mesure opportuniste : la batterie vient d'être déconnectée par la protection.
* On ne la reconnecte pas — on lit simplement V2 et V3 aux délais attendus. */
if (s_prot == NULL || battery_idx >= s_prot->nb_ina) {
return;
}
const float i_min_a = CONFIG_BMU_RINT_MIN_CURRENT_MA / 1000.0f;
if (v_before_mv < BMU_MIN_VOLTAGE_MV || fabsf(i_before_a) < i_min_a) {
ESP_LOGD(TAG, "Bat %d opportuniste : skip (V=%.0f mV I=%.3f A)",
battery_idx, v_before_mv, i_before_a);
return;
}
/* Acquisition non-bloquante : si une mesure active est déjà en cours, on abandonne */
if (s_measure_mutex == NULL) return;
if (xSemaphoreTake(s_measure_mutex, 0) != pdTRUE) {
ESP_LOGD(TAG, "Bat %d opportuniste : mesure active deja en cours", battery_idx);
return;
}
s_measuring = true;
ESP_LOGI(TAG, "Bat %d : mesure opportuniste (V_before=%.0f mV, I=%.3f A)",
battery_idx, v_before_mv, i_before_a);
int64_t ts = now_ms();
float v2 = 0.0f, v3 = 0.0f, dummy = 0.0f;
esp_err_t ret;
/* Attente → V2 ohmique */
vTaskDelay(pdMS_TO_TICKS(CONFIG_BMU_RINT_PULSE_FAST_MS));
ret = bmu_ina237_read_voltage_current(
&s_prot->ina_devices[battery_idx], &v2, &dummy);
if (ret != ESP_OK) {
ESP_LOGD(TAG, "Bat %d opportuniste : erreur V2", battery_idx);
goto cleanup;
}
/* Attente complémentaire → V3 totale */
{
int rest_ms = CONFIG_BMU_RINT_PULSE_TOTAL_MS - CONFIG_BMU_RINT_PULSE_FAST_MS;
if (rest_ms > 0) {
vTaskDelay(pdMS_TO_TICKS(rest_ms));
}
}
ret = bmu_ina237_read_voltage_current(
&s_prot->ina_devices[battery_idx], &v3, &dummy);
if (ret != ESP_OK) {
ESP_LOGD(TAG, "Bat %d opportuniste : erreur V3", battery_idx);
goto cleanup;
}
{
bmu_rint_result_t result = compute_result(v_before_mv, i_before_a, v2, v3, ts);
if (s_mutex != NULL && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
s_cache[battery_idx] = result;
xSemaphoreGive(s_mutex);
}
rint_output_route(battery_idx, BMU_RINT_TRIGGER_OPPORTUNISTIC, &result);
}
cleanup:
s_measuring = false;
xSemaphoreGive(s_measure_mutex);
}
/* ── Tâche périodique ─────────────────────────────────────────────────── */
static void rint_periodic_task(void *pv)
{
/* Attente initiale de 2 minutes au boot */
vTaskDelay(pdMS_TO_TICKS(2UL * 60UL * 1000UL));
for (;;) {
ESP_LOGI(TAG, "Mesure périodique R_int");
bmu_rint_measure_all(BMU_RINT_TRIGGER_PERIODIC);
/* Pause jusqu'à la prochaine mesure */
vTaskDelay(pdMS_TO_TICKS((uint32_t)CONFIG_BMU_RINT_PERIOD_MIN * 60UL * 1000UL));
}
}
esp_err_t bmu_rint_start_periodic(void)
{
#ifndef CONFIG_BMU_RINT_PERIODIC_ENABLED
ESP_LOGI(TAG, "Mesure périodique désactivée (Kconfig)");
return ESP_OK;
#endif
if (s_task_handle != NULL) {
ESP_LOGW(TAG, "Tâche périodique déjà démarrée");
return ESP_OK;
}
BaseType_t ok = xTaskCreate(rint_periodic_task, "rint_periodic",
CONFIG_BMU_RINT_TASK_STACK, NULL,
CONFIG_BMU_RINT_TASK_PRIORITY, &s_task_handle);
if (ok != pdPASS) {
ESP_LOGE(TAG, "Échec création tâche périodique");
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "Tâche périodique R_int démarrée (période %d min)",
CONFIG_BMU_RINT_PERIOD_MIN);
return ESP_OK;
}