diff --git a/README.md b/README.md index 15be4c6..737c77f 100644 --- a/README.md +++ b/README.md @@ -322,10 +322,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/data/webui/index.html b/data/webui/index.html index b2e0681..2e17a04 100644 --- a/data/webui/index.html +++ b/data/webui/index.html @@ -3,63 +3,99 @@ - RTC_BL_PHONE WebUI + RTC_BL_PHONE Web UI
-

RTC_BL_PHONE Dashboard

-

Chargement...

+

RTC_BL_PHONE Web UI

+

Chargement...

-
-

Annuaire Téléphone

- -
-

Ajouter / Modifier un contact

-
- - - - -
-
+
+

Statut runtime

+

     

Configuration

-

-      
- - - -
+
+ +
+

     
-
-

Logs

-

-      
+    
+

Réseau et bridges

+
+
+

WiFi

+
+ + + +
+
+ + +
+

+        
+ +
+

MQTT

+
+ + +
+

+        
+ +
+

ESP-NOW

+
+ + + +
+
+ + + +
+
+ + + +
+

+          

+        
+

Contrôle

- - - -

+      
+ + + + +
+
+ + +
+

     
diff --git a/data/webui/script.js b/data/webui/script.js index 6a04322..7584b9a 100644 --- a/data/webui/script.js +++ b/data/webui/script.js @@ -1,155 +1,130 @@ -let contactsData = []; +const SECTION_MAP = { + dashboard: "dashboardSection", + config: "configSection", + network: "networkSection", + control: "controlSection", +}; function showSection(section) { - const map = { - contacts: "contactsSection", - config: "configSection", - logs: "logsSection", - control: "controlSection", - }; - Object.values(map).forEach((id) => { + Object.values(SECTION_MAP).forEach((id) => { const el = document.getElementById(id); if (el) { el.classList.remove("active"); } }); - const sectionEl = document.getElementById(map[section]); + const sectionEl = document.getElementById(SECTION_MAP[section]); if (sectionEl) { sectionEl.classList.add("active"); } } -async function safeFetchJson(url, options = {}) { - const response = await fetch(url, options); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); +function setJson(id, value) { + const el = document.getElementById(id); + if (!el) { + return; } - return response.json(); + if (typeof value === "string") { + el.textContent = value; + return; + } + el.textContent = JSON.stringify(value, null, 2); +} + +async function requestJson(url, options = {}) { + const response = await fetch(url, options); + const text = await response.text(); + let parsed = {}; + if (text) { + try { + parsed = JSON.parse(text); + } catch (_) { + parsed = { raw: text }; + } + } + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${text || ""}`.trim()); + } + return parsed; +} + +function jsonHeaders() { + return { "Content-Type": "application/json" }; +} + +function parsePayloadValue(rawPayload) { + const trimmed = rawPayload.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.parse(trimmed); + } catch (_) { + return rawPayload; + } + } + return rawPayload; } async function refreshStatus() { - const status = document.getElementById("status"); + const line = document.getElementById("statusLine"); try { - const data = await safeFetchJson("/api/status"); - status.textContent = - `state=${data.state} board=${data.board_profile || "n/a"} ` + - `telephony=${data.telephony || "n/a"} hook=${data.hook || "n/a"} ` + - `full_duplex=${data.full_duplex} underrun=${data.audio_underrun_count || 0} ` + - `drop=${data.audio_drop_frames || 0}`; + const status = await requestJson("/api/status"); + const telephonyState = status.telephony?.state || "n/a"; + const hook = status.telephony?.hook || "n/a"; + const wifiState = status.wifi?.state || "n/a"; + const mqttConnected = status.mqtt?.connected ? "on" : "off"; + const peers = status.espnow?.peer_count ?? 0; + line.textContent = + `board=${status.board_profile || "n/a"} telephony=${telephonyState} hook=${hook} ` + + `wifi=${wifiState} mqtt=${mqttConnected} espnow_peers=${peers}`; + setJson("statusJson", status); } catch (error) { - status.textContent = `Erreur statut: ${error.message}`; + line.textContent = `Erreur statut: ${error.message}`; + setJson("statusJson", { error: error.message }); } } -async function loadContacts() { +async function refreshConfig() { try { - contactsData = await safeFetchJson("/api/contacts"); - renderContacts(); + const [pins, audio, mqtt] = await Promise.all([ + requestJson("/api/config/pins"), + requestJson("/api/config/audio"), + requestJson("/api/config/mqtt"), + ]); + setJson("configJson", { pins, audio, mqtt }); } catch (error) { - document.getElementById("contactFeedback").textContent = `Erreur contacts: ${error.message}`; + setJson("configJson", { error: error.message }); } } -function renderContacts() { - const list = document.getElementById("contactsList"); - const searchInput = document.getElementById("searchContact"); - const search = (searchInput?.value || "").toLowerCase(); - list.innerHTML = ""; - - contactsData - .filter((c) => c.nom.toLowerCase().includes(search) || c.numero.includes(search)) - .forEach((c, idx) => { - const card = document.createElement("div"); - card.className = "contact-card"; - card.innerHTML = `${c.nom}
${c.numero}
${c.type}`; - - const actions = document.createElement("div"); - actions.className = "contact-actions"; - actions.innerHTML = - `` + - `` + - ``; - card.appendChild(actions); - list.appendChild(card); - }); -} - -function editContact(idx) { - const c = contactsData[idx]; - if (!c) { - return; - } - const form = document.getElementById("contactForm"); - form.nom.value = c.nom; - form.numero.value = c.numero; - form.type.value = c.type; - form.dataset.editIdx = String(idx); -} - -async function deleteContact(idx) { - const response = await fetch("/api/contacts", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ idx }), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - await loadContacts(); - document.getElementById("contactFeedback").textContent = "Contact supprimé"; -} - -async function callContact(numero) { - await sendControl("call", { numero }); - document.getElementById("contactFeedback").textContent = `Appel lancé vers ${numero}`; -} - -async function loadConfig() { +async function refreshNetwork() { try { - const data = await safeFetchJson("/api/config"); - document.getElementById("config").textContent = JSON.stringify(data, null, 2); + const [wifi, mqtt, espnow, peers] = await Promise.all([ + requestJson("/api/network/wifi"), + requestJson("/api/network/mqtt"), + requestJson("/api/network/espnow"), + requestJson("/api/network/espnow/peer"), + ]); + setJson("wifiJson", wifi); + setJson("mqttJson", mqtt); + setJson("espnowJson", espnow); + setJson("espnowPeersJson", peers); } catch (error) { - document.getElementById("config").textContent = `Erreur config: ${error.message}`; + const err = { error: error.message }; + setJson("wifiJson", err); + setJson("mqttJson", err); + setJson("espnowJson", err); + setJson("espnowPeersJson", err); } } -async function saveConfig(event) { - event.preventDefault(); - const form = event.target; - const payload = { - param1: form.param1.value || "valeur1", - param2: form.param2.value || "valeur2", - }; - const response = await fetch("/api/config", { +async function sendControl(action) { + const result = await requestJson("/api/control", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + headers: jsonHeaders(), + body: JSON.stringify({ action }), }); - if (!response.ok) { - document.getElementById("config").textContent = `Erreur config: HTTP ${response.status}`; - return; - } - await loadConfig(); -} - -async function refreshLogs() { - const response = await fetch("/api/logs"); - const logs = response.ok ? await response.text() : `Erreur logs: HTTP ${response.status}`; - document.getElementById("logs").textContent = logs; -} - -async function sendControl(action, extraPayload = {}) { - const response = await fetch("/api/control", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action, ...extraPayload }), - }); - const body = await response.text(); - document.getElementById("controlResult").textContent = body; - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - return true; + setJson("actionResult", result); + await Promise.all([refreshStatus(), refreshNetwork()]); + return result; } function bindEvents() { @@ -157,58 +132,147 @@ function bindEvents() { button.addEventListener("click", () => showSection(button.dataset.section)); }); - document.getElementById("refreshStatusBtn").addEventListener("click", refreshStatus); - document.getElementById("refreshLogsBtn").addEventListener("click", refreshLogs); - document.getElementById("searchContact").addEventListener("input", renderContacts); - document.getElementById("configForm").addEventListener("submit", saveConfig); + document.getElementById("refreshAllBtn").addEventListener("click", async () => { + await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork()]); + }); + document.getElementById("refreshConfigBtn").addEventListener("click", refreshConfig); + document.getElementById("espnowRefreshBtn").addEventListener("click", refreshNetwork); - document.getElementById("contactForm").addEventListener("submit", async (event) => { + document.getElementById("wifiConnectForm").addEventListener("submit", async (event) => { event.preventDefault(); - const form = event.target; - const editIdxRaw = form.dataset.editIdx; - const hasEditIdx = typeof editIdxRaw !== "undefined"; - const payload = { - nom: form.nom.value, - numero: form.numero.value, - type: form.type.value, - }; - const method = hasEditIdx ? "PUT" : "POST"; - const body = hasEditIdx ? { ...payload, idx: Number(editIdxRaw) } : payload; - - const response = await fetch("/api/contacts", { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - document.getElementById("contactFeedback").textContent = `Erreur contact: HTTP ${response.status}`; - return; + const ssid = document.getElementById("wifiSsid").value.trim(); + const pass = document.getElementById("wifiPass").value; + try { + const result = await requestJson("/api/network/wifi/connect", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ ssid, pass }), + }); + setJson("actionResult", result); + await Promise.all([refreshStatus(), refreshNetwork()]); + } catch (error) { + setJson("actionResult", { error: error.message }); } - delete form.dataset.editIdx; - form.reset(); - document.getElementById("contactFeedback").textContent = hasEditIdx - ? "Contact modifié" - : "Contact ajouté"; - await loadContacts(); }); - document.getElementById("contactsList").addEventListener("click", async (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } + document.getElementById("wifiDisconnectBtn").addEventListener("click", async () => { try { - if (target.dataset.call) { - await callContact(target.dataset.call); - } - if (target.dataset.edit) { - editContact(Number(target.dataset.edit)); - } - if (target.dataset.delete) { - await deleteContact(Number(target.dataset.delete)); - } + const result = await requestJson("/api/network/wifi/disconnect", { + method: "POST", + headers: jsonHeaders(), + body: "{}", + }); + setJson("actionResult", result); + await Promise.all([refreshStatus(), refreshNetwork()]); } catch (error) { - document.getElementById("contactFeedback").textContent = error.message; + setJson("actionResult", { error: error.message }); + } + }); + + document.getElementById("wifiScanBtn").addEventListener("click", async () => { + try { + const result = await requestJson("/api/network/wifi/scan", { + method: "POST", + headers: jsonHeaders(), + body: "{}", + }); + setJson("actionResult", result); + await refreshNetwork(); + } catch (error) { + setJson("actionResult", { error: error.message }); + } + }); + + document.getElementById("mqttConnectBtn").addEventListener("click", async () => { + try { + const result = await requestJson("/api/network/mqtt/connect", { + method: "POST", + headers: jsonHeaders(), + body: "{}", + }); + setJson("actionResult", result); + await Promise.all([refreshStatus(), refreshNetwork()]); + } catch (error) { + setJson("actionResult", { error: error.message }); + } + }); + + document.getElementById("mqttDisconnectBtn").addEventListener("click", async () => { + try { + const result = await requestJson("/api/network/mqtt/disconnect", { + method: "POST", + headers: jsonHeaders(), + body: "{}", + }); + setJson("actionResult", result); + await Promise.all([refreshStatus(), refreshNetwork()]); + } catch (error) { + setJson("actionResult", { error: error.message }); + } + }); + + document.getElementById("espnowOnBtn").addEventListener("click", async () => { + try { + await sendControl("ESPNOW_ON"); + } catch (error) { + setJson("actionResult", { error: error.message }); + } + }); + + document.getElementById("espnowOffBtn").addEventListener("click", async () => { + try { + await sendControl("ESPNOW_OFF"); + } catch (error) { + setJson("actionResult", { error: error.message }); + } + }); + + document.getElementById("espnowPeerForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const mac = document.getElementById("espnowMac").value.trim(); + try { + const result = await requestJson("/api/network/espnow/peer", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ mac }), + }); + setJson("actionResult", result); + await refreshNetwork(); + } catch (error) { + setJson("actionResult", { error: error.message }); + } + }); + + document.getElementById("espnowDelBtn").addEventListener("click", async () => { + const mac = document.getElementById("espnowMac").value.trim(); + try { + const result = await requestJson("/api/network/espnow/peer", { + method: "DELETE", + headers: jsonHeaders(), + body: JSON.stringify({ mac }), + }); + setJson("actionResult", result); + await refreshNetwork(); + } catch (error) { + setJson("actionResult", { error: error.message }); + } + }); + + document.getElementById("espnowSendForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const target = document.getElementById("espnowTarget").value.trim(); + const payloadRaw = document.getElementById("espnowPayload").value; + const payload = parsePayloadValue(payloadRaw); + try { + const result = await requestJson("/api/network/espnow/send", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ mac: target, payload }), + }); + setJson("actionResult", result); + await refreshNetwork(); + } catch (error) { + setJson("actionResult", { error: error.message }); } }); @@ -217,14 +281,24 @@ function bindEvents() { try { await sendControl(button.dataset.action); } catch (error) { - document.getElementById("controlResult").textContent = error.message; + setJson("actionResult", { error: error.message }); } }); }); + + document.getElementById("rawCommandForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const action = document.getElementById("rawCommandInput").value.trim(); + try { + await sendControl(action); + } catch (error) { + setJson("actionResult", { error: error.message }); + } + }); } document.addEventListener("DOMContentLoaded", async () => { bindEvents(); - await Promise.all([refreshStatus(), loadContacts(), loadConfig(), refreshLogs()]); - showSection("contacts"); + await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork()]); + showSection("dashboard"); }); diff --git a/data/webui/style.css b/data/webui/style.css index 7cf69aa..8b71e44 100644 --- a/data/webui/style.css +++ b/data/webui/style.css @@ -6,6 +6,7 @@ --accent-strong: #084a81; --ok: #19753d; --border: #d8dee4; + --warn: #9a5f00; } * { @@ -61,31 +62,28 @@ section.active { display: block; } -#contactsList { +.grid2 { display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 12px; - margin-top: 10px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; } -.contact-card { - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px; - background: #fcfdff; -} - -.contact-actions { - margin-top: 8px; +.inline-actions { + margin: 8px 0; display: flex; - gap: 6px; + flex-wrap: wrap; + gap: 8px; } input, -select { +select, +textarea { padding: 8px; border: 1px solid var(--border); border-radius: 6px; + font: inherit; + color: inherit; + background: #fff; } form { @@ -101,9 +99,18 @@ pre { border-radius: 6px; padding: 12px; overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; } -#contactFeedback { - margin-top: 8px; - color: var(--ok); +@media (max-width: 760px) { + body { + padding: 12px; + } + + button, + input, + textarea { + width: 100%; + } } 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; } +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) { - // TODO: Implémentation Goertzel pour détecter DTMF - // Si détection, appeler onDigit(digit) + 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; +}