chore: retire l'arbre Arduino legacy, doc ESP-IDF
Repo State / repo-state (push) Failing after 24s

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:
clement
2026-06-21 21:54:15 +02:00
parent 8625665a41
commit dbd789f335
60 changed files with 0 additions and 12319 deletions
-113
View File
@@ -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>
-433
View File
@@ -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");
});
-116
View File
@@ -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
View File
@@ -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>
-108
View File
@@ -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;
}
-49
View File
@@ -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
-48
View File
@@ -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_;
}
-23
View File
@@ -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
-31
View File
@@ -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();
}
-26
View File
@@ -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
-316
View File
@@ -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
-66
View File
@@ -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();
}
-25
View File
@@ -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
-157
View File
@@ -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;
}
-30
View File
@@ -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
-149
View File
@@ -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;
}
-32
View File
@@ -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
-954
View File
@@ -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;
}
-124
View File
@@ -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
-47
View File
@@ -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
-33
View File
@@ -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);
}
}
-27
View File
@@ -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_;
};
-71
View File
@@ -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;
}
-34
View File
@@ -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
-37
View File
@@ -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";
}
}
-19
View File
@@ -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
-29
View File
@@ -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();
}
-20
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-317
View File
@@ -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
-25
View File
@@ -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;
}
-18
View File
@@ -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
-339
View File
@@ -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_++;
}
}
-55
View File
@@ -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
-82
View File
@@ -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");
}
}
-27
View File
@@ -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
-108
View File
@@ -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;
}
}
-26
View File
@@ -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
-80
View File
@@ -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();
}
-33
View File
@@ -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
-27
View File
@@ -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
-30
View File
@@ -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();
}
-19
View File
@@ -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
-148
View File
@@ -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);
}
}
}
}
-22
View File
@@ -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_;
};
-747
View File
@@ -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_;
}
-115
View File
@@ -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
-18
View File
@@ -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
-228
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
#pragma once
namespace usb_msc_runtime {
bool beginUsbMassStorage();
} // namespace usb_msc_runtime
-126
View File
@@ -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
}
-35
View File
@@ -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
-492
View File
@@ -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);
}
-64
View File
@@ -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
-230
View File
@@ -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");
});
-27
View File
@@ -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
-318
View File
@@ -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");
}
-58
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
#include "wifi/WifiManager.h"
WifiManager g_wifi;