Contexte : le repo contenait deux projets superposés — le projet Arduino/ PlatformIO d'origine (ESP32-S3, hotline WAV, ESP-NOW, UI web) et le firmware ESP-IDF actuel (kit mains-libres Bluetooth, ESP32 classique). Le code Arduino n'est plus utilise par le build IDF (verifie : aucune reference dans CMakeLists/main/components) et entretenait une dette de confusion. Approche : suppression du code mort verifie independant du build, et reecriture de la doc pour la realite ESP-IDF. Build IDF revalide apres coup (binaire OK). Changements : - supprime src/ (56 fichiers C++ Arduino : AudioCodec, AudioFilePlayer, SlicK50835F, web/, wifi/, usb/, ...) - supprime platformio.ini (build Arduino) et data/webui/ (UI web abandonnee, aucune partition data) - supprime les CLAUDE.md imbriques documentant ces dossiers - README.md : reecrit pour le kit mains-libres BT HFP-HF / ESP-IDF - CLAUDE.md : reecrit (Arduino/ESP32-S3 -> ESP-IDF/ESP32 classique) et versionne Impact : un seul projet dans le repo, doc alignee sur le code, build inchange.
This commit is contained in:
@@ -1,113 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RTC_BL_PHONE Web UI</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>RTC_BL_PHONE Web UI</h1>
|
||||
<p id="statusLine">Chargement...</p>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<button data-section="dashboard">Dashboard</button>
|
||||
<button data-section="config">Configuration</button>
|
||||
<button data-section="network">Réseau</button>
|
||||
<button data-section="control">Contrôle</button>
|
||||
<button id="refreshAllBtn">Rafraîchir tout</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section id="dashboardSection" class="active">
|
||||
<h2>Statut runtime</h2>
|
||||
<pre id="statusJson"></pre>
|
||||
</section>
|
||||
|
||||
<section id="configSection">
|
||||
<h2>Configuration</h2>
|
||||
<div class="inline-actions">
|
||||
<button id="refreshConfigBtn">Rafraîchir configuration</button>
|
||||
</div>
|
||||
<pre id="configJson"></pre>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<h3>Audio config (JSON)</h3>
|
||||
<textarea id="audioConfigInput" rows="8" placeholder='{"volume":80}'></textarea>
|
||||
<div class="inline-actions">
|
||||
<button id="applyAudioConfigBtn">Appliquer audio</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Pins config (JSON)</h3>
|
||||
<textarea id="pinsConfigInput" rows="8" placeholder='{"slic":{"line":23}}'></textarea>
|
||||
<div class="inline-actions">
|
||||
<button id="applyPinsConfigBtn">Appliquer pins</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="wifiReconnectBtn">Reconnecter WiFi</button>
|
||||
<button id="wifiScanBtn">Scanner WiFi</button>
|
||||
</div>
|
||||
<pre id="wifiJson"></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">
|
||||
<textarea id="espnowPayload" rows="3" placeholder="Payload (texte ou JSON)">{\"cmd\":\"STATUS\"}</textarea>
|
||||
<p>ESPNOW: envoi en broadcast uniquement</p>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,433 +0,0 @@
|
||||
const SECTION_MAP = {
|
||||
dashboard: "dashboardSection",
|
||||
config: "configSection",
|
||||
network: "networkSection",
|
||||
control: "controlSection",
|
||||
};
|
||||
let realtimeSource = null;
|
||||
let realtimeConnected = false;
|
||||
let fallbackPollingTimer = null;
|
||||
|
||||
function showSection(section) {
|
||||
Object.values(SECTION_MAP).forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.classList.remove("active");
|
||||
}
|
||||
});
|
||||
const sectionEl = document.getElementById(SECTION_MAP[section]);
|
||||
if (sectionEl) {
|
||||
sectionEl.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
function setJson(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
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 parseJsonInput(id) {
|
||||
const raw = document.getElementById(id).value.trim();
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function parsePayloadValue(rawPayload) {
|
||||
const trimmed = rawPayload.trim();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (_) {
|
||||
return rawPayload;
|
||||
}
|
||||
}
|
||||
return rawPayload;
|
||||
}
|
||||
|
||||
function parseRealtimeData(raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_) {
|
||||
return { raw };
|
||||
}
|
||||
}
|
||||
|
||||
function applyStatusSnapshot(status) {
|
||||
const line = document.getElementById("statusLine");
|
||||
const telephonyState = status.telephony?.state || "n/a";
|
||||
const hook = status.telephony?.hook || "n/a";
|
||||
const wifiState = status.wifi?.state || "n/a";
|
||||
const peers = status.espnow?.peer_count ?? 0;
|
||||
const liveState = realtimeConnected ? "live=on" : "live=off";
|
||||
line.textContent =
|
||||
`board=${status.board_profile || "n/a"} telephony=${telephonyState} hook=${hook} ` +
|
||||
`wifi=${wifiState} espnow_peers=${peers} ${liveState}`;
|
||||
|
||||
setJson("statusJson", status);
|
||||
if (status.wifi) {
|
||||
setJson("wifiJson", status.wifi);
|
||||
}
|
||||
if (status.espnow) {
|
||||
setJson("espnowJson", status.espnow);
|
||||
setJson("espnowPeersJson", { peers: status.espnow.peers || [] });
|
||||
}
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
if (!window.EventSource) {
|
||||
return;
|
||||
}
|
||||
if (realtimeSource) {
|
||||
realtimeSource.close();
|
||||
}
|
||||
|
||||
realtimeSource = new EventSource("/api/events");
|
||||
|
||||
realtimeSource.onopen = () => {
|
||||
realtimeConnected = true;
|
||||
};
|
||||
|
||||
realtimeSource.addEventListener("hello", () => {
|
||||
realtimeConnected = true;
|
||||
});
|
||||
|
||||
realtimeSource.addEventListener("status", (event) => {
|
||||
realtimeConnected = true;
|
||||
const status = parseRealtimeData(event.data);
|
||||
applyStatusSnapshot(status);
|
||||
});
|
||||
|
||||
realtimeSource.addEventListener("dispatch", (event) => {
|
||||
const payload = parseRealtimeData(event.data);
|
||||
const out = { stream: "dispatch" };
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
Object.assign(out, payload);
|
||||
} else {
|
||||
out.payload = payload;
|
||||
}
|
||||
setJson("actionResult", out);
|
||||
});
|
||||
|
||||
realtimeSource.addEventListener("effect", (event) => {
|
||||
const payload = parseRealtimeData(event.data);
|
||||
const out = { stream: "effect" };
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
Object.assign(out, payload);
|
||||
} else {
|
||||
out.payload = payload;
|
||||
}
|
||||
setJson("actionResult", out);
|
||||
});
|
||||
|
||||
realtimeSource.onerror = () => {
|
||||
realtimeConnected = false;
|
||||
};
|
||||
}
|
||||
|
||||
function ensureFallbackPolling() {
|
||||
if (fallbackPollingTimer !== null) {
|
||||
return;
|
||||
}
|
||||
fallbackPollingTimer = window.setInterval(() => {
|
||||
if (!realtimeConnected) {
|
||||
refreshStatus().catch(() => {});
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const status = await requestJson("/api/status");
|
||||
applyStatusSnapshot(status);
|
||||
} catch (error) {
|
||||
const line = document.getElementById("statusLine");
|
||||
const liveState = realtimeConnected ? "live=on" : "live=off";
|
||||
line.textContent = `Erreur statut: ${error.message}`;
|
||||
line.textContent += ` ${liveState}`;
|
||||
setJson("statusJson", { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConfig() {
|
||||
try {
|
||||
const [pins, audio] = await Promise.all([
|
||||
requestJson("/api/config/pins"),
|
||||
requestJson("/api/config/audio"),
|
||||
]);
|
||||
setJson("configJson", { pins, audio });
|
||||
const audioInput = document.getElementById("audioConfigInput");
|
||||
const pinsInput = document.getElementById("pinsConfigInput");
|
||||
if (audioInput) {
|
||||
audioInput.value = JSON.stringify(audio, null, 2);
|
||||
}
|
||||
if (pinsInput) {
|
||||
pinsInput.value = JSON.stringify(pins, null, 2);
|
||||
}
|
||||
} catch (error) {
|
||||
setJson("configJson", { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshNetwork() {
|
||||
try {
|
||||
const [wifi, espnow, peers] = await Promise.all([
|
||||
requestJson("/api/network/wifi"),
|
||||
requestJson("/api/network/espnow"),
|
||||
requestJson("/api/network/espnow/peer"),
|
||||
]);
|
||||
setJson("wifiJson", wifi);
|
||||
setJson("espnowJson", espnow);
|
||||
setJson("espnowPeersJson", peers);
|
||||
} catch (error) {
|
||||
const err = { error: error.message };
|
||||
setJson("wifiJson", err);
|
||||
setJson("espnowJson", err);
|
||||
setJson("espnowPeersJson", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendControl(action) {
|
||||
const result = await requestJson("/api/control", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
return result;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.querySelectorAll("nav button[data-section]").forEach((button) => {
|
||||
button.addEventListener("click", () => showSection(button.dataset.section));
|
||||
});
|
||||
|
||||
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("applyAudioConfigBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = parseJsonInput("audioConfigInput");
|
||||
const result = await requestJson("/api/config/audio", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshConfig(), refreshStatus()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("applyPinsConfigBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = parseJsonInput("pinsConfigInput");
|
||||
const result = await requestJson("/api/config/pins", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshConfig(), refreshStatus()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wifiConnectForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wifiDisconnectBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/wifi/disconnect", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wifiReconnectBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/wifi/reconnect", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
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("espnowOnBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/espnow/on", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("espnowOffBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/espnow/off", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} 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 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({ payload }),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await refreshNetwork();
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("#controlSection button[data-action]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await sendControl(button.dataset.action);
|
||||
} catch (error) {
|
||||
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();
|
||||
connectRealtime();
|
||||
ensureFallbackPolling();
|
||||
await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork()]);
|
||||
showSection("dashboard");
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
:root {
|
||||
--bg: #f4f6f8;
|
||||
--panel: #ffffff;
|
||||
--text: #1f2933;
|
||||
--accent: #0f6ab6;
|
||||
--accent-strong: #084a81;
|
||||
--ok: #19753d;
|
||||
--border: #d8dee4;
|
||||
--warn: #9a5f00;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||
background: linear-gradient(135deg, #edf4fa, #f8f9fb);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
section {
|
||||
display: none;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #1f2933;
|
||||
color: #e8eef4;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
; PlatformIO Project Configuration File
|
||||
|
||||
[platformio]
|
||||
default_envs = esp32-s3-devkitc-1
|
||||
build_dir = .pio/build
|
||||
|
||||
[env]
|
||||
platform = espressif32
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
test_build_src = yes
|
||||
build_flags =
|
||||
-DCORE_DEBUG_LEVEL=1
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson@^7.0.4
|
||||
throwtheswitch/Unity@^2.6.1
|
||||
ESP32Async/AsyncTCP@^3.3.2
|
||||
ESP32Async/ESPAsyncWebServer@^3.6.0
|
||||
https://github.com/pschatzmann/arduino-audio-tools.git
|
||||
https://github.com/bitluni/OsciDisplay.git
|
||||
lib_ignore =
|
||||
ESPAsyncTCP
|
||||
RPAsyncTCP
|
||||
|
||||
|
||||
; S3 scope only: ESP32-A252 build profile intentionally removed from active configs in this project phase.
|
||||
|
||||
[env:esp32-s3-devkitc-1]
|
||||
board = esp32-s3-devkitc-1
|
||||
board_build.partitions = partitions/esp32s3_no_ota_spiffs_16MB.csv
|
||||
upload_port = /dev/cu.usbserial-0001
|
||||
monitor_port = /dev/cu.usbserial-0001
|
||||
build_flags =
|
||||
${env.build_flags}
|
||||
-DBOARD_PROFILE_ESP32_S3
|
||||
build_src_filter =
|
||||
+<main.cpp>
|
||||
+<audio/AudioEngine.cpp>
|
||||
+<audio/ToneCatalog.cpp>
|
||||
+<audio/Es8388Driver.cpp>
|
||||
+<config/A252ConfigStore.cpp>
|
||||
+<core/CommandDispatcher.cpp>
|
||||
+<core/AgentSupervisor.cpp>
|
||||
+<core/PlatformProfile.cpp>
|
||||
+<props/EspNowBridge.cpp>
|
||||
+<wifi/WifiManager.cpp>
|
||||
+<visual/ScopeDisplay.cpp>
|
||||
+<slic/Ks0835SlicController.cpp>
|
||||
+<telephony/DtmfDecoder.cpp>
|
||||
+<telephony/TelephonyService.cpp>
|
||||
+<web/WebServerManager.cpp>
|
||||
|
||||
[env:esp32-s3-usb-host]
|
||||
board = esp32s3usbotg
|
||||
board_build.partitions = partitions/esp32s3_no_ota_spiffs_16MB.csv
|
||||
upload_port = /dev/cu.usbserial-0001
|
||||
monitor_port = /dev/cu.usbserial-0001
|
||||
build_flags =
|
||||
${env.build_flags}
|
||||
-DBOARD_PROFILE_ESP32_S3
|
||||
-DUSB_HOST_BOOT_ENABLE
|
||||
build_src_filter =
|
||||
+<main.cpp>
|
||||
+<audio/AudioEngine.cpp>
|
||||
+<audio/ToneCatalog.cpp>
|
||||
+<audio/Es8388Driver.cpp>
|
||||
+<config/A252ConfigStore.cpp>
|
||||
+<core/CommandDispatcher.cpp>
|
||||
+<core/AgentSupervisor.cpp>
|
||||
+<core/PlatformProfile.cpp>
|
||||
+<props/EspNowBridge.cpp>
|
||||
+<wifi/WifiManager.cpp>
|
||||
+<visual/ScopeDisplay.cpp>
|
||||
+<slic/Ks0835SlicController.cpp>
|
||||
+<telephony/DtmfDecoder.cpp>
|
||||
+<telephony/TelephonyService.cpp>
|
||||
+<web/WebServerManager.cpp>
|
||||
|
||||
[env:esp32-s3-usb-msc]
|
||||
board = esp32-s3-devkitc-1
|
||||
board_build.partitions = partitions/esp32s3_no_ota_spiffs_usb_msc_16MB.csv
|
||||
extra_scripts = scripts/upload_usbmsc.py
|
||||
upload_port = /dev/cu.usbserial-0001
|
||||
monitor_port = /dev/cu.usbserial-0001
|
||||
build_unflags =
|
||||
-DARDUINO_USB_MODE=1
|
||||
build_flags =
|
||||
${env.build_flags}
|
||||
-DBOARD_PROFILE_ESP32_S3
|
||||
-DARDUINO_USB_MODE=0
|
||||
-DUSB_MSC_BOOT_ENABLE
|
||||
build_src_filter =
|
||||
+<main.cpp>
|
||||
+<usb/UsbMassStorageRuntime.cpp>
|
||||
+<audio/AudioEngine.cpp>
|
||||
+<audio/ToneCatalog.cpp>
|
||||
+<audio/Es8388Driver.cpp>
|
||||
+<config/A252ConfigStore.cpp>
|
||||
+<core/CommandDispatcher.cpp>
|
||||
+<core/AgentSupervisor.cpp>
|
||||
+<core/PlatformProfile.cpp>
|
||||
+<props/EspNowBridge.cpp>
|
||||
+<wifi/WifiManager.cpp>
|
||||
+<visual/ScopeDisplay.cpp>
|
||||
+<slic/Ks0835SlicController.cpp>
|
||||
+<telephony/DtmfDecoder.cpp>
|
||||
+<telephony/TelephonyService.cpp>
|
||||
+<web/WebServerManager.cpp>
|
||||
@@ -1,108 +0,0 @@
|
||||
#include "AudioCodec.h"
|
||||
#include <driver/i2s.h>
|
||||
#include <Wire.h>
|
||||
#include "config/a1s_board_pins.h"
|
||||
|
||||
// Documentation technique :
|
||||
// ES8388 : Codec I2S + I2C, contrôle volume/mute/routage via registres.
|
||||
// PCM5102 : Codec I2S, volume/mute via atténuation ou pin externe.
|
||||
// Routage audio : géré par setRoute, peut impliquer multiplexeur/relais.
|
||||
// Extensibilité : ajouter un nouveau codec = nouvelle classe dérivée.
|
||||
// Testabilité : mock des méthodes, logs sur chaque action.
|
||||
// --- ES8388Codec ---
|
||||
bool ES8388Codec::init() {
|
||||
i2s_config_t i2s_config = {
|
||||
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_RX),
|
||||
.sample_rate = 16000,
|
||||
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
|
||||
.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
|
||||
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
|
||||
.intr_alloc_flags = 0,
|
||||
.dma_buf_count = 8,
|
||||
.dma_buf_len = 64,
|
||||
.use_apll = false,
|
||||
.tx_desc_auto_clear = true,
|
||||
.fixed_mclk = 0
|
||||
};
|
||||
i2s_pin_config_t pin_config = {
|
||||
.bck_io_num = A1S_I2S_BCLK,
|
||||
.ws_io_num = A1S_I2S_LRCK,
|
||||
.data_out_num = A1S_I2S_DOUT,
|
||||
.data_in_num = A1S_I2S_DIN
|
||||
};
|
||||
esp_err_t err = i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
|
||||
if (err != ESP_OK) return false;
|
||||
err = i2s_set_pin(I2S_NUM_0, &pin_config);
|
||||
if (err != ESP_OK) return false;
|
||||
Wire.begin(A1S_I2C_SDA, A1S_I2C_SCL);
|
||||
setVolume(80);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ES8388Codec::setVolume(uint8_t volume) {
|
||||
Wire.beginTransmission(A1S_ES8388_I2C_ADDR);
|
||||
Wire.write(0x2B);
|
||||
Wire.write(volume);
|
||||
Wire.endTransmission();
|
||||
Wire.beginTransmission(A1S_ES8388_I2C_ADDR);
|
||||
Wire.write(0x2C);
|
||||
Wire.write(volume);
|
||||
Wire.endTransmission();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ES8388Codec::mute(bool state) {
|
||||
Wire.beginTransmission(A1S_ES8388_I2C_ADDR);
|
||||
Wire.write(0x2F);
|
||||
Wire.write(state ? 0x01 : 0x00);
|
||||
Wire.endTransmission();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ES8388Codec::setRoute(AudioRoute route) {
|
||||
Wire.beginTransmission(A1S_ES8388_I2C_ADDR);
|
||||
Wire.write(0x30);
|
||||
Wire.write(route == ROUTE_BLUETOOTH ? 0x01 : 0x00);
|
||||
Wire.endTransmission();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PCM5102Codec::init() {
|
||||
i2s_config_t i2s_config = {
|
||||
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
|
||||
.sample_rate = 16000,
|
||||
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
|
||||
.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
|
||||
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
|
||||
.intr_alloc_flags = 0,
|
||||
.dma_buf_count = 8,
|
||||
.dma_buf_len = 64,
|
||||
.use_apll = false,
|
||||
.tx_desc_auto_clear = true,
|
||||
.fixed_mclk = 0
|
||||
};
|
||||
i2s_pin_config_t pin_config = {
|
||||
.bck_io_num = A1S_I2S_BCLK,
|
||||
.ws_io_num = A1S_I2S_LRCK,
|
||||
.data_out_num = A1S_I2S_DOUT,
|
||||
.data_in_num = A1S_I2S_DIN
|
||||
};
|
||||
esp_err_t err = i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
|
||||
if (err != ESP_OK) return false;
|
||||
err = i2s_set_pin(I2S_NUM_0, &pin_config);
|
||||
if (err != ESP_OK) return false;
|
||||
setVolume(80);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PCM5102Codec::setVolume(uint8_t volume) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PCM5102Codec::mute(bool state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PCM5102Codec::setRoute(AudioRoute route) {
|
||||
return true;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Interface générique pour codec audio (I2S)
|
||||
// Permet d'abstraire ES8388, PCM5102, et une interface générique
|
||||
|
||||
|
||||
#ifndef AUDIO_CODEC_H
|
||||
#define AUDIO_CODEC_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
enum AudioRoute {
|
||||
ROUTE_RTC,
|
||||
ROUTE_BLUETOOTH,
|
||||
ROUTE_NONE
|
||||
};
|
||||
|
||||
class AudioCodec {
|
||||
public:
|
||||
virtual bool init() = 0;
|
||||
virtual bool setVolume(uint8_t volume) = 0;
|
||||
virtual bool mute(bool state) = 0;
|
||||
virtual bool setRoute(AudioRoute route) = 0;
|
||||
virtual ~AudioCodec() {}
|
||||
};
|
||||
|
||||
class ES8388Codec : public AudioCodec {
|
||||
public:
|
||||
bool init() override;
|
||||
bool setVolume(uint8_t volume) override;
|
||||
bool mute(bool state) override;
|
||||
bool setRoute(AudioRoute route) override;
|
||||
};
|
||||
|
||||
class PCM5102Codec : public AudioCodec {
|
||||
public:
|
||||
bool init() override;
|
||||
bool setVolume(uint8_t volume) override;
|
||||
bool mute(bool state) override;
|
||||
bool setRoute(AudioRoute route) override;
|
||||
};
|
||||
|
||||
class GenericCodec : public AudioCodec {
|
||||
public:
|
||||
bool init() override { return true; }
|
||||
bool setVolume(uint8_t) override { return true; }
|
||||
bool mute(bool) override { return true; }
|
||||
bool setRoute(AudioRoute) override { return true; }
|
||||
};
|
||||
|
||||
#endif // AUDIO_CODEC_H
|
||||
@@ -1,48 +0,0 @@
|
||||
#include "AudioFilePlayer.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kPlaybackDurationMs = 3000U;
|
||||
}
|
||||
|
||||
AudioFilePlayer::AudioFilePlayer()
|
||||
: sd_ready_(false), playing_(false), play_until_ms_(0), current_file_("") {}
|
||||
|
||||
bool AudioFilePlayer::begin() {
|
||||
sd_ready_ = SD.begin();
|
||||
if (!sd_ready_) {
|
||||
Serial.println("[AudioFilePlayer] SD init failed");
|
||||
}
|
||||
return sd_ready_;
|
||||
}
|
||||
|
||||
bool AudioFilePlayer::play(const char* filename) {
|
||||
if (!sd_ready_ || filename == nullptr || filename[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (!SD.exists(filename)) {
|
||||
Serial.printf("[AudioFilePlayer] File not found: %s\n", filename);
|
||||
return false;
|
||||
}
|
||||
current_file_ = filename;
|
||||
playing_ = true;
|
||||
play_until_ms_ = millis() + kPlaybackDurationMs;
|
||||
Serial.printf("[AudioFilePlayer] Playing %s\n", filename);
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioFilePlayer::loop() {
|
||||
if (playing_ && static_cast<int32_t>(millis() - play_until_ms_) >= 0) {
|
||||
playing_ = false;
|
||||
Serial.printf("[AudioFilePlayer] Playback finished: %s\n", current_file_.c_str());
|
||||
current_file_ = "";
|
||||
}
|
||||
}
|
||||
|
||||
void AudioFilePlayer::stop() {
|
||||
playing_ = false;
|
||||
current_file_ = "";
|
||||
}
|
||||
|
||||
bool AudioFilePlayer::isPlaying() const {
|
||||
return playing_;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
#ifndef AUDIO_FILE_PLAYER_H
|
||||
#define AUDIO_FILE_PLAYER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
class AudioFilePlayer {
|
||||
public:
|
||||
AudioFilePlayer();
|
||||
bool begin();
|
||||
bool play(const char* filename);
|
||||
void loop();
|
||||
void stop();
|
||||
bool isPlaying() const;
|
||||
|
||||
private:
|
||||
bool sd_ready_;
|
||||
bool playing_;
|
||||
uint32_t play_until_ms_;
|
||||
String current_file_;
|
||||
};
|
||||
|
||||
#endif // AUDIO_FILE_PLAYER_H
|
||||
@@ -1,31 +0,0 @@
|
||||
#include "SlicK50835F.h"
|
||||
|
||||
SlicK50835F::SlicK50835F(uint8_t pinHook, uint8_t pinRingCmd, uint8_t pinLineSense)
|
||||
: _pinHook(pinHook), _pinRingCmd(pinRingCmd), _pinLineSense(pinLineSense), _hookState(false), _lineState(false) {}
|
||||
|
||||
void SlicK50835F::begin() {
|
||||
pinMode(_pinHook, INPUT_PULLUP);
|
||||
pinMode(_pinRingCmd, OUTPUT);
|
||||
pinMode(_pinLineSense, INPUT);
|
||||
digitalWrite(_pinRingCmd, LOW);
|
||||
}
|
||||
|
||||
void SlicK50835F::setRing(bool enable) {
|
||||
digitalWrite(_pinRingCmd, enable ? HIGH : LOW);
|
||||
}
|
||||
|
||||
bool SlicK50835F::isHookOn() {
|
||||
_hookState = digitalRead(_pinHook) == LOW;
|
||||
return _hookState;
|
||||
}
|
||||
|
||||
bool SlicK50835F::isLineActive() {
|
||||
_lineState = digitalRead(_pinLineSense) == HIGH;
|
||||
return _lineState;
|
||||
}
|
||||
|
||||
void SlicK50835F::loop() {
|
||||
// Mettre à jour les états, ajouter logique avancée si besoin
|
||||
isHookOn();
|
||||
isLineActive();
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// Classe d'abstraction pour le SLIC K50835F (AG1171S)
|
||||
// Permet de piloter la ligne RTC, le hook, la sonnerie et la détection d'état
|
||||
|
||||
#ifndef SLIC_K50835F_H
|
||||
#define SLIC_K50835F_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
class SlicK50835F {
|
||||
public:
|
||||
SlicK50835F(uint8_t pinHook, uint8_t pinRingCmd, uint8_t pinLineSense);
|
||||
void begin();
|
||||
void setRing(bool enable);
|
||||
bool isHookOn();
|
||||
bool isLineActive();
|
||||
void loop();
|
||||
|
||||
private:
|
||||
uint8_t _pinHook;
|
||||
uint8_t _pinRingCmd;
|
||||
uint8_t _pinLineSense;
|
||||
bool _hookState;
|
||||
bool _lineState;
|
||||
};
|
||||
|
||||
#endif // SLIC_K50835F_H
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,316 +0,0 @@
|
||||
#ifndef AUDIO_ENGINE_H
|
||||
#define AUDIO_ENGINE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <AudioTools.h>
|
||||
#include <FS.h>
|
||||
#include <driver/i2s.h>
|
||||
#include <memory>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#include "core/PlatformProfile.h"
|
||||
#include "audio/ToneCatalog.h"
|
||||
#include "media/MediaRouting.h"
|
||||
|
||||
struct AudioConfig {
|
||||
i2s_port_t port = I2S_NUM_0;
|
||||
uint32_t sample_rate = 16000;
|
||||
i2s_bits_per_sample_t bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT;
|
||||
i2s_channel_fmt_t channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT;
|
||||
int bck_pin = 27;
|
||||
int ws_pin = 25;
|
||||
int data_out_pin = 26;
|
||||
int data_in_pin = 35;
|
||||
int capture_adc_pin = -1;
|
||||
bool enable_capture = true;
|
||||
bool adc_dsp_enabled = true;
|
||||
bool adc_fft_enabled = true;
|
||||
uint8_t adc_dsp_fft_downsample = 2U;
|
||||
uint16_t adc_fft_ignore_low_bin = 1U;
|
||||
uint16_t adc_fft_ignore_high_bin = 1U;
|
||||
uint8_t dma_buf_count = 8;
|
||||
uint16_t dma_buf_len = 256;
|
||||
bool hybrid_telco_clock_policy = true;
|
||||
bool wav_auto_normalize_limiter = true;
|
||||
int16_t wav_target_rms_dbfs = -18;
|
||||
int16_t wav_limiter_ceiling_dbfs = -2;
|
||||
uint16_t wav_limiter_attack_ms = 8;
|
||||
uint16_t wav_limiter_release_ms = 120;
|
||||
};
|
||||
|
||||
struct AudioRuntimeMetrics {
|
||||
uint32_t frames_requested = 0;
|
||||
uint32_t frames_read = 0;
|
||||
uint32_t drop_frames = 0;
|
||||
uint32_t underrun_count = 0;
|
||||
uint32_t last_latency_ms = 0;
|
||||
uint32_t max_latency_ms = 0;
|
||||
uint16_t adc_fft_peak_bin = 0;
|
||||
uint16_t adc_fft_probe_rate_hz = 0;
|
||||
float adc_fft_peak_freq_hz = 0.0f;
|
||||
float adc_fft_peak_magnitude = 0.0f;
|
||||
uint32_t tone_jitter_us_max = 0;
|
||||
uint32_t tone_write_miss_count = 0;
|
||||
};
|
||||
|
||||
struct AudioPlaybackProbeResult {
|
||||
bool ok = false;
|
||||
String error;
|
||||
String path;
|
||||
MediaSource source = MediaSource::AUTO;
|
||||
uint32_t input_sample_rate = 0;
|
||||
uint8_t input_bits_per_sample = 0;
|
||||
uint8_t input_channels = 0;
|
||||
uint32_t output_sample_rate = 0;
|
||||
uint8_t output_bits_per_sample = 0;
|
||||
uint8_t output_channels = 0;
|
||||
bool resampler_active = false;
|
||||
bool channel_upmix_active = false;
|
||||
bool loudness_auto = false;
|
||||
float loudness_gain_db = 0.0f;
|
||||
bool limiter_active = false;
|
||||
uint32_t rate_fallback = 0;
|
||||
uint32_t data_size_bytes = 0;
|
||||
uint32_t duration_ms = 0;
|
||||
};
|
||||
|
||||
AudioConfig defaultAudioConfigForProfile(BoardProfile profile);
|
||||
|
||||
class AudioEngine {
|
||||
public:
|
||||
enum CaptureClient : uint8_t {
|
||||
CAPTURE_CLIENT_GENERIC = 0x01,
|
||||
CAPTURE_CLIENT_TELEPHONY = 0x02,
|
||||
CAPTURE_CLIENT_BLUETOOTH = 0x04,
|
||||
};
|
||||
|
||||
virtual ~AudioEngine();
|
||||
AudioEngine();
|
||||
virtual bool begin(const AudioConfig& config);
|
||||
virtual void end();
|
||||
virtual bool playFile(const char* path);
|
||||
virtual bool playFileFromSource(const char* path, MediaSource source);
|
||||
virtual bool playFileWithPolicy(const char* path);
|
||||
virtual void stopPlayback();
|
||||
virtual bool requestCapture(CaptureClient client);
|
||||
virtual void releaseCapture(CaptureClient client);
|
||||
virtual bool startCapture();
|
||||
virtual size_t readCaptureFrame(int16_t* dst, size_t samples);
|
||||
virtual size_t readCaptureFrameNonBlocking(int16_t* dst, size_t samples);
|
||||
virtual size_t writePlaybackFrame(const int16_t* src, size_t samples);
|
||||
virtual void stopCapture();
|
||||
virtual bool playTone(ToneProfile profile, ToneEvent event);
|
||||
virtual void stopTone();
|
||||
virtual bool isToneActive() const;
|
||||
virtual bool isToneRouteActive() const;
|
||||
virtual bool isToneRenderingActive() const;
|
||||
virtual ToneProfile activeToneProfile() const;
|
||||
virtual ToneEvent activeToneEvent() const;
|
||||
virtual bool startDialTone();
|
||||
virtual void stopDialTone();
|
||||
virtual uint16_t playbackInputSampleRate() const;
|
||||
virtual uint8_t playbackInputBitsPerSample() const;
|
||||
virtual uint8_t playbackInputChannels() const;
|
||||
virtual uint16_t playbackOutputSampleRate() const;
|
||||
virtual uint8_t playbackOutputBitsPerSample() const;
|
||||
virtual uint8_t playbackOutputChannels() const;
|
||||
virtual bool playbackResamplerActive() const;
|
||||
virtual bool playbackChannelUpmixActive() const;
|
||||
virtual bool playbackLoudnessAuto() const;
|
||||
virtual float playbackLoudnessGainDb() const;
|
||||
virtual bool playbackLimiterActive() const;
|
||||
virtual uint32_t playbackRateFallback() const;
|
||||
virtual uint32_t playbackCopySourceBytes() const;
|
||||
virtual uint32_t playbackCopyAcceptedBytes() const;
|
||||
virtual uint32_t playbackCopyLossBytes() const;
|
||||
virtual uint32_t playbackCopyLossEvents() const;
|
||||
virtual String playbackLastError() const;
|
||||
virtual uint16_t playbackSampleRate() const;
|
||||
virtual uint8_t playbackBitsPerSample() const;
|
||||
virtual uint8_t playbackChannels() const;
|
||||
virtual bool playbackFormatOverridden() const;
|
||||
virtual uint32_t toneJitterUsMax() const;
|
||||
virtual uint32_t toneWriteMissCount() const;
|
||||
virtual bool isDialToneActive() const;
|
||||
virtual bool supportsFullDuplex() const;
|
||||
virtual bool isPlaying() const;
|
||||
virtual bool isSdReady() const;
|
||||
virtual bool isLittleFsReady() const;
|
||||
virtual bool isReady() const;
|
||||
virtual MediaSource lastStorageSource() const;
|
||||
virtual String lastStoragePath() const;
|
||||
virtual AudioRuntimeMetrics metrics() const;
|
||||
virtual void resetMetrics();
|
||||
virtual bool probePlaybackFileFromSource(const char* path, MediaSource source, AudioPlaybackProbeResult& out);
|
||||
virtual void tick();
|
||||
const AudioConfig& config() const;
|
||||
|
||||
private:
|
||||
class BlockingOutput : public Print {
|
||||
public:
|
||||
void setOutput(Print* out);
|
||||
size_t write(uint8_t b) override;
|
||||
size_t write(const uint8_t* data, size_t len) override;
|
||||
int availableForWrite() override;
|
||||
|
||||
private:
|
||||
Print* out_ = nullptr;
|
||||
};
|
||||
|
||||
static size_t activeChannelCount(i2s_channel_fmt_t channel_format);
|
||||
static void audioTaskFn(void* arg);
|
||||
size_t captureFromAdc(int16_t* dst, size_t samples, bool blocking);
|
||||
void initAdcDspChain(uint32_t sample_rate_hz);
|
||||
int16_t processAdcSample(int16_t raw_sample);
|
||||
void resetAdcDspState();
|
||||
float applyDcBlocker(float sample);
|
||||
float applyFirNoiseReduction(float sample);
|
||||
int16_t applyBiquadChain(float sample);
|
||||
void appendAdcFftSample(float sample);
|
||||
void runAdcFftProbe();
|
||||
void initAdcFftDspBackend();
|
||||
void deinitAdcFftDspBackend();
|
||||
void startTask();
|
||||
void stopTask();
|
||||
bool lockI2s() const;
|
||||
void unlockI2s() const;
|
||||
bool ensureSdMounted();
|
||||
bool ensureLittleFsMounted();
|
||||
bool ensureStorageForSource(MediaSource source);
|
||||
bool openPlaybackFileForSource(const char* path, MediaSource source, fs::FS*& out_fs, MediaSource& out_source);
|
||||
void stopPlaybackFile();
|
||||
bool prepareWavPlayback(File& file, const char* path);
|
||||
bool readWavHeaderInfo(
|
||||
File& file,
|
||||
audio_tools::AudioInfo& info,
|
||||
uint32_t* out_data_offset = nullptr,
|
||||
uint32_t* out_data_size = nullptr) const;
|
||||
bool isPlaybackAudioInfoSupported(const audio_tools::AudioInfo& info) const;
|
||||
audio_tools::AudioInfo resolvePlaybackFormat(const audio_tools::AudioInfo& input);
|
||||
uint32_t resolveStableSampleRate(uint32_t requested_rate_hz, uint32_t& fallback_rate_hz) const;
|
||||
void applyPlaybackAudioInfo(const audio_tools::AudioInfo& info);
|
||||
float analyzeWavLoudnessGainDb(
|
||||
File& file,
|
||||
const audio_tools::AudioInfo& input,
|
||||
uint32_t data_offset,
|
||||
uint32_t data_size,
|
||||
bool& out_limiter_active) const;
|
||||
bool decodePcmSample(const uint8_t* bytes, uint8_t bits_per_sample, int32_t& out) const;
|
||||
void updateToneJitter(uint32_t now_ms);
|
||||
void restorePlaybackAudioInfo();
|
||||
bool streamPlaybackChunk();
|
||||
bool advanceToneStep();
|
||||
bool configureWavPlaybackPipeline(const audio_tools::AudioInfo& input, const audio_tools::AudioInfo& output);
|
||||
bool loadTonePattern(ToneProfile profile, ToneEvent event);
|
||||
int16_t sampleToneWave(float& phase, uint16_t freq_hz) const;
|
||||
void updateAdcDspConfig(const AudioConfig& cfg);
|
||||
void clearToneStateIfIdle();
|
||||
|
||||
bool driver_installed_ = false;
|
||||
bool capture_active_ = false;
|
||||
uint8_t capture_clients_mask_ = 0;
|
||||
bool playing_ = false;
|
||||
bool tone_active_ = false;
|
||||
bool tone_route_active_ = false;
|
||||
uint32_t tone_state_seq_ = 0U;
|
||||
ToneProfile tone_profile_ = ToneProfile::NONE;
|
||||
ToneEvent tone_event_ = ToneEvent::NONE;
|
||||
TonePattern tone_pattern_;
|
||||
ToneStep tone_step_;
|
||||
uint8_t tone_step_index_ = 0U;
|
||||
uint32_t tone_step_remaining_frames_ = 0U;
|
||||
float tone_phase_a_ = 0.0f;
|
||||
float tone_phase_b_ = 0.0f;
|
||||
volatile bool running_task_ = false;
|
||||
float dial_tone_gain_ = 0.0f;
|
||||
uint32_t next_dial_tone_push_ms_ = 0;
|
||||
static constexpr size_t kToneLutSize = 1024U;
|
||||
bool tone_lut_ready_ = false;
|
||||
int16_t tone_lut_[kToneLutSize] = {0};
|
||||
bool sd_mount_attempted_ = false;
|
||||
bool sd_ready_ = false;
|
||||
bool littlefs_mount_attempted_ = false;
|
||||
bool littlefs_ready_ = false;
|
||||
MediaSource last_storage_source_ = MediaSource::AUTO;
|
||||
String last_storage_path_;
|
||||
File playback_file_;
|
||||
String playback_path_;
|
||||
uint32_t playback_data_remaining_ = 0;
|
||||
uint16_t playback_input_channels_ = 0;
|
||||
bool playback_audio_info_overridden_ = false;
|
||||
uint32_t playback_data_offset_ = 0;
|
||||
audio_tools::AudioInfo playback_input_audio_info_;
|
||||
audio_tools::AudioInfo default_playback_audio_info_;
|
||||
audio_tools::AudioInfo active_playback_audio_info_;
|
||||
bool playback_resampler_active_ = false;
|
||||
bool playback_channel_upmix_active_ = false;
|
||||
bool playback_loudness_auto_ = false;
|
||||
float playback_loudness_gain_db_ = 0.0f;
|
||||
bool playback_limiter_active_ = false;
|
||||
uint32_t playback_rate_fallback_ = 0;
|
||||
uint32_t playback_copy_source_bytes_ = 0U;
|
||||
uint32_t playback_copy_accepted_bytes_ = 0U;
|
||||
uint32_t playback_copy_loss_bytes_ = 0U;
|
||||
uint32_t playback_copy_loss_events_ = 0U;
|
||||
String playback_last_error_;
|
||||
uint32_t playback_next_chunk_ms_ = 0U;
|
||||
AudioConfig _config;
|
||||
FeatureMatrix features_;
|
||||
AudioRuntimeMetrics metrics_;
|
||||
int adc_capture_pin_ = -1;
|
||||
uint32_t adc_capture_sample_interval_us_ = 0;
|
||||
uint64_t next_adc_capture_us_ = 0;
|
||||
bool use_adc_capture_ = false;
|
||||
bool adc_dsp_chain_enabled_ = false;
|
||||
bool adc_fft_enabled_ = false;
|
||||
uint8_t adc_dsp_fft_downsample_ = kAdcDspDefaultFftDownsample;
|
||||
uint16_t adc_fft_ignore_low_bin_ = 1U;
|
||||
uint16_t adc_fft_ignore_high_bin_ = 1U;
|
||||
static constexpr uint32_t kAdcDspDefaultSampleRateHz = 16000U;
|
||||
static constexpr uint8_t kAdcDspDefaultFftDownsample = 2U;
|
||||
float adc_dsp_prev_input_ = 0.0f;
|
||||
float adc_dsp_prev_output_ = 0.0f;
|
||||
float adc_dsp_fir_state_[5U] = {0.0f};
|
||||
uint8_t adc_dsp_fir_pos_ = 0U;
|
||||
float adc_dsp_biquad_hp_b0_ = 1.0f;
|
||||
float adc_dsp_biquad_hp_b1_ = 0.0f;
|
||||
float adc_dsp_biquad_hp_b2_ = 0.0f;
|
||||
float adc_dsp_biquad_hp_a1_ = 0.0f;
|
||||
float adc_dsp_biquad_hp_a2_ = 0.0f;
|
||||
float adc_dsp_biquad_hp_z1_ = 0.0f;
|
||||
float adc_dsp_biquad_hp_z2_ = 0.0f;
|
||||
float adc_dsp_biquad_lp_b0_ = 1.0f;
|
||||
float adc_dsp_biquad_lp_b1_ = 0.0f;
|
||||
float adc_dsp_biquad_lp_b2_ = 0.0f;
|
||||
float adc_dsp_biquad_lp_a1_ = 0.0f;
|
||||
float adc_dsp_biquad_lp_a2_ = 0.0f;
|
||||
float adc_dsp_biquad_lp_z1_ = 0.0f;
|
||||
float adc_dsp_biquad_lp_z2_ = 0.0f;
|
||||
static constexpr size_t kAdcDspFftWindowSamples = 64U;
|
||||
float adc_dsp_fft_buffer_[kAdcDspFftWindowSamples] = {0.0f};
|
||||
uint8_t adc_dsp_fft_head_ = 0U;
|
||||
uint8_t adc_dsp_fft_fill_ = 0U;
|
||||
uint8_t adc_dsp_fft_decimator_ = 0U;
|
||||
float adc_dsp_fft_complex_buffer_[kAdcDspFftWindowSamples * 2U] = {0.0f};
|
||||
bool adc_dsp_fft_probe_enabled_ = false;
|
||||
bool adc_dsp_fft_probe_backend_ready_ = false;
|
||||
audio_tools::I2SStream i2s_stream_;
|
||||
BlockingOutput playback_blocking_output_;
|
||||
audio_tools::VolumeStream playback_volume_stream_;
|
||||
std::unique_ptr<audio_tools::ConverterScaler<int16_t>> playback_gain_scaler_;
|
||||
audio_tools::ConverterStream<int16_t> playback_gain_stream_;
|
||||
audio_tools::ResampleStream playback_resample_stream_;
|
||||
audio_tools::ChannelFormatConverterStream playback_channel_converter_stream_;
|
||||
audio_tools::WAVDecoder wav_decoder_;
|
||||
audio_tools::EncodedAudioStream wav_stream_;
|
||||
audio_tools::StreamCopy wav_copy_;
|
||||
mutable SemaphoreHandle_t i2s_io_mutex_ = nullptr;
|
||||
TaskHandle_t task_handle_ = nullptr;
|
||||
static constexpr uint16_t kAudioTaskStackWords = 4096;
|
||||
static constexpr uint8_t kAudioTaskPriority = 8;
|
||||
portMUX_TYPE capture_lock_ = portMUX_INITIALIZER_UNLOCKED;
|
||||
};
|
||||
|
||||
#endif // AUDIO_ENGINE_H
|
||||
@@ -1,66 +0,0 @@
|
||||
#include "audio/AudioManager.h"
|
||||
#include "core/AgentSupervisor.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
AudioManager::AudioManager() : initialized_(false) {}
|
||||
|
||||
void notifyAudio(const std::string& state, const std::string& error = "") {
|
||||
AgentStatus status{state, error, millis()};
|
||||
AgentSupervisor::instance().notify("audio", status);
|
||||
}
|
||||
|
||||
bool AudioManager::begin(const AudioConfig& config) {
|
||||
initialized_ = engine_.begin(config);
|
||||
notifyAudio(initialized_ ? "initialized" : "init_failed", initialized_ ? "" : "init error");
|
||||
return initialized_;
|
||||
}
|
||||
|
||||
bool AudioManager::playFile(const char* path) {
|
||||
bool ok = initialized_ && engine_.playFile(path);
|
||||
notifyAudio(ok ? "playing" : "play_failed", ok ? "" : "play error");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool AudioManager::startCapture() {
|
||||
bool ok = initialized_ && engine_.startCapture();
|
||||
notifyAudio(ok ? "capture" : "capture_failed", ok ? "" : "capture error");
|
||||
return ok;
|
||||
}
|
||||
|
||||
size_t AudioManager::readCaptureFrame(int16_t* dst, size_t samples) {
|
||||
if (!initialized_) {
|
||||
return 0;
|
||||
}
|
||||
return engine_.readCaptureFrame(dst, samples);
|
||||
}
|
||||
|
||||
void AudioManager::stopCapture() {
|
||||
if (!initialized_) {
|
||||
return;
|
||||
}
|
||||
engine_.stopCapture();
|
||||
notifyAudio("stopped");
|
||||
}
|
||||
|
||||
bool AudioManager::supportsFullDuplex() const {
|
||||
return initialized_ && engine_.supportsFullDuplex();
|
||||
}
|
||||
|
||||
bool AudioManager::isPlaying() const {
|
||||
return initialized_ && engine_.isPlaying();
|
||||
}
|
||||
|
||||
AudioRuntimeMetrics AudioManager::metrics() const {
|
||||
return engine_.metrics();
|
||||
}
|
||||
|
||||
void AudioManager::resetMetrics() {
|
||||
engine_.resetMetrics();
|
||||
}
|
||||
|
||||
void AudioManager::tick() {
|
||||
if (!initialized_) {
|
||||
return;
|
||||
}
|
||||
engine_.tick();
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#ifndef AUDIO_AUDIO_MANAGER_H
|
||||
#define AUDIO_AUDIO_MANAGER_H
|
||||
|
||||
#include "audio/AudioEngine.h"
|
||||
|
||||
class AudioManager {
|
||||
public:
|
||||
AudioManager();
|
||||
bool begin(const AudioConfig& config);
|
||||
bool playFile(const char* path);
|
||||
bool startCapture();
|
||||
size_t readCaptureFrame(int16_t* dst, size_t samples);
|
||||
void stopCapture();
|
||||
bool supportsFullDuplex() const;
|
||||
bool isPlaying() const;
|
||||
AudioRuntimeMetrics metrics() const;
|
||||
void resetMetrics();
|
||||
void tick();
|
||||
|
||||
private:
|
||||
AudioEngine engine_;
|
||||
bool initialized_;
|
||||
};
|
||||
|
||||
#endif // AUDIO_AUDIO_MANAGER_H
|
||||
@@ -1,157 +0,0 @@
|
||||
#include "audio/Es8388Driver.h"
|
||||
|
||||
#include <Wire.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <initializer_list>
|
||||
|
||||
namespace {
|
||||
constexpr float kEs8388VolumeDbMin = -96.0f;
|
||||
constexpr float kEs8388VolumeDbMax = 0.0f;
|
||||
|
||||
float percentToVolumeDb(uint8_t percent) {
|
||||
const float clamped = static_cast<float>(std::min<uint8_t>(100U, percent));
|
||||
const float normalized = clamped / 100.0f;
|
||||
// Linear in dB (perceptually logarithmic gain); 0%= -96 dB, 100%=0 dB.
|
||||
return kEs8388VolumeDbMin + (kEs8388VolumeDbMax - kEs8388VolumeDbMin) * normalized;
|
||||
}
|
||||
|
||||
uint8_t dbToVolumeReg(float db) {
|
||||
const float clamped_db = std::max(kEs8388VolumeDbMin, std::min(kEs8388VolumeDbMax, db));
|
||||
// ES8388: 0x00 = 0 dB, 0xC0 = -96 dB (0.5 dB/step).
|
||||
return static_cast<uint8_t>(std::lround((-clamped_db) * 2.0f));
|
||||
}
|
||||
|
||||
constexpr uint8_t kEs8388DacUnmuted = 0x32; // DACCONTROL3 unmute (spec baseline)
|
||||
constexpr uint8_t kEs8388DacMuted = 0x36; // DACCONTROL3 mute (spec example)
|
||||
constexpr uint8_t kEs8388DacRoute = 0xB8; // DAC->mixer baseline path
|
||||
constexpr uint8_t kEs8388Output0dB = 0x1E; // LOUT/ROUT driver volume 0dB
|
||||
} // namespace
|
||||
|
||||
bool Es8388Driver::begin(int sda_pin, int scl_pin, uint8_t address) {
|
||||
address_ = address;
|
||||
Wire.begin(sda_pin, scl_pin);
|
||||
Wire.setClock(100000);
|
||||
|
||||
const auto write_sequence = [&](std::initializer_list<std::pair<uint8_t, uint8_t>> seq) {
|
||||
bool ok = true;
|
||||
for (const auto& it : seq) {
|
||||
ok &= writeReg(it.first, it.second);
|
||||
}
|
||||
return ok;
|
||||
};
|
||||
|
||||
// ES8388 setup aligned with A1S board spec:
|
||||
// - I2C 100 kHz on SDA=33/SCL=32
|
||||
// - full-duplex I2S slave mode
|
||||
// - 16-bit, ratio 256
|
||||
// - conservative output driver values (0x1E = 0 dB)
|
||||
const bool ok = write_sequence(
|
||||
{
|
||||
{0x19, 0x04}, // DACCONTROL3: mute during init.
|
||||
{0x01, 0x50}, // CONTROL2
|
||||
{0x02, 0x00}, // CHIPPOWER normal mode
|
||||
{0x35, 0xA0}, // Disable internal DLL for low sample rates stability.
|
||||
{0x37, 0xD0},
|
||||
{0x39, 0xD0},
|
||||
{0x08, 0x00}, // MASTERMODE: codec slave
|
||||
{0x04, 0xC0}, // DACPOWER: DAC outputs disabled while configuring
|
||||
{0x00, 0x12}, // CONTROL1: play+record mode
|
||||
{0x17, 0x18}, // DACCONTROL1: 16-bit I2S
|
||||
{0x18, 0x02}, // DACCONTROL2: single speed, ratio 256
|
||||
{0x26, 0x00}, // DACCONTROL16: DAC to mixer
|
||||
{0x27, kEs8388DacRoute}, // DACCONTROL17: DAC -> mixer path (spec baseline 0xB8)
|
||||
{0x2A, kEs8388DacRoute}, // DACCONTROL20: DAC -> mixer path (spec baseline 0xB8)
|
||||
{0x2B, 0x80}, // DACCONTROL21
|
||||
{0x2D, 0x00}, // DACCONTROL23
|
||||
{0x2E, kEs8388Output0dB}, // DACCONTROL24: LOUT1 volume = 0dB
|
||||
{0x2F, kEs8388Output0dB}, // DACCONTROL25: ROUT1 volume = 0dB
|
||||
{0x30, 0x00}, // DACCONTROL26
|
||||
{0x31, 0x00}, // DACCONTROL27
|
||||
{0x04, 0x3C}, // DACPOWER: enable LOUT/ROUT
|
||||
{0x03, 0xFF}, // ADCPOWER: power down before ADC config
|
||||
{0x09, 0xBB}, // ADCCONTROL1: PGA gain defaults
|
||||
{0x0A, 0x00}, // ADCCONTROL2: LIN1/RIN1
|
||||
{0x0B, 0x02}, // ADCCONTROL3
|
||||
{0x0C, 0x0C}, // ADCCONTROL4: 16-bit I2S
|
||||
{0x0D, 0x02}, // ADCCONTROL5: single speed, ratio 256
|
||||
{0x10, 0x00}, // ADCCONTROL8: 0 dB
|
||||
{0x11, 0x00}, // ADCCONTROL9: 0 dB
|
||||
{0x03, 0x09}, // ADCPOWER: enable ADC path
|
||||
});
|
||||
|
||||
ready_ = ok;
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setMute(true);
|
||||
setVolume(volume_);
|
||||
setMute(muted_);
|
||||
setRoute(route_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Es8388Driver::setVolume(uint8_t percent) {
|
||||
volume_ = std::min<uint8_t>(100, percent);
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
const float db = percentToVolumeDb(volume_);
|
||||
const uint8_t reg = dbToVolumeReg(db);
|
||||
// DAC digital volume controls.
|
||||
return writeReg(0x1A, reg) && writeReg(0x1B, reg);
|
||||
}
|
||||
|
||||
bool Es8388Driver::setMute(bool enabled) {
|
||||
muted_ = enabled;
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
// DACCONTROL3 bit2 is mute; use spec baseline values.
|
||||
return writeReg(0x19, static_cast<uint8_t>(enabled ? kEs8388DacMuted : kEs8388DacUnmuted));
|
||||
}
|
||||
|
||||
bool Es8388Driver::setRoute(const String& route) {
|
||||
route_ = route;
|
||||
route_.toLowerCase();
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep route as metadata and ensure output path is enabled for RTC.
|
||||
if (route_ == "rtc") {
|
||||
return writeReg(0x26, 0x00) && writeReg(0x27, kEs8388DacRoute) && writeReg(0x2A, kEs8388DacRoute) &&
|
||||
writeReg(0x04, 0x3C);
|
||||
}
|
||||
if (route_ == "none") {
|
||||
return writeReg(0x04, 0xC0);
|
||||
}
|
||||
route_ = "rtc";
|
||||
return writeReg(0x26, 0x00) && writeReg(0x27, kEs8388DacRoute) && writeReg(0x2A, kEs8388DacRoute) &&
|
||||
writeReg(0x04, 0x3C);
|
||||
}
|
||||
|
||||
bool Es8388Driver::isReady() const {
|
||||
return ready_;
|
||||
}
|
||||
|
||||
uint8_t Es8388Driver::volume() const {
|
||||
return volume_;
|
||||
}
|
||||
|
||||
bool Es8388Driver::muted() const {
|
||||
return muted_;
|
||||
}
|
||||
|
||||
String Es8388Driver::route() const {
|
||||
return route_;
|
||||
}
|
||||
|
||||
bool Es8388Driver::writeReg(uint8_t reg, uint8_t value) {
|
||||
Wire.beginTransmission(address_);
|
||||
Wire.write(reg);
|
||||
Wire.write(value);
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
#ifndef AUDIO_ES8388_DRIVER_H
|
||||
#define AUDIO_ES8388_DRIVER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "config/a1s_board_pins.h"
|
||||
|
||||
class Es8388Driver {
|
||||
public:
|
||||
bool begin(int sda_pin, int scl_pin, uint8_t address = A1S_ES8388_I2C_ADDR);
|
||||
bool setVolume(uint8_t percent);
|
||||
bool setMute(bool enabled);
|
||||
bool setRoute(const String& route);
|
||||
|
||||
bool isReady() const;
|
||||
uint8_t volume() const;
|
||||
bool muted() const;
|
||||
String route() const;
|
||||
|
||||
private:
|
||||
bool writeReg(uint8_t reg, uint8_t value);
|
||||
|
||||
bool ready_ = false;
|
||||
uint8_t address_ = A1S_ES8388_I2C_ADDR;
|
||||
uint8_t volume_ = 60;
|
||||
bool muted_ = false;
|
||||
String route_ = "rtc";
|
||||
};
|
||||
|
||||
#endif // AUDIO_ES8388_DRIVER_H
|
||||
@@ -1,149 +0,0 @@
|
||||
#include "audio/ToneCatalog.h"
|
||||
|
||||
namespace {
|
||||
|
||||
#define TONE_ON1(freq_hz, ms) \
|
||||
ToneStep { static_cast<uint16_t>(freq_hz), 0U, static_cast<uint16_t>(ms), false }
|
||||
#define TONE_ON2(freq_a_hz, freq_b_hz, ms) \
|
||||
ToneStep { static_cast<uint16_t>(freq_a_hz), static_cast<uint16_t>(freq_b_hz), static_cast<uint16_t>(ms), false }
|
||||
#define TONE_OFF(ms) \
|
||||
ToneStep { 0U, 0U, static_cast<uint16_t>(ms), true }
|
||||
|
||||
constexpr ToneStep kEtsiDial[] = {TONE_ON1(425, 1000)};
|
||||
constexpr ToneStep kEtsiSecondaryDial[] = {TONE_ON1(425, 1000)};
|
||||
constexpr ToneStep kEtsiSpecialDialStutter[] = {TONE_ON1(425, 500), TONE_OFF(50)};
|
||||
constexpr ToneStep kEtsiRecallDial[] = {TONE_ON1(425, 1000)};
|
||||
constexpr ToneStep kEtsiRingback[] = {TONE_ON1(425, 1000), TONE_OFF(4000)};
|
||||
constexpr ToneStep kEtsiBusy[] = {TONE_ON1(425, 500), TONE_OFF(500)};
|
||||
constexpr ToneStep kEtsiCongestion[] = {TONE_ON1(425, 250), TONE_OFF(250)};
|
||||
constexpr ToneStep kEtsiCallWaiting[] = {TONE_ON1(425, 200), TONE_OFF(200), TONE_ON1(425, 200), TONE_OFF(3000)};
|
||||
constexpr ToneStep kEtsiConfirmation[] = {
|
||||
TONE_ON1(425, 100), TONE_OFF(100), TONE_ON1(425, 100), TONE_OFF(100), TONE_ON1(425, 100), TONE_OFF(1000)};
|
||||
constexpr ToneStep kEtsiSitIntercept[] = {
|
||||
TONE_ON1(950, 330), TONE_OFF(30), TONE_ON1(1400, 330), TONE_OFF(30), TONE_ON1(1800, 330), TONE_OFF(1000)};
|
||||
|
||||
constexpr ToneStep kFrDial[] = {TONE_ON1(440, 1000)};
|
||||
constexpr ToneStep kFrSecondaryDial[] = {TONE_ON1(440, 1000)};
|
||||
constexpr ToneStep kFrSpecialDialStutter[] = {TONE_ON1(440, 500), TONE_OFF(50)};
|
||||
constexpr ToneStep kFrRecallDial[] = {TONE_ON1(440, 1000)};
|
||||
constexpr ToneStep kFrRingback[] = {TONE_ON1(440, 1500), TONE_OFF(3500)};
|
||||
constexpr ToneStep kFrBusy[] = {TONE_ON1(440, 500), TONE_OFF(500)};
|
||||
constexpr ToneStep kFrCongestion[] = {TONE_ON1(440, 250), TONE_OFF(250)};
|
||||
constexpr ToneStep kFrCallWaiting[] = {TONE_ON1(440, 300), TONE_OFF(10000)};
|
||||
constexpr ToneStep kFrConfirmation[] = {
|
||||
TONE_ON1(440, 100), TONE_OFF(100), TONE_ON1(440, 100), TONE_OFF(100), TONE_ON1(440, 100), TONE_OFF(1000)};
|
||||
constexpr ToneStep kFrSitIntercept[] = {
|
||||
TONE_ON1(950, 300), TONE_OFF(30), TONE_ON1(1400, 300), TONE_OFF(30), TONE_ON1(1800, 300), TONE_OFF(1000)};
|
||||
|
||||
constexpr ToneStep kUkDial[] = {TONE_ON2(350, 440, 1000)};
|
||||
constexpr ToneStep kUkSecondaryDial[] = {TONE_ON2(350, 440, 1000)};
|
||||
constexpr ToneStep kUkSpecialDialStutter[] = {TONE_ON2(350, 440, 100), TONE_OFF(100)};
|
||||
constexpr ToneStep kUkRecallDial[] = {TONE_ON2(350, 440, 1000)};
|
||||
constexpr ToneStep kUkRingback[] = {TONE_ON2(400, 450, 400), TONE_OFF(200), TONE_ON2(400, 450, 400), TONE_OFF(2000)};
|
||||
constexpr ToneStep kUkBusy[] = {TONE_ON2(400, 450, 375), TONE_OFF(375)};
|
||||
constexpr ToneStep kUkCongestion[] = {TONE_ON2(400, 450, 400), TONE_OFF(400)};
|
||||
constexpr ToneStep kUkCallWaiting[] = {TONE_ON2(400, 450, 100), TONE_OFF(100), TONE_ON2(400, 450, 100), TONE_OFF(9700)};
|
||||
constexpr ToneStep kUkSitIntercept[] = {
|
||||
TONE_ON1(950, 330), TONE_OFF(30), TONE_ON1(1400, 330), TONE_OFF(30), TONE_ON1(1800, 330), TONE_OFF(1000)};
|
||||
|
||||
constexpr ToneStep kNaDial[] = {TONE_ON2(350, 440, 1000)};
|
||||
constexpr ToneStep kNaSecondaryDial[] = {TONE_ON2(350, 440, 1000)};
|
||||
constexpr ToneStep kNaSpecialDialStutter[] = {TONE_ON2(350, 440, 100), TONE_OFF(100)};
|
||||
constexpr ToneStep kNaRecallDial[] = {TONE_ON2(350, 440, 1000)};
|
||||
constexpr ToneStep kNaRingback[] = {TONE_ON2(440, 480, 2000), TONE_OFF(4000)};
|
||||
constexpr ToneStep kNaBusy[] = {TONE_ON2(480, 620, 500), TONE_OFF(500)};
|
||||
constexpr ToneStep kNaCongestion[] = {TONE_ON2(480, 620, 250), TONE_OFF(250)};
|
||||
constexpr ToneStep kNaCallWaiting[] = {TONE_ON1(440, 300), TONE_OFF(9700)};
|
||||
constexpr ToneStep kNaConfirmation[] = {TONE_ON2(350, 440, 100), TONE_OFF(100), TONE_ON2(350, 440, 100), TONE_OFF(900)};
|
||||
constexpr ToneStep kNaSitIntercept[] = {
|
||||
TONE_ON1(950, 330), TONE_OFF(30), TONE_ON1(1400, 330), TONE_OFF(30), TONE_ON1(1800, 330), TONE_OFF(1000)};
|
||||
|
||||
struct PatternEntry {
|
||||
ToneProfile profile;
|
||||
ToneEvent event;
|
||||
const ToneStep* steps;
|
||||
uint8_t count;
|
||||
bool loop;
|
||||
uint8_t loop_start;
|
||||
};
|
||||
|
||||
#define ENTRY(profile, event, arr, should_loop) \
|
||||
PatternEntry { profile, event, arr, static_cast<uint8_t>(sizeof(arr) / sizeof(arr[0])), should_loop, 0U }
|
||||
|
||||
constexpr PatternEntry kPatternTable[] = {
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::DIAL, kEtsiDial, true),
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::SECONDARY_DIAL, kEtsiSecondaryDial, true),
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::SPECIAL_DIAL_STUTTER, kEtsiSpecialDialStutter, true),
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::RECALL_DIAL, kEtsiRecallDial, true),
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::RINGBACK, kEtsiRingback, true),
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::BUSY, kEtsiBusy, true),
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::CONGESTION, kEtsiCongestion, true),
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::CALL_WAITING, kEtsiCallWaiting, true),
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::CONFIRMATION, kEtsiConfirmation, false),
|
||||
ENTRY(ToneProfile::ETSI_EU, ToneEvent::SIT_INTERCEPT, kEtsiSitIntercept, true),
|
||||
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::DIAL, kFrDial, true),
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::SECONDARY_DIAL, kFrSecondaryDial, true),
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::SPECIAL_DIAL_STUTTER, kFrSpecialDialStutter, true),
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::RECALL_DIAL, kFrRecallDial, true),
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::RINGBACK, kFrRingback, true),
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::BUSY, kFrBusy, true),
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::CONGESTION, kFrCongestion, true),
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::CALL_WAITING, kFrCallWaiting, true),
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::CONFIRMATION, kFrConfirmation, false),
|
||||
ENTRY(ToneProfile::FR_FR, ToneEvent::SIT_INTERCEPT, kFrSitIntercept, true),
|
||||
|
||||
ENTRY(ToneProfile::UK_GB, ToneEvent::DIAL, kUkDial, true),
|
||||
ENTRY(ToneProfile::UK_GB, ToneEvent::SECONDARY_DIAL, kUkSecondaryDial, true),
|
||||
ENTRY(ToneProfile::UK_GB, ToneEvent::SPECIAL_DIAL_STUTTER, kUkSpecialDialStutter, true),
|
||||
ENTRY(ToneProfile::UK_GB, ToneEvent::RECALL_DIAL, kUkRecallDial, true),
|
||||
ENTRY(ToneProfile::UK_GB, ToneEvent::RINGBACK, kUkRingback, true),
|
||||
ENTRY(ToneProfile::UK_GB, ToneEvent::BUSY, kUkBusy, true),
|
||||
ENTRY(ToneProfile::UK_GB, ToneEvent::CONGESTION, kUkCongestion, true),
|
||||
ENTRY(ToneProfile::UK_GB, ToneEvent::CALL_WAITING, kUkCallWaiting, true),
|
||||
ENTRY(ToneProfile::UK_GB, ToneEvent::SIT_INTERCEPT, kUkSitIntercept, true),
|
||||
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::DIAL, kNaDial, true),
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::SECONDARY_DIAL, kNaSecondaryDial, true),
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::SPECIAL_DIAL_STUTTER, kNaSpecialDialStutter, true),
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::RECALL_DIAL, kNaRecallDial, true),
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::RINGBACK, kNaRingback, true),
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::BUSY, kNaBusy, true),
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::CONGESTION, kNaCongestion, true),
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::CALL_WAITING, kNaCallWaiting, true),
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::CONFIRMATION, kNaConfirmation, false),
|
||||
ENTRY(ToneProfile::NA_US, ToneEvent::SIT_INTERCEPT, kNaSitIntercept, true),
|
||||
};
|
||||
|
||||
bool lookupPattern(ToneProfile profile, ToneEvent event, TonePattern& out_pattern) {
|
||||
for (const PatternEntry& entry : kPatternTable) {
|
||||
if (entry.profile != profile || entry.event != event) {
|
||||
continue;
|
||||
}
|
||||
out_pattern.steps = entry.steps;
|
||||
out_pattern.step_count = entry.count;
|
||||
out_pattern.loop = entry.loop;
|
||||
out_pattern.loop_start_index = entry.loop_start;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ToneCatalog::resolve(ToneProfile profile, ToneEvent event, TonePattern& out_pattern) {
|
||||
out_pattern = TonePattern{};
|
||||
if (event == ToneEvent::NONE) {
|
||||
return false;
|
||||
}
|
||||
if (profile == ToneProfile::NONE) {
|
||||
profile = ToneProfile::FR_FR;
|
||||
}
|
||||
if (lookupPattern(profile, event, out_pattern)) {
|
||||
return true;
|
||||
}
|
||||
if (lookupPattern(ToneProfile::ETSI_EU, event, out_pattern)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
#ifndef AUDIO_TONE_CATALOG_H
|
||||
#define AUDIO_TONE_CATALOG_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "media/MediaRouting.h"
|
||||
|
||||
struct ToneStep {
|
||||
uint16_t freq_a_hz = 0;
|
||||
uint16_t freq_b_hz = 0;
|
||||
uint16_t duration_ms = 0;
|
||||
bool silence = true;
|
||||
|
||||
constexpr ToneStep() = default;
|
||||
constexpr ToneStep(uint16_t freq_a, uint16_t freq_b, uint16_t duration, bool is_silence)
|
||||
: freq_a_hz(freq_a), freq_b_hz(freq_b), duration_ms(duration), silence(is_silence) {}
|
||||
};
|
||||
|
||||
struct TonePattern {
|
||||
const ToneStep* steps = nullptr;
|
||||
uint8_t step_count = 0;
|
||||
bool loop = false;
|
||||
uint8_t loop_start_index = 0;
|
||||
};
|
||||
|
||||
class ToneCatalog {
|
||||
public:
|
||||
static bool resolve(ToneProfile profile, ToneEvent event, TonePattern& out_pattern);
|
||||
};
|
||||
|
||||
#endif // AUDIO_TONE_CATALOG_H
|
||||
@@ -1,954 +0,0 @@
|
||||
#include "config/A252ConfigStore.h"
|
||||
|
||||
#include <Preferences.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/PlatformProfile.h"
|
||||
|
||||
namespace {
|
||||
constexpr const char* kPinsNs = "a252-pins";
|
||||
constexpr const char* kAudioNs = "a252-audio";
|
||||
constexpr const char* kEspNowNs = "espnow";
|
||||
constexpr const char* kEspNowCallMapNs = "espnow-call";
|
||||
constexpr const char* kDialMediaMapNs = "dial-media";
|
||||
constexpr uint16_t kMaxPlaybackPauseMs = 10000U;
|
||||
constexpr const char* kEspNowKeyPeers = "peers";
|
||||
constexpr const char* kEspNowKeyDeviceName = "dev_name";
|
||||
constexpr const char* kDefaultEspNowDeviceName = "HOTLINE_PHONE";
|
||||
|
||||
// NVS keys are limited to 15 visible chars on ESP32 Preferences/NVS.
|
||||
constexpr const char* kAudioKeySampleRate = "sr";
|
||||
constexpr const char* kAudioKeyBitsPerSample = "bits";
|
||||
constexpr const char* kAudioKeyEnableCapture = "capture";
|
||||
constexpr const char* kAudioKeyAdcDspEnabled = "adc_dsp";
|
||||
constexpr const char* kAudioKeyAdcFftEnabled = "adc_fft";
|
||||
constexpr const char* kAudioKeyAdcDspFftDownsample = "adc_fft_ds";
|
||||
constexpr const char* kAudioKeyAdcFftIgnoreLowBin = "adc_fft_lo";
|
||||
constexpr const char* kAudioKeyAdcFftIgnoreHighBin = "adc_fft_hi";
|
||||
constexpr const char* kAudioKeyVolume = "vol";
|
||||
constexpr const char* kAudioKeyRoute = "route";
|
||||
constexpr const char* kAudioKeyMute = "mute";
|
||||
constexpr const char* kAudioKeyClockPolicy = "clock_policy";
|
||||
constexpr const char* kAudioKeyWavLoudnessPolicy = "wav_loud_pol";
|
||||
constexpr const char* kAudioKeyWavTargetRmsDbfs = "wav_rms_dbfs";
|
||||
constexpr const char* kAudioKeyWavLimiterCeilingDbfs = "wav_ceil_db";
|
||||
constexpr const char* kAudioKeyWavLimiterAttackMs = "wav_attack_ms";
|
||||
constexpr const char* kAudioKeyWavLimiterReleaseMs = "wav_release_ms";
|
||||
constexpr int kMaxGpioA252 = 39;
|
||||
constexpr int kMaxGpioS3 = 48;
|
||||
|
||||
int maxAllowedPinForProfile(BoardProfile profile) {
|
||||
return profile == BoardProfile::ESP32_S3 ? kMaxGpioS3 : kMaxGpioA252;
|
||||
}
|
||||
|
||||
bool saveString(Preferences& prefs, const char* key, const String& value) {
|
||||
return prefs.putString(key, value) > 0U;
|
||||
}
|
||||
|
||||
bool saveUChar(Preferences& prefs, const char* key, uint8_t value) {
|
||||
return prefs.putUChar(key, value) == 1U;
|
||||
}
|
||||
|
||||
bool saveUInt(Preferences& prefs, const char* key, uint32_t value) {
|
||||
return prefs.putUInt(key, value) == sizeof(uint32_t);
|
||||
}
|
||||
|
||||
bool saveInt(Preferences& prefs, const char* key, int32_t value) {
|
||||
return prefs.putInt(key, value) == sizeof(int32_t);
|
||||
}
|
||||
|
||||
bool saveBool(Preferences& prefs, const char* key, bool value) {
|
||||
return prefs.putBool(key, value) == 1U;
|
||||
}
|
||||
|
||||
bool loadJsonArray(const String& raw, JsonDocument& doc) {
|
||||
if (raw.isEmpty()) {
|
||||
doc.to<JsonArray>();
|
||||
return true;
|
||||
}
|
||||
const auto err = deserializeJson(doc, raw);
|
||||
return err == DeserializationError::Ok && doc.is<JsonArray>();
|
||||
}
|
||||
|
||||
bool loadJsonObject(const String& raw, JsonDocument& doc) {
|
||||
if (raw.isEmpty()) {
|
||||
doc.to<JsonObject>();
|
||||
return true;
|
||||
}
|
||||
const auto err = deserializeJson(doc, raw);
|
||||
return err == DeserializationError::Ok && doc.is<JsonObject>();
|
||||
}
|
||||
|
||||
String normalizeEspNowCallKeyword(const String& keyword) {
|
||||
String normalized = keyword;
|
||||
normalized.trim();
|
||||
normalized.toUpperCase();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
void mergeCallMapEntry(EspNowCallMap& map, const String& keyword, const MediaRouteEntry& route) {
|
||||
const String normalized_keyword = normalizeEspNowCallKeyword(keyword);
|
||||
if (normalized_keyword.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!mediaRouteHasPayload(route)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (EspNowCallMapEntry& entry : map) {
|
||||
if (entry.keyword == normalized_keyword) {
|
||||
entry.route = route;
|
||||
return;
|
||||
}
|
||||
}
|
||||
EspNowCallMapEntry created;
|
||||
created.keyword = normalized_keyword;
|
||||
created.route = route;
|
||||
map.push_back(created);
|
||||
}
|
||||
|
||||
void mergeDialMediaMapEntry(DialMediaMap& map, const String& number, const MediaRouteEntry& route) {
|
||||
String normalized_number = number;
|
||||
normalized_number.trim();
|
||||
if (normalized_number.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!mediaRouteHasPayload(route)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (DialMediaMapEntry& entry : map) {
|
||||
if (entry.number == normalized_number) {
|
||||
entry.route = route;
|
||||
return;
|
||||
}
|
||||
}
|
||||
DialMediaMapEntry created;
|
||||
created.number = normalized_number;
|
||||
created.route = route;
|
||||
map.push_back(created);
|
||||
}
|
||||
|
||||
bool parseMediaRouteEntry(JsonVariantConst value, MediaRouteEntry& out) {
|
||||
out = MediaRouteEntry{};
|
||||
if (value.is<const char*>()) {
|
||||
out.kind = MediaRouteKind::FILE;
|
||||
out.path = sanitizeMediaPath(value.as<const char*>());
|
||||
out.source = MediaSource::AUTO;
|
||||
return !out.path.isEmpty();
|
||||
}
|
||||
if (!value.is<JsonObjectConst>()) {
|
||||
return false;
|
||||
}
|
||||
JsonObjectConst obj = value.as<JsonObjectConst>();
|
||||
|
||||
MediaRouteKind kind = MediaRouteKind::FILE;
|
||||
if (obj["kind"].is<const char*>()) {
|
||||
if (!parseMediaRouteKind(obj["kind"].as<const char*>(), kind)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
out.kind = kind;
|
||||
|
||||
if (kind == MediaRouteKind::TONE) {
|
||||
if (!obj["profile"].is<const char*>() || !obj["event"].is<const char*>()) {
|
||||
return false;
|
||||
}
|
||||
if (!parseToneProfile(obj["profile"].as<const char*>(), out.tone.profile)) {
|
||||
return false;
|
||||
}
|
||||
if (!parseToneEvent(obj["event"].as<const char*>(), out.tone.event)) {
|
||||
return false;
|
||||
}
|
||||
if (out.tone.profile == ToneProfile::NONE || out.tone.event == ToneEvent::NONE) {
|
||||
return false;
|
||||
}
|
||||
out.path = "";
|
||||
out.source = MediaSource::AUTO;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!obj["path"].is<const char*>()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out.path = sanitizeMediaPath(obj["path"].as<const char*>());
|
||||
if (out.path.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out.source = MediaSource::AUTO;
|
||||
if (obj["source"].is<const char*>()) {
|
||||
MediaSource parsed = MediaSource::AUTO;
|
||||
if (!parseMediaSource(obj["source"].as<const char*>(), parsed)) {
|
||||
return false;
|
||||
}
|
||||
out.source = parsed;
|
||||
}
|
||||
|
||||
bool loop = false;
|
||||
if (obj["playback"]["loop"].is<bool>()) {
|
||||
loop = obj["playback"]["loop"].as<bool>();
|
||||
} else if (obj["loop"].is<bool>()) {
|
||||
loop = obj["loop"].as<bool>();
|
||||
}
|
||||
|
||||
uint16_t pause_ms = 0U;
|
||||
if (obj["playback"]["pause_ms"].is<int>()) {
|
||||
const int raw = obj["playback"]["pause_ms"].as<int>();
|
||||
if (raw < 0 || raw > static_cast<int>(kMaxPlaybackPauseMs)) {
|
||||
return false;
|
||||
}
|
||||
pause_ms = static_cast<uint16_t>(raw);
|
||||
} else if (obj["pause_ms"].is<int>()) {
|
||||
const int raw = obj["pause_ms"].as<int>();
|
||||
if (raw < 0 || raw > static_cast<int>(kMaxPlaybackPauseMs)) {
|
||||
return false;
|
||||
}
|
||||
pause_ms = static_cast<uint16_t>(raw);
|
||||
}
|
||||
|
||||
out.playback.loop = loop;
|
||||
out.playback.pause_ms = pause_ms;
|
||||
return true;
|
||||
}
|
||||
|
||||
void writeMediaRouteToObject(JsonObject obj, const char* key, const MediaRouteEntry& route) {
|
||||
if (key == nullptr || key[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
if (route.kind == MediaRouteKind::TONE) {
|
||||
JsonObject tone_obj = obj[key].to<JsonObject>();
|
||||
tone_obj["kind"] = "tone";
|
||||
tone_obj["profile"] = toneProfileToString(route.tone.profile);
|
||||
tone_obj["event"] = toneEventToString(route.tone.event);
|
||||
return;
|
||||
}
|
||||
const bool has_playback_policy = route.playback.loop || route.playback.pause_ms > 0U;
|
||||
if (route.source == MediaSource::AUTO && !has_playback_policy) {
|
||||
obj[key] = route.path;
|
||||
return;
|
||||
}
|
||||
JsonObject route_obj = obj[key].to<JsonObject>();
|
||||
route_obj["kind"] = "file";
|
||||
route_obj["path"] = route.path;
|
||||
if (route.source != MediaSource::AUTO) {
|
||||
route_obj["source"] = mediaSourceToString(route.source);
|
||||
}
|
||||
if (has_playback_policy) {
|
||||
JsonObject playback = route_obj["playback"].to<JsonObject>();
|
||||
playback["loop"] = route.playback.loop;
|
||||
playback["pause_ms"] = route.playback.pause_ms;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
A252PinsConfig A252ConfigStore::defaultPins() {
|
||||
A252PinsConfig cfg;
|
||||
if (detectBoardProfile() == BoardProfile::ESP32_S3) {
|
||||
cfg.i2s_bck = 40;
|
||||
cfg.i2s_ws = 41;
|
||||
cfg.i2s_dout = 42;
|
||||
cfg.i2s_din = 39;
|
||||
cfg.es8388_sda = -1;
|
||||
cfg.es8388_scl = -1;
|
||||
cfg.slic_rm = 32;
|
||||
cfg.slic_fr = 5;
|
||||
cfg.slic_shk = 23;
|
||||
cfg.slic_pd = 14;
|
||||
cfg.slic_adc_in = 34;
|
||||
cfg.hook_active_high = true;
|
||||
cfg.pcm_flt = -1;
|
||||
cfg.pcm_demp = -1;
|
||||
cfg.pcm_xsmt = -1;
|
||||
cfg.pcm_fmt = -1;
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
S3PinsConfig A252ConfigStore::defaultS3Pins() {
|
||||
return defaultPins();
|
||||
}
|
||||
|
||||
A252AudioConfig A252ConfigStore::defaultAudio() {
|
||||
return A252AudioConfig{};
|
||||
}
|
||||
|
||||
S3AudioConfig A252ConfigStore::defaultS3Audio() {
|
||||
return defaultAudio();
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadPins(A252PinsConfig& out) {
|
||||
out = defaultPins();
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kPinsNs, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out.i2s_bck = prefs.getInt("i2s_bck", out.i2s_bck);
|
||||
out.i2s_ws = prefs.getInt("i2s_ws", out.i2s_ws);
|
||||
out.i2s_dout = prefs.getInt("i2s_dout", out.i2s_dout);
|
||||
out.i2s_din = prefs.getInt("i2s_din", out.i2s_din);
|
||||
|
||||
out.es8388_sda = prefs.getInt("i2c_sda", out.es8388_sda);
|
||||
out.es8388_scl = prefs.getInt("i2c_scl", out.es8388_scl);
|
||||
|
||||
out.slic_rm = prefs.getInt("slic_rm", out.slic_rm);
|
||||
out.slic_fr = prefs.getInt("slic_fr", out.slic_fr);
|
||||
out.slic_shk = prefs.getInt("slic_shk", out.slic_shk);
|
||||
out.slic_line = prefs.getInt("slic_line", out.slic_line);
|
||||
out.slic_pd = prefs.getInt("slic_pd", out.slic_pd);
|
||||
out.slic_adc_in = prefs.getInt("slic_adc_in", out.slic_adc_in);
|
||||
out.hook_active_high = prefs.getBool("hook_hi", out.hook_active_high);
|
||||
out.pcm_flt = prefs.getInt("pcm_flt", out.pcm_flt);
|
||||
out.pcm_demp = prefs.getInt("pcm_demp", out.pcm_demp);
|
||||
out.pcm_xsmt = prefs.getInt("pcm_xsmt", out.pcm_xsmt);
|
||||
out.pcm_fmt = prefs.getInt("pcm_fmt", out.pcm_fmt);
|
||||
prefs.end();
|
||||
|
||||
String error;
|
||||
if (!validatePins(out, error)) {
|
||||
out = defaultPins();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadS3Pins(S3PinsConfig& out) {
|
||||
return loadPins(out);
|
||||
}
|
||||
|
||||
bool A252ConfigStore::saveS3Pins(const S3PinsConfig& cfg, String* error) {
|
||||
return savePins(cfg, error);
|
||||
}
|
||||
|
||||
bool A252ConfigStore::savePins(const A252PinsConfig& cfg, String* error) {
|
||||
String local_error;
|
||||
if (!validatePins(cfg, local_error)) {
|
||||
if (error) {
|
||||
*error = local_error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kPinsNs, false)) {
|
||||
if (error) {
|
||||
*error = "nvs_open_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
prefs.putInt("i2s_bck", cfg.i2s_bck);
|
||||
prefs.putInt("i2s_ws", cfg.i2s_ws);
|
||||
prefs.putInt("i2s_dout", cfg.i2s_dout);
|
||||
prefs.putInt("i2s_din", cfg.i2s_din);
|
||||
|
||||
prefs.putInt("i2c_sda", cfg.es8388_sda);
|
||||
prefs.putInt("i2c_scl", cfg.es8388_scl);
|
||||
|
||||
prefs.putInt("slic_rm", cfg.slic_rm);
|
||||
prefs.putInt("slic_fr", cfg.slic_fr);
|
||||
prefs.putInt("slic_shk", cfg.slic_shk);
|
||||
prefs.putInt("slic_line", cfg.slic_line);
|
||||
prefs.putInt("slic_pd", cfg.slic_pd);
|
||||
prefs.putInt("slic_adc_in", cfg.slic_adc_in);
|
||||
prefs.putBool("hook_hi", cfg.hook_active_high);
|
||||
prefs.putInt("pcm_flt", cfg.pcm_flt);
|
||||
prefs.putInt("pcm_demp", cfg.pcm_demp);
|
||||
prefs.putInt("pcm_xsmt", cfg.pcm_xsmt);
|
||||
prefs.putInt("pcm_fmt", cfg.pcm_fmt);
|
||||
prefs.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadAudio(A252AudioConfig& out) {
|
||||
out = defaultAudio();
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kAudioNs, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out.sample_rate = prefs.getUInt(kAudioKeySampleRate, out.sample_rate);
|
||||
out.bits_per_sample = static_cast<uint8_t>(prefs.getUChar(kAudioKeyBitsPerSample, out.bits_per_sample));
|
||||
out.enable_capture = prefs.getBool(kAudioKeyEnableCapture, out.enable_capture);
|
||||
out.adc_dsp_enabled = prefs.getBool(kAudioKeyAdcDspEnabled, out.adc_dsp_enabled);
|
||||
out.adc_fft_enabled = prefs.getBool(kAudioKeyAdcFftEnabled, out.adc_fft_enabled);
|
||||
out.adc_dsp_fft_downsample = static_cast<uint8_t>(prefs.getUChar(kAudioKeyAdcDspFftDownsample, out.adc_dsp_fft_downsample));
|
||||
out.adc_fft_ignore_low_bin =
|
||||
static_cast<uint16_t>(prefs.getUInt(kAudioKeyAdcFftIgnoreLowBin, out.adc_fft_ignore_low_bin));
|
||||
out.adc_fft_ignore_high_bin =
|
||||
static_cast<uint16_t>(prefs.getUInt(kAudioKeyAdcFftIgnoreHighBin, out.adc_fft_ignore_high_bin));
|
||||
out.volume = static_cast<uint8_t>(prefs.getUChar(kAudioKeyVolume, out.volume));
|
||||
out.mute = prefs.getBool(kAudioKeyMute, out.mute);
|
||||
if (prefs.isKey(kAudioKeyRoute)) {
|
||||
out.route = prefs.getString(kAudioKeyRoute, out.route);
|
||||
}
|
||||
if (prefs.isKey(kAudioKeyClockPolicy)) {
|
||||
out.clock_policy = prefs.getString(kAudioKeyClockPolicy, out.clock_policy);
|
||||
}
|
||||
if (prefs.isKey(kAudioKeyWavLoudnessPolicy)) {
|
||||
out.wav_loudness_policy = prefs.getString(kAudioKeyWavLoudnessPolicy, out.wav_loudness_policy);
|
||||
}
|
||||
out.wav_target_rms_dbfs = static_cast<int16_t>(prefs.getInt(kAudioKeyWavTargetRmsDbfs, out.wav_target_rms_dbfs));
|
||||
out.wav_limiter_ceiling_dbfs =
|
||||
static_cast<int16_t>(prefs.getInt(kAudioKeyWavLimiterCeilingDbfs, out.wav_limiter_ceiling_dbfs));
|
||||
out.wav_limiter_attack_ms = static_cast<uint16_t>(prefs.getUInt(kAudioKeyWavLimiterAttackMs, out.wav_limiter_attack_ms));
|
||||
out.wav_limiter_release_ms = static_cast<uint16_t>(prefs.getUInt(kAudioKeyWavLimiterReleaseMs, out.wav_limiter_release_ms));
|
||||
prefs.end();
|
||||
|
||||
String error;
|
||||
if (!validateAudio(out, error)) {
|
||||
out = defaultAudio();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadS3Audio(S3AudioConfig& out) {
|
||||
return loadAudio(out);
|
||||
}
|
||||
|
||||
bool A252ConfigStore::saveS3Audio(const S3AudioConfig& cfg, String* error) {
|
||||
return saveAudio(cfg, error);
|
||||
}
|
||||
|
||||
bool A252ConfigStore::saveAudio(const A252AudioConfig& cfg, String* error) {
|
||||
String local_error;
|
||||
if (!validateAudio(cfg, local_error)) {
|
||||
if (error) {
|
||||
*error = local_error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kAudioNs, false)) {
|
||||
if (error) {
|
||||
*error = "nvs_open_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
ok = ok && saveUInt(prefs, kAudioKeySampleRate, cfg.sample_rate);
|
||||
ok = ok && saveUChar(prefs, kAudioKeyBitsPerSample, cfg.bits_per_sample);
|
||||
ok = ok && saveBool(prefs, kAudioKeyEnableCapture, cfg.enable_capture);
|
||||
ok = ok && saveBool(prefs, kAudioKeyAdcDspEnabled, cfg.adc_dsp_enabled);
|
||||
ok = ok && saveBool(prefs, kAudioKeyAdcFftEnabled, cfg.adc_fft_enabled);
|
||||
ok = ok && saveUChar(prefs, kAudioKeyAdcDspFftDownsample, cfg.adc_dsp_fft_downsample);
|
||||
ok = ok && saveUInt(prefs, kAudioKeyAdcFftIgnoreLowBin, cfg.adc_fft_ignore_low_bin);
|
||||
ok = ok && saveUInt(prefs, kAudioKeyAdcFftIgnoreHighBin, cfg.adc_fft_ignore_high_bin);
|
||||
ok = ok && saveUChar(prefs, kAudioKeyVolume, cfg.volume);
|
||||
ok = ok && saveString(prefs, kAudioKeyRoute, cfg.route);
|
||||
ok = ok && saveBool(prefs, kAudioKeyMute, cfg.mute);
|
||||
ok = ok && saveString(prefs, kAudioKeyClockPolicy, cfg.clock_policy);
|
||||
ok = ok && saveString(prefs, kAudioKeyWavLoudnessPolicy, cfg.wav_loudness_policy);
|
||||
ok = ok && saveInt(prefs, kAudioKeyWavTargetRmsDbfs, cfg.wav_target_rms_dbfs);
|
||||
ok = ok && saveInt(prefs, kAudioKeyWavLimiterCeilingDbfs, cfg.wav_limiter_ceiling_dbfs);
|
||||
ok = ok && saveUInt(prefs, kAudioKeyWavLimiterAttackMs, cfg.wav_limiter_attack_ms);
|
||||
ok = ok && saveUInt(prefs, kAudioKeyWavLimiterReleaseMs, cfg.wav_limiter_release_ms);
|
||||
prefs.end();
|
||||
if (!ok) {
|
||||
if (error) {
|
||||
*error = "nvs_write_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadEspNowPeers(EspNowPeerStore& out) {
|
||||
out.peers.clear();
|
||||
out.device_name = kDefaultEspNowDeviceName;
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kEspNowNs, false)) {
|
||||
return false;
|
||||
}
|
||||
const String raw = prefs.isKey(kEspNowKeyPeers) ? prefs.getString(kEspNowKeyPeers, "[]") : String("[]");
|
||||
if (prefs.isKey(kEspNowKeyDeviceName)) {
|
||||
const String normalized_name = normalizeDeviceName(prefs.getString(kEspNowKeyDeviceName, kDefaultEspNowDeviceName));
|
||||
if (!normalized_name.isEmpty()) {
|
||||
out.device_name = normalized_name;
|
||||
}
|
||||
}
|
||||
prefs.end();
|
||||
|
||||
JsonDocument doc;
|
||||
if (!loadJsonArray(raw, doc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const JsonVariantConst item : doc.as<JsonArrayConst>()) {
|
||||
if (!item.is<const char*>()) {
|
||||
continue;
|
||||
}
|
||||
const String norm = normalizeMac(item.as<const char*>());
|
||||
if (norm.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (std::find(out.peers.begin(), out.peers.end(), norm) == out.peers.end()) {
|
||||
out.peers.push_back(norm);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadEspNowCallMap(EspNowCallMap& out) {
|
||||
out.clear();
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kEspNowCallMapNs, false)) {
|
||||
return false;
|
||||
}
|
||||
const String raw = prefs.isKey("mappings") ? prefs.getString("mappings", "{}") : String("{}");
|
||||
prefs.end();
|
||||
|
||||
JsonDocument doc;
|
||||
if (!loadJsonObject(raw, doc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
for (JsonPair item : obj) {
|
||||
MediaRouteEntry route;
|
||||
if (!parseMediaRouteEntry(item.value(), route)) {
|
||||
continue;
|
||||
}
|
||||
const String key = item.key().c_str();
|
||||
mergeCallMapEntry(out, key, route);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::saveEspNowCallMap(const EspNowCallMap& map, String* error) {
|
||||
JsonDocument doc;
|
||||
JsonObject obj = doc.to<JsonObject>();
|
||||
for (const EspNowCallMapEntry& entry : map) {
|
||||
if (entry.keyword.isEmpty() || !mediaRouteHasPayload(entry.route)) {
|
||||
continue;
|
||||
}
|
||||
writeMediaRouteToObject(obj, entry.keyword.c_str(), entry.route);
|
||||
}
|
||||
|
||||
String raw;
|
||||
serializeJson(obj, raw);
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kEspNowCallMapNs, false)) {
|
||||
if (error) {
|
||||
*error = "nvs_open_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const bool ok = prefs.putString("mappings", raw) >= 0;
|
||||
prefs.end();
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadDialMediaMap(DialMediaMap& out) {
|
||||
out.clear();
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kDialMediaMapNs, false)) {
|
||||
return false;
|
||||
}
|
||||
const String raw = prefs.isKey("mappings") ? prefs.getString("mappings", "{}") : String("{}");
|
||||
prefs.end();
|
||||
|
||||
JsonDocument doc;
|
||||
if (!loadJsonObject(raw, doc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
for (JsonPair item : obj) {
|
||||
MediaRouteEntry route;
|
||||
if (!parseMediaRouteEntry(item.value(), route)) {
|
||||
continue;
|
||||
}
|
||||
mergeDialMediaMapEntry(out, item.key().c_str(), route);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::saveDialMediaMap(const DialMediaMap& map, String* error) {
|
||||
JsonDocument doc;
|
||||
JsonObject obj = doc.to<JsonObject>();
|
||||
for (const DialMediaMapEntry& entry : map) {
|
||||
if (entry.number.isEmpty() || !mediaRouteHasPayload(entry.route)) {
|
||||
continue;
|
||||
}
|
||||
writeMediaRouteToObject(obj, entry.number.c_str(), entry.route);
|
||||
}
|
||||
|
||||
String raw;
|
||||
serializeJson(obj, raw);
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kDialMediaMapNs, false)) {
|
||||
if (error) {
|
||||
*error = "nvs_open_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const bool ok = prefs.putString("mappings", raw) >= 0;
|
||||
prefs.end();
|
||||
return ok;
|
||||
}
|
||||
|
||||
void A252ConfigStore::espNowCallMapToJson(const EspNowCallMap& map, JsonObject obj) {
|
||||
for (const EspNowCallMapEntry& entry : map) {
|
||||
if (entry.keyword.isEmpty() || !mediaRouteHasPayload(entry.route)) {
|
||||
continue;
|
||||
}
|
||||
writeMediaRouteToObject(obj, entry.keyword.c_str(), entry.route);
|
||||
}
|
||||
}
|
||||
|
||||
void A252ConfigStore::dialMediaMapToJson(const DialMediaMap& map, JsonObject obj) {
|
||||
for (const DialMediaMapEntry& entry : map) {
|
||||
if (entry.number.isEmpty() || !mediaRouteHasPayload(entry.route)) {
|
||||
continue;
|
||||
}
|
||||
writeMediaRouteToObject(obj, entry.number.c_str(), entry.route);
|
||||
}
|
||||
}
|
||||
|
||||
bool A252ConfigStore::saveEspNowPeers(const EspNowPeerStore& store, String* error) {
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc.to<JsonArray>();
|
||||
for (const String& peer : store.peers) {
|
||||
if (!normalizeMac(peer).isEmpty()) {
|
||||
arr.add(normalizeMac(peer));
|
||||
}
|
||||
}
|
||||
|
||||
String raw;
|
||||
serializeJson(arr, raw);
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kEspNowNs, false)) {
|
||||
if (error) {
|
||||
*error = "nvs_open_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const String normalized_name = normalizeDeviceName(store.device_name);
|
||||
const String device_name = normalized_name.isEmpty() ? String(kDefaultEspNowDeviceName) : normalized_name;
|
||||
|
||||
bool ok = true;
|
||||
ok = ok && saveString(prefs, kEspNowKeyPeers, raw);
|
||||
ok = ok && saveString(prefs, kEspNowKeyDeviceName, device_name);
|
||||
prefs.end();
|
||||
if (!ok) {
|
||||
if (error) {
|
||||
*error = "nvs_write_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
|
||||
std::vector<int> used;
|
||||
used.reserve(14);
|
||||
const int max_gpio = maxAllowedPinForProfile(detectBoardProfile());
|
||||
|
||||
const int required_pins[] = {
|
||||
cfg.i2s_bck,
|
||||
cfg.i2s_ws,
|
||||
cfg.i2s_dout,
|
||||
cfg.i2s_din,
|
||||
cfg.slic_rm,
|
||||
cfg.slic_fr,
|
||||
cfg.slic_shk,
|
||||
cfg.slic_pd,
|
||||
};
|
||||
|
||||
const int optional_pins[] = {
|
||||
cfg.slic_adc_in,
|
||||
cfg.pcm_flt,
|
||||
cfg.pcm_demp,
|
||||
cfg.pcm_xsmt,
|
||||
cfg.pcm_fmt,
|
||||
};
|
||||
|
||||
for (int pin : required_pins) {
|
||||
if (pin < 0 || pin > max_gpio) {
|
||||
error = "invalid_pin_range";
|
||||
return false;
|
||||
}
|
||||
if (std::find(used.begin(), used.end(), pin) != used.end()) {
|
||||
error = "pin_conflict";
|
||||
return false;
|
||||
}
|
||||
used.push_back(pin);
|
||||
}
|
||||
|
||||
for (int pin : optional_pins) {
|
||||
if (pin == -1) {
|
||||
continue;
|
||||
}
|
||||
if (pin < 0 || pin > max_gpio) {
|
||||
error = "invalid_pin_range";
|
||||
return false;
|
||||
}
|
||||
if (std::find(used.begin(), used.end(), pin) != used.end()) {
|
||||
error = "pin_conflict";
|
||||
return false;
|
||||
}
|
||||
used.push_back(pin);
|
||||
}
|
||||
|
||||
if (detectBoardProfile() == BoardProfile::ESP32_A252) {
|
||||
if (cfg.es8388_sda < 0 || cfg.es8388_scl < 0) {
|
||||
error = "invalid_pin_range";
|
||||
return false;
|
||||
}
|
||||
if (cfg.es8388_sda == cfg.es8388_scl) {
|
||||
error = "pin_conflict";
|
||||
return false;
|
||||
}
|
||||
if (cfg.es8388_sda < 0 || cfg.es8388_sda > max_gpio || cfg.es8388_scl < 0 || cfg.es8388_scl > max_gpio) {
|
||||
error = "invalid_pin_range";
|
||||
return false;
|
||||
}
|
||||
if (std::find(used.begin(), used.end(), cfg.es8388_sda) != used.end() ||
|
||||
std::find(used.begin(), used.end(), cfg.es8388_scl) != used.end()) {
|
||||
error = "pin_conflict";
|
||||
return false;
|
||||
}
|
||||
used.push_back(cfg.es8388_sda);
|
||||
used.push_back(cfg.es8388_scl);
|
||||
} else {
|
||||
if (cfg.es8388_sda >= 0) {
|
||||
if (cfg.es8388_sda > max_gpio || std::find(used.begin(), used.end(), cfg.es8388_sda) != used.end()) {
|
||||
error = cfg.es8388_sda > max_gpio ? "invalid_pin_range" : "pin_conflict";
|
||||
return false;
|
||||
}
|
||||
used.push_back(cfg.es8388_sda);
|
||||
}
|
||||
if (cfg.es8388_scl >= 0) {
|
||||
if (cfg.es8388_scl > max_gpio || std::find(used.begin(), used.end(), cfg.es8388_scl) != used.end()) {
|
||||
error = cfg.es8388_scl > max_gpio ? "invalid_pin_range" : "pin_conflict";
|
||||
return false;
|
||||
}
|
||||
used.push_back(cfg.es8388_scl);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional legacy line-enable pin, retired by default (-1).
|
||||
if (cfg.slic_line != -1) {
|
||||
if (cfg.slic_line < 0 || cfg.slic_line > max_gpio) {
|
||||
error = "invalid_pin_range";
|
||||
return false;
|
||||
}
|
||||
if (std::find(used.begin(), used.end(), cfg.slic_line) != used.end()) {
|
||||
error = "pin_conflict";
|
||||
return false;
|
||||
}
|
||||
used.push_back(cfg.slic_line);
|
||||
}
|
||||
|
||||
error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::validateAudio(const A252AudioConfig& cfg, String& error) {
|
||||
if (cfg.sample_rate < 8000 || cfg.sample_rate > 48000) {
|
||||
error = "invalid_sample_rate";
|
||||
return false;
|
||||
}
|
||||
if (!(cfg.bits_per_sample == 16 || cfg.bits_per_sample == 24 || cfg.bits_per_sample == 32)) {
|
||||
error = "invalid_bits_per_sample";
|
||||
return false;
|
||||
}
|
||||
if (cfg.adc_dsp_fft_downsample == 0U || cfg.adc_dsp_fft_downsample > 64U) {
|
||||
error = "invalid_adc_dsp_fft_downsample";
|
||||
return false;
|
||||
}
|
||||
if (cfg.adc_fft_ignore_low_bin > 32U) {
|
||||
error = "invalid_adc_fft_ignore_low_bin";
|
||||
return false;
|
||||
}
|
||||
if (cfg.adc_fft_ignore_high_bin > 32U) {
|
||||
error = "invalid_adc_fft_ignore_high_bin";
|
||||
return false;
|
||||
}
|
||||
if (cfg.volume > 100) {
|
||||
error = "invalid_volume";
|
||||
return false;
|
||||
}
|
||||
|
||||
String route = cfg.route;
|
||||
route.trim();
|
||||
route.toLowerCase();
|
||||
if (!(route == "rtc" || route == "none")) {
|
||||
error = "invalid_route";
|
||||
return false;
|
||||
}
|
||||
|
||||
String clock_policy = cfg.clock_policy;
|
||||
clock_policy.trim();
|
||||
clock_policy.toUpperCase();
|
||||
if (!(clock_policy == "HYBRID_TELCO")) {
|
||||
error = "invalid_clock_policy";
|
||||
return false;
|
||||
}
|
||||
|
||||
String wav_policy = cfg.wav_loudness_policy;
|
||||
wav_policy.trim();
|
||||
wav_policy.toUpperCase();
|
||||
if (!(wav_policy == "AUTO_NORMALIZE_LIMITER" || wav_policy == "FIXED_GAIN_ONLY")) {
|
||||
error = "invalid_wav_loudness_policy";
|
||||
return false;
|
||||
}
|
||||
if (cfg.wav_target_rms_dbfs < -36 || cfg.wav_target_rms_dbfs > -6) {
|
||||
error = "invalid_wav_target_rms_dbfs";
|
||||
return false;
|
||||
}
|
||||
if (cfg.wav_limiter_ceiling_dbfs < -12 || cfg.wav_limiter_ceiling_dbfs > 0) {
|
||||
error = "invalid_wav_limiter_ceiling_dbfs";
|
||||
return false;
|
||||
}
|
||||
if (cfg.wav_limiter_attack_ms < 1 || cfg.wav_limiter_attack_ms > 1000) {
|
||||
error = "invalid_wav_limiter_attack_ms";
|
||||
return false;
|
||||
}
|
||||
if (cfg.wav_limiter_release_ms < 1 || cfg.wav_limiter_release_ms > 5000) {
|
||||
error = "invalid_wav_limiter_release_ms";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
void A252ConfigStore::pinsToJson(const A252PinsConfig& cfg, JsonObject obj) {
|
||||
JsonObject i2s = obj["i2s"].to<JsonObject>();
|
||||
i2s["bck"] = cfg.i2s_bck;
|
||||
i2s["ws"] = cfg.i2s_ws;
|
||||
i2s["dout"] = cfg.i2s_dout;
|
||||
i2s["din"] = cfg.i2s_din;
|
||||
|
||||
JsonObject i2c = obj["codec_i2c"].to<JsonObject>();
|
||||
i2c["sda"] = cfg.es8388_sda;
|
||||
i2c["scl"] = cfg.es8388_scl;
|
||||
|
||||
JsonObject slic = obj["slic"].to<JsonObject>();
|
||||
slic["rm"] = cfg.slic_rm;
|
||||
slic["fr"] = cfg.slic_fr;
|
||||
slic["shk"] = cfg.slic_shk;
|
||||
slic["line"] = cfg.slic_line;
|
||||
slic["pd"] = cfg.slic_pd;
|
||||
slic["adc_in"] = cfg.slic_adc_in;
|
||||
slic["hook_active_high"] = cfg.hook_active_high;
|
||||
|
||||
JsonObject pcm = obj["pcm"].to<JsonObject>();
|
||||
pcm["flt"] = cfg.pcm_flt;
|
||||
pcm["demp"] = cfg.pcm_demp;
|
||||
pcm["xsmt"] = cfg.pcm_xsmt;
|
||||
pcm["fmt"] = cfg.pcm_fmt;
|
||||
}
|
||||
|
||||
void A252ConfigStore::audioToJson(const A252AudioConfig& cfg, JsonObject obj) {
|
||||
obj["sample_rate"] = cfg.sample_rate;
|
||||
obj["bits_per_sample"] = cfg.bits_per_sample;
|
||||
obj["enable_capture"] = cfg.enable_capture;
|
||||
obj["adc_dsp_enabled"] = cfg.adc_dsp_enabled;
|
||||
obj["adc_fft_enabled"] = cfg.adc_fft_enabled;
|
||||
obj["adc_dsp_fft_downsample"] = cfg.adc_dsp_fft_downsample;
|
||||
obj["adc_fft_ignore_low_bin"] = cfg.adc_fft_ignore_low_bin;
|
||||
obj["adc_fft_ignore_high_bin"] = cfg.adc_fft_ignore_high_bin;
|
||||
obj["volume"] = cfg.volume;
|
||||
obj["mute"] = cfg.mute;
|
||||
obj["route"] = cfg.route;
|
||||
obj["clock_policy"] = cfg.clock_policy;
|
||||
obj["wav_loudness_policy"] = cfg.wav_loudness_policy;
|
||||
obj["wav_target_rms_dbfs"] = cfg.wav_target_rms_dbfs;
|
||||
obj["wav_limiter_ceiling_dbfs"] = cfg.wav_limiter_ceiling_dbfs;
|
||||
obj["wav_limiter_attack_ms"] = cfg.wav_limiter_attack_ms;
|
||||
obj["wav_limiter_release_ms"] = cfg.wav_limiter_release_ms;
|
||||
}
|
||||
|
||||
void A252ConfigStore::peersToJson(const EspNowPeerStore& store, JsonArray arr) {
|
||||
for (const String& peer : store.peers) {
|
||||
arr.add(peer);
|
||||
}
|
||||
}
|
||||
|
||||
String A252ConfigStore::normalizeMac(const String& value) {
|
||||
String mac = value;
|
||||
mac.trim();
|
||||
mac.toUpperCase();
|
||||
|
||||
String compact;
|
||||
compact.reserve(12);
|
||||
for (size_t i = 0; i < mac.length(); ++i) {
|
||||
const char c = mac[i];
|
||||
if (c == ':' || c == '-' || c == ' ') {
|
||||
continue;
|
||||
}
|
||||
const bool is_hex = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F');
|
||||
if (!is_hex) {
|
||||
return "";
|
||||
}
|
||||
compact += c;
|
||||
}
|
||||
|
||||
if (compact.length() != 12) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String formatted;
|
||||
formatted.reserve(17);
|
||||
for (int i = 0; i < 12; i += 2) {
|
||||
if (i > 0) {
|
||||
formatted += ':';
|
||||
}
|
||||
formatted += compact.substring(i, i + 2);
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
String A252ConfigStore::normalizeDeviceName(const String& value) {
|
||||
String name = value;
|
||||
name.trim();
|
||||
name.toUpperCase();
|
||||
if (name.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
constexpr size_t kMaxDeviceNameLength = 24;
|
||||
String normalized;
|
||||
normalized.reserve(std::min<size_t>(name.length(), kMaxDeviceNameLength));
|
||||
for (size_t i = 0; i < name.length(); ++i) {
|
||||
const char c = name[i];
|
||||
const bool is_alpha = (c >= 'A' && c <= 'Z');
|
||||
const bool is_digit = (c >= '0' && c <= '9');
|
||||
const bool is_allowed_symbol = (c == '_' || c == '-');
|
||||
if (!(is_alpha || is_digit || is_allowed_symbol)) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.length() >= kMaxDeviceNameLength) {
|
||||
break;
|
||||
}
|
||||
normalized += c;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::parseMac(const String& value, uint8_t out[6]) {
|
||||
const String formatted = normalizeMac(value);
|
||||
if (formatted.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
const String chunk = formatted.substring(i * 3, i * 3 + 2);
|
||||
out[i] = static_cast<uint8_t>(strtoul(chunk.c_str(), nullptr, 16));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
#ifndef CONFIG_A252_CONFIG_STORE_H
|
||||
#define CONFIG_A252_CONFIG_STORE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "config/a1s_board_pins.h"
|
||||
#include "media/MediaRouting.h"
|
||||
|
||||
struct A252PinsConfig {
|
||||
int i2s_bck = A1S_I2S_BCLK;
|
||||
int i2s_ws = A1S_I2S_LRCK;
|
||||
int i2s_dout = A1S_I2S_DOUT;
|
||||
int i2s_din = A1S_I2S_DIN;
|
||||
|
||||
int es8388_sda = A1S_I2C_SDA;
|
||||
int es8388_scl = A1S_I2C_SCL;
|
||||
|
||||
// A252 bench defaults.
|
||||
int slic_rm = A1S_SLIC_RM;
|
||||
int slic_fr = A1S_SLIC_FR;
|
||||
int slic_shk = A1S_SLIC_SHK;
|
||||
int slic_line = -1;
|
||||
int slic_pd = A1S_SLIC_PD;
|
||||
int slic_adc_in = -1;
|
||||
bool hook_active_high = true;
|
||||
|
||||
int pcm_flt = -1;
|
||||
int pcm_demp = -1;
|
||||
int pcm_xsmt = -1;
|
||||
int pcm_fmt = -1;
|
||||
};
|
||||
|
||||
// Intentional aliases for board-centric naming in S3-focused firmware branches.
|
||||
using S3PinsConfig = A252PinsConfig;
|
||||
|
||||
struct A252AudioConfig {
|
||||
uint32_t sample_rate = 8000;
|
||||
uint8_t bits_per_sample = 16;
|
||||
bool enable_capture = true;
|
||||
bool adc_dsp_enabled = true;
|
||||
bool adc_fft_enabled = true;
|
||||
uint8_t adc_dsp_fft_downsample = 2U;
|
||||
uint16_t adc_fft_ignore_low_bin = 1U;
|
||||
uint16_t adc_fft_ignore_high_bin = 1U;
|
||||
uint8_t volume = 100;
|
||||
bool mute = false;
|
||||
String route = "rtc";
|
||||
String clock_policy = "HYBRID_TELCO";
|
||||
String wav_loudness_policy = "FIXED_GAIN_ONLY";
|
||||
int16_t wav_target_rms_dbfs = -18;
|
||||
int16_t wav_limiter_ceiling_dbfs = -2;
|
||||
uint16_t wav_limiter_attack_ms = 8;
|
||||
uint16_t wav_limiter_release_ms = 120;
|
||||
};
|
||||
|
||||
// Intentional aliases for board-centric naming in S3-focused firmware branches.
|
||||
using S3AudioConfig = A252AudioConfig;
|
||||
|
||||
struct EspNowCallMapEntry {
|
||||
String keyword;
|
||||
MediaRouteEntry route;
|
||||
};
|
||||
|
||||
using EspNowCallMap = std::vector<EspNowCallMapEntry>;
|
||||
|
||||
struct DialMediaMapEntry {
|
||||
String number;
|
||||
MediaRouteEntry route;
|
||||
};
|
||||
|
||||
using DialMediaMap = std::vector<DialMediaMapEntry>;
|
||||
|
||||
struct EspNowPeerStore {
|
||||
std::vector<String> peers;
|
||||
String device_name = "HOTLINE_PHONE";
|
||||
};
|
||||
|
||||
class A252ConfigStore {
|
||||
public:
|
||||
// Legacy board-agnostic interface.
|
||||
static A252PinsConfig defaultPins();
|
||||
static A252AudioConfig defaultAudio();
|
||||
|
||||
// S3/board-clarity façade.
|
||||
static S3PinsConfig defaultS3Pins();
|
||||
static S3AudioConfig defaultS3Audio();
|
||||
|
||||
static bool loadPins(A252PinsConfig& out);
|
||||
static bool savePins(const A252PinsConfig& cfg, String* error = nullptr);
|
||||
|
||||
static bool loadS3Pins(S3PinsConfig& out);
|
||||
static bool saveS3Pins(const S3PinsConfig& cfg, String* error = nullptr);
|
||||
|
||||
static bool loadAudio(A252AudioConfig& out);
|
||||
static bool saveAudio(const A252AudioConfig& cfg, String* error = nullptr);
|
||||
|
||||
static bool loadS3Audio(S3AudioConfig& out);
|
||||
static bool saveS3Audio(const S3AudioConfig& cfg, String* error = nullptr);
|
||||
|
||||
static bool loadEspNowPeers(EspNowPeerStore& out);
|
||||
static bool saveEspNowPeers(const EspNowPeerStore& store, String* error = nullptr);
|
||||
static bool loadEspNowCallMap(EspNowCallMap& out);
|
||||
static bool saveEspNowCallMap(const EspNowCallMap& map, String* error = nullptr);
|
||||
static bool loadDialMediaMap(DialMediaMap& out);
|
||||
static bool saveDialMediaMap(const DialMediaMap& map, String* error = nullptr);
|
||||
|
||||
static bool validatePins(const A252PinsConfig& cfg, String& error);
|
||||
static bool validateAudio(const A252AudioConfig& cfg, String& error);
|
||||
|
||||
static void pinsToJson(const A252PinsConfig& cfg, JsonObject obj);
|
||||
static void audioToJson(const A252AudioConfig& cfg, JsonObject obj);
|
||||
static void peersToJson(const EspNowPeerStore& store, JsonArray arr);
|
||||
static void espNowCallMapToJson(const EspNowCallMap& map, JsonObject obj);
|
||||
static void dialMediaMapToJson(const DialMediaMap& map, JsonObject obj);
|
||||
|
||||
static String normalizeMac(const String& value);
|
||||
static String normalizeDeviceName(const String& value);
|
||||
static bool parseMac(const String& value, uint8_t out[6]);
|
||||
};
|
||||
|
||||
#endif // CONFIG_A252_CONFIG_STORE_H
|
||||
@@ -1,47 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// Board: Ai-Thinker ESP32 Audio Kit v2.2 + ESP32-A1S (ES8388)
|
||||
// Memory: N4R8 => 4MB Flash, 8MB PSRAM
|
||||
// DIP: 1 OFF, 2 ON, 3 ON, 4 OFF, 5 OFF
|
||||
// Result: SD(SPI) active, KEY2 unavailable, JTAG disconnected.
|
||||
|
||||
#include <driver/i2c.h>
|
||||
#include <driver/i2s.h>
|
||||
|
||||
// ===== ES8388 control (I2C) =====
|
||||
#define A1S_I2C_PORT I2C_NUM_0
|
||||
#define A1S_I2C_SCL 32
|
||||
#define A1S_I2C_SDA 33
|
||||
#define A1S_ES8388_I2C_ADDR 0x10 // 7-bit address
|
||||
|
||||
// ===== Audio data (I2S) =====
|
||||
#define A1S_I2S_PORT I2S_NUM_0
|
||||
#define A1S_I2S_MCLK 0
|
||||
#define A1S_I2S_BCLK 27
|
||||
#define A1S_I2S_LRCK 25
|
||||
#define A1S_I2S_DOUT 26
|
||||
#define A1S_I2S_DIN 35 // input-only pin
|
||||
|
||||
// ===== Speaker amp + headphone detect =====
|
||||
#define A1S_PA_ENABLE 21
|
||||
#define A1S_HP_DETECT 39 // input-only; typical active LOW
|
||||
|
||||
// ===== SLIC / telephony front-end wiring (project-specific on A252) =====
|
||||
#define A1S_SLIC_RM 18
|
||||
#define A1S_SLIC_FR 5
|
||||
#define A1S_SLIC_SHK 23
|
||||
#define A1S_SLIC_PD 19
|
||||
|
||||
// ===== SD card (SPI) =====
|
||||
#define A1S_SD_CS 13
|
||||
#define A1S_SD_MISO 2
|
||||
#define A1S_SD_MOSI 15
|
||||
#define A1S_SD_SCK 14
|
||||
|
||||
// ===== Keys =====
|
||||
#define A1S_KEY1 36
|
||||
#define A1S_KEY2 13 // NOT AVAILABLE with DIP1=OFF when SD CS is active (DIP2=ON)
|
||||
#define A1S_KEY3 19
|
||||
#define A1S_KEY4 23
|
||||
#define A1S_KEY5 18
|
||||
#define A1S_KEY6 5
|
||||
@@ -1,33 +0,0 @@
|
||||
// AgentSupervisor.cpp
|
||||
#include "core/AgentSupervisor.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
AgentSupervisor& AgentSupervisor::instance() {
|
||||
static AgentSupervisor inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
void AgentSupervisor::notify(const std::string& agent, const AgentStatus& status) {
|
||||
statusMap_[agent] = status;
|
||||
publishEvent("status_update", agent, status);
|
||||
}
|
||||
|
||||
AgentStatus AgentSupervisor::getStatus(const std::string& agent) const {
|
||||
auto it = statusMap_.find(agent);
|
||||
if (it != statusMap_.end()) return it->second;
|
||||
return {"unknown", "", 0};
|
||||
}
|
||||
|
||||
std::map<std::string, AgentStatus> AgentSupervisor::getAllStatus() const {
|
||||
return statusMap_;
|
||||
}
|
||||
|
||||
void AgentSupervisor::subscribe(const std::string& event, std::function<void(const std::string&, const AgentStatus&)> cb) {
|
||||
subscribers_[event].push_back(cb);
|
||||
}
|
||||
|
||||
void AgentSupervisor::publishEvent(const std::string& event, const std::string& agent, const AgentStatus& status) {
|
||||
for (auto& cb : subscribers_[event]) {
|
||||
cb(agent, status);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// AgentSupervisor.h
|
||||
// Superviseur central pour la coordination des agents RTC_BL_PHONE
|
||||
#pragma once
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
struct AgentStatus {
|
||||
std::string state;
|
||||
std::string lastError;
|
||||
unsigned long lastUpdate;
|
||||
};
|
||||
|
||||
class AgentSupervisor {
|
||||
public:
|
||||
static AgentSupervisor& instance();
|
||||
void notify(const std::string& agent, const AgentStatus& status);
|
||||
AgentStatus getStatus(const std::string& agent) const;
|
||||
std::map<std::string, AgentStatus> getAllStatus() const;
|
||||
void subscribe(const std::string& event, std::function<void(const std::string&, const AgentStatus&)> cb);
|
||||
void publishEvent(const std::string& event, const std::string& agent, const AgentStatus& status);
|
||||
private:
|
||||
AgentSupervisor() = default;
|
||||
std::map<std::string, AgentStatus> statusMap_;
|
||||
std::map<std::string, std::vector<std::function<void(const std::string&, const AgentStatus&)>>> subscribers_;
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
#include "core/CommandDispatcher.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
void CommandDispatcher::registerCommand(const String& name, Handler handler) {
|
||||
const String key = normalizeCommand(name);
|
||||
if (key.isEmpty() || !handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlers_.find(key) == handlers_.end()) {
|
||||
order_.push_back(key);
|
||||
}
|
||||
handlers_[key] = std::move(handler);
|
||||
}
|
||||
|
||||
DispatchResponse CommandDispatcher::dispatch(const String& line) const {
|
||||
String input = line;
|
||||
input.trim();
|
||||
if (input.isEmpty()) {
|
||||
DispatchResponse resp;
|
||||
resp.ok = false;
|
||||
resp.code = "EMPTY_COMMAND";
|
||||
return resp;
|
||||
}
|
||||
|
||||
const int sep = input.indexOf(' ');
|
||||
const String cmd = normalizeCommand(sep > 0 ? input.substring(0, sep) : input);
|
||||
const String args = sep > 0 ? input.substring(sep + 1) : "";
|
||||
|
||||
const auto it = handlers_.find(cmd);
|
||||
if (it == handlers_.end()) {
|
||||
DispatchResponse resp;
|
||||
resp.ok = false;
|
||||
resp.code = "unsupported_command";
|
||||
if (!cmd.isEmpty()) {
|
||||
resp.code += ' ';
|
||||
resp.code += cmd;
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
return it->second(args);
|
||||
}
|
||||
|
||||
bool CommandDispatcher::hasCommand(const String& name) const {
|
||||
return handlers_.find(normalizeCommand(name)) != handlers_.end();
|
||||
}
|
||||
|
||||
String CommandDispatcher::helpText() const {
|
||||
String out;
|
||||
out.reserve(order_.size() * 24);
|
||||
for (size_t i = 0; i < order_.size(); ++i) {
|
||||
out += order_[i];
|
||||
if (i + 1 < order_.size()) {
|
||||
out += '\n';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<String> CommandDispatcher::commands() const {
|
||||
return order_;
|
||||
}
|
||||
|
||||
String CommandDispatcher::normalizeCommand(const String& name) {
|
||||
String out = name;
|
||||
out.trim();
|
||||
out.toUpperCase();
|
||||
return out;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
#ifndef CORE_COMMAND_DISPATCHER_H
|
||||
#define CORE_COMMAND_DISPATCHER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
struct DispatchResponse {
|
||||
bool ok = true;
|
||||
String code = "";
|
||||
String json;
|
||||
String raw;
|
||||
};
|
||||
|
||||
class CommandDispatcher {
|
||||
public:
|
||||
using Handler = std::function<DispatchResponse(const String& args)>;
|
||||
|
||||
void registerCommand(const String& name, Handler handler);
|
||||
DispatchResponse dispatch(const String& line) const;
|
||||
bool hasCommand(const String& name) const;
|
||||
String helpText() const;
|
||||
std::vector<String> commands() const;
|
||||
|
||||
private:
|
||||
std::map<String, Handler> handlers_;
|
||||
std::vector<String> order_;
|
||||
|
||||
static String normalizeCommand(const String& name);
|
||||
};
|
||||
|
||||
#endif // CORE_COMMAND_DISPATCHER_H
|
||||
@@ -1,37 +0,0 @@
|
||||
#include "core/PlatformProfile.h"
|
||||
|
||||
BoardProfile detectBoardProfile() {
|
||||
#if defined(BOARD_PROFILE_ESP32_S3) || defined(CONFIG_IDF_TARGET_ESP32S3)
|
||||
return BoardProfile::ESP32_S3;
|
||||
#else
|
||||
return BoardProfile::ESP32_A252;
|
||||
#endif
|
||||
}
|
||||
|
||||
FeatureMatrix getFeatureMatrix(BoardProfile profile) {
|
||||
switch (profile) {
|
||||
case BoardProfile::ESP32_A252:
|
||||
return FeatureMatrix{
|
||||
.has_full_duplex_i2s = true,
|
||||
};
|
||||
case BoardProfile::ESP32_S3:
|
||||
return FeatureMatrix{
|
||||
.has_full_duplex_i2s = false,
|
||||
};
|
||||
default:
|
||||
return FeatureMatrix{
|
||||
.has_full_duplex_i2s = false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const char* boardProfileToString(BoardProfile profile) {
|
||||
switch (profile) {
|
||||
case BoardProfile::ESP32_A252:
|
||||
return "ESP32_A252";
|
||||
case BoardProfile::ESP32_S3:
|
||||
return "ESP32_S3";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
#ifndef CORE_PLATFORM_PROFILE_H
|
||||
#define CORE_PLATFORM_PROFILE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
enum class BoardProfile : uint8_t {
|
||||
ESP32_A252 = 0,
|
||||
ESP32_S3 = 1
|
||||
};
|
||||
|
||||
struct FeatureMatrix {
|
||||
bool has_full_duplex_i2s;
|
||||
};
|
||||
|
||||
BoardProfile detectBoardProfile();
|
||||
FeatureMatrix getFeatureMatrix(BoardProfile profile);
|
||||
const char* boardProfileToString(BoardProfile profile);
|
||||
|
||||
#endif // CORE_PLATFORM_PROFILE_H
|
||||
@@ -1,29 +0,0 @@
|
||||
#include "lecture_audio/LectureAudioManager.h"
|
||||
|
||||
LectureAudioManager::LectureAudioManager() : initialized_(false) {}
|
||||
|
||||
bool LectureAudioManager::begin(BoardProfile profile) {
|
||||
initialized_ = audio_.begin(defaultAudioConfigForProfile(profile));
|
||||
return initialized_;
|
||||
}
|
||||
|
||||
bool LectureAudioManager::playFile(const char* filename) {
|
||||
if (!initialized_) {
|
||||
return false;
|
||||
}
|
||||
return audio_.playFile(filename);
|
||||
}
|
||||
|
||||
void LectureAudioManager::controlPlayback() {
|
||||
if (!initialized_) {
|
||||
return;
|
||||
}
|
||||
audio_.tick();
|
||||
}
|
||||
|
||||
bool LectureAudioManager::isPlaying() const {
|
||||
if (!initialized_) {
|
||||
return false;
|
||||
}
|
||||
return audio_.isPlaying();
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
#ifndef LECTURE_AUDIO_MANAGER_H
|
||||
#define LECTURE_AUDIO_MANAGER_H
|
||||
|
||||
#include "audio/AudioManager.h"
|
||||
#include "core/PlatformProfile.h"
|
||||
|
||||
class LectureAudioManager {
|
||||
public:
|
||||
LectureAudioManager();
|
||||
bool begin(BoardProfile profile);
|
||||
bool playFile(const char* filename);
|
||||
void controlPlayback();
|
||||
bool isPlaying() const;
|
||||
|
||||
private:
|
||||
AudioManager audio_;
|
||||
bool initialized_;
|
||||
};
|
||||
|
||||
#endif // LECTURE_AUDIO_MANAGER_H
|
||||
-3075
File diff suppressed because it is too large
Load Diff
@@ -1,317 +0,0 @@
|
||||
#ifndef MEDIA_MEDIA_ROUTING_H
|
||||
#define MEDIA_MEDIA_ROUTING_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
enum class MediaSource : uint8_t {
|
||||
AUTO = 0,
|
||||
SD,
|
||||
LITTLEFS,
|
||||
};
|
||||
|
||||
enum class MediaRouteKind : uint8_t {
|
||||
FILE = 0,
|
||||
TONE,
|
||||
};
|
||||
|
||||
enum class ToneProfile : uint8_t {
|
||||
FR_FR = 0,
|
||||
ETSI_EU,
|
||||
UK_GB,
|
||||
NA_US,
|
||||
NONE,
|
||||
};
|
||||
|
||||
enum class ToneEvent : uint8_t {
|
||||
DIAL = 0,
|
||||
SECONDARY_DIAL,
|
||||
SPECIAL_DIAL_STUTTER,
|
||||
RECALL_DIAL,
|
||||
RINGBACK,
|
||||
BUSY,
|
||||
CONGESTION,
|
||||
CALL_WAITING,
|
||||
CONFIRMATION,
|
||||
SIT_INTERCEPT,
|
||||
NONE,
|
||||
};
|
||||
|
||||
inline String sanitizeMediaPath(const String& raw_path) {
|
||||
String path = raw_path;
|
||||
path.trim();
|
||||
if (path.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (path.length() >= 2U && path[0] == '"' && path[path.length() - 1U] == '"') {
|
||||
path = path.substring(1U, path.length() - 1U);
|
||||
}
|
||||
path.trim();
|
||||
if (path.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (path == "null" || path.startsWith("{") || path.startsWith("[")) {
|
||||
return "";
|
||||
}
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
path.toLowerCase();
|
||||
if (!path.endsWith(".wav") && !path.endsWith(".mp3")) {
|
||||
path += ".wav";
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
struct ToneRouteEntry {
|
||||
ToneProfile profile = ToneProfile::FR_FR;
|
||||
ToneEvent event = ToneEvent::DIAL;
|
||||
};
|
||||
|
||||
struct FilePlaybackPolicy {
|
||||
bool loop = false;
|
||||
uint16_t pause_ms = 0U;
|
||||
};
|
||||
|
||||
struct MediaRouteEntry {
|
||||
MediaRouteKind kind = MediaRouteKind::FILE;
|
||||
ToneRouteEntry tone{};
|
||||
String path;
|
||||
MediaSource source = MediaSource::AUTO;
|
||||
FilePlaybackPolicy playback{};
|
||||
};
|
||||
|
||||
inline const char* mediaSourceToString(MediaSource source) {
|
||||
switch (source) {
|
||||
case MediaSource::SD:
|
||||
return "SD";
|
||||
case MediaSource::LITTLEFS:
|
||||
return "LITTLEFS";
|
||||
case MediaSource::AUTO:
|
||||
default:
|
||||
return "AUTO";
|
||||
}
|
||||
}
|
||||
|
||||
inline bool parseMediaSource(const String& raw, MediaSource& out) {
|
||||
String value = raw;
|
||||
value.trim();
|
||||
value.toLowerCase();
|
||||
if (value.isEmpty() || value == "auto") {
|
||||
out = MediaSource::AUTO;
|
||||
return true;
|
||||
}
|
||||
if (value == "sd") {
|
||||
out = MediaSource::SD;
|
||||
return true;
|
||||
}
|
||||
if (value == "littlefs" || value == "ffat" || value == "flash") {
|
||||
out = MediaSource::LITTLEFS;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline const char* mediaRouteKindToString(MediaRouteKind kind) {
|
||||
switch (kind) {
|
||||
case MediaRouteKind::TONE:
|
||||
return "tone";
|
||||
case MediaRouteKind::FILE:
|
||||
default:
|
||||
return "file";
|
||||
}
|
||||
}
|
||||
|
||||
inline bool parseMediaRouteKind(const String& raw, MediaRouteKind& out) {
|
||||
String value = raw;
|
||||
value.trim();
|
||||
value.toLowerCase();
|
||||
if (value == "tone") {
|
||||
out = MediaRouteKind::TONE;
|
||||
return true;
|
||||
}
|
||||
if (value.isEmpty() || value == "file") {
|
||||
out = MediaRouteKind::FILE;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline const char* toneProfileToString(ToneProfile profile) {
|
||||
switch (profile) {
|
||||
case ToneProfile::FR_FR:
|
||||
return "FR_FR";
|
||||
case ToneProfile::ETSI_EU:
|
||||
return "ETSI_EU";
|
||||
case ToneProfile::UK_GB:
|
||||
return "UK_GB";
|
||||
case ToneProfile::NA_US:
|
||||
return "NA_US";
|
||||
case ToneProfile::NONE:
|
||||
default:
|
||||
return "NONE";
|
||||
}
|
||||
}
|
||||
|
||||
inline bool parseToneProfile(const String& raw, ToneProfile& out) {
|
||||
String value = raw;
|
||||
value.trim();
|
||||
value.toLowerCase();
|
||||
if (value == "fr_fr" || value == "fr") {
|
||||
out = ToneProfile::FR_FR;
|
||||
return true;
|
||||
}
|
||||
if (value == "etsi_eu" || value == "eu" || value == "etsi") {
|
||||
out = ToneProfile::ETSI_EU;
|
||||
return true;
|
||||
}
|
||||
if (value == "uk_gb" || value == "uk" || value == "gb") {
|
||||
out = ToneProfile::UK_GB;
|
||||
return true;
|
||||
}
|
||||
if (value == "na_us" || value == "us" || value == "na") {
|
||||
out = ToneProfile::NA_US;
|
||||
return true;
|
||||
}
|
||||
if (value == "none") {
|
||||
out = ToneProfile::NONE;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline const char* toneEventToString(ToneEvent event) {
|
||||
switch (event) {
|
||||
case ToneEvent::DIAL:
|
||||
return "dial";
|
||||
case ToneEvent::SECONDARY_DIAL:
|
||||
return "secondary_dial";
|
||||
case ToneEvent::SPECIAL_DIAL_STUTTER:
|
||||
return "special_dial_stutter";
|
||||
case ToneEvent::RECALL_DIAL:
|
||||
return "recall_dial";
|
||||
case ToneEvent::RINGBACK:
|
||||
return "ringback";
|
||||
case ToneEvent::BUSY:
|
||||
return "busy";
|
||||
case ToneEvent::CONGESTION:
|
||||
return "congestion";
|
||||
case ToneEvent::CALL_WAITING:
|
||||
return "call_waiting";
|
||||
case ToneEvent::CONFIRMATION:
|
||||
return "confirmation";
|
||||
case ToneEvent::SIT_INTERCEPT:
|
||||
return "sit_intercept";
|
||||
case ToneEvent::NONE:
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
inline bool parseToneEvent(const String& raw, ToneEvent& out) {
|
||||
String value = raw;
|
||||
value.trim();
|
||||
value.toLowerCase();
|
||||
if (value == "dial") {
|
||||
out = ToneEvent::DIAL;
|
||||
return true;
|
||||
}
|
||||
if (value == "secondary_dial") {
|
||||
out = ToneEvent::SECONDARY_DIAL;
|
||||
return true;
|
||||
}
|
||||
if (value == "special_dial_stutter" || value == "special_dial_mwi_stutter") {
|
||||
out = ToneEvent::SPECIAL_DIAL_STUTTER;
|
||||
return true;
|
||||
}
|
||||
if (value == "recall_dial") {
|
||||
out = ToneEvent::RECALL_DIAL;
|
||||
return true;
|
||||
}
|
||||
if (value == "ringback") {
|
||||
out = ToneEvent::RINGBACK;
|
||||
return true;
|
||||
}
|
||||
if (value == "busy") {
|
||||
out = ToneEvent::BUSY;
|
||||
return true;
|
||||
}
|
||||
if (value == "congestion" || value == "reorder") {
|
||||
out = ToneEvent::CONGESTION;
|
||||
return true;
|
||||
}
|
||||
if (value == "call_waiting") {
|
||||
out = ToneEvent::CALL_WAITING;
|
||||
return true;
|
||||
}
|
||||
if (value == "confirmation") {
|
||||
out = ToneEvent::CONFIRMATION;
|
||||
return true;
|
||||
}
|
||||
if (value == "sit_intercept") {
|
||||
out = ToneEvent::SIT_INTERCEPT;
|
||||
return true;
|
||||
}
|
||||
if (value == "none") {
|
||||
out = ToneEvent::NONE;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline bool isLegacyToneWavPath(const String& raw_path, ToneRouteEntry* out_tone = nullptr) {
|
||||
String path = raw_path;
|
||||
path.trim();
|
||||
if (path.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (path.length() >= 2U && path[0] == '"' && path[path.length() - 1U] == '"') {
|
||||
path = path.substring(1, path.length() - 1);
|
||||
}
|
||||
path.trim();
|
||||
if (path.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
path.toLowerCase();
|
||||
if (!path.startsWith("/assets/wav/") || !path.endsWith(".wav")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int profile_begin = static_cast<int>(strlen("/assets/wav/"));
|
||||
const int profile_end = path.indexOf('/', profile_begin);
|
||||
if (profile_end <= profile_begin) {
|
||||
return false;
|
||||
}
|
||||
const int event_begin = profile_end + 1;
|
||||
const int ext_pos = path.lastIndexOf('.');
|
||||
if (ext_pos <= event_begin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const String profile_raw = path.substring(profile_begin, profile_end);
|
||||
const String event_raw = path.substring(event_begin, ext_pos);
|
||||
|
||||
ToneProfile profile = ToneProfile::NONE;
|
||||
ToneEvent event = ToneEvent::NONE;
|
||||
if (!parseToneProfile(profile_raw, profile) || !parseToneEvent(event_raw, event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (out_tone != nullptr) {
|
||||
out_tone->profile = profile;
|
||||
out_tone->event = event;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool mediaRouteHasPayload(const MediaRouteEntry& route) {
|
||||
if (route.kind == MediaRouteKind::TONE) {
|
||||
return route.tone.profile != ToneProfile::NONE && route.tone.event != ToneEvent::NONE;
|
||||
}
|
||||
return !route.path.isEmpty();
|
||||
}
|
||||
|
||||
#endif // MEDIA_MEDIA_ROUTING_H
|
||||
@@ -1,25 +0,0 @@
|
||||
#include "power/PowerManager.h"
|
||||
|
||||
#include <driver/gpio.h>
|
||||
#include <esp_sleep.h>
|
||||
|
||||
PowerManager::PowerManager() = default;
|
||||
|
||||
void PowerManager::monitorBattery(uint8_t pin) {
|
||||
const float voltage = getBatteryVoltage(pin);
|
||||
Serial.printf("[PowerManager] battery=%.2fV\n", voltage);
|
||||
}
|
||||
|
||||
void PowerManager::enterDeepSleep(uint32_t ms) {
|
||||
esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(ms) * 1000ULL);
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
void PowerManager::wakeupOnPin(uint8_t pin) {
|
||||
esp_sleep_enable_ext0_wakeup(static_cast<gpio_num_t>(pin), 0);
|
||||
}
|
||||
|
||||
float PowerManager::getBatteryVoltage(uint8_t pin) {
|
||||
const int raw = analogRead(pin);
|
||||
return static_cast<float>(raw) * (3.3f / 4095.0f) * 2.0f;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// PowerManager.h
|
||||
// Gestion batterie, deep sleep, wakeup
|
||||
|
||||
#ifndef POWERMANAGER_H
|
||||
#define POWERMANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
class PowerManager {
|
||||
public:
|
||||
PowerManager();
|
||||
void monitorBattery(uint8_t pin);
|
||||
void enterDeepSleep(uint32_t ms);
|
||||
void wakeupOnPin(uint8_t pin);
|
||||
float getBatteryVoltage(uint8_t pin);
|
||||
};
|
||||
|
||||
#endif // POWERMANAGER_H
|
||||
@@ -1,339 +0,0 @@
|
||||
#include "props/EspNowBridge.h"
|
||||
|
||||
#include <WiFi.h>
|
||||
#include <esp_now.h>
|
||||
#include <esp_wifi.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
EspNowBridge* EspNowBridge::instance_ = nullptr;
|
||||
|
||||
namespace {
|
||||
constexpr size_t kEspNowMaxPayloadBytes = 240;
|
||||
constexpr size_t kEspNowMaxPeers = 16;
|
||||
constexpr const char* kDefaultEspNowDeviceName = "HOTLINE_PHONE";
|
||||
|
||||
void enforceEspNowCoexPolicy() {
|
||||
WiFi.setSleep(true);
|
||||
const esp_err_t err = esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
|
||||
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT && err != ESP_ERR_WIFI_NOT_STARTED) {
|
||||
Serial.printf("[EspNowBridge] warn: esp_wifi_set_ps(min_modem) failed err=0x%04x\n",
|
||||
static_cast<unsigned>(err));
|
||||
}
|
||||
}
|
||||
|
||||
bool isBroadcastTarget(const String& target) {
|
||||
return target.equalsIgnoreCase("broadcast");
|
||||
}
|
||||
|
||||
bool parseTargetMac(const String& target, uint8_t out[6], bool& is_broadcast) {
|
||||
const String normalized = A252ConfigStore::normalizeMac(target);
|
||||
is_broadcast = false;
|
||||
if (isBroadcastTarget(target)) {
|
||||
is_broadcast = true;
|
||||
return true;
|
||||
}
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return A252ConfigStore::parseMac(normalized, out);
|
||||
}
|
||||
|
||||
String normalizeOrDefaultDeviceName(const String& name) {
|
||||
const String normalized = A252ConfigStore::normalizeDeviceName(name);
|
||||
return normalized.isEmpty() ? String(kDefaultEspNowDeviceName) : normalized;
|
||||
}
|
||||
}
|
||||
|
||||
EspNowBridge::EspNowBridge() {
|
||||
instance_ = this;
|
||||
}
|
||||
|
||||
bool EspNowBridge::begin(const EspNowPeerStore& initial_peers) {
|
||||
if (ready_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
store_ = initial_peers;
|
||||
store_.device_name = normalizeOrDefaultDeviceName(store_.device_name);
|
||||
|
||||
const wifi_mode_t current_mode = WiFi.getMode();
|
||||
if (current_mode == WIFI_MODE_NULL) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
delay(5);
|
||||
} else if (current_mode == WIFI_MODE_AP) {
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
delay(5);
|
||||
}
|
||||
enforceEspNowCoexPolicy();
|
||||
if (esp_now_init() != ESP_OK) {
|
||||
ready_ = false;
|
||||
return false;
|
||||
}
|
||||
enforceEspNowCoexPolicy();
|
||||
|
||||
esp_now_register_recv_cb(onDataRecv);
|
||||
esp_now_register_send_cb(onDataSent);
|
||||
|
||||
ready_ = true;
|
||||
|
||||
std::vector<String> peers_copy = store_.peers;
|
||||
store_.peers.clear();
|
||||
for (const String& mac : peers_copy) {
|
||||
addPeerInternal(mac, false);
|
||||
}
|
||||
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.
|
||||
}
|
||||
|
||||
bool EspNowBridge::addPeer(const String& mac) {
|
||||
return addPeerInternal(mac, true);
|
||||
}
|
||||
|
||||
bool EspNowBridge::deletePeer(const String& mac) {
|
||||
return deletePeerInternal(mac, true);
|
||||
}
|
||||
|
||||
const std::vector<String>& EspNowBridge::peers() const {
|
||||
return store_.peers;
|
||||
}
|
||||
|
||||
const String& EspNowBridge::deviceName() const {
|
||||
return store_.device_name;
|
||||
}
|
||||
|
||||
bool EspNowBridge::setDeviceName(const String& name, bool persist) {
|
||||
const String normalized = A252ConfigStore::normalizeDeviceName(name);
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
store_.device_name = normalized;
|
||||
if (persist) {
|
||||
return A252ConfigStore::saveEspNowPeers(store_);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EspNowBridge::sendJson(const String& target, const String& json_payload) {
|
||||
if (!ready_) {
|
||||
Serial.printf("[EspNowBridge] send rejected: bridge not started\n");
|
||||
tx_fail_++;
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalized_target = target;
|
||||
normalized_target.trim();
|
||||
if (normalized_target.isEmpty()) {
|
||||
Serial.printf("[EspNowBridge] send rejected: empty target\n");
|
||||
tx_fail_++;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (json_payload.length() > kEspNowMaxPayloadBytes) {
|
||||
Serial.printf("[EspNowBridge] send rejected: payload too large=%u bytes\n",
|
||||
static_cast<unsigned>(json_payload.length()));
|
||||
tx_fail_++;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool is_broadcast = false;
|
||||
uint8_t target_mac[6] = {0};
|
||||
if (!parseTargetMac(normalized_target, target_mac, is_broadcast)) {
|
||||
Serial.printf("[EspNowBridge] send rejected: invalid target '%s'\n", normalized_target.c_str());
|
||||
tx_fail_++;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is_broadcast) {
|
||||
const String normalized_source = A252ConfigStore::normalizeMac(normalized_target);
|
||||
if (std::find(store_.peers.begin(), store_.peers.end(), normalized_source) == store_.peers.end()) {
|
||||
Serial.printf("[EspNowBridge] send rejected: target not configured '%s'\n", normalized_source.c_str());
|
||||
tx_fail_++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_broadcast) {
|
||||
const uint8_t broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||
return sendToMac(broadcast_mac, json_payload);
|
||||
}
|
||||
|
||||
return sendToMac(target_mac, json_payload);
|
||||
}
|
||||
|
||||
bool EspNowBridge::isReady() const {
|
||||
return ready_;
|
||||
}
|
||||
|
||||
void EspNowBridge::setCommandCallback(std::function<void(const String&, const JsonVariantConst&)> cb) {
|
||||
command_callback_ = std::move(cb);
|
||||
}
|
||||
|
||||
void EspNowBridge::statusToJson(JsonObject obj) const {
|
||||
obj["ready"] = ready_;
|
||||
obj["device_name"] = store_.device_name;
|
||||
obj["peer_count"] = static_cast<uint32_t>(store_.peers.size());
|
||||
obj["tx_ok"] = tx_ok_;
|
||||
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) {
|
||||
peers.add(peer);
|
||||
}
|
||||
}
|
||||
|
||||
bool EspNowBridge::addPeerInternal(const String& mac, bool persist) {
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const String normalized = A252ConfigStore::normalizeMac(mac);
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (std::find(store_.peers.begin(), store_.peers.end(), normalized) != store_.peers.end()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (store_.peers.size() >= kEspNowMaxPeers) {
|
||||
Serial.println("[EspNowBridge] peer rejected: max peers reached");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t peer_mac[6] = {0};
|
||||
if (!A252ConfigStore::parseMac(normalized, peer_mac)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_now_peer_info_t peer_info = {};
|
||||
memcpy(peer_info.peer_addr, peer_mac, 6);
|
||||
peer_info.channel = 0;
|
||||
peer_info.encrypt = false;
|
||||
|
||||
if (esp_now_add_peer(&peer_info) != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
store_.peers.push_back(normalized);
|
||||
if (persist) {
|
||||
A252ConfigStore::saveEspNowPeers(store_);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EspNowBridge::deletePeerInternal(const String& mac, bool persist) {
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const String normalized = A252ConfigStore::normalizeMac(mac);
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t peer_mac[6] = {0};
|
||||
if (!A252ConfigStore::parseMac(normalized, peer_mac)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_now_del_peer(peer_mac);
|
||||
|
||||
const auto it = std::remove(store_.peers.begin(), store_.peers.end(), normalized);
|
||||
const bool removed = it != store_.peers.end();
|
||||
store_.peers.erase(it, store_.peers.end());
|
||||
if (removed && persist) {
|
||||
A252ConfigStore::saveEspNowPeers(store_);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
bool EspNowBridge::sendToMac(const uint8_t mac[6], const String& payload) {
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.length() > kEspNowMaxPayloadBytes) {
|
||||
tx_fail_++;
|
||||
return false;
|
||||
}
|
||||
|
||||
const esp_err_t err = esp_now_send(mac, reinterpret_cast<const uint8_t*>(payload.c_str()), payload.length());
|
||||
if (err != ESP_OK) {
|
||||
tx_fail_++;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void EspNowBridge::onDataRecv(const uint8_t* mac_addr, const uint8_t* data, int len) {
|
||||
if (!instance_) {
|
||||
return;
|
||||
}
|
||||
|
||||
char mac_buf[18] = {0};
|
||||
snprintf(mac_buf,
|
||||
sizeof(mac_buf),
|
||||
"%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
mac_addr[0],
|
||||
mac_addr[1],
|
||||
mac_addr[2],
|
||||
mac_addr[3],
|
||||
mac_addr[4],
|
||||
mac_addr[5]);
|
||||
|
||||
if (len <= 0 || len > static_cast<int>(kEspNowMaxPayloadBytes)) {
|
||||
Serial.printf("[EspNowBridge] rx dropped: invalid len=%d (max=%u)\n",
|
||||
len,
|
||||
static_cast<unsigned>(kEspNowMaxPayloadBytes));
|
||||
return;
|
||||
}
|
||||
|
||||
String payload;
|
||||
payload.reserve(len + 1);
|
||||
for (int i = 0; i < len; ++i) {
|
||||
payload += static_cast<char>(data[i]);
|
||||
}
|
||||
|
||||
instance_->rx_count_++;
|
||||
instance_->last_rx_mac_ = mac_buf;
|
||||
instance_->last_rx_payload_ = payload;
|
||||
|
||||
if (!instance_->command_callback_) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, payload) != DeserializationError::Ok) {
|
||||
doc.clear();
|
||||
doc["raw"] = payload;
|
||||
}
|
||||
instance_->command_callback_(String(mac_buf), doc.as<JsonVariantConst>());
|
||||
}
|
||||
|
||||
void EspNowBridge::onDataSent(const uint8_t* mac_addr, esp_now_send_status_t status) {
|
||||
if (!instance_) {
|
||||
return;
|
||||
}
|
||||
if (status == ESP_NOW_SEND_SUCCESS) {
|
||||
instance_->tx_ok_++;
|
||||
} else {
|
||||
instance_->tx_fail_++;
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
#ifndef PROPS_ESPNOW_BRIDGE_H
|
||||
#define PROPS_ESPNOW_BRIDGE_H
|
||||
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <esp_now.h>
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include "config/A252ConfigStore.h"
|
||||
|
||||
class EspNowBridge {
|
||||
public:
|
||||
EspNowBridge();
|
||||
|
||||
bool begin(const EspNowPeerStore& initial_peers);
|
||||
bool stop();
|
||||
void tick();
|
||||
|
||||
bool addPeer(const String& mac);
|
||||
bool deletePeer(const String& mac);
|
||||
const std::vector<String>& peers() const;
|
||||
const String& deviceName() const;
|
||||
bool setDeviceName(const String& name, bool persist = true);
|
||||
|
||||
bool sendJson(const String& target, const String& json_payload);
|
||||
bool isReady() const;
|
||||
|
||||
void setCommandCallback(std::function<void(const String&, const JsonVariantConst&)> cb);
|
||||
void statusToJson(JsonObject obj) const;
|
||||
|
||||
private:
|
||||
bool addPeerInternal(const String& normalized_mac, bool persist);
|
||||
bool deletePeerInternal(const String& normalized_mac, bool persist);
|
||||
bool sendToMac(const uint8_t mac[6], const String& payload);
|
||||
|
||||
static void onDataRecv(const uint8_t* mac_addr, const uint8_t* data, int len);
|
||||
static void onDataSent(const uint8_t* mac_addr, esp_now_send_status_t status);
|
||||
|
||||
static EspNowBridge* instance_;
|
||||
|
||||
bool ready_ = false;
|
||||
EspNowPeerStore store_;
|
||||
std::function<void(const String&, const JsonVariantConst&)> command_callback_;
|
||||
|
||||
uint32_t tx_ok_ = 0;
|
||||
uint32_t tx_fail_ = 0;
|
||||
uint32_t rx_count_ = 0;
|
||||
String last_rx_mac_;
|
||||
String last_rx_payload_;
|
||||
};
|
||||
|
||||
#endif // PROPS_ESPNOW_BRIDGE_H
|
||||
@@ -1,82 +0,0 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "core/AgentSupervisor.h"
|
||||
|
||||
void notifyRTOS(const std::string& state, const std::string& error = "") {
|
||||
AgentStatus status{state, error, millis()};
|
||||
AgentSupervisor::instance().notify("rtos", status);
|
||||
}
|
||||
#include "RTOSManager.h"
|
||||
#include <Arduino.h>
|
||||
#include <cstdlib>
|
||||
#include <esp_idf_version.h>
|
||||
|
||||
RTOSManager::RTOSManager() {}
|
||||
|
||||
bool RTOSManager::begin() {
|
||||
initialized = true;
|
||||
Serial.println("RTOSManager: Initialisation OK");
|
||||
notifyRTOS("initialized");
|
||||
return initialized;
|
||||
}
|
||||
|
||||
bool RTOSManager::createTask(const char* name, void (*taskFunc)(void*), uint16_t stackSize, void* params, UBaseType_t priority) {
|
||||
BaseType_t res = xTaskCreate(taskFunc, name, stackSize, params, priority, nullptr);
|
||||
if (res != pdPASS) {
|
||||
Serial.printf("RTOSManager: Échec création tâche %s\n", name);
|
||||
notifyRTOS("task_failed", name);
|
||||
return false;
|
||||
}
|
||||
Serial.printf("RTOSManager: Tâche %s créée\n", name);
|
||||
notifyRTOS("task_created", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
void RTOSManager::startScheduler() {
|
||||
Serial.println("RTOSManager: Scheduler FreeRTOS démarré");
|
||||
// Scheduler déjà géré par ESP32
|
||||
}
|
||||
|
||||
void RTOSManager::auditTasks() {
|
||||
Serial.println("RTOSManager: Audit des tâches en cours...");
|
||||
TaskStatus_t* pxTaskStatusArray;
|
||||
UBaseType_t uxArraySize = uxTaskGetNumberOfTasks();
|
||||
pxTaskStatusArray = (TaskStatus_t*)malloc(uxArraySize * sizeof(TaskStatus_t));
|
||||
if (pxTaskStatusArray != nullptr) {
|
||||
uxArraySize = uxTaskGetSystemState(pxTaskStatusArray, uxArraySize, nullptr);
|
||||
for (UBaseType_t i = 0; i < uxArraySize; i++) {
|
||||
Serial.printf("Tâche: %s, Etat: %d, Priorité: %d\n", pxTaskStatusArray[i].pcTaskName, pxTaskStatusArray[i].eCurrentState, pxTaskStatusArray[i].uxCurrentPriority);
|
||||
}
|
||||
free(pxTaskStatusArray);
|
||||
}
|
||||
}
|
||||
|
||||
void RTOSManager::logStatus() {
|
||||
Serial.printf("RTOSManager: init=%s, watchdog=%s, timeout=%lu ms\n", initialized ? "true" : "false", watchdogEnabled ? "true" : "false", watchdogTimeout);
|
||||
}
|
||||
|
||||
void RTOSManager::enableWatchdog(uint32_t timeoutMs) {
|
||||
#if ESP_IDF_VERSION_MAJOR >= 5
|
||||
esp_task_wdt_config_t config = {
|
||||
.timeout_ms = timeoutMs,
|
||||
.idle_core_mask = static_cast<uint32_t>((1U << portNUM_PROCESSORS) - 1U),
|
||||
.trigger_panic = true,
|
||||
};
|
||||
esp_task_wdt_init(&config);
|
||||
#else
|
||||
esp_task_wdt_init(timeoutMs / 1000, true);
|
||||
#endif
|
||||
esp_task_wdt_add(nullptr);
|
||||
watchdogEnabled = true;
|
||||
watchdogTimeout = timeoutMs;
|
||||
Serial.printf("RTOSManager: Watchdog activé (%lu ms)\n", watchdogTimeout);
|
||||
notifyRTOS("watchdog_enabled");
|
||||
}
|
||||
|
||||
void RTOSManager::feedWatchdog() {
|
||||
if (watchdogEnabled) {
|
||||
esp_task_wdt_reset();
|
||||
Serial.println("RTOSManager: Watchdog feed");
|
||||
notifyRTOS("watchdog_feed");
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// RTOSManager.h
|
||||
// Gestion des tâches FreeRTOS
|
||||
|
||||
#ifndef RTOSMANAGER_H
|
||||
#define RTOSMANAGER_H
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
class RTOSManager {
|
||||
public:
|
||||
RTOSManager();
|
||||
bool begin();
|
||||
bool createTask(const char* name, void (*taskFunc)(void*), uint16_t stackSize, void* params, UBaseType_t priority);
|
||||
void startScheduler();
|
||||
void auditTasks();
|
||||
void logStatus();
|
||||
void enableWatchdog(uint32_t timeoutMs);
|
||||
void feedWatchdog();
|
||||
private:
|
||||
bool initialized = false;
|
||||
bool watchdogEnabled = false;
|
||||
uint32_t watchdogTimeout = 0;
|
||||
};
|
||||
|
||||
#endif // RTOSMANAGER_H
|
||||
@@ -1,108 +0,0 @@
|
||||
#include "slic/Ks0835SlicController.h"
|
||||
|
||||
Ks0835SlicController::Ks0835SlicController()
|
||||
: initialized_(false), ring_enabled_(false), power_down_enabled_(false), fr_state_(false), last_fr_toggle_ms_(0) {
|
||||
pins_ = {0, 0, 0, -1, -1, true};
|
||||
}
|
||||
|
||||
bool Ks0835SlicController::begin(const SlicPins& pins) {
|
||||
pins_ = pins;
|
||||
|
||||
pinMode(pins_.pin_rm, OUTPUT);
|
||||
pinMode(pins_.pin_fr, OUTPUT);
|
||||
pinMode(pins_.pin_shk, INPUT_PULLUP);
|
||||
|
||||
digitalWrite(pins_.pin_rm, LOW);
|
||||
digitalWrite(pins_.pin_fr, LOW);
|
||||
|
||||
if (pins_.pin_line_enable >= 0) {
|
||||
pinMode(pins_.pin_line_enable, OUTPUT);
|
||||
digitalWrite(pins_.pin_line_enable, LOW);
|
||||
}
|
||||
|
||||
// Keep PD in open-drain released state (HIGH => high-impedance).
|
||||
if (pins_.pin_pd >= 0) {
|
||||
pinMode(pins_.pin_pd, OUTPUT_OPEN_DRAIN);
|
||||
digitalWrite(pins_.pin_pd, HIGH);
|
||||
}
|
||||
|
||||
initialized_ = true;
|
||||
ring_enabled_ = false;
|
||||
power_down_enabled_ = false;
|
||||
fr_state_ = false;
|
||||
last_fr_toggle_ms_ = millis();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Ks0835SlicController::setRing(bool enabled) {
|
||||
if (!initialized_) {
|
||||
return;
|
||||
}
|
||||
ring_enabled_ = enabled;
|
||||
digitalWrite(pins_.pin_rm, enabled ? HIGH : LOW);
|
||||
if (!enabled) {
|
||||
fr_state_ = false;
|
||||
digitalWrite(pins_.pin_fr, LOW);
|
||||
}
|
||||
}
|
||||
|
||||
void Ks0835SlicController::setLineEnabled(bool enabled) {
|
||||
if (!initialized_ || pins_.pin_line_enable < 0) {
|
||||
return;
|
||||
}
|
||||
digitalWrite(pins_.pin_line_enable, enabled ? HIGH : LOW);
|
||||
}
|
||||
|
||||
bool Ks0835SlicController::isHookOff() const {
|
||||
if (!initialized_) {
|
||||
return false;
|
||||
}
|
||||
const int level = digitalRead(pins_.pin_shk);
|
||||
return pins_.hook_active_high ? (level == HIGH) : (level == LOW);
|
||||
}
|
||||
|
||||
void Ks0835SlicController::setPowerDown(bool enabled) {
|
||||
if (!initialized_ || pins_.pin_pd < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (power_down_enabled_ == enabled) {
|
||||
return;
|
||||
}
|
||||
power_down_enabled_ = enabled;
|
||||
|
||||
if (enabled) {
|
||||
ring_enabled_ = false;
|
||||
digitalWrite(pins_.pin_rm, LOW);
|
||||
fr_state_ = false;
|
||||
digitalWrite(pins_.pin_fr, LOW);
|
||||
if (pins_.pin_line_enable >= 0) {
|
||||
digitalWrite(pins_.pin_line_enable, LOW);
|
||||
}
|
||||
pinMode(pins_.pin_pd, OUTPUT_OPEN_DRAIN);
|
||||
digitalWrite(pins_.pin_pd, HIGH);
|
||||
} else {
|
||||
pinMode(pins_.pin_pd, OUTPUT_OPEN_DRAIN);
|
||||
digitalWrite(pins_.pin_pd, HIGH);
|
||||
if (pins_.pin_line_enable >= 0) {
|
||||
digitalWrite(pins_.pin_line_enable, HIGH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Ks0835SlicController::isPowerDownEnabled() const {
|
||||
return power_down_enabled_;
|
||||
}
|
||||
|
||||
void Ks0835SlicController::tick() {
|
||||
if (!initialized_ || power_down_enabled_ || !ring_enabled_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
if (now - last_fr_toggle_ms_ >= 20) {
|
||||
fr_state_ = !fr_state_;
|
||||
digitalWrite(pins_.pin_fr, fr_state_ ? HIGH : LOW);
|
||||
last_fr_toggle_ms_ = now;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
#ifndef KS0835_SLIC_CONTROLLER_H
|
||||
#define KS0835_SLIC_CONTROLLER_H
|
||||
|
||||
#include "slic/SlicController.h"
|
||||
|
||||
class Ks0835SlicController : public SlicController {
|
||||
public:
|
||||
Ks0835SlicController();
|
||||
bool begin(const SlicPins& pins) override;
|
||||
void setRing(bool enabled) override;
|
||||
void setLineEnabled(bool enabled) override;
|
||||
bool isHookOff() const override;
|
||||
void setPowerDown(bool enabled) override;
|
||||
bool isPowerDownEnabled() const override;
|
||||
void tick() override;
|
||||
|
||||
private:
|
||||
SlicPins pins_;
|
||||
bool initialized_;
|
||||
bool ring_enabled_;
|
||||
bool power_down_enabled_;
|
||||
bool fr_state_;
|
||||
uint32_t last_fr_toggle_ms_;
|
||||
};
|
||||
|
||||
#endif // KS0835_SLIC_CONTROLLER_H
|
||||
@@ -1,80 +0,0 @@
|
||||
#include "core/AgentSupervisor.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
void notifySLIC(const std::string& state, const std::string& error = "") {
|
||||
AgentStatus status{state, error, millis()};
|
||||
AgentSupervisor::instance().notify("slic", status);
|
||||
}
|
||||
#include "slic/SLICManager.h"
|
||||
|
||||
SLICManager::SLICManager(SlicController* controller)
|
||||
: controller_(controller), state_(SLICLineState::UNINITIALIZED), incoming_ring_(false) {}
|
||||
|
||||
void SLICManager::attachController(SlicController* controller) {
|
||||
controller_ = controller;
|
||||
}
|
||||
|
||||
void SLICManager::begin() {
|
||||
if (controller_ == nullptr) {
|
||||
state_ = SLICLineState::UNINITIALIZED;
|
||||
notifySLIC("uninitialized", "no controller");
|
||||
return;
|
||||
}
|
||||
state_ = controller_->isHookOff() ? SLICLineState::OFF_HOOK : SLICLineState::ON_HOOK;
|
||||
notifySLIC(state_ == SLICLineState::OFF_HOOK ? "off_hook" : "on_hook");
|
||||
}
|
||||
|
||||
bool SLICManager::begin(const SlicPins& pins) {
|
||||
if (controller_ == nullptr || !controller_->begin(pins)) {
|
||||
state_ = SLICLineState::UNINITIALIZED;
|
||||
notifySLIC("uninitialized", "begin failed");
|
||||
return false;
|
||||
}
|
||||
begin();
|
||||
return true;
|
||||
}
|
||||
|
||||
void SLICManager::monitorLine() {
|
||||
if (controller_ == nullptr) {
|
||||
state_ = SLICLineState::UNINITIALIZED;
|
||||
notifySLIC("uninitialized", "no controller");
|
||||
return;
|
||||
}
|
||||
controller_->tick();
|
||||
if (incoming_ring_) {
|
||||
state_ = SLICLineState::RINGING;
|
||||
notifySLIC("ringing");
|
||||
} else {
|
||||
state_ = controller_->isHookOff() ? SLICLineState::OFF_HOOK : SLICLineState::ON_HOOK;
|
||||
notifySLIC(state_ == SLICLineState::OFF_HOOK ? "off_hook" : "on_hook");
|
||||
}
|
||||
}
|
||||
|
||||
void SLICManager::controlCall() {
|
||||
controlCall(incoming_ring_);
|
||||
}
|
||||
|
||||
void SLICManager::controlCall(bool incoming_ring) {
|
||||
incoming_ring_ = incoming_ring;
|
||||
if (controller_ == nullptr) {
|
||||
notifySLIC("uninitialized", "no controller");
|
||||
return;
|
||||
}
|
||||
if (incoming_ring_) {
|
||||
controller_->setRing(true);
|
||||
state_ = SLICLineState::RINGING;
|
||||
notifySLIC("ringing");
|
||||
} else {
|
||||
controller_->setRing(false);
|
||||
state_ = controller_->isHookOff() ? SLICLineState::OFF_HOOK : SLICLineState::ON_HOOK;
|
||||
notifySLIC(state_ == SLICLineState::OFF_HOOK ? "off_hook" : "on_hook");
|
||||
}
|
||||
}
|
||||
|
||||
SLICLineState SLICManager::state() const {
|
||||
return state_;
|
||||
}
|
||||
|
||||
bool SLICManager::isHookOff() const {
|
||||
return controller_ != nullptr && controller_->isHookOff();
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
#ifndef SLIC_MANAGER_H
|
||||
#define SLIC_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "slic/SlicController.h"
|
||||
|
||||
enum class SLICLineState : uint8_t {
|
||||
UNINITIALIZED = 0,
|
||||
ON_HOOK,
|
||||
OFF_HOOK,
|
||||
RINGING
|
||||
};
|
||||
|
||||
class SLICManager {
|
||||
public:
|
||||
explicit SLICManager(SlicController* controller = nullptr);
|
||||
void attachController(SlicController* controller);
|
||||
void begin();
|
||||
bool begin(const SlicPins& pins);
|
||||
void monitorLine();
|
||||
void controlCall();
|
||||
void controlCall(bool incoming_ring);
|
||||
SLICLineState state() const;
|
||||
bool isHookOff() const;
|
||||
|
||||
private:
|
||||
SlicController* controller_;
|
||||
SLICLineState state_;
|
||||
bool incoming_ring_;
|
||||
};
|
||||
|
||||
#endif // SLIC_MANAGER_H
|
||||
@@ -1,27 +0,0 @@
|
||||
#ifndef SLIC_CONTROLLER_H
|
||||
#define SLIC_CONTROLLER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
struct SlicPins {
|
||||
uint8_t pin_rm;
|
||||
uint8_t pin_fr;
|
||||
uint8_t pin_shk;
|
||||
int8_t pin_line_enable;
|
||||
int8_t pin_pd;
|
||||
bool hook_active_high;
|
||||
};
|
||||
|
||||
class SlicController {
|
||||
public:
|
||||
virtual ~SlicController() = default;
|
||||
virtual bool begin(const SlicPins& pins) = 0;
|
||||
virtual void setRing(bool enabled) = 0;
|
||||
virtual void setLineEnabled(bool enabled) = 0;
|
||||
virtual bool isHookOff() const = 0;
|
||||
virtual void setPowerDown(bool enabled) = 0;
|
||||
virtual bool isPowerDownEnabled() const = 0;
|
||||
virtual void tick() = 0;
|
||||
};
|
||||
|
||||
#endif // SLIC_CONTROLLER_H
|
||||
@@ -1,30 +0,0 @@
|
||||
#include "telephone_sfp/TelephoneSFPManager.h"
|
||||
|
||||
TelephoneSFPManager::TelephoneSFPManager() : service_(nullptr) {}
|
||||
|
||||
void TelephoneSFPManager::attachService(TelephonyService* service) {
|
||||
service_ = service;
|
||||
}
|
||||
|
||||
void TelephoneSFPManager::begin() {}
|
||||
|
||||
void TelephoneSFPManager::triggerIncomingCall() {
|
||||
if (service_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
service_->triggerIncomingRing();
|
||||
}
|
||||
|
||||
void TelephoneSFPManager::monitorState() {
|
||||
if (service_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
service_->tick();
|
||||
}
|
||||
|
||||
TelephonyState TelephoneSFPManager::state() const {
|
||||
if (service_ == nullptr) {
|
||||
return TelephonyState::IDLE;
|
||||
}
|
||||
return service_->state();
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
#ifndef TELEPHONE_SFP_MANAGER_H
|
||||
#define TELEPHONE_SFP_MANAGER_H
|
||||
|
||||
#include "telephony/TelephonyService.h"
|
||||
|
||||
class TelephoneSFPManager {
|
||||
public:
|
||||
TelephoneSFPManager();
|
||||
void attachService(TelephonyService* service);
|
||||
void begin();
|
||||
void triggerIncomingCall();
|
||||
void monitorState();
|
||||
TelephonyState state() const;
|
||||
|
||||
private:
|
||||
TelephonyService* service_;
|
||||
};
|
||||
|
||||
#endif // TELEPHONE_SFP_MANAGER_H
|
||||
@@ -1,148 +0,0 @@
|
||||
#include "DtmfDecoder.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,22 +0,0 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
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;
|
||||
uint16_t sampleRateHz_;
|
||||
size_t windowSize_;
|
||||
char lastCandidate_;
|
||||
uint8_t stableCount_;
|
||||
char latchedDigit_;
|
||||
};
|
||||
@@ -1,747 +0,0 @@
|
||||
#include "telephony/TelephonyService.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint16_t kDtmfFrameSamples = 160U;
|
||||
constexpr uint32_t kHookHangupMs = 300U;
|
||||
constexpr uint32_t kHookStabilizeMs = 40U;
|
||||
constexpr uint32_t kPulseInterDigitGapMs = 700U;
|
||||
constexpr uint32_t kPulseEdgeDebounceMs = 22U;
|
||||
constexpr uint32_t kPulseBreakMinMs = 28U;
|
||||
constexpr uint32_t kPulseBreakMaxMs = 220U;
|
||||
constexpr uint32_t kPulseMakeMinMs = 28U;
|
||||
constexpr uint32_t kPulseDtmfGuardMs = 900U;
|
||||
// Keep this short so the first rotary digit is not lost when users dial
|
||||
// immediately after lifting the handset.
|
||||
constexpr uint32_t kIdleHookOffEnterDebounceMs = 80U;
|
||||
constexpr size_t kDialMaxDigits = 20U;
|
||||
constexpr uint32_t kDialExactPendingCommitMs = 1200U;
|
||||
constexpr uint32_t kDtmfCaptureStartDelayMs = 0U;
|
||||
constexpr uint32_t kDtmfReadPeriodMs = 12U;
|
||||
constexpr uint32_t kTelephonyIdlePowerDownDelayMs = 2500U;
|
||||
constexpr uint32_t kTelephonyPowerProbeIntervalMs = 1200U;
|
||||
constexpr uint32_t kTelephonyPowerProbeWindowMs = 180U;
|
||||
constexpr uint8_t kDialSourceNone = 0U;
|
||||
constexpr uint8_t kDialSourceDtmf = 1U;
|
||||
constexpr uint8_t kDialSourcePulse = 2U;
|
||||
}
|
||||
|
||||
const char* telephonyStateToString(TelephonyState state) {
|
||||
switch (state) {
|
||||
case TelephonyState::IDLE:
|
||||
return "IDLE";
|
||||
case TelephonyState::RINGING:
|
||||
return "RINGING";
|
||||
case TelephonyState::PLAYING_MESSAGE:
|
||||
return "PLAYING_MESSAGE";
|
||||
case TelephonyState::OFF_HOOK:
|
||||
return "OFF_HOOK";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
const char* dialMatchStateToString(DialMatchState state) {
|
||||
switch (state) {
|
||||
case DialMatchState::PREFIX:
|
||||
return "PREFIX";
|
||||
case DialMatchState::EXACT_PENDING:
|
||||
return "EXACT_PENDING";
|
||||
case DialMatchState::TRIGGERED:
|
||||
return "TRIGGERED";
|
||||
case DialMatchState::NONE:
|
||||
default:
|
||||
return "NONE";
|
||||
}
|
||||
}
|
||||
|
||||
TelephonyService::TelephonyService()
|
||||
: profile_(BoardProfile::ESP32_A252),
|
||||
features_(getFeatureMatrix(BoardProfile::ESP32_A252)),
|
||||
slic_(nullptr),
|
||||
audio_(nullptr),
|
||||
dial_callback_(nullptr),
|
||||
dial_match_callback_(nullptr),
|
||||
answer_callback_(nullptr),
|
||||
dtmf_(8000U, kDtmfFrameSamples),
|
||||
state_(TelephonyState::IDLE),
|
||||
incoming_ring_(false),
|
||||
ring_phase_on_(false),
|
||||
ring_cycle_start_ms_(0),
|
||||
telephony_powered_(true),
|
||||
power_probe_active_(false),
|
||||
idle_since_ms_(0),
|
||||
next_power_probe_ms_(0),
|
||||
power_probe_end_ms_(0),
|
||||
capture_active_(false),
|
||||
pulse_hook_initialized_(false),
|
||||
pulse_last_hook_off_(false),
|
||||
pulse_collecting_(false),
|
||||
pulse_count_(0),
|
||||
last_hook_edge_ms_(0),
|
||||
pulse_break_start_ms_(0),
|
||||
pulse_make_start_ms_(0),
|
||||
idle_hook_off_since_ms_(0),
|
||||
last_pulse_ms_(0),
|
||||
dtmf_capture_start_ms_(0),
|
||||
next_dtmf_read_ms_(0),
|
||||
off_hook_enter_ms_(0),
|
||||
last_pulse_edge_ms_(0),
|
||||
suppress_dial_tone_(false),
|
||||
dialing_started_(false),
|
||||
dial_lock_until_on_hook_(false),
|
||||
dial_source_(kDialSourceNone),
|
||||
dial_match_state_(DialMatchState::NONE),
|
||||
dial_buffer_(""),
|
||||
last_digit_ms_(0),
|
||||
dial_exact_pending_since_ms_(0),
|
||||
last_dial_error_(""),
|
||||
message_path_("/welcome.wav") {}
|
||||
|
||||
bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEngine& audio) {
|
||||
profile_ = profile;
|
||||
features_ = getFeatureMatrix(profile);
|
||||
slic_ = &slic;
|
||||
audio_ = &audio;
|
||||
state_ = TelephonyState::IDLE;
|
||||
incoming_ring_ = false;
|
||||
ring_phase_on_ = false;
|
||||
ring_cycle_start_ms_ = millis();
|
||||
telephony_powered_ = true;
|
||||
power_probe_active_ = false;
|
||||
idle_since_ms_ = ring_cycle_start_ms_;
|
||||
next_power_probe_ms_ = ring_cycle_start_ms_ + kTelephonyPowerProbeIntervalMs;
|
||||
power_probe_end_ms_ = 0;
|
||||
capture_active_ = false;
|
||||
pulse_hook_initialized_ = false;
|
||||
pulse_last_hook_off_ = false;
|
||||
pulse_collecting_ = false;
|
||||
pulse_count_ = 0;
|
||||
last_hook_edge_ms_ = 0;
|
||||
pulse_break_start_ms_ = 0;
|
||||
pulse_make_start_ms_ = 0;
|
||||
idle_hook_off_since_ms_ = 0;
|
||||
last_pulse_ms_ = 0;
|
||||
dtmf_capture_start_ms_ = 0;
|
||||
next_dtmf_read_ms_ = 0;
|
||||
off_hook_enter_ms_ = 0;
|
||||
last_pulse_edge_ms_ = 0;
|
||||
suppress_dial_tone_ = false;
|
||||
dial_tone_suppressed_until_ms_ = 0U;
|
||||
dialing_started_ = false;
|
||||
dial_lock_until_on_hook_ = false;
|
||||
dial_source_ = kDialSourceNone;
|
||||
dial_match_state_ = DialMatchState::NONE;
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
dial_exact_pending_since_ms_ = 0;
|
||||
last_dial_error_ = "";
|
||||
|
||||
dtmf_.setDigitCallback([this](char digit) {
|
||||
onDialDigit(digit, false);
|
||||
});
|
||||
|
||||
slic_->setRing(false);
|
||||
setTelephonyPower(true);
|
||||
setTelephonyPower(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
void TelephonyService::setDialCallback(DialCallback cb) {
|
||||
dial_callback_ = cb;
|
||||
}
|
||||
|
||||
void TelephonyService::setDialMatchCallback(DialMatchCallback cb) {
|
||||
dial_match_callback_ = cb;
|
||||
}
|
||||
|
||||
void TelephonyService::setAnswerCallback(AnswerCallback cb) {
|
||||
answer_callback_ = cb;
|
||||
}
|
||||
|
||||
void TelephonyService::triggerIncomingRing() {
|
||||
incoming_ring_ = true;
|
||||
setTelephonyPower(true);
|
||||
power_probe_active_ = false;
|
||||
idle_since_ms_ = 0;
|
||||
}
|
||||
|
||||
void TelephonyService::setIncomingRing(bool active) {
|
||||
incoming_ring_ = active;
|
||||
if (active) {
|
||||
setTelephonyPower(true);
|
||||
power_probe_active_ = false;
|
||||
idle_since_ms_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void TelephonyService::forceTelephonyPower(bool enabled) {
|
||||
setTelephonyPower(enabled);
|
||||
power_probe_active_ = false;
|
||||
if (enabled) {
|
||||
idle_since_ms_ = 0;
|
||||
} else {
|
||||
idle_since_ms_ = millis();
|
||||
next_power_probe_ms_ = idle_since_ms_ + kTelephonyPowerProbeIntervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
void TelephonyService::setTelephonyPower(bool enabled) {
|
||||
if (slic_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (telephony_powered_ == enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
slic_->setPowerDown(false);
|
||||
slic_->setLineEnabled(true);
|
||||
} else {
|
||||
if (ring_phase_on_) {
|
||||
ring_phase_on_ = false;
|
||||
slic_->setRing(false);
|
||||
}
|
||||
slic_->setLineEnabled(false);
|
||||
slic_->setPowerDown(true);
|
||||
}
|
||||
|
||||
telephony_powered_ = enabled;
|
||||
Serial.printf("[Telephony] slic_power=%s\n", enabled ? "on" : "off");
|
||||
}
|
||||
|
||||
void TelephonyService::applyPowerPolicyPreTick(uint32_t now) {
|
||||
if (slic_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool keep_power_for_audio =
|
||||
(audio_ != nullptr) && (audio_->isToneRenderingActive() || audio_->isPlaying());
|
||||
if (keep_power_for_audio) {
|
||||
setTelephonyPower(true);
|
||||
power_probe_active_ = false;
|
||||
idle_since_ms_ = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state_ != TelephonyState::IDLE || incoming_ring_) {
|
||||
setTelephonyPower(true);
|
||||
power_probe_active_ = false;
|
||||
idle_since_ms_ = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (telephony_powered_) {
|
||||
if (idle_since_ms_ == 0U) {
|
||||
idle_since_ms_ = now;
|
||||
}
|
||||
if (!power_probe_active_ && (now - idle_since_ms_) >= kTelephonyIdlePowerDownDelayMs) {
|
||||
setTelephonyPower(false);
|
||||
next_power_probe_ms_ = now + kTelephonyPowerProbeIntervalMs;
|
||||
power_probe_end_ms_ = 0U;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (now >= next_power_probe_ms_) {
|
||||
setTelephonyPower(true);
|
||||
power_probe_active_ = true;
|
||||
power_probe_end_ms_ = now + kTelephonyPowerProbeWindowMs;
|
||||
}
|
||||
}
|
||||
|
||||
void TelephonyService::applyPowerPolicyPostTick(bool hook_off, uint32_t now) {
|
||||
if (slic_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool keep_power_for_audio =
|
||||
(audio_ != nullptr) && (audio_->isToneRenderingActive() || audio_->isPlaying());
|
||||
if (keep_power_for_audio) {
|
||||
setTelephonyPower(true);
|
||||
power_probe_active_ = false;
|
||||
idle_since_ms_ = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state_ != TelephonyState::IDLE || incoming_ring_ || hook_off) {
|
||||
setTelephonyPower(true);
|
||||
power_probe_active_ = false;
|
||||
idle_since_ms_ = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (telephony_powered_ && idle_since_ms_ == 0U) {
|
||||
idle_since_ms_ = now;
|
||||
}
|
||||
|
||||
if (power_probe_active_ && telephony_powered_ && now >= power_probe_end_ms_) {
|
||||
setTelephonyPower(false);
|
||||
power_probe_active_ = false;
|
||||
next_power_probe_ms_ = now + kTelephonyPowerProbeIntervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
void TelephonyService::onDialDigit(char digit, bool from_pulse) {
|
||||
if (digit < '0' || digit > '9') {
|
||||
return;
|
||||
}
|
||||
if (dial_lock_until_on_hook_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
if (!from_pulse) {
|
||||
// Rotary pulse has priority: suppress DTMF captures while pulse edges are active/recent.
|
||||
const bool pulse_recent =
|
||||
pulse_collecting_ || pulse_count_ > 0U ||
|
||||
(last_pulse_edge_ms_ != 0U && (now - last_pulse_edge_ms_) < kPulseDtmfGuardMs);
|
||||
if (pulse_recent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const uint8_t source = from_pulse ? kDialSourcePulse : kDialSourceDtmf;
|
||||
if (dial_source_ == kDialSourceNone) {
|
||||
dial_source_ = source;
|
||||
} else if (dial_source_ != source) {
|
||||
// Allow pulse to override an early DTMF false-start (typically tone bleed).
|
||||
if (from_pulse && dial_source_ == kDialSourceDtmf && dial_buffer_.length() <= 1U) {
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
dial_source_ = source;
|
||||
} else {
|
||||
// Keep strict ordering by ignoring mixed-source digits in the same session.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (audio_ != nullptr && dial_buffer_.isEmpty() && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
dialing_started_ = true;
|
||||
if (dial_buffer_.length() >= kDialMaxDigits) {
|
||||
dial_buffer_ = "";
|
||||
dial_match_state_ = DialMatchState::NONE;
|
||||
dial_exact_pending_since_ms_ = 0U;
|
||||
dial_source_ = kDialSourceNone;
|
||||
}
|
||||
|
||||
dial_buffer_ += digit;
|
||||
last_digit_ms_ = now;
|
||||
Serial.printf("[Telephony] digit=%c source=%s buffer=%s\n",
|
||||
digit,
|
||||
from_pulse ? "pulse" : "dtmf",
|
||||
dial_buffer_.c_str());
|
||||
evaluateDialBuffer(now, from_pulse ? "digit_pulse" : "digit_dtmf");
|
||||
}
|
||||
|
||||
void TelephonyService::updatePulseDecode(bool hook_off, uint32_t now) {
|
||||
if (!pulse_hook_initialized_) {
|
||||
pulse_hook_initialized_ = true;
|
||||
pulse_last_hook_off_ = hook_off;
|
||||
last_hook_edge_ms_ = now;
|
||||
pulse_break_start_ms_ = 0U;
|
||||
pulse_make_start_ms_ = now;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hook_off == pulse_last_hook_off_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((now - last_pulse_edge_ms_) < kPulseEdgeDebounceMs) {
|
||||
return;
|
||||
}
|
||||
last_pulse_edge_ms_ = now;
|
||||
|
||||
// Any valid hook edge during OFF_HOOK indicates dialing activity start.
|
||||
if (audio_ != nullptr && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
dialing_started_ = true;
|
||||
|
||||
if (pulse_last_hook_off_ && !hook_off) {
|
||||
// Make -> Break
|
||||
const uint32_t make_ms = (pulse_make_start_ms_ == 0U) ? 0U : (now - pulse_make_start_ms_);
|
||||
if (make_ms >= kPulseMakeMinMs) {
|
||||
if (!pulse_collecting_) {
|
||||
pulse_collecting_ = true;
|
||||
pulse_count_ = 0;
|
||||
// Stop dial tone as soon as rotary dialing starts (first pulse edge),
|
||||
// not only after the first full decoded digit.
|
||||
if (audio_ != nullptr && audio_->isToneRenderingActive()) {
|
||||
audio_->stopTone();
|
||||
}
|
||||
}
|
||||
pulse_break_start_ms_ = now;
|
||||
}
|
||||
} else if (!pulse_last_hook_off_ && hook_off) {
|
||||
// Break -> Make
|
||||
pulse_make_start_ms_ = now;
|
||||
const uint32_t break_ms = (pulse_break_start_ms_ == 0U) ? 0U : (now - pulse_break_start_ms_);
|
||||
if (pulse_collecting_ && pulse_count_ < 20U && break_ms >= kPulseBreakMinMs && break_ms <= kPulseBreakMaxMs) {
|
||||
++pulse_count_;
|
||||
last_pulse_ms_ = now;
|
||||
Serial.printf("[Telephony] pulse_count=%u break_ms=%u\n", pulse_count_, break_ms);
|
||||
}
|
||||
}
|
||||
|
||||
pulse_last_hook_off_ = hook_off;
|
||||
last_hook_edge_ms_ = now;
|
||||
}
|
||||
|
||||
void TelephonyService::commitDialBuffer(const char* reason) {
|
||||
if (dial_buffer_.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (audio_ != nullptr && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
|
||||
const String number = dial_buffer_;
|
||||
const bool from_pulse = (dial_source_ == kDialSourcePulse);
|
||||
const bool ok = dial_callback_ ? dial_callback_(number, from_pulse) : false;
|
||||
if (ok) {
|
||||
// Freeze dialing once a hotline route is launched; unlock only on hangup.
|
||||
dial_lock_until_on_hook_ = true;
|
||||
}
|
||||
last_dial_error_ = ok ? "" : "dial_failed";
|
||||
dial_match_state_ = DialMatchState::TRIGGERED;
|
||||
Serial.printf("[Telephony] dial_trigger reason=%s number=%s ok=%s\n",
|
||||
reason != nullptr ? reason : "unknown",
|
||||
number.c_str(),
|
||||
ok ? "true" : "false");
|
||||
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
dial_exact_pending_since_ms_ = 0U;
|
||||
dial_source_ = kDialSourceNone;
|
||||
}
|
||||
|
||||
void TelephonyService::evaluateDialBuffer(uint32_t now, const char* reason) {
|
||||
if (dial_buffer_.isEmpty()) {
|
||||
dial_match_state_ = DialMatchState::NONE;
|
||||
dial_exact_pending_since_ms_ = 0U;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dial_match_callback_) {
|
||||
dial_match_state_ = DialMatchState::PREFIX;
|
||||
if (dial_buffer_.length() >= 10U) {
|
||||
commitDialBuffer(reason != nullptr ? reason : "legacy_len10");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const DialRouteMatch match = dial_match_callback_(dial_buffer_);
|
||||
switch (match) {
|
||||
case DialRouteMatch::NONE:
|
||||
Serial.printf("[Telephony] dial_no_match buffer=%s reset\n", dial_buffer_.c_str());
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0U;
|
||||
dial_exact_pending_since_ms_ = 0U;
|
||||
dial_match_state_ = DialMatchState::NONE;
|
||||
dial_source_ = kDialSourceNone;
|
||||
dialing_started_ = false;
|
||||
return;
|
||||
case DialRouteMatch::PREFIX:
|
||||
dial_match_state_ = DialMatchState::PREFIX;
|
||||
dial_exact_pending_since_ms_ = 0U;
|
||||
return;
|
||||
case DialRouteMatch::EXACT:
|
||||
commitDialBuffer(reason != nullptr ? reason : "exact");
|
||||
return;
|
||||
case DialRouteMatch::EXACT_AND_PREFIX:
|
||||
dial_match_state_ = DialMatchState::EXACT_PENDING;
|
||||
if (dial_exact_pending_since_ms_ == 0U) {
|
||||
dial_exact_pending_since_ms_ = now;
|
||||
}
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void TelephonyService::clearDialSession() {
|
||||
if (audio_ != nullptr && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
if (audio_ != nullptr && capture_active_) {
|
||||
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
|
||||
}
|
||||
capture_active_ = false;
|
||||
dtmf_capture_start_ms_ = 0;
|
||||
next_dtmf_read_ms_ = 0;
|
||||
off_hook_enter_ms_ = 0;
|
||||
pulse_hook_initialized_ = false;
|
||||
pulse_collecting_ = false;
|
||||
pulse_count_ = 0;
|
||||
last_hook_edge_ms_ = 0;
|
||||
pulse_break_start_ms_ = 0;
|
||||
pulse_make_start_ms_ = 0;
|
||||
last_pulse_ms_ = 0;
|
||||
last_pulse_edge_ms_ = 0;
|
||||
dial_source_ = kDialSourceNone;
|
||||
dial_match_state_ = DialMatchState::NONE;
|
||||
dialing_started_ = false;
|
||||
dial_lock_until_on_hook_ = false;
|
||||
suppress_dial_tone_ = false;
|
||||
dial_tone_suppressed_until_ms_ = 0U;
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
dial_exact_pending_since_ms_ = 0U;
|
||||
}
|
||||
|
||||
void TelephonyService::suppressDialToneForMs(uint32_t duration_ms) {
|
||||
if (duration_ms == 0U) {
|
||||
dial_tone_suppressed_until_ms_ = 0U;
|
||||
return;
|
||||
}
|
||||
dial_tone_suppressed_until_ms_ = millis() + duration_ms;
|
||||
}
|
||||
|
||||
void TelephonyService::clearDialToneSuppression() {
|
||||
dial_tone_suppressed_until_ms_ = 0U;
|
||||
}
|
||||
|
||||
bool TelephonyService::isDialToneSuppressed(uint32_t now_ms) const {
|
||||
return dial_tone_suppressed_until_ms_ != 0U && now_ms < dial_tone_suppressed_until_ms_;
|
||||
}
|
||||
|
||||
void TelephonyService::tick() {
|
||||
if (slic_ == nullptr || audio_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
applyPowerPolicyPreTick(now);
|
||||
slic_->tick();
|
||||
|
||||
const bool hook_off = telephony_powered_ ? slic_->isHookOff() : false;
|
||||
const bool tone_suppressed = suppress_dial_tone_ || isDialToneSuppressed(now);
|
||||
const TelephonyState prev_state = state_;
|
||||
|
||||
switch (state_) {
|
||||
case TelephonyState::IDLE:
|
||||
if (incoming_ring_ && !hook_off) {
|
||||
ring_cycle_start_ms_ = millis();
|
||||
ring_phase_on_ = true;
|
||||
slic_->setRing(true);
|
||||
state_ = TelephonyState::RINGING;
|
||||
idle_hook_off_since_ms_ = 0;
|
||||
} else if (hook_off) {
|
||||
if (idle_hook_off_since_ms_ == 0U) {
|
||||
idle_hook_off_since_ms_ = now;
|
||||
} else if ((now - idle_hook_off_since_ms_) >= kIdleHookOffEnterDebounceMs) {
|
||||
state_ = TelephonyState::OFF_HOOK;
|
||||
idle_hook_off_since_ms_ = 0;
|
||||
}
|
||||
} else {
|
||||
idle_hook_off_since_ms_ = 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case TelephonyState::RINGING: {
|
||||
if (hook_off) {
|
||||
incoming_ring_ = false;
|
||||
ring_phase_on_ = false;
|
||||
slic_->setRing(false);
|
||||
const bool answered = answer_callback_ ? answer_callback_() : false;
|
||||
// Keep dial tone muted while transitioning from incoming ring to call answer.
|
||||
suppress_dial_tone_ = true;
|
||||
suppressDialToneForMs(3000U);
|
||||
last_dial_error_ = answered ? "" : "answer_failed";
|
||||
state_ = TelephonyState::OFF_HOOK;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!incoming_ring_) {
|
||||
ring_phase_on_ = false;
|
||||
slic_->setRing(false);
|
||||
state_ = TelephonyState::IDLE;
|
||||
break;
|
||||
}
|
||||
|
||||
const uint32_t elapsed = (millis() - ring_cycle_start_ms_) % 5000U;
|
||||
const bool should_ring = elapsed < 1000U;
|
||||
if (should_ring != ring_phase_on_) {
|
||||
ring_phase_on_ = should_ring;
|
||||
slic_->setRing(ring_phase_on_);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TelephonyState::PLAYING_MESSAGE:
|
||||
if (!audio_->isPlaying()) {
|
||||
state_ = hook_off ? TelephonyState::OFF_HOOK : TelephonyState::IDLE;
|
||||
}
|
||||
break;
|
||||
|
||||
case TelephonyState::OFF_HOOK:
|
||||
// While dial is locked, pulse decoder is disabled; keep hangup edge timing
|
||||
// in sync without touching normal pulse decoding flow.
|
||||
if (dial_lock_until_on_hook_ && hook_off != pulse_last_hook_off_) {
|
||||
last_hook_edge_ms_ = now;
|
||||
pulse_last_hook_off_ = hook_off;
|
||||
}
|
||||
|
||||
if (!dial_lock_until_on_hook_ && (now - off_hook_enter_ms_) >= kHookStabilizeMs) {
|
||||
updatePulseDecode(hook_off, now);
|
||||
}
|
||||
|
||||
if (!hook_off) {
|
||||
const bool hangup_confirmed = (now - last_hook_edge_ms_) >= kHookHangupMs;
|
||||
if (hangup_confirmed) {
|
||||
if (audio_ != nullptr && audio_->isToneRenderingActive()) {
|
||||
audio_->stopTone();
|
||||
}
|
||||
if (audio_ != nullptr && audio_->isPlaying()) {
|
||||
audio_->stopPlayback();
|
||||
}
|
||||
if (audio_ != nullptr && capture_active_) {
|
||||
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
|
||||
capture_active_ = false;
|
||||
}
|
||||
incoming_ring_ = false;
|
||||
state_ = TelephonyState::IDLE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (dial_lock_until_on_hook_) {
|
||||
if (audio_ != nullptr && capture_active_) {
|
||||
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
|
||||
capture_active_ = false;
|
||||
}
|
||||
if (!dial_buffer_.isEmpty() || dial_source_ != kDialSourceNone || dial_match_state_ != DialMatchState::NONE) {
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0U;
|
||||
dial_source_ = kDialSourceNone;
|
||||
dial_match_state_ = DialMatchState::NONE;
|
||||
dial_exact_pending_since_ms_ = 0U;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (pulse_collecting_ && pulse_count_ > 0U && (now - last_pulse_ms_) >= kPulseInterDigitGapMs) {
|
||||
const uint8_t count = pulse_count_;
|
||||
pulse_collecting_ = false;
|
||||
pulse_count_ = 0;
|
||||
const char digit = (count == 10U) ? '0' : ((count >= 1U && count <= 9U) ? static_cast<char>('0' + count)
|
||||
: '\0');
|
||||
if (digit != '\0') {
|
||||
onDialDigit(digit, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (dial_match_state_ == DialMatchState::EXACT_PENDING &&
|
||||
dial_exact_pending_since_ms_ != 0U &&
|
||||
(now - last_digit_ms_) >= kDialExactPendingCommitMs) {
|
||||
commitDialBuffer("exact_pending_timeout");
|
||||
}
|
||||
|
||||
if (!capture_active_ && now >= dtmf_capture_start_ms_) {
|
||||
capture_active_ = audio_->requestCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
|
||||
}
|
||||
if (capture_active_ && now >= next_dtmf_read_ms_) {
|
||||
int16_t frame[kDtmfFrameSamples] = {0};
|
||||
const size_t samples_read = audio_->readCaptureFrameNonBlocking(frame, kDtmfFrameSamples);
|
||||
if (samples_read > 0U) {
|
||||
dtmf_.feedAudioSamples(frame, samples_read);
|
||||
}
|
||||
next_dtmf_read_ms_ = now + kDtmfReadPeriodMs;
|
||||
}
|
||||
|
||||
if (suppress_dial_tone_ && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
|
||||
const bool pulse_dial_in_progress =
|
||||
pulse_collecting_ || pulse_count_ > 0U ||
|
||||
(last_pulse_edge_ms_ != 0U && (now - last_pulse_edge_ms_) < kPulseInterDigitGapMs);
|
||||
if (!tone_suppressed && !dialing_started_ && dial_buffer_.isEmpty() && !audio_->isDialToneActive() &&
|
||||
!pulse_dial_in_progress) {
|
||||
audio_->startDialTone();
|
||||
}
|
||||
|
||||
if (!dial_buffer_.isEmpty() && (now - last_digit_ms_) >= 10000U) {
|
||||
// Drop stale partial numbers instead of dialing an incomplete value.
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
dial_match_state_ = DialMatchState::NONE;
|
||||
dial_exact_pending_since_ms_ = 0U;
|
||||
dial_source_ = kDialSourceNone;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (prev_state != state_) {
|
||||
if (state_ == TelephonyState::OFF_HOOK) {
|
||||
off_hook_enter_ms_ = now;
|
||||
pulse_hook_initialized_ = false;
|
||||
pulse_collecting_ = false;
|
||||
pulse_count_ = 0;
|
||||
last_hook_edge_ms_ = now;
|
||||
pulse_last_hook_off_ = hook_off;
|
||||
pulse_break_start_ms_ = 0U;
|
||||
pulse_make_start_ms_ = now;
|
||||
last_pulse_ms_ = 0;
|
||||
last_pulse_edge_ms_ = 0;
|
||||
dial_source_ = kDialSourceNone;
|
||||
dial_match_state_ = DialMatchState::NONE;
|
||||
dialing_started_ = false;
|
||||
dial_lock_until_on_hook_ = false;
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
dial_exact_pending_since_ms_ = 0U;
|
||||
dtmf_capture_start_ms_ = now + kDtmfCaptureStartDelayMs;
|
||||
next_dtmf_read_ms_ = now;
|
||||
if (audio_ != nullptr && !tone_suppressed) {
|
||||
audio_->startDialTone();
|
||||
}
|
||||
}
|
||||
|
||||
if (prev_state == TelephonyState::OFF_HOOK && state_ != TelephonyState::OFF_HOOK) {
|
||||
clearDialSession();
|
||||
}
|
||||
}
|
||||
|
||||
applyPowerPolicyPostTick(hook_off, now);
|
||||
}
|
||||
|
||||
TelephonyState TelephonyService::state() const {
|
||||
return state_;
|
||||
}
|
||||
|
||||
bool TelephonyService::isTelephonyPowered() const {
|
||||
return telephony_powered_;
|
||||
}
|
||||
|
||||
bool TelephonyService::isPowerProbeActive() const {
|
||||
return power_probe_active_;
|
||||
}
|
||||
|
||||
const String& TelephonyService::dialBuffer() const {
|
||||
return dial_buffer_;
|
||||
}
|
||||
|
||||
const char* TelephonyService::dialSource() const {
|
||||
switch (dial_source_) {
|
||||
case kDialSourceDtmf:
|
||||
return "DTMF";
|
||||
case kDialSourcePulse:
|
||||
return "PULSE";
|
||||
case kDialSourceNone:
|
||||
default:
|
||||
return "NONE";
|
||||
}
|
||||
}
|
||||
|
||||
DialMatchState TelephonyService::dialMatchState() const {
|
||||
return dial_match_state_;
|
||||
}
|
||||
|
||||
bool TelephonyService::dialingStarted() const {
|
||||
return dialing_started_;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
#ifndef TELEPHONY_SERVICE_H
|
||||
#define TELEPHONY_SERVICE_H
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "audio/AudioEngine.h"
|
||||
#include "core/PlatformProfile.h"
|
||||
#include "slic/SlicController.h"
|
||||
#include "telephony/DtmfDecoder.h"
|
||||
|
||||
enum class TelephonyState : uint8_t {
|
||||
IDLE = 0,
|
||||
RINGING,
|
||||
PLAYING_MESSAGE,
|
||||
OFF_HOOK
|
||||
};
|
||||
|
||||
enum class DialRouteMatch : uint8_t {
|
||||
NONE = 0,
|
||||
PREFIX,
|
||||
EXACT,
|
||||
EXACT_AND_PREFIX,
|
||||
};
|
||||
|
||||
enum class DialMatchState : uint8_t {
|
||||
NONE = 0,
|
||||
PREFIX,
|
||||
EXACT_PENDING,
|
||||
TRIGGERED,
|
||||
};
|
||||
|
||||
const char* telephonyStateToString(TelephonyState state);
|
||||
const char* dialMatchStateToString(DialMatchState state);
|
||||
|
||||
class TelephonyService {
|
||||
public:
|
||||
using DialCallback = std::function<bool(const String&, bool from_pulse)>;
|
||||
using DialMatchCallback = std::function<DialRouteMatch(const String&)>;
|
||||
using AnswerCallback = std::function<bool()>;
|
||||
|
||||
TelephonyService();
|
||||
bool begin(BoardProfile profile, SlicController& slic, AudioEngine& audio);
|
||||
void setDialCallback(DialCallback cb);
|
||||
void setDialMatchCallback(DialMatchCallback cb);
|
||||
void setAnswerCallback(AnswerCallback cb);
|
||||
void triggerIncomingRing();
|
||||
void setIncomingRing(bool active);
|
||||
void forceTelephonyPower(bool enabled);
|
||||
void tick();
|
||||
TelephonyState state() const;
|
||||
bool isTelephonyPowered() const;
|
||||
bool isPowerProbeActive() const;
|
||||
void suppressDialToneForMs(uint32_t duration_ms);
|
||||
void clearDialToneSuppression();
|
||||
bool isDialToneSuppressed(uint32_t now_ms) const;
|
||||
const String& dialBuffer() const;
|
||||
const char* dialSource() const;
|
||||
DialMatchState dialMatchState() const;
|
||||
bool dialingStarted() const;
|
||||
|
||||
private:
|
||||
void setTelephonyPower(bool enabled);
|
||||
void applyPowerPolicyPreTick(uint32_t now);
|
||||
void applyPowerPolicyPostTick(bool hook_off, uint32_t now);
|
||||
void onDialDigit(char digit, bool from_pulse);
|
||||
void updatePulseDecode(bool hook_off, uint32_t now);
|
||||
void evaluateDialBuffer(uint32_t now, const char* reason);
|
||||
void commitDialBuffer(const char* reason);
|
||||
void clearDialSession();
|
||||
|
||||
BoardProfile profile_;
|
||||
FeatureMatrix features_;
|
||||
SlicController* slic_;
|
||||
AudioEngine* audio_;
|
||||
DialCallback dial_callback_;
|
||||
DialMatchCallback dial_match_callback_;
|
||||
AnswerCallback answer_callback_;
|
||||
DtmfDecoder dtmf_;
|
||||
TelephonyState state_;
|
||||
bool incoming_ring_;
|
||||
bool ring_phase_on_;
|
||||
uint32_t ring_cycle_start_ms_;
|
||||
bool telephony_powered_;
|
||||
bool power_probe_active_;
|
||||
uint32_t idle_since_ms_;
|
||||
uint32_t next_power_probe_ms_;
|
||||
uint32_t power_probe_end_ms_;
|
||||
bool capture_active_;
|
||||
bool pulse_hook_initialized_;
|
||||
bool pulse_last_hook_off_;
|
||||
bool pulse_collecting_;
|
||||
uint8_t pulse_count_;
|
||||
uint32_t last_hook_edge_ms_;
|
||||
uint32_t pulse_break_start_ms_;
|
||||
uint32_t pulse_make_start_ms_;
|
||||
uint32_t idle_hook_off_since_ms_;
|
||||
uint32_t last_pulse_ms_;
|
||||
uint32_t dtmf_capture_start_ms_;
|
||||
uint32_t next_dtmf_read_ms_;
|
||||
uint32_t off_hook_enter_ms_;
|
||||
uint32_t last_pulse_edge_ms_;
|
||||
bool suppress_dial_tone_;
|
||||
uint32_t dial_tone_suppressed_until_ms_ = 0U;
|
||||
bool dialing_started_;
|
||||
bool dial_lock_until_on_hook_;
|
||||
uint8_t dial_source_;
|
||||
DialMatchState dial_match_state_;
|
||||
String dial_buffer_;
|
||||
uint32_t last_digit_ms_;
|
||||
uint32_t dial_exact_pending_since_ms_;
|
||||
String last_dial_error_;
|
||||
const char* message_path_;
|
||||
};
|
||||
|
||||
#endif // TELEPHONY_SERVICE_H
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace usb_host_runtime {
|
||||
|
||||
inline bool enableHostPortPower() {
|
||||
#if defined(ARDUINO_ESP32_S3_USB_OTG) && defined(USB_HOST_EN) && \
|
||||
defined(USB_HOST_POWER_VBUS) && defined(USB_HOST_POWER_OFF)
|
||||
usbHostEnable(true);
|
||||
usbHostPower(USB_HOST_POWER_VBUS);
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace usb_host_runtime
|
||||
@@ -1,228 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include <USB.h>
|
||||
#include <USBMSC.h>
|
||||
|
||||
#include <esp_log.h>
|
||||
#include <esp_partition.h>
|
||||
|
||||
#include "usb/UsbMassStorageRuntime.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kUsbMscBlockSize = 512;
|
||||
constexpr uint32_t kSectorBytes = 4096;
|
||||
constexpr char kUsbMscPartitionLabel[] = "usbmsc";
|
||||
|
||||
constexpr char kUsbMscVendorId[] = "ESP32";
|
||||
constexpr char kUsbMscProductId[] = "USB_MSC";
|
||||
constexpr char kUsbMscProductRevision[] = "1.0";
|
||||
constexpr char kUsbMscLogTag[] = "USB_MSC";
|
||||
|
||||
USBMSC g_usb_msc;
|
||||
const esp_partition_t* g_msc_partition = nullptr;
|
||||
uint32_t g_msc_blocks = 0;
|
||||
bool g_msc_ready = false;
|
||||
|
||||
uint32_t alignDown(uint32_t value, uint32_t align) {
|
||||
return value & ~(align - 1U);
|
||||
}
|
||||
|
||||
uint32_t alignUp(uint32_t value, uint32_t align) {
|
||||
return (value + align - 1U) & ~(align - 1U);
|
||||
}
|
||||
|
||||
bool isInRange(uint32_t offset, uint32_t size) {
|
||||
if (g_msc_partition == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (offset > g_msc_partition->size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (static_cast<uint64_t>(offset) + size) <= g_msc_partition->size;
|
||||
}
|
||||
|
||||
bool eraseRange(uint32_t offset, uint32_t size) {
|
||||
if (!isInRange(offset, size)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint32_t aligned_offset = alignDown(offset, kSectorBytes);
|
||||
const uint32_t aligned_size = alignUp(size, kSectorBytes);
|
||||
|
||||
const uint64_t partition_end = g_msc_partition->size;
|
||||
if (aligned_offset >= partition_end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (aligned_offset + aligned_size > partition_end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const esp_err_t err = esp_partition_erase_range(g_msc_partition, aligned_offset, aligned_size);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(kUsbMscLogTag, "erase_range failed off=%lu size=%lu err=%s", aligned_offset, aligned_size, esp_err_to_name(err));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool onStartStop(uint8_t power_condition, bool start, bool load_eject) {
|
||||
(void)power_condition;
|
||||
g_msc_ready = start || !load_eject;
|
||||
ESP_LOGI(kUsbMscLogTag, "start_stop power_condition=%u start=%d eject=%d", power_condition, static_cast<int>(start),
|
||||
static_cast<int>(load_eject));
|
||||
return true;
|
||||
}
|
||||
|
||||
int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t* buffer, uint32_t bufsize) {
|
||||
if (!g_msc_ready || g_msc_partition == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (offset >= kUsbMscBlockSize) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint64_t block_offset = static_cast<uint64_t>(lba) * kUsbMscBlockSize;
|
||||
const uint64_t bytes_offset_64 = block_offset + offset;
|
||||
const uint64_t max_writable = g_msc_partition->size;
|
||||
if (bytes_offset_64 >= max_writable) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint32_t bytes_offset = static_cast<uint32_t>(bytes_offset_64);
|
||||
const uint32_t max_chunk = static_cast<uint32_t>(max_writable - bytes_offset);
|
||||
const uint32_t write_size = (bufsize > max_chunk) ? max_chunk : bufsize;
|
||||
if (!isInRange(bytes_offset, write_size)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t written = 0;
|
||||
while (written < write_size) {
|
||||
const uint32_t dst_offset = bytes_offset + written;
|
||||
const uint32_t sector_start = alignDown(dst_offset, kSectorBytes);
|
||||
const uint32_t sector_end = sector_start + kSectorBytes;
|
||||
const uint32_t chunk_end = (bytes_offset + write_size < sector_end) ? (bytes_offset + write_size) : sector_end;
|
||||
const uint32_t copy_len = chunk_end - dst_offset;
|
||||
const uint32_t sector_pos = dst_offset - sector_start;
|
||||
|
||||
uint8_t sector[kSectorBytes];
|
||||
const esp_err_t read_err = esp_partition_read(g_msc_partition, sector_start, sector, kSectorBytes);
|
||||
if (read_err != ESP_OK) {
|
||||
ESP_LOGE(kUsbMscLogTag, "sector read failed addr=%lu err=%s", sector_start, esp_err_to_name(read_err));
|
||||
return 0;
|
||||
}
|
||||
|
||||
memcpy(sector + sector_pos, buffer + written, copy_len);
|
||||
|
||||
if (!eraseRange(sector_start, kSectorBytes)) {
|
||||
return 0;
|
||||
}
|
||||
const esp_err_t write_err = esp_partition_write(g_msc_partition, sector_start, sector, kSectorBytes);
|
||||
if (write_err != ESP_OK) {
|
||||
ESP_LOGE(kUsbMscLogTag,
|
||||
"write failed lba=%lu offset=%lu size=%lu err=%s",
|
||||
lba,
|
||||
offset,
|
||||
write_size,
|
||||
esp_err_to_name(write_err));
|
||||
return static_cast<int32_t>(written);
|
||||
}
|
||||
|
||||
written += copy_len;
|
||||
}
|
||||
|
||||
return static_cast<int32_t>(written);
|
||||
}
|
||||
|
||||
int32_t onRead(uint32_t lba, uint32_t offset, void* buffer, uint32_t bufsize) {
|
||||
if (!g_msc_ready || g_msc_partition == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (offset >= kUsbMscBlockSize) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint64_t block_offset = static_cast<uint64_t>(lba) * kUsbMscBlockSize;
|
||||
const uint64_t bytes_offset_64 = block_offset + offset;
|
||||
if (bytes_offset_64 >= g_msc_partition->size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint32_t bytes_offset = static_cast<uint32_t>(bytes_offset_64);
|
||||
const uint32_t max_chunk = static_cast<uint32_t>(g_msc_partition->size - bytes_offset);
|
||||
const uint32_t read_size = (bufsize > max_chunk) ? max_chunk : bufsize;
|
||||
|
||||
if (!isInRange(bytes_offset, read_size)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const esp_err_t err = esp_partition_read(g_msc_partition, bytes_offset, buffer, read_size);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(kUsbMscLogTag, "read failed lba=%lu offset=%lu size=%lu err=%s", lba, offset, read_size, esp_err_to_name(err));
|
||||
return 0;
|
||||
}
|
||||
return static_cast<int32_t>(read_size);
|
||||
}
|
||||
|
||||
void onUsbEvent(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) {
|
||||
(void)arg;
|
||||
(void)event_data;
|
||||
|
||||
if (event_base != ARDUINO_USB_EVENTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event_id) {
|
||||
case ARDUINO_USB_STARTED_EVENT:
|
||||
ESP_LOGI(kUsbMscLogTag, "USB plugged");
|
||||
break;
|
||||
case ARDUINO_USB_STOPPED_EVENT:
|
||||
ESP_LOGI(kUsbMscLogTag, "USB unplugged");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace usb_msc_runtime {
|
||||
|
||||
bool beginUsbMassStorage() {
|
||||
g_msc_partition = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, kUsbMscPartitionLabel);
|
||||
if (g_msc_partition == nullptr) {
|
||||
ESP_LOGE(kUsbMscLogTag, "partition '%s' not found", kUsbMscPartitionLabel);
|
||||
return false;
|
||||
}
|
||||
|
||||
g_msc_blocks = static_cast<uint32_t>(g_msc_partition->size / kUsbMscBlockSize);
|
||||
g_usb_msc.onStartStop(onStartStop);
|
||||
g_usb_msc.onRead(onRead);
|
||||
g_usb_msc.onWrite(onWrite);
|
||||
g_usb_msc.vendorID(kUsbMscVendorId);
|
||||
g_usb_msc.productID(kUsbMscProductId);
|
||||
g_usb_msc.productRevision(kUsbMscProductRevision);
|
||||
g_usb_msc.mediaPresent(true);
|
||||
if (!g_usb_msc.begin(g_msc_blocks, kUsbMscBlockSize)) {
|
||||
ESP_LOGE(kUsbMscLogTag, "USBMSC begin failed");
|
||||
g_msc_partition = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
USB.onEvent(onUsbEvent);
|
||||
USB.begin();
|
||||
g_msc_ready = true;
|
||||
ESP_LOGI(kUsbMscLogTag,
|
||||
"started: blocks=%lu size=%luKB label=%s",
|
||||
g_msc_blocks,
|
||||
g_msc_partition->size / 1024U,
|
||||
g_msc_partition->label);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace usb_msc_runtime
|
||||
@@ -1,7 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
namespace usb_msc_runtime {
|
||||
|
||||
bool beginUsbMassStorage();
|
||||
|
||||
} // namespace usb_msc_runtime
|
||||
@@ -1,126 +0,0 @@
|
||||
#include "ScopeDisplay.h"
|
||||
|
||||
#include <math.h>
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32)
|
||||
#include <driver/dac.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t kDefaultAmplitude = 48U;
|
||||
constexpr uint16_t kDefaultFrequencyHz = 1200U;
|
||||
constexpr uint16_t kMinFrequencyHz = 60U;
|
||||
constexpr uint16_t kMaxFrequencyHz = 5000U;
|
||||
} // namespace
|
||||
|
||||
ScopeDisplay::ScopeDisplay()
|
||||
: initialized_(false),
|
||||
configured_(false),
|
||||
enabled_(false),
|
||||
supported_(false),
|
||||
frequency_hz_(kDefaultFrequencyHz),
|
||||
amplitude_(kDefaultAmplitude),
|
||||
last_tick_us_(0),
|
||||
phase_(0.0f) {}
|
||||
|
||||
bool ScopeDisplay::supported() const {
|
||||
return supported_;
|
||||
}
|
||||
|
||||
bool ScopeDisplay::enabled() const {
|
||||
return initialized_ && enabled_;
|
||||
}
|
||||
|
||||
uint16_t ScopeDisplay::frequency() const {
|
||||
return frequency_hz_;
|
||||
}
|
||||
|
||||
uint8_t ScopeDisplay::amplitude() const {
|
||||
return amplitude_;
|
||||
}
|
||||
|
||||
bool ScopeDisplay::begin() {
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32)
|
||||
dac_output_enable(DAC_CHANNEL_1);
|
||||
dac_output_enable(DAC_CHANNEL_2);
|
||||
initialized_ = true;
|
||||
supported_ = true;
|
||||
configured_ = true;
|
||||
enabled_ = true;
|
||||
last_tick_us_ = micros();
|
||||
phase_ = 0.0f;
|
||||
return true;
|
||||
#else
|
||||
initialized_ = false;
|
||||
supported_ = false;
|
||||
configured_ = false;
|
||||
enabled_ = false;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void ScopeDisplay::end() {
|
||||
if (!initialized_) {
|
||||
return;
|
||||
}
|
||||
|
||||
enabled_ = false;
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32)
|
||||
dac_output_disable(DAC_CHANNEL_1);
|
||||
dac_output_disable(DAC_CHANNEL_2);
|
||||
#endif
|
||||
initialized_ = false;
|
||||
}
|
||||
|
||||
bool ScopeDisplay::configure(uint16_t frequency_hz, uint8_t amplitude) {
|
||||
if (frequency_hz < kMinFrequencyHz || frequency_hz > kMaxFrequencyHz) {
|
||||
return false;
|
||||
}
|
||||
if (amplitude == 0U) {
|
||||
return false;
|
||||
}
|
||||
frequency_hz_ = frequency_hz;
|
||||
amplitude_ = amplitude;
|
||||
configured_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScopeDisplay::enable(bool value) {
|
||||
if (!configured_ || !supported_) {
|
||||
return;
|
||||
}
|
||||
enabled_ = value;
|
||||
if (enabled_) {
|
||||
if (!initialized_) {
|
||||
begin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeDisplay::tick() {
|
||||
if (!initialized_ || !enabled_ || !configured_) {
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32)
|
||||
const uint32_t now = micros();
|
||||
if ((now - last_tick_us_) < kTickIntervalUs) {
|
||||
return;
|
||||
}
|
||||
last_tick_us_ = now;
|
||||
|
||||
const float step = kTau * static_cast<float>(frequency_hz_) * (kTickIntervalUs / 1000000.0f);
|
||||
phase_ += step;
|
||||
if (phase_ >= kTau) {
|
||||
phase_ -= kTau;
|
||||
}
|
||||
|
||||
const float x = sinf(phase_);
|
||||
const float y = cosf(phase_);
|
||||
const int v1 = 128 + static_cast<int>(x * static_cast<float>(amplitude_));
|
||||
const int v2 = 128 + static_cast<int>(y * static_cast<float>(amplitude_));
|
||||
const uint8_t sample1 = static_cast<uint8_t>(constrain(v1, 0, 255));
|
||||
const uint8_t sample2 = static_cast<uint8_t>(constrain(v2, 0, 255));
|
||||
dac_output_voltage(DAC_CHANNEL_1, sample1);
|
||||
dac_output_voltage(DAC_CHANNEL_2, sample2);
|
||||
#endif
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
#ifndef VISUAL_SCOPE_DISPLAY_H
|
||||
#define VISUAL_SCOPE_DISPLAY_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
class ScopeDisplay {
|
||||
public:
|
||||
ScopeDisplay();
|
||||
|
||||
bool begin();
|
||||
void end();
|
||||
bool supported() const;
|
||||
bool enabled() const;
|
||||
bool configure(uint16_t frequency_hz, uint8_t amplitude);
|
||||
void enable(bool value);
|
||||
void tick();
|
||||
|
||||
uint16_t frequency() const;
|
||||
uint8_t amplitude() const;
|
||||
|
||||
private:
|
||||
static constexpr uint32_t kTickIntervalUs = 300;
|
||||
static constexpr float kTau = 6.283185307179586f;
|
||||
|
||||
bool initialized_;
|
||||
bool configured_;
|
||||
bool enabled_;
|
||||
bool supported_;
|
||||
uint16_t frequency_hz_;
|
||||
uint8_t amplitude_;
|
||||
uint32_t last_tick_us_;
|
||||
float phase_;
|
||||
};
|
||||
|
||||
#endif // VISUAL_SCOPE_DISPLAY_H
|
||||
@@ -1,492 +0,0 @@
|
||||
#include "web/WebServerManager.h"
|
||||
|
||||
#ifdef USB_MSC_BOOT_ENABLE
|
||||
#include <FFat.h>
|
||||
#else
|
||||
#include <SPIFFS.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
constexpr bool kForceAuthDisabled = false;
|
||||
constexpr bool kEnableRealtimeEvents = true;
|
||||
|
||||
String quoteArg(const String& value) {
|
||||
String escaped = value;
|
||||
escaped.replace("\\", "\\\\");
|
||||
escaped.replace("\"", "\\\"");
|
||||
return String("\"") + escaped + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
WebServerManager::WebServerManager(uint16_t port)
|
||||
: server_(port),
|
||||
events_("/api/events"),
|
||||
rate_limit_ms_(250),
|
||||
last_status_push_ms_(0),
|
||||
status_cache_json_(""),
|
||||
status_cache_ready_(false),
|
||||
status_cache_mux_(portMUX_INITIALIZER_UNLOCKED),
|
||||
auth_enabled_(true),
|
||||
auth_user_("admin"),
|
||||
auth_pass_("zacus-web-42") {}
|
||||
|
||||
void WebServerManager::begin() {
|
||||
#ifdef USB_MSC_BOOT_ENABLE
|
||||
if (FFat.begin(true, "/usbmsc", 10, "usbmsc")) {
|
||||
server_.serveStatic("/", FFat, "/webui/").setDefaultFile("index.html");
|
||||
} else {
|
||||
Serial.println("[WebServerManager] FFat mount failed (label usbmsc)");
|
||||
}
|
||||
#else
|
||||
if (!SPIFFS.begin(true)) {
|
||||
Serial.println("[WebServerManager] SPIFFS mount failed");
|
||||
} else {
|
||||
server_.serveStatic("/", SPIFFS, "/webui/").setDefaultFile("index.html");
|
||||
}
|
||||
#endif
|
||||
registerRoutes();
|
||||
server_.begin();
|
||||
Serial.println("[WebServerManager] HTTP server started");
|
||||
}
|
||||
|
||||
void WebServerManager::handle() {
|
||||
const uint32_t now = millis();
|
||||
if (now - last_status_push_ms_ >= 1000U) {
|
||||
last_status_push_ms_ = now;
|
||||
refreshStatusCache();
|
||||
publishRealtimeStatus();
|
||||
}
|
||||
}
|
||||
|
||||
void WebServerManager::setAuthCredentials(const String& user, const String& pass, bool persist_to_nvs) {
|
||||
(void)persist_to_nvs;
|
||||
if (!isValidInput(user, 32) || !isValidInput(pass, 64)) {
|
||||
return;
|
||||
}
|
||||
auth_user_ = user;
|
||||
auth_pass_ = pass;
|
||||
}
|
||||
|
||||
void WebServerManager::setAuthEnabled(bool enabled) {
|
||||
if (kForceAuthDisabled) {
|
||||
auth_enabled_ = false;
|
||||
auth_override_set_ = true;
|
||||
return;
|
||||
}
|
||||
auth_override_set_ = true;
|
||||
auth_enabled_ = enabled;
|
||||
}
|
||||
|
||||
bool WebServerManager::isAuthEnabled() const {
|
||||
if (kForceAuthDisabled && !auth_override_set_) {
|
||||
return false;
|
||||
}
|
||||
return auth_enabled_;
|
||||
}
|
||||
|
||||
void WebServerManager::setCommandValidator(std::function<bool(const String&)> callback) {
|
||||
command_validator_ = std::move(callback);
|
||||
}
|
||||
|
||||
void WebServerManager::setRateLimitMs(uint32_t rate_limit_ms) {
|
||||
rate_limit_ms_ = rate_limit_ms;
|
||||
}
|
||||
|
||||
void WebServerManager::setStatusCallback(std::function<void(JsonObject)> callback) {
|
||||
status_callback_ = std::move(callback);
|
||||
}
|
||||
|
||||
void WebServerManager::setCommandExecutor(std::function<DispatchResponse(const String&)> callback) {
|
||||
command_executor_ = std::move(callback);
|
||||
}
|
||||
|
||||
void WebServerManager::registerRoutes() {
|
||||
if (kEnableRealtimeEvents) {
|
||||
events_.onConnect([this](AsyncEventSourceClient* client) {
|
||||
JsonDocument hello;
|
||||
hello["transport"] = "sse";
|
||||
hello["connected"] = true;
|
||||
hello["ts"] = millis();
|
||||
const String payload = toJsonString(hello);
|
||||
client->send(payload.c_str(), "hello", millis());
|
||||
bool ready = false;
|
||||
const String cached = snapshotStatusCache(&ready);
|
||||
if (ready) {
|
||||
client->send(cached.c_str(), "status", millis());
|
||||
}
|
||||
});
|
||||
server_.addHandler(&events_);
|
||||
}
|
||||
|
||||
server_.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* request) {
|
||||
bool ready = false;
|
||||
const String cached = snapshotStatusCache(&ready);
|
||||
if (ready) {
|
||||
request->send(200, "application/json", cached);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonDocument warmup;
|
||||
warmup["auth_enabled"] = isAuthEnabled();
|
||||
warmup["state"] = "status_warmup";
|
||||
request->send(200, "application/json", toJsonString(warmup));
|
||||
});
|
||||
|
||||
server_.on("/api/control", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String action = doc["action"] | "";
|
||||
if (!isValidInput(action, 128)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid action\"}");
|
||||
return;
|
||||
}
|
||||
handleDispatch(request, action);
|
||||
});
|
||||
|
||||
// A252 config endpoints.
|
||||
server_.on("/api/config/pins", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "SLIC_CONFIG_GET"); });
|
||||
server_.on("/api/config/pins", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
handleDispatch(request, "SLIC_CONFIG_SET " + payload);
|
||||
});
|
||||
|
||||
server_.on("/api/config/audio", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "AUDIO_CONFIG_GET"); });
|
||||
server_.on("/api/config/audio", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
handleDispatch(request, "AUDIO_CONFIG_SET " + payload);
|
||||
});
|
||||
|
||||
// WiFi.
|
||||
server_.on("/api/network/wifi", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_STATUS"); });
|
||||
server_.on("/api/network/wifi/connect", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String ssid = doc["ssid"] | "";
|
||||
const String pass = doc["pass"] | "";
|
||||
if (!isValidInput(ssid, 64)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid ssid\"}");
|
||||
return;
|
||||
}
|
||||
handleDispatch(request, "WIFI_CONNECT " + quoteArg(ssid) + " " + quoteArg(pass));
|
||||
});
|
||||
server_.on("/api/network/wifi/disconnect", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_DISCONNECT"); });
|
||||
server_.on("/api/network/wifi/reconnect", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_RECONNECT"); });
|
||||
server_.on("/api/network/wifi/scan", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_SCAN"); });
|
||||
|
||||
// ESP-NOW.
|
||||
server_.on("/api/network/espnow", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_STATUS"); });
|
||||
server_.on("/api/network/espnow/on", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_ON"); });
|
||||
server_.on("/api/network/espnow/off", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_OFF"); });
|
||||
server_.on("/api/network/espnow/peer", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_PEER_LIST"); });
|
||||
server_.on("/api/network/espnow/peer", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String mac = doc["mac"] | "";
|
||||
if (!isValidInput(mac, 32)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
|
||||
return;
|
||||
}
|
||||
handleDispatch(request, "ESPNOW_PEER_ADD " + mac);
|
||||
});
|
||||
server_.on("/api/network/espnow/peer", HTTP_DELETE, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String mac = doc["mac"] | "";
|
||||
if (!isValidInput(mac, 32)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
|
||||
return;
|
||||
}
|
||||
handleDispatch(request, "ESPNOW_PEER_DEL " + mac);
|
||||
});
|
||||
server_.on("/api/network/espnow/send", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String mac = doc["mac"] | "";
|
||||
if (!isValidInput(mac, 32)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
|
||||
return;
|
||||
}
|
||||
String payload;
|
||||
JsonVariantConst payload_variant = doc["payload"].as<JsonVariantConst>();
|
||||
bool already_enveloped = false;
|
||||
if (payload_variant.is<JsonObjectConst>()) {
|
||||
JsonObjectConst payload_obj = payload_variant.as<JsonObjectConst>();
|
||||
already_enveloped = payload_obj["msg_id"].is<const char*>() && payload_obj["type"].is<const char*>();
|
||||
}
|
||||
|
||||
if (already_enveloped) {
|
||||
serializeJson(payload_variant, payload);
|
||||
} else {
|
||||
JsonDocument envelope;
|
||||
envelope["msg_id"] = String("web-") + String(millis());
|
||||
envelope["seq"] = millis();
|
||||
envelope["type"] = "command";
|
||||
envelope["ack"] = true;
|
||||
if (!payload_variant.isNull()) {
|
||||
envelope["payload"].set(payload_variant);
|
||||
} else {
|
||||
envelope["payload"].to<JsonObject>();
|
||||
}
|
||||
serializeJson(envelope, payload);
|
||||
}
|
||||
handleDispatch(request, "ESPNOW_SEND " + mac + " " + payload);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
bool WebServerManager::authenticateRequest(AsyncWebServerRequest* request) const {
|
||||
if (kForceAuthDisabled || !auth_enabled_) {
|
||||
return true;
|
||||
}
|
||||
if (!request->authenticate(auth_user_.c_str(), auth_pass_.c_str())) {
|
||||
request->requestAuthentication();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebServerManager::extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc) {
|
||||
if (request->hasParam("plain", true)) {
|
||||
const String body = request->getParam("plain", true)->value();
|
||||
return deserializeJson(doc, body) == DeserializationError::Ok;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String WebServerManager::toJsonString(const JsonDocument& doc) {
|
||||
String out;
|
||||
serializeJson(doc, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool WebServerManager::isValidInput(const String& value, size_t max_len) {
|
||||
if (value.isEmpty() || value.length() > max_len) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < value.length(); ++i) {
|
||||
const char c = value[i];
|
||||
if (c < 32 || c > 126) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebServerManager::isEffectCommand(const String& command_line) {
|
||||
String token = command_line;
|
||||
const int sep = token.indexOf(' ');
|
||||
if (sep > 0) {
|
||||
token = token.substring(0, sep);
|
||||
}
|
||||
token.trim();
|
||||
token.toUpperCase();
|
||||
|
||||
return token == "CALL" || token == "PLAY" || token == "CAPTURE_START" || token == "CAPTURE_STOP";
|
||||
}
|
||||
|
||||
bool WebServerManager::extractCommandId(const String& command_line, String& command_id) {
|
||||
command_id = "";
|
||||
String line = command_line;
|
||||
line.trim();
|
||||
if (line.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int sep = -1;
|
||||
const int len = line.length();
|
||||
bool in_quote = false;
|
||||
bool escaped = false;
|
||||
for (int i = 0; i < len; ++i) {
|
||||
const char c = line[i];
|
||||
if (in_quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (c == '\\') {
|
||||
escaped = true;
|
||||
} else if (c == '"') {
|
||||
in_quote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == '"') {
|
||||
in_quote = true;
|
||||
continue;
|
||||
}
|
||||
if (c == ' ') {
|
||||
sep = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sep < 0) {
|
||||
command_id = line;
|
||||
} else {
|
||||
command_id = line.substring(0, sep);
|
||||
}
|
||||
command_id.trim();
|
||||
command_id.toUpperCase();
|
||||
return !command_id.isEmpty();
|
||||
}
|
||||
|
||||
bool WebServerManager::isCommandRegistered(const String& command_line,
|
||||
const std::function<bool(const String&)>& validator) {
|
||||
if (!validator) {
|
||||
return true;
|
||||
}
|
||||
String command_id;
|
||||
if (!extractCommandId(command_line, command_id)) {
|
||||
return false;
|
||||
}
|
||||
return validator(command_id);
|
||||
}
|
||||
|
||||
void WebServerManager::refreshStatusCache() {
|
||||
if (!status_callback_) {
|
||||
portENTER_CRITICAL(&status_cache_mux_);
|
||||
status_cache_ready_ = false;
|
||||
status_cache_json_ = "";
|
||||
portEXIT_CRITICAL(&status_cache_mux_);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
doc["auth_enabled"] = isAuthEnabled();
|
||||
status_callback_(doc.to<JsonObject>());
|
||||
const String payload = toJsonString(doc);
|
||||
|
||||
portENTER_CRITICAL(&status_cache_mux_);
|
||||
status_cache_json_ = payload;
|
||||
status_cache_ready_ = true;
|
||||
portEXIT_CRITICAL(&status_cache_mux_);
|
||||
}
|
||||
|
||||
String WebServerManager::snapshotStatusCache(bool* ready) {
|
||||
portENTER_CRITICAL(&status_cache_mux_);
|
||||
const bool has_data = status_cache_ready_;
|
||||
const String payload = status_cache_json_;
|
||||
portEXIT_CRITICAL(&status_cache_mux_);
|
||||
if (ready != nullptr) {
|
||||
*ready = has_data;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
void WebServerManager::publishRealtimeEvent(const char* event_name, const String& payload_json) {
|
||||
if (!kEnableRealtimeEvents) {
|
||||
return;
|
||||
}
|
||||
events_.send(payload_json.c_str(), event_name, millis());
|
||||
}
|
||||
|
||||
void WebServerManager::publishRealtimeStatus() {
|
||||
bool ready = false;
|
||||
const String cached = snapshotStatusCache(&ready);
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
publishRealtimeEvent("status", cached);
|
||||
}
|
||||
|
||||
void WebServerManager::publishDispatchEvent(const String& command_line, const DispatchResponse& res) {
|
||||
JsonDocument doc;
|
||||
doc["command"] = command_line;
|
||||
doc["ok"] = res.ok;
|
||||
if (!res.code.isEmpty()) {
|
||||
doc["code"] = res.code;
|
||||
}
|
||||
if (!res.raw.isEmpty()) {
|
||||
doc["raw"] = res.raw;
|
||||
}
|
||||
if (!res.json.isEmpty()) {
|
||||
JsonDocument parsed;
|
||||
if (deserializeJson(parsed, res.json) == DeserializationError::Ok) {
|
||||
doc["json"].set(parsed.as<JsonVariantConst>());
|
||||
} else {
|
||||
doc["json_raw"] = res.json;
|
||||
}
|
||||
}
|
||||
|
||||
const String payload = toJsonString(doc);
|
||||
publishRealtimeEvent("dispatch", payload);
|
||||
if (isEffectCommand(command_line)) {
|
||||
publishRealtimeEvent("effect", payload);
|
||||
}
|
||||
}
|
||||
|
||||
void WebServerManager::handleDispatch(AsyncWebServerRequest* request,
|
||||
const String& command_line,
|
||||
uint16_t success_code,
|
||||
uint16_t error_code) {
|
||||
if (!authenticateRequest(request)) {
|
||||
return;
|
||||
}
|
||||
if (!command_executor_) {
|
||||
request->send(500, "application/json", "{\"error\":\"command executor not configured\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCommandRegistered(command_line, command_validator_)) {
|
||||
JsonDocument invalid;
|
||||
invalid["ok"] = false;
|
||||
invalid["error"] = "unsupported_command";
|
||||
invalid["command"] = command_line;
|
||||
invalid["path"] = request->url();
|
||||
request->send(400, "application/json", toJsonString(invalid));
|
||||
return;
|
||||
}
|
||||
|
||||
const DispatchResponse res = command_executor_(command_line);
|
||||
|
||||
if (!res.json.isEmpty()) {
|
||||
request->send(res.ok ? success_code : error_code, "application/json", res.json);
|
||||
} else {
|
||||
JsonDocument doc;
|
||||
doc["ok"] = res.ok;
|
||||
if (!res.code.isEmpty()) {
|
||||
doc["code"] = res.code;
|
||||
}
|
||||
if (!res.raw.isEmpty()) {
|
||||
doc["raw"] = res.raw;
|
||||
}
|
||||
request->send(res.ok ? success_code : error_code, "application/json", toJsonString(doc));
|
||||
}
|
||||
|
||||
publishDispatchEvent(command_line, res);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
#ifndef WEB_WEB_SERVER_MANAGER_H
|
||||
#define WEB_WEB_SERVER_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "core/CommandDispatcher.h"
|
||||
|
||||
class WebServerManager {
|
||||
public:
|
||||
explicit WebServerManager(uint16_t port = 80);
|
||||
void begin();
|
||||
void handle();
|
||||
|
||||
void setAuthCredentials(const String& user, const String& pass, bool persist_to_nvs = false);
|
||||
void setAuthEnabled(bool enabled);
|
||||
bool isAuthEnabled() const;
|
||||
void setRateLimitMs(uint32_t rate_limit_ms);
|
||||
|
||||
void setStatusCallback(std::function<void(JsonObject)> callback);
|
||||
void setCommandExecutor(std::function<DispatchResponse(const String&)> callback);
|
||||
void setCommandValidator(std::function<bool(const String&)> callback);
|
||||
|
||||
private:
|
||||
AsyncWebServer server_;
|
||||
AsyncEventSource events_;
|
||||
uint32_t rate_limit_ms_;
|
||||
uint32_t last_status_push_ms_;
|
||||
String status_cache_json_;
|
||||
bool status_cache_ready_;
|
||||
portMUX_TYPE status_cache_mux_;
|
||||
bool auth_enabled_;
|
||||
bool auth_override_set_ = false;
|
||||
String auth_user_;
|
||||
String auth_pass_;
|
||||
std::function<void(JsonObject)> status_callback_;
|
||||
std::function<DispatchResponse(const String&)> command_executor_;
|
||||
std::function<bool(const String&)> command_validator_;
|
||||
|
||||
static bool extractCommandId(const String& command_line, String& command_id);
|
||||
static bool isCommandRegistered(const String& command_line,
|
||||
const std::function<bool(const String&)>& validator);
|
||||
void registerRoutes();
|
||||
bool authenticateRequest(AsyncWebServerRequest* request) const;
|
||||
static bool extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc);
|
||||
static String toJsonString(const JsonDocument& doc);
|
||||
static bool isValidInput(const String& value, size_t max_len);
|
||||
static bool isEffectCommand(const String& command_line);
|
||||
String snapshotStatusCache(bool* ready = nullptr);
|
||||
void refreshStatusCache();
|
||||
void publishRealtimeEvent(const char* event_name, const String& payload_json);
|
||||
void publishRealtimeStatus();
|
||||
void publishDispatchEvent(const String& command_line, const DispatchResponse& res);
|
||||
void handleDispatch(AsyncWebServerRequest* request,
|
||||
const String& command_line,
|
||||
uint16_t success_code = 200,
|
||||
uint16_t error_code = 400);
|
||||
};
|
||||
|
||||
#endif // WEB_WEB_SERVER_MANAGER_H
|
||||
@@ -1,230 +0,0 @@
|
||||
let contactsData = [];
|
||||
|
||||
function showSection(section) {
|
||||
const map = {
|
||||
contacts: "contactsSection",
|
||||
config: "configSection",
|
||||
logs: "logsSection",
|
||||
control: "controlSection",
|
||||
};
|
||||
Object.values(map).forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.classList.remove("active");
|
||||
}
|
||||
});
|
||||
const sectionEl = document.getElementById(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}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const status = document.getElementById("status");
|
||||
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}`;
|
||||
} catch (error) {
|
||||
status.textContent = `Erreur statut: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContacts() {
|
||||
try {
|
||||
contactsData = await safeFetchJson("/api/contacts");
|
||||
renderContacts();
|
||||
} catch (error) {
|
||||
document.getElementById("contactFeedback").textContent = `Erreur contacts: ${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() {
|
||||
try {
|
||||
const data = await safeFetchJson("/api/config");
|
||||
document.getElementById("config").textContent = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
document.getElementById("config").textContent = `Erreur config: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.querySelectorAll("nav button[data-section]").forEach((button) => {
|
||||
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("contactForm").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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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));
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById("contactFeedback").textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("#controlSection button[data-action]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await sendControl(button.dataset.action);
|
||||
} catch (error) {
|
||||
document.getElementById("controlResult").textContent = error.message;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
bindEvents();
|
||||
await Promise.all([refreshStatus(), loadContacts(), loadConfig(), refreshLogs()]);
|
||||
showSection("contacts");
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
#ifndef WIFI_CREDENTIALS_STORAGE_H
|
||||
#define WIFI_CREDENTIALS_STORAGE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Preferences.h>
|
||||
|
||||
class WifiCredentialsStorage {
|
||||
public:
|
||||
static bool load(String& ssid, String& password) {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin("wifi-creds", false)) return false;
|
||||
ssid = prefs.isKey("ssid") ? prefs.getString("ssid", "") : String("");
|
||||
password = prefs.isKey("password") ? prefs.getString("password", "") : String("");
|
||||
prefs.end();
|
||||
return !ssid.isEmpty();
|
||||
}
|
||||
|
||||
static void save(const String& ssid, const String& password) {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin("wifi-creds", false)) return;
|
||||
prefs.putString("ssid", ssid);
|
||||
prefs.putString("password", password);
|
||||
prefs.end();
|
||||
}
|
||||
};
|
||||
|
||||
#endif // WIFI_CREDENTIALS_STORAGE_H
|
||||
@@ -1,318 +0,0 @@
|
||||
#include "wifi/WifiManager.h"
|
||||
|
||||
#include "core/AgentSupervisor.h"
|
||||
#include "wifi/WifiCredentialsStorage.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <esp_wifi.h>
|
||||
|
||||
namespace {
|
||||
constexpr char kFallbackApPrefix[] = "RTC_BL_A252";
|
||||
// Open fallback AP by default to avoid lockout in local recovery mode.
|
||||
constexpr char kFallbackApPassword[] = "";
|
||||
constexpr uint8_t kFallbackApChannel = 6;
|
||||
constexpr uint8_t kFallbackApMaxConnections = 4;
|
||||
|
||||
void notifyWifi(const std::string& state, const std::string& error = "") {
|
||||
AgentStatus status{state, error, millis()};
|
||||
AgentSupervisor::instance().notify("wifi", status);
|
||||
}
|
||||
|
||||
String wifiStateToString(wl_status_t status) {
|
||||
switch (status) {
|
||||
case WL_CONNECTED:
|
||||
return "connected";
|
||||
case WL_IDLE_STATUS:
|
||||
return "idle";
|
||||
case WL_NO_SSID_AVAIL:
|
||||
return "no_ssid";
|
||||
case WL_SCAN_COMPLETED:
|
||||
return "scan_completed";
|
||||
case WL_CONNECT_FAILED:
|
||||
return "connect_failed";
|
||||
case WL_CONNECTION_LOST:
|
||||
return "connection_lost";
|
||||
case WL_DISCONNECTED:
|
||||
return "disconnected";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
String wifiModeToString(wifi_mode_t mode) {
|
||||
switch (mode) {
|
||||
case WIFI_MODE_NULL:
|
||||
return "null";
|
||||
case WIFI_MODE_STA:
|
||||
return "sta";
|
||||
case WIFI_MODE_AP:
|
||||
return "ap";
|
||||
case WIFI_MODE_APSTA:
|
||||
return "ap_sta";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
String buildFallbackApSsid() {
|
||||
const uint64_t chip_id = ESP.getEfuseMac();
|
||||
const unsigned long suffix = static_cast<unsigned long>(chip_id & 0xFFFFFFULL);
|
||||
char name[32];
|
||||
snprintf(name, sizeof(name), "%s_%06lX", kFallbackApPrefix, suffix);
|
||||
return String(name);
|
||||
}
|
||||
|
||||
void enforceCoexModemSleep() {
|
||||
WiFi.setSleep(true);
|
||||
const esp_err_t err = esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
|
||||
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT && err != ESP_ERR_WIFI_NOT_STARTED) {
|
||||
Serial.printf("[WifiManager] warn: esp_wifi_set_ps(min_modem) failed err=0x%04x\n",
|
||||
static_cast<unsigned>(err));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WifiManager::WifiManager()
|
||||
: connected_(false),
|
||||
ssid_(""),
|
||||
password_(""),
|
||||
ap_active_(false),
|
||||
ap_ssid_(buildFallbackApSsid()),
|
||||
ap_password_(kFallbackApPassword),
|
||||
next_auto_reconnect_ms_(0),
|
||||
reconnect_backoff_ms_(3000),
|
||||
next_coex_reassert_ms_(0) {}
|
||||
|
||||
void WifiManager::enforceCoexPolicy() const {
|
||||
enforceCoexModemSleep();
|
||||
}
|
||||
|
||||
bool WifiManager::begin(const char* ssid, const char* password, uint32_t timeout_ms) {
|
||||
return connect(ssid ? String(ssid) : "", password ? String(password) : "", timeout_ms, true);
|
||||
}
|
||||
|
||||
bool WifiManager::connect(const String& ssid, const String& password, uint32_t timeout_ms, bool persist) {
|
||||
if (ssid.isEmpty()) {
|
||||
connected_ = false;
|
||||
notifyWifi("init_failed", "no_ssid");
|
||||
startFallbackAp();
|
||||
return false;
|
||||
}
|
||||
|
||||
ssid_ = ssid;
|
||||
password_ = password;
|
||||
|
||||
stopFallbackAp();
|
||||
WiFi.mode(WIFI_STA);
|
||||
// Keep reconnect policy manual to avoid repeated WiFi timer churn from external clients.
|
||||
WiFi.setAutoReconnect(false);
|
||||
enforceCoexPolicy();
|
||||
WiFi.disconnect(false, true);
|
||||
enforceCoexPolicy();
|
||||
delay(100);
|
||||
WiFi.begin(ssid_.c_str(), password_.c_str());
|
||||
enforceCoexPolicy();
|
||||
|
||||
connected_ = waitForConnection(timeout_ms);
|
||||
if (connected_) {
|
||||
const String link_bssid = WiFi.BSSIDstr();
|
||||
const int32_t link_channel = static_cast<int32_t>(WiFi.channel());
|
||||
Serial.printf("[WifiManager] STA connected: ssid=%s ip=%s rssi=%d ch=%ld bssid=%s\n",
|
||||
WiFi.SSID().c_str(),
|
||||
WiFi.localIP().toString().c_str(),
|
||||
static_cast<int>(WiFi.RSSI()),
|
||||
static_cast<long>(link_channel),
|
||||
link_bssid.c_str());
|
||||
if (persist) {
|
||||
WifiCredentialsStorage::save(ssid_, password_);
|
||||
}
|
||||
notifyWifi("connected");
|
||||
next_auto_reconnect_ms_ = 0;
|
||||
stopFallbackAp();
|
||||
} else {
|
||||
// Clear partial STA state/timers before switching to fallback.
|
||||
WiFi.disconnect(false, true);
|
||||
notifyWifi("connect_failed");
|
||||
next_auto_reconnect_ms_ = 0;
|
||||
startFallbackAp();
|
||||
}
|
||||
return connected_;
|
||||
}
|
||||
|
||||
bool WifiManager::reconnect(uint32_t timeout_ms) {
|
||||
if (ssid_.isEmpty()) {
|
||||
String ssid;
|
||||
String password;
|
||||
if (!WifiCredentialsStorage::load(ssid, password)) {
|
||||
notifyWifi("reconnect_failed", "no_credentials");
|
||||
return false;
|
||||
}
|
||||
ssid_ = ssid;
|
||||
password_ = password;
|
||||
}
|
||||
return connect(ssid_, password_, timeout_ms, false);
|
||||
}
|
||||
|
||||
void WifiManager::disconnect(bool erase_credentials) {
|
||||
WiFi.disconnect(true, false);
|
||||
connected_ = false;
|
||||
next_auto_reconnect_ms_ = 0;
|
||||
if (erase_credentials) {
|
||||
WifiCredentialsStorage::save("", "");
|
||||
ssid_ = "";
|
||||
password_ = "";
|
||||
}
|
||||
startFallbackAp();
|
||||
notifyWifi("disconnected");
|
||||
}
|
||||
|
||||
void WifiManager::loop() {
|
||||
const uint32_t now = millis();
|
||||
if (now >= next_coex_reassert_ms_) {
|
||||
const wifi_mode_t mode = WiFi.getMode();
|
||||
if (mode != WIFI_MODE_NULL) {
|
||||
enforceCoexPolicy();
|
||||
}
|
||||
next_coex_reassert_ms_ = now + 5000U;
|
||||
}
|
||||
|
||||
connected_ = (WiFi.status() == WL_CONNECTED);
|
||||
if (connected_) {
|
||||
next_auto_reconnect_ms_ = 0;
|
||||
stopFallbackAp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ap_active_) {
|
||||
startFallbackAp();
|
||||
}
|
||||
|
||||
// Manual reconnect only (WIFI_RECONNECT command).
|
||||
}
|
||||
|
||||
void WifiManager::ensureFallbackAp() {
|
||||
startFallbackAp();
|
||||
}
|
||||
|
||||
bool WifiManager::isConnected() const {
|
||||
return connected_;
|
||||
}
|
||||
|
||||
bool WifiManager::hasCredentials() const {
|
||||
if (!ssid_.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
String ssid;
|
||||
String password;
|
||||
return WifiCredentialsStorage::load(ssid, password);
|
||||
}
|
||||
|
||||
WifiStatusSnapshot WifiManager::status() const {
|
||||
WifiStatusSnapshot snap;
|
||||
const bool connected = (WiFi.status() == WL_CONNECTED);
|
||||
snap.connected = connected;
|
||||
snap.has_credentials = hasCredentials();
|
||||
snap.ssid = connected ? WiFi.SSID() : ssid_;
|
||||
snap.ip = connected ? WiFi.localIP().toString() : String("0.0.0.0");
|
||||
snap.rssi = connected ? WiFi.RSSI() : 0;
|
||||
snap.channel = connected ? static_cast<int32_t>(WiFi.channel()) : 0;
|
||||
snap.bssid = connected ? WiFi.BSSIDstr() : String("");
|
||||
snap.ap_active = ap_active_;
|
||||
snap.ap_ssid = ap_active_ ? ap_ssid_ : String("");
|
||||
snap.ap_ip = ap_active_ ? WiFi.softAPIP().toString() : String("0.0.0.0");
|
||||
snap.mode = wifiModeToString(WiFi.getMode());
|
||||
if (connected) {
|
||||
snap.state = "connected";
|
||||
} else if (snap.ap_active) {
|
||||
snap.state = "ap_fallback";
|
||||
} else {
|
||||
snap.state = wifiStateToString(WiFi.status());
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
void WifiManager::statusToJson(JsonObject obj) const {
|
||||
const WifiStatusSnapshot snap = status();
|
||||
obj["connected"] = snap.connected;
|
||||
obj["has_credentials"] = snap.has_credentials;
|
||||
obj["ssid"] = snap.ssid;
|
||||
obj["ip"] = snap.ip;
|
||||
obj["rssi"] = snap.rssi;
|
||||
obj["channel"] = snap.channel;
|
||||
obj["bssid"] = snap.bssid;
|
||||
obj["state"] = snap.state;
|
||||
obj["ap_active"] = snap.ap_active;
|
||||
obj["ap_ssid"] = snap.ap_ssid;
|
||||
obj["ap_ip"] = snap.ap_ip;
|
||||
obj["mode"] = snap.mode;
|
||||
}
|
||||
|
||||
void WifiManager::scanToJson(JsonArray arr, int max_networks) const {
|
||||
const int count = WiFi.scanNetworks(
|
||||
/*async=*/false,
|
||||
/*show_hidden=*/false,
|
||||
/*passive=*/false,
|
||||
/*max_ms_per_chan=*/80);
|
||||
const int limit = (max_networks > 0) ? max_networks : 20;
|
||||
for (int i = 0; i < count && i < limit; ++i) {
|
||||
JsonObject item = arr.add<JsonObject>();
|
||||
item["ssid"] = WiFi.SSID(i);
|
||||
item["rssi"] = WiFi.RSSI(i);
|
||||
item["chan"] = WiFi.channel(i);
|
||||
item["enc"] = static_cast<int>(WiFi.encryptionType(i));
|
||||
}
|
||||
WiFi.scanDelete();
|
||||
}
|
||||
|
||||
bool WifiManager::waitForConnection(uint32_t timeout_ms) {
|
||||
const uint32_t start_ms = millis();
|
||||
while (WiFi.status() != WL_CONNECTED && (millis() - start_ms) < timeout_ms) {
|
||||
delay(100);
|
||||
}
|
||||
return WiFi.status() == WL_CONNECTED;
|
||||
}
|
||||
|
||||
bool WifiManager::startFallbackAp() {
|
||||
if (ap_active_) {
|
||||
return true;
|
||||
}
|
||||
if (ap_ssid_.isEmpty()) {
|
||||
ap_ssid_ = buildFallbackApSsid();
|
||||
}
|
||||
if (ap_password_.isEmpty()) {
|
||||
ap_password_ = kFallbackApPassword;
|
||||
}
|
||||
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
// Keep a stable WiFi mode under AP+STA conditions.
|
||||
WiFi.setAutoReconnect(false);
|
||||
enforceCoexPolicy();
|
||||
const bool ok = WiFi.softAP(
|
||||
ap_ssid_.c_str(),
|
||||
ap_password_.c_str(),
|
||||
kFallbackApChannel,
|
||||
false,
|
||||
kFallbackApMaxConnections);
|
||||
enforceCoexPolicy();
|
||||
|
||||
ap_active_ = ok;
|
||||
if (ok) {
|
||||
Serial.printf("[WifiManager] fallback AP active: ssid=%s ip=%s\n",
|
||||
ap_ssid_.c_str(),
|
||||
WiFi.softAPIP().toString().c_str());
|
||||
notifyWifi("ap_active");
|
||||
} else {
|
||||
notifyWifi("ap_failed");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void WifiManager::stopFallbackAp() {
|
||||
if (!ap_active_) {
|
||||
return;
|
||||
}
|
||||
WiFi.softAPdisconnect(true);
|
||||
ap_active_ = false;
|
||||
notifyWifi("ap_stopped");
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
#ifndef WIFIMANAGER_H
|
||||
#define WIFIMANAGER_H
|
||||
|
||||
#include <WiFi.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
struct WifiStatusSnapshot {
|
||||
bool connected = false;
|
||||
bool has_credentials = false;
|
||||
String ssid;
|
||||
String ip;
|
||||
int32_t rssi = 0;
|
||||
int32_t channel = 0;
|
||||
String bssid;
|
||||
String state;
|
||||
bool ap_active = false;
|
||||
String ap_ssid;
|
||||
String ap_ip;
|
||||
String mode;
|
||||
};
|
||||
|
||||
class WifiManager {
|
||||
public:
|
||||
WifiManager();
|
||||
|
||||
bool begin(const char* ssid, const char* password, uint32_t timeout_ms = 10000);
|
||||
bool connect(const String& ssid, const String& password, uint32_t timeout_ms = 10000,
|
||||
bool persist = true);
|
||||
bool reconnect(uint32_t timeout_ms = 10000);
|
||||
void disconnect(bool erase_credentials = false);
|
||||
void loop();
|
||||
void ensureFallbackAp();
|
||||
|
||||
bool isConnected() const;
|
||||
bool hasCredentials() const;
|
||||
WifiStatusSnapshot status() const;
|
||||
void statusToJson(JsonObject obj) const;
|
||||
void scanToJson(JsonArray arr, int max_networks = 20) const;
|
||||
|
||||
private:
|
||||
void enforceCoexPolicy() const;
|
||||
|
||||
bool connected_;
|
||||
String ssid_;
|
||||
String password_;
|
||||
bool ap_active_;
|
||||
String ap_ssid_;
|
||||
String ap_password_;
|
||||
mutable uint32_t next_auto_reconnect_ms_;
|
||||
uint32_t reconnect_backoff_ms_;
|
||||
uint32_t next_coex_reassert_ms_;
|
||||
|
||||
bool startFallbackAp();
|
||||
void stopFallbackAp();
|
||||
bool waitForConnection(uint32_t timeout_ms);
|
||||
};
|
||||
|
||||
#endif // WIFIMANAGER_H
|
||||
@@ -1,3 +0,0 @@
|
||||
#include "wifi/WifiManager.h"
|
||||
|
||||
WifiManager g_wifi;
|
||||
Reference in New Issue
Block a user