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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
+78
-10
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <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 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
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
+72
-20
@@ -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");
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<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 {
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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::function<DispatchResponse(const S
|
||||
}
|
||||
|
||||
void WebServerManager::registerRoutes() {
|
||||
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());
|
||||
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<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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user