align espnow interop and add terminal dtmf tests

This commit is contained in:
Clément SAILLANT
2026-02-20 22:57:07 +01:00
parent a91734b2db
commit 05867cca03
11 changed files with 412 additions and 19 deletions
+6 -2
View File
@@ -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.
+11
View File
@@ -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
+49
View File
@@ -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 dexé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 dinterop RTC <-> Zacus
- Commandes série ESP-NOW alignées:
- `ESPNOW_ON`
- `ESPNOW_OFF`
- `ESPNOW_STATUS`
- `ESPNOW_PEER_ADD <mac>`
- `ESPNOW_PEER_DEL <mac>`
- `ESPNOW_PEER_LIST`
- `ESPNOW_SEND <mac|broadcast> <payload>`
- 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é.
+6
View File
@@ -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.
+15
View File
@@ -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"
+52 -5
View File
@@ -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<const char*>()) {
out_cmd = payload.as<const char*>();
out_cmd.trim();
return !out_cmd.isEmpty();
}
if (!payload.is<JsonObjectConst>()) {
return false;
}
const char* keys[] = {"cmd", "raw", "command", "action"};
for (const char* key : keys) {
if (!payload[key].is<const char*>()) {
continue;
}
out_cmd = payload[key].as<const char*>();
out_cmd.trim();
if (!out_cmd.isEmpty()) {
return true;
}
}
if (!payload["event"].isNull() && extractBridgeCommand(payload["event"], out_cmd, static_cast<uint8_t>(depth + 1U))) {
return true;
}
if (!payload["message"].isNull() &&
extractBridgeCommand(payload["message"], out_cmd, static_cast<uint8_t>(depth + 1U))) {
return true;
}
if (!payload["payload"].isNull() &&
extractBridgeCommand(payload["payload"], out_cmd, static_cast<uint8_t>(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;
}
+19 -5
View File
@@ -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<JsonArray>();
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<const uint8_t*>(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) {
+1
View File
@@ -16,6 +16,7 @@ public:
EspNowBridge();
bool begin(const EspNowPeerStore& initial_peers);
bool stop();
void tick();
bool addPeer(const String& mac);
+140 -5
View File
@@ -1,13 +1,148 @@
#include "DtmfDecoder.h"
#include <math.h>
#include <algorithm>
#include <array>
#include <cmath>
DtmfDecoder::DtmfDecoder() : onDigit(nullptr) {}
namespace {
constexpr std::array<double, 4> kLowFreq = {{697.0, 770.0, 852.0, 941.0}};
constexpr std::array<double, 4> 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<double>(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<double>(samples[i]);
q2 = q1;
q1 = q0;
}
return q1 * q1 + q2 * q2 - coeff * q1 * q2;
}
template <size_t N>
size_t indexOfMax(const std::array<double, N>& values) {
size_t idx = 0;
for (size_t i = 1; i < N; ++i) {
if (values[i] > values[idx]) {
idx = i;
}
}
return idx;
}
template <size_t N>
double secondBest(const std::array<double, N>& 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<double, 4> lowPower = {{0.0, 0.0, 0.0, 0.0}};
std::array<double, 4> 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);
}
}
}
}
+10 -2
View File
@@ -1,14 +1,22 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <vector>
class DtmfDecoder {
public:
using DigitCallback = std::function<void(char)>;
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_;
};
+103
View File
@@ -0,0 +1,103 @@
#include "telephony/DtmfDecoder.h"
#include <cmath>
#include <cstdint>
#include <functional>
#include <iostream>
#include <string>
#include <vector>
namespace {
constexpr double kPi = 3.14159265358979323846;
std::vector<int16_t> generateDtmfTone(double lowFreqHz,
double highFreqHz,
int sampleRateHz,
double durationSeconds,
int16_t amplitude = 12000) {
const size_t sampleCount = static_cast<size_t>(durationSeconds * static_cast<double>(sampleRateHz));
std::vector<int16_t> samples(sampleCount, 0);
for (size_t i = 0; i < sampleCount; ++i) {
const double t = static_cast<double>(i) / static_cast<double>(sampleRateHz);
const double value = 0.5 * (std::sin(2.0 * kPi * lowFreqHz * t) + std::sin(2.0 * kPi * highFreqHz * t));
samples[i] = static_cast<int16_t>(std::round(static_cast<double>(amplitude) * value));
}
return samples;
}
std::vector<int16_t> generateSingleTone(double freqHz,
int sampleRateHz,
double durationSeconds,
int16_t amplitude = 12000) {
const size_t sampleCount = static_cast<size_t>(durationSeconds * static_cast<double>(sampleRateHz));
std::vector<int16_t> samples(sampleCount, 0);
for (size_t i = 0; i < sampleCount; ++i) {
const double t = static_cast<double>(i) / static_cast<double>(sampleRateHz);
const double value = std::sin(2.0 * kPi * freqHz * t);
samples[i] = static_cast<int16_t>(std::round(static_cast<double>(amplitude) * value));
}
return samples;
}
bool runScenario(const std::string& name, const std::function<bool()>& 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<int16_t> 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<int16_t> 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<int16_t> 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<int16_t> tone = generateDtmfTone(770.0, 1336.0, 8000, 0.12);
std::vector<int16_t> 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;
}