feat: Enhance BluetoothManager with audio bridge and auto-reconnect features

- Added AudioEngine integration to BluetoothManager for audio handling.
- Implemented auto-reconnect functionality with enabling/disabling options.
- Introduced methods for managing audio state and HFP events.
- Updated BluetoothManager's internal state management for better audio handling.

refactor: Update A252ConfigStore to improve pin validation logic

- Renamed critical_pins to required_pins for clarity.
- Added optional legacy line-enable pin validation.
- Improved error handling for pin conflicts and invalid ranges.

fix: Adjust default volume and slic_line configuration in A252ConfigStore

- Set default slic_line to -1 to indicate retirement.
- Increased default audio volume from 80 to 90.

feat: Implement audio amplifier control in main application

- Added commands to enable/disable audio amplifier via GPIO.
- Integrated audio amplifier control into the setup process.

fix: Improve ESP-NOW and WiFi coexistence policies

- Enforced modem sleep for better coexistence between WiFi and Bluetooth.
- Added checks to ensure proper WiFi mode before enforcing coexistence.

feat: Enhance TelephonyService with DTMF and pulse dialing support

- Added DTMF capture and processing capabilities.
- Implemented rotary pulse dialing logic with state management.
- Introduced callbacks for dialing and answering actions.

feat: Add real-time event publishing to WebServerManager

- Enabled real-time status updates via SSE.
- Improved status caching and thread safety with critical sections.

fix: General code cleanup and optimizations across modules

- Refactored various methods for better readability and maintainability.
- Improved error handling and logging for better debugging.
This commit is contained in:
Clément SAILLANT
2026-02-23 11:54:09 +01:00
parent f20b12e77a
commit 7da6ea37e7
26 changed files with 2236 additions and 170 deletions
+9
View File
@@ -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 dinterface (direct combiné/clavier, SLIC/FXS, ATA externe), dont une variante AG1171S (Silvertel).
+2
View File
@@ -130,6 +130,8 @@
<div class="inline-actions">
<button id="btDiscoverableOnBtn">Discoverable ON</button>
<button id="btDiscoverableOffBtn">Discoverable OFF</button>
<button id="btAutoReconnectOnBtn">Auto Reconnect ON</button>
<button id="btAutoReconnectOffBtn">Auto Reconnect OFF</button>
<button id="btPbapSyncBtn">PBAP Sync</button>
<button id="btBleStartBtn">BLE Start</button>
<button id="btBleStopBtn">BLE Stop</button>
+47 -1
View File
@@ -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");
});
+49 -30
View File
@@ -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`
+86 -3
View File
@@ -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 lartefact.
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é dune 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`
+78 -10
View File
@@ -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 derreur 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 dauto-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).
+4 -1
View File
@@ -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",
+49
View File
@@ -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`.
+15 -11
View File
@@ -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 dapplication du SLIC K50835F (niveau logique, polarité, etc.).
- Les signaux SHK/RM/FR/PD sont à adapter selon le schéma dapplication du SLIC K50835F.
- La broche `LINE` n'est pas utilisée dans la config bench validée.
- Laudio analogique transite via un codec I2S (ex : PCM5102, ES8388) entre lESP32 et le SLIC K50835F.
- Prévoir adaptation dimpédance et filtrage sur les lignes audio.
- Les broches sont données à titre dexemple, à ajuster selon le routage réel.
+21 -1
View File
@@ -21,6 +21,8 @@ Livrer une stack Bluetooth téléphonie réellement opérationnelle sur ESP32 Au
## 2-bis. État dimplé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 <number>` / alias `DIAL <number>`
- `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 <number>`).
- La numérotation DOIT échouer proprement si HFP non connec.
- La numérotation DOIT accepter la demande même si SLC n'est pas encore mon:
- 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
+4
View File
@@ -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
+453 -4
View File
@@ -1,7 +1,39 @@
#include "audio/AudioEngine.h"
#include <SPIFFS.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <limits>
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<float>(std::numeric_limits<int16_t>::max())) {
return std::numeric_limits<int16_t>::max();
}
if (value < static_cast<float>(std::numeric_limits<int16_t>::min())) {
return std::numeric_limits<int16_t>::min();
}
return static_cast<int16_t>(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<AudioEngine*>(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<uint8_t>(client);
if (bit == 0U) {
return false;
}
if (!supportsFullDuplex() && playing_) {
return false;
}
capture_active_ = true;
portENTER_CRITICAL(&capture_lock_);
capture_clients_mask_ = static_cast<uint8_t>(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<uint8_t>(client);
if (bit == 0U) {
return;
}
portENTER_CRITICAL(&capture_lock_);
capture_clients_mask_ = static_cast<uint8_t>(capture_clients_mask_ & static_cast<uint8_t>(~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<uint32_t>(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<uint32_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 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<uint32_t>(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<uint32_t>(read_samples);
if (read_samples < samples) {
metrics_.drop_frames += static_cast<uint32_t>(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<float>(_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<size_t>(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<float>(_config.sample_rate) * (kDialToneAttackMs / 1000.0f)));
const float release_step =
1.0f / std::max(1.0f, (static_cast<float>(_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<int16_t>(
static_cast<uint16_t>(pcm_raw[l_byte]) |
static_cast<uint16_t>(static_cast<uint16_t>(pcm_raw[l_byte + 1]) << 8));
sample_r = static_cast<int16_t>(
static_cast<uint16_t>(pcm_raw[r_byte]) |
static_cast<uint16_t>(static_cast<uint16_t>(pcm_raw[r_byte + 1]) << 8));
} else {
const int16_t sample = static_cast<int16_t>(std::sin(dial_tone_phase_) * static_cast<float>(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<float>(sample_l) * gain);
frame[(i * kStereoChannels) + 1] = clampInt16(static_cast<float>(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<uint32_t>((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<uint16_t>(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<uint16_t>(channels * bytes_per_sample);
auto write_u16 = [&](uint16_t v) {
uint8_t b[2] = {static_cast<uint8_t>(v & 0xFFU), static_cast<uint8_t>((v >> 8) & 0xFFU)};
return file.write(b, sizeof(b)) == sizeof(b);
};
auto write_u32 = [&](uint32_t v) {
uint8_t b[4] = {static_cast<uint8_t>(v & 0xFFU), static_cast<uint8_t>((v >> 8) & 0xFFU),
static_cast<uint8_t>((v >> 16) & 0xFFU), static_cast<uint8_t>((v >> 24) & 0xFFU)};
return file.write(b, sizeof(b)) == sizeof(b);
};
bool ok = true;
ok &= file.write(reinterpret_cast<const uint8_t*>("RIFF"), 4) == 4;
ok &= write_u32(riff_size);
ok &= file.write(reinterpret_cast<const uint8_t*>("WAVE"), 4) == 4;
ok &= file.write(reinterpret_cast<const uint8_t*>("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<const uint8_t*>("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<float>(_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<uint32_t>(static_cast<uint32_t>(kDialToneChunkFrames), remaining);
for (uint32_t i = 0; i < this_chunk; ++i) {
const int16_t s = static_cast<int16_t>(std::sin(phase) * static_cast<float>(kDialToneAmplitude));
pcm[(i * kStereoChannels)] = s;
pcm[(i * kStereoChannels) + 1] = s;
phase += phase_step;
if (phase >= kTwoPi) {
phase -= kTwoPi;
}
}
const size_t bytes = static_cast<size_t>(this_chunk * kStereoChannels * sizeof(int16_t));
if (file.write(reinterpret_cast<const uint8_t*>(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();
}
}
+45
View File
@@ -2,7 +2,11 @@
#define AUDIO_ENGINE_H
#include <Arduino.h>
#include <FS.h>
#include <driver/i2s.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/semphr.h>
#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
+60 -17
View File
@@ -3,28 +3,65 @@
#include <Wire.h>
#include <algorithm>
#include <initializer_list>
namespace {
uint8_t clampVolumeToReg(uint8_t percent) {
const uint8_t clamped = std::min<uint8_t>(100, percent);
return static_cast<uint8_t>((clamped * 0x21U) / 100U);
// ES8388 DAC digital volume registers (0x1A/0x1B):
// 0x00 = 0 dB, 0xC0 = -96 dB.
return static_cast<uint8_t>(((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<std::pair<uint8_t, uint8_t>> seq) {
bool ok = true;
for (const auto& it : seq) {
ok &= writeReg(it.first, it.second);
}
return ok;
};
// ES8388 setup aligned with 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 {
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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<String(const String&)> ble_command_handler_;
};
+15 -3
View File
@@ -271,7 +271,7 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
std::vector<int> 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;
}
+2 -2
View File
@@ -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";
};
+71 -19
View File
@@ -1,5 +1,6 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#include <esp_log.h>
#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<uint8_t>(g_pins_cfg.slic_rm),
.pin_fr = static_cast<uint8_t>(g_pins_cfg.slic_fr),
.pin_shk = static_cast<uint8_t>(g_pins_cfg.slic_shk),
.pin_line_enable = static_cast<int8_t>(g_pins_cfg.slic_line),
.pin_line_enable = static_cast<int8_t>(-1),
.pin_pd = static_cast<int8_t>(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<JsonObject>();
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<int>()) {
next.slic_shk = patch["slic"]["shk"].as<int>();
}
if (patch["slic"]["line"].is<int>()) {
next.slic_line = patch["slic"]["line"].as<int>();
}
if (patch["slic"]["pd"].is<int>()) {
next.slic_pd = patch["slic"]["pd"].as<int>();
}
@@ -449,9 +457,6 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
if (patch["slic_shk"].is<int>()) {
next.slic_shk = patch["slic_shk"].as<int>();
}
if (patch["slic_line"].is<int>()) {
next.slic_line = patch["slic_line"].as<int>();
}
if (patch["slic_pd"].is<int>()) {
next.slic_pd = patch["slic_pd"].as<int>();
}
@@ -459,6 +464,9 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
next.hook_active_high = patch["hook_active_high"].as<bool>();
}
// 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<JsonObject>();
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<JsonObject>());
@@ -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");
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
+21
View File
@@ -2,11 +2,23 @@
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <algorithm>
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<unsigned>(err));
}
}
}
EspNowBridge::EspNowBridge() {
instance_ = this;
}
@@ -18,11 +30,20 @@ bool EspNowBridge::begin(const EspNowPeerStore& initial_peers) {
store_ = initial_peers;
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);
+309 -3
View File
@@ -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) {
// 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<char>('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 {
+34
View File
@@ -1,9 +1,12 @@
#ifndef TELEPHONY_SERVICE_H
#define TELEPHONY_SERVICE_H
#include <functional>
#include "audio/AudioEngine.h"
#include "core/PlatformProfile.h"
#include "slic/SlicController.h"
#include "telephony/DtmfDecoder.h"
enum class TelephonyState : uint8_t {
IDLE = 0,
@@ -16,21 +19,52 @@ const char* telephonyStateToString(TelephonyState state);
class TelephonyService {
public:
using DialCallback = std::function<bool(const String&)>;
using AnswerCallback = std::function<bool()>;
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_;
};
+49 -7
View File
@@ -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,6 +81,7 @@ void WebServerManager::setCommandExecutor(std::function<DispatchResponse(const S
}
void WebServerManager::registerRoutes() {
if (kEnableRealtimeEvents) {
events_.onConnect([this](AsyncEventSourceClient* client) {
JsonDocument hello;
hello["transport"] = "sse";
@@ -85,15 +89,20 @@ void WebServerManager::registerRoutes() {
hello["ts"] = millis();
const String payload = toJsonString(hello);
client->send(payload.c_str(), "hello", millis());
if (status_cache_ready_) {
client->send(status_cache_json_.c_str(), "status", 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<JsonObject>());
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) {
+3
View File
@@ -4,6 +4,7 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#include <ESPAsyncWebServer.h>
#include <freertos/FreeRTOS.h>
#include <functional>
@@ -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();
+39 -6
View File
@@ -4,6 +4,7 @@
#include "wifi/WifiCredentialsStorage.h"
#include <Arduino.h>
#include <esp_wifi.h>
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<unsigned>(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) {
+3
View File
@@ -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();