From 7da6ea37e74708fbca5e34bd99c166f33570def5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Cl=C3=A9ment=20SAILLANT?=
<108685187+electron-rare@users.noreply.github.com>
Date: Mon, 23 Feb 2026 11:54:09 +0100
Subject: [PATCH] feat: Enhance BluetoothManager with audio bridge and
auto-reconnect features
- Added AudioEngine integration to BluetoothManager for audio handling.
- Implemented auto-reconnect functionality with enabling/disabling options.
- Introduced methods for managing audio state and HFP events.
- Updated BluetoothManager's internal state management for better audio handling.
refactor: Update A252ConfigStore to improve pin validation logic
- Renamed critical_pins to required_pins for clarity.
- Added optional legacy line-enable pin validation.
- Improved error handling for pin conflicts and invalid ranges.
fix: Adjust default volume and slic_line configuration in A252ConfigStore
- Set default slic_line to -1 to indicate retirement.
- Increased default audio volume from 80 to 90.
feat: Implement audio amplifier control in main application
- Added commands to enable/disable audio amplifier via GPIO.
- Integrated audio amplifier control into the setup process.
fix: Improve ESP-NOW and WiFi coexistence policies
- Enforced modem sleep for better coexistence between WiFi and Bluetooth.
- Added checks to ensure proper WiFi mode before enforcing coexistence.
feat: Enhance TelephonyService with DTMF and pulse dialing support
- Added DTMF capture and processing capabilities.
- Implemented rotary pulse dialing logic with state management.
- Introduced callbacks for dialing and answering actions.
feat: Add real-time event publishing to WebServerManager
- Enabled real-time status updates via SSE.
- Improved status caching and thread safety with critical sections.
fix: General code cleanup and optimizations across modules
- Refactored various methods for better readability and maintainability.
- Improved error handling and logging for better debugging.
---
README.md | 9 +
data/webui/index.html | 2 +
data/webui/script.js | 48 +-
docs/AGENT_TODO.md | 79 +--
docs/SPECS_STATE.md | 89 +++-
docs/audit_initial_audio.md | 88 +++-
docs/espnow_api_v1.md | 5 +-
docs/rapport_tests_fonctionnels.md | 49 ++
docs/solutions_rtc_phone_esp32.md | 26 +-
docs/spec_bt_hfp_pbap_dialing_v1.md | 22 +-
platformio.ini | 4 +
src/audio/AudioEngine.cpp | 457 ++++++++++++++++-
src/audio/AudioEngine.h | 45 ++
src/audio/Es8388Driver.cpp | 77 ++-
src/bluetooth/BluetoothManager.cpp | 756 ++++++++++++++++++++++++++--
src/bluetooth/BluetoothManager.h | 36 ++
src/config/A252ConfigStore.cpp | 18 +-
src/config/A252ConfigStore.h | 4 +-
src/main.cpp | 92 +++-
src/props/EspNowBridge.cpp | 23 +-
src/telephony/TelephonyService.cpp | 316 +++++++++++-
src/telephony/TelephonyService.h | 34 ++
src/web/WebServerManager.cpp | 76 ++-
src/web/WebServerManager.h | 3 +
src/wifi/WifiManager.cpp | 45 +-
src/wifi/WifiManager.h | 3 +
26 files changed, 2236 insertions(+), 170 deletions(-)
diff --git a/README.md b/README.md
index 737c77f..a6328e7 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,15 @@ _Agent Repo & GitHub – README généré automatiquement._
- Si décroché pendant sonnerie : `answer` automatique.
- Si raccroché pendant appel : `end/reject` automatique.
+## Wiring A252 validé (bench courant)
+- `SLIC RM` -> `GPIO18`
+- `SLIC FR` -> `GPIO5`
+- `SLIC SHK` -> `GPIO23` (`INPUT_PULLUP`, hook actif haut)
+- `SLIC PD` -> `GPIO19`
+- `SLIC LINE` -> non utilisé (`-1`, logique retirée du runtime)
+- `AMP_EN` carte audio -> `GPIO21`, polarité active bas (`LOW=ON`, `HIGH=OFF`)
+- tonalité locale: `425 Hz` (couleur France/Europe)
+
## Choix de cartes ESP32
Voir `docs/solutions_rtc_phone_esp32.md` pour la shortlist des DevKit utilisables (ESP32-DevKitC, ESP32-S3-DevKitC-1, NodeMCU-32S, LOLIN32), les liens de référence web, et les solutions d’interface (direct combiné/clavier, SLIC/FXS, ATA externe), dont une variante AG1171S (Silvertel).
diff --git a/data/webui/index.html b/data/webui/index.html
index 88dc947..ed6d485 100644
--- a/data/webui/index.html
+++ b/data/webui/index.html
@@ -130,6 +130,8 @@
+
+
diff --git a/data/webui/script.js b/data/webui/script.js
index 0884920..25b825c 100644
--- a/data/webui/script.js
+++ b/data/webui/script.js
@@ -6,6 +6,7 @@ const SECTION_MAP = {
};
let realtimeSource = null;
let realtimeConnected = false;
+let fallbackPollingTimer = null;
function showSection(section) {
Object.values(SECTION_MAP).forEach((id) => {
@@ -90,11 +91,12 @@ function applyStatusSnapshot(status) {
const peers = status.espnow?.peer_count ?? 0;
const btCallState = status.bluetooth?.call_state || "n/a";
const btConnected = status.bluetooth?.connected ? "on" : "off";
+ const btAutoReconnect = status.bluetooth?.auto_reconnect_enabled ? "on" : "off";
const pbapSupported = status.bluetooth?.pbap_supported ? "yes" : "no";
const liveState = realtimeConnected ? "live=on" : "live=off";
line.textContent =
`board=${status.board_profile || "n/a"} telephony=${telephonyState} hook=${hook} ` +
- `wifi=${wifiState} mqtt=${mqttConnected} espnow_peers=${peers} bt=${btConnected} bt_call=${btCallState} pbap=${pbapSupported} ${liveState}`;
+ `wifi=${wifiState} mqtt=${mqttConnected} espnow_peers=${peers} bt=${btConnected} bt_auto=${btAutoReconnect} bt_call=${btCallState} pbap=${pbapSupported} ${liveState}`;
setJson("statusJson", status);
if (status.wifi) {
@@ -122,6 +124,10 @@ function connectRealtime() {
realtimeSource = new EventSource("/api/events");
+ realtimeSource.onopen = () => {
+ realtimeConnected = true;
+ };
+
realtimeSource.addEventListener("hello", () => {
realtimeConnected = true;
});
@@ -159,6 +165,17 @@ function connectRealtime() {
};
}
+function ensureFallbackPolling() {
+ if (fallbackPollingTimer !== null) {
+ return;
+ }
+ fallbackPollingTimer = window.setInterval(() => {
+ if (!realtimeConnected) {
+ refreshStatus().catch(() => {});
+ }
+ }, 2000);
+}
+
async function refreshStatus() {
try {
const status = await requestJson("/api/status");
@@ -606,6 +623,34 @@ function bindEvents() {
}
});
+ document.getElementById("btAutoReconnectOnBtn").addEventListener("click", async () => {
+ try {
+ const result = await requestJson("/api/bluetooth/hfp/auto", {
+ method: "POST",
+ headers: jsonHeaders(),
+ body: JSON.stringify({ enabled: true }),
+ });
+ setJson("actionResult", result);
+ await Promise.all([refreshBluetooth(), refreshStatus()]);
+ } catch (error) {
+ setJson("actionResult", { error: error.message });
+ }
+ });
+
+ document.getElementById("btAutoReconnectOffBtn").addEventListener("click", async () => {
+ try {
+ const result = await requestJson("/api/bluetooth/hfp/auto", {
+ method: "POST",
+ headers: jsonHeaders(),
+ body: JSON.stringify({ enabled: false }),
+ });
+ setJson("actionResult", result);
+ await Promise.all([refreshBluetooth(), refreshStatus()]);
+ } catch (error) {
+ setJson("actionResult", { error: error.message });
+ }
+ });
+
document.getElementById("btPbapSyncBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/pbap/sync", {
@@ -672,6 +717,7 @@ function bindEvents() {
document.addEventListener("DOMContentLoaded", async () => {
bindEvents();
connectRealtime();
+ ensureFallbackPolling();
await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork(), refreshBluetooth()]);
showSection("dashboard");
});
diff --git a/docs/AGENT_TODO.md b/docs/AGENT_TODO.md
index 5339835..e400d92 100644
--- a/docs/AGENT_TODO.md
+++ b/docs/AGENT_TODO.md
@@ -1,35 +1,54 @@
-# Gates & objectifs — Phase suivante RTC_BL_PHONE
+# RTC_BL_PHONE Kanban (Live Execution)
-## [2026-02-20] Alignement multi-repos (RTC + Zacus + Kill_LIFE)
-- Compatibilité ESP-NOW renforcée côté RTC:
- - commandes runtime `ESPNOW_ON` / `ESPNOW_OFF`
- - extraction de commande bridge depuis payload JSON imbriqué (`event/message/payload`)
-- Correctif mesures ESP-NOW:
- - suppression du double comptage `tx_ok` (ack + callback)
-- Base méthodologique importée depuis Kill_LIFE:
- - gates minimales Spec/Build/Test/Release
- - evidence pack et standards firmware de référence
- - documentée dans `docs/CROSS_REPO_INTELLIGENCE.md`
+## Todo
+- Validate hardware gate `incoming GSM -> RTC ring -> BT_ANSWER` on `/dev/cu.usbserial-0001`.
+- Validate hardware gate `dial len10 (pulse)` with queued HFP reconnection then auto dial.
+- Validate hardware gate `dial len10 (DTMF)` with no false pulse digits.
+- Re-run `ESP-NOW` 2-board validation when peer card is available.
-## Gates prioritaires
-- Extension endpoints HTTP : audio, batterie, rtos, bluetooth, wifi
-- Sécurisation endpoints : authentification, validation, gestion des droits
-- Tests fonctionnels avancés : charge, robustesse, scénarios multi-utilisateurs
-- Documentation endpoints et API : structure, exemples, onboarding
-- CI : validation automatisée, reporting, traçabilité
+## In Progress
+- Track `rtos-coex-wifi-bt`:
+ - endurance monitor 10 min sans `abort/assert`.
+- Track `bt-hfp-stability`:
+ - validation E2E entrant GSM -> ring RTC -> décroché -> `BT_ANSWER`.
+- Track `webui-runtime`:
+ - valider charge SSE en AP fallback + STA connecté.
-## Objectifs
-- Couverture complète des modules audio, SLIC, téléphone, RTOS, Bluetooth, Wifi
-- Robustesse et sécurité des interfaces web
-- Traçabilité des artefacts, logs et verdicts
-- Onboarding et documentation utilisateur/technique
+## Blocked
+- `PBAP` contact sync on Arduino ESP32 Bluedroid stack (`BT_PBAP_SYNC` remains `unsupported`).
+- Full ESP-NOW E2E protocol proof while second board is unavailable.
+- Notion sync automation blocked until MCP Notion OAuth session is reconnected.
-## Actions à lancer
-- Développement endpoints manquants
-- Implémentation sécurité et validation
-- Création/extension des tests fonctionnels
-- Mise à jour documentation et scripts CI
+## Done
+- Added HFP auto reconnect runtime control (`BT_AUTO_RECONNECT_ON/OFF`) and status flag.
+- Preserved dial queue logic (`pending_dial_number`) with auto dial after SLC.
+- Added `/api/bluetooth/hfp/auto` endpoint (GET status via `BT_STATUS`, POST toggle).
+- Added WebUI controls for BT auto reconnect and status line visibility.
+- Re-enabled SSE transport server-side and added browser fallback polling.
+- Hardened ESP-NOW init to preserve WiFi mode and apply WiFi/BT modem sleep policy.
+- Boot order adjusted to initialize WiFi + ESP-NOW before BT stack bring-up.
+- Re-asserted WiFi/BT coex policy periodically in `WifiManager::loop()`.
+- Added coex policy enforcement in BT stack bring-up (`BluetoothManager::ensureBtStackReady()`).
+- Reduced GAP callback logging pressure (guarded by `kVerboseGapLogs=false`).
+- Reduced AsyncTCP memory footprint (`STACK=4096`, `QUEUE=12`) to lower RTOS pressure.
+- Reordered main loop to run `g_wifi.loop()` before `g_bt.tick()`.
+- Updated GitHub issues `#10` and `#11` with latest CI/HW proof links:
+ - `https://github.com/electron-rare/RTC_BL_PHONE/issues/10#issuecomment-3941685856`
+ - `https://github.com/electron-rare/RTC_BL_PHONE/issues/11#issuecomment-3941685860`
----
-
-**Version :** 2026-02-17
+## Run Ledger
+- `2026-02-22`:
+ - Scope: coex WiFi/BT hardening + HFP autoradio policy + realtime WebUI + docs sync.
+ - Target port: `/dev/cu.usbserial-0001`.
+ - Firmware hash (sha256): `2c345d5ac459e6aa914f47438cc35bbe33dda68e5a65f41eaf3d8f1efb53e833`.
+ - Executed:
+ - `platformio run -e esp32dev` -> `PASS`
+ - `platformio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> `PASS`
+ - `python3 scripts/hw_validation.py --port-a252 /dev/cu.usbserial-0001 --report-json artifacts/hw_validation_report.json --report-md artifacts/hw_validation_report.md` -> `PASS`
+ - `bash scripts/test_terminal.sh` -> `PASS`
+ - `python3 -m unittest scripts/test_check_web_route_parity.py` -> `PASS`
+ - `python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` -> `PASS`
+ - Artifacts:
+ - `artifacts/hw_validation_report.json`
+ - `artifacts/hw_validation_report.md`
+ - `artifacts/route_parity_report.json`
diff --git a/docs/SPECS_STATE.md b/docs/SPECS_STATE.md
index 15f4bac..542e60f 100644
--- a/docs/SPECS_STATE.md
+++ b/docs/SPECS_STATE.md
@@ -22,7 +22,7 @@
- `platformio run -e esp32dev` : OK
- `platformio test --without-uploading --without-testing -e esp32dev` : OK
-- `scripts/check_web_route_parity.py` (local) : backend routes 29, frontend routes 29, check passé
+- `scripts/check_web_route_parity.py` (local) : backend routes 39, frontend routes 38, check passé
- `scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` : OK (report généré)
- `platformio run -e esp32-s3-devkitc-1` : échec connu sur liens Bluetooth/HFP externes (suivi séparé)
@@ -30,14 +30,14 @@
- HFP commande-level: OK (connect/disconnect commandes acceptées).
- HFP call-control AT: implémenté (`BT_DIAL`/`DIAL`, `BT_REDIAL`, `BT_ANSWER`, `BT_HANGUP`, `BT_CALLS`).
+- Discoverable policy: OK (si aucun client BT connecté, `discoverable=true` forcé; validation locale via `BT_STATUS` avant/après `BT_DISCOVERABLE_OFF`).
- HFP liaison réelle: KO sur tests assistés (pas de montée `connected=true`/`hfp_active=true`).
- PBAP: bloqué sur stack actuelle (`arduino-esp32` bluedroid sans API PBAP exposée côté firmware), `BT_PBAP_SYNC` retourne `unsupported`.
- Numérotation HFP/AT: implémentée côté firmware, validation E2E téléphone encore à obtenir (dépend de la liaison HFP réelle).
## 4) Lacunes à combler pour passer la spec en `DONE`
-1. Mettre à jour les issues de pilotage (`#10`, `#11`) avec les liens vers les runs CI et l’artefact.
-2. Conserver le suivi S3 Bluetooth/HFP hors scope parity V1.
+1. Conserver le suivi S3 Bluetooth/HFP hors scope parity V1.
## 5) Risques actifs
@@ -88,3 +88,86 @@
- HFP opérable (stack actuelle): `artifacts/hfp_operational_report.json` -> `PASS` (discoverable on/off ok, `BT_DIAL` sans SLC en erreur contrôlée, `BT_PBAP_SYNC unsupported` explicite)
- ESP-NOW protocole v1: `artifacts/espnow_protocol_v1_report.json` -> `FAIL` en bench 1 carte (pas de peer radio actif, `ESPNOW_SEND` retourne `ERR`)
- Décision de gate: WiFi/WebUI/Bluetooth HFP command-level `GO`; ESP-NOW E2E v1 two-board `BLOCKED` jusqu’à disponibilité d’une 2e carte
+
+### Addendum (2026-02-22)
+
+- Firmware reflash `esp32dev` sur `/dev/cu.usbserial-0001` effectué.
+- Validation BT ready-for-pairing: `artifacts/bt_hfp_readiness.json`
+ - `discoverable=true` sans client connecté.
+ - `BT_CALLS`/`BT_DIAL` en erreur contrôlée hors SLC.
+ - `BT_PBAP_SYNC` retourne `ERR ... unsupported` (attendu).
+- Validation discoverable forcé: `artifacts/bt_discoverable_policy_check.json`
+ - `BT_DISCOVERABLE_OFF` accepté mais état final reste `discoverable=true` tant que non connecté.
+- ESP-NOW (tests minimisés): `artifacts/espnow_peer91_probe_clean.json`
+ - peer `10:20:BA:58:C7:48` ajouté.
+ - envoi unitaire observé avec `tx_fail=1` côté A252 (non bloquant pour le track Bluetooth).
+
+### Addendum (2026-02-22 — wiring/polarité/audio)
+
+- Polarité `GPIO21` verrouillée: `AMP_EN` actif bas (`LOW=ON`, `HIGH=OFF`).
+- `SLIC LINE` retiré du runtime (broche non utilisée en bench courant).
+- Mapping bench confirmé:
+ - `RM=GPIO18`, `FR=GPIO5`, `SHK=GPIO23`, `PD=GPIO19`.
+- Tonalité locale fixée à `425 Hz` et niveau augmenté (boost léger) sans changement de timbre.
+- Durcissement Bluetooth en cours:
+ - reprise explicite sur `ACL_DISCONN`,
+ - retry audio SCO piloté par état d'appel (`dialing/alerting/ringing/active`).
+
+### Addendum (2026-02-22 — dial/ring policy BT)
+
+- Spécification ajoutée:
+ - `DIAL`/`BT_DIAL` doit accepter une demande même sans SLC immédiat:
+ - queue numéro,
+ - déclenche/reprend connexion HFP,
+ - compose automatiquement après `SLC_CONNECTED`.
+ - Appel entrant GSM via HFP (`call_state=ringing`) doit déclencher la sonnerie RTC.
+ - Décroché RTC en phase sonnerie doit déclencher `BT_ANSWER`.
+- Précondition produit explicitée:
+ - sans lien HFP actif, la sonnerie RTC sur appel GSM n'est pas possible;
+ - mitigation firmware: auto-reconnect sur peer bondé + discoverable forcé si non connecté.
+- Statut:
+ - implémentation firmware: `in_progress` (queue dial + auto reconnect renforcés),
+ - validation hardware E2E appel entrant/sortant: `pending`.
+
+### Addendum (2026-02-22 — autonomous execution batch)
+
+- Runtime policy applied:
+ - HFP auto reconnect remains enabled by default (`auto_reconnect_enabled=true`).
+ - New runtime toggles available:
+ - `BT_AUTO_RECONNECT_ON`
+ - `BT_AUTO_RECONNECT_OFF`
+ - `BT_STATUS` now exposes `auto_reconnect_enabled`.
+- Web/API alignment:
+ - endpoint added: `POST /api/bluetooth/hfp/auto` with body `{"enabled":true|false}`.
+ - endpoint readback: `GET /api/bluetooth/hfp/auto` via `BT_STATUS`.
+ - SSE realtime path re-enabled (`/api/events`) with client polling fallback.
+- Coexistence hardening:
+ - ESP-NOW init now preserves current WiFi mode and applies modem sleep policy for WiFi/BT coexistence.
+ - setup order adjusted: WiFi + ESP-NOW before BT startup.
+- Tracking:
+ - `docs/AGENT_TODO.md` converted to live kanban with run ledger.
+
+### Addendum (2026-02-22 — coex/runtime hardening batch 2)
+
+- Build + flash:
+ - `platformio run -e esp32dev` -> `PASS`
+ - `platformio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> `PASS`
+- Runtime/bench:
+ - `python3 scripts/hw_validation.py --port-a252 /dev/cu.usbserial-0001 --report-json artifacts/hw_validation_report.json --report-md artifacts/hw_validation_report.md` -> `PASS`
+ - firmware hash (sha256): `2c345d5ac459e6aa914f47438cc35bbe33dda68e5a65f41eaf3d8f1efb53e833`
+- Coexistence hardening applied:
+ - réimposition périodique `WIFI_PS_MIN_MODEM` dans `WifiManager::loop()`.
+ - réimposition coex avant/après `btStart()` dans `BluetoothManager::ensureBtStackReady()`.
+ - logs GAP en callback réduits (`kVerboseGapLogs=false`) pour éviter contention newlib/locks en task BT.
+ - pression mémoire AsyncTCP réduite (`STACK=4096`, `QUEUE=12`).
+- Qualif scripts:
+ - `bash scripts/test_terminal.sh` -> `PASS`
+ - `python3 -m unittest scripts/test_check_web_route_parity.py` -> `PASS`
+ - `python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` -> `PASS`
+- Résiduel:
+ - endurance 10 min sans assert à confirmer en monitor live.
+ - validation E2E entrant GSM -> ring RTC -> `BT_ANSWER` encore `pending`.
+- Gouvernance:
+ - Issues mises à jour avec preuves:
+ - `#10`: `https://github.com/electron-rare/RTC_BL_PHONE/issues/10#issuecomment-3941685856`
+ - `#11`: `https://github.com/electron-rare/RTC_BL_PHONE/issues/11#issuecomment-3941685860`
diff --git a/docs/audit_initial_audio.md b/docs/audit_initial_audio.md
index f007446..f8e46b4 100644
--- a/docs/audit_initial_audio.md
+++ b/docs/audit_initial_audio.md
@@ -1,14 +1,82 @@
-# Audit initial Audio
+# Audit Audio / BT / RTOS — état et plan d'enchaînement
-## Robustesse
-- Instanciation, configuration codec OK
-- Aucun crash lors des appels de méthodes
+## État validé (run local USB `/dev/cu.usbserial-0001`)
+- Boot stable: audio + codec + web server OK.
+- Capture audio: arbitrage multi-clients en place (`TELEPHONY`, `BLUETOOTH`, `GENERIC`).
+- Bluetooth HFP:
+- plus de crash immédiat au boot.
+- plus d'auto-connect agressif au boot (pair bond restauré, connexion sur demande).
+- dial peut rester en queue (`pending_dial_number`) si SLC non prêt.
+- Coexistence WiFi/BT:
+- erreurs fatales/abort au boot réduites après déport d'actions callback vers `tick()`.
-## Fiabilité
-- Méthodes stables, pas d’erreur détectée
+## Correctifs clés appliqués
+- `AudioEngine`:
+- `requestCapture/releaseCapture` thread-safe.
+- task audio avec backoff idle (charge RTOS réduite).
+- `TelephonyService` / `BluetoothManager`:
+- plus de `startCapture/stopCapture` concurrents.
+- utilisation des clients de capture dédiés.
+- `BluetoothManager`:
+- déport de `applyDiscoverablePolicy/publishBleStatus` hors callbacks.
+- suppression d’auto-reconnect HFP au boot.
+- timeout SLC en mode backoff (sans disconnect agressif).
+- désactivation `AgentSupervisor` côté BT pour retirer allocations C++ en callback.
-## Points à surveiller
-- Tests hardware à approfondir
+## Plan exécutable (skills coordonnés)
----
-_Audit généré automatiquement._
+### Track 1 — `esp32-rtos-coex-triage` (en cours)
+- Objectif: zéro reboot/assert sur séquence boot + WiFi STA + BT activé.
+- Actions:
+- garder le BT callback-safe (déjà fait).
+- surveiller `wifi: fail to alloc timer` sur endurance.
+- livrer un rapport de stabilité sur 10 min.
+
+### Track 2 — `esp32-bt-hfp-stability` (prochaine étape)
+- Objectif: connexion GSM stable, SLC up, dial auto depuis buffer len10.
+- Actions:
+- test live: `BT_HFP_CONNECT`, `BT_STATUS`, appel sortant/entrant.
+- valider transition `pending_dial_number -> dial requested` quand SLC connecté.
+- verrouiller comportement discoverable auto si non connecté.
+
+### Track 3 — `esp32-audio-runtime-gating` (prochaine étape)
+- Objectif: tonalité et audio call sans glitch ni coupure.
+- Actions:
+- vérifier arrêt instant tonalité au 1er digit.
+- vérifier arrêt tonalité immédiat au raccroché.
+- valider chemin HFP audio I2S en appel actif.
+
+## Critères de sortie
+- Aucun `abort/assert` pendant test run.
+- Aucun `vQueueDelete queue.c` observé.
+- Numérotation 10 chiffres pulse/DTMF déclenche `BT_DIAL` avec succès quand SLC monte.
+- Appel entrant GSM: ring, décroché, audio duplex.
+
+## Plan autonome coordonné (skills + agents)
+
+- Skill utilisé pour cadrage spec->implémentation: `notion-spec-to-implementation` (adapté en mode repo local).
+- Agent `bt-hfp-stability`:
+ - garantir `DIAL => queue + connect HFP + auto-dial post-SLC`.
+ - garder `discoverable=true` si aucun client BT connecté.
+- Agent `rtos-coex-wifi-bt`:
+ - forcer `WIFI_PS_MIN_MODEM` en STA/APSTA pour coexistence WiFi+BT.
+ - supprimer les reboots `Should enable WiFi modem sleep ...`.
+- Agent `telephony-call-routing`:
+ - appel entrant HFP (`ringing`) => sonnerie RTC.
+ - décroché RTC en sonnerie => `BT_ANSWER`.
+- Gate de validation utilisateur (bench USB):
+ - confirmer logs `dial_trigger ... ok=true` puis `hfp_slc_connected`/`hfp_dial_requested`.
+ - confirmer appel entrant GSM => sonnerie RTC immédiate.
+
+## Exécution autonome (batch courant)
+- `main.cpp`:
+ - ordre de boot ajusté pour réduire contention WiFi/BT (`WiFi/ESP-NOW` avant `BT begin`).
+ - commandes runtime ajoutées: `BT_AUTO_RECONNECT_ON`, `BT_AUTO_RECONNECT_OFF`.
+- `BluetoothManager`:
+ - flag runtime `auto_reconnect_enabled` exposé dans `BT_STATUS`.
+ - reconnexion auto conditionnée par policy + queue dial.
+- `WebServerManager` + WebUI:
+ - SSE réactivé (`/api/events`) + fallback polling navigateur.
+ - endpoint ajouté: `/api/bluetooth/hfp/auto`.
+- `EspNowBridge`:
+ - init WiFi coex durci (mode préservé + modem sleep policy).
diff --git a/docs/espnow_api_v1.md b/docs/espnow_api_v1.md
index f007013..24b9a99 100644
--- a/docs/espnow_api_v1.md
+++ b/docs/espnow_api_v1.md
@@ -14,6 +14,7 @@ Normaliser les trames ESP-NOW pour:
```json
{
+ "proto": "rtcbl/1",
"msg_id": "req-001",
"seq": 1,
"type": "command",
@@ -26,9 +27,10 @@ Normaliser les trames ESP-NOW pour:
```
Règles:
+- `proto=rtcbl/1` recommandé (toléré absent pour compat).
- `msg_id` sert à corréler la réponse.
- `seq` est un compteur local de trame (recommandé monotone par source).
-- `type=command` déclenche l'exécution côté firmware.
+- `type=command|request|cmd` déclenche l'exécution côté firmware.
- `ack=true` demande une réponse corrélée.
- `payload.cmd` obligatoire pour une commande dispatcher.
- `payload.args` optionnel; sérialisé puis passé au dispatcher.
@@ -37,6 +39,7 @@ Règles:
```json
{
+ "proto": "rtcbl/1",
"msg_id": "req-001",
"seq": 1,
"type": "ack",
diff --git a/docs/rapport_tests_fonctionnels.md b/docs/rapport_tests_fonctionnels.md
index 541d9be..def9c94 100644
--- a/docs/rapport_tests_fonctionnels.md
+++ b/docs/rapport_tests_fonctionnels.md
@@ -94,3 +94,52 @@
- ESP-NOW:
- `ESPNOW_STATUS.ready=true`.
- `ESPNOW_SEND` v1 et legacy en échec sur banc mono-carte (pas de pair receveur actif), à retester en configuration 2 cartes.
+
+## Exécution batch autoradio (2026-02-22)
+
+- Scope:
+ - reconnect HFP persistant type autoradio,
+ - dial queue + auto dial post-SLC,
+ - toggle runtime auto reconnect,
+ - SSE WebUI live + fallback polling,
+ - hardening coex ESP-NOW/WiFi/BT.
+- Changements appliqués:
+ - commandes série: `BT_AUTO_RECONNECT_ON/OFF`.
+ - endpoint HTTP: `POST /api/bluetooth/hfp/auto`.
+ - statut BT enrichi: `auto_reconnect_enabled`.
+- Validation manuelle attendue sur banc:
+ - `incoming GSM -> RTC ring -> décroché -> BT_ANSWER`.
+ - `dial len10 pulse` et `dial len10 dtmf` avec logs:
+ - `dial_trigger ... ok=true`
+ - `hfp_slc_connected`
+ - `hfp_dial_requested`.
+
+## Exécution batch coex/runtime (2026-02-22, run 2)
+
+- Firmware:
+ - sha256 `.pio/build/esp32dev/firmware.bin`:
+ - `2c345d5ac459e6aa914f47438cc35bbe33dda68e5a65f41eaf3d8f1efb53e833`
+- Build/flash:
+ - `platformio run -e esp32dev` -> `PASS`
+ - `platformio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> `PASS`
+- Validation:
+ - `python3 scripts/hw_validation.py --port-a252 /dev/cu.usbserial-0001 --report-json artifacts/hw_validation_report.json --report-md artifacts/hw_validation_report.md` -> `PASS`
+ - `bash scripts/test_terminal.sh` -> `PASS`
+ - `python3 -m unittest scripts/test_check_web_route_parity.py` -> `PASS`
+ - `python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` -> `PASS`
+
+### Correctifs inclus
+
+- Coex WiFi/BT:
+ - réimposition périodique modem sleep (`WIFI_PS_MIN_MODEM`) côté `WifiManager`.
+ - réimposition coex au bring-up stack BT (`BluetoothManager`).
+- Stabilité BT callbacks:
+ - logs GAP lourds désactivés par défaut (`kVerboseGapLogs=false`).
+- Pression RTOS/réseau:
+ - `CONFIG_ASYNC_TCP_STACK_SIZE=4096`
+ - `CONFIG_ASYNC_TCP_QUEUE_SIZE=12`
+
+### Reste à valider manuellement (bench)
+
+- endurance monitor 10 min sans `abort/assert`.
+- appel entrant GSM: sonnerie RTC + décroché RTC -> `BT_ANSWER`.
diff --git a/docs/solutions_rtc_phone_esp32.md b/docs/solutions_rtc_phone_esp32.md
index 2ddf6b9..47e189b 100644
--- a/docs/solutions_rtc_phone_esp32.md
+++ b/docs/solutions_rtc_phone_esp32.md
@@ -63,10 +63,12 @@ void setup() {
| Fonction | ESP32 Audio Kit V2.2 Pin | Direction | Remarque |
|--------------------|--------------------------|-----------|-------------------------------------------|
-| hookSense | GPIO36 (ADC1_CH0) | IN | Crochet (INPUT_PULLUP) |
-| ringCmd | GPIO21 | OUT | Commande sonnerie (GPIO dispo) |
-| lineEnable | GPIO19 | OUT | Activation ligne (GPIO dispo) |
-| led | GPIO22 | OUT | Debug LED (ou autre GPIO libre) |
+| hookSense (SHK) | GPIO23 | IN | Crochet (INPUT_PULLUP, actif haut) |
+| ringCmd (RM) | GPIO18 | OUT | Commande sonnerie |
+| ringFreq (FR) | GPIO5 | OUT | Modulation sonnerie |
+| powerDown (PD) | GPIO19 | OUT/OD | Pilotage power-down SLIC |
+| lineEnable | non utilisé | - | Logique retirée du runtime (`-1`) |
+| ampEnable (AMP_EN) | GPIO21 | OUT | Ampli audio, **actif bas** (`LOW=ON`) |
| I2S BCK | GPIO27 | OUT | I2S0_BCK_OUT (vers ES8388, casque, HP) |
| I2S WS | GPIO25 | OUT | I2S0_WS_OUT (vers ES8388) |
| I2S DIN | GPIO26 | OUT | I2S0_DO_OUT (vers ES8388) |
@@ -75,8 +77,8 @@ void setup() {
- **Aucun conflit de pin détecté** avec cette configuration sur ESP32 Audio Kit V2.2 A252.
- Les pins I2S sont câblés d'origine vers le codec ES8388 (sortie casque, HP, entrée micro, etc.).
-- GPIO34/36 sont disponibles pour entrées analogiques (micro, hook, etc.).
-- GPIO21/19/22 sont libres sur la carte pour la logique RTC.
+- GPIO34/36 restent disponibles pour entrées analogiques supplémentaires.
+- GPIO21 est réservé à `AMP_EN` (actif bas), ne pas l'utiliser pour `LINE`.
**À ajuster selon ton routage réel et la disponibilité des broches sur ta carte.**
## Options de câblage audio ESP32 <-> SLIC K50835F
@@ -171,10 +173,11 @@ void loop() {
+-------------------+ +---------------------+
| ESP32 | | SLIC K50835F |
| | | |
- | GPIO4 <-------- |---HOOK--| > Hook sense |
- | GPIO5 --------> |---RING--| < Ring control |
- | GPIO6 --------> |---LINE--| < Line enable |
- | GPIO48 --------> |---LED---| (debug, optionnel) |
+ | GPIO23 <-------- |---SHK---| > Hook sense |
+ | GPIO18 --------> |---RM----| < Ring control |
+ | GPIO5 --------> |---FR----| < Ring modulation |
+ | GPIO19 --------> |---PD----| < Power down ctrl |
+ | GPIO21 --------> |--AMP_EN-| (actif bas) |
| | | |
| I2S_OUT ------+ | | +-- AUDIO_IN |
| |--|---------|--| |
@@ -183,7 +186,8 @@ void loop() {
```
**Explications :**
-- Les signaux HOOK, RING, LINE sont à adapter selon le schéma d’application du SLIC K50835F (niveau logique, polarité, etc.).
+- Les signaux SHK/RM/FR/PD sont à adapter selon le schéma d’application du SLIC K50835F.
+- La broche `LINE` n'est pas utilisée dans la config bench validée.
- L’audio analogique transite via un codec I2S (ex : PCM5102, ES8388) entre l’ESP32 et le SLIC K50835F.
- Prévoir adaptation d’impédance et filtrage sur les lignes audio.
- Les broches sont données à titre d’exemple, à ajuster selon le routage réel.
diff --git a/docs/spec_bt_hfp_pbap_dialing_v1.md b/docs/spec_bt_hfp_pbap_dialing_v1.md
index 66c4b60..c956ba2 100644
--- a/docs/spec_bt_hfp_pbap_dialing_v1.md
+++ b/docs/spec_bt_hfp_pbap_dialing_v1.md
@@ -21,6 +21,8 @@ Livrer une stack Bluetooth téléphonie réellement opérationnelle sur ESP32 Au
## 2-bis. État d’implémentation (snapshot 2026-02-21)
- HFP commandes firmware: implémenté (`BT_HFP_CONNECT`, `BT_HFP_DISCONNECT`, `BT_DISCOVERABLE_ON/OFF`, `BT_STATUS`).
+- Politique de reconnexion: si un peer bondé est trouvé au boot, le firmware planifie la reconnexion HFP automatiquement.
+- Politique runtime: `BT_AUTO_RECONNECT_ON/OFF` permet de forcer le mode autoradio en live.
- Numérotation/contrôle appel via HFP AT: implémenté côté firmware:
- `BT_DIAL
` / alias `DIAL `
- `BT_REDIAL`
@@ -29,6 +31,11 @@ Livrer une stack Bluetooth téléphonie réellement opérationnelle sur ESP32 Au
- `BT_CALLS` (query `AT+CLCC`)
- `BT_STATUS` expose maintenant `slc_connected`, `call_state`, `last_dialed_number`.
- PBAP (contacts): non disponible sur la stack actuelle `arduino-esp32` (Bluedroid exposé sans API PBAP côté firmware). La commande `BT_PBAP_SYNC` retourne explicitement `unsupported`.
+- Audio local RTC: tonalité de numérotation calée à `425 Hz` (couleur France/Europe), niveau renforcé.
+- Wiring bench verrouillé:
+ - `RM=GPIO18`, `FR=GPIO5`, `SHK=GPIO23`, `PD=GPIO19`
+ - `AMP_EN=GPIO21` actif bas (`LOW=ON`)
+ - `LINE` retiré du runtime (non utilisé).
## 3. Périmètre
@@ -50,6 +57,7 @@ Out-of-scope v1:
- `connected=true` après connexion RFCOMM/SLC,
- `hfp_active=true` lorsque l'audio HFP est actif.
- Les commandes `BT_HFP_CONNECT` et `BT_HFP_DISCONNECT` DOIVENT piloter la session réelle.
+- Le firmware DOIT exposer un contrôle runtime du mode auto reconnect (`BT_AUTO_RECONNECT_ON/OFF` et statut via `BT_STATUS`).
### EF-02 — PBAP contacts
- Le firmware DOIT récupérer un sous-ensemble de contacts via PBAP.
@@ -61,7 +69,10 @@ Out-of-scope v1:
### EF-03 — Numérotation
- Le firmware DOIT exposer une commande de numérotation sortante (ex: `DIAL `).
-- La numérotation DOIT échouer proprement si HFP non connecté.
+- La numérotation DOIT accepter la demande même si SLC n'est pas encore monté:
+ - mettre le numéro en file d'attente,
+ - déclencher/reprendre la connexion HFP,
+ - émettre automatiquement le `dial` dès `SLC_CONNECTED`.
- Les transitions d'appel DOIVENT être visibles dans `STATUS`/`BT_STATUS` (idle, dialing, ringing, active, ended).
### EF-04 — Compatibilité pairing
@@ -69,6 +80,12 @@ Out-of-scope v1:
- Le flux de jumelage DOIT être documenté et testable depuis iPhone/Mac.
- Les erreurs de profil non supporté DOIVENT être tracées avec cause exploitable.
+### EF-05 — Appel entrant GSM vers sonnerie RTC
+- En cas d'appel entrant détecté côté HFP (`call_state=ringing`), le téléphone RTC DOIT sonner.
+- Au décroché RTC, le firmware DOIT déclencher `BT_ANSWER`.
+- Précondition explicite: téléphone GSM appairé et liaison HFP active (ou reconnexion auto en cours).
+- Si aucun peer n'est actif, le firmware DOIT rester découvrable pour restauration du lien.
+
## 5. Exigences non fonctionnelles
- Temps de connexion HFP cible: < 15s dans des conditions normales.
@@ -80,8 +97,11 @@ Out-of-scope v1:
- [ ] HFP réel validé sur hardware (preuve `connected=true` et `hfp_active=true`).
- [ ] PBAP contacts synchronisés avec au moins un contact lisible (bloqué stack actuelle, nécessite changement de stack BT).
- [ ] Numérotation sortante validée sur téléphone réel depuis commande firmware (`BT_DIAL`).
+- [ ] Numérotation sortante validée en mode queue: `DIAL`/`BT_DIAL` avant SLC, puis émission auto après `SLC_CONNECTED`.
+- [ ] Appel entrant GSM validé: sonnerie RTC, décroché RTC => `BT_ANSWER`.
- [ ] Rapport de test hardware mis à jour avec preuves (logs + JSON).
- [ ] Documentation opératoire de pairing/mise en service publiée.
+- [ ] Stabilité appel: maintien HFP/SCO sans décrochage sur scénario appel sortant réel (preuve logs `hfp_*` + `sco_*`).
## 7. Preuves attendues
diff --git a/platformio.ini b/platformio.ini
index db1ee2a..91a29e7 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -2,6 +2,7 @@
[platformio]
default_envs = esp32dev
+build_dir = .pio/build_live
[env]
platform = espressif32
@@ -10,6 +11,9 @@ monitor_speed = 115200
test_build_src = yes
build_flags =
-DCORE_DEBUG_LEVEL=1
+ -DCONFIG_ASYNC_TCP_STACK_SIZE=3072
+ -DCONFIG_ASYNC_TCP_QUEUE_SIZE=8
+ -DCONFIG_ASYNC_TCP_PRIORITY=8
lib_deps =
bblanchon/ArduinoJson@^7.0.4
ESP32Async/AsyncTCP@^3.3.2
diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp
index e950c75..77ccd23 100644
--- a/src/audio/AudioEngine.cpp
+++ b/src/audio/AudioEngine.cpp
@@ -1,7 +1,39 @@
#include "audio/AudioEngine.h"
+#include
+
#include
+#include
+#include
#include
+#include
+
+namespace {
+constexpr float kDialToneHz = 425.0f;
+constexpr float kTwoPi = 6.28318530718f;
+constexpr int16_t kDialToneAmplitude = 32000;
+constexpr float kDialToneLinearGain = 1.14f;
+constexpr size_t kDialToneChunkFrames = 160;
+constexpr size_t kStereoChannels = 2;
+constexpr float kDialToneAttackMs = 25.0f;
+constexpr float kDialToneReleaseMs = 40.0f;
+constexpr uint32_t kDialToneWavSeconds = 1;
+constexpr char kDialToneWavPrefix[] = "/dialtone_425_";
+constexpr bool kDialToneUseWav = false;
+constexpr TickType_t kI2sWriteTimeoutTicks = pdMS_TO_TICKS(2);
+}
+
+namespace {
+int16_t clampInt16(float value) {
+ if (value > static_cast(std::numeric_limits::max())) {
+ return std::numeric_limits::max();
+ }
+ if (value < static_cast(std::numeric_limits::min())) {
+ return std::numeric_limits::min();
+ }
+ return static_cast(value);
+}
+} // namespace
AudioConfig defaultAudioConfigForProfile(BoardProfile profile) {
AudioConfig cfg;
@@ -27,6 +59,7 @@ AudioConfig defaultAudioConfigForProfile(BoardProfile profile) {
AudioEngine::AudioEngine()
: driver_installed_(false),
capture_active_(false),
+ capture_clients_mask_(0),
playing_(false),
play_until_ms_(0),
features_(getFeatureMatrix(detectBoardProfile())) {}
@@ -35,6 +68,19 @@ AudioEngine::~AudioEngine() {
end();
}
+bool AudioEngine::lockI2s() const {
+ if (i2s_io_mutex_ == nullptr) {
+ return true;
+ }
+ return xSemaphoreTake(i2s_io_mutex_, pdMS_TO_TICKS(1)) == pdTRUE;
+}
+
+void AudioEngine::unlockI2s() const {
+ if (i2s_io_mutex_ != nullptr) {
+ xSemaphoreGive(i2s_io_mutex_);
+ }
+}
+
bool AudioEngine::begin(const AudioConfig& config) {
end();
_config = config;
@@ -76,10 +122,32 @@ bool AudioEngine::begin(const AudioConfig& config) {
return false;
}
+ if (i2s_io_mutex_ == nullptr) {
+ i2s_io_mutex_ = xSemaphoreCreateMutex();
+ if (i2s_io_mutex_ == nullptr) {
+ Serial.println("[AudioEngine] i2s mutex unavailable");
+ }
+ }
+
i2s_zero_dma_buffer(_config.port);
driver_installed_ = true;
+ portENTER_CRITICAL(&capture_lock_);
+ capture_clients_mask_ = 0U;
+ capture_active_ = false;
+ portEXIT_CRITICAL(&capture_lock_);
capture_active_ = false;
playing_ = false;
+ dial_tone_active_ = false;
+ dial_tone_gain_ = 0.0f;
+ dial_tone_phase_ = 0.0f;
+ next_dial_tone_push_ms_ = 0;
+ closeDialToneWav();
+ dial_tone_wav_ready_ = false;
+ dial_tone_wav_path_ = String(kDialToneWavPrefix) + String(_config.sample_rate) + ".wav";
+ if (kDialToneUseWav) {
+ ensureDialToneWav();
+ }
+ startTask();
Serial.printf("[AudioEngine] ready (full_duplex=%s)\n",
supportsFullDuplex() ? "true" : "false");
return true;
@@ -89,11 +157,67 @@ void AudioEngine::end() {
if (!driver_installed_) {
return;
}
- stopCapture();
+ stopTask();
+ stopDialTone();
+ portENTER_CRITICAL(&capture_lock_);
+ capture_clients_mask_ = 0U;
+ capture_active_ = false;
+ portEXIT_CRITICAL(&capture_lock_);
+ closeDialToneWav();
+ if (i2s_io_mutex_ != nullptr) {
+ vSemaphoreDelete(i2s_io_mutex_);
+ i2s_io_mutex_ = nullptr;
+ }
i2s_driver_uninstall(_config.port);
driver_installed_ = false;
}
+void AudioEngine::audioTaskFn(void* arg) {
+ auto* self = static_cast(arg);
+ while (self != nullptr && self->running_task_) {
+ self->tick();
+ const bool audio_busy = self->capture_active_ || self->dial_tone_active_ ||
+ (self->dial_tone_gain_ > 0.0005f) || self->playing_;
+ vTaskDelay(pdMS_TO_TICKS(audio_busy ? 1U : 6U));
+ }
+ if (self != nullptr) {
+ self->task_handle_ = nullptr;
+ }
+ vTaskDelete(nullptr);
+}
+
+void AudioEngine::startTask() {
+ if (!driver_installed_ || task_handle_ != nullptr) {
+ return;
+ }
+ running_task_ = true;
+ const BaseType_t rc = xTaskCreatePinnedToCore(
+ audioTaskFn,
+ "audio_engine",
+ kAudioTaskStackWords,
+ this,
+ kAudioTaskPriority,
+ &task_handle_,
+ tskNO_AFFINITY);
+ if (rc != pdPASS) {
+ running_task_ = false;
+ task_handle_ = nullptr;
+ Serial.println("[AudioEngine] failed to start audio task");
+ }
+}
+
+void AudioEngine::stopTask() {
+ if (task_handle_ == nullptr) {
+ return;
+ }
+ running_task_ = false;
+ vTaskDelay(pdMS_TO_TICKS(10));
+ if (task_handle_ != nullptr) {
+ vTaskDelete(task_handle_);
+ task_handle_ = nullptr;
+ }
+}
+
bool AudioEngine::playFile(const char* path) {
if (!driver_installed_ || path == nullptr || path[0] == '\0') {
return false;
@@ -105,21 +229,47 @@ bool AudioEngine::playFile(const char* path) {
return true;
}
-bool AudioEngine::startCapture() {
+bool AudioEngine::requestCapture(CaptureClient client) {
if (!driver_installed_) {
return false;
}
+ const uint8_t bit = static_cast(client);
+ if (bit == 0U) {
+ return false;
+ }
if (!supportsFullDuplex() && playing_) {
return false;
}
- capture_active_ = true;
+
+ portENTER_CRITICAL(&capture_lock_);
+ capture_clients_mask_ = static_cast(capture_clients_mask_ | bit);
+ capture_active_ = (capture_clients_mask_ != 0U);
+ portEXIT_CRITICAL(&capture_lock_);
return true;
}
+void AudioEngine::releaseCapture(CaptureClient client) {
+ const uint8_t bit = static_cast(client);
+ if (bit == 0U) {
+ return;
+ }
+ portENTER_CRITICAL(&capture_lock_);
+ capture_clients_mask_ = static_cast(capture_clients_mask_ & static_cast(~bit));
+ capture_active_ = (capture_clients_mask_ != 0U);
+ portEXIT_CRITICAL(&capture_lock_);
+}
+
+bool AudioEngine::startCapture() {
+ return requestCapture(CAPTURE_CLIENT_GENERIC);
+}
+
size_t AudioEngine::readCaptureFrame(int16_t* dst, size_t samples) {
if (!capture_active_ || !driver_installed_ || dst == nullptr || samples == 0) {
return 0;
}
+ if (!lockI2s()) {
+ return 0;
+ }
metrics_.frames_requested += static_cast(samples);
const uint32_t start_ms = millis();
@@ -131,6 +281,7 @@ size_t AudioEngine::readCaptureFrame(int16_t* dst, size_t samples) {
metrics_.drop_frames += static_cast(samples);
metrics_.last_latency_ms = millis() - start_ms;
metrics_.max_latency_ms = std::max(metrics_.max_latency_ms, metrics_.last_latency_ms);
+ unlockI2s();
return 0;
}
const size_t read_samples = bytes_read / sizeof(int16_t);
@@ -140,11 +291,81 @@ size_t AudioEngine::readCaptureFrame(int16_t* dst, size_t samples) {
}
metrics_.last_latency_ms = millis() - start_ms;
metrics_.max_latency_ms = std::max(metrics_.max_latency_ms, metrics_.last_latency_ms);
+ unlockI2s();
return read_samples;
}
+size_t AudioEngine::readCaptureFrameNonBlocking(int16_t* dst, size_t samples) {
+ if (!capture_active_ || !driver_installed_ || dst == nullptr || samples == 0) {
+ return 0;
+ }
+ if (!lockI2s()) {
+ return 0;
+ }
+
+ metrics_.frames_requested += static_cast(samples);
+ const size_t byte_count = samples * sizeof(int16_t);
+ size_t bytes_read = 0;
+ if (i2s_read(_config.port, dst, byte_count, &bytes_read, 0) != ESP_OK || bytes_read == 0) {
+ unlockI2s();
+ return 0;
+ }
+
+ const size_t read_samples = bytes_read / sizeof(int16_t);
+ metrics_.frames_read += static_cast(read_samples);
+ if (read_samples < samples) {
+ metrics_.drop_frames += static_cast(samples - read_samples);
+ }
+ unlockI2s();
+ return read_samples;
+}
+
+size_t AudioEngine::writePlaybackFrame(const int16_t* src, size_t samples) {
+ if (!driver_installed_ || src == nullptr || samples == 0) {
+ return 0;
+ }
+ if (!lockI2s()) {
+ return 0;
+ }
+
+ const size_t byte_count = samples * sizeof(int16_t);
+ size_t bytes_written = 0;
+ if (i2s_write(_config.port, src, byte_count, &bytes_written, kI2sWriteTimeoutTicks) != ESP_OK) {
+ unlockI2s();
+ return 0;
+ }
+ unlockI2s();
+ return bytes_written / sizeof(int16_t);
+}
+
void AudioEngine::stopCapture() {
- capture_active_ = false;
+ releaseCapture(CAPTURE_CLIENT_GENERIC);
+}
+
+bool AudioEngine::startDialTone() {
+ if (!driver_installed_) {
+ return false;
+ }
+ if (kDialToneUseWav) {
+ ensureDialToneWav();
+ }
+ const bool was_active = dial_tone_active_;
+ dial_tone_active_ = true;
+ if (!was_active && dial_tone_gain_ <= 0.0001f) {
+ dial_tone_phase_ = 0.0f;
+ }
+ next_dial_tone_push_ms_ = 0;
+ return true;
+}
+
+void AudioEngine::stopDialTone() {
+ dial_tone_active_ = false;
+ dial_tone_gain_ = 0.0f;
+ next_dial_tone_push_ms_ = 0;
+}
+
+bool AudioEngine::isDialToneActive() const {
+ return dial_tone_active_ || dial_tone_gain_ > 0.001f;
}
bool AudioEngine::supportsFullDuplex() const {
@@ -167,8 +388,236 @@ void AudioEngine::tick() {
if (playing_ && millis() >= play_until_ms_) {
playing_ = false;
}
+
+ if (!driver_installed_ || playing_) {
+ return;
+ }
+
+ const bool tone_requested = dial_tone_active_;
+ const bool tone_tail_active = dial_tone_gain_ > 0.0005f;
+ if (!tone_requested && !tone_tail_active) {
+ return;
+ }
+
+ const uint32_t now = millis();
+ if (next_dial_tone_push_ms_ != 0 && now < next_dial_tone_push_ms_) {
+ return;
+ }
+
+ int16_t frame[kDialToneChunkFrames * kStereoChannels] = {0};
+ const float phase_step = (kTwoPi * kDialToneHz) / static_cast(_config.sample_rate);
+ const size_t chunk_samples = kDialToneChunkFrames * kStereoChannels;
+ const size_t chunk_bytes = chunk_samples * sizeof(int16_t);
+ uint8_t pcm_raw[kDialToneChunkFrames * kStereoChannels * sizeof(int16_t)] = {0};
+ bool wav_ready = false;
+ size_t wav_filled = 0;
+
+ if (kDialToneUseWav && ensureDialToneWav() && openDialToneWav()) {
+ while (wav_filled < chunk_bytes) {
+ const int got = dial_tone_file_.read(pcm_raw + wav_filled, chunk_bytes - wav_filled);
+ if (got > 0) {
+ wav_filled += static_cast(got);
+ continue;
+ }
+ if (!dial_tone_file_.seek(dial_tone_wav_data_offset_)) {
+ break;
+ }
+ }
+ wav_ready = (wav_filled == chunk_bytes);
+ }
+
+ const float attack_step =
+ 1.0f / std::max(1.0f, (static_cast(_config.sample_rate) * (kDialToneAttackMs / 1000.0f)));
+ const float release_step =
+ 1.0f / std::max(1.0f, (static_cast(_config.sample_rate) * (kDialToneReleaseMs / 1000.0f)));
+ for (size_t i = 0; i < kDialToneChunkFrames; ++i) {
+ if (dial_tone_active_) {
+ dial_tone_gain_ = std::min(1.0f, dial_tone_gain_ + attack_step);
+ } else {
+ dial_tone_gain_ = std::max(0.0f, dial_tone_gain_ - release_step);
+ }
+
+ int16_t sample_l = 0;
+ int16_t sample_r = 0;
+ if (wav_ready) {
+ const size_t l_idx = i * kStereoChannels;
+ const size_t r_idx = l_idx + 1;
+ const size_t l_byte = l_idx * sizeof(int16_t);
+ const size_t r_byte = r_idx * sizeof(int16_t);
+ sample_l = static_cast(
+ static_cast(pcm_raw[l_byte]) |
+ static_cast(static_cast(pcm_raw[l_byte + 1]) << 8));
+ sample_r = static_cast(
+ static_cast(pcm_raw[r_byte]) |
+ static_cast(static_cast(pcm_raw[r_byte + 1]) << 8));
+ } else {
+ const int16_t sample = static_cast(std::sin(dial_tone_phase_) * static_cast(kDialToneAmplitude));
+ sample_l = sample;
+ sample_r = sample;
+ dial_tone_phase_ += phase_step;
+ if (dial_tone_phase_ >= kTwoPi) {
+ dial_tone_phase_ -= kTwoPi;
+ }
+ }
+
+ const float gain = dial_tone_gain_ * kDialToneLinearGain;
+ frame[(i * kStereoChannels)] = clampInt16(static_cast(sample_l) * gain);
+ frame[(i * kStereoChannels) + 1] = clampInt16(static_cast(sample_r) * gain);
+ }
+
+ const size_t requested_samples = kDialToneChunkFrames * kStereoChannels;
+ const size_t written_samples = writePlaybackFrame(frame, requested_samples);
+ if (written_samples == 0U) {
+ // Retry fast when TX queue/mutex was temporarily unavailable.
+ next_dial_tone_push_ms_ = now + 1U;
+ return;
+ }
+
+ const size_t written_frames = written_samples / kStereoChannels;
+ const uint32_t chunk_ms = static_cast((1000U * written_frames) / _config.sample_rate);
+ next_dial_tone_push_ms_ = now + (chunk_ms == 0U ? 1U : chunk_ms);
}
const AudioConfig& AudioEngine::config() const {
return _config;
}
+
+bool AudioEngine::ensureSpiffsMounted() {
+ if (spiffs_mount_attempted_) {
+ return spiffs_ready_;
+ }
+ spiffs_mount_attempted_ = true;
+ spiffs_ready_ = SPIFFS.begin(false);
+ if (!spiffs_ready_) {
+ Serial.println("[AudioEngine] SPIFFS not available (dial tone WAV fallback)");
+ }
+ return spiffs_ready_;
+}
+
+bool AudioEngine::generateDialToneWav(const char* path) {
+ if (path == nullptr || path[0] == '\0') {
+ return false;
+ }
+ if (!ensureSpiffsMounted()) {
+ return false;
+ }
+
+ SPIFFS.remove(path);
+ File file = SPIFFS.open(path, FILE_WRITE);
+ if (!file) {
+ Serial.printf("[AudioEngine] cannot create %s\n", path);
+ return false;
+ }
+
+ const uint16_t channels = static_cast(kStereoChannels);
+ const uint16_t bits_per_sample = 16;
+ const uint32_t bytes_per_sample = bits_per_sample / 8U;
+ const uint32_t frames = _config.sample_rate * kDialToneWavSeconds;
+ const uint32_t data_bytes = frames * channels * bytes_per_sample;
+ const uint32_t riff_size = 36U + data_bytes;
+ const uint32_t byte_rate = _config.sample_rate * channels * bytes_per_sample;
+ const uint16_t block_align = static_cast(channels * bytes_per_sample);
+
+ auto write_u16 = [&](uint16_t v) {
+ uint8_t b[2] = {static_cast(v & 0xFFU), static_cast((v >> 8) & 0xFFU)};
+ return file.write(b, sizeof(b)) == sizeof(b);
+ };
+ auto write_u32 = [&](uint32_t v) {
+ uint8_t b[4] = {static_cast(v & 0xFFU), static_cast((v >> 8) & 0xFFU),
+ static_cast((v >> 16) & 0xFFU), static_cast((v >> 24) & 0xFFU)};
+ return file.write(b, sizeof(b)) == sizeof(b);
+ };
+
+ bool ok = true;
+ ok &= file.write(reinterpret_cast("RIFF"), 4) == 4;
+ ok &= write_u32(riff_size);
+ ok &= file.write(reinterpret_cast("WAVE"), 4) == 4;
+ ok &= file.write(reinterpret_cast("fmt "), 4) == 4;
+ ok &= write_u32(16U);
+ ok &= write_u16(1U);
+ ok &= write_u16(channels);
+ ok &= write_u32(_config.sample_rate);
+ ok &= write_u32(byte_rate);
+ ok &= write_u16(block_align);
+ ok &= write_u16(bits_per_sample);
+ ok &= file.write(reinterpret_cast("data"), 4) == 4;
+ ok &= write_u32(data_bytes);
+ if (!ok) {
+ file.close();
+ SPIFFS.remove(path);
+ return false;
+ }
+
+ int16_t pcm[kDialToneChunkFrames * kStereoChannels] = {0};
+ const float phase_step = (kTwoPi * kDialToneHz) / static_cast(_config.sample_rate);
+ float phase = 0.0f;
+ uint32_t written_frames = 0;
+ while (written_frames < frames) {
+ const uint32_t remaining = frames - written_frames;
+ const uint32_t this_chunk = std::min(static_cast(kDialToneChunkFrames), remaining);
+ for (uint32_t i = 0; i < this_chunk; ++i) {
+ const int16_t s = static_cast(std::sin(phase) * static_cast(kDialToneAmplitude));
+ pcm[(i * kStereoChannels)] = s;
+ pcm[(i * kStereoChannels) + 1] = s;
+ phase += phase_step;
+ if (phase >= kTwoPi) {
+ phase -= kTwoPi;
+ }
+ }
+ const size_t bytes = static_cast(this_chunk * kStereoChannels * sizeof(int16_t));
+ if (file.write(reinterpret_cast(pcm), bytes) != bytes) {
+ file.close();
+ SPIFFS.remove(path);
+ return false;
+ }
+ written_frames += this_chunk;
+ }
+
+ file.close();
+ return true;
+}
+
+bool AudioEngine::ensureDialToneWav() {
+ if (!dial_tone_wav_path_.isEmpty() && dial_tone_wav_ready_) {
+ return true;
+ }
+ if (dial_tone_wav_path_.isEmpty()) {
+ dial_tone_wav_path_ = String(kDialToneWavPrefix) + String(_config.sample_rate) + ".wav";
+ }
+ if (!ensureSpiffsMounted()) {
+ return false;
+ }
+ if (!SPIFFS.exists(dial_tone_wav_path_.c_str())) {
+ if (!generateDialToneWav(dial_tone_wav_path_.c_str())) {
+ return false;
+ }
+ }
+ dial_tone_wav_ready_ = SPIFFS.exists(dial_tone_wav_path_.c_str());
+ return dial_tone_wav_ready_;
+}
+
+bool AudioEngine::openDialToneWav() {
+ if (!dial_tone_wav_ready_) {
+ return false;
+ }
+ if (dial_tone_file_) {
+ return true;
+ }
+ dial_tone_file_ = SPIFFS.open(dial_tone_wav_path_.c_str(), FILE_READ);
+ if (!dial_tone_file_) {
+ dial_tone_wav_ready_ = false;
+ return false;
+ }
+ if (!dial_tone_file_.seek(dial_tone_wav_data_offset_)) {
+ dial_tone_file_.close();
+ dial_tone_wav_ready_ = false;
+ return false;
+ }
+ return true;
+}
+
+void AudioEngine::closeDialToneWav() {
+ if (dial_tone_file_) {
+ dial_tone_file_.close();
+ }
+}
diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h
index 6f03145..58d9234 100644
--- a/src/audio/AudioEngine.h
+++ b/src/audio/AudioEngine.h
@@ -2,7 +2,11 @@
#define AUDIO_ENGINE_H
#include
+#include
#include
+#include
+#include
+#include
#include "core/PlatformProfile.h"
@@ -33,14 +37,27 @@ 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 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 startDialTone();
+ virtual void stopDialTone();
+ virtual bool isDialToneActive() const;
virtual bool supportsFullDuplex() const;
virtual bool isPlaying() const;
virtual AudioRuntimeMetrics metrics() const;
@@ -49,15 +66,43 @@ public:
const AudioConfig& config() const;
private:
+ static void audioTaskFn(void* arg);
+ void startTask();
+ void stopTask();
+ bool lockI2s() const;
+ void unlockI2s() const;
+ bool ensureDialToneWav();
+ bool ensureSpiffsMounted();
+ bool generateDialToneWav(const char* path);
+ bool openDialToneWav();
+ void closeDialToneWav();
+
bool driver_installed_ = false;
bool capture_active_ = false;
+ uint8_t capture_clients_mask_ = 0;
bool playing_ = false;
+ bool dial_tone_active_ = false;
+ volatile bool running_task_ = false;
+ float dial_tone_gain_ = 0.0f;
+ float dial_tone_phase_ = 0.0f;
+ uint32_t next_dial_tone_push_ms_ = 0;
uint32_t play_until_ms_ = 0;
AudioConfig _config;
FeatureMatrix features_;
AudioRuntimeMetrics metrics_;
i2s_config_t _i2s_config{};
i2s_pin_config_t _i2s_pins{};
+ mutable SemaphoreHandle_t i2s_io_mutex_ = nullptr;
+ TaskHandle_t task_handle_ = nullptr;
+ static constexpr uint16_t kAudioTaskStackWords = 4096;
+ static constexpr uint8_t kAudioTaskPriority = 5;
+ bool spiffs_mount_attempted_ = false;
+ bool spiffs_ready_ = false;
+ bool dial_tone_wav_ready_ = false;
+ String dial_tone_wav_path_;
+ File dial_tone_file_;
+ uint32_t dial_tone_wav_data_offset_ = 44;
+ portMUX_TYPE capture_lock_ = portMUX_INITIALIZER_UNLOCKED;
};
#endif // AUDIO_ENGINE_H
diff --git a/src/audio/Es8388Driver.cpp b/src/audio/Es8388Driver.cpp
index 62faf5b..9462516 100644
--- a/src/audio/Es8388Driver.cpp
+++ b/src/audio/Es8388Driver.cpp
@@ -3,28 +3,65 @@
#include
#include
+#include
namespace {
uint8_t clampVolumeToReg(uint8_t percent) {
const uint8_t clamped = std::min(100, percent);
- return static_cast((clamped * 0x21U) / 100U);
+ // ES8388 DAC digital volume registers (0x1A/0x1B):
+ // 0x00 = 0 dB, 0xC0 = -96 dB.
+ return static_cast(((100U - clamped) * 0xC0U) / 100U);
}
-
} // namespace
bool Es8388Driver::begin(int sda_pin, int scl_pin, uint8_t address) {
address_ = address;
Wire.begin(sda_pin, scl_pin);
+ Wire.setClock(100000);
- // Minimal ES8388 init sequence for playback + capture paths.
- const bool ok = writeReg(0x00, 0x80) && // reset
- writeReg(0x01, 0x58) &&
- writeReg(0x02, 0x50) &&
- writeReg(0x04, 0xC0) &&
- writeReg(0x08, 0x00) &&
- writeReg(0x0A, 0x00) &&
- writeReg(0x17, 0x18) &&
- writeReg(0x19, 0x02);
+ const auto write_sequence = [&](std::initializer_list> seq) {
+ bool ok = true;
+ for (const auto& it : seq) {
+ ok &= writeReg(it.first, it.second);
+ }
+ return ok;
+ };
+
+ // ES8388 setup aligned with Espressif ADF defaults (codec in I2S slave mode).
+ // Register map: https://github.com/espressif/esp-adf (es8388 driver).
+ 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, 0x90}, // DACCONTROL17: left DAC to left mixer
+ {0x2A, 0x90}, // DACCONTROL20: right DAC to right mixer
+ {0x2B, 0x80}, // DACCONTROL21
+ {0x2D, 0x00}, // DACCONTROL23
+ {0x2E, 0x24}, // DACCONTROL24: analog output boosted (bench tuning)
+ {0x2F, 0x24}, // DACCONTROL25: analog output boosted (bench tuning)
+ {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_) {
@@ -43,7 +80,8 @@ bool Es8388Driver::setVolume(uint8_t percent) {
return false;
}
const uint8_t reg = clampVolumeToReg(volume_);
- return writeReg(0x2B, reg) && writeReg(0x2C, reg);
+ // DAC digital volume controls.
+ return writeReg(0x1A, reg) && writeReg(0x1B, reg);
}
bool Es8388Driver::setMute(bool enabled) {
@@ -51,7 +89,8 @@ bool Es8388Driver::setMute(bool enabled) {
if (!ready_) {
return false;
}
- return writeReg(0x2F, enabled ? 0x01 : 0x00);
+ // DACCONTROL3 bit2 is mute.
+ return writeReg(0x19, enabled ? 0x04 : 0x00);
}
bool Es8388Driver::setRoute(const String& route) {
@@ -61,14 +100,18 @@ bool Es8388Driver::setRoute(const String& route) {
return false;
}
- if (route_ == "bluetooth") {
- return writeReg(0x30, 0x01);
+ // Current hardware path uses DAC->mixer->line output for both RTC and BT.
+ // Keep route as metadata and ensure output path is enabled.
+ if (route_ == "bluetooth" || route_ == "rtc") {
+ return writeReg(0x26, 0x00) && writeReg(0x27, 0x90) && writeReg(0x2A, 0x90) &&
+ writeReg(0x04, 0x3C);
}
if (route_ == "none") {
- return writeReg(0x30, 0x02);
+ return writeReg(0x04, 0xC0);
}
route_ = "rtc";
- return writeReg(0x30, 0x00);
+ return writeReg(0x26, 0x00) && writeReg(0x27, 0x90) && writeReg(0x2A, 0x90) &&
+ writeReg(0x04, 0x3C);
}
bool Es8388Driver::isReady() const {
diff --git a/src/bluetooth/BluetoothManager.cpp b/src/bluetooth/BluetoothManager.cpp
index 1ef2918..8be3d7f 100644
--- a/src/bluetooth/BluetoothManager.cpp
+++ b/src/bluetooth/BluetoothManager.cpp
@@ -1,5 +1,6 @@
#include "bluetooth/BluetoothManager.h"
+#include "audio/AudioEngine.h"
#include "core/AgentSupervisor.h"
#include
@@ -7,20 +8,31 @@
#include
#include
#include
+#include
#include
#include
#include
#include
#include
#include
+#include
+#include
#include
#include
+#include
namespace {
constexpr char kBtDeviceName[] = "RTC_BL_PHONE_A252";
constexpr char kLegacyPinCode[] = "1234";
+constexpr esp_bt_io_cap_t kSspIoCap = ESP_BT_IO_CAP_NONE;
+constexpr uint8_t kBtCodMinorHandsFree = 0x02;
+constexpr uint32_t kSlcWaitTimeoutMs = 20000U;
+constexpr uint32_t kPendingDialRetryMs = 1200U;
+constexpr uint32_t kInitialBondReconnectDelayMs = 4000U;
+constexpr uint32_t kReconnectDeferMs = 1500U;
+constexpr uint32_t kMinHeapForHfpConnectBytes = 45000U;
constexpr char kBleServiceUuid[] = "8fce0001-93ea-4f8f-8bde-4e8f0ea20001";
constexpr char kBleCmdUuid[] = "8fce0002-93ea-4f8f-8bde-4e8f0ea20002";
@@ -32,9 +44,15 @@ BLECharacteristic* g_ble_cmd_char = nullptr;
BLECharacteristic* g_ble_status_char = nullptr;
bool g_gap_callback_registered = false;
bool g_hfp_callback_registered = false;
+bool g_hfp_data_callback_registered = false;
+constexpr bool kEnableBtAgentSupervisorNotify = false;
+constexpr bool kVerboseGapLogs = false;
-void notifyBluetooth(const std::string& state, const std::string& error = "") {
- AgentStatus status{state, error, millis()};
+void notifyBluetooth(const char* state, const char* error = "") {
+ if (!kEnableBtAgentSupervisorNotify) {
+ return;
+ }
+ AgentStatus status{state != nullptr ? state : "", error != nullptr ? error : "", millis()};
AgentSupervisor::instance().notify("bluetooth", status);
}
@@ -44,6 +62,35 @@ String errToString(esp_err_t err) {
return String(buf);
}
+void enforceBtWifiCoexPolicy() {
+ wifi_mode_t mode = WIFI_MODE_NULL;
+ const esp_err_t mode_err = esp_wifi_get_mode(&mode);
+ if (mode_err != ESP_OK || mode == WIFI_MODE_NULL) {
+ return;
+ }
+ WiFi.setSleep(true);
+ const esp_err_t ps_err = esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
+ if (ps_err != ESP_OK && ps_err != ESP_ERR_WIFI_NOT_INIT && ps_err != ESP_ERR_WIFI_NOT_STARTED) {
+ Serial.printf("[BluetoothManager] warn: esp_wifi_set_ps(min_modem) failed err=0x%04x\n",
+ static_cast(ps_err));
+ }
+}
+
+bool isWifiScanRunning() {
+ return WiFi.scanComplete() == WIFI_SCAN_RUNNING;
+}
+
+void writeMacToString(const uint8_t* mac, String& out) {
+ if (mac == nullptr) {
+ out = "";
+ return;
+ }
+ char buf[18];
+ snprintf(buf, sizeof(buf), "%02X:%02X:%02X:%02X:%02X:%02X",
+ mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
+ out = buf;
+}
+
bool isDialableChar(char c) {
return (c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#' || c == ',' || c == 'p' || c == 'P' ||
c == 'w' || c == 'W';
@@ -61,6 +108,11 @@ bool isValidDialNumber(const String& number) {
return true;
}
+bool callStateNeedsAudio(const String& call_state) {
+ return call_state == "dialing" || call_state == "alerting" || call_state == "ringing" || call_state == "active" ||
+ call_state == "held" || call_state == "held_active";
+}
+
const char* callSetupStateToString(esp_hf_call_setup_status_t status) {
switch (status) {
case ESP_HF_CALL_SETUP_STATUS_IDLE:
@@ -136,17 +188,38 @@ void btGapCallback(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t* param) {
if (g_manager == nullptr || param == nullptr) {
return;
}
+ if (kVerboseGapLogs) {
+ Serial.printf("[BluetoothManager] GAP event=%d\n", static_cast(event));
+ }
switch (event) {
case ESP_BT_GAP_AUTH_CMPL_EVT: {
- if (param->auth_cmpl.stat == ESP_BT_STATUS_SUCCESS) {
+ const bool ok = (param->auth_cmpl.stat == ESP_BT_STATUS_SUCCESS);
+ if (ok) {
+ if (kVerboseGapLogs) {
+ Serial.printf("[BluetoothManager] GAP auth ok bda=%02X:%02X:%02X:%02X:%02X:%02X\n",
+ param->auth_cmpl.bda[0], param->auth_cmpl.bda[1], param->auth_cmpl.bda[2],
+ param->auth_cmpl.bda[3], param->auth_cmpl.bda[4], param->auth_cmpl.bda[5]);
+ }
notifyBluetooth("gap_auth_ok");
} else {
+ if (kVerboseGapLogs) {
+ Serial.printf("[BluetoothManager] GAP auth failed stat=%d\n",
+ static_cast(param->auth_cmpl.stat));
+ }
notifyBluetooth("gap_auth_fail");
}
+ g_manager->onGapAuthComplete(param->auth_cmpl.bda, ok);
break;
}
case ESP_BT_GAP_PIN_REQ_EVT: {
+ if (param->pin_req.min_16_digit) {
+ esp_bt_pin_code_t pin_code;
+ memset(pin_code, '0', sizeof(pin_code));
+ esp_bt_gap_pin_reply(param->pin_req.bda, true, 16, pin_code);
+ notifyBluetooth("gap_pin_reply_16");
+ break;
+ }
esp_bt_pin_code_t pin_code;
memset(pin_code, 0, sizeof(pin_code));
memcpy(pin_code, kLegacyPinCode, strlen(kLegacyPinCode));
@@ -155,15 +228,54 @@ void btGapCallback(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t* param) {
break;
}
case ESP_BT_GAP_CFM_REQ_EVT: {
+ if (kVerboseGapLogs) {
+ Serial.printf("[BluetoothManager] GAP cfm num=%u\n",
+ static_cast(param->cfm_req.num_val));
+ }
esp_bt_gap_ssp_confirm_reply(param->cfm_req.bda, true);
notifyBluetooth("gap_ssp_confirm");
break;
}
+ case ESP_BT_GAP_KEY_NOTIF_EVT: {
+ if (kVerboseGapLogs) {
+ Serial.printf("[BluetoothManager] GAP key notif passkey=%u\n",
+ static_cast(param->key_notif.passkey));
+ }
+ notifyBluetooth("gap_ssp_key_notif");
+ break;
+ }
case ESP_BT_GAP_KEY_REQ_EVT: {
- esp_bt_gap_ssp_passkey_reply(param->key_req.bda, true, 1234);
+ if (kVerboseGapLogs) {
+ Serial.println("[BluetoothManager] GAP key request -> passkey 123456");
+ }
+ esp_bt_gap_ssp_passkey_reply(param->key_req.bda, true, 123456);
notifyBluetooth("gap_ssp_passkey");
break;
}
+ case ESP_BT_GAP_ACL_CONN_CMPL_STAT_EVT: {
+ const uint16_t stat = static_cast(param->acl_conn_cmpl_stat.stat);
+ if (kVerboseGapLogs) {
+ Serial.printf("[BluetoothManager] GAP acl_conn stat=%u bda=%02X:%02X:%02X:%02X:%02X:%02X\n",
+ static_cast(stat),
+ param->acl_conn_cmpl_stat.bda[0], param->acl_conn_cmpl_stat.bda[1],
+ param->acl_conn_cmpl_stat.bda[2], param->acl_conn_cmpl_stat.bda[3],
+ param->acl_conn_cmpl_stat.bda[4], param->acl_conn_cmpl_stat.bda[5]);
+ }
+ // Do not trigger HFP actions from ACL callback context (can re-enter BT stack).
+ break;
+ }
+ case ESP_BT_GAP_ACL_DISCONN_CMPL_STAT_EVT: {
+ if (kVerboseGapLogs) {
+ Serial.printf("[BluetoothManager] GAP acl_disconn reason=%d bda=%02X:%02X:%02X:%02X:%02X:%02X\n",
+ static_cast(param->acl_disconn_cmpl_stat.reason),
+ param->acl_disconn_cmpl_stat.bda[0], param->acl_disconn_cmpl_stat.bda[1],
+ param->acl_disconn_cmpl_stat.bda[2], param->acl_disconn_cmpl_stat.bda[3],
+ param->acl_disconn_cmpl_stat.bda[4], param->acl_disconn_cmpl_stat.bda[5]);
+ }
+ g_manager->onGapAclDisconnected(param->acl_disconn_cmpl_stat.bda,
+ param->acl_disconn_cmpl_stat.reason);
+ break;
+ }
default:
break;
}
@@ -175,6 +287,19 @@ void hfpClientCallback(esp_hf_client_cb_event_t event, esp_hf_client_cb_param_t*
}
}
+void hfpIncomingDataCallback(const uint8_t* buf, uint32_t len) {
+ if (g_manager != nullptr) {
+ g_manager->onHfpIncomingAudio(buf, len);
+ }
+}
+
+uint32_t hfpOutgoingDataCallback(uint8_t* buf, uint32_t len) {
+ if (g_manager == nullptr) {
+ return 0;
+ }
+ return g_manager->onHfpOutgoingAudio(buf, len);
+}
+
class ManagerBleServerCallbacks : public BLEServerCallbacks {
public:
explicit ManagerBleServerCallbacks(BluetoothManager* manager) : manager_(manager) {}
@@ -228,41 +353,71 @@ BluetoothManager::BluetoothManager()
stack_ready_(false),
hfp_initialized_(false),
hfp_requested_(false),
+ auto_reconnect_enabled_(true),
ble_stack_initialized_(false),
ble_service_ready_(false),
ble_client_connected_(false),
connected_(false),
hfp_active_(false),
slc_connected_(false),
+ call_setup_active_(false),
ble_active_(false),
discoverable_(false),
security_enabled_(false),
pbap_supported_(false),
pbap_synced_(false),
+ hfp_data_callback_registered_(false),
+ hfp_audio_link_up_(false),
peer_mac_(""),
peer_addr_{0},
peer_addr_valid_(false),
+ hfp_connect_inflight_(false),
+ hfp_reconnect_attempts_(0),
+ next_hfp_reconnect_ms_(0),
+ reconnect_suspended_until_ms_(0),
+ slc_wait_deadline_ms_(0),
+ next_audio_retry_ms_(0),
+ next_pending_dial_retry_ms_(0),
+ pending_status_publish_(false),
+ pending_discoverable_policy_(false),
call_state_("idle"),
+ pending_dial_number_(""),
last_dialed_number_(""),
pbap_last_error_("pbap_not_available_on_esp32_arduino_bluedroid"),
last_hfp_event_("idle"),
last_ble_event_("idle"),
last_error_(""),
ble_last_command_(""),
- ble_last_response_("") {}
+ ble_last_response_(""),
+ sco_rx_bytes_(0),
+ sco_tx_bytes_(0),
+ last_sco_activity_ms_(0),
+ audio_bridge_(nullptr) {}
bool BluetoothManager::begin(BoardProfile profile) {
features_ = getFeatureMatrix(profile);
connected_ = false;
hfp_active_ = false;
slc_connected_ = false;
+ call_setup_active_ = false;
+ hfp_requested_ = false;
ble_active_ = false;
discoverable_ = false;
pbap_synced_ = false;
ble_client_connected_ = false;
peer_mac_ = "";
peer_addr_valid_ = false;
+ hfp_connect_inflight_ = false;
+ hfp_reconnect_attempts_ = 0;
+ next_hfp_reconnect_ms_ = 0;
+ reconnect_suspended_until_ms_ = 0;
+ slc_wait_deadline_ms_ = 0;
+ next_audio_retry_ms_ = 0;
+ next_pending_dial_retry_ms_ = 0;
+ pending_status_publish_ = false;
+ pending_discoverable_policy_ = false;
call_state_ = "idle";
+ pending_dial_number_ = "";
last_dialed_number_ = "";
memset(peer_addr_, 0, sizeof(peer_addr_));
last_error_ = "";
@@ -270,6 +425,17 @@ bool BluetoothManager::begin(BoardProfile profile) {
last_ble_event_ = "initialized";
ble_last_command_ = "";
ble_last_response_ = "";
+ hfp_audio_link_up_ = false;
+ hfp_data_callback_registered_ = false;
+ sco_rx_bytes_ = 0;
+ sco_tx_bytes_ = 0;
+ last_sco_activity_ms_ = 0;
+ peer_mac_.reserve(18);
+ call_state_.reserve(16);
+ pending_dial_number_.reserve(24);
+ last_dialed_number_.reserve(24);
+ last_hfp_event_.reserve(32);
+ last_error_.reserve(96);
g_manager = this;
if (features_.has_hfp || features_.has_ble_control) {
@@ -277,10 +443,30 @@ bool BluetoothManager::begin(BoardProfile profile) {
} else {
stack_ready_ = true;
}
+
+ if (auto_reconnect_enabled_ && stack_ready_ && !peer_addr_valid_ && features_.has_hfp && loadBondedPeerFromStack()) {
+ // Always-connected policy: if we have a bonded peer, reconnect automatically.
+ hfp_requested_ = true;
+ next_hfp_reconnect_ms_ = millis() + kInitialBondReconnectDelayMs;
+ last_hfp_event_ = "peer_bond_restored";
+ }
+
+ if (stack_ready_ && features_.has_hfp) {
+ if (!ensureHfpClientReady()) {
+ Serial.printf("[BluetoothManager] HFP init at boot failed: %s\n", last_error_.c_str());
+ } else {
+ Serial.println("[BluetoothManager] HFP initialized at boot");
+ }
+ }
+ applyDiscoverablePolicy();
notifyBluetooth("initialized");
return stack_ready_;
}
+void BluetoothManager::setAudioBridge(AudioEngine* audio) {
+ audio_bridge_ = audio;
+}
+
bool BluetoothManager::connect(const char* mac) {
if (mac == nullptr || mac[0] == '\0') {
last_error_ = "invalid_mac";
@@ -305,19 +491,11 @@ bool BluetoothManager::connect(const char* mac) {
memcpy(peer_addr_, addr, sizeof(peer_addr_));
peer_addr_valid_ = true;
- peer_mac_ = formatMac(peer_addr_);
-
- const esp_err_t err = esp_hf_client_connect(peer_addr_);
- if (err != ESP_OK) {
- last_error_ = "hfp_connect_req_failed:" + errToString(err);
- notifyBluetooth("connect_failed", last_error_.c_str());
- return false;
- }
-
+ writeMacToString(peer_addr_, peer_mac_);
hfp_requested_ = true;
- last_hfp_event_ = "connect_requested";
- notifyBluetooth("hfp_connecting");
- return true;
+ hfp_reconnect_attempts_ = 0;
+ next_hfp_reconnect_ms_ = 0;
+ return requestHfpConnect("connect_requested");
}
bool BluetoothManager::disconnect() {
@@ -333,9 +511,18 @@ bool BluetoothManager::disconnect() {
connected_ = false;
hfp_active_ = false;
+ hfp_audio_link_up_ = false;
hfp_requested_ = false;
+ hfp_reconnect_attempts_ = 0;
+ next_hfp_reconnect_ms_ = 0;
+ hfp_connect_inflight_ = false;
+ next_audio_retry_ms_ = 0;
+ next_pending_dial_retry_ms_ = 0;
+ pending_dial_number_ = "";
last_hfp_event_ = "disconnect_requested";
notifyBluetooth(ok ? "disconnected" : "disconnect_failed", ok ? "" : last_error_.c_str());
+ onHfpAudioStateChanged(false);
+ applyDiscoverablePolicy();
publishBleStatus();
return ok;
}
@@ -383,8 +570,11 @@ bool BluetoothManager::stopHFP() {
}
hfp_active_ = false;
+ hfp_audio_link_up_ = false;
+ next_audio_retry_ms_ = 0;
last_hfp_event_ = "audio_stop_requested";
notifyBluetooth("hfp_stopped");
+ onHfpAudioStateChanged(false);
publishBleStatus();
return true;
}
@@ -449,6 +639,66 @@ bool BluetoothManager::stopBLE() {
return true;
}
+void BluetoothManager::tick() {
+ const uint32_t now = millis();
+ const bool reconnect_suspended = (reconnect_suspended_until_ms_ != 0U && now < reconnect_suspended_until_ms_);
+
+ if (!pending_dial_number_.isEmpty()) {
+ if (connected_ && slc_connected_ && now >= next_pending_dial_retry_ms_) {
+ const String queued = pending_dial_number_;
+ if (issueDialRequest(queued, "dial_pending_requested")) {
+ pending_dial_number_ = "";
+ next_pending_dial_retry_ms_ = 0;
+ } else {
+ next_pending_dial_retry_ms_ = now + kPendingDialRetryMs;
+ }
+ } else if (!connected_ && peer_addr_valid_ && hfp_requested_ && next_hfp_reconnect_ms_ == 0) {
+ next_hfp_reconnect_ms_ = now + 300U;
+ }
+ }
+
+ if (hfp_requested_ && peer_addr_valid_ && !connected_ && !hfp_connect_inflight_ && next_hfp_reconnect_ms_ != 0 &&
+ now >= next_hfp_reconnect_ms_) {
+ if (reconnect_suspended) {
+ next_hfp_reconnect_ms_ = reconnect_suspended_until_ms_;
+ last_hfp_event_ = "reconnect_suspended";
+ } else if (isWifiScanRunning() || ESP.getFreeHeap() < kMinHeapForHfpConnectBytes) {
+ next_hfp_reconnect_ms_ = now + kReconnectDeferMs;
+ last_hfp_event_ = isWifiScanRunning() ? "reconnect_deferred_wifi_scan" : "reconnect_deferred_low_heap";
+ } else if (!requestHfpConnect("reconnect_tick")) {
+ const uint8_t exp = (hfp_reconnect_attempts_ < 5) ? hfp_reconnect_attempts_ : 5;
+ next_hfp_reconnect_ms_ = now + 2000U * (1U << exp);
+ }
+ }
+
+ const bool call_needs_audio = callStateNeedsAudio(call_state_);
+ if (call_needs_audio && connected_ && slc_connected_ && peer_addr_valid_ && !hfp_audio_link_up_ &&
+ now >= next_audio_retry_ms_) {
+ requestAudioIfNeeded("audio_retry_tick");
+ }
+
+ if (connected_ && !slc_connected_ && peer_addr_valid_ && slc_wait_deadline_ms_ != 0 &&
+ now >= slc_wait_deadline_ms_) {
+ // Avoid aggressive disconnect from tick() while stack is negotiating SLC.
+ // This path was triggering unstable queue teardown in Bluedroid.
+ last_hfp_event_ = "slc_timeout_backoff";
+ slc_wait_deadline_ms_ = now + kSlcWaitTimeoutMs;
+ hfp_connect_inflight_ = false;
+ if ((auto_reconnect_enabled_ || !pending_dial_number_.isEmpty()) && next_hfp_reconnect_ms_ == 0) {
+ next_hfp_reconnect_ms_ = now + 2000U;
+ }
+ }
+
+ if (pending_discoverable_policy_) {
+ pending_discoverable_policy_ = false;
+ applyDiscoverablePolicy();
+ }
+ if (pending_status_publish_) {
+ pending_status_publish_ = false;
+ publishBleStatus();
+ }
+}
+
void BluetoothManager::logStatus() const {
Serial.printf("[BluetoothManager] stack=%s connected=%s hfp=%s ble=%s ble_client=%s security=%s peer=%s\n",
stack_ready_ ? "true" : "false",
@@ -464,9 +714,14 @@ void BluetoothManager::statusToJson(JsonObject obj) const {
obj["stack_ready"] = stack_ready_;
obj["connected"] = connected_;
obj["hfp_active"] = hfp_active_;
+ obj["hfp_audio_link"] = hfp_audio_link_up_;
obj["slc_connected"] = slc_connected_;
obj["hfp_requested"] = hfp_requested_;
+ obj["auto_reconnect_enabled"] = auto_reconnect_enabled_;
+ obj["reconnect_suspended_until_ms"] = reconnect_suspended_until_ms_;
obj["call_state"] = call_state_;
+ obj["call_setup_active"] = call_setup_active_;
+ obj["pending_dial_number"] = pending_dial_number_;
obj["last_dialed_number"] = last_dialed_number_;
obj["ble_active"] = ble_active_;
obj["discoverable"] = discoverable_;
@@ -479,6 +734,9 @@ void BluetoothManager::statusToJson(JsonObject obj) const {
obj["last_hfp_event"] = last_hfp_event_;
obj["last_ble_event"] = last_ble_event_;
obj["last_error"] = last_error_;
+ obj["sco_rx_bytes"] = sco_rx_bytes_;
+ obj["sco_tx_bytes"] = sco_tx_bytes_;
+ obj["last_sco_activity_ms"] = last_sco_activity_ms_;
obj["ble_last_command"] = ble_last_command_;
obj["ble_last_response"] = ble_last_response_;
}
@@ -491,26 +749,64 @@ bool BluetoothManager::setDiscoverable(bool enabled) {
if (!ensureBtStackReady()) {
return false;
}
+ const bool target = enabled;
+ if (discoverable_ == target) {
+ return true;
+ }
const esp_bt_discovery_mode_t mode =
- enabled ? ESP_BT_GENERAL_DISCOVERABLE : ESP_BT_NON_DISCOVERABLE;
- const esp_err_t err = esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, mode);
+ target ? ESP_BT_GENERAL_DISCOVERABLE : ESP_BT_NON_DISCOVERABLE;
+ const esp_bt_connection_mode_t conn_mode = target ? ESP_BT_CONNECTABLE : ESP_BT_NON_CONNECTABLE;
+ const esp_err_t err = esp_bt_gap_set_scan_mode(conn_mode, mode);
if (err != ESP_OK) {
last_error_ = "bt_discoverable_failed:" + errToString(err);
notifyBluetooth("discoverable_failed", last_error_.c_str());
return false;
}
- discoverable_ = enabled;
+ discoverable_ = target;
last_error_ = "";
- notifyBluetooth(enabled ? "discoverable_on" : "discoverable_off");
+ notifyBluetooth(target ? "discoverable_on" : "discoverable_off");
publishBleStatus();
return true;
}
-bool BluetoothManager::dial(const String& number) {
- if (!requireCallControlReady("dial")) {
- return false;
+bool BluetoothManager::setAutoReconnectEnabled(bool enabled) {
+ auto_reconnect_enabled_ = enabled;
+ if (!auto_reconnect_enabled_) {
+ hfp_reconnect_attempts_ = 0;
+ next_hfp_reconnect_ms_ = 0;
+ if (pending_dial_number_.isEmpty() && !connected_) {
+ hfp_requested_ = false;
+ }
+ last_hfp_event_ = "auto_reconnect_off";
+ } else {
+ hfp_requested_ = true;
+ if (!connected_ && peer_addr_valid_ && !hfp_connect_inflight_) {
+ next_hfp_reconnect_ms_ = millis() + 400U;
+ }
+ last_hfp_event_ = "auto_reconnect_on";
}
+ publishBleStatus();
+ return true;
+}
+bool BluetoothManager::autoReconnectEnabled() const {
+ return auto_reconnect_enabled_;
+}
+
+void BluetoothManager::suspendReconnect(uint32_t duration_ms) {
+ if (duration_ms == 0U) {
+ return;
+ }
+ const uint32_t until = millis() + duration_ms;
+ if (reconnect_suspended_until_ms_ == 0U || until > reconnect_suspended_until_ms_) {
+ reconnect_suspended_until_ms_ = until;
+ }
+ if (next_hfp_reconnect_ms_ != 0U && next_hfp_reconnect_ms_ < reconnect_suspended_until_ms_) {
+ next_hfp_reconnect_ms_ = reconnect_suspended_until_ms_;
+ }
+}
+
+bool BluetoothManager::dial(const String& number) {
String dialed = number;
dialed.trim();
if (!isValidDialNumber(dialed)) {
@@ -519,6 +815,80 @@ bool BluetoothManager::dial(const String& number) {
return false;
}
+ if (!features_.has_hfp) {
+ last_error_ = "hfp_not_supported";
+ notifyBluetooth("hfp_dial_failed", last_error_.c_str());
+ return false;
+ }
+
+ const bool hfp_ready = ensureHfpClientReady();
+
+ if (connected_ && slc_connected_) {
+ if (hfp_ready) {
+ return issueDialRequest(dialed, "dial_requested");
+ }
+ pending_dial_number_ = dialed;
+ next_pending_dial_retry_ms_ = millis();
+ hfp_requested_ = true;
+ if (next_hfp_reconnect_ms_ == 0) {
+ next_hfp_reconnect_ms_ = millis() + kPendingDialRetryMs;
+ }
+ last_hfp_event_ = "dial_queued_wait_stack";
+ notifyBluetooth("hfp_dial_queued_wait_stack");
+ publishBleStatus();
+ return true;
+ }
+
+ pending_dial_number_ = dialed;
+ next_pending_dial_retry_ms_ = millis();
+ hfp_requested_ = true;
+ last_error_ = "";
+
+ if (!hfp_ready) {
+ if (stack_ready_) {
+ setDiscoverable(true);
+ }
+ last_hfp_event_ = "dial_queued_wait_stack";
+ if (next_hfp_reconnect_ms_ == 0) {
+ next_hfp_reconnect_ms_ = millis() + kPendingDialRetryMs;
+ }
+ notifyBluetooth("hfp_dial_queued_wait_stack");
+ publishBleStatus();
+ return true;
+ }
+
+ if (!peer_addr_valid_) {
+ if (!loadBondedPeerFromStack()) {
+ last_hfp_event_ = "dial_queued_wait_pair";
+ setDiscoverable(true);
+ notifyBluetooth("hfp_dial_queued_wait_pair");
+ publishBleStatus();
+ return true;
+ }
+ last_hfp_event_ = "dial_queued_peer_restored";
+ }
+
+ if (!connected_) {
+ setDiscoverable(true);
+ const bool connect_requested = requestHfpConnect("dial_queue_connect_requested");
+ if (!connect_requested && next_hfp_reconnect_ms_ == 0) {
+ next_hfp_reconnect_ms_ = millis() + kPendingDialRetryMs;
+ }
+ last_hfp_event_ = connect_requested ? "dial_queued_connecting" : "dial_queued_wait_reconnect";
+ notifyBluetooth(connect_requested ? "hfp_dial_queued_connecting" : "hfp_dial_queued_wait_reconnect");
+ } else {
+ if (slc_wait_deadline_ms_ == 0) {
+ slc_wait_deadline_ms_ = millis() + kSlcWaitTimeoutMs;
+ }
+ last_hfp_event_ = "dial_queued_wait_slc";
+ notifyBluetooth("hfp_dial_queued_wait_slc");
+ }
+
+ publishBleStatus();
+ return true;
+}
+
+bool BluetoothManager::issueDialRequest(const String& dialed, const char* event_name) {
const esp_err_t err = esp_hf_client_dial(dialed.c_str());
if (err != ESP_OK) {
last_error_ = "hfp_dial_failed:" + errToString(err);
@@ -528,7 +898,8 @@ bool BluetoothManager::dial(const String& number) {
last_dialed_number_ = dialed;
call_state_ = "dialing";
- last_hfp_event_ = "dial_requested";
+ last_hfp_event_ = event_name != nullptr ? String(event_name) : String("dial_requested");
+ last_error_ = "";
notifyBluetooth("hfp_dial_requested");
publishBleStatus();
return true;
@@ -638,6 +1009,28 @@ bool BluetoothManager::isDiscoverable() const {
return discoverable_;
}
+bool BluetoothManager::hasAnyBluetoothClientConnected() const {
+ return connected_ || ble_client_connected_;
+}
+
+void BluetoothManager::applyDiscoverablePolicy() {
+ if (!stack_ready_) {
+ return;
+ }
+
+ // Policy: discoverable when no BT client is connected, hidden otherwise.
+ const bool should_discoverable = !hasAnyBluetoothClientConnected();
+ if (discoverable_ == should_discoverable) {
+ return;
+ }
+
+ if (!setDiscoverable(should_discoverable)) {
+ Serial.printf("[BluetoothManager] discoverable policy apply failed: target=%s err=%s\n",
+ should_discoverable ? "on" : "off",
+ last_error_.c_str());
+ }
+}
+
bool BluetoothManager::isPbapSupported() const {
return pbap_supported_;
}
@@ -655,6 +1048,7 @@ bool BluetoothManager::ensureBtStackReady() {
return true;
}
+ enforceBtWifiCoexPolicy();
if (!btStart()) {
last_error_ = "bt_start_failed";
notifyBluetooth("stack_failed", "bt_start_failed");
@@ -681,6 +1075,24 @@ bool BluetoothManager::ensureBtStackReady() {
esp_bt_dev_set_device_name(kBtDeviceName);
+ esp_bt_io_cap_t iocap = kSspIoCap;
+ const esp_err_t sec_err =
+ esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap, sizeof(iocap));
+ if (sec_err != ESP_OK) {
+ last_error_ = "gap_security_param_failed:" + errToString(sec_err);
+ notifyBluetooth("stack_warn", last_error_.c_str());
+ }
+
+ esp_bt_cod_t cod = {};
+ cod.major = ESP_BT_COD_MAJOR_DEV_AV;
+ cod.minor = kBtCodMinorHandsFree;
+ cod.service = ESP_BT_COD_SRVC_AUDIO | ESP_BT_COD_SRVC_TELEPHONY;
+ const esp_err_t cod_err = esp_bt_gap_set_cod(cod, ESP_BT_SET_COD_ALL);
+ if (cod_err != ESP_OK) {
+ last_error_ = "gap_set_cod_failed:" + errToString(cod_err);
+ notifyBluetooth("stack_warn", last_error_.c_str());
+ }
+
if (!g_gap_callback_registered) {
const esp_err_t gap_err = esp_bt_gap_register_callback(btGapCallback);
if (gap_err == ESP_OK) {
@@ -696,9 +1108,9 @@ bool BluetoothManager::ensureBtStackReady() {
memset(pin_code, 0, sizeof(pin_code));
memcpy(pin_code, kLegacyPinCode, strlen(kLegacyPinCode));
esp_bt_gap_set_pin(ESP_BT_PIN_TYPE_FIXED, 4, pin_code);
- // Keep classic BT connectable for outbound HFP while avoiding random inbound ACL grabs.
+ // Default hardening: no inbound ACL unless explicitly requested.
const esp_err_t scan_err =
- esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_NON_DISCOVERABLE);
+ esp_bt_gap_set_scan_mode(ESP_BT_NON_CONNECTABLE, ESP_BT_NON_DISCOVERABLE);
if (scan_err != ESP_OK) {
last_error_ = "gap_scan_mode_failed:" + errToString(scan_err);
notifyBluetooth("stack_failed", last_error_.c_str());
@@ -706,6 +1118,7 @@ bool BluetoothManager::ensureBtStackReady() {
}
discoverable_ = false;
+ enforceBtWifiCoexPolicy();
stack_ready_ = true;
notifyBluetooth("stack_ready");
return true;
@@ -736,11 +1149,64 @@ bool BluetoothManager::ensureHfpClientReady() {
return false;
}
+ if (!g_hfp_data_callback_registered) {
+ const esp_err_t data_err =
+ esp_hf_client_register_data_callback(hfpIncomingDataCallback, hfpOutgoingDataCallback);
+ if (data_err == ESP_OK) {
+ g_hfp_data_callback_registered = true;
+ } else {
+ last_error_ = "hfp_data_callback_failed:" + errToString(data_err);
+ notifyBluetooth("hfp_warn", last_error_.c_str());
+ }
+ }
+ hfp_data_callback_registered_ = g_hfp_data_callback_registered;
+
hfp_initialized_ = true;
notifyBluetooth("hfp_ready");
return true;
}
+bool BluetoothManager::requestHfpConnect(const char* reason) {
+ if (!ensureHfpClientReady()) {
+ return false;
+ }
+ if (!peer_addr_valid_) {
+ last_error_ = "hfp_connect_no_peer";
+ notifyBluetooth("connect_failed", last_error_.c_str());
+ return false;
+ }
+
+ if (hfp_connect_inflight_) {
+ return true;
+ }
+
+ if (reconnect_suspended_until_ms_ != 0U && millis() < reconnect_suspended_until_ms_) {
+ next_hfp_reconnect_ms_ = reconnect_suspended_until_ms_;
+ last_hfp_event_ = "connect_suspended";
+ return false;
+ }
+
+ if (isWifiScanRunning() || ESP.getFreeHeap() < kMinHeapForHfpConnectBytes) {
+ next_hfp_reconnect_ms_ = millis() + kReconnectDeferMs;
+ last_hfp_event_ = isWifiScanRunning() ? "connect_deferred_wifi_scan" : "connect_deferred_low_heap";
+ return false;
+ }
+
+ const esp_err_t err = esp_hf_client_connect(peer_addr_);
+ if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
+ last_error_ = "hfp_connect_req_failed:" + errToString(err);
+ notifyBluetooth("connect_failed", last_error_.c_str());
+ return false;
+ }
+
+ last_hfp_event_ = reason != nullptr ? String(reason) : String("connect_requested");
+ last_error_ = "";
+ hfp_connect_inflight_ = true;
+ next_hfp_reconnect_ms_ = 0;
+ notifyBluetooth("hfp_connecting");
+ return true;
+}
+
bool BluetoothManager::requireCallControlReady(const char* operation) {
if (!features_.has_hfp) {
last_error_ = "hfp_not_supported";
@@ -761,6 +1227,25 @@ bool BluetoothManager::requireCallControlReady(const char* operation) {
return true;
}
+bool BluetoothManager::loadBondedPeerFromStack() {
+ int dev_num = esp_bt_gap_get_bond_device_num();
+ if (dev_num <= 0) {
+ return false;
+ }
+
+ std::vector dev_list(static_cast(dev_num));
+ int list_count = dev_num;
+ const esp_err_t err = esp_bt_gap_get_bond_device_list(&list_count, dev_list.data());
+ if (err != ESP_OK || list_count <= 0) {
+ return false;
+ }
+
+ memcpy(peer_addr_, dev_list[0], sizeof(peer_addr_));
+ peer_addr_valid_ = true;
+ writeMacToString(peer_addr_, peer_mac_);
+ return true;
+}
+
bool BluetoothManager::parseMac(const String& mac, uint8_t out[6]) const {
if (out == nullptr) {
return false;
@@ -827,49 +1312,141 @@ String BluetoothManager::executeBleCommand(const String& cmd) {
return response;
}
+void BluetoothManager::onHfpIncomingAudio(const uint8_t* buf, uint32_t len) {
+ if (buf == nullptr || len == 0) {
+ return;
+ }
+
+ sco_rx_bytes_ += len;
+ last_sco_activity_ms_ = millis();
+
+ if (audio_bridge_ == nullptr) {
+ return;
+ }
+
+ const size_t samples = static_cast(len / sizeof(int16_t));
+ if (samples == 0) {
+ return;
+ }
+ audio_bridge_->writePlaybackFrame(reinterpret_cast(buf), samples);
+}
+
+uint32_t BluetoothManager::onHfpOutgoingAudio(uint8_t* buf, uint32_t len) {
+ if (buf == nullptr || len < sizeof(int16_t)) {
+ return 0;
+ }
+
+ memset(buf, 0, len);
+ if (audio_bridge_ == nullptr) {
+ return 0;
+ }
+
+ constexpr size_t kChunkSamples = 160;
+ uint32_t produced_bytes = 0;
+ uint8_t* out = buf;
+ uint32_t remaining = len;
+
+ while (remaining >= sizeof(int16_t)) {
+ const size_t request_samples = std::min(static_cast(remaining / sizeof(int16_t)), kChunkSamples);
+ int16_t tmp[kChunkSamples] = {0};
+ const size_t got = audio_bridge_->readCaptureFrameNonBlocking(tmp, request_samples);
+ if (got == 0) {
+ break;
+ }
+ const uint32_t chunk_bytes = static_cast(got * sizeof(int16_t));
+ memcpy(out, tmp, chunk_bytes);
+ out += chunk_bytes;
+ remaining -= chunk_bytes;
+ produced_bytes += chunk_bytes;
+ if (got < request_samples) {
+ break;
+ }
+ }
+
+ if (produced_bytes > 0) {
+ sco_tx_bytes_ += produced_bytes;
+ last_sco_activity_ms_ = millis();
+ }
+ return produced_bytes;
+}
+
void BluetoothManager::handleHfpEvent(int event, const void* raw_param) {
const auto* param = static_cast(raw_param);
switch (static_cast(event)) {
case ESP_HF_CLIENT_CONNECTION_STATE_EVT: {
if (param != nullptr) {
- peer_mac_ = formatMac(param->conn_stat.remote_bda);
memcpy(peer_addr_, param->conn_stat.remote_bda, sizeof(peer_addr_));
peer_addr_valid_ = true;
+ writeMacToString(peer_addr_, peer_mac_);
switch (param->conn_stat.state) {
case ESP_HF_CLIENT_CONNECTION_STATE_DISCONNECTED:
connected_ = false;
+ hfp_connect_inflight_ = false;
hfp_active_ = false;
+ hfp_audio_link_up_ = false;
slc_connected_ = false;
+ call_setup_active_ = false;
+ slc_wait_deadline_ms_ = 0;
call_state_ = "idle";
+ next_audio_retry_ms_ = 0;
last_hfp_event_ = "disconnected";
notifyBluetooth("hfp_disconnected");
+ onHfpAudioStateChanged(false);
+ if ((auto_reconnect_enabled_ || !pending_dial_number_.isEmpty()) &&
+ hfp_requested_ && peer_addr_valid_) {
+ hfp_reconnect_attempts_++;
+ const uint8_t exp = (hfp_reconnect_attempts_ < 5) ? hfp_reconnect_attempts_ : 5;
+ next_hfp_reconnect_ms_ = millis() + 2000U * (1U << exp);
+ }
break;
case ESP_HF_CLIENT_CONNECTION_STATE_CONNECTING:
connected_ = false;
+ hfp_connect_inflight_ = true;
hfp_active_ = false;
+ hfp_audio_link_up_ = false;
slc_connected_ = false;
+ call_setup_active_ = false;
+ slc_wait_deadline_ms_ = 0;
last_hfp_event_ = "connecting";
notifyBluetooth("hfp_connecting");
break;
case ESP_HF_CLIENT_CONNECTION_STATE_CONNECTED:
connected_ = true;
+ hfp_connect_inflight_ = false;
hfp_active_ = false;
+ hfp_audio_link_up_ = false;
slc_connected_ = false;
+ call_setup_active_ = false;
+ slc_wait_deadline_ms_ = millis() + kSlcWaitTimeoutMs;
+ next_audio_retry_ms_ = 0;
+ hfp_reconnect_attempts_ = 0;
+ next_hfp_reconnect_ms_ = 0;
last_hfp_event_ = "rfcomm_connected";
notifyBluetooth("hfp_rfcomm_connected");
break;
case ESP_HF_CLIENT_CONNECTION_STATE_SLC_CONNECTED:
connected_ = true;
- hfp_active_ = true;
+ hfp_connect_inflight_ = false;
+ hfp_active_ = false;
+ hfp_audio_link_up_ = false;
slc_connected_ = true;
+ call_setup_active_ = false;
+ slc_wait_deadline_ms_ = 0;
+ next_audio_retry_ms_ = millis() + 500U;
+ if (!pending_dial_number_.isEmpty()) {
+ next_pending_dial_retry_ms_ = millis();
+ }
+ hfp_reconnect_attempts_ = 0;
+ next_hfp_reconnect_ms_ = 0;
last_hfp_event_ = "slc_connected";
notifyBluetooth("hfp_slc_connected");
- if (hfp_requested_ && peer_addr_valid_) {
- esp_hf_client_connect_audio(peer_addr_);
- }
break;
case ESP_HF_CLIENT_CONNECTION_STATE_DISCONNECTING:
+ hfp_connect_inflight_ = false;
slc_connected_ = false;
+ call_setup_active_ = false;
+ slc_wait_deadline_ms_ = 0;
+ next_audio_retry_ms_ = 0;
last_hfp_event_ = "disconnecting";
notifyBluetooth("hfp_disconnecting");
break;
@@ -881,12 +1458,20 @@ void BluetoothManager::handleHfpEvent(int event, const void* raw_param) {
}
case ESP_HF_CLIENT_AUDIO_STATE_EVT: {
if (param != nullptr) {
- peer_mac_ = formatMac(param->audio_stat.remote_bda);
+ writeMacToString(param->audio_stat.remote_bda, peer_mac_);
switch (param->audio_stat.state) {
case ESP_HF_CLIENT_AUDIO_STATE_DISCONNECTED:
- hfp_active_ = connected_;
+ hfp_audio_link_up_ = false;
+ hfp_active_ = false;
+ if (call_state_ == "dialing" || call_state_ == "alerting" || call_state_ == "ringing" ||
+ call_state_ == "active" || call_state_ == "held" || call_state_ == "held_active") {
+ next_audio_retry_ms_ = millis() + 500U;
+ } else {
+ next_audio_retry_ms_ = 0;
+ }
last_hfp_event_ = "audio_disconnected";
notifyBluetooth("hfp_audio_disconnected");
+ onHfpAudioStateChanged(false);
break;
case ESP_HF_CLIENT_AUDIO_STATE_CONNECTING:
last_hfp_event_ = "audio_connecting";
@@ -894,9 +1479,12 @@ void BluetoothManager::handleHfpEvent(int event, const void* raw_param) {
break;
case ESP_HF_CLIENT_AUDIO_STATE_CONNECTED:
case ESP_HF_CLIENT_AUDIO_STATE_CONNECTED_MSBC:
+ hfp_audio_link_up_ = true;
hfp_active_ = true;
+ next_audio_retry_ms_ = 0;
last_hfp_event_ = "audio_connected";
notifyBluetooth("hfp_audio_connected");
+ onHfpAudioStateChanged(true);
break;
default:
break;
@@ -907,6 +1495,10 @@ void BluetoothManager::handleHfpEvent(int event, const void* raw_param) {
case ESP_HF_CLIENT_CIND_CALL_SETUP_EVT:
if (param != nullptr) {
call_state_ = callSetupStateToString(param->call_setup.status);
+ call_setup_active_ = (param->call_setup.status != ESP_HF_CALL_SETUP_STATUS_IDLE);
+ if (param->call_setup.status != ESP_HF_CALL_SETUP_STATUS_IDLE) {
+ next_audio_retry_ms_ = millis() + 80U;
+ }
}
last_hfp_event_ = "call_setup";
notifyBluetooth("hfp_call_setup");
@@ -915,9 +1507,13 @@ void BluetoothManager::handleHfpEvent(int event, const void* raw_param) {
if (param != nullptr) {
if (param->call.status == ESP_HF_CALL_STATUS_CALL_IN_PROGRESS) {
call_state_ = "active";
- } else if (call_state_ == "ending" || call_state_ == "active" || call_state_ == "dialing" ||
- call_state_ == "alerting" || call_state_ == "ringing") {
+ call_setup_active_ = false;
+ next_audio_retry_ms_ = millis() + 80U;
+ } else if (!call_setup_active_ &&
+ (call_state_ == "ending" || call_state_ == "active" || call_state_ == "dialing" ||
+ call_state_ == "alerting" || call_state_ == "ringing")) {
call_state_ = "idle";
+ next_audio_retry_ms_ = 0;
}
}
last_hfp_event_ = "call_status";
@@ -949,16 +1545,100 @@ void BluetoothManager::handleHfpEvent(int event, const void* raw_param) {
last_hfp_event_ = "ring";
call_state_ = "ringing";
notifyBluetooth("hfp_ring");
+ next_audio_retry_ms_ = millis() + 80U;
break;
default:
break;
}
- publishBleStatus();
+ pending_discoverable_policy_ = true;
+ pending_status_publish_ = true;
+}
+
+void BluetoothManager::onHfpAudioStateChanged(bool connected) {
+ if (audio_bridge_ == nullptr) {
+ return;
+ }
+ if (connected) {
+ if (callStateNeedsAudio(call_state_)) {
+ audio_bridge_->stopDialTone();
+ }
+ if (!audio_bridge_->requestCapture(AudioEngine::CAPTURE_CLIENT_BLUETOOTH)) {
+ last_error_ = "hfp_audio_capture_request_failed";
+ notifyBluetooth("hfp_audio_capture_request_failed", last_error_.c_str());
+ }
+ } else {
+ audio_bridge_->releaseCapture(AudioEngine::CAPTURE_CLIENT_BLUETOOTH);
+ }
+}
+
+void BluetoothManager::requestAudioIfNeeded(const char* reason) {
+ if (!connected_ || !slc_connected_ || !peer_addr_valid_ || hfp_audio_link_up_) {
+ return;
+ }
+ const esp_err_t err = esp_hf_client_connect_audio(peer_addr_);
+ if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
+ last_error_ = "hfp_audio_connect_failed:" + errToString(err);
+ notifyBluetooth("hfp_audio_connect_failed", last_error_.c_str());
+ next_audio_retry_ms_ = millis() + 800U;
+ return;
+ }
+ next_audio_retry_ms_ = millis() + 1200U;
+ last_hfp_event_ = reason != nullptr ? String(reason) : String("audio_requested");
}
void BluetoothManager::onBleClientConnected(bool connected) {
ble_client_connected_ = connected;
last_ble_event_ = connected ? "client_connected" : "client_disconnected";
notifyBluetooth(connected ? "ble_client_connected" : "ble_client_disconnected");
- publishBleStatus();
+ pending_discoverable_policy_ = true;
+ pending_status_publish_ = true;
+}
+
+void BluetoothManager::onGapAuthComplete(const uint8_t remote_bda[6], bool success) {
+ if (!success || remote_bda == nullptr || !features_.has_hfp) {
+ return;
+ }
+
+ memcpy(peer_addr_, remote_bda, sizeof(peer_addr_));
+ peer_addr_valid_ = true;
+ writeMacToString(peer_addr_, peer_mac_);
+ hfp_requested_ = true;
+ hfp_reconnect_attempts_ = 0;
+ if (auto_reconnect_enabled_ && !connected_ && !hfp_connect_inflight_) {
+ next_hfp_reconnect_ms_ = millis() + 700U;
+ }
+ last_hfp_event_ = "auth_ok_wait_connect";
+ pending_status_publish_ = true;
+}
+
+void BluetoothManager::onGapAclDisconnected(const uint8_t remote_bda[6], uint16_t reason) {
+ if (remote_bda == nullptr) {
+ return;
+ }
+
+ if (peer_addr_valid_ && memcmp(remote_bda, peer_addr_, sizeof(peer_addr_)) != 0) {
+ return;
+ }
+
+ connected_ = false;
+ hfp_connect_inflight_ = false;
+ hfp_active_ = false;
+ hfp_audio_link_up_ = false;
+ slc_connected_ = false;
+ slc_wait_deadline_ms_ = 0;
+ call_state_ = "idle";
+ next_audio_retry_ms_ = 0;
+ last_hfp_event_ = "acl_disconnected";
+ last_error_ = "acl_disconnect_reason:" + String(reason);
+ notifyBluetooth("hfp_acl_disconnected", last_error_.c_str());
+ onHfpAudioStateChanged(false);
+
+ if ((auto_reconnect_enabled_ || !pending_dial_number_.isEmpty()) && hfp_requested_ && peer_addr_valid_) {
+ hfp_reconnect_attempts_++;
+ const uint8_t exp = (hfp_reconnect_attempts_ < 5) ? hfp_reconnect_attempts_ : 5;
+ next_hfp_reconnect_ms_ = millis() + 1500U * (1U << exp);
+ }
+
+ pending_discoverable_policy_ = true;
+ pending_status_publish_ = true;
}
diff --git a/src/bluetooth/BluetoothManager.h b/src/bluetooth/BluetoothManager.h
index c97c7b2..5cf08a7 100644
--- a/src/bluetooth/BluetoothManager.h
+++ b/src/bluetooth/BluetoothManager.h
@@ -7,10 +7,13 @@
#include "core/PlatformProfile.h"
+class AudioEngine;
+
class BluetoothManager {
public:
BluetoothManager();
bool begin(BoardProfile profile);
+ void setAudioBridge(AudioEngine* audio);
bool connect(const char* mac);
bool disconnect();
bool isConnected() const;
@@ -18,7 +21,11 @@ public:
bool stopHFP();
bool startBLE();
bool stopBLE();
+ void tick();
bool setDiscoverable(bool enabled);
+ bool setAutoReconnectEnabled(bool enabled);
+ bool autoReconnectEnabled() const;
+ void suspendReconnect(uint32_t duration_ms);
bool dial(const String& number);
bool redial();
bool answerCall();
@@ -32,7 +39,11 @@ public:
void publishBleStatus();
String executeBleCommand(const String& cmd);
void handleHfpEvent(int event, const void* param);
+ void onHfpIncomingAudio(const uint8_t* buf, uint32_t len);
+ uint32_t onHfpOutgoingAudio(uint8_t* buf, uint32_t len);
void onBleClientConnected(bool connected);
+ void onGapAuthComplete(const uint8_t remote_bda[6], bool success);
+ void onGapAclDisconnected(const uint8_t remote_bda[6], uint16_t reason);
bool isSecurityEnabled() const;
bool isHfpActive() const;
bool isBleActive() const;
@@ -45,28 +56,49 @@ private:
bool ensureBtStackReady();
bool ensureHfpClientReady();
bool requireCallControlReady(const char* operation);
+ bool requestHfpConnect(const char* reason);
+ bool issueDialRequest(const String& dialed, const char* event_name);
+ bool loadBondedPeerFromStack();
bool parseMac(const String& mac, uint8_t out[6]) const;
String formatMac(const uint8_t* mac) const;
+ void applyDiscoverablePolicy();
+ void onHfpAudioStateChanged(bool connected);
+ void requestAudioIfNeeded(const char* reason);
+ bool hasAnyBluetoothClientConnected() const;
FeatureMatrix features_;
bool stack_ready_;
bool hfp_initialized_;
bool hfp_requested_;
+ bool auto_reconnect_enabled_;
bool ble_stack_initialized_;
bool ble_service_ready_;
bool ble_client_connected_;
bool connected_;
bool hfp_active_;
bool slc_connected_;
+ bool call_setup_active_;
bool ble_active_;
bool discoverable_;
bool security_enabled_;
bool pbap_supported_;
bool pbap_synced_;
+ bool hfp_data_callback_registered_;
+ bool hfp_audio_link_up_;
String peer_mac_;
uint8_t peer_addr_[6];
bool peer_addr_valid_;
+ bool hfp_connect_inflight_;
+ uint8_t hfp_reconnect_attempts_;
+ uint32_t next_hfp_reconnect_ms_;
+ uint32_t reconnect_suspended_until_ms_;
+ uint32_t slc_wait_deadline_ms_;
+ uint32_t next_audio_retry_ms_;
+ uint32_t next_pending_dial_retry_ms_;
+ volatile bool pending_status_publish_;
+ volatile bool pending_discoverable_policy_;
String call_state_;
+ String pending_dial_number_;
String last_dialed_number_;
String pbap_last_error_;
String last_hfp_event_;
@@ -74,6 +106,10 @@ private:
String last_error_;
String ble_last_command_;
String ble_last_response_;
+ uint32_t sco_rx_bytes_;
+ uint32_t sco_tx_bytes_;
+ uint32_t last_sco_activity_ms_;
+ AudioEngine* audio_bridge_;
std::function ble_command_handler_;
};
diff --git a/src/config/A252ConfigStore.cpp b/src/config/A252ConfigStore.cpp
index a270857..f66fe32 100644
--- a/src/config/A252ConfigStore.cpp
+++ b/src/config/A252ConfigStore.cpp
@@ -271,7 +271,7 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
std::vector used;
used.reserve(11);
- const int critical_pins[] = {
+ const int required_pins[] = {
cfg.i2s_bck,
cfg.i2s_ws,
cfg.i2s_dout,
@@ -281,11 +281,10 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
cfg.slic_rm,
cfg.slic_fr,
cfg.slic_shk,
- cfg.slic_line,
cfg.slic_pd,
};
- for (int pin : critical_pins) {
+ for (int pin : required_pins) {
if (pin < 0 || pin > 39) {
error = "invalid_pin_range";
return false;
@@ -297,6 +296,19 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
used.push_back(pin);
}
+ // Optional legacy line-enable pin, retired by default (-1).
+ if (cfg.slic_line != -1) {
+ if (cfg.slic_line < 0 || cfg.slic_line > 39) {
+ 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;
}
diff --git a/src/config/A252ConfigStore.h b/src/config/A252ConfigStore.h
index 7e4619d..7271afd 100644
--- a/src/config/A252ConfigStore.h
+++ b/src/config/A252ConfigStore.h
@@ -18,7 +18,7 @@ struct A252PinsConfig {
int slic_rm = 22;
int slic_fr = 19;
int slic_shk = 36;
- int slic_line = 23;
+ int slic_line = -1;
int slic_pd = 18;
bool hook_active_high = false;
};
@@ -27,7 +27,7 @@ struct A252AudioConfig {
uint32_t sample_rate = 16000;
uint8_t bits_per_sample = 16;
bool enable_capture = true;
- uint8_t volume = 80;
+ uint8_t volume = 90;
bool mute = false;
String route = "rtc";
};
diff --git a/src/main.cpp b/src/main.cpp
index ac4e258..b19ad1b 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,5 +1,6 @@
#include
#include
+#include
#include "audio/AudioEngine.h"
#include "audio/Es8388Driver.h"
@@ -19,6 +20,10 @@
namespace {
constexpr uint32_t kSerialBaud = 115200;
+constexpr int kAudioAmpEnablePin = 21;
+constexpr char kBootLogTag[] = "RTC_BOOT";
+constexpr uint32_t kBootWifiConnectTimeoutMs = 2500;
+constexpr bool kPrintHelpOnBoot = false;
BoardProfile g_profile = BoardProfile::ESP32_A252;
FeatureMatrix g_features = getFeatureMatrix(BoardProfile::ESP32_A252);
@@ -290,14 +295,13 @@ bool applyHardwareConfig() {
.pin_rm = static_cast(g_pins_cfg.slic_rm),
.pin_fr = static_cast(g_pins_cfg.slic_fr),
.pin_shk = static_cast(g_pins_cfg.slic_shk),
- .pin_line_enable = static_cast(g_pins_cfg.slic_line),
+ .pin_line_enable = static_cast(-1),
.pin_pd = static_cast(g_pins_cfg.slic_pd),
.hook_active_high = g_pins_cfg.hook_active_high,
};
const bool slic_ok = g_slic.begin(slic_pins);
g_slic.setPowerDown(false);
- g_slic.setLineEnabled(true);
g_slic.setRing(false);
const bool codec_ok = g_codec.begin(g_pins_cfg.es8388_sda, g_pins_cfg.es8388_scl);
@@ -310,6 +314,12 @@ bool applyHardwareConfig() {
g_audio.resetMetrics();
g_telephony.begin(g_profile, g_slic, g_audio);
+ g_telephony.setDialCallback([](const String& number) {
+ return g_bt.dial(number);
+ });
+ g_telephony.setAnswerCallback([]() {
+ return g_bt.answerCall();
+ });
Serial.printf("[RTC_BL_PHONE] HW init slic=%s codec=%s audio=%s\n",
slic_ok ? "ok" : "fail",
@@ -331,6 +341,7 @@ void appendAudioMetrics(JsonObject root) {
JsonObject audio = root["audio"].to();
audio["full_duplex"] = g_audio.supportsFullDuplex();
+ audio["dial_tone_active"] = g_audio.isDialToneActive();
audio["frames"] = metrics.frames_read;
audio["underrun"] = metrics.underrun_count;
audio["drop"] = metrics.drop_frames;
@@ -410,9 +421,6 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
if (patch["slic"]["shk"].is()) {
next.slic_shk = patch["slic"]["shk"].as();
}
- if (patch["slic"]["line"].is()) {
- next.slic_line = patch["slic"]["line"].as();
- }
if (patch["slic"]["pd"].is()) {
next.slic_pd = patch["slic"]["pd"].as();
}
@@ -449,9 +457,6 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
if (patch["slic_shk"].is()) {
next.slic_shk = patch["slic_shk"].as();
}
- if (patch["slic_line"].is()) {
- next.slic_line = patch["slic_line"].as();
- }
if (patch["slic_pd"].is()) {
next.slic_pd = patch["slic_pd"].as();
}
@@ -459,6 +464,9 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
next.hook_active_high = patch["hook_active_high"].as();
}
+ // LINE enable logic is retired on A252 bench wiring.
+ next.slic_line = -1;
+
if (!A252ConfigStore::validatePins(next, error)) {
return false;
}
@@ -591,6 +599,26 @@ void registerCommands() {
return makeResponse(true, "RESET_METRICS");
});
+ g_dispatcher.registerCommand("TONE_ON", [](const String&) {
+ return makeResponse(g_audio.startDialTone(), "TONE_ON");
+ });
+
+ g_dispatcher.registerCommand("TONE_OFF", [](const String&) {
+ g_audio.stopDialTone();
+ return makeResponse(true, "TONE_OFF");
+ });
+
+ g_dispatcher.registerCommand("AMP_ON", [](const String&) {
+ // Locked polarity: AMP_EN active LOW on GPIO21.
+ digitalWrite(kAudioAmpEnablePin, LOW);
+ return makeResponse(true, "AMP_ON");
+ });
+
+ g_dispatcher.registerCommand("AMP_OFF", [](const String&) {
+ digitalWrite(kAudioAmpEnablePin, HIGH);
+ return makeResponse(true, "AMP_OFF");
+ });
+
g_dispatcher.registerCommand("WIFI_CONNECT", [](const String& args) {
String ssid;
String password_raw;
@@ -623,6 +651,8 @@ void registerCommands() {
});
g_dispatcher.registerCommand("WIFI_SCAN", [](const String&) {
+ // Guard BT/HFP reconnect while WiFi allocates scan resources.
+ g_bt.suspendReconnect(8000U);
JsonDocument doc;
JsonObject root = doc.to();
root["ok"] = true;
@@ -815,6 +845,14 @@ void registerCommands() {
return makeResponse(g_bt.setDiscoverable(false), "BT_DISCOVERABLE_OFF");
});
+ g_dispatcher.registerCommand("BT_AUTO_RECONNECT_ON", [](const String&) {
+ return makeResponse(g_bt.setAutoReconnectEnabled(true), "BT_AUTO_RECONNECT_ON");
+ });
+
+ g_dispatcher.registerCommand("BT_AUTO_RECONNECT_OFF", [](const String&) {
+ return makeResponse(g_bt.setAutoReconnectEnabled(false), "BT_AUTO_RECONNECT_OFF");
+ });
+
g_dispatcher.registerCommand("BT_STATUS", [](const String&) {
JsonDocument doc;
g_bt.statusToJson(doc.to());
@@ -1018,7 +1056,12 @@ void pollSerial() {
void setup() {
Serial.begin(kSerialBaud);
- delay(200);
+ delay(80);
+
+ // Warm up ESP-IDF log/stdout locks from the main task context.
+ ESP_LOGI(kBootLogTag, "log lock warmup");
+ printf("[RTC_BL_PHONE] stdio lock warmup\n");
+ fflush(stdout);
g_profile = BoardProfile::ESP32_A252;
g_features = getFeatureMatrix(g_profile);
@@ -1028,9 +1071,13 @@ void setup() {
g_pins_cfg.slic_rm = 18;
g_pins_cfg.slic_fr = 5;
g_pins_cfg.slic_shk = 23;
- g_pins_cfg.slic_line = 21;
+ // LINE enable is not used on this wiring.
+ g_pins_cfg.slic_line = -1;
g_pins_cfg.slic_pd = 19;
g_pins_cfg.hook_active_high = true;
+
+ pinMode(kAudioAmpEnablePin, OUTPUT);
+ digitalWrite(kAudioAmpEnablePin, LOW);
A252ConfigStore::loadAudio(g_audio_cfg);
A252ConfigStore::loadMqtt(g_mqtt_cfg);
A252ConfigStore::loadEspNowPeers(g_peer_store);
@@ -1041,23 +1088,17 @@ void setup() {
g_bt.setBleCommandHandler([](const String& cmd) {
return responseToText(executeCommandLine(cmd));
});
-
- g_bt.begin(g_profile);
+ g_bt.setAudioBridge(&g_audio);
g_mqtt.begin(g_mqtt_cfg);
g_mqtt.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
processInboundBridgeCommand(source, payload);
});
- g_espnow.begin(g_peer_store);
- g_espnow.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
- processInboundBridgeCommand(source, payload);
- });
-
String ssid;
String password;
if (WifiCredentialsStorage::load(ssid, password)) {
- g_wifi.connect(ssid, password, 10000, false);
+ g_wifi.connect(ssid, password, kBootWifiConnectTimeoutMs, false);
if (g_wifi.isConnected() && g_mqtt_cfg.enabled) {
g_mqtt.connectNow();
}
@@ -1065,6 +1106,13 @@ void setup() {
g_wifi.ensureFallbackAp();
}
+ g_espnow.begin(g_peer_store);
+ g_espnow.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
+ processInboundBridgeCommand(source, payload);
+ });
+
+ g_bt.begin(g_profile);
+
g_web.setRateLimitMs(250);
g_web.setAuthEnabled(false);
g_web.setStatusCallback(fillStatusSnapshot);
@@ -1075,17 +1123,21 @@ void setup() {
boardProfileToString(g_profile),
g_features.has_bt_classic ? "true" : "false",
g_features.has_full_duplex_i2s ? "true" : "false");
- printHelp();
+ if (kPrintHelpOnBoot) {
+ printHelp();
+ }
publishStatusIfConnected();
}
void loop() {
+ g_telephony.setIncomingRing(g_bt.callState() == "ringing");
g_telephony.tick();
g_wifi.loop();
+ g_bt.tick();
g_mqtt.tick();
g_espnow.tick();
g_web.handle();
pollSerial();
- delay(10);
+ delay(1);
}
#endif // UNIT_TEST
diff --git a/src/props/EspNowBridge.cpp b/src/props/EspNowBridge.cpp
index 016cfbd..998dbeb 100644
--- a/src/props/EspNowBridge.cpp
+++ b/src/props/EspNowBridge.cpp
@@ -2,11 +2,23 @@
#include
#include
+#include
#include
EspNowBridge* EspNowBridge::instance_ = nullptr;
+namespace {
+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(err));
+ }
+}
+}
+
EspNowBridge::EspNowBridge() {
instance_ = this;
}
@@ -18,11 +30,20 @@ bool EspNowBridge::begin(const EspNowPeerStore& initial_peers) {
store_ = initial_peers;
- WiFi.mode(WIFI_STA);
+ 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);
diff --git a/src/telephony/TelephonyService.cpp b/src/telephony/TelephonyService.cpp
index 88e3b25..2b35345 100644
--- a/src/telephony/TelephonyService.cpp
+++ b/src/telephony/TelephonyService.cpp
@@ -1,5 +1,20 @@
#include "telephony/TelephonyService.h"
+namespace {
+constexpr uint16_t kDtmfFrameSamples = 160U;
+constexpr uint32_t kHookHangupMs = 800U;
+constexpr uint32_t kHookStabilizeMs = 40U;
+constexpr uint32_t kPulseInterDigitGapMs = 420U;
+constexpr uint32_t kPulseEdgeDebounceMs = 18U;
+constexpr uint32_t kPulseDtmfGuardMs = 900U;
+constexpr size_t kDialDigitsTarget = 10U;
+constexpr uint32_t kDtmfCaptureStartDelayMs = 0U;
+constexpr uint32_t kDtmfReadPeriodMs = 12U;
+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:
@@ -20,10 +35,30 @@ TelephonyService::TelephonyService()
features_(getFeatureMatrix(BoardProfile::ESP32_A252)),
slic_(nullptr),
audio_(nullptr),
+ dial_callback_(nullptr),
+ answer_callback_(nullptr),
+ dtmf_(8000U, kDtmfFrameSamples),
state_(TelephonyState::IDLE),
incoming_ring_(false),
ring_phase_on_(false),
ring_cycle_start_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),
+ 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_source_(kDialSourceNone),
+ dial_buffer_(""),
+ last_digit_ms_(0),
+ last_dial_error_(""),
message_path_("/welcome.wav") {}
bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEngine& audio) {
@@ -35,25 +70,200 @@ bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEn
incoming_ring_ = false;
ring_phase_on_ = false;
ring_cycle_start_ms_ = millis();
+ capture_active_ = false;
+ pulse_hook_initialized_ = false;
+ pulse_last_hook_off_ = false;
+ pulse_collecting_ = false;
+ pulse_count_ = 0;
+ last_hook_edge_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_source_ = kDialSourceNone;
+ dial_buffer_ = "";
+ last_digit_ms_ = 0;
+ last_dial_error_ = "";
+
+ dtmf_.setDigitCallback([this](char digit) {
+ onDialDigit(digit, false);
+ });
slic_->setRing(false);
slic_->setLineEnabled(true);
return true;
}
+void TelephonyService::setDialCallback(DialCallback cb) {
+ dial_callback_ = cb;
+}
+
+void TelephonyService::setAnswerCallback(AnswerCallback cb) {
+ answer_callback_ = cb;
+}
+
void TelephonyService::triggerIncomingRing() {
incoming_ring_ = true;
}
+void TelephonyService::setIncomingRing(bool active) {
+ incoming_ring_ = active;
+}
+
+void TelephonyService::onDialDigit(char digit, bool from_pulse) {
+ if (digit < '0' || digit > '9') {
+ 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() >= kDialDigitsTarget) {
+ dial_buffer_ = "";
+ }
+
+ 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());
+
+ if (dial_buffer_.length() == kDialDigitsTarget) {
+ commitDialBuffer("len10");
+ }
+}
+
+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;
+ 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) {
+ 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_->isDialToneActive()) {
+ audio_->stopDialTone();
+ }
+ }
+ } else if (!pulse_last_hook_off_ && hook_off) {
+ if (pulse_collecting_ && pulse_count_ < 20U) {
+ ++pulse_count_;
+ last_pulse_ms_ = now;
+ }
+ }
+
+ pulse_last_hook_off_ = hook_off;
+ last_hook_edge_ms_ = now;
+}
+
+void TelephonyService::commitDialBuffer(const char* reason) {
+ if (dial_buffer_.length() != kDialDigitsTarget) {
+ return;
+ }
+
+ if (audio_ != nullptr && audio_->isDialToneActive()) {
+ audio_->stopDialTone();
+ }
+
+ const String number = dial_buffer_;
+ const bool ok = dial_callback_ ? dial_callback_(number) : false;
+ last_dial_error_ = ok ? "" : "bt_dial_failed";
+ 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;
+}
+
+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;
+ last_pulse_ms_ = 0;
+ last_pulse_edge_ms_ = 0;
+ dial_source_ = kDialSourceNone;
+ dialing_started_ = false;
+ suppress_dial_tone_ = false;
+ dial_buffer_ = "";
+ last_digit_ms_ = 0;
+}
+
void TelephonyService::tick() {
if (slic_ == nullptr || audio_ == nullptr) {
return;
}
slic_->tick();
- audio_->tick();
const bool hook_off = slic_->isHookOff();
+ const uint32_t now = millis();
+ const TelephonyState prev_state = state_;
+
switch (state_) {
case TelephonyState::IDLE:
if (incoming_ring_ && !hook_off) {
@@ -71,8 +281,12 @@ void TelephonyService::tick() {
incoming_ring_ = false;
ring_phase_on_ = false;
slic_->setRing(false);
- audio_->playFile(message_path_);
- state_ = TelephonyState::PLAYING_MESSAGE;
+ const bool answered = answer_callback_ ? answer_callback_() : false;
+ // While transitioning from incoming ring to call answer, keep dial tone muted
+ // even if BT answer fails transiently.
+ suppress_dial_tone_ = true;
+ last_dial_error_ = answered ? "" : "bt_answer_failed";
+ state_ = TelephonyState::OFF_HOOK;
break;
}
@@ -99,12 +313,104 @@ void TelephonyService::tick() {
break;
case TelephonyState::OFF_HOOK:
+ if ((now - off_hook_enter_ms_) >= kHookStabilizeMs) {
+ updatePulseDecode(hook_off, now);
+ }
+
if (!hook_off) {
- incoming_ring_ = false;
- state_ = TelephonyState::IDLE;
+ // Stop audible dial tone immediately on hangup, even if we keep
+ // a short debounce before transitioning back to IDLE.
+ if (audio_ != nullptr && audio_->isDialToneActive()) {
+ audio_->stopDialTone();
+ }
+ if (audio_ != nullptr && capture_active_) {
+ audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
+ capture_active_ = false;
+ }
+ // Reset dialing session immediately on hangup.
+ if (!dial_buffer_.isEmpty() || dial_source_ != kDialSourceNone || pulse_collecting_ || pulse_count_ > 0U) {
+ dial_buffer_ = "";
+ last_digit_ms_ = 0;
+ dial_source_ = kDialSourceNone;
+ dialing_started_ = false;
+ pulse_collecting_ = false;
+ pulse_count_ = 0;
+ last_pulse_ms_ = 0;
+ }
+ if ((now - last_hook_edge_ms_) >= kHookHangupMs) {
+ incoming_ring_ = false;
+ state_ = TelephonyState::IDLE;
+ }
+ 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('0' + count)
+ : '\0');
+ if (digit != '\0') {
+ onDialDigit(digit, true);
+ }
+ }
+
+ 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 (!suppress_dial_tone_ && !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;
}
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;
+ last_pulse_ms_ = 0;
+ last_pulse_edge_ms_ = 0;
+ dial_source_ = kDialSourceNone;
+ dialing_started_ = false;
+ dial_buffer_ = "";
+ last_digit_ms_ = 0;
+ dtmf_capture_start_ms_ = now + kDtmfCaptureStartDelayMs;
+ next_dtmf_read_ms_ = now;
+ if (audio_ != nullptr && !suppress_dial_tone_) {
+ audio_->startDialTone();
+ }
+ }
+
+ if (prev_state == TelephonyState::OFF_HOOK && state_ != TelephonyState::OFF_HOOK) {
+ clearDialSession();
+ }
+ }
}
TelephonyState TelephonyService::state() const {
diff --git a/src/telephony/TelephonyService.h b/src/telephony/TelephonyService.h
index e9aaa02..0d21aaf 100644
--- a/src/telephony/TelephonyService.h
+++ b/src/telephony/TelephonyService.h
@@ -1,9 +1,12 @@
#ifndef TELEPHONY_SERVICE_H
#define TELEPHONY_SERVICE_H
+#include
+
#include "audio/AudioEngine.h"
#include "core/PlatformProfile.h"
#include "slic/SlicController.h"
+#include "telephony/DtmfDecoder.h"
enum class TelephonyState : uint8_t {
IDLE = 0,
@@ -16,21 +19,52 @@ const char* telephonyStateToString(TelephonyState state);
class TelephonyService {
public:
+ using DialCallback = std::function;
+ using AnswerCallback = std::function;
+
TelephonyService();
bool begin(BoardProfile profile, SlicController& slic, AudioEngine& audio);
+ void setDialCallback(DialCallback cb);
+ void setAnswerCallback(AnswerCallback cb);
void triggerIncomingRing();
+ void setIncomingRing(bool active);
void tick();
TelephonyState state() const;
private:
+ void onDialDigit(char digit, bool from_pulse);
+ void updatePulseDecode(bool hook_off, uint32_t now);
+ void commitDialBuffer(const char* reason);
+ void clearDialSession();
+
BoardProfile profile_;
FeatureMatrix features_;
SlicController* slic_;
AudioEngine* audio_;
+ DialCallback dial_callback_;
+ AnswerCallback answer_callback_;
+ DtmfDecoder dtmf_;
TelephonyState state_;
bool incoming_ring_;
bool ring_phase_on_;
uint32_t ring_cycle_start_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 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_;
+ bool dialing_started_;
+ uint8_t dial_source_;
+ String dial_buffer_;
+ uint32_t last_digit_ms_;
+ String last_dial_error_;
const char* message_path_;
};
diff --git a/src/web/WebServerManager.cpp b/src/web/WebServerManager.cpp
index 6c8ff71..a4347a9 100644
--- a/src/web/WebServerManager.cpp
+++ b/src/web/WebServerManager.cpp
@@ -4,6 +4,7 @@
namespace {
constexpr bool kForceAuthDisabled = true;
+constexpr bool kEnableRealtimeEvents = true;
String quoteArg(const String& value) {
String escaped = value;
@@ -20,6 +21,7 @@ WebServerManager::WebServerManager(uint16_t port)
last_status_push_ms_(0),
status_cache_json_(""),
status_cache_ready_(false),
+ status_cache_mux_(portMUX_INITIALIZER_UNLOCKED),
auth_enabled_(false),
auth_user_("admin"),
auth_pass_("admin") {}
@@ -41,6 +43,7 @@ void WebServerManager::handle() {
if (now - last_status_push_ms_ >= 1000U) {
last_status_push_ms_ = now;
refreshStatusCache();
+ publishRealtimeStatus();
}
}
@@ -78,22 +81,28 @@ void WebServerManager::setCommandExecutor(std::functionsend(payload.c_str(), "hello", millis());
- if (status_cache_ready_) {
- client->send(status_cache_json_.c_str(), "status", millis());
- }
- });
- server_.addHandler(&events_);
+ 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) {
- if (status_cache_ready_) {
- request->send(200, "application/json", status_cache_json_);
+ bool ready = false;
+ const String cached = snapshotStatusCache(&ready);
+ if (ready) {
+ request->send(200, "application/json", cached);
return;
}
@@ -289,6 +298,17 @@ void WebServerManager::registerRoutes() {
});
server_.on("/api/bluetooth/hfp/disconnect", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_HFP_DISCONNECT"); });
+ server_.on("/api/bluetooth/hfp/auto", HTTP_GET,
+ [this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_STATUS"); });
+ server_.on("/api/bluetooth/hfp/auto", HTTP_POST, [this](AsyncWebServerRequest* request) {
+ JsonDocument doc;
+ if (!extractJsonBody(request, doc)) {
+ request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
+ return;
+ }
+ const bool enabled = doc["enabled"] | true;
+ handleDispatch(request, enabled ? "BT_AUTO_RECONNECT_ON" : "BT_AUTO_RECONNECT_OFF");
+ });
server_.on("/api/bluetooth/discoverable/on", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_DISCOVERABLE_ON"); });
server_.on("/api/bluetooth/discoverable/off", HTTP_POST,
@@ -376,27 +396,49 @@ bool WebServerManager::isEffectCommand(const String& command_line) {
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());
- status_cache_json_ = toJsonString(doc);
+ 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() {
- if (!status_cache_ready_) {
+ bool ready = false;
+ const String cached = snapshotStatusCache(&ready);
+ if (!ready) {
return;
}
- publishRealtimeEvent("status", status_cache_json_);
+ publishRealtimeEvent("status", cached);
}
void WebServerManager::publishDispatchEvent(const String& command_line, const DispatchResponse& res) {
diff --git a/src/web/WebServerManager.h b/src/web/WebServerManager.h
index 4f09f7a..52a4151 100644
--- a/src/web/WebServerManager.h
+++ b/src/web/WebServerManager.h
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
#include
@@ -30,6 +31,7 @@ private:
uint32_t last_status_push_ms_;
String status_cache_json_;
bool status_cache_ready_;
+ portMUX_TYPE status_cache_mux_;
bool auth_enabled_;
String auth_user_;
String auth_pass_;
@@ -42,6 +44,7 @@ private:
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();
diff --git a/src/wifi/WifiManager.cpp b/src/wifi/WifiManager.cpp
index c95e586..0246168 100644
--- a/src/wifi/WifiManager.cpp
+++ b/src/wifi/WifiManager.cpp
@@ -4,6 +4,7 @@
#include "wifi/WifiCredentialsStorage.h"
#include
+#include
namespace {
constexpr char kFallbackApPrefix[] = "RTC_BL_A252";
@@ -60,6 +61,15 @@ String buildFallbackApSsid() {
return String(name);
}
+void enforceBtCoexModemSleep() {
+ 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(err));
+ }
+}
+
} // namespace
WifiManager::WifiManager()
@@ -70,7 +80,12 @@ WifiManager::WifiManager()
ap_ssid_(buildFallbackApSsid()),
ap_password_(kFallbackApPassword),
next_auto_reconnect_ms_(0),
- reconnect_backoff_ms_(3000) {}
+ reconnect_backoff_ms_(3000),
+ next_coex_reassert_ms_(0) {}
+
+void WifiManager::enforceCoexPolicy() const {
+ enforceBtCoexModemSleep();
+}
bool WifiManager::begin(const char* ssid, const char* password, uint32_t timeout_ms) {
return connect(ssid ? String(ssid) : "", password ? String(password) : "", timeout_ms, true);
@@ -89,10 +104,15 @@ bool WifiManager::connect(const String& ssid, const String& password, uint32_t t
stopFallbackAp();
WiFi.mode(WIFI_STA);
- WiFi.setAutoReconnect(true);
+ // Required by ESP32 WiFi+BT coexistence.
+ // Keep reconnect policy manual to avoid repeated WiFi timer churn under BT load.
+ WiFi.setAutoReconnect(false);
+ enforceCoexPolicy(); // Re-assert after mode switch to avoid BT coex abort.
WiFi.disconnect(false, true);
+ enforceCoexPolicy();
delay(100);
WiFi.begin(ssid_.c_str(), password_.c_str());
+ enforceCoexPolicy();
connected_ = waitForConnection(timeout_ms);
if (connected_) {
@@ -111,8 +131,10 @@ bool WifiManager::connect(const String& ssid, const String& password, uint32_t t
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_ = millis() + reconnect_backoff_ms_;
+ next_auto_reconnect_ms_ = 0;
startFallbackAp();
}
return connected_;
@@ -146,6 +168,15 @@ void WifiManager::disconnect(bool erase_credentials) {
}
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;
@@ -157,9 +188,7 @@ void WifiManager::loop() {
startFallbackAp();
}
- if (!ssid_.isEmpty() && next_auto_reconnect_ms_ != 0 && millis() >= next_auto_reconnect_ms_) {
- reconnect(5000);
- }
+ // Manual reconnect only (WIFI_RECONNECT command).
}
void WifiManager::ensureFallbackAp() {
@@ -256,12 +285,16 @@ bool WifiManager::startFallbackAp() {
}
WiFi.mode(WIFI_AP_STA);
+ // Required by ESP32 WiFi+BT coexistence in AP+STA mode.
+ 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) {
diff --git a/src/wifi/WifiManager.h b/src/wifi/WifiManager.h
index 71cbf17..0e2fffa 100644
--- a/src/wifi/WifiManager.h
+++ b/src/wifi/WifiManager.h
@@ -38,6 +38,8 @@ public:
void scanToJson(JsonArray arr, int max_networks = 20) const;
private:
+ void enforceCoexPolicy() const;
+
bool connected_;
String ssid_;
String password_;
@@ -46,6 +48,7 @@ private:
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();