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();