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
+ Dashboard
Configuration
- Logs
+ Réseau
Contrôle
- Rafraîchir statut
+ Rafraîchir tout
-
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 =
- `Appeler ` +
- `Modifier ` +
- `Supprimer `;
- 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;
+}