diff --git a/README.md b/README.md index ea56375..61c0b66 100644 --- a/README.md +++ b/README.md @@ -314,10 +314,14 @@ Le rapport HTML sera disponible dans `coverage/html`. Lancez les tests avec PlatformIO : ```bash -pio test +bash scripts/test_terminal.sh ``` -Puis générez le rapport de couverture. +Ce script enchaîne: +- Build des tests embarqués sans upload matériel. +- Tests hôte DTMF (génération de tonalités synthétiques) en terminal. + +Puis, si besoin, générez le rapport de couverture. ### Objectif Ces ajouts permettent de valider la robustesse, la gestion mémoire, la sécurité multithread et les interactions entre modules, tout en mesurant la couverture des tests. diff --git a/docs/AGENT_TODO.md b/docs/AGENT_TODO.md index a52c323..5339835 100644 --- a/docs/AGENT_TODO.md +++ b/docs/AGENT_TODO.md @@ -1,5 +1,16 @@ # Gates & objectifs — Phase suivante RTC_BL_PHONE +## [2026-02-20] Alignement multi-repos (RTC + Zacus + Kill_LIFE) +- Compatibilité ESP-NOW renforcée côté RTC: + - commandes runtime `ESPNOW_ON` / `ESPNOW_OFF` + - extraction de commande bridge depuis payload JSON imbriqué (`event/message/payload`) +- Correctif mesures ESP-NOW: + - suppression du double comptage `tx_ok` (ack + callback) +- Base méthodologique importée depuis Kill_LIFE: + - gates minimales Spec/Build/Test/Release + - evidence pack et standards firmware de référence + - documentée dans `docs/CROSS_REPO_INTELLIGENCE.md` + ## Gates prioritaires - Extension endpoints HTTP : audio, batterie, rtos, bluetooth, wifi - Sécurisation endpoints : authentification, validation, gestion des droits diff --git a/docs/CROSS_REPO_INTELLIGENCE.md b/docs/CROSS_REPO_INTELLIGENCE.md new file mode 100644 index 0000000..f644d24 --- /dev/null +++ b/docs/CROSS_REPO_INTELLIGENCE.md @@ -0,0 +1,49 @@ +# Cross-Repo Intelligence Base + +Date: 2026-02-20 + +## Repos de référence + +- `electron-rare/RTC_BL_PHONE`: + - firmware téléphonie RTC + Bluetooth + WiFi + MQTT + ESP-NOW. + - repo d’exécution pour la stack A252. +- `electron-rare/le-mystere-professeur-zacus`: + - repo scénario/UI avec bridge WiFi/ESP-NOW orienté événements story. + - référence pour formats de payload entrants hétérogènes. +- `electron-rare/Kill_LIFE`: + - repo base méthodologique (agents, gates, evidence pack, standards firmware). + - référence process qualité/traçabilité. + +## Standards repris depuis Kill_LIFE + +- Gates minimales obligatoires sur changements firmware: + - `Spec` -> `Build` -> `Test` -> `Release`. +- Evidence minimum à conserver: + - logs build/test, + - résultat CI, + - artefacts de validation. +- Standards firmware: + - PlatformIO + Unity, + - wrappers autour des drivers, + - timeouts/watchdogs pour IO bloquants. + +## Contrat d’interop RTC <-> Zacus + +- Commandes série ESP-NOW alignées: + - `ESPNOW_ON` + - `ESPNOW_OFF` + - `ESPNOW_STATUS` + - `ESPNOW_PEER_ADD ` + - `ESPNOW_PEER_DEL ` + - `ESPNOW_PEER_LIST` + - `ESPNOW_SEND ` +- Payload entrant bridge accepté par `RTC_BL_PHONE`: + - direct: `cmd`, `raw`, `command`, `action` + - imbriqué: `event.*`, `message.*`, `payload.*` + +## Règle opérationnelle + +- Toute évolution protocolaire ESP-NOW doit: + - être documentée ici + `docs/props.md`, + - être testée au minimum par build terminal + smoke host, + - être reflétée dans une PR liée côté repo partenaire si impact croisé. diff --git a/docs/props.md b/docs/props.md index c78fa90..be3079f 100644 --- a/docs/props.md +++ b/docs/props.md @@ -8,6 +8,12 @@ ## ESP-NOW - Contrôle local sans broker, même schéma de payload JSON. - Les événements sont broadcastés à tous les pairs ESP-NOW. +- Commandes série runtime disponibles : `ESPNOW_ON`, `ESPNOW_OFF`, `ESPNOW_STATUS`, `ESPNOW_PEER_ADD`, `ESPNOW_PEER_DEL`, `ESPNOW_PEER_LIST`, `ESPNOW_SEND`. +- Pour la compatibilité inter-repos (`le-mystere-professeur-zacus`), la commande entrante peut être lue depuis : + - `cmd`, `raw`, `command`, `action` + - `event.<...>` (objet imbriqué) + - `message.<...>` (objet imbriqué) + - `payload.<...>` (objet ou texte) ## DTMF logiciel - Détection Goertzel sur frames audio, publication des chiffres détectés dans les événements MQTT/ESP-NOW. diff --git a/scripts/test_terminal.sh b/scripts/test_terminal.sh new file mode 100755 index 0000000..f23f2db --- /dev/null +++ b/scripts/test_terminal.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +echo "[test_terminal] build unit tests (no upload / no hardware)" +platformio test --without-uploading --without-testing -e esp32dev + +echo "[test_terminal] run host dtmf tests" +mkdir -p .pio/host +c++ -std=c++17 -Wall -Wextra -pedantic -Isrc test/host/test_dtmf_host.cpp src/telephony/DtmfDecoder.cpp -o .pio/host/test_dtmf_host +.pio/host/test_dtmf_host + +echo "[test_terminal] all checks passed" diff --git a/src/main.cpp b/src/main.cpp index 697e47a..1b45b97 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -109,6 +109,48 @@ bool splitFirstToken(const String& input, String& first, String& rest) { return true; } +bool extractBridgeCommand(JsonVariantConst payload, String& out_cmd, uint8_t depth = 0) { + if (depth > 4U) { + return false; + } + + if (payload.is()) { + out_cmd = payload.as(); + out_cmd.trim(); + return !out_cmd.isEmpty(); + } + + if (!payload.is()) { + return false; + } + + const char* keys[] = {"cmd", "raw", "command", "action"}; + for (const char* key : keys) { + if (!payload[key].is()) { + continue; + } + out_cmd = payload[key].as(); + out_cmd.trim(); + if (!out_cmd.isEmpty()) { + return true; + } + } + + if (!payload["event"].isNull() && extractBridgeCommand(payload["event"], out_cmd, static_cast(depth + 1U))) { + return true; + } + if (!payload["message"].isNull() && + extractBridgeCommand(payload["message"], out_cmd, static_cast(depth + 1U))) { + return true; + } + if (!payload["payload"].isNull() && + extractBridgeCommand(payload["payload"], out_cmd, static_cast(depth + 1U))) { + return true; + } + + return false; +} + AudioConfig buildI2sConfig(const A252PinsConfig& pins_cfg, const A252AudioConfig& audio_cfg) { AudioConfig cfg; cfg.port = I2S_NUM_0; @@ -534,6 +576,14 @@ void registerCommands() { return makeResponse(ok, "MQTT_PUB"); }); + g_dispatcher.registerCommand("ESPNOW_ON", [](const String&) { + return makeResponse(g_espnow.begin(g_peer_store), "ESPNOW_ON"); + }); + + g_dispatcher.registerCommand("ESPNOW_OFF", [](const String&) { + return makeResponse(g_espnow.stop(), "ESPNOW_OFF"); + }); + g_dispatcher.registerCommand("ESPNOW_PEER_ADD", [](const String& args) { if (args.isEmpty()) { return makeResponse(false, "ESPNOW_PEER_ADD invalid_mac"); @@ -683,11 +733,8 @@ void registerCommands() { } void processInboundBridgeCommand(const String& source, const JsonVariantConst& payload) { - String cmd = payload["cmd"] | ""; - if (cmd.isEmpty()) { - cmd = payload["raw"] | ""; - } - if (cmd.isEmpty()) { + String cmd; + if (!extractBridgeCommand(payload, cmd)) { return; } diff --git a/src/props/EspNowBridge.cpp b/src/props/EspNowBridge.cpp index e629858..016cfbd 100644 --- a/src/props/EspNowBridge.cpp +++ b/src/props/EspNowBridge.cpp @@ -12,6 +12,10 @@ EspNowBridge::EspNowBridge() { } bool EspNowBridge::begin(const EspNowPeerStore& initial_peers) { + if (ready_) { + return true; + } + store_ = initial_peers; WiFi.mode(WIFI_STA); @@ -33,6 +37,16 @@ bool EspNowBridge::begin(const EspNowPeerStore& initial_peers) { return true; } +bool EspNowBridge::stop() { + if (!ready_) { + return true; + } + + const esp_err_t err = esp_now_deinit(); + ready_ = false; + return err == ESP_OK; +} + void EspNowBridge::tick() { // ESP-NOW uses callbacks, no polling required. } @@ -81,6 +95,7 @@ void EspNowBridge::statusToJson(JsonObject obj) const { obj["tx_fail"] = tx_fail_; obj["rx_count"] = rx_count_; obj["last_rx_mac"] = last_rx_mac_; + obj["last_rx_payload"] = last_rx_payload_; JsonArray peers = obj["peers"].to(); for (const String& peer : store_.peers) { @@ -155,12 +170,11 @@ bool EspNowBridge::sendToMac(const uint8_t mac[6], const String& payload) { } const esp_err_t err = esp_now_send(mac, reinterpret_cast(payload.c_str()), payload.length()); - if (err == ESP_OK) { - tx_ok_++; - return true; + if (err != ESP_OK) { + tx_fail_++; + return false; } - tx_fail_++; - return false; + return true; } void EspNowBridge::onDataRecv(const uint8_t* mac_addr, const uint8_t* data, int len) { diff --git a/src/props/EspNowBridge.h b/src/props/EspNowBridge.h index b6cc37e..7ad10f7 100644 --- a/src/props/EspNowBridge.h +++ b/src/props/EspNowBridge.h @@ -16,6 +16,7 @@ public: EspNowBridge(); bool begin(const EspNowPeerStore& initial_peers); + bool stop(); void tick(); bool addPeer(const String& mac); diff --git a/src/telephony/DtmfDecoder.cpp b/src/telephony/DtmfDecoder.cpp index 0f22c57..46f4231 100644 --- a/src/telephony/DtmfDecoder.cpp +++ b/src/telephony/DtmfDecoder.cpp @@ -1,13 +1,148 @@ #include "DtmfDecoder.h" -#include +#include +#include +#include -DtmfDecoder::DtmfDecoder() : onDigit(nullptr) {} +namespace { +constexpr std::array kLowFreq = {{697.0, 770.0, 852.0, 941.0}}; +constexpr std::array kHighFreq = {{1209.0, 1336.0, 1477.0, 1633.0}}; +constexpr char kDigitMap[4][4] = { + {'1', '2', '3', 'A'}, + {'4', '5', '6', 'B'}, + {'7', '8', '9', 'C'}, + {'*', '0', '#', 'D'}, +}; +constexpr double kPi = 3.14159265358979323846; +constexpr double kDominanceRatio = 1.8; + +double goertzelPower(const int16_t* samples, size_t count, double freqHz, uint16_t sampleRateHz) { + if (samples == nullptr || count == 0 || sampleRateHz == 0U) { + return 0.0; + } + + const double omega = 2.0 * kPi * freqHz / static_cast(sampleRateHz); + const double coeff = 2.0 * std::cos(omega); + double q0 = 0.0; + double q1 = 0.0; + double q2 = 0.0; + + for (size_t i = 0; i < count; ++i) { + q0 = coeff * q1 - q2 + static_cast(samples[i]); + q2 = q1; + q1 = q0; + } + return q1 * q1 + q2 * q2 - coeff * q1 * q2; +} + +template +size_t indexOfMax(const std::array& values) { + size_t idx = 0; + for (size_t i = 1; i < N; ++i) { + if (values[i] > values[idx]) { + idx = i; + } + } + return idx; +} + +template +double secondBest(const std::array& values, size_t bestIndex) { + double second = 0.0; + for (size_t i = 0; i < N; ++i) { + if (i == bestIndex) { + continue; + } + second = std::max(second, values[i]); + } + return second; +} +} // namespace + +DtmfDecoder::DtmfDecoder() + : DtmfDecoder(8000U, 160U) {} + +DtmfDecoder::DtmfDecoder(uint16_t sampleRateHz, size_t windowSize) + : onDigit(nullptr), + sampleRateHz_(sampleRateHz == 0U ? 8000U : sampleRateHz), + windowSize_(windowSize < 80U ? 80U : windowSize), + lastCandidate_('\0'), + stableCount_(0U), + latchedDigit_('\0') {} void DtmfDecoder::setDigitCallback(DigitCallback cb) { onDigit = cb; } -void DtmfDecoder::feedAudioSamples(const int16_t* samples, size_t count) { - // TODO: Implémentation Goertzel pour détecter DTMF - // Si détection, appeler onDigit(digit) +char DtmfDecoder::detectDigit(const int16_t* samples, size_t count) const { + if (samples == nullptr || count < (windowSize_ / 2U)) { + return '\0'; + } + + std::array lowPower = {{0.0, 0.0, 0.0, 0.0}}; + std::array highPower = {{0.0, 0.0, 0.0, 0.0}}; + for (size_t i = 0; i < 4; ++i) { + lowPower[i] = goertzelPower(samples, count, kLowFreq[i], sampleRateHz_); + highPower[i] = goertzelPower(samples, count, kHighFreq[i], sampleRateHz_); + } + + const size_t lowIdx = indexOfMax(lowPower); + const size_t highIdx = indexOfMax(highPower); + const double lowBest = lowPower[lowIdx]; + const double highBest = highPower[highIdx]; + const double lowSecond = secondBest(lowPower, lowIdx); + const double highSecond = secondBest(highPower, highIdx); + const double lowSum = lowPower[0] + lowPower[1] + lowPower[2] + lowPower[3]; + const double highSum = highPower[0] + highPower[1] + highPower[2] + highPower[3]; + + if (lowBest <= 0.0 || highBest <= 0.0) { + return '\0'; + } + if (lowSecond > 0.0 && (lowBest / lowSecond) < kDominanceRatio) { + return '\0'; + } + if (highSecond > 0.0 && (highBest / highSecond) < kDominanceRatio) { + return '\0'; + } + if ((lowBest / (lowSum + 1.0)) < 0.55 || (highBest / (highSum + 1.0)) < 0.55) { + return '\0'; + } + + return kDigitMap[lowIdx][highIdx]; +} + +void DtmfDecoder::feedAudioSamples(const int16_t* samples, size_t count) { + if (samples == nullptr || count == 0U) { + return; + } + + for (size_t offset = 0; offset < count; offset += windowSize_) { + const size_t frameSize = std::min(windowSize_, count - offset); + if (frameSize < (windowSize_ / 2U)) { + continue; + } + + const char candidate = detectDigit(samples + offset, frameSize); + if (candidate == '\0') { + lastCandidate_ = '\0'; + stableCount_ = 0U; + latchedDigit_ = '\0'; + continue; + } + + if (candidate == lastCandidate_) { + if (stableCount_ < 255U) { + ++stableCount_; + } + } else { + lastCandidate_ = candidate; + stableCount_ = 1U; + } + + if (stableCount_ >= 2U && candidate != latchedDigit_) { + latchedDigit_ = candidate; + if (onDigit) { + onDigit(candidate); + } + } + } } diff --git a/src/telephony/DtmfDecoder.h b/src/telephony/DtmfDecoder.h index 683f8bb..ef8e920 100644 --- a/src/telephony/DtmfDecoder.h +++ b/src/telephony/DtmfDecoder.h @@ -1,14 +1,22 @@ #pragma once +#include +#include #include -#include class DtmfDecoder { public: using DigitCallback = std::function; DtmfDecoder(); + explicit DtmfDecoder(uint16_t sampleRateHz, size_t windowSize = 160); void feedAudioSamples(const int16_t* samples, size_t count); void setDigitCallback(DigitCallback cb); + private: + char detectDigit(const int16_t* samples, size_t count) const; DigitCallback onDigit; - // ...internals Goertzel... + uint16_t sampleRateHz_; + size_t windowSize_; + char lastCandidate_; + uint8_t stableCount_; + char latchedDigit_; }; diff --git a/test/host/test_dtmf_host.cpp b/test/host/test_dtmf_host.cpp new file mode 100644 index 0000000..1a9a5a9 --- /dev/null +++ b/test/host/test_dtmf_host.cpp @@ -0,0 +1,103 @@ +#include "telephony/DtmfDecoder.h" + +#include +#include +#include +#include +#include +#include + +namespace { +constexpr double kPi = 3.14159265358979323846; + +std::vector generateDtmfTone(double lowFreqHz, + double highFreqHz, + int sampleRateHz, + double durationSeconds, + int16_t amplitude = 12000) { + const size_t sampleCount = static_cast(durationSeconds * static_cast(sampleRateHz)); + std::vector samples(sampleCount, 0); + for (size_t i = 0; i < sampleCount; ++i) { + const double t = static_cast(i) / static_cast(sampleRateHz); + const double value = 0.5 * (std::sin(2.0 * kPi * lowFreqHz * t) + std::sin(2.0 * kPi * highFreqHz * t)); + samples[i] = static_cast(std::round(static_cast(amplitude) * value)); + } + return samples; +} + +std::vector generateSingleTone(double freqHz, + int sampleRateHz, + double durationSeconds, + int16_t amplitude = 12000) { + const size_t sampleCount = static_cast(durationSeconds * static_cast(sampleRateHz)); + std::vector samples(sampleCount, 0); + for (size_t i = 0; i < sampleCount; ++i) { + const double t = static_cast(i) / static_cast(sampleRateHz); + const double value = std::sin(2.0 * kPi * freqHz * t); + samples[i] = static_cast(std::round(static_cast(amplitude) * value)); + } + return samples; +} + +bool runScenario(const std::string& name, const std::function& scenario) { + const bool ok = scenario(); + std::cout << "[host-dtmf] " << name << ": " << (ok ? "PASS" : "FAIL") << std::endl; + return ok; +} +} // namespace + +int main() { + bool allOk = true; + + allOk &= runScenario("silence_does_not_emit", []() { + DtmfDecoder decoder(8000U, 160U); + bool emitted = false; + decoder.setDigitCallback([&](char) { emitted = true; }); + std::vector silence(800, 0); + decoder.feedAudioSamples(silence.data(), silence.size()); + return !emitted; + }); + + allOk &= runScenario("detects_digit_5", []() { + DtmfDecoder decoder(8000U, 160U); + int callbackCount = 0; + char lastDigit = '\0'; + decoder.setDigitCallback([&](char digit) { + ++callbackCount; + lastDigit = digit; + }); + const std::vector tone = generateDtmfTone(770.0, 1336.0, 8000, 0.12); + decoder.feedAudioSamples(tone.data(), tone.size()); + return callbackCount == 1 && lastDigit == '5'; + }); + + allOk &= runScenario("single_tone_is_rejected", []() { + DtmfDecoder decoder(8000U, 160U); + bool emitted = false; + decoder.setDigitCallback([&](char) { emitted = true; }); + const std::vector tone = generateSingleTone(770.0, 8000, 0.12); + decoder.feedAudioSamples(tone.data(), tone.size()); + return !emitted; + }); + + allOk &= runScenario("digit_can_retrigger_after_pause", []() { + DtmfDecoder decoder(8000U, 160U); + int callbackCount = 0; + decoder.setDigitCallback([&](char digit) { + if (digit == '5') { + ++callbackCount; + } + }); + + const std::vector tone = generateDtmfTone(770.0, 1336.0, 8000, 0.12); + std::vector payload; + payload.reserve(tone.size() * 2 + 320); + payload.insert(payload.end(), tone.begin(), tone.end()); + payload.insert(payload.end(), 320, 0); + payload.insert(payload.end(), tone.begin(), tone.end()); + decoder.feedAudioSamples(payload.data(), payload.size()); + return callbackCount == 2; + }); + + return allOk ? 0 : 1; +}