Merge branch 'codex/webui-align-api'
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <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é.
|
||||
@@ -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.
|
||||
|
||||
Executable
+15
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,6 +16,7 @@ public:
|
||||
EspNowBridge();
|
||||
|
||||
bool begin(const EspNowPeerStore& initial_peers);
|
||||
bool stop();
|
||||
void tick();
|
||||
|
||||
bool addPeer(const String& mac);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user