Merge branch 'codex/webui-align-api'

This commit is contained in:
Clément SAILLANT
2026-02-21 21:27:58 +01:00
14 changed files with 749 additions and 239 deletions
+6 -2
View File
@@ -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.
+71 -35
View File
@@ -3,63 +3,99 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RTC_BL_PHONE WebUI</title>
<title>RTC_BL_PHONE Web UI</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>RTC_BL_PHONE Dashboard</h1>
<p id="status">Chargement...</p>
<h1>RTC_BL_PHONE Web UI</h1>
<p id="statusLine">Chargement...</p>
</header>
<nav>
<button data-section="contacts">Annuaire</button>
<button data-section="dashboard">Dashboard</button>
<button data-section="config">Configuration</button>
<button data-section="logs">Logs</button>
<button data-section="network">Réseau</button>
<button data-section="control">Contrôle</button>
<button id="refreshStatusBtn">Rafraîchir statut</button>
<button id="refreshAllBtn">Rafraîchir tout</button>
</nav>
<main>
<section id="contactsSection" class="active">
<h2>Annuaire Téléphone</h2>
<input id="searchContact" type="text" placeholder="Recherche..." />
<div id="contactsList"></div>
<h3>Ajouter / Modifier un contact</h3>
<form id="contactForm">
<input type="text" name="nom" placeholder="Nom" required>
<input type="text" name="numero" placeholder="Numéro" required>
<select name="type">
<option value="mobile">Mobile</option>
<option value="fixe">Fixe</option>
</select>
<button type="submit">Enregistrer</button>
</form>
<div id="contactFeedback"></div>
<section id="dashboardSection" class="active">
<h2>Statut runtime</h2>
<pre id="statusJson"></pre>
</section>
<section id="configSection">
<h2>Configuration</h2>
<pre id="config"></pre>
<form id="configForm">
<input type="text" name="param1" placeholder="Paramètre 1">
<input type="text" name="param2" placeholder="Paramètre 2">
<button type="submit">Enregistrer</button>
</form>
<div class="inline-actions">
<button id="refreshConfigBtn">Rafraîchir configuration</button>
</div>
<pre id="configJson"></pre>
</section>
<section id="logsSection">
<h2>Logs</h2>
<pre id="logs"></pre>
<button id="refreshLogsBtn">Rafraîchir logs</button>
<section id="networkSection">
<h2>Réseau et bridges</h2>
<div class="grid2">
<div>
<h3>WiFi</h3>
<form id="wifiConnectForm">
<input type="text" id="wifiSsid" placeholder="SSID" required>
<input type="password" id="wifiPass" placeholder="Mot de passe">
<button type="submit">Connecter WiFi</button>
</form>
<div class="inline-actions">
<button id="wifiDisconnectBtn">Déconnecter WiFi</button>
<button id="wifiScanBtn">Scanner WiFi</button>
</div>
<pre id="wifiJson"></pre>
</div>
<div>
<h3>MQTT</h3>
<div class="inline-actions">
<button id="mqttConnectBtn">Connecter MQTT</button>
<button id="mqttDisconnectBtn">Déconnecter MQTT</button>
</div>
<pre id="mqttJson"></pre>
</div>
<div>
<h3>ESP-NOW</h3>
<form id="espnowPeerForm">
<input type="text" id="espnowMac" placeholder="AA:BB:CC:DD:EE:FF" required>
<button type="submit">Ajouter pair</button>
<button type="button" id="espnowDelBtn">Supprimer pair</button>
</form>
<form id="espnowSendForm">
<input type="text" id="espnowTarget" placeholder="broadcast ou MAC" value="broadcast" required>
<textarea id="espnowPayload" rows="3" placeholder="Payload (texte ou JSON)">{\"cmd\":\"STATUS\"}</textarea>
<button type="submit">Envoyer</button>
</form>
<div class="inline-actions">
<button id="espnowOnBtn">ESPNOW_ON</button>
<button id="espnowOffBtn">ESPNOW_OFF</button>
<button id="espnowRefreshBtn">Rafraîchir ESP-NOW</button>
</div>
<pre id="espnowJson"></pre>
<pre id="espnowPeersJson"></pre>
</div>
</div>
</section>
<section id="controlSection">
<h2>Contrôle</h2>
<button data-action="call">Déclencher sonnerie</button>
<button data-action="capture_start">Démarrer capture</button>
<button data-action="capture_stop">Arrêter capture</button>
<pre id="controlResult"></pre>
<div class="inline-actions">
<button data-action="CALL">CALL</button>
<button data-action="CAPTURE_START">CAPTURE_START</button>
<button data-action="CAPTURE_STOP">CAPTURE_STOP</button>
<button data-action="RESET_METRICS">RESET_METRICS</button>
</div>
<form id="rawCommandForm">
<input type="text" id="rawCommandInput" placeholder="Commande série (ex: ESPNOW_STATUS)" required>
<button type="submit">Exécuter</button>
</form>
<pre id="actionResult"></pre>
</section>
</main>
+241 -167
View File
@@ -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 = `<b>${c.nom}</b><br><span>${c.numero}</span><br><span>${c.type}</span>`;
const actions = document.createElement("div");
actions.className = "contact-actions";
actions.innerHTML =
`<button data-call="${c.numero}">Appeler</button>` +
`<button data-edit="${idx}">Modifier</button>` +
`<button data-delete="${idx}">Supprimer</button>`;
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");
});
+25 -18
View File
@@ -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%;
}
}
+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;
}